@runeditrun/rer 0.2.2 → 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 (86) hide show
  1. package/README.md +9 -2
  2. package/cli/README.md +21 -0
  3. package/cli/container.mjs +11 -10
  4. package/cli/contract.mjs +2 -2
  5. package/cli/demo-host.mjs +47 -0
  6. package/cli/demo.mjs +47 -0
  7. package/cli/dependencies.mjs +26 -4
  8. package/cli/deploy-command.mjs +94 -0
  9. package/cli/deploy-inputs.mjs +22 -0
  10. package/cli/deploy-observation.mjs +28 -0
  11. package/cli/deploy-owner.mjs +52 -37
  12. package/cli/deploy-readiness.mjs +16 -0
  13. package/cli/deploy.mjs +20 -10
  14. package/cli/detached-server.mjs +1 -1
  15. package/cli/export.mjs +1 -1
  16. package/cli/hosted-output.mjs +2 -1
  17. package/cli/lifecycle.mjs +20 -7
  18. package/cli/local.mjs +7 -0
  19. package/cli/main.mjs +1 -1
  20. package/cli/managed-server.mjs +27 -18
  21. package/cli/parse.mjs +7 -2
  22. package/cli/pnpm-probe.mjs +4 -1
  23. package/cli/portability.mjs +4 -1
  24. package/cli/scheduled.mjs +2 -2
  25. package/cli/setup-input.mjs +1 -1
  26. package/global/CHANGELOG.md +46 -0
  27. package/global/README.md +9 -2
  28. package/global/launch/capacity.mjs +1 -1
  29. package/global/launch/facts.mjs +12 -6
  30. package/global/launch/install-pinned.mjs +26 -2
  31. package/global/launch/prepare.mjs +20 -8
  32. package/global/launch/state.mjs +54 -1
  33. package/global/main.mjs +18 -20
  34. package/global/update/command.mjs +2 -1
  35. package/modules/container/build.mjs +4 -2
  36. package/modules/container/host.mjs +1 -1
  37. package/modules/container/ingress.mjs +4 -1
  38. package/modules/container/migrations.mjs +2 -1
  39. package/modules/container/worker-engine.mjs +3 -1
  40. package/modules/demo-host/README.md +30 -0
  41. package/modules/demo-host/capacity.mjs +19 -0
  42. package/modules/demo-host/claim.mjs +21 -0
  43. package/modules/demo-host/container.mjs +74 -0
  44. package/modules/demo-host/pages.mjs +56 -0
  45. package/modules/demo-host/plan.mjs +52 -0
  46. package/modules/demo-host/worker.mjs +72 -0
  47. package/modules/deploy-kit/README.md +27 -0
  48. package/modules/deploy-kit/env-file.mjs +2 -1
  49. package/modules/deploy-kit/health-request.mjs +7 -2
  50. package/modules/deploy-kit/migration-ledger.mjs +38 -1
  51. package/modules/deploy-kit/migrations.mjs +38 -2
  52. package/modules/deploy-kit/owner-cli.mjs +30 -9
  53. package/modules/deploy-kit/owner-remove.mjs +5 -2
  54. package/modules/deploy-kit/owner-secret.mjs +67 -22
  55. package/modules/deploy-kit/owner.mjs +71 -24
  56. package/modules/deploy-kit/prepare.mjs +3 -1
  57. package/modules/deploy-kit/provider.mjs +3 -0
  58. package/modules/erasure/README.md +2 -3
  59. package/modules/operator-auth/accounts/settings-page.mjs +8 -3
  60. package/modules/operator-auth/csp.mjs +26 -0
  61. package/modules/operator-auth/dev/badge.mjs +1 -22
  62. package/modules/operator-auth/page.mjs +2 -2
  63. package/modules/operator-auth/routes.mjs +1 -1
  64. package/modules/portability/README.md +9 -0
  65. package/modules/portability/bridge.mjs +4 -1
  66. package/modules/portability/catalogue.mjs +9 -2
  67. package/modules/portability/index.d.mts +4 -0
  68. package/modules/portability/index.mjs +1 -0
  69. package/modules/portability/lines.mjs +3 -1
  70. package/modules/release/produce.mjs +1 -1
  71. package/modules/release/source.mjs +5 -2
  72. package/onboarding/adapter-labels.mjs +1 -0
  73. package/onboarding/adapter.mjs +9 -4
  74. package/onboarding/dependency-environment.mjs +33 -0
  75. package/onboarding/local/owner.mjs +1 -1
  76. package/onboarding/operator-installer.mjs +10 -4
  77. package/onboarding/redaction.mjs +22 -3
  78. package/package.json +6 -1
  79. package/scripts/publish-check/README.md +22 -1
  80. package/scripts/publish-check/c2pa.py +121 -0
  81. package/scripts/publish-check/content_identities.py +3 -1
  82. package/scripts/publish-check/controller_metadata.py +31 -0
  83. package/scripts/publish-check/history_scan.py +13 -3
  84. package/scripts/publish-check/media.py +283 -0
  85. package/scripts/publish-check/png_text.py +34 -0
  86. package/scripts/publish-check/public_hygiene.py +39 -4
package/cli/deploy.mjs CHANGED
@@ -6,8 +6,9 @@ import { missing, invalid, refuse, RerError } from './errors.mjs';
6
6
  import { ownerPlan, ownerWorkerName, localOnlyBinding } from '../modules/deploy-kit/owner.mjs';
