@runeditrun/rer 0.2.1 → 0.2.4

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 (121) hide show
  1. package/README.md +21 -4
  2. package/cli/README.md +21 -0
  3. package/cli/auth-copy.mjs +25 -18
  4. package/cli/bounded.mjs +13 -4
  5. package/cli/container.mjs +11 -10
  6. package/cli/contract.mjs +2 -2
  7. package/cli/demo-host.mjs +47 -0
  8. package/cli/demo.mjs +47 -0
  9. package/cli/dependencies.mjs +26 -4
  10. package/cli/deploy-command.mjs +94 -0
  11. package/cli/deploy-inputs.mjs +55 -0
  12. package/cli/deploy-observation.mjs +28 -0
  13. package/cli/deploy-owner.mjs +76 -0
  14. package/cli/deploy-readiness.mjs +16 -0
  15. package/cli/deploy.mjs +99 -17
  16. package/cli/detached-server.mjs +1 -1
  17. package/cli/door.mjs +1 -1
  18. package/cli/export.mjs +1 -1
  19. package/cli/hosted-output.mjs +2 -1
  20. package/cli/inspect.mjs +24 -32
  21. package/cli/lifecycle.mjs +31 -8
  22. package/cli/local.mjs +7 -0
  23. package/cli/main.mjs +1 -1
  24. package/cli/managed-server.mjs +27 -18
  25. package/cli/parse.mjs +9 -2
  26. package/cli/pnpm-probe.mjs +17 -0
  27. package/cli/pnpm-runtime.mjs +22 -22
  28. package/cli/portability-policy.mjs +55 -0
  29. package/cli/portability-worker.mjs +8 -4
  30. package/cli/portability.mjs +24 -5
  31. package/cli/project-pnpm.mjs +67 -0
  32. package/cli/release.mjs +35 -0
  33. package/cli/scheduled.mjs +2 -2
  34. package/cli/server-registry.mjs +2 -2
  35. package/cli/setup-input.mjs +1 -1
  36. package/cli/site-check.mjs +15 -6
  37. package/cli/site-data.mjs +6 -5
  38. package/cli/site-references.mjs +153 -3
  39. package/global/CHANGELOG.md +60 -0
  40. package/global/README.md +21 -4
  41. package/global/cli/install.mjs +26 -9
  42. package/global/cli/path-link.mjs +30 -0
  43. package/global/cli/status.mjs +1 -1
  44. package/global/cli/uninstall.mjs +5 -1
  45. package/global/launch/capacity.mjs +1 -1
  46. package/global/launch/facts.mjs +12 -6
  47. package/global/launch/install-pinned.mjs +30 -2
  48. package/global/launch/prepare.mjs +20 -8
  49. package/global/launch/state.mjs +54 -1
  50. package/global/main.mjs +43 -20
  51. package/global/registry.mjs +2 -2
  52. package/global/update/command.mjs +2 -1
  53. package/modules/container/build.mjs +4 -2
  54. package/modules/container/deploy-input.mjs +5 -9
  55. package/modules/container/host.mjs +1 -1
  56. package/modules/container/ingress.mjs +4 -1
  57. package/modules/container/migrations.mjs +2 -1
  58. package/modules/container/worker-engine.mjs +3 -1
  59. package/modules/demo-host/README.md +30 -0
  60. package/modules/demo-host/capacity.mjs +19 -0
  61. package/modules/demo-host/claim.mjs +21 -0
  62. package/modules/demo-host/container.mjs +74 -0
  63. package/modules/demo-host/pages.mjs +56 -0
  64. package/modules/demo-host/plan.mjs +52 -0
  65. package/modules/demo-host/worker.mjs +72 -0
  66. package/modules/dependencies/auth.mjs +2 -1
  67. package/modules/deploy-kit/README.md +52 -7
  68. package/modules/deploy-kit/d1-import.mjs +35 -0
  69. package/modules/deploy-kit/deploy.mjs +4 -3
  70. package/modules/deploy-kit/diagnostics.mjs +11 -1
  71. package/modules/deploy-kit/env-file.mjs +27 -0
  72. package/modules/deploy-kit/health-request.mjs +13 -3
  73. package/modules/deploy-kit/health.mjs +35 -15
  74. package/modules/deploy-kit/migration-ledger.mjs +85 -0
  75. package/modules/deploy-kit/migrations.mjs +48 -16
  76. package/modules/deploy-kit/owner-cli.mjs +42 -8
  77. package/modules/deploy-kit/owner-remove.mjs +108 -0
  78. package/modules/deploy-kit/owner-secret.mjs +94 -0
  79. package/modules/deploy-kit/owner.mjs +124 -19
  80. package/modules/deploy-kit/prepare.mjs +3 -1
  81. package/modules/deploy-kit/provider.mjs +18 -2
  82. package/modules/erasure/README.md +2 -3
  83. package/modules/job-queue/config.mjs +4 -2
  84. package/modules/operator-auth/accounts/settings-page.mjs +8 -3
  85. package/modules/operator-auth/csp.mjs +26 -0
  86. package/modules/operator-auth/dev/badge.mjs +1 -22
  87. package/modules/operator-auth/identity/oidc/README.md +18 -0
  88. package/modules/operator-auth/identity/oidc/config.mjs +4 -2
  89. package/modules/operator-auth/identity/oidc/index.d.mts +1 -1
  90. package/modules/operator-auth/identity/oidc/index.mjs +28 -9
  91. package/modules/operator-auth/identity/oidc/network.mjs +9 -4
  92. package/modules/operator-auth/identity/oidc/store.mjs +19 -3
  93. package/modules/operator-auth/index.d.mts +2 -0
  94. package/modules/operator-auth/page.mjs +2 -2
  95. package/modules/operator-auth/routes.mjs +2 -2
  96. package/modules/portability/README.md +37 -0
  97. package/modules/portability/bridge.mjs +14 -4
  98. package/modules/portability/catalogue.mjs +9 -2
  99. package/modules/portability/import.mjs +4 -2
  100. package/modules/portability/index.d.mts +4 -0
  101. package/modules/portability/index.mjs +1 -0
  102. package/modules/portability/lines.mjs +3 -1
  103. package/modules/release/produce.mjs +1 -1
  104. package/modules/release/source.mjs +5 -2
  105. package/onboarding/adapter-labels.mjs +1 -0
  106. package/onboarding/adapter.mjs +9 -4
  107. package/onboarding/commands.mjs +3 -1
  108. package/onboarding/dependency-environment.mjs +33 -0
  109. package/onboarding/local/owner.mjs +1 -1
  110. package/onboarding/operator-installer.mjs +10 -4
  111. package/onboarding/redaction.mjs +22 -3
  112. package/package.json +6 -1
  113. package/scripts/publish-check/README.md +22 -1
  114. package/scripts/publish-check/c2pa.py +121 -0
  115. package/scripts/publish-check/content_identities.py +3 -1
  116. package/scripts/publish-check/controller_metadata.py +31 -0
  117. package/scripts/publish-check/history_scan.py +13 -3
  118. package/scripts/publish-check/media.py +283 -0
  119. package/scripts/publish-check/png_text.py +34 -0
  120. package/scripts/publish-check/public_hygiene.py +39 -4
  121. package/scripts/publish-check/publication-config.json +7 -1
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`
@@ -179,8 +182,15 @@ as an exact devDependency instead (`"@runeditrun/rer": "0.2.0"`, locked in
179
182
  which inside `pnpm run` is the pinned copy in `node_modules/.bin`, so
180
183
  `pnpm install --frozen-lockfile && pnpm run build` needs no global `rer`. A global
181
184
  `rer` hands every verb to the pinned package, launching included. Until
182
- `node_modules` holds the pinned version, `rer install` and `rer dev` install it
183
- first (the frozen `pnpm install`) and every other verb says to run `rer install`.
185
+ `node_modules` holds the pinned version (a fresh clone, or a pulled pin change),
186
+ every verb installs it first (the frozen `pnpm install`), then runs it; verbs
187
+ started at once share that one install.
188
+ Every dependency install, `rer release`'s included, receives an allowlisted
189
+ environment: the shell, locale, terminal and temporary-folder variables, `CI`,
190
+ `TZ`, `SSH_AUTH_SOCK`, `NODE_OPTIONS`, `NODE_EXTRA_CA_CERTS`, `XDG_*`, proxy
191
+ settings without their user:password, and `npm_config_*`, `PNPM_*`,
192
+ `COREPACK_*` and `RER_*` settings that carry no credential by name or value.
193
+ No other variable reaches the package manager or a dependency's build script.
184
194
 
185
195
  Heavy package scripts wait for this computer's job queue with
186
196
  `rer queue NAME -- COMMAND [ARGS]` (in vendored mode,
@@ -267,3 +277,10 @@ Anywhere: `rer list [DIR]`, `rer cli status|install|uninstall`, `rer update`,
267
277
  `rer queue NAME -- COMMAND`, `rer --version` and `rer --help`.
268
278
 
269
279
  Licence: MIT.
280
+
281
+ First-party commands acquire the exact `packageManager` pnpm pin when PATH lacks
282
+ it, verify its registry sha512 integrity and cache it under `$HOME/.rer/pnpm`.
283
+ No global package manager installation or sudo is needed. Independent projects
284
+ continue to run their declared commands with their own tools. Server and job
285
+ state default to `$HOME/.rer/servers` and `$HOME/.rer/jobs`; supplied
286
+ `RER_SERVERS_DIR` and `RER_JOBS_DIR` take precedence and are passed to older pins.
package/cli/README.md CHANGED
@@ -268,3 +268,24 @@ reproducible and `.github/workflows/rer-release.yml` publishes it; see
268
268
  to end on the package-mode reference (served from a local registry, see
269
269
  `scripts/rer-package/registry.mjs`), and drives `install.sh` and `rer update`
270
270
  against that registry.
271
+
272
+
273
+ Owner-account application deploy completion is shared across standard and custom
274
+ hooks. An adapter can declare `deployDeclaration` (or `deploy.declaration`);
275
+ legacy hooks with one root owner deployment declaration are supported too. A
276
+ custom hook may return only `{ deployed: true, origin }`: rer verifies the
277
+ recorded Worker, runs the adapter's actual owner bootstrap, records successful
278
+ ownership separately from retired secrets, and removes the setup token before
279
+ returning success. An owner already claimed by the hook is kept without another
280
+ bootstrap. An existing owner is explicitly reported as kept. Function owner
281
+ hooks run their complete bootstrap flow; setup manifest paths do not replace it.
282
+ A missing or ambiguous declaration or deployment receipt refuses completion.
283
+
284
+ `--env-file` values are available in the command's preflight, custom hook and
285
+ child build environment and are redacted; the caller environment is restored
286
+ on success and failure. `--remove`, `--set-secret` and `--unset-secret` always go
287
+ through the owner-account controller, without invoking the application's build
288
+ or deploy hook. Failure reports distinguish deployment, owner claim and token
289
+ retirement and preserve the environment, checkout and env-file in recovery
290
+ commands. Retirement can be finished with `rer deploy --unset-secret SETUP_TOKEN`
291
+ plus the same `--environment`, `--env-file` and `--cwd` arguments.
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/container.mjs CHANGED
@@ -18,6 +18,16 @@ async function run(command,args,options={}) {
18
18
  try{await new Promise((resolve,reject)=>{child.once('error',reject);child.once('exit',code=>code===0?resolve():reject(new Error('Container command failed')));});}
19
19
  finally{process.off('SIGTERM',abort);process.off('SIGINT',abort);if(child.exitCode===null&&child.signalCode===null)child.kill('SIGTERM');}
20
20
  }
21
+ /** The image's build folder (.rer/container/image unless `output`), built with the checkout's private local settings hidden. */
22
+ export async function imageFolder(project,operation,config,{output,engine}={}) {
23
+ const {root}=project,{withoutLocalVariables}=await shared('local/build.mjs'),{buildEnvironment}=await shared('local/environment.mjs');
24
+ const declaration=await readFile(join(root,'seed.json'),'utf8').then(JSON.parse).catch(()=>({}));
25
+ const declared=Array.isArray(declaration.env)?declaration.env.map(item=>item.name):Object.keys(declaration.env??{});
26
+ await withoutLocalVariables(root,async()=>{
27
+ const builder=fileURLToPath(new URL('../modules/container/build.mjs',import.meta.url));
28
+ await run(process.execPath,[builder,root,JSON.stringify({output,engine})],{cwd:root,env:buildEnvironment([...declared,...(config.secrets??[]).flatMap(item=>[item.binding,item.environment])])});
29
+ },{nested:operation.token,settings:[...(project.adapter?.settings??[]),...(project.privateInputs??[])]});
30
+ }
21
31
  export async function containerCommand(project,options,out) {
22
32
  if(options.stop)return runContainer(project,options,out);
23
33
  const {localOperation}=await shared('local/operation.mjs'),{recoverInterrupted}=await shared('local/recovery.mjs');
@@ -35,16 +45,7 @@ async function runContainer(project,options,out,operation) {
35
45
  if(options.verb==='deploy'){if(!options.host)throw missing('Container deploy requires --host ssh://user@host.');sshTarget(options.host);await deployInputs({root,envFile:options.envFile,localOnlyKeys:project.adapter?.localOnlyKeys});}
36
46
  const ingress={...(options.bindAddress?{address:options.bindAddress}:{}),...(options.httpPort?{httpPort:options.httpPort}:{}),...(options.httpsPort?{httpsPort:options.httpsPort}:{})};
37
47
  ingressBindings({...config.container?.ingress,...ingress});
38
- if(!options.image){
39
- const {withoutLocalVariables}=await shared('local/build.mjs'),{buildEnvironment}=await shared('local/environment.mjs');
40
- const declaration=await readFile(join(root,'seed.json'),'utf8').then(JSON.parse).catch(()=>({}));
41
- const declared=Array.isArray(declaration.env)?declaration.env.map(item=>item.name):Object.keys(declaration.env??{});
42
- await withoutLocalVariables(root,async()=>{
43
- const builder=fileURLToPath(new URL('../modules/container/build.mjs',import.meta.url));
44
- await run(process.execPath,[builder,root],{cwd:root,env:buildEnvironment([...declared,...(config.secrets??[]).flatMap(item=>[item.binding,item.environment])])});
45
- await run('docker',['build','--tag',image,join(root,'.rer/container/image')],{cwd:root});
46
- },{nested:operation.token,settings:[...(project.adapter?.settings??[]),...(project.privateInputs??[])]});
47
- }
48
+ if(!options.image){await imageFolder(project,operation,config);await run('docker',['build','--tag',image,join(root,'.rer/container/image')],{cwd:root});}
48
49
  if(options.verb==='build')return{platform:'container',image};
49
50
  if(!options.host)throw missing('Container deploy requires --host ssh://user@host.');
50
51
  return deploySSH({root,image:options.image??image,ingress,host:options.host,envFile:options.envFile,environment:options.environment,localOnlyKeys:project.adapter?.localOnlyKeys,dryRun:options.dryRun});
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', '--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.'],
9
+ dev: [['--port', '--host', '--idle-timeout', '--stop', '--detach', '--foreground', '--as', '--login', '--models', '--models-service', '--door', '--direct', '--demo'], '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. --demo (or rer demo) serves a throwaway demo: fresh data in .rer/demo/START (deleted when it stops: --stop, Ctrl-C or its end, one hour by default or --idle-timeout), the saved owner signed in, the seed\'s demo data, a Demo bar, and nothing sent (RER_DEMO=1; the checkout\'s own data is never touched).'],
10
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', '--demo', '--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. --demo deploys the hosted demo instead (deploy-demo.json): a front Worker on demo.<domain> giving each visitor their own signed-in, throwaway container of this seed that can send nothing; --dry-run writes it to .rer/demo-host 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,47 @@
1
+ import { cp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
2
+ import { createRequire } from 'node:module';
3
+ import { join } from 'node:path';
4
+ import { contained } from './project.mjs';
5
+ import { invalid, missing, refuse, RerError } from './errors.mjs';
6
+ import { imageFolder } from './container.mjs';
7
+ import { DEMO_FILES, demoPlan } from '../modules/demo-host/plan.mjs';
8
+
9
+ // `rer deploy --demo [--dry-run]`: the seed's hosted demo at demo.<domain>
10
+ // (modules/demo-host), from deploy-demo.json. It writes .rer/demo-host/: the
11
+ // front Worker, its wrangler.json and the container image folder (workerd
12
+ // engine). --dry-run stops there, so `wrangler dev` can serve that folder with
13
+ // Docker; a deploy runs `wrangler deploy` on it with the Turnstile secret.
14
+ export async function demoHostCommand(project, options, store, signal, out, operation) {
15
+ const read = async path => { try { return JSON.parse(await readFile(await contained(project.root, path), 'utf8')); } catch (error) { if (error instanceof RerError) throw error; throw invalid(`Missing or malformed ${path}.`); } };
16
+ const app = await read(project.adapter.deploy?.declaration ?? 'deploy.json'), declaration = await read('deploy-demo.json');
17
+ if (!(app.platforms ?? ['workers']).includes('container')) throw refuse('A hosted demo runs the seed\'s container image: declare "container" in deploy.json platforms first.');
18
+ let plan;
19
+ try { plan = demoPlan({ project: app.project, environment: options.environment ?? 'production', compatibilityDate: app.compatibilityDate, originVar: app.originVar, declaration, turnstileSiteKey: process.env.TURNSTILE_SITE_KEY?.trim() }); }
20
+ catch (error) { throw invalid(`The demo deploy declaration is invalid (${error.message}). deploy-demo.json holds {"version": 1, "domain": "<run site apex>", "name": "<product name>"}, optionally "maxInstances" and plain "settings".`); }
21
+ const folder = join(project.root, '.rer', 'demo-host');
22
+ await rm(folder, { recursive: true, force: true });
23
+ await mkdir(folder, { recursive: true, mode: 0o700 });
24
+ await store.step('Building the demo container image (workerd engine)');
25
+ await imageFolder(project, operation, app, { output: join(folder, 'image'), engine: 'workerd' });
26
+ for (const file of DEMO_FILES) await cp(new URL(`../modules/demo-host/${file}`, import.meta.url), join(folder, file));
27
+ await writeFile(join(folder, 'wrangler.json'), JSON.stringify(plan.wrangler, null, 2) + '\n');
28
+ const summary = { worker: plan.worker, origin: plan.origin, hostname: plan.hostname, folder: '.rer/demo-host', maxInstances: plan.maxInstances, settings: plan.settings, instanceType: 'basic', startsPerIpPerMinute: 3, minutes: 60, idleMinutes: 15, enableInternet: false };
29
+ const human = [`Demo plan: Worker ${summary.worker} on ${summary.hostname} (custom domain; workers.dev off), written to ${summary.folder}`,
30
+ ` one container per visitor (basic, at most ${summary.maxInstances} at once, no internet, RER_DEMO=1), 3 starts per IP per minute behind Turnstile`,
31
+ ` each demo ends after 60 minutes or 15 idle minutes; its container is destroyed and its state deleted`];
32
+ if (options.dryRun) {
33
+ if (options.json) out.event('demo', summary); else for (const line of human) out.human(line);
34
+ store.report('deploy --demo: dry run complete; no deployment occurred and no provider request was made.');
35
+ return { dryRun: true, deployed: false, demo: summary };
36
+ }
37
+ const names = ['CLOUDFLARE_ACCOUNT_ID', 'CLOUDFLARE_API_TOKEN', 'TURNSTILE_SITE_KEY', 'TURNSTILE_SECRET_KEY'];
38
+ if (names.some(name => !process.env[name]?.trim())) throw missing(`${names.join(', ')} are required; see DEMO.md "Hosting the demo".`);
39
+ if (!options.json) for (const line of human) out.human(line);
40
+ const secrets = join(folder, '.secrets.json');
41
+ await writeFile(secrets, JSON.stringify({ TURNSTILE_SECRET_KEY: process.env.TURNSTILE_SECRET_KEY.trim() }), { mode: 0o600 });
42
+ try {
43
+ const wrangler = createRequire(join(project.root, 'package.json')).resolve('wrangler/bin/wrangler.js');
44
+ await store.run('Deploying the demo Worker and its container image', process.execPath, [wrangler, 'deploy', '--config', 'wrangler.json', '--secrets-file', '.secrets.json'], { cwd: folder, signal });
45
+ } finally { await rm(secrets, { force: true }); }
46
+ return { dryRun: false, deployed: true, origin: plan.origin, demo: summary };
47
+ }
package/cli/demo.mjs ADDED
@@ -0,0 +1,47 @@
1
+ import { readdir, readFile, rm } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { DatabaseSync } from 'node:sqlite';
4
+ import { refuse } from './errors.mjs';
5
+ import { shared } from './shared.mjs';
6
+
7
+ // `rer demo` (dev --demo) state and its two adoption checks (export/DEMO.md).
8
+ const adopt = 'See DEMO.md: a seed adopts demo mode with src/demo.ts, withDemo as the first import of its Worker entry, and three vite.config changes.';
9
+
10
+ // Before anything starts: the seed's Vite persists where rer says, so the
11
+ // checkout's own .wrangler/state is never opened by a demo, and hands the
12
+ // Worker's cloudflare:sockets imports demo mode's guarded connect.
13
+ export async function demoAdopted(root) {
14
+ let text = '';
15
+ for (const name of ['vite.config.ts', 'vite.config.mts', 'vite.config.js', 'vite.config.mjs']) {
16
+ try { text = await readFile(join(root, name), 'utf8'); break; } catch (error) { if (error.code !== 'ENOENT') throw error; }
17
+ }
18
+ if (!text.includes('RER_STATE_DIR')) throw refuse(`This seed's vite.config does not persist to RER_STATE_DIR, so a demo would run on this checkout's own data. ${adopt}`);
19
+ if (!text.includes('demoSockets(')) throw refuse(`This seed's vite.config does not list demoSockets(), so a demo's cloudflare:sockets connections could leave this computer. ${adopt}`);
20
+ }
21
+
22
+ // A demo's data lives in .rer/demo/START. Only one server runs per checkout, so
23
+ // any other demo folder is a stopped demo's leftover (a hard kill) and goes now.
24
+ // The end is local ISO time with its offset: the Demo bar shows its HH:MM as written.
25
+ export async function demoState(root, seconds) {
26
+ const { privateDirectory } = await shared('private-files.mjs');
27
+ const started = new Date(), stamp = started.toISOString().replace(/[-:]|\.\d+/g, '');
28
+ const parent = await privateDirectory(root, ['.rer', 'demo']);
29
+ for (const entry of await readdir(parent)) await rm(join(parent, entry), { recursive: true, force: true });
30
+ const end = new Date(started.getTime() + seconds * 1000), offset = -end.getTimezoneOffset();
31
+ const local = new Date(end.getTime() + offset * 60000).toISOString().slice(0, 19);
32
+ const zone = `${offset < 0 ? '-' : '+'}${String(Math.trunc(Math.abs(offset) / 60)).padStart(2, '0')}:${String(Math.abs(offset) % 60).padStart(2, '0')}`;
33
+ return { state: await privateDirectory(root, ['.rer', 'demo', stamp]), ends: local + zone };
34
+ }
35
+
36
+ // After start: a request has passed withDemo, which creates rer_demo in the
37
+ // demo's own database. Without it nothing is blocked, so the demo stops.
38
+ export async function demoGuarded(url, state, signal) {
39
+ const { appFetch } = await shared('app-fetch.mjs');
40
+ await (await appFetch(url + '/health', { signal })).body?.cancel();
41
+ const directory = join(state, 'v3/d1/miniflare-D1DatabaseObject');
42
+ for (const file of (await readdir(directory).catch(() => [])).filter(name => name.endsWith('.sqlite') && name !== 'metadata.sqlite')) {
43
+ const db = new DatabaseSync(join(directory, file), { readOnly: true });
44
+ try { if (db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'rer_demo'").get()) return; } finally { db.close(); }
45
+ }
46
+ throw refuse(`The app did not start in demo mode (no withDemo guard answered), so it could send. The demo was stopped and its data deleted. ${adopt}`);
47
+ }
@@ -1,25 +1,41 @@
1
1
  import {linkApproval,approvalPath} from './dependency-approval.mjs';
2
2
  import { unlink } from 'node:fs/promises';
3
3
  import { missing } from './errors.mjs';
4
- import { providerMetadata } from '../modules/dependencies/protocol.mjs';
4
+ import { INTERFACE, providerMetadata } from '../modules/dependencies/protocol.mjs';
5
5
  import { readJson } from '../modules/operator-auth/identity/oidc/network.mjs';
6
6
 
7
7
  // Only an owner-only, untracked token file is accepted. The token never enters
8
- // setup answers, generated runtime configuration, logs or an archive.
8
+ // setup answers, generated runtime configuration, logs or an archive. Returns
9
+ // connected dependencies and the verified native owner outcome; an unanswered
10
+ // optional dependency is skipped.
9
11
  export async function linkDependencies({project,input,origin,store,signal,appSetupToken,fetch:fetcher=globalThis.fetch,clock=Date.now}) {
10
- const links=[];
12
+ const links=[],connected=[];
13
+ const ownerDependency = project.setupManifest?.steps?.find(step => step.type === 'owner-claim' && step.mode === 'idp')?.dependency;
14
+ let ownerClaimed = false;
15
+ let ownerIssuer;
16
+ const ownerState = async () => {
17
+ if (ownerDependency !== INTERFACE || !ownerIssuer) return undefined;
18
+ const document = await readJson(fetcher, origin+'/.well-known/rer-dependencies', {timeoutMs:5000});
19
+ // claim:false also describes apps without owner-capable dependencies.
20
+ if (document.origin === origin && document.dependencies?.some(item => item.interface === INTERFACE) && typeof document.claim === 'boolean') return document.claim;
21
+ };
11
22
  for(const dependency of project.setupManifest?.dependencies??[]) {
12
23
  const issuer=input?.[dependency.address];
13
24
  if(!issuer) {if(!dependency.optional)throw missing('A required dependency address is missing.');continue;}
25
+ connected.push(dependency);
26
+ if(dependency.interface===ownerDependency)ownerIssuer=issuer;
14
27
  const tokenPath=approvalPath(project,dependency.interface);
15
28
  if(!tokenPath) {
16
29
  const current=await readJson(fetcher,origin+'/auth/dependencies/status',{timeoutMs:5000}).catch(()=>null);
17
- if(current?.links?.some(link=>link.interface===dependency.interface && link.issuer===issuer && link.linked===true))continue;
30
+ if(current?.links?.some(link=>link.interface===dependency.interface && link.issuer===issuer && link.linked===true)){ if(dependency.interface===ownerDependency)ownerClaimed=true;continue; }
18
31
  }
19
32
  if(!tokenPath)throw missing('Provider approval is required. Set RER_LINK_TOKEN_FILE to an owner-only, untracked link-token JSON file for this exact origin and callback set.');
20
33
  const {path,value}=await linkApproval({project,dependency,issuer,origin,store,clock});
21
34
  links.push({dependency,issuer,path,value});
22
35
  }
36
+ // Validate every approval first, then distinguish an existing native owner
37
+ // from one this link is about to claim, without relying on local deploy history.
38
+ const unclaimedBefore = await ownerState();
23
39
  // Open every initial app approval before the first provider can claim its owner.
24
40
  // Hosted runtimes accept their private setup token; no local CLI HMAC is sent.
25
41
  if(links.length && appSetupToken) {
@@ -39,7 +55,13 @@ export async function linkDependencies({project,input,origin,store,signal,appSet
39
55
  if(result.linked!==true)throw missing('Provider approval did not complete linking.');
40
56
  const status=await readJson(fetcher,origin+'/auth/dependencies/status',{timeoutMs:5000});
41
57
  if(!status.links?.some(link=>link.interface===dependency.interface && link.issuer===issuer && link.linked===true))throw missing('The application has not accepted the provider link.');
58
+ if(dependency.interface===ownerDependency)ownerClaimed=true;
42
59
  await unlink(path);
43
60
  store.report('Dependency linked; its one-time approval token was deleted.');
44
61
  }
62
+ if (ownerClaimed) {
63
+ const status = await readJson(fetcher, origin+'/auth/dependencies/status', {timeoutMs:5000});
64
+ ownerClaimed = unclaimedBefore !== undefined && await ownerState() === false && status.links?.some(link => link.interface === ownerDependency && link.issuer === ownerIssuer && link.linked === true) === true;
65
+ }
66
+ return {connected,ownerClaimed, ...(ownerClaimed ? {ownerOutcome: unclaimedBefore ? 'claimed' : 'kept'} : {})};
45
67
  }
@@ -0,0 +1,94 @@
1
+ import { readFile, readdir } from 'node:fs/promises';
2
+ import { contained } from './project.mjs';
3
+ import { invalid, refuse, RerError } from './errors.mjs';
4
+ import { ownerDeploy } from './deploy.mjs';
5
+ import { deployEnvironment, deployAgain } from './deploy-inputs.mjs';
6
+ import { claimHostedOwner } from './deploy-owner.mjs';
7
+ import { mutationReadinessFailure } from './deploy-readiness.mjs';
8
+ import { observeOwner } from './deploy-observation.mjs';
9
+ import { main as owner } from '../modules/deploy-kit/owner-cli.mjs';
10
+ import { localOnlyBinding, ownerWorkerName } from '../modules/deploy-kit/owner.mjs';
11
+ import { refuseLocalModels } from '../modules/deploy-kit/local-only.mjs';
12
+ import { shared } from './shared.mjs';
13
+
14
+ export function validateDeployOptions(options) {
15
+ const changes = [options.remove && '--remove', options.setSecret && '--set-secret', options.unsetSecret && '--unset-secret'].filter(Boolean);
16
+ if (changes.length > 1 || changes.length && options.dryRun) throw invalid(`Use ${changes.join(', ')}${options.dryRun ? ' and --dry-run' : ''} on separate runs.`);
17
+ if (options.remove && !options.yes) throw invalid(`${deployAgain(options)} --remove deletes this environment's Worker and every D1, R2, KV and queue rer created for it, with their data. Add --yes to confirm.`);
18
+ }
19
+
20
+ // An explicit adapter declaration wins. Older hooks use a conventional root
21
+ // declaration; discover it by its schema, never by seed identity or hook source.
22
+ async function declarationFor(root, adapter) {
23
+ const declared = adapter.deployDeclaration ?? adapter.deploy?.declaration;
24
+ const paths = declared ? [declared] : (await readdir(root)).filter(path => path.endsWith('.json') && path !== adapter.site?.declaration);
25
+ const matches = [];
26
+ for (const path of paths) {
27
+ let value;
28
+ try { value = JSON.parse(await readFile(await contained(root, path), 'utf8')); }
29
+ catch (error) { if (declared) throw error; continue; }
30
+ if (declared || value?.version === 1 && typeof value.project === 'string' && typeof value.compatibilityDate === 'string') matches.push({ path, value });
31
+ }
32
+ if (matches.length !== 1) throw refuse('A single owner-account deploy declaration is required. Declare deployDeclaration on the adapter; missing or ambiguous declarations cannot be used safely.');
33
+ return matches[0];
34
+ }
35
+
36
+ export async function deployCommand(context) {
37
+ const { adapter, root, options, store, setupManifest, linkDependencies, localOnlyKeys = [] } = context;
38
+ validateDeployOptions(options);
39
+ try { await refuseLocalModels(root); } catch { throw refuse('Deploy refuses the local-only key file .rer/local/models.json. Remove live-dev credentials before deployment.'); }
40
+ const { path, value: declaration } = await declarationFor(root, adapter);
41
+ ownerWorkerName(declaration.project, options.environment ?? 'production');
42
+ const local = localOnlyBinding(declaration, localOnlyKeys);
43
+ if (local) throw refuse(`${path} names the local-only key ${local} as a deploy binding or secret. Remove it; local dev keys never reach a deployment.`);
44
+ // Custom hooks historically ignored these flags. Only the ownership controller
45
+ // may perform them; neither a build nor a custom upload runs here.
46
+ if (options.remove || options.setSecret || options.unsetSecret) return ownerDeploy(path, adapter)(context);
47
+ if (adapter.deploy.managesHostedOwner) return adapter.deploy(context);
48
+ const { env, supplied } = await deployEnvironment({ root, options, declaration, setupManifest, settings: adapter.settings, localOnlyKeys });
49
+ const { createRedaction, credentialValues } = await shared('redaction.mjs');
50
+ const redaction = createRedaction();
51
+ const addSecret = value => { redaction.addSecrets([value]); store.addSecrets?.([value]); };
52
+ for (const value of [...credentialValues(env), env.CLOUDFLARE_ACCOUNT_ID, ...(declaration.secrets ?? []).map(item => env[item.environment])].filter(Boolean)) addSecret(value);
53
+ const command = (verb, extra = [], values = env) => owner([verb, '--declaration', path, '--environment', options.environment ?? 'production', ...extra], values, { cwd: root, localOnlyKeys });
54
+ let recorded;
55
+ const inspect = async () => (recorded = await command('inspect'));
56
+ let observedClaims = [];
57
+ let deployed, claimed, linked = false, linkResult, step = 'custom deploy';
58
+ const link = async token => { if (!linked) { const state = await inspect(); linkResult = await linkDependencies?.(state.origin, token); linked = true; } return linkResult; };
59
+ try {
60
+ return await observeOwner({ paths: ['/auth/bootstrap', adapter.hooks?.owner?.path, setupManifest?.steps?.find(item => item.type === 'owner-claim')?.path].filter(Boolean), inspect, beforeClaim: link, addSecret }, async claims => {
61
+ observedClaims = claims;
62
+ const saved = { ...process.env };
63
+ try {
64
+ Object.assign(process.env, env);
65
+ deployed = await adapter.deploy({ ...context, linkDependencies: async (_origin, token) => link(token) });
66
+ } finally {
67
+ for (const key of Object.keys(process.env)) if (!Object.hasOwn(saved, key)) delete process.env[key];
68
+ Object.assign(process.env, saved);
69
+ }
70
+ if (options.dryRun) return deployed;
71
+ if (!deployed?.deployed) throw new Error('custom-deploy-did-not-confirm-deployment');
72
+ step = 'verify recorded deployment';
73
+ const state = await inspect();
74
+ if (state.origin !== deployed.origin) throw new Error('owner-claim-origin-mismatch');
75
+ if (claims.some(item => !item.outcome)) throw new Error('owner-claim-failed');
76
+ claimed = claims.at(-1)?.outcome;
77
+ step = 'claim owner';
78
+ claimed = await claimHostedOwner({ hook: adapter.hooks?.owner, setupManifest, declaration, deployed: { ...state, owner: claimed, observedOwner: () => claims.at(-1)?.outcome }, env, supplied, secret: (extra, values) => command('secret', extra, values), addSecret, beforeClaim: link, recordClaim: outcome => command('record-owner', ['--outcome', outcome]), store, signal: context.signal });
79
+ if (!linked) await linkDependencies?.(state.origin);
80
+ if (claimed) store.report(`${claimed === 'claimed' ? 'Claimed the hosted owner' : 'The hosted owner was already claimed and was kept'}; the one-time setup token was removed. Sign in by email at ${state.origin}.`);
81
+ return { ...deployed, ...(claimed ? { owner: claimed } : {}), dependenciesLinked: true };
82
+ });
83
+ } catch (error) {
84
+ const again = deployAgain(options);
85
+ if (error instanceof RerError && !deployed?.deployed && !recorded) { error.message = store.redact?.(redaction.redact(error.message)) ?? redaction.redact(error.message); throw error; }
86
+ const { commandFailure } = await shared('failures.mjs');
87
+ const outcome = error.owner ?? claimed ?? observedClaims.at(-1)?.outcome;
88
+ const state = deployed?.deployed || recorded ? `The application is deployed at ${deployed?.origin ?? recorded.origin}; ${outcome ? `owner ${outcome}` : 'owner claim is not confirmed'}; setup token retirement is not confirmed.` : 'The custom deploy did not finish; inspect its deployment record before recovery.';
89
+ const redact = value => store.redact?.(redaction.redact(value)) ?? redaction.redact(value);
90
+ const readiness = mutationReadinessFailure(error, { again, redact });
91
+ if (readiness) throw commandFailure(readiness);
92
+ throw commandFailure(redact(`${state} Failed at ${step}: ${error.message}${error.cause ? ` (${error.cause})` : ''}. Fix the cause and run ${again} again using the same environment credentials. ${outcome ? `To retire the token without rebuilding: ${again} --unset-secret ${setupManifest?.steps?.find(item => item.type === 'owner-claim')?.tokenField ?? 'SETUP_TOKEN'}.` : ''}`));
93
+ }
94
+ }
@@ -0,0 +1,55 @@
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
+ }
34
+
35
+ // Hooks and their child builds read process.env. Install the private file for
36
+ // this command only, and restore even keys changed by a failing hook.
37
+ export async function withDeployEnvironment(root, options, run) {
38
+ const saved = { ...process.env };
39
+ const { env } = await deployEnvironment({ root, options, declaration: {} });
40
+ const { createRedaction, credentialValues } = await shared('redaction.mjs');
41
+ const redaction = createRedaction();
42
+ redaction.addSecrets([...credentialValues(env), ...Object.entries(env).filter(([key, value]) => saved[key] !== value).map(([, value]) => value)]);
43
+ Object.assign(process.env, env);
44
+ try { return await run(redaction); }
45
+ catch (error) { if (typeof error?.message === 'string') error.message = redaction.redact(error.message); throw error; }
46
+ finally {
47
+ for (const key of Object.keys(process.env)) if (!Object.hasOwn(saved, key)) delete process.env[key];
48
+ Object.assign(process.env, saved);
49
+ }
50
+ }
51
+
52
+ export function deployAgain(options) {
53
+ const quote = value => /^[a-zA-Z0-9_./-]+$/.test(value) ? value : `'${String(value).replaceAll("'", "'\"'\"'")}'`;
54
+ return `rer deploy --environment ${quote(options.environment ?? 'production')}${options.envFile ? ` --env-file ${quote(options.envFile)}` : ''}${options.cwd ? ` --cwd ${quote(options.cwd)}` : ''}`;
55
+ }
@@ -0,0 +1,28 @@
1
+ // Observe the actual bootstrap exchange of a custom hook, including hooks that
2
+ // call the shared owner helper themselves. Never issue a second claim after it.
3
+ // The deployment record is checked before linking with its live setup token.
4
+ export async function observeOwner({ paths, inspect, beforeClaim, addSecret }, run) {
5
+ const fetch = globalThis.fetch, claims = [];
6
+ globalThis.fetch = async (input, init = {}) => {
7
+ const url = new URL(input instanceof Request ? input.url : input);
8
+ let body;
9
+ if (paths.includes(url.pathname) && (init.method ?? (input instanceof Request ? input.method : 'GET')).toUpperCase() === 'POST') {
10
+ try { body = JSON.parse(typeof init.body === 'string' ? init.body : input instanceof Request ? await input.clone().text() : 'null'); } catch {}
11
+ }
12
+ if (typeof body?.token !== 'string' || !body.token) return fetch(input, init);
13
+ addSecret(body.token);
14
+ const deployed = await inspect();
15
+ if (url.origin !== deployed.origin) throw new Error('owner-claim-origin-mismatch');
16
+ await beforeClaim?.(body.token);
17
+ const response = await fetch(input, init);
18
+ let code;
19
+ if (response.status !== 303) {
20
+ try { code = (await response.clone().json()).error; } catch {}
21
+ }
22
+ const outcome = response.status === 303 ? 'claimed' : response.status === 409 && code === 'already_initialized' ? 'kept' : undefined;
23
+ claims.push({ origin: url.origin, outcome, status: response.status });
24
+ return response;
25
+ };
26
+ try { return await run(claims); }
27
+ finally { globalThis.fetch = fetch; }
28
+ }