@runeditrun/rer 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/README.md +15 -4
  2. package/cli/README.md +4 -2
  3. package/cli/auth-copy.mjs +25 -18
  4. package/cli/bounded.mjs +13 -4
  5. package/cli/contract.mjs +3 -3
  6. package/cli/deploy-inputs.mjs +33 -0
  7. package/cli/deploy-owner.mjs +61 -0
  8. package/cli/deploy.mjs +95 -9
  9. package/cli/detached-server.mjs +51 -0
  10. package/cli/door.mjs +1 -1
  11. package/cli/hosted-output.mjs +2 -2
  12. package/cli/hydration/check.mjs +2 -2
  13. package/cli/inspect.mjs +24 -32
  14. package/cli/lifecycle.mjs +14 -4
  15. package/cli/main.mjs +1 -0
  16. package/cli/managed-server.mjs +12 -5
  17. package/cli/parse.mjs +5 -2
  18. package/cli/pnpm-probe.mjs +14 -0
  19. package/cli/pnpm-runtime.mjs +22 -22
  20. package/cli/portability-policy.mjs +55 -0
  21. package/cli/portability-worker.mjs +8 -4
  22. package/cli/portability.mjs +20 -4
  23. package/cli/project-pnpm.mjs +67 -0
  24. package/cli/release.mjs +36 -0
  25. package/cli/server-registry.mjs +2 -2
  26. package/cli/site-bindings.mjs +291 -0
  27. package/cli/site-browser-objects.mjs +36 -0
  28. package/cli/site-check.mjs +55 -31
  29. package/cli/site-client.mjs +50 -0
  30. package/cli/site-data.mjs +55 -0
  31. package/cli/site-entities.json +1 -0
  32. package/cli/site-input-assets.mjs +58 -0
  33. package/cli/site-inputs.mjs +79 -0
  34. package/cli/site-markup.mjs +102 -0
  35. package/cli/site-private.mjs +36 -0
  36. package/cli/site-references.mjs +243 -0
  37. package/cli/site-render.mjs +7 -5
  38. package/cli/site-syntax.mjs +21 -0
  39. package/cli/site.mjs +32 -11
  40. package/global/CHANGELOG.md +39 -0
  41. package/global/README.md +15 -4
  42. package/global/cli/install.mjs +26 -9
  43. package/global/cli/path-link.mjs +30 -0
  44. package/global/cli/status.mjs +1 -1
  45. package/global/cli/uninstall.mjs +5 -1
  46. package/global/launch/install-pinned.mjs +4 -0
  47. package/global/main.mjs +30 -3
  48. package/global/output.mjs +12 -0
  49. package/global/registry.mjs +2 -2
  50. package/global/rer.mjs +3 -0
  51. package/modules/container/deploy-input.mjs +5 -9
  52. package/modules/dependencies/auth.mjs +2 -1
  53. package/modules/deploy-kit/README.md +25 -7
  54. package/modules/deploy-kit/d1-import.mjs +35 -0
  55. package/modules/deploy-kit/deploy.mjs +9 -5
  56. package/modules/deploy-kit/diagnostics.mjs +65 -0
  57. package/modules/deploy-kit/env-file.mjs +26 -0
  58. package/modules/deploy-kit/health-request.mjs +6 -1
  59. package/modules/deploy-kit/health.mjs +35 -15
  60. package/modules/deploy-kit/migration-ledger.mjs +48 -0
  61. package/modules/deploy-kit/migrations.mjs +11 -15
  62. package/modules/deploy-kit/owner-cli.mjs +29 -10
  63. package/modules/deploy-kit/owner-remove.mjs +105 -0
  64. package/modules/deploy-kit/owner-secret.mjs +49 -0
  65. package/modules/deploy-kit/owner.mjs +71 -10
  66. package/modules/deploy-kit/provider.mjs +59 -7
  67. package/modules/job-queue/config.mjs +4 -2
  68. package/modules/operator-auth/accounts/native.mjs +1 -1
  69. package/modules/operator-auth/dev/badge.mjs +4 -1
  70. package/modules/operator-auth/identity/oidc/README.md +18 -0
  71. package/modules/operator-auth/identity/oidc/config.mjs +4 -2
  72. package/modules/operator-auth/identity/oidc/index.d.mts +1 -1
  73. package/modules/operator-auth/identity/oidc/index.mjs +28 -9
  74. package/modules/operator-auth/identity/oidc/network.mjs +9 -4
  75. package/modules/operator-auth/identity/oidc/store.mjs +19 -3
  76. package/modules/operator-auth/index.d.mts +6 -9
  77. package/modules/operator-auth/page.mjs +1 -1
  78. package/modules/operator-auth/routes.mjs +1 -1
  79. package/modules/operator-auth/service.mjs +1 -0
  80. package/modules/operator-auth/signin/index.d.mts +11 -0
  81. package/modules/operator-auth/signin/index.mjs +10 -7
  82. package/modules/portability/README.md +28 -0
  83. package/modules/portability/bridge.mjs +10 -3
  84. package/modules/portability/import.mjs +4 -2
  85. package/modules/release/README.md +63 -0
  86. package/modules/release/keygen.mjs +18 -0
  87. package/modules/release/produce.mjs +9 -0
  88. package/modules/release/sign.mjs +44 -0
  89. package/modules/release/signature.mjs +336 -0
  90. package/modules/release/signing-cli.mjs +56 -0
  91. package/modules/release/signing-io.mjs +61 -0
  92. package/modules/release/signing-key.mjs +22 -0
  93. package/modules/release/statement.mjs +80 -0
  94. package/modules/release/vectors/generate.mjs +381 -0
  95. package/modules/release/vectors/release-signature-vectors.json +2305 -0
  96. package/modules/release/verify.mjs +27 -0
  97. package/onboarding/commands.mjs +3 -1
  98. package/onboarding/production-assets.mjs +23 -0
  99. package/onboarding/production-runtime.mjs +8 -4
  100. package/package.json +2 -1
  101. package/scripts/publish-check/README.md +27 -17
  102. package/scripts/publish-check/content_identities.py +14 -233
  103. package/scripts/publish-check/git_history.py +2 -2
  104. package/scripts/publish-check/gitleaks.toml +1 -1
  105. package/scripts/publish-check/history_scan.py +6 -4
  106. package/scripts/publish-check/hygiene_config.py +3 -1
  107. package/scripts/publish-check/public_hygiene.py +29 -5
  108. package/scripts/publish-check/publication-config.json +9 -1
  109. package/scripts/publish-check/source_metadata.py +62 -0
  110. package/scripts/publish-check/source_shapes.py +91 -0
  111. package/scripts/publish-check/tree_scan.py +2 -2
  112. package/scripts/publish-check/source_hashes.mjs +0 -6