7
7
  import { packageBuild } from '../modules/deploy-kit/prepare.mjs';
8
8
  import { refuseLocalModels } from '../modules/deploy-kit/local-only.mjs';
9
- import { deployEnvironment } from './deploy-inputs.mjs';
9
+ import { deployEnvironment, deployAgain } from './deploy-inputs.mjs';
10
10
  import { claimHostedOwner } from './deploy-owner.mjs';
11
+ import { mutationReadinessFailure } from './deploy-readiness.mjs';
11
12
 
12
13
  export function ownerDeploy(declarationPath, lifecycle) {
13
14
  // rer verify serves the build with this declaration's vars and secrets.
@@ -18,12 +19,14 @@ export function ownerDeploy(declarationPath, lifecycle) {
18
19
  const local = localOnlyBinding(declaration, localOnlyKeys);
19
20
  if (local) throw refuse(`${declarationPath} names the local-only key ${local} as a deploy binding or secret. Remove it; local dev keys never reach a deployment.`);
20
21
  const environment = options.environment ?? 'production';
21
- const again = `rer deploy --environment ${environment}`;
22
+ const again = deployAgain(options);
22
23
  const change = [options.remove && '--remove', options.setSecret && '--set-secret', options.unsetSecret && '--unset-secret'].filter(Boolean);
23
24
  // The command that finishes what this run started: the same verb, never a plain deploy in its place.
24
25
  const retry = options.remove ? `${again} --remove --yes` : options.setSecret ? `${again} --set-secret ${options.setSecret}` : options.unsetSecret ? `${again} --unset-secret ${options.unsetSecret}` : again;
25
26
  if (change.length > 1 || change.length && options.dryRun) throw invalid(`Use ${change.join(', ')}${options.dryRun ? ' and --dry-run' : ''} on separate runs.`);
26
27
  if (options.remove && !options.yes) throw invalid(`${again} --remove deletes this environment's Worker and every D1, R2, KV and queue rer created for it, with their data. Add --yes to confirm.`);
28
+ // A dry run reads the settings file too, so a refused key fails here and not only on the real deploy.
29
+ if (options.dryRun && options.envFile) await deployEnvironment({ root, options, declaration, localOnlyKeys });
27
30
  if (!change.length) {
28
31
  await lifecycle.run(root, {}, store, () => {}, signal, { verb: 'build', operationToken });
29
32
  await checkBuild();
@@ -37,9 +40,9 @@ export function ownerDeploy(declarationPath, lifecycle) {
37
40
  store.report('deploy: dry run complete; no deployment occurred and no provider request was made.');
38
41
  return { dryRun: true, deployed: false };
39
42
  }
40
- const names = ['CLOUDFLARE_ACCOUNT_ID', 'CLOUDFLARE_API_TOKEN'];
41
- if (names.some(name => !process.env[name]?.trim())) throw missing('CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_API_TOKEN are required. See the deploy-kit owner-account instructions.');
42
43
  const { env, supplied } = await deployEnvironment({ root, options, declaration, setupManifest, settings: lifecycle.settings, localOnlyKeys });
44
+ const names = ['CLOUDFLARE_ACCOUNT_ID', 'CLOUDFLARE_API_TOKEN'];
45
+ if (names.some(name => !env[name]?.trim())) throw missing('CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_API_TOKEN are required. See the deploy-kit owner-account instructions.');
43
46
  const { main } = await import('../modules/deploy-kit/owner-cli.mjs');
44
47
  const { createRedaction, credentialValues } = await shared('redaction.mjs');
45
48
  const redaction = createRedaction();
@@ -61,35 +64,40 @@ export function ownerDeploy(declarationPath, lifecycle) {
61
64
  step = 'secrets';
62
65
  const binding = options.setSecret ?? options.unsetSecret;
63
66
  await owner('secret', [options.setSecret ? '--set' : '--unset', binding]);
64
- store.report(`${options.setSecret ? `Set ${binding}` : `Removed ${binding}`} on this environment's Worker and recorded its new version; the next ${again} updates it.`);
67
+ store.report(`${options.setSecret ? `Set ${binding}` : `Removed ${binding}`} on this environment's Worker, recorded its new version and verified hosted readiness.`);
65
68
  return { binding, present: Boolean(options.setSecret), deployed: false };
66
69
  }
70
+ store.report(`Deploying the ${environment} environment to your Cloudflare account.`);
67
71
  deployed = await owner('deploy', [], env, { notice: line => store.report(line), onCreated: resource => created.push(redaction.redact(resource)) });
68
72
  const sent = supplied.filter(name => deployed.sentSecrets.includes(name));
69
73
  if (sent.length) store.report(`rer supplied ${sent.join(', ')} from the local setup owner and fresh generated values.`);
70
74
  step = 'health';
71
75
  await owner('health');
76
+ store.report(`Your application is live on your Cloudflare account at ${deployed.origin}.`);
72
77
  step = 'claim owner';
78
+ const { liveSecrets, retiredSecrets, ownerClaim } = await owner('inspect');
73
79
  const linked = token => linkDependencies?.(deployed.origin, token);
74
- const claimed = await claimHostedOwner({ hook: lifecycle.hooks?.owner, setupManifest, declaration, deployed, env, supplied, addSecret: value => redaction.addSecrets([value]),
75
- secret: (extra, values) => owner('secret', extra, values), beforeClaim: linked });
80
+ const claimed = await claimHostedOwner({ hook: lifecycle.hooks?.owner, setupManifest, declaration, deployed: { ...deployed, liveSecrets, retiredSecrets, ownerClaim }, env, supplied, addSecret: value => { redaction.addSecrets([value]); store.addSecrets?.([value]); },
81
+ secret: (extra, values) => owner('secret', extra, values), beforeClaim: linked, recordClaim: outcome => owner('record-owner', ['--outcome', outcome]), inspect: () => owner('inspect'), store, signal });
76
82
  // A seed that claims its owner its own way links with the token it was given.
77
83
  if (claimed === undefined) await linked(env[declared(claim?.tokenField ?? 'SETUP_TOKEN')?.environment]);
78
84
  // The app claims the OWNER_EMAIL the Worker holds: the local one only when this run uploaded it.
79
85
  const email = declared(claim?.emailField);
80
86
  const named = !email ? '' : deployed.sentSecrets.includes(email.binding) && env[email.environment] ? ` (${env[email.environment]})` : ` (the ${email.binding} already set on the Worker)`;
81
- if (claimed) store.report(`${claimed === 'claimed' ? 'Claimed the hosted owner' : 'The hosted owner was already claimed and was kept'}${named}; the one-time setup token was used and removed. Sign in by email at ${deployed.origin}.`);
87
+ if (claimed) store.report(`${claimed === 'claimed' ? 'Claimed the hosted owner' : 'The hosted owner was already claimed and was kept'}${named}; the one-time setup token was removed. Sign in by email at ${deployed.origin}.`);
82
88
  return { deployed: true, origin: deployed.origin, ...(claimed ? { owner: claimed } : {}), dependenciesLinked: true };
83
89
  } catch (error) {
84
90
  if (error instanceof RerError) throw error;
85
91
  const { commandFailure } = await shared('failures.mjs');
86
92
  throw commandFailure(failureText({ error, step, created, again, retry, secretChange: Boolean(change.length && !options.remove), deployed, redact: redaction.redact }));
87
93
  }
88
- }, { declaration: declarationPath });
94
+ }, { declaration: declarationPath, managesHostedOwner: true });
89
95
  }
