@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
@@ -1,6 +1,7 @@
1
1
  import { prepareLiveModels } from './ai-local.mjs';
2
2
  import {fileURLToPath} from 'node:url';
3
3
  import {join} from 'node:path';
4
+ import {rm} from 'node:fs/promises';
4
5
  import { serverRegistry } from './server-registry.mjs';
5
6
  import { activityClock } from './server-activity.mjs';
6
7
  import { runtimeFrontDoor } from './runtime-front-door.mjs';
@@ -13,6 +14,7 @@ import { tools } from './inspect.mjs';
13
14
  import { invalid,missing,refuse,RerError,lifecycleError } from './errors.mjs';
14
15
  import { processInfo } from '../modules/job-queue/processes.mjs';
15
16
  import { declaredCrons,runScheduled } from './scheduled.mjs';
17
+ import { demoAdopted,demoGuarded,demoState } from './demo.mjs';
16
18
  import { readDevKey,requireDevKey,refuseManagedInstallation,resolvePersona,openUrl,DEV_KEY_HEADER } from './dev-identity.mjs';
17
19
  import { announceDoorRoute,claimSavedName,moveCheckout,routeRunning,verifyDoorFor } from './door-mode.mjs';
18
20
 
@@ -22,9 +24,10 @@ import { announceDoorRoute,claimSavedName,moveCheckout,routeRunning,verifyDoorFo
22
24
  // serves no outbox (a seed's preview without one) is not pointed at it.
23
25
  // A seed that declares a run site also gets RER_SITE: its static run site
24
26
  // (homepage, /run, docs) served locally on its own port, after the ready lines.
25
- export function ready(out,url,dev,json=false,outbox=true,siteUrl){
27
+ // A demo adds RER_DEMO_ENDS_ON_STOP: its own data folder, deleted when it stops.
28
+ export function ready(out,url,dev,json=false,outbox=true,siteUrl,demo){
26
29
  const signInUrl=url+'/__dev/outbox',open=dev&&(dev.identity||outbox);
27
- if(json){out.event('ready',{url,...(outbox?{signInUrl}:{}),...(dev?{signedInAs:dev.signedInAs??null,...(open?{openUrl:dev.openUrl}:{})}:{}),...(siteUrl?{siteUrl}:{})});return;}
30
+ if(json){out.event('ready',{url,...(outbox?{signInUrl}:{}),...(dev?{signedInAs:dev.signedInAs??null,...(open?{openUrl:dev.openUrl}:{})}:{}),...(siteUrl?{siteUrl}:{}),...(demo?{demo}:{})});return;}
28
31
  out.human(`RER_READY ${url}`);
29
32
  if(dev?.identity){if(dev.signedInAs)out.human(`RER_SIGNED_IN ${dev.signedInAs}`);else if(dev.ownerLeft)out.human('No active owner remains in this app, so rer dev cannot sign in as one. Use rer dev --login with another operator, or rer reset --yes and rer setup for a new owner.');out.human(`RER_OPEN ${dev.openUrl}`);out.human('Open that link on this computer. Its fragment holds this checkout\'s dev key, which the page keeps only as a cookie; never share the link.');}
30
33
  else if(outbox){
@@ -33,10 +36,13 @@ export function ready(out,url,dev,json=false,outbox=true,siteUrl){
33
36
  out.human(`Open ${dev?'the last link on this computer (it carries the dev key; never share it)':'the outbox'}, choose the newest sign-in message and follow its link. No email was sent.`);
34
37
  }
35
38
  if(siteUrl)out.human(`RER_SITE ${siteUrl}`);
39
+ if(demo){out.human(`RER_DEMO_ENDS_ON_STOP ${demo.state}`);out.human(`This demo runs on its own data in that folder, which is deleted when it stops: rer demo --stop, Ctrl-C, or at its end (${demo.ends.slice(11,16)}). Nothing it does is sent; this checkout's own data is untouched.`);}
36
40
  }
37
41
  export async function managedServer(project,options,out,{clock={}}={}){
38
42
  const registry=await serverRegistry(project.root),controller=new AbortController();
39
- let store,operation,proxy,activity,app,termination,reason,cleanupPromise,finished,selected,cleaned,site;
43
+ // A demo registers as its own mode: `rer dev --stop` never stops it by accident.
44
+ const mode=options.demo?'demo':options.verb;
45
+ let store,operation,proxy,activity,app,termination,reason,cleanupPromise,finished,selected,cleaned,site,demo,demoEnd;
40
46
  const scheduling=new AbortController();
41
47
  let activityWrites=Promise.resolve(),pendingActivity,activityWriting=false,infrastructureFailure;
42
48
  function recordActivity(fields){
@@ -45,27 +51,27 @@ export async function managedServer(project,options,out,{clock={}}={}){
45
51
  }
46
52
  const stopped=new Promise(resolve=>{finished=resolve;});
47
53
  const cleanupDone=new Promise(resolve=>{cleaned=resolve;});
48
- const cleanup=()=>cleanupPromise??=(async()=>{scheduling.abort();activity?.close();await site?.close();await store?.closeCommands();await proxy?.close();await operation?.release();})();
54
+ const cleanup=()=>cleanupPromise??=(async()=>{scheduling.abort();clearTimeout(demoEnd);activity?.close();await site?.close();await store?.closeCommands();if(demo)await rm(demo.state,{recursive:true,force:true});await proxy?.close();await operation?.release();})();
49
55
  const stop=async why=>{reason??=why;controller.abort();finished();await cleanupDone;return {};};
50
56
  const handlers=new Map(['SIGINT','SIGTERM'].map(name=>[name,()=>{termination=name;void stop(name);} ]));
51
57
  for(const [name,handler]of handlers)process.on(name,handler);
52
58
  try{
53
- if(options.stop){await registry.stop(options.verb,options);return {};}
59
+ if(options.stop){await registry.stop(mode,options);return {};}
54
60
  controller.signal.throwIfAborted();
55
61
  if(options.door||options.direct)await moveCheckout(project,options,out,registry);
56
62
  const devIdentity=project.adapter.devIdentity;
57
63
  if(options.as!==undefined&&!(devIdentity&&options.verb==='dev'))throw invalid('--as needs a seed that declares devIdentity.');
58
64
  let devKey=devIdentity?await readDevKey(project.root):null;
59
- let current=await registry.existing(options.verb,options);
65
+ let current=await registry.existing(mode,options);
60
66
  if(!current){
61
- const reservation=await registry.reserve(options.verb,options);
67
+ const reservation=await registry.reserve(mode,options);
62
68
  current=reservation.existing;
63
69
  }
64
70
  if(current){
65
71
  // Resolve a competing reservation through the same explicit-option check.
66
- current=await registry.existing(options.verb,options);
72
+ current=await registry.existing(mode,options);
67
73
  const running=await registry.waitReady(current,controller.signal);
68
- await registry.existing(options.verb,options);
74
+ await registry.existing(mode,options);
69
75
  if(options.verb==='dev' && (running.models??'fake')!==(options.models??'fake'))throw refuse('Stop the running dev server before changing --models mode.');
70
76
  if(options.modelsService)throw refuse('Stop the running dev server before saving live-dev credentials.');
71
77
  if(options.verb==='dev'&&Boolean(running.login)!==Boolean(options.login))throw refuse(`The running dev server ${running.login?'uses':'does not use'} --login. Stop it with rer dev --stop to change sign-in mode.`);
@@ -79,10 +85,10 @@ export async function managedServer(project,options,out,{clock={}}={}){
79
85
  // A key is sent (or printed) for a door origin only once the door proves it is ours.
80
86
  if(devIdentity)await verifyDoorFor(running.url);
81
87
  const dev=devIdentity&&await devReady(running.url,devKey,options,controller.signal);
82
- await emitReady(out,options,project.root,running.url,dev,true,running.siteUrl);return {reused:true};
88
+ await emitReady(out,options,project.root,running.url,dev,true,running.siteUrl,running.demo);return {reused:true};
83
89
  }
84
90
  await registry.serve(async action=>{
85
- if(action==='status')return {...activity?.snapshot(),url:selected?.url,probe:proxy?.probe,siteUrl:site?.url};
91
+ if(action==='status')return {...activity?.snapshot(),url:selected?.url,probe:proxy?.probe,siteUrl:site?.url,demo};
86
92
  if(action==='stop')return stop('explicit stop');
87
93
  throw Error('Unknown management operation');
88
94
  });
@@ -99,10 +105,11 @@ export async function managedServer(project,options,out,{clock={}}={}){
99
105
  if(!selected.vars[keys.email])throw missing('Private owner settings are missing. Run rer setup first.');
100
106
  if(devIdentity&&!devKey)devKey=await requireDevKey(project.root);
101
107
  if(devIdentity&&options.verb==='dev'&&!options.login)refuseManagedInstallation(selected.vars,process.env);
102
- if(project.adapter.localPrerequisites&&!await project.adapter.localPrerequisites({root:project.root,vars:selected.vars,email:selected.vars[keys.email]}))throw missing('Local migrations, owner or local authentication/mail settings are missing. Run rer setup first.');
108
+ if(project.adapter.localPrerequisites&&!await project.adapter.localPrerequisites({root:project.root,vars:selected.vars,email:selected.vars[keys.email],demo:Boolean(options.demo)}))throw missing('Local migrations, owner or local authentication/mail settings are missing. Run rer setup first.');
103
109
  await registry.update({url:selected.url,host:selected.host,port:selected.port,login:Boolean(options.login),models:options.models??'fake'});
104
110
  const {privateDirectory}=await shared('private-files.mjs');
105
- await privateDirectory(project.root,['.wrangler','state']);
111
+ if(options.demo){await demoAdopted(project.root);demo=await demoState(project.root,options.idleTimeout);}
112
+ else await privateDirectory(project.root,['.wrangler','state']);
106
113
  const directory=await privateDirectory(project.root,['.seed','installer',...(process.env.RER_ONBOARDING_PROCESS_OWNER?[`commands-${process.pid}`]:[])]);
107
114
  const {createCommands}=await shared('commands.mjs');const state={logs:[]};
108
115
  store=await createCommands(directory,state,{terminal:text=>out.progress(text),onWaiting:options.json?details=>out.event('waiting',details):undefined,custodyToken:registry.custodyToken(),queue:{verb:options.verb,originalCI:process.env.CI==='true'}});
@@ -110,11 +117,12 @@ export async function managedServer(project,options,out,{clock={}}={}){
110
117
  store.setupManifest=seed?.manifest;if(devKey)store.addSecrets([devKey]);
111
118
  await tools(project.root,store,controller.signal);
112
119
  activity=activityClock({...clock,seconds:options.idleTimeout,onIdle:()=>void stop('idle timeout'),onChange:recordActivity});
120
+ if(demo)demoEnd=setTimeout(()=>void stop('demo ended'),Math.min(Date.parse(demo.ends)-Date.now(),2**31-1));
113
121
  const crons=project.adapter.scheduled!==false?await declaredCrons(project.root,project.adapter).catch(error=>{throw refuse(`The declared scheduled triggers cannot run locally: ${error.message}`);}):[];
114
122
  proxy=await runtimeFrontDoor({host:selected.host,port:selected.port,activity,door:selected.door,devKey:devKey??undefined,origin:selected.url});const internal=proxy.runtimePort;
115
123
  if(selected.door)await announceDoorRoute(selected,proxy,registry);
116
124
  const modelsLive = options.verb==='dev' ? await prepareLiveModels(project.root,options,store,controller.signal) : undefined;
117
- const adapterOptions={modelsLive,selectedOrigin:selected.url,operationToken:operation.token,privateSettings:project.adapter.settings,runtimePort:internal,devKey:devKey??undefined,devLogin:Boolean(options.login),scheduled:crons.length>0};
125
+ const adapterOptions={modelsLive,demo,selectedOrigin:selected.url,operationToken:operation.token,privateSettings:project.adapter.settings,runtimePort:internal,devKey:devKey??undefined,devLogin:Boolean(options.login),scheduled:crons.length>0};
118
126
  const input={[keys.email]:selected.vars[keys.email]};
119
127
  if(options.verb==='preview')await productionBuild(project,()=>store.run('Building fixed production output',process.execPath,[fileURLToPath(new URL('../bin/rer.mjs',import.meta.url)),'build','--cwd',project.root,'--json'],{cwd:project.root,phase:'build',signal:controller.signal,env:{...process.env,RER_LOCAL_OPERATION:operation.token},afterCommand:()=>recoverInterrupted(project.root,operation)}),text=>store.report(text));
120
128
  await serverResources(project.root);
@@ -127,6 +135,7 @@ export async function managedServer(project,options,out,{clock={}}={}){
127
135
  },controller.signal,{...adapterOptions,verb:options.verb,dev:project.adapter.dev,preview:project.adapter.preview});
128
136
  // A key is sent (or printed) for a door origin only once the door proves it is ours.
129
137
  if(devKey)await verifyDoorFor(selected.url);
138
+ if(demo)await demoGuarded(selected.url,demo.state,controller.signal);
130
139
  const dev=devIdentity&&await devReady(selected.url,devKey,options,controller.signal);
131
140
  const outbox=await outboxServed(selected.url,devKey,controller.signal);
132
141
  if(options.verb==='dev'&&project.adapter.site){
@@ -140,7 +149,7 @@ export async function managedServer(project,options,out,{clock={}}={}){
140
149
  if(options.json)out.event('warning',{code:error.code,message});else out.progress(`Warning: ${message}\n`);
141
150
  }
142
151
  }
143
- activity.ready();await emitReady(out,options,project.root,selected.url,dev,outbox,site?.url);
152
+ activity.ready();await emitReady(out,options,project.root,selected.url,dev,outbox,site?.url,demo);
144
153
  if(crons.length){
145
154
  out.progress(`Running ${crons.length===1?'the scheduled trigger':'the scheduled triggers'} locally: ${crons.join(' | ')}\n`);
146
155
  void runScheduled({crons,url:`http://127.0.0.1:${internal}`,path:options.verb==='preview'?'/cdn-cgi/local/scheduled':undefined,signal:scheduling.signal,report:run=>reportScheduled(out,options,run)});
@@ -150,7 +159,7 @@ export async function managedServer(project,options,out,{clock={}}={}){
150
159
  if(infrastructureFailure)throw new RerError('server_bookkeeping_failed','Server bookkeeping failed; owned serving was stopped.',1);
151
160
  if(result?.code)throw new RerError('app_exited','The application stopped unexpectedly.',result.code);
152
161
  out.progress(`Server stopped: ${reason}.\n`);return {};
153
- }catch(error){if(termination)throw new RerError('cancelled',`Stopped by ${termination} after owned children were stopped.`,termination==='SIGINT'?130:143);if(['idle timeout','explicit stop'].includes(reason))return {};throw await lifecycleError(error,store);}finally{await cleanup();cleaned();while(activityWriting)await activityWrites;await registry.close();for(const [name,handler]of handlers)process.off(name,handler);}
162
+ }catch(error){if(termination)throw new RerError('cancelled',`Stopped by ${termination} after owned children were stopped.`,termination==='SIGINT'?130:143);if(['idle timeout','explicit stop','demo ended'].includes(reason))return {};throw await lifecycleError(error,store);}finally{await cleanup();cleaned();while(activityWriting)await activityWrites;await registry.close();for(const [name,handler]of handlers)process.off(name,handler);}
154
163
  }
155
164
  function reportScheduled(out,options,run){
156
165
  const error=options.verb==='preview'&&run.status===404 ? 'The preview target does not expose /cdn-cgi/local/scheduled. Forward the adapter context scheduled flag to previewTarget, or set RER_RUNTIME_TRIGGERS=1 in a custom target using the shared production runtime. For another runtime, expose that internal trigger endpoint or declare scheduled:false.' : run.error;
@@ -167,12 +176,12 @@ async function outboxServed(url,devKey,signal){
167
176
  // dev also keeps its RER_OPEN link in owner-only .rer/local/open-url (0600),
168
177
  // which reset removes with the rest of .rer/local, for a person or agent that
169
178
  // did not keep the terminal output.
170
- async function emitReady(out,options,root,url,dev,outbox,siteUrl){
179
+ async function emitReady(out,options,root,url,dev,outbox,siteUrl,demo){
171
180
  if(options.verb==='dev'&&dev?.openUrl){
172
181
  const {privateDirectory,writePrivateFile}=await shared('private-files.mjs');
173
182
  await writePrivateFile(join(await privateDirectory(root,['.rer','local']),'open-url'),dev.openUrl+'\n');
174
183
  }
175
- ready(out,url,dev||undefined,options.json,outbox,siteUrl);
184
+ ready(out,url,dev||undefined,options.json,outbox,siteUrl,demo);
176
185
  }
177
186
  async function devReady(url,key,options,signal){
178
187
  if(options.verb==='preview')return {openUrl:openUrl(url,key,{path:'/__dev/key',next:'/__dev/outbox'})};
package/cli/parse.mjs CHANGED
@@ -2,7 +2,7 @@ import { resolve } from 'node:path';
2
2
  import { commonFlags, serverDefaults, verbs } from './contract.mjs';
3
3
  import { invalid, refuse } from './errors.mjs';
4
4
  import { isLoopbackHost } from '../modules/operator-auth/loopback.mjs';
5
- const booleans = new Set(['--help', '--json', '--non-interactive', '--yes', '--dry-run', '--open', '--stop', '--detach', '--foreground', '--no-queue', '--login', '--legacy-v1', '--acknowledge-unverified-metadata', '--door', '--direct', '--site']);
5
+ const booleans = new Set(['--help', '--json', '--non-interactive', '--yes', '--dry-run', '--open', '--stop', '--detach', '--foreground', '--no-queue', '--login', '--legacy-v1', '--acknowledge-unverified-metadata', '--door', '--direct', '--site', '--remove', '--demo']);
6
6
  export const mailbox = value => typeof value === 'string' && value.length <= 254 && /^[a-zA-Z0-9.!+_%\-]+@[a-zA-Z0-9\-]+(?:\.[a-zA-Z0-9\-]+)+$/.test(value);
7
7
  // A loopback host: the shared predicate (including `*.localhost` door names),
8
8
  // plus `::1` as --host and a bracket-stripped hostname spell it.
@@ -17,6 +17,8 @@ export function parse(argv, cwd = process.cwd(), { independent = false, deferTai
17
17
  if (!verb || verb === '--help') return { help: true };
18
18
  if (verb === 'release') return { verb, releaseArgs: args };
19
19
  if (verb === 'archive') return { verb, archiveArgs: args };
20
+ // rer demo is rer dev --demo.
21
+ if (verb === 'demo' && !independent) return parse(['dev', '--demo', ...args], cwd, { independent, deferTail });
20
22
  // Independent projects own verb names; only the common wrapper flags apply
21
23
  // to unknown names. Profile resolution rejects undeclared commands later.
22
24
  if (!/^[a-z][a-z0-9-]*$/.test(verb)) throw invalid('Unknown rer command. Run rer --help.');
@@ -53,8 +55,10 @@ export function parse(argv, cwd = process.cwd(), { independent = false, deferTai
53
55
  if(options.models!==undefined && !['fake','live'].includes(options.models))throw invalid('--models takes fake or live.');
54
56
  if(options.modelsService!==undefined && options.models!=='live')throw invalid('--models-service requires --models live.');
55
57
  if(options.as!==undefined && (options.login || !/^[a-z][a-z0-9-]{0,39}$/.test(options.as)))throw invalid(options.login?'--as and --login cannot be combined.':'--as takes a persona name such as owner, signed-out or admin.');
58
+ if(options.demo&&['--door','--direct','--as','--login','--models','--models-service'].some(flag=>options.supplied.has(flag)))throw invalid('A demo opens signed in as the owner on its own data: --demo takes only --port, --host, --idle-timeout, --stop, --detach and --foreground.');
56
59
  if(['dev','preview'].includes(verb) && !options.stop){
57
- const value=options.idleTimeout??process.env.RER_SERVER_IDLE_SECONDS??String(serverDefaults.idleSeconds);
60
+ // A demo ends one hour after it starts unless --idle-timeout says otherwise.
61
+ const value=options.idleTimeout??(options.demo?'3600':process.env.RER_SERVER_IDLE_SECONDS??String(serverDefaults.idleSeconds));
58
62
  if(!/^[1-9][0-9]*$/.test(value)||!Number.isSafeInteger(Number(value)))throw invalid('Idle timeout must be positive integer seconds.');
59
63
  options.idleTimeout=Number(value);
60
64
  }
@@ -72,6 +76,9 @@ export function parse(argv, cwd = process.cwd(), { independent = false, deferTai
72
76
  if (options.target !== undefined && !['local', 'remote'].includes(options.target)) throw invalid('Target must be local or remote.');
73
77
  if (options.profile !== undefined && options.profile !== 'first-party') throw invalid('The supported explicit profile is first-party.');
74
78
  if (options.environment !== undefined && !/^[a-z][a-z0-9-]{0,39}$/.test(options.environment)) throw invalid('Provide a valid deployment environment name.');
79
+ for (const key of ['setSecret', 'unsetSecret']) if (options[key] !== undefined && !/^[A-Z][A-Z0-9_]{0,63}$/.test(options[key])) throw invalid('Name a declared secret binding, such as SESSION_SECRET.');
80
+ if(verb==='deploy'&&options.demo&&(options.site||['platform','image','bindAddress','httpPort','httpsPort','envFile','host','remove','setSecret','unsetSecret'].some(key=>options[key]!==undefined)))throw invalid('--demo takes only --environment and --dry-run: the hosted demo is its own Worker on demo.<domain>.');
81
+ if ((options.remove || options.setSecret || options.unsetSecret) && options.site) throw invalid('--remove, --set-secret and --unset-secret act on the application Worker, not the run site.');
75
82
  if (options.origin !== undefined) {
76
83
  let url; try { url = new URL(options.origin); } catch { throw invalid('Provide a valid credential-free origin.'); }
77
84
  if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash || url.pathname !== '/') throw invalid('Provide a credential-free HTTP origin without a path.');
@@ -0,0 +1,17 @@
1
+ import { missing } from './errors.mjs';
2
+ import { shared } from './shared.mjs';
3
+
4
+ // A version check owns its descendants just like a lifecycle command. Cleanup
5
+ // starts at leader exit, even when quiet descendants still hold the pipes.
6
+ // The package manager never sees the caller's credentials.
7
+ export async function probePnpm(root, store, signal, env = process.env) {
8
+ const { dependencyEnvironment } = await shared('dependency-environment.mjs');
9
+ const probeSignal = AbortSignal.any([signal, AbortSignal.timeout(10000)].filter(Boolean));
10
+ const child = store.spawn('Checking pinned pnpm', 'pnpm', ['--version'], { cwd: root, env: dependencyEnvironment(env), signal: probeSignal });
11
+ let version = '';
12
+ child.stdout.on('data', chunk => { version = (version + chunk).slice(-1024); });
13
+ const code = await new Promise(resolve => { child.once('error', () => resolve(null)); child.once('exit', code => resolve(code)); });
14
+ await store.stop(child);
15
+ if (probeSignal.aborted || code !== 0) throw missing('The pinned pnpm version probe failed. Select the documented tool.');
16
+ return version.trim();
17
+ }
@@ -15,11 +15,11 @@ import { shellQuote } from './node-runtime-cache.mjs';
15
15
  // rer's own cache (~/.rer/pnpm/pnpm-VERSION). Only rer's own commands for the
16
16
  // project put it on their PATH; nothing global changes, and it is used only
17
17
  // when the person's PATH has no pnpm of the pinned version.
18
- export const pnpmCache = () => join(homedir(), '.rer', 'pnpm');
19
- const directoryOf = version => join(pnpmCache(), `pnpm-${version}`);
20
- const shim = version => join(directoryOf(version), 'shim');
21
- const receipt = version => join(directoryOf(version), '.rer-verified.json');
22
- const entry = version => join(directoryOf(version), 'bin', 'pnpm.cjs');
18
+ export const pnpmCache = (env = process.env) => join(env.HOME || homedir(), '.rer', 'pnpm');
19
+ const directoryOf = (version, env) => join(pnpmCache(env), `pnpm-${version}`);
20
+ const shim = (version, env) => join(directoryOf(version, env), 'shim');
21
+ const receipt = (version, env) => join(directoryOf(version, env), '.rer-verified.json');
22
+ const entry = (version, env) => join(directoryOf(version, env), 'bin', 'pnpm.cjs');
23
23
 
24
24
  async function digest(path, algorithm, encoding) {
25
25
  const hash = createHash(algorithm);
@@ -30,25 +30,25 @@ async function owned(path, directory) {
30
30
  const stat = await lstat(path);
31
31
  if (stat.isSymbolicLink() || (directory ? !stat.isDirectory() : !stat.isFile()) || (process.getuid && stat.uid !== process.getuid()) || (stat.mode & 0o022)) throw Error('Unsafe pnpm cache placement.');
32
32
  }
33
- async function prepareCache() {
34
- for (const path of [join(homedir(), '.rer'), pnpmCache()]) {
33
+ async function prepareCache(env) {
34
+ for (const path of [join(env.HOME || homedir(), '.rer'), pnpmCache(env)]) {
35
35
  await mkdir(path, { mode: 0o700 }).catch(error => { if (error.code !== 'EEXIST') throw error; });
36
36
  try { await owned(path, true); } catch { throw missing(`The pnpm cache directory must be an owned, non-linked directory without group/other write access: ${path}. Correct its placement and permissions before retrying.`); }
37
37
  }
38
- return pnpmCache();
38
+ return pnpmCache(env);
39
39
  }
40
40
 
41
41
  /** The verified cached pnpm's PATH directory, or undefined when none is cached. */
42
- export async function cachedPnpm(version) {
43
- try { await lstat(directoryOf(version)); } catch (error) { if (error.code === 'ENOENT') return; throw error; }
42
+ export async function cachedPnpm(version, env = process.env) {
43
+ try { await lstat(directoryOf(version, env)); } catch (error) { if (error.code === 'ENOENT') return; throw error; }
44
44
  try {
45
- for (const path of [join(homedir(), '.rer'), pnpmCache(), directoryOf(version), shim(version)]) await owned(path, true);
46
- for (const path of [receipt(version), entry(version), join(shim(version), 'pnpm')]) await owned(path, false);
47
- const record = JSON.parse(await readFile(receipt(version), 'utf8'));
48
- if (record.version !== version || !/^sha512-/.test(record.integrity) || record.entrySha256 !== await digest(entry(version), 'sha256', 'hex')) throw Error('pnpm cache integrity mismatch.');
49
- return shim(version);
45
+ for (const path of [join(env.HOME || homedir(), '.rer'), pnpmCache(env), directoryOf(version, env), shim(version, env)]) await owned(path, true);
46
+ for (const path of [receipt(version, env), entry(version, env), join(shim(version, env), 'pnpm')]) await owned(path, false);
47
+ const record = JSON.parse(await readFile(receipt(version, env), 'utf8'));
48
+ if (record.version !== version || !/^sha512-/.test(record.integrity) || record.entrySha256 !== await digest(entry(version, env), 'sha256', 'hex')) throw Error('pnpm cache integrity mismatch.');
49
+ return shim(version, env);
50
50
  } catch {
51
- throw missing(`The cached pnpm is unsafe or incomplete. Preserve it for inspection with: mv ${shellQuote(directoryOf(version))} ${shellQuote(directoryOf(version) + '.quarantine-' + Date.now())}. Then rerun the original rer command to fetch a verified release.`);
51
+ throw missing(`The cached pnpm is unsafe or incomplete. Preserve it for inspection with: mv ${shellQuote(directoryOf(version, env))} ${shellQuote(directoryOf(version, env) + '.quarantine-' + Date.now())}. Then rerun the original rer command to fetch a verified release.`);
52
52
  }
53
53
  }
54
54
 
@@ -69,8 +69,8 @@ function extract(archive, directory, signal) {
69
69
  }
70
70
 
71
71
  /** Fetch, verify and cache pnpm VERSION; returns its PATH directory. `registry` is the npm registry origin. */
72
- export async function downloadPnpm(version, cancellation, { registry = 'https://registry.npmjs.org' } = {}) {
73
- const cache = await prepareCache(), temporary = await mkdtemp(join(cache, '.download-'));
72
+ export async function downloadPnpm(version, cancellation, { registry = 'https://registry.npmjs.org', env = process.env } = {}) {
73
+ const cache = await prepareCache(env), temporary = await mkdtemp(join(cache, '.download-'));
74
74
  const signal = AbortSignal.any([cancellation, AbortSignal.timeout(60000)].filter(Boolean));
75
75
  try {
76
76
  const metadata = join(temporary, 'release.json'), archive = join(temporary, 'pnpm.tgz');
@@ -86,11 +86,11 @@ export async function downloadPnpm(version, cancellation, { registry = 'https://
86
86
  if (JSON.parse(await readFile(join(extracted, 'package.json'), 'utf8')).version !== version) throw Error('Verified pnpm release did not name the project pin.');
87
87
  await mkdir(join(extracted, 'shim'), { mode: 0o700 });
88
88
  // `node` is rer's own: the command puts its Node first on PATH beside this.
89
- await writeFile(join(extracted, 'shim', 'pnpm'), `#!/bin/sh\nexec node ${shellQuote(entry(version))} "$@"\n`, { mode: 0o700 });
89
+ await writeFile(join(extracted, 'shim', 'pnpm'), `#!/bin/sh\nexec node ${shellQuote(entry(version, env))} "$@"\n`, { mode: 0o700 });
90
90
  await writeFile(join(extracted, '.rer-verified.json'), JSON.stringify({ version, integrity, entrySha256: await digest(join(extracted, 'bin', 'pnpm.cjs'), 'sha256', 'hex') }) + '\n', { flag: 'wx', mode: 0o600 });
91
91
  await chmod(extracted, 0o700);
92
- try { await rename(extracted, directoryOf(version)); }
93
- catch (error) { if (!['EEXIST', 'ENOTEMPTY'].includes(error.code) || !(await cachedPnpm(version))) throw error; }
94
- return shim(version);
92
+ try { await rename(extracted, directoryOf(version, env)); }
93
+ catch (error) { if (!['EEXIST', 'ENOTEMPTY'].includes(error.code) || !(await cachedPnpm(version, env))) throw error; }
94
+ return shim(version, env);
95
95
  } finally { await rm(temporary, { recursive: true, force: true }); }
96
96
  }
@@ -0,0 +1,55 @@
1
+ import { realpath, stat } from 'node:fs/promises';
2
+ import { isAbsolute, relative, resolve, sep } from 'node:path';
3
+ import { fileURLToPath, pathToFileURL } from 'node:url';
4
+ import { policy as validatePolicy } from '../modules/portability/catalogue.mjs';
5
+ import { PortabilityError } from '../modules/portability/index.mjs';
6
+
7
+ // Only seed configuration supplies this entry point. Never resolve executable
8
+ // policy from archive metadata, or stringify functions across the process seam.
9
+ export async function policyModuleURL(root, entry) {
10
+ try {
11
+ let path;
12
+ if (entry instanceof URL || (typeof entry === 'string' && entry.startsWith('file:'))) {
13
+ const url = new URL(entry);
14
+ if (url.protocol !== 'file:' || url.search || url.hash) throw new Error();
15
+ path = fileURLToPath(url);
16
+ } else {
17
+ if (typeof entry !== 'string' || !entry || isAbsolute(entry) || /^[a-z][a-z\d+.-]*:/i.test(entry)) throw new Error();
18
+ path = resolve(root, entry);
19
+ }
20
+ const base = await realpath(root), actual = await realpath(path), within = relative(base, actual);
21
+ if (!within || within === '..' || within.startsWith('..' + sep) || isAbsolute(within) || !(await stat(actual)).isFile()) throw new Error();
22
+ return pathToFileURL(actual).href;
23
+ } catch { throw new PortabilityError('invalid_policy_module', 409); }
24
+ }
25
+
26
+ export function serializablePolicy(input) {
27
+ const seen = new Set();
28
+ function copy(value) {
29
+ if (value === null || value === undefined || typeof value === 'string' || typeof value === 'boolean' || (typeof value === 'number' && Number.isFinite(value))) return value;
30
+ if (typeof value !== 'object' || seen.has(value) || (!Array.isArray(value) && ![Object.prototype, null].includes(Object.getPrototypeOf(value)))) throw new PortabilityError('policy_module_required', 409);
31
+ seen.add(value);
32
+ const result = Array.isArray(value) ? [] : Object.create(null);
33
+ for (const key of Reflect.ownKeys(value)) {
34
+ if (Array.isArray(value) && key === 'length') continue;
35
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
36
+ if (typeof key !== 'string' || !descriptor.enumerable || !('value' in descriptor)) throw new PortabilityError('policy_module_required', 409);
37
+ result[key] = copy(descriptor.value);
38
+ }
39
+ seen.delete(value);
40
+ return result;
41
+ }
42
+ const result = copy(input);
43
+ validatePolicy(result);
44
+ return result;
45
+ }
46
+
47
+ export async function loadPolicy(root, policy, policyModule) {
48
+ if (policyModule === undefined) return serializablePolicy(policy);
49
+ if (policy !== undefined) throw new PortabilityError('ambiguous_policy', 409);
50
+ try {
51
+ const { default: input } = await import(await policyModuleURL(root, policyModule));
52
+ validatePolicy(input);
53
+ return input;
54
+ } catch { throw new PortabilityError('invalid_policy_module', 409); }
55
+ }
@@ -4,27 +4,31 @@ import { readLegacyArchive } from '../modules/portability/format.mjs';
4
4
  import { recoverLegacyArchive } from '../modules/portability/legacy-files.mjs';
5
5
  import { legacyTargetState, requireLegacyEmpty } from '../modules/portability/legacy-safety.mjs';
6
6
  import { migrationReplay } from '../modules/portability/migration-history.mjs';
7
+ import { appliedMigrations as appliedMigrationNames } from '../modules/portability/bridge.mjs';
7
8
  import { join } from 'node:path';
8
9
  import { readFile } from 'node:fs/promises';
9
10
  import { createRequire } from 'node:module';
10
11
  import { createPortability, PortabilityError, readArchive } from '../modules/portability/index.mjs';
11
12
  import { parseJsonc } from './jsonc.mjs';
13
+ import { loadPolicy } from './portability-policy.mjs';
12
14
 
13
- // The migrations this installation applied, in Wrangler's applied order, so a
15
+ // The migrations this installation applied, in its ledger's order, so a
14
16
  // restore can place an older archive within them.
15
17
  async function appliedMigrations(root, db, binding, split) {
16
18
  const config = parseJsonc(await readFile(join(root, 'wrangler.jsonc'), 'utf8'));
17
19
  const directory = join(root, config.d1_databases.find(entry => entry.binding === binding).migrations_dir ?? 'migrations');
18
- const names = (await db.prepare('SELECT name FROM d1_migrations ORDER BY id').all()).results.map(row => row.name);
20
+ const names = await appliedMigrationNames(db) ?? [];
19
21
  return Promise.all(names.map(async name => ({ name, statements: split(await readFile(join(directory, name), 'utf8')) })));
20
22
  }
21
23
 
22
- async function run(direction, { root, path, journal, binding, buckets, policy, transient, legacyRecovery }) {
24
+ async function run(direction, { root, path, journal, binding, buckets, policy, policyModule, transient, legacyRecovery }) {
23
25
  // Validate all inputs before opening bindings or acquiring a restore lock.
24
26
  if (direction === 'import') {
25
27
  for await (const record of fileRecords(path,legacyRecovery?readLegacyArchive:readArchive)) {}
26
28
  if(journal)for await(const record of fileRecords(journal)) {}
27
29
  }
30
+ policy = await loadPolicy(root, policy, policyModule);
31
+ if (direction === 'policy-preflight') return { checkedPolicy: true };
28
32
  const { getPlatformProxy, unstable_splitSqlQuery } = createRequire(join(root, 'package.json'))('wrangler');
29
33
 
30
34
  // Unlike Wrangler's --persist-to base, the API takes the versioned directory.
@@ -46,7 +50,7 @@ async function run(direction, { root, path, journal, binding, buckets, policy, t
46
50
  try { console.log(JSON.stringify({ ok: true, ...await run(process.argv[2], JSON.parse(process.argv[3])) })); }
47
51
  catch (error) {
48
52
  // Erasure-fence refusals name the fence: an older archive is refused, not a failed transfer.
49
- const refusal = error instanceof PortabilityError && (error.status === 409 || ['wrong_product', 'schema_mismatch', 'table_inventory', 'configuration_inventory', 'erased_row', 'erasure_epoch', 'later_journal', 'invalid_journal'].includes(error.code.split(':')[0]));
53
+ const refusal = error instanceof PortabilityError && (error.status === 409 || ['wrong_product', 'schema_mismatch', 'table_inventory', 'configuration_inventory', 'erased_row', 'erasure_epoch', 'later_journal', 'invalid_journal', 'staged_validation'].includes(error.code.split(':')[0]));
50
54
  const exitCode = error.message === 'archive_json' ? 2 : refusal || error.code === 'EEXIST' ? 4 : 1;
51
55
  // Report only symbolic codes; native database errors can contain private data.
52
56
  const reason = error instanceof PortabilityError ? error.code.split(':')[0] : /^[A-Z_]+$/.test(error.code ?? '') ? error.code : error.message === 'archive_json' ? 'archive_json' : 'native_error';
@@ -5,10 +5,12 @@ import { readArchive, PortabilityError } from '../modules/portability/index.mjs'
5
5
  import { readLegacyArchive } from '../modules/portability/format.mjs';
6
6
  import { fileRecords } from '../modules/portability/files.mjs';
7
7
  import { RerError, refuse } from './errors.mjs';
8
+ import { policyModuleURL, serializablePolicy } from './portability-policy.mjs';
9
+ import { shared } from './shared.mjs';
8
10
 
9
11
  // Seeds declare their real bindings and data policy. The shared module owns the
10
12
  // archive format and atomic restore; the shared runner owns its native children.
11
- export function localPortability({ database, binding = 'DB', buckets = {}, policy, transient = [] }) {
13
+ export function localPortability({ database, binding = 'DB', buckets = {}, policy, policyModule, transient = [] }) {
12
14
  async function run({ root, path, journal, store, signal, operationToken, platform, legacyRecovery }, direction) {
13
15
  if (direction === 'export') {
14
16
  if (await lstat(path).catch(error => { if (error.code === 'ENOENT') return null; throw error; })) throw refuse('Export requires a new archive path.');
@@ -22,6 +24,7 @@ export function localPortability({ database, binding = 'DB', buckets = {}, polic
22
24
  if(journal)for await(const record of fileRecords(journal)) {}
23
25
  }
24
26
  catch (error) {
27
+ if (error.message === 'archive_json') throw new RerError('invalid_archive', 'Restore refused (archive_json): archive JSON is invalid.', 2);
25
28
  if (!(error instanceof PortabilityError)) throw error;
26
29
  const message = error.code === 'legacy_archive_unverifiable'
27
30
  ? 'Restore refused (legacy_archive_unverifiable): version 1 metadata cannot be authenticated. Upgrade the original installation and re-export, or use rer import --legacy-v1 --input FILE --yes --acknowledge-unverified-metadata --output exports/recovered-v2.rer in an empty installation. See modules/portability/README.md#backup-only-v1-recovery.'
@@ -31,13 +34,29 @@ export function localPortability({ database, binding = 'DB', buckets = {}, polic
31
34
  }
32
35
  }
33
36
  if(legacyRecovery)store.report('Legacy v1 recovery: manifest metadata is unverified. Restored data will be audited and exported to a new v2 backup.');
34
- if(platform==='container') return (await import('./container-transfer.mjs')).containerTransfer({root,path,journal,store,signal,direction,binding,buckets,policy,transient});
35
- const input={ root, path, journal, binding, buckets, policy, transient, legacyRecovery };
37
+ let declaration;
38
+ try {
39
+ if (policyModule !== undefined) {
40
+ if (policy !== undefined) throw new PortabilityError('ambiguous_policy', 409);
41
+ if (platform === 'container') throw new PortabilityError('container_policy_module_unsupported', 409);
42
+ declaration = { policyModule: await policyModuleURL(root, policyModule) };
43
+ } else declaration = { policy: serializablePolicy(policy) };
44
+ } catch (error) {
45
+ if (!(error instanceof PortabilityError)) throw error;
46
+ throw refuse(`Local portability policy refused (${error.code}). Executable policies require a seed-owned policyModule with a valid default policy export; policyModule requires native local transport.`);
47
+ }
48
+ if(platform==='container') return (await import('./container-transfer.mjs')).containerTransfer({root,path,journal,store,signal,direction,binding,buckets,policy:declaration.policy,transient});
49
+ const input={ root, path, journal, binding, buckets, ...declaration, transient, legacyRecovery };
50
+ // Check the executable policy before migrations; the transfer worker imports
51
+ // it again in its own process so module closures are retained during restore.
52
+ if (policyModule !== undefined) await nativeWorker(store,'policy-preflight',input,signal);
36
53
  // Do not apply pending product migrations to a populated recovery target.
37
54
  // Check real existing bindings first; finalize repeats this under the lock.
38
55
  if(legacyRecovery)await nativeWorker(store,'legacy-preflight',input,signal);
56
+ // Local migrations need no provider account: wrangler runs without credentials.
57
+ const { dependencyEnvironment } = await shared('dependency-environment.mjs');
39
58
  await store.run('Applying local data migrations', 'pnpm', ['exec', 'wrangler', 'd1', 'migrations', 'apply', database, '--local', '--persist-to', '.wrangler/state', '--config', 'wrangler.jsonc'], {
40
- cwd: root, signal, env: { ...process.env, RER_LOCAL_OPERATION: operationToken, WRANGLER_SEND_METRICS: 'false' },
59
+ cwd: root, signal, env: { ...dependencyEnvironment(), RER_LOCAL_OPERATION: operationToken, WRANGLER_SEND_METRICS: 'false' },
41
60
  });
42
61
  const receipt=await nativeWorker(store,direction,input,signal);
43
62
  if(legacyRecovery)receipt.output=relative(root,legacyRecovery.output);
@@ -48,7 +67,7 @@ export function localPortability({ database, binding = 'DB', buckets = {}, polic
48
67
 
49
68
  async function nativeWorker(store,direction,input,signal) {
50
69
  const worker = fileURLToPath(new URL('./portability-worker.mjs', import.meta.url));
51
- const child = store.spawn(`${direction === 'legacy-preflight' ? 'Checking empty recovery destination' : direction === 'export' ? 'Exporting' : 'Restoring'} local installation data`, process.execPath,
70
+ const child = store.spawn(`${direction === 'policy-preflight' ? 'Checking archive policy' : direction === 'legacy-preflight' ? 'Checking empty recovery destination' : direction === 'export' ? 'Exporting' : 'Restoring'} local installation data`, process.execPath,
52
71
  [worker, direction, JSON.stringify(input)], { cwd: input.root, signal, env: { ...process.env, WRANGLER_SEND_METRICS: 'false' } });
53
72
  let output = '';
54
73
  child.stdout.on('data', chunk => { output += chunk; });
@@ -0,0 +1,67 @@
1
+ // Resolve the checkout's package manager before its own rer can be loaded.
2
+ // The verified cache is private to rer; the resulting PATH travels to children.
3
+ import { mkdtemp, readFile, rm } from 'node:fs/promises';
4
+ import { delimiter, dirname, join } from 'node:path';
5
+ import { homedir, tmpdir } from 'node:os';
6
+ import { cachedPnpm, downloadPnpm } from './pnpm-runtime.mjs';
7
+ import { missing, RerError } from './errors.mjs';
8
+ import { shellQuote } from './node-runtime-cache.mjs';
9
+ import { probePnpm } from './pnpm-probe.mjs';
10
+ import { shared } from './shared.mjs';
11
+
12
+ export async function projectPnpmPin(root) {
13
+ let pkg;
14
+ try { pkg = JSON.parse(await readFile(join(root, 'package.json'), 'utf8')); } catch {}
15
+ const pin = /^pnpm@(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)(?:\+sha\d+\.[0-9a-f]+)?$/.exec(pkg?.packageManager ?? '')?.[1];
16
+ if (!pin) throw missing('This first-party checkout must declare an exact pnpm version in package.json packageManager. Correct that declaration, then rerun rer.');
17
+ return pin;
18
+ }
19
+ async function probe(root, env, signal, store) {
20
+ try { return await probePnpm(root, store, signal, env); }
21
+ catch (error) { if (signal?.aborted) throw error; return undefined; }
22
+ }
23
+ export async function ensureProjectPnpm(root, options = {}) {
24
+ if (options.store) return ensure(root, options);
25
+ // Global bootstrap has no adapter or lifecycle store yet. Give it the same
26
+ // command custody, with a temporary log and cancellation lasting through the
27
+ // download, both probes, and cleanup.
28
+ const cancellation = new AbortController();
29
+ const signal = AbortSignal.any([options.signal, cancellation.signal].filter(Boolean));
30
+ let termination, directory, store;
31
+ const handlers = new Map(['SIGINT', 'SIGTERM'].map(name => [name, () => { termination ??= name; cancellation.abort(name); }]));
32
+ for (const [name, handler] of handlers) process.on(name, handler);
33
+ try {
34
+ // Invalid manifests refuse before acquiring command custody or tools.
35
+ const pin = await projectPnpmPin(root);
36
+ directory = await mkdtemp(join(tmpdir(), 'rer-prerequisites-'));
37
+ const { createCommands } = await shared('commands.mjs');
38
+ store = await createCommands(directory, { logs: [] }, { terminal: () => {} });
39
+ store.setCommandSignal(signal);
40
+ return await ensure(root, { ...options, signal, store }, pin);
41
+ } finally {
42
+ try { await store?.closeCommands(); if (directory) await rm(directory, { recursive: true, force: true }); }
43
+ finally { for (const [name, handler] of handlers) process.removeListener(name, handler); }
44
+ if (termination) throw new RerError('cancelled', `Stopped by ${termination} after owned children were stopped.`, termination === 'SIGINT' ? 130 : 143);
45
+ }
46
+ }
47
+ async function ensure(root, { env = process.env, signal, report = () => {}, store }, pin) {
48
+ pin ??= await projectPnpmPin(root);
49
+ if (await probe(root, env, signal, store) === pin) return env;
50
+ let directory = await cachedPnpm(pin, env);
51
+ if (!directory) {
52
+ report(`Fetching pnpm ${pin} for this project; rer keeps it in its own cache.`);
53
+ try { directory = await downloadPnpm(pin, signal, { env }); }
54
+ catch (error) {
55
+ if (signal?.aborted || error instanceof RerError) throw error;
56
+ const prefix = join(env.HOME || homedir(), '.rer', 'pnpm', `manual-${pin}`);
57
+ throw missing(`pnpm ${pin} could not be fetched (${error.message}). Retry with a working connection, or run in this shell: npm install --prefix ${shellQuote(prefix)} --no-save pnpm@${pin} && export PATH=${shellQuote(join(prefix, 'node_modules/.bin'))}:"$PATH"`);
58
+ }
59
+ }
60
+ // Select the verified pnpm before any tool beside Node. Its shim still finds
61
+ // this process's Node before the caller's PATH, but cannot be shadowed by a
62
+ // different pnpm installed in that Node's bin directory.
63
+ const supplied = { ...env, PATH: [directory, dirname(process.execPath), env.PATH].filter(Boolean).join(delimiter) };
64
+ if (await probe(root, supplied, signal, store) !== pin) throw missing(`The verified pnpm ${pin} did not report the project version. Run ${shellQuote(join(directory, 'pnpm'))} --version to inspect it, then rerun rer.`);
65
+ Object.assign(env, supplied);
66
+ return env;
67
+ }
package/cli/release.mjs CHANGED
@@ -1,5 +1,40 @@
1
1
  // Preserve release's accepted parser, producer, receipt and validation exits.
2
+ const releaseHelp = `Usage: rer release --repo DIR --project PUBLISHER/NAME --repository GITHUB_URL --output NEW_DIR [FLAGS]
3
+
4
+ Produce local release artifacts after frozen installation, verification and publication checks.
5
+ Without --tag, write a preview. This command never pushes, uploads or deploys.
6
+
7
+ --repo DIR Source Git repository.
8
+ --project PUBLISHER/NAME
9
+ Publishing identity.
10
+ --repository GITHUB_URL
11
+ Credential-free https://github.com/<owner>/<repository> destination.
12
+ --output NEW_DIR New output directory outside the source repository.
13
+ --ref REF Selected committed source (default: HEAD).
14
+ --tag [vVERSION] Prepare source artifacts and create a local annotated tag;
15
+ an explicit tag must match the committed release version.
16
+ --dry-run Run the real gates and suppress local tag creation.
17
+ --clean-history Prepare a parentless public export.
18
+ --onto GITHUB_URL#BRANCH
19
+ Prepare a public child of the destination's current branch;
20
+ cannot be combined with --clean-history.
21
+ --env-file FILE Credential file for publication inspection.
22
+ --no-queue Bypass the job queue only in the original CI environment.
23
+ --help Print this help without performing release work.
24
+
25
+ Signing and receipt commands:
26
+ rer release sign --artifact DIR (--key-file FILE | --key-stdin) [--scope test|rer]
27
+ rer release receipt --artifact DIR --anchors FILE [--environment test] [--after VERSION:SHA256]
28
+ rer release keygen --scope test|rer --publisher NAMESPACE --github-owner LOGIN --github-owner-id ID --anchor-out FILE
29
+ [--not-before YYYY-MM-DDTHH:MM:SSZ] [--not-after YYYY-MM-DDTHH:MM:SSZ]
30
+ [--unsigned-through PROJECT:VERSION ...]
31
+
32
+ Signing scope defaults to rer; receipt environment defaults to production.
33
+ Key generation requires non-terminal stdout; --key-stdin requires non-terminal stdin.
34
+ `;
35
+
2
36
  export async function dispatchRelease(args) {
37
+ if (args.length === 1 && args[0] === '--help') { console.log(releaseHelp); return 0; }
3
38
  if (['sign', 'receipt', 'keygen'].includes(args[0])) return (await import('../modules/release/signing-cli.mjs')).dispatchSigning(args[0], args.slice(1));
4
39
  try {
5
40
  const options = {};
package/cli/scheduled.mjs CHANGED
@@ -1,5 +1,3 @@
1
- import { declaredCronSources } from './deploy-config.mjs';
2
-
3
1
  // `rer dev` runs the project's declared Cron Triggers (wrangler `triggers.crons`, from wrangler.jsonc, wrangler.toml or a deploy template)
4
2
  // on their schedule by calling the local runtime's scheduled test endpoint (the Vite plugin's under dev, Miniflare's own under preview), so
5
3
  // alerts, retries and checks behave on the owner's computer as they do deployed.
@@ -40,6 +38,8 @@ export function cronMatches(cron, date) {
40
38
  // triggers.crons must declare the same ones: otherwise `rer dev` would run a
41
39
  // schedule the deployed Worker does not have.
42
40
  export async function declaredCrons(root, adapter) {
41
+ // Imported here: the container image runs this file's parseCron and cronMatches without the CLI's project modules.
42
+ const { declaredCronSources } = await import('./deploy-config.mjs');
43
43
  const sources = await declaredCronSources(root, adapter);
44
44
  for (const { path, crons } of sources) for (const cron of crons) parseCron(cron);
45
45
  const [first, ...others] = sources;
@@ -1,6 +1,6 @@
1
1
  import { createConnection, createServer } from 'node:net';
2
2
  import { randomUUID, createHash } from 'node:crypto';
3
- import { userInfo, tmpdir } from 'node:os';
3
+ import { homedir, tmpdir } from 'node:os';
4
4
  import { chmod, realpath, unlink, lstat, mkdir } from 'node:fs/promises';
5
5
  import { join, resolve } from 'node:path';
6
6
  import { setTimeout as delay } from 'node:timers/promises';
@@ -80,7 +80,7 @@ function limitMessage(counted,cap,explicit,mode) {
80
80
  // the old default of 2 in records written before this rule) never lowers the cap
81
81
  // below the current default.
82
82
  export const defaultServerLimit=serverDefaults.serverLimit;
83
- export const serversDirectory=(env=process.env)=>resolve(env.RER_SERVERS_DIR??join(userInfo().homedir,'.rer/servers'));
83
+ export const serversDirectory=(env=process.env)=>resolve(env.RER_SERVERS_DIR??join((env.HOME || homedir()),'.rer/servers'));
84
84
  export async function serverRegistry(root,{env=process.env,memory=availableMemory}={}) {
85
85
  const floor=memoryFloor(env),explicit=env.RER_SERVER_LIMIT!==undefined,limit=positive(env.RER_SERVER_LIMIT,'RER_SERVER_LIMIT',defaultServerLimit),journey=declaresJourney(env);
86
86
  const checkout=await realpath(root), lifetime=await identity();