package/cli/inspect.mjs CHANGED
@@ -7,7 +7,7 @@ import { aliases, pins, requiredHooks, serverDefaults, verbs, releaseFlags } fro
7
7
  import { runningVersion } from './version.mjs';
8
8
  import { contained, readPackage } from './project.mjs';
9
9
  import { shared } from './shared.mjs';
10
- import { missing, RerError } from './errors.mjs';
10
+ import { missing } from './errors.mjs';
11
11
  import { projectNodePin } from './node-runtime-discovery.mjs';
12
12
  import { localSettingKeys, requiredLocalSettings } from './settings.mjs';
13
13
  import { localAiDiagnostics } from './ai-local.mjs';
@@ -18,6 +18,7 @@ import { commandArgv, commandLine, declaredPackage, exactVersion, lockedPackage,
18
18
  import { vendoredCalls } from './package-calls.mjs';
19
19
  import { doorProtocol } from './door.mjs';
20
20
  import { savedOrigin } from './door-mode.mjs';
21
+ import { probePnpm } from './pnpm-probe.mjs';
21
22
 
22
23
  // cliVersion: the rer version of the code answering (absent for a vendored copy
23
24
  // too old to record one). defaults: what a server start applies when the person
@@ -31,7 +32,16 @@ export async function describe(project) {
31
32
  };
32
33
  const capabilities = project.adapter?.verbs ?? {};
33
34
  return { contractVersion: 1, ...versioned, profile: 'first-party', defaults, pins: { node: pkg.engines?.node, pnpm: pkg.packageManager?.replace(/^pnpm@/, '') },
34
- verbs: Object.fromEntries(Object.entries(capabilities).map(([verb, capability]) => [verb, { flags: (verbs[verb]?.flags ?? releaseFlags).filter(flag => capability.flags?.includes(flag)), effects: verbs[verb]?.effects ?? 'Existing Base release producer.' }])),
35
+ verbs: Object.fromEntries(Object.entries(capabilities).map(([verb, capability]) => {
36
+ const adapterEffects = capability.effects;
37
+ const operationsEffects = project.descriptor.commands?.[verb]?.effects;
38
+ const effects = typeof adapterEffects === 'string' && adapterEffects.trim()
39
+ ? adapterEffects
40
+ : typeof operationsEffects === 'string' && operationsEffects.trim()
41
+ ? operationsEffects
42
+ : verbs[verb]?.effects ?? 'Produce releases; keygen writes a public anchor and, on success, its private key to non-terminal stdout; sign writes a detached signature and prints (but does not execute) the GitHub upload command; receipt verifies against local anchors offline.';
43
+ return [verb, { flags: (verbs[verb]?.flags ?? releaseFlags).filter(flag => capability.flags?.includes(flag)), effects }];
44
+ })),
35
45
  aliases: Object.fromEntries(Object.entries(aliases).filter(([alias, verb]) => pkg.scripts?.[alias] === commandLine(verb, declaredPackage(pkg) !== undefined))),
36
46
  local: { state: project.adapter?.state, settings: project.adapter?.settings ?? [], installerState: '.seed/installer', outbox: '/__dev/outbox', ...await localState(project), origin: await savedOrigin(project), ...(project.adapter?.devIdentity ? { devIdentity: true, devKey: '.rer/local/dev-key.env' } : {}) },
37
47
  // This pin serves a checkout through the machine's front door (rer door).
@@ -137,25 +147,20 @@ async function packageClauses(root, pkg) {
137
147
  export async function tools(root, store, signal) {
138
148
  const node = await projectNodePin(root);
139
149
  if (node && process.versions.node !== node) throw missing(`Node ${node} is required. Run it with rer, with automatic project runtime selection enabled.`);
140
- const found = await probePnpm(root, store, signal).catch(error => { if (signal?.aborted) throw error; return null; });
141
- if (found === pins.pnpm) return;
142
- const { cachedPnpm, downloadPnpm } = await import('./pnpm-runtime.mjs');
143
- let directory = await cachedPnpm(pins.pnpm);
144
- if (!directory) {
145
- store.report?.(`Fetching pnpm ${pins.pnpm} for this project (PATH has ${found ? `pnpm ${found}` : 'no pnpm'}); rer keeps it in its own cache.`);
146
- try { directory = await downloadPnpm(pins.pnpm, signal); }
147
- catch (error) { if (signal?.aborted || error instanceof RerError) throw error; throw missing(`pnpm ${pins.pnpm} is required and could not be fetched (${error.message}). Check the connection, or install pnpm ${pins.pnpm} yourself.`); }
148
- }
149
- usePnpm(directory);
150
- if (await probePnpm(root, store, signal) !== pins.pnpm) throw missing(`pnpm ${pins.pnpm} is required. The pnpm rer provides did not report it; install pnpm ${pins.pnpm} yourself.`);
150
+ const { ensureProjectPnpm } = await import('./project-pnpm.mjs');
151
+ await ensureProjectPnpm(root, { signal, store, report: text => store.report?.(text) });
151
152
  }
152
- const usePnpm = directory => { process.env.PATH = [dirname(process.execPath), directory, process.env.PATH].filter(Boolean).join(delimiter); };
153
+ const usePnpm = directory => { process.env.PATH = [directory, dirname(process.execPath), process.env.PATH].filter(Boolean).join(delimiter); };
153
154
 
154
155
  export async function doctor(project, store, signal) {
155
156
  const { inspectOperations } = await import('../operations/operations.mjs');
156
157
  const diagnostic = await inspectOperations(project.root);
157
158
  const { localVariables } = await shared('local/environment.mjs');
158
- let values = {}, pnpm, stateValid = true;
159
+ let values = {}, pnpm, expectedPnpm, pnpmIssue, stateValid = true;
160
+ if (project.profile === 'first-party') {
161
+ try { expectedPnpm = await (await import('./project-pnpm.mjs')).projectPnpmPin(project.root); }
162
+ catch (error) { pnpmIssue = error.message; }
163
+ }
159
164
  if (project.profile === 'first-party') {
160
165
  const { selectLocalState } = await shared('local/state.mjs');
161
166
  try { await selectLocalState(project.root, project.adapter.state); } catch { stateValid = false; }
@@ -170,8 +175,8 @@ export async function doctor(project, store, signal) {
170
175
  } catch {}
171
176
  try { pnpm = await probePnpm(project.root, store, signal); } catch (error) { if (signal?.aborted) throw error; }
172
177
  // A pnpm rer already provides counts; doctor never fetches one.
173
- if (pnpm !== pins.pnpm) {
174
- const directory = await (await import('./pnpm-runtime.mjs')).cachedPnpm(pins.pnpm).catch(() => undefined);
178
+ if (expectedPnpm && pnpm !== expectedPnpm) {
179
+ const directory = await (await import('./pnpm-runtime.mjs')).cachedPnpm(expectedPnpm).catch(() => undefined);
175
180
  if (directory) { usePnpm(directory); try { pnpm = await probePnpm(project.root, store, signal); } catch (error) { if (signal?.aborted) throw error; } }
176
181
  }
177
182
  }
@@ -181,27 +186,14 @@ export async function doctor(project, store, signal) {
181
186
  const keys = localSettingKeys(project);
182
187
  const inputs = project.profile === 'first-party' ? requiredLocalSettings(project, values) : [];
183
188
  const warnings = project.profile === 'first-party' ? await cacheWarnings(project.root) : [];
184
- return { dependencies, warnings, ok: !aiIssues.length && dependencies.every(d=>d.ok) && diagnostic.ok && stateValid && !inputs.length && (project.profile !== 'first-party' || (process.versions.node === expectedNode && pnpm === pins.pnpm)),
189
+ return { dependencies, warnings, ok: !aiIssues.length && dependencies.every(d=>d.ok) && diagnostic.ok && stateValid && !inputs.length && (project.profile !== 'first-party' || (Boolean(expectedPnpm) && process.versions.node === expectedNode && pnpm === expectedPnpm)),
185
190
  node: { actual: process.versions.node, expected: expectedNode },
186
- pnpm: project.profile === 'first-party' ? { actual: pnpm, expected: pins.pnpm } : undefined,
191
+ pnpm: project.profile === 'first-party' ? { actual: pnpm, expected: expectedPnpm ?? null, ...(pnpmIssue ? { issue: pnpmIssue } : {}) } : undefined,
187
192
  ai: { issues: aiIssues },
188
193
  missingInputs: [...new Set([...diagnostic.missingEnvironment, ...inputs])],
189
194
  sources: project.descriptor.environmentSources ?? {}, state: { path: project.adapter?.state, safe: stateValid }, scope: 'local-inputs-only' };
190
195
  }
191
196
 
192
- // Diagnostics and execution use the same identity-checked process custody.
193
- // Exit starts cleanup even if a descendant still holds the output pipes.
194
- async function probePnpm(root, store, signal) {
195
- const probeSignal = AbortSignal.any([signal, AbortSignal.timeout(10000)].filter(Boolean));
196
- const child = store.spawn('Checking pinned pnpm', 'pnpm', ['--version'], { cwd: root, signal: probeSignal });
197
- let version = '';
198
- child.stdout.on('data', chunk => { version = (version + chunk).slice(-1024); });
199
- const code = await new Promise(resolve => { child.once('error', () => resolve(null)); child.once('exit', code => resolve(code)); });
200
- await store.stop(child);
201
- if (probeSignal.aborted || code !== 0) throw missing('The pinned pnpm version probe failed. Select the documented tool.');
202
- return version.trim();
203
- }
204
-
205
197
  // SC-05: a seed declaring devIdentity loads the dev layer only from a local-only
206
198
  // entry that its dev configuration names, never from the deployed main, lets
207
199
  // rer build scan its output, and keeps one end-to-end email-link test.
package/cli/lifecycle.mjs CHANGED
@@ -19,7 +19,7 @@ import { announceDoorRoute, routeRunning, verifyDoorFor } from './door-mode.mjs'
19
19
  export async function runCommand(project, options, out) {
20
20
  if(project.profile==='first-party'&&['dev','preview'].includes(options.verb))return (await import('./managed-server.mjs')).managedServer(project,options,out);
21
21
  const cancellation = new AbortController(), signal = cancellation.signal;
22
- let termination, operation, store, temporary, proxy;
22
+ let termination, operation, store, temporary, proxy, failed = false;
23
23
  const handlers = new Map(['SIGINT', 'SIGTERM'].map(name => [name, () => { if (!termination) { termination = name; cancellation.abort(name); } }]));
24
24
  for (const [name, handler] of handlers) process.on(name, handler);
25
25
  try {
@@ -85,8 +85,10 @@ export async function runCommand(project, options, out) {
85
85
  if (['export', 'import'].includes(options.verb)) return await transfer(project, options, signal, { store, operationToken: operation.token });
86
86
  if (options.verb === 'deploy') {
87
87
  if (typeof adapter.deploy !== 'function') throw refuse('Owner-account deploy declaration is missing.');
88
- const deployed = await adapter.deploy({ root: project.root, options, store, signal, operationToken: operation.token, localOnlyKeys: adapter.localOnlyKeys ?? [], checkBuild: () => refuseDevLayerOutput(project.root, adapter.devIdentity) });
89
- if(deployed.deployed && !options.dryRun) await linkDependencies({project,input:await savedSetupAnswers(project,project.setupSeed.model),origin:deployed.origin,store,signal,appSetupToken:process.env[project.setupManifest?.steps.find(step=>step.type==='owner-claim')?.tokenField??'SETUP_TOKEN']});
88
+ const link = async (origin, appSetupToken) => linkDependencies({project,input:project.setupSeed?await savedSetupAnswers(project,project.setupSeed.model):undefined,origin,store,signal,appSetupToken});
89
+ const deployed = await adapter.deploy({ root: project.root, options, store, signal, operationToken: operation.token, localOnlyKeys: adapter.localOnlyKeys ?? [], checkBuild: () => refuseDevLayerOutput(project.root, adapter.devIdentity), setupManifest: project.setupManifest, linkDependencies: link });
90
+ // Base's owner-account deploy links while its setup token is live; another deploy hook is linked here.
91
+ if(deployed.deployed && !options.dryRun && !deployed.dependenciesLinked) await link(deployed.origin,process.env[project.setupManifest?.steps.find(step=>step.type==='owner-claim')?.tokenField??'SETUP_TOKEN']);
90
92
  return deployed;
91
93
  }
92
94
  // Setup writes the checkout's dev key; its temporary app and the CLI's own
@@ -135,10 +137,18 @@ export async function runCommand(project, options, out) {
135
137
  } else if (options.verb !== 'build') throw refuse('The requested first-party capability is unavailable.');
136
138
  return {};
137
139
  } catch (error) {
140
+ failed = true;
138
141
  if (error instanceof RerError) throw error;
139
142
  throw await lifecycleError(error, store);
140
143
  } finally {
141
- await store?.closeCommands(); await proxy?.close(); await operation?.release();
144
+ // Every closer runs: one that fails must not leave the job-queue policy socket
145
+ // listening (the process would never exit) or the local operation held, and
146
+ // must not turn a finished command into a failure or hide the real one.
147
+ const unclosed = [];
148
+ for (const close of [() => store?.closeCommands(), () => proxy?.close(), () => operation?.release()]) {
149
+ try { await close(); } catch (error) { unclosed.push(store?.redact?.(String(error?.message)) ?? 'unknown cause'); }
150
+ }
151
+ if (unclosed.length && !failed) out.progress(`Local cleanup could not confirm that every helper process stopped (${unclosed.join('; ')}). The ${options.verb} itself finished.\n`);
142
152
  if (temporary) await rm(temporary, { recursive: true, force: true });
143
153
  for (const [name, handler] of handlers) process.removeListener(name, handler);
144
154
  if (termination) throw new RerError('cancelled', `Stopped by ${termination} after owned children were stopped.`, termination === 'SIGINT' ? 130 : 143);
package/cli/main.mjs CHANGED
@@ -27,6 +27,7 @@ export async function main(argv = process.argv.slice(2)) {
27
27
  if (project.profile === 'independent' && !['describe', 'check', 'doctor'].includes(options.verb)) options = parse(argv, process.cwd(), { independent: true });
28
28
  else if (options.forwarded.length) throw invalid('First-party commands accept only the documented flags, without forwarded arguments.');
29
29
  out = output(options);
30
+ if (project.profile === 'first-party' && options.detach && ['dev','preview'].includes(options.verb)) return (await import('./detached-server.mjs')).detachedServer(options, argv, out);
30
31
  if (options.verb === 'describe') { const result = await describe(project); out.human(options.json ? JSON.stringify(result) : JSON.stringify(result, null, 2)); return 0; }
31
32
  if (options.verb === 'check') {
32
33
  const result = await check(project, options.profile);
@@ -131,8 +131,14 @@ export async function managedServer(project,options,out,{clock={}}={}){
131
131
  const outbox=await outboxServed(selected.url,devKey,controller.signal);
132
132
  if(options.verb==='dev'&&project.adapter.site){
133
133
  const {devSite}=await import('./site.mjs');
134
- site=await devSite(project,store,controller.signal,{host:selected.host,port:selected.port});
135
- await registry.update({siteUrl:site.url});
134
+ try {
135
+ site=await devSite(project,store,controller.signal,{host:selected.host,port:selected.port});
136
+ await registry.update({siteUrl:site.url});
137
+ } catch(error) {
138
+ if(error.code!=='site_private_data')throw error;
139
+ const message=`Run site preview refused: ${error.message} The app is running; fix the site and run rer build --site before publishing it.`;
140
+ if(options.json)out.event('warning',{code:error.code,message});else out.progress(`Warning: ${message}\n`);
141
+ }
136
142
  }
137
143
  activity.ready();await emitReady(out,options,project.root,selected.url,dev,outbox,site?.url);
138
144
  if(crons.length){
@@ -147,8 +153,9 @@ export async function managedServer(project,options,out,{clock={}}={}){
147
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);}
148
154
  }
149
155
  function reportScheduled(out,options,run){
150
- const at=new Date().toISOString().replace(/\.\d+Z$/,'Z'),outcome=run.skipped?`skipped (${run.error})`:run.ok?`ok ${run.ms}ms`:`failed (${run.status?`HTTP ${run.status}`:run.error}) ${run.ms}ms`;
151
- if(options.json)out.event('scheduled',{at,cron:run.cron,ok:run.ok,skipped:Boolean(run.skipped),status:run.status,ms:run.ms});else out.human(`RER_SCHEDULED ${at} "${run.cron}" ${outcome}`);
156
+ 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;
157
+ const at=new Date().toISOString().replace(/\.\d+Z$/,'Z'),outcome=run.skipped?`skipped (${error})`:run.ok?`ok ${run.ms}ms`:`failed (${run.status?`HTTP ${run.status}`:error}) ${run.ms}ms`;
158
+ if(options.json)out.event('scheduled',{at,cron:run.cron,ok:run.ok,skipped:Boolean(run.skipped),status:run.status,ms:run.ms,...(error?{error}:{})});else out.human(`RER_SCHEDULED ${at} "${run.cron}" ${outcome}${error&&run.status?` — ${error}`:''}`);
152
159
  }
153
160
  // GET, not HEAD: a seed's outbox route need not answer HEAD. The body is never read.
154
161
  async function outboxServed(url,devKey,signal){
@@ -169,7 +176,7 @@ async function emitReady(out,options,root,url,dev,outbox,siteUrl){
169
176
  }
170
177
  async function devReady(url,key,options,signal){
171
178
  if(options.verb==='preview')return {openUrl:openUrl(url,key,{path:'/__dev/key',next:'/__dev/outbox'})};
172
- if(options.login)return {openUrl:openUrl(url,key,{next:'/__dev/outbox'})};
179
+ if(options.login)return {openUrl:openUrl(url,key,{path:'/__dev/key',next:'/__dev/outbox'})};
173
180
  const as=options.as??'owner',persona=await resolvePersona(url,key,as,signal);
174
181
  // The owner persona names the owner the session will actually have (the
175
182
  // remaining owner once the setup owner has left), or none when none is left.
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', '--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']);
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.
@@ -45,7 +45,8 @@ export function parse(argv, cwd = process.cwd(), { independent = false, deferTai
45
45
  }
46
46
  if (options.help) return options;
47
47
  if (deferTail) { options.cwd=resolve(cwd,options.cwd);return options; }
48
- if(options.stop && ['--port','--host','--idle-timeout','--no-queue','--as','--login','--models','--models-service','--door','--direct'].some(flag=>options.supplied.has(flag)))throw invalid('--stop cannot be combined with start-only flags.');
48
+ if (options.detach && options.foreground) throw invalid('Choose one of --detach and --foreground.');
49
+ if(options.stop && ['--port','--host','--idle-timeout','--detach','--foreground','--no-queue','--as','--login','--models','--models-service','--door','--direct'].some(flag=>options.supplied.has(flag)))throw invalid('--stop cannot be combined with start-only flags.');
49
50
  // --port and --host always mean direct mode; --door and --direct pick a mode.
50
51
  if(options.door&&options.direct)throw invalid('Choose one of --door and --direct.');
51
52
  if(options.door&&['--port','--host'].some(flag=>options.supplied.has(flag)))throw invalid('--door cannot be combined with --port or --host, which always serve directly.');
@@ -71,6 +72,8 @@ export function parse(argv, cwd = process.cwd(), { independent = false, deferTai
71
72
  if (options.target !== undefined && !['local', 'remote'].includes(options.target)) throw invalid('Target must be local or remote.');
72
73
  if (options.profile !== undefined && options.profile !== 'first-party') throw invalid('The supported explicit profile is first-party.');
73
74
  if (options.environment !== undefined && !/^[a-z][a-z0-9-]{0,39}$/.test(options.environment)) throw invalid('Provide a valid deployment environment name.');
75
+ 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.');
76
+ 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.');
74
77
  if (options.origin !== undefined) {
75
78
  let url; try { url = new URL(options.origin); } catch { throw invalid('Provide a valid credential-free origin.'); }
76
79
  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,14 @@
1
+ import { missing } from './errors.mjs';
2
+
3
+ // A version check owns its descendants just like a lifecycle command. Cleanup
4
+ // starts at leader exit, even when quiet descendants still hold the pipes.
5
+ export async function probePnpm(root, store, signal, env = process.env) {
6
+ const probeSignal = AbortSignal.any([signal, AbortSignal.timeout(10000)].filter(Boolean));
7
+ const child = store.spawn('Checking pinned pnpm', 'pnpm', ['--version'], { cwd: root, env, signal: probeSignal });
8
+ let version = '';
9
+ child.stdout.on('data', chunk => { version = (version + chunk).slice(-1024); });
10
+ const code = await new Promise(resolve => { child.once('error', () => resolve(null)); child.once('exit', code => resolve(code)); });
11
+ await store.stop(child);
12
+ if (probeSignal.aborted || code !== 0) throw missing('The pinned pnpm version probe failed. Select the documented tool.');
13
+ return version.trim();
14
+ }
@@ -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,11 @@ 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';
8
9
 
9
10
  // Seeds declare their real bindings and data policy. The shared module owns the
10
11
  // archive format and atomic restore; the shared runner owns its native children.
11
- export function localPortability({ database, binding = 'DB', buckets = {}, policy, transient = [] }) {
12
+ export function localPortability({ database, binding = 'DB', buckets = {}, policy, policyModule, transient = [] }) {
12
13
  async function run({ root, path, journal, store, signal, operationToken, platform, legacyRecovery }, direction) {
13
14
  if (direction === 'export') {
14
15
  if (await lstat(path).catch(error => { if (error.code === 'ENOENT') return null; throw error; })) throw refuse('Export requires a new archive path.');
@@ -22,6 +23,7 @@ export function localPortability({ database, binding = 'DB', buckets = {}, polic
22
23
  if(journal)for await(const record of fileRecords(journal)) {}
23
24
  }
24
25
  catch (error) {
26
+ if (error.message === 'archive_json') throw new RerError('invalid_archive', 'Restore refused (archive_json): archive JSON is invalid.', 2);
25
27
  if (!(error instanceof PortabilityError)) throw error;
26
28
  const message = error.code === 'legacy_archive_unverifiable'
27
29
  ? '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,8 +33,22 @@ export function localPortability({ database, binding = 'DB', buckets = {}, polic
31
33
  }
32
34
  }
33
35
  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 };
36
+ let declaration;
37
+ try {
38
+ if (policyModule !== undefined) {
39
+ if (policy !== undefined) throw new PortabilityError('ambiguous_policy', 409);
40
+ if (platform === 'container') throw new PortabilityError('container_policy_module_unsupported', 409);
41
+ declaration = { policyModule: await policyModuleURL(root, policyModule) };
42
+ } else declaration = { policy: serializablePolicy(policy) };
43
+ } catch (error) {
44
+ if (!(error instanceof PortabilityError)) throw error;
45
+ 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.`);
46
+ }
47
+ if(platform==='container') return (await import('./container-transfer.mjs')).containerTransfer({root,path,journal,store,signal,direction,binding,buckets,policy:declaration.policy,transient});
48
+ const input={ root, path, journal, binding, buckets, ...declaration, transient, legacyRecovery };
49
+ // Check the executable policy before migrations; the transfer worker imports
50
+ // it again in its own process so module closures are retained during restore.
51
+ if (policyModule !== undefined) await nativeWorker(store,'policy-preflight',input,signal);
36
52
  // Do not apply pending product migrations to a populated recovery target.
37
53
  // Check real existing bindings first; finalize repeats this under the lock.
38
54
  if(legacyRecovery)await nativeWorker(store,'legacy-preflight',input,signal);
@@ -48,7 +64,7 @@ export function localPortability({ database, binding = 'DB', buckets = {}, polic
48
64
 
49
65
  async function nativeWorker(store,direction,input,signal) {
50
66
  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,
67
+ 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
68
  [worker, direction, JSON.stringify(input)], { cwd: input.root, signal, env: { ...process.env, WRANGLER_SEND_METRICS: 'false' } });
53
69
  let output = '';
54
70
  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,41 @@
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; }
38
+ if (['sign', 'receipt', 'keygen'].includes(args[0])) return (await import('../modules/release/signing-cli.mjs')).dispatchSigning(args[0], args.slice(1));
3
39
  try {
4
40
  const options = {};
5
41
  for (let index = 0; index < args.length; index++) {