90
96
 
91
97
  // The failing step, its redacted cause, what this run created and the command that continues.
92
98
  function failureText({ error, step, created, again, retry, secretChange, deployed, redact }) {
99
+ const readiness = mutationReadinessFailure(error, { again, redact });
100
+ if (readiness) return readiness;
93
101
  const diagnostic = deployDiagnostic(error, redact);
94
102
  const code = diagnostic?.code ?? (/^[a-zA-Z0-9_-]{1,120}$/.test(error.message) ? redact(error.message) : 'deploy-failed');
95
103
  const at = step === 'deploy' ? diagnostic?.step ?? 'prepare deploy' : step;
@@ -97,8 +105,10 @@ function failureText({ error, step, created, again, retry, secretChange, deploye
97
105
  if (input) return `Deploy needs ${input}, which the deploy declaration lists as a secret and the Worker does not hold it. Set ${input} in the environment or in an --env-file file (an owned 0600 file of KEY=VALUE lines), then run the same command again. Nothing was uploaded.${created.length ? ` Resources created this attempt: ${created.join(', ')}.` : ''}`;
98
106
  if (code === 'remove-incomplete') return `Removal stopped with ${error.remaining.join(', ')} still present${error.failed.length ? ` (${error.failed.map(item => `${item.resource}: ${item.code}`).join('; ')})` : ''}. Removed: ${error.removed.join(', ') || 'nothing'}. An R2 bucket must be empty before it can be deleted. Run ${again} --remove --yes again to finish.`;
99
107
  if (code === 'nothing-to-remove') return `This checkout has no rer deployment record for this environment, so --remove deletes nothing. Run it from the checkout that deployed it.`;
100
- if (code === 'owner-claim-failed') return `The application is deployed at ${deployed?.origin}, but claiming its owner failed: ${redact(String(error.cause))}. The setup token is still on the Worker; run ${again} again to retry the claim.`;
108
+ if (code === 'owner-token-retirement-failed') return `The application is deployed at ${deployed?.origin}; owner ${error.owner}; setup token retirement failed (${redact(String(error.cause))}). Run ${again} --unset-secret ${error.tokenKey ?? 'SETUP_TOKEN'} using the same environment credentials, or run ${again} again to finish.`;
109
+ if (code === 'owner-claim-failed') return `The application is deployed at ${deployed?.origin}, but claiming its owner failed: ${redact(String(error.cause))}. Owner claim and setup token retirement are not confirmed; run ${again} again using the same environment credentials to finish.`;
101
110
  const resource = diagnostic?.resource ?? (error.resource ? redact(String(error.resource)) : undefined);
111
+ if (code === 'resource-not-listed') return `Deploy stopped at ${at}: ${resource} was created, but Cloudflare's account listing still does not show it after two minutes. Nothing was uploaded.\nResources created this attempt: ${created.join(', ') || 'none'}.\nRun ${again} again once the listing catches up: it reuses ${resource}. To delete what this environment created instead: ${again} --remove --yes.`;
102
112
  const lines = [`Deploy failed at ${at}: ${code}${resource ? ` (${resource})` : ''}${diagnostic?.providerCodes?.length ? ` (provider ${diagnostic.providerCodes.join(', ')})` : ''}.`];
103
113
  if (diagnostic?.output && diagnostic.output !== '(no provider output)') lines.push(`First redacted output lines:\n${diagnostic.output}`);
104
114
  lines.push(`Resources created this attempt: ${created.join(', ') || 'none'}.`);
@@ -35,7 +35,7 @@ export async function detachedServer(options, argv, out) {
35
35
  if (event.event==='ready') {
36
36
  keep=true;child.unref();
37
37
  const dev = event.openUrl ? {openUrl:event.openUrl,identity:options.verb === 'dev' && !options.login,signedInAs:event.signedInAs,ownerLeft:options.verb === 'dev' && !options.login && event.signedInAs === null} : undefined;
38
- ready(out,event.url,dev,options.json,Boolean(event.signInUrl),event.siteUrl);
38
+ ready(out,event.url,dev,options.json,Boolean(event.signInUrl),event.siteUrl,event.demo);
39
39
  return 0;
40
40
  }
41
41
  if (event.event==='waiting' || event.event==='warning') out.event(event.event,event);
package/cli/export.mjs CHANGED
@@ -6,7 +6,7 @@ import { cliVersion } from './version.mjs';
6
6
 
7
7
  // Copy whole maintained source/tooling directories. Python publication scans
8
8
  // load sibling modules and data dynamically; they belong in the inventory too.
9
- export const sourceRoots = ['bin/rer.mjs', 'cli', 'modules/job-queue', 'modules/release', 'modules/deploy-kit', 'modules/container', 'modules/integrations/core', 'modules/mail', 'modules/ai', 'modules/portability', 'modules/erasure', 'modules/dependencies', 'modules/operator-auth', 'modules/owner-claim', 'operations', 'scripts/publish-check', 'scripts/publish-check.mjs', 'composition/git-source.mjs', 'edition-authoring/manifest.mjs', 'edition-authoring/files.mjs'];
9
+ export const sourceRoots = ['bin/rer.mjs', 'cli', 'modules/job-queue', 'modules/release', 'modules/deploy-kit', 'modules/container', 'modules/demo-host', 'modules/integrations/core', 'modules/mail', 'modules/ai', 'modules/portability', 'modules/erasure', 'modules/dependencies', 'modules/operator-auth', 'modules/owner-claim', 'operations', 'scripts/publish-check', 'scripts/publish-check.mjs', 'composition/git-source.mjs', 'edition-authoring/manifest.mjs', 'edition-authoring/files.mjs'];
10
10
  export async function inventory(base, roots = sourceRoots) {
11
11
  const files = [];
12
12
  async function walk(path) {
@@ -6,6 +6,7 @@ import { pathToFileURL } from 'node:url';
6
6
  import { RUN_WORKER_FIRST } from '../modules/deploy-kit/render.mjs';
7
7
  import { publicAssetPaths } from '../modules/deploy-kit/artifact.mjs';
8
8
  import { migrate } from '../modules/container/migrations.mjs';
9
+ import { readMigrationRewrites, withRewrites } from '../modules/deploy-kit/migrations.mjs';
9
10
  import { RerError } from './errors.mjs';
10
11
 
11
12
  // Serve the production build the way rer deploy hosts it (assets behind the
@@ -76,7 +77,7 @@ export async function serveHosted(root, { declaration: declarationPath = 'deploy
76
77
  const sql = await readFile(join(directory, name), 'utf8');
77
78
  plan.push({ name, sql, sha256: createHash('sha256').update(sql).digest('hex'), statements: split(sql) });
78
79
  }
79
- await migrate(await mf.getD1Database(item.binding, 'app'), plan);
80
+ await migrate(await mf.getD1Database(item.binding, 'app'), withRewrites(plan, await readMigrationRewrites(root), { partial: true }));
80
81
  }
81
82
  } catch (error) { await mf.dispose(); throw error; }
82
83
  return { client, identity, origin: ORIGIN, close: () => mf.dispose(),
package/cli/lifecycle.mjs CHANGED
@@ -17,6 +17,16 @@ import { ensureDevKey, readDevKey, refuseDevLayerOutput, openUrl, DEV_KEY_HEADER
17
17
  import { announceDoorRoute, routeRunning, verifyDoorFor } from './door-mode.mjs';
18
18
 
19
19
  export async function runCommand(project, options, out) {
20
+ if (options.verb === 'deploy' && !options.site && !options.demo) {
21
+ const { validateDeployOptions } = await import('./deploy-command.mjs');
22
+ validateDeployOptions(options);
23
+ const { withDeployEnvironment } = await import('./deploy-inputs.mjs');
24
+ return withDeployEnvironment(project.root, options, redaction => runLifecycle(project, options, out, redaction));
25
+ }
26
+ return runLifecycle(project, options, out);
27
+ }
28
+
29
+ async function runLifecycle(project, options, out, deployRedaction) {
20
30
  if(project.profile==='first-party'&&['dev','preview'].includes(options.verb))return (await import('./managed-server.mjs')).managedServer(project,options,out);
21
31
  const cancellation = new AbortController(), signal = cancellation.signal;
22
32
  let termination, operation, store, temporary, proxy, failed = false;
@@ -59,6 +69,7 @@ export async function runCommand(project, options, out) {
59
69
  const state = { logs: [] };
60
70
  store = await createCommands(directory, state, { terminal: text => out.progress(text), onWaiting: options.json ? details => out.event('waiting', details) : undefined, queue: ['install','setup','build','verify','deploy','export','import'].includes(options.verb)?{verb:options.verb,originalCI:process.env.CI==='true'}:undefined });
61
71
  store.state = state;
72
+ if (deployRedaction) store.addSecrets?.(Object.values(process.env).filter(value => value && deployRedaction.redact(value) !== value));
62
73
  store.setupManifest = project.setupManifest;
63
74
  store.step = async label => { state.step = store.redact(label); store.report(label); };
64
75
  store.phase = async phase => { state.phase = phase; };
@@ -69,8 +80,11 @@ export async function runCommand(project, options, out) {
69
80
  const adapter = project.adapter;
70
81
  // The run site: static pages built from edition identity, never the app build.
71
82
  if (options.site) return await (await import('./site.mjs')).siteCommand(project, options, store, signal, out);
83
+ // The hosted demo: a front Worker and the seed's container image on demo.<domain>.
84
+ if (options.verb === 'deploy' && options.demo) return await (await import('./demo-host.mjs')).demoHostCommand(project, options, store, signal, out, operation);
72
85
  if (options.verb === 'install') {
73
- await store.run('Installing frozen dependencies', 'pnpm', ['install', '--frozen-lockfile'], { cwd: project.root, signal, env: { ...process.env, CI: 'true' } }); return {};
86
+ const { dependencyEnvironment } = await shared('dependency-environment.mjs');
87
+ await store.run('Installing frozen dependencies', 'pnpm', ['install', '--frozen-lockfile'], { cwd: project.root, signal, env: { ...dependencyEnvironment(), CI: 'true' } }); return {};
74
88
  }
75
89
  if (options.verb === 'verify') {
76
90
  const target = adapter.verify;
@@ -86,10 +100,7 @@ export async function runCommand(project, options, out) {
86
100
  if (options.verb === 'deploy') {
87
101
  if (typeof adapter.deploy !== 'function') throw refuse('Owner-account deploy declaration is missing.');
88
102
  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']);
92
- return deployed;
103
+ return await (await import('./deploy-command.mjs')).deployCommand({ adapter, root: project.root, options, store, signal, operationToken: operation.token, localOnlyKeys: adapter.localOnlyKeys ?? [], checkBuild: () => refuseDevLayerOutput(project.root, adapter.devIdentity), setupManifest: project.setupManifest, linkDependencies: link });
93
104
  }
94
105
  // Setup writes the checkout's dev key; its temporary app and the CLI's own
95
106
  // outbox reads present it. Captured logs redact it.
@@ -114,8 +125,10 @@ export async function runCommand(project, options, out) {
114
125
  if (['setup', 'build'].includes(options.verb)) await refuseDevLayerOutput(project.root, adapter.devIdentity);
115
126
  const url = selected?.url, signInUrl = url ? url + '/__dev/outbox' : undefined;
116
127
  if (options.verb === 'setup') {
117
- await linkDependencies({project,input,origin:url,store,signal});
118
- if(project.setupManifest?.dependencies?.length) {
128
+ // Only a connected provider leaves first setup open; a native claim with
129
+ // every optional dependency unanswered has already closed it.
130
+ const {connected}=await linkDependencies({project,input,origin:url,store,signal});
131
+ if(connected.length) {
119
132
  const proof=await localCliSessionProof(project);
120
133
  store.addSecrets?.([proof]);
121
134
  const completed=await appFetch(url+'/auth/setup-complete',{method:'POST',headers:{Origin:url,'Content-Type':'application/json','x-rer-local-cli':proof},body:'{}',signal});
package/cli/local.mjs CHANGED
@@ -73,6 +73,13 @@ export async function reset(project, options, out) {
73
73
  await refuseLegacyProcess(project,{reset:true});
74
74
  const { strandedOriginals } = await shared('local/recovery.mjs');
75
75
  if ((await strandedOriginals(project.root)).length) throw refuse('An interrupted build holds private settings. Run rer build to recover them before reviewing reset.');
76
+ // The seed's own safety check (legacy data it still has to recover, for
77
+ // example) runs before the inventory and confirmation; --yes cannot skip it.
78
+ const preflight = project.adapter.resetPreflight;
79
+ if (preflight !== undefined) {
80
+ if (typeof preflight !== 'function') throw refuse('The seed adapter declares a reset preflight that is not a function.');
81
+ try { await preflight({ root: project.root }); } catch (error) { throw refuse(`Reset refused by the seed's preflight: ${error?.message || 'no reason given'}`); }
82
+ }
76
83
  const declared = project.adapter.settings ?? [];
77
84
  for (const path of declared) if (!/^\.dev\.vars(?:\.previous)?$|^data\/[a-zA-Z0-9_-]+\.json$/.test(path)) throw refuse('Generated settings inventory is unsafe; declare specific private settings files.');
78
85
  const paths = [...new Set(['.wrangler/state', '.rer/local', '.seed/installer', ...declared])];
package/cli/main.mjs CHANGED
@@ -27,7 +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
+ if (project.profile === 'first-party' && options.detach && ['dev','preview'].includes(options.verb)) return await (await import('./detached-server.mjs')).detachedServer(options, argv, out);
31
31
  if (options.verb === 'describe') { const result = await describe(project); out.human(options.json ? JSON.stringify(result) : JSON.stringify(result, null, 2)); return 0; }
32
32
  if (options.verb === 'check') {
33
33
  const result = await check(project, options.profile);
@@ -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', '--remove']);
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
  }
@@ -73,6 +77,7 @@ export function parse(argv, cwd = process.cwd(), { independent = false, deferTai
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.');
75
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>.');
76
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.');
77
82
  if (options.origin !== undefined) {
78
83
  let url; try { url = new URL(options.origin); } catch { throw invalid('Provide a valid credential-free origin.'); }
@@ -1,10 +1,13 @@
1
1
  import { missing } from './errors.mjs';
2
+ import { shared } from './shared.mjs';
2
3
 
3
4
  // A version check owns its descendants just like a lifecycle command. Cleanup
4
5
  // starts at leader exit, even when quiet descendants still hold the pipes.
6
+ // The package manager never sees the caller's credentials.
5
7
  export async function probePnpm(root, store, signal, env = process.env) {
8
+ const { dependencyEnvironment } = await shared('dependency-environment.mjs');
6
9
  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 });
10
+ const child = store.spawn('Checking pinned pnpm', 'pnpm', ['--version'], { cwd: root, env: dependencyEnvironment(env), signal: probeSignal });
8
11
  let version = '';
9
12
  child.stdout.on('data', chunk => { version = (version + chunk).slice(-1024); });
10
13
  const code = await new Promise(resolve => { child.once('error', () => resolve(null)); child.once('exit', code => resolve(code)); });
@@ -6,6 +6,7 @@ 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
8
  import { policyModuleURL, serializablePolicy } from './portability-policy.mjs';
9
+ import { shared } from './shared.mjs';
9
10
 
10
11
  // Seeds declare their real bindings and data policy. The shared module owns the
11
12
  // archive format and atomic restore; the shared runner owns its native children.
@@ -52,8 +53,10 @@ export function localPortability({ database, binding = 'DB', buckets = {}, polic
52
53
  // Do not apply pending product migrations to a populated recovery target.
53
54
  // Check real existing bindings first; finalize repeats this under the lock.
54
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');
55
58
  await store.run('Applying local data migrations', 'pnpm', ['exec', 'wrangler', 'd1', 'migrations', 'apply', database, '--local', '--persist-to', '.wrangler/state', '--config', 'wrangler.jsonc'], {
56
- 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' },
57
60
  });
58
61
  const receipt=await nativeWorker(store,direction,input,signal);
59
62
  if(legacyRecovery)receipt.output=relative(root,legacyRecovery.output);
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;
@@ -87,7 +87,7 @@ export async function setupInput(project, options, vars, signal, { origin } = {}
87
87
  if(savedEmail&&!mailbox(savedEmail))throw refuse('Saved owner is malformed. Restore private settings or run rer reset --yes.');
88
88
  if(savedEmail&&savedEmail.toLowerCase()!==answers[names.email]){
89
89
  if(owners.length)throw refuse(`Private setting ${names.email} differs from the retained database owner. Restore ${names.email} in ${project.adapter.hooks?.secrets?.file??'.dev.vars'} to ${answers[names.email]}, then run rer setup --owner-email ${answers[names.email]}; or back up data and run rer reset --yes. Private bytes were kept.`);
90
- throw refuse(`Owner conflicts with the saved installation (${savedEmail}). Run rer setup --owner-email ${savedEmail}, or back up data and run rer reset --yes.`);
90
+ throw refuse(`Owner conflicts with the saved installation (${savedEmail}). Run rer setup --owner-email ${savedEmail}, or back up data, run rer dev --stop, then rer reset --yes followed by rer setup --owner-email ${answers[names.email]}. Reset deletes local data.`);
91
91
  }
92
92
 
93
93
  try{return seed.model.validateInput(answers,known);}catch{throw missing('Installer answers are incomplete. Supply the active manifest fields using rer setup --answers answers.json --non-interactive.');}
@@ -1,5 +1,51 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.4
4
+
5
+ 0.2.3 was tagged but not published; its changes are in 0.2.4.
6
+
7
+ - A first `rer deploy` waits up to two minutes (it was 30 seconds) for Cloudflare's account listing to show a database, bucket, namespace or queue it has just created, whether the listing disagrees with its own page counts or leaves the new resource out; one that never appears stops the deploy before any upload, names the resource, and the same command resumes with it.
8
+ - A first `rer dev` no longer chooses the port another, stopped checkout was set up on, so both checkouts keep working origins.
9
+ - Seed recovery code can check the deploy migration ledger with one call: `migrationLedgerShape(rows)` from `@runeditrun/rer/modules/deploy-kit/migration-ledger`, given the rows of its `ledgerShapeQuery`, accepts all four ledger shapes a deployment can hold (Wrangler's `d1_migrations`, that table after a kit deploy adopted it, the kit's own `d1_migrations` since 0.2.2, and the `_rer_deploy_migrations` ledger a 0.2.1 or earlier deploy left, shape `legacy`, which wins when both tables exist) and refuses any other. A seed whose recovery checks that the ledger is exactly Wrangler's three columns refuses ledgers from 0.2.2 on (`MIGRATION_MISMATCH`); replace that check with this call.
10
+ - `rer import` and hosted restores refuse an archive that records a migration this installation has not applied (`schema_mismatch`), even when that migration left the schema unchanged (an index or a data-only change); such an archive is newer than the installation.
11
+ - After a pulled change to a checkout's `@runeditrun/rer` pin, every verb (`rer deploy` included) installs the pinned version with the frozen lockfile before it runs, instead of asking you to run `rer install` first.
12
+ - Two verbs started at the same moment after a pulled pin change share one install of the new pin: the second waits for the first's install, then runs, instead of racing it and failing with "The declared adapter could not be loaded".
13
+ - The publication scan accepts AI-generated PNGs whose C2PA manifest carries the signing certificate's email address (certificate names are metadata) and phone JPEGs that store more images after the first (every image's pixels are skipped as pixels), while an address in the rest of the manifest, XMP or EXIF is still flagged.
14
+ - Dependency installs receive only the environment an install needs, never your credentials: the frozen `pnpm install` that `rer install`, a fresh clone's first verb, `rer dev`, the installer and `rer release` run, the pnpm version check, `rer update`'s npm install and local data migrations get `PATH`, `HOME`, `USER`, `LOGNAME`, `SHELL`, `TMPDIR`/`TMP`/`TEMP`, `TERM`, `LANG`/`LANGUAGE`/`LC_*`, `TZ`, `CI`, `SSH_AUTH_SOCK` (git+ssh dependencies), `NODE_OPTIONS`, `NODE_EXTRA_CA_CERTS`, `XDG_*`, proxy settings without their user:password, and `npm_config_*`, `PNPM_*`, `COREPACK_*` and `RER_*` settings except credentials (by name, such as `_authToken` or `*_SESSION`, or by value: a URL with a password, a credential query or connection-string parameter, a JWT, a private key, an Authorization header or a provider token). Every other variable stays with you, so dependency build scripts (esbuild, workerd, sharp, oxide) never see vault sessions (`BW_SESSION`, `OP_SESSION_*`), connection strings, keys or tokens under any name. `rer deploy` and the seed's own `verify` still receive everything.
15
+ - A proxy setting reaches a dependency install without its user:password even when the password contains spaces, an extra `@` or the value starts with whitespace.
16
+ - `rer deploy` setting or removing a secret whose answer was lost settles against the Worker version it recorded, so a later deploy recognises the change instead of refusing it.
17
+ - A secret change whose answer was lost while the read-back also failed stays recorded as pending, so the next `rer deploy` recognises it instead of locking you out; replacing an existing secret after a lost answer writes it again and confirms it before reporting success.
18
+ - After it claims the hosted owner, retires the setup token or sets or removes a secret, `rer deploy` checks the app's health on the new Worker version and fails with the HTTP status and the response's first line, instead of reporting success, when the app is not healthy.
19
+ - `rer setup` finishes provider-free when a seed declares an optional dependency that you leave unconnected.
20
+ - A seed adapter can declare `resetPreflight`; `rer reset` runs it before listing or deleting anything, and `--yes` cannot skip it.
21
+ - A first `rer dev` whose chosen port is taken by another program before setup can use it moves to the next free port and says which.
22
+ - `rer deploy` says it is deploying to your Cloudflare account and names the live address, instead of saying the application is running locally; `rer build` ends with "Your application is built".
23
+ - `rer describe` in the default seed and new foundations names the release keygen, sign and receipt subcommands again.
24
+ - Importing an archive whose bytes are not valid UTF-8 is refused with the same `archive_json` diagnostic (exit 2) as invalid JSON.
25
+ - Converting a seed to package mode no longer leaves a broken queue sentence in its README or `scripts/base-cli` paths in its operations guide and erasure README.
26
+ - The owner's sign-in settings page (`/auth/settings/identity`) wears the seed's declared theme, like sign-in and setup.
27
+ - `rer demo` (the same as `rer dev --demo`) runs a seed as a throwaway local demo: signed in as the saved owner, filled with the seed's demo data, marked by a Demo bar, unable to send anything, and deleted on `--stop`, Ctrl-C or after an hour; the checkout's own state is never written.
28
+ - Under `rer demo`, `fetch`, redirects, `WebSocket`, `navigator.sendBeacon`, `EventSource`, `node:net`/`node:tls` and `cloudflare:sockets` reach loopback hosts only, and every refused send is listed in the Demo bar.
29
+ - `rer demo` refuses a seed that has not adopted it (no `withDemo` guard, no `demoSockets()` or a state path that ignores `RER_STATE_DIR`), and `rer deploy`, including `--dry-run`, refuses a deploy declaration or `--env-file` that carries `RER_DEMO` and names the key.
30
+ - `rer deploy --demo` hosts a seed's demo at `demo.<domain>`: each visitor passes a Turnstile check and gets their own signed-in, throwaway container of the seed that can send nothing, ended after an hour or 15 idle minutes. `export/run-demo.yml` deploys it when `RUN_DEMO_DEPLOY` is on.
31
+ - `rer deploy` claims the hosted owner after every deploy, including seeds whose own deploy hooks run, retires the setup token, keeps an existing native identity-provider owner and keeps a live owner token you supply.
32
+ - Container images built by `rer build --platform container` start again; they exited at start.
33
+ - The default seed's sign-in serves the signed owner claim whenever its public keys are configured.
34
+ - A seed can declare a reviewed rewrite of an already released migration in `migrations/rewrites.json`; deploys and local builds accept the released bytes for that migration only and still refuse every undeclared change.
35
+ - Seed table inventories can use the exported portability exclusion list, so native ZIP and remote export no longer refuse the deploy controller's migration ledger table.
36
+ - The publication scan skips only the compressed pixel and sample data of recognised images and videos; their comments, XMP, EXIF and tags are checked, and a text file behind a fake media header is checked whole.
37
+ - With `--public-base`, the publication scan reports a file identical to the public base's copy as pre-existing instead of a finding.
38
+ - Base's default seed and source import kit (outside the rer npm package) prepare and verify 100,000-row imports across requests within the Worker CPU limit and resume after a killed step; a changed re-upload under a published key is refused.
39
+ - Base's default seed and source import kit erase a person named in other people's imported records at seed scale, across requests, and when they never became a native record; an interrupted erasure is finished by a cron sweep within about 10 minutes.
40
+
41
+ ### Upgrade notes
42
+
43
+ - Existing OIDC provider links show **not linked** until the next successful sign-in through each provider, because older stores have no verified token-exchange observation for them.
44
+ - The default seed's erasure queue message is now `{ redact, chain }` instead of `{ redact, operator, continuation }`; a message still queued in the old shape when you deploy is refused, so let the erasure queue drain first. Default-seed migrations 0021 to 0023 add the import-step, stream-source and erasure-progress tables, and its cron trigger sweeps erasures every 10 minutes.
45
+ - A seed adopting `rer demo` adds `src/demo.ts` (exporting an idempotent `seedDemo`), copies Base's `modules/demo/` and wraps its Worker entry's default export in `withDemo` as the entry's first import, and makes three `vite.config` changes: the state path reads `RER_STATE_DIR`, the dev entry's vars pass `RER_DEMO` and `RER_DEMO_EXPIRES`, and `demoSockets()` comes first in `plugins`; add `.rer/demo/` to `.gitignore`. `export/DEMO.md` has the steps.
46
+ - A private registry's auth must be in `~/.npmrc` (or the registry config your package manager reads from `HOME`): dependency installs now get only an allowlisted environment, so a token passed as an environment variable no longer reaches them.
47
+ - A seed with custom recovery code that checks the deploy migration ledger is exactly Wrangler's three columns uses `migrationLedgerShape` instead; the old check refuses ledgers from 0.2.2 on (`MIGRATION_MISMATCH`).
48
+
3
49
  ## 0.2.2
4
50
 
5
51
  - `rer deploy` claims the hosted owner, exits cleanly when it finishes, changes and removes secrets safely, and removes the resources it created when you ask it to.
package/global/README.md CHANGED
@@ -182,8 +182,15 @@ as an exact devDependency instead (`"@runeditrun/rer": "0.2.0"`, locked in
182
182
  which inside `pnpm run` is the pinned copy in `node_modules/.bin`, so
183
183
  `pnpm install --frozen-lockfile && pnpm run build` needs no global `rer`. A global
184
184
  `rer` hands every verb to the pinned package, launching included. Until
185
- `node_modules` holds the pinned version, `rer install` and `rer dev` install it
186
- first (the frozen `pnpm install`) and every other verb says to run `rer install`.
185
+ `node_modules` holds the pinned version (a fresh clone, or a pulled pin change),
186
+ every verb installs it first (the frozen `pnpm install`), then runs it; verbs
187
+ started at once share that one install.
188
+ Every dependency install, `rer release`'s included, receives an allowlisted
189
+ environment: the shell, locale, terminal and temporary-folder variables, `CI`,
190
+ `TZ`, `SSH_AUTH_SOCK`, `NODE_OPTIONS`, `NODE_EXTRA_CA_CERTS`, `XDG_*`, proxy
191
+ settings without their user:password, and `npm_config_*`, `PNPM_*`,
192
+ `COREPACK_*` and `RER_*` settings that carry no credential by name or value.
193
+ No other variable reaches the package manager or a dependency's build script.
187
194
 
188
195
  Heavy package scripts wait for this computer's job queue with
189
196
  `rer queue NAME -- COMMAND [ARGS]` (in vendored mode,
@@ -13,7 +13,7 @@ import { LaunchFailure } from './failure.mjs';
13
13
  export const personDefaults = { limit: 10, idleSeconds: 43200 };
14
14
  const unboundedLoad = '1000000';
15
15
  // The verbs that start a server or enter the job queue on every pin.
16
- export const personVerbs = new Set(['dev', 'preview', 'install', 'setup', 'build', 'verify', 'deploy', 'export', 'import']);
16
+ export const personVerbs = new Set(['dev', 'demo', 'preview', 'install', 'setup', 'build', 'verify', 'deploy', 'export', 'import']);
17
17
 
18
18
  // How the pinned dispatcher counts servers, and which person defaults it lacks.
19
19
  // describe's `defaults` (from rer-cli-2) is authoritative: the launcher supplies