@runeditrun/rer 0.2.1 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. package/README.md +21 -4
  2. package/cli/README.md +21 -0
  3. package/cli/auth-copy.mjs +25 -18
  4. package/cli/bounded.mjs +13 -4
  5. package/cli/container.mjs +11 -10
  6. package/cli/contract.mjs +2 -2
  7. package/cli/demo-host.mjs +47 -0
  8. package/cli/demo.mjs +47 -0
  9. package/cli/dependencies.mjs +26 -4
  10. package/cli/deploy-command.mjs +94 -0
  11. package/cli/deploy-inputs.mjs +55 -0
  12. package/cli/deploy-observation.mjs +28 -0
  13. package/cli/deploy-owner.mjs +76 -0
  14. package/cli/deploy-readiness.mjs +16 -0
  15. package/cli/deploy.mjs +99 -17
  16. package/cli/detached-server.mjs +1 -1
  17. package/cli/door.mjs +1 -1
  18. package/cli/export.mjs +1 -1
  19. package/cli/hosted-output.mjs +2 -1
  20. package/cli/inspect.mjs +24 -32
  21. package/cli/lifecycle.mjs +31 -8
  22. package/cli/local.mjs +7 -0
  23. package/cli/main.mjs +1 -1
  24. package/cli/managed-server.mjs +27 -18
  25. package/cli/parse.mjs +9 -2
  26. package/cli/pnpm-probe.mjs +17 -0
  27. package/cli/pnpm-runtime.mjs +22 -22
  28. package/cli/portability-policy.mjs +55 -0
  29. package/cli/portability-worker.mjs +8 -4
  30. package/cli/portability.mjs +24 -5
  31. package/cli/project-pnpm.mjs +67 -0
  32. package/cli/release.mjs +35 -0
  33. package/cli/scheduled.mjs +2 -2
  34. package/cli/server-registry.mjs +2 -2
  35. package/cli/setup-input.mjs +1 -1
  36. package/cli/site-check.mjs +15 -6
  37. package/cli/site-data.mjs +6 -5
  38. package/cli/site-references.mjs +153 -3
  39. package/global/CHANGELOG.md +60 -0
  40. package/global/README.md +21 -4
  41. package/global/cli/install.mjs +26 -9
  42. package/global/cli/path-link.mjs +30 -0
  43. package/global/cli/status.mjs +1 -1
  44. package/global/cli/uninstall.mjs +5 -1
  45. package/global/launch/capacity.mjs +1 -1
  46. package/global/launch/facts.mjs +12 -6
  47. package/global/launch/install-pinned.mjs +30 -2
  48. package/global/launch/prepare.mjs +20 -8
  49. package/global/launch/state.mjs +54 -1
  50. package/global/main.mjs +43 -20
  51. package/global/registry.mjs +2 -2
  52. package/global/update/command.mjs +2 -1
  53. package/modules/container/build.mjs +4 -2
  54. package/modules/container/deploy-input.mjs +5 -9
  55. package/modules/container/host.mjs +1 -1
  56. package/modules/container/ingress.mjs +4 -1
  57. package/modules/container/migrations.mjs +2 -1
  58. package/modules/container/worker-engine.mjs +3 -1
  59. package/modules/demo-host/README.md +30 -0
  60. package/modules/demo-host/capacity.mjs +19 -0
  61. package/modules/demo-host/claim.mjs +21 -0
  62. package/modules/demo-host/container.mjs +74 -0
  63. package/modules/demo-host/pages.mjs +56 -0
  64. package/modules/demo-host/plan.mjs +52 -0
  65. package/modules/demo-host/worker.mjs +72 -0
  66. package/modules/dependencies/auth.mjs +2 -1
  67. package/modules/deploy-kit/README.md +52 -7
  68. package/modules/deploy-kit/d1-import.mjs +35 -0
  69. package/modules/deploy-kit/deploy.mjs +4 -3
  70. package/modules/deploy-kit/diagnostics.mjs +11 -1
  71. package/modules/deploy-kit/env-file.mjs +27 -0
  72. package/modules/deploy-kit/health-request.mjs +13 -3
  73. package/modules/deploy-kit/health.mjs +35 -15
  74. package/modules/deploy-kit/migration-ledger.mjs +85 -0
  75. package/modules/deploy-kit/migrations.mjs +48 -16
  76. package/modules/deploy-kit/owner-cli.mjs +42 -8
  77. package/modules/deploy-kit/owner-remove.mjs +108 -0
  78. package/modules/deploy-kit/owner-secret.mjs +94 -0
  79. package/modules/deploy-kit/owner.mjs +124 -19
  80. package/modules/deploy-kit/prepare.mjs +3 -1
  81. package/modules/deploy-kit/provider.mjs +18 -2
  82. package/modules/erasure/README.md +2 -3
  83. package/modules/job-queue/config.mjs +4 -2
  84. package/modules/operator-auth/accounts/settings-page.mjs +8 -3
  85. package/modules/operator-auth/csp.mjs +26 -0
  86. package/modules/operator-auth/dev/badge.mjs +1 -22
  87. package/modules/operator-auth/identity/oidc/README.md +18 -0
  88. package/modules/operator-auth/identity/oidc/config.mjs +4 -2
  89. package/modules/operator-auth/identity/oidc/index.d.mts +1 -1
  90. package/modules/operator-auth/identity/oidc/index.mjs +28 -9
  91. package/modules/operator-auth/identity/oidc/network.mjs +9 -4
  92. package/modules/operator-auth/identity/oidc/store.mjs +19 -3
  93. package/modules/operator-auth/index.d.mts +2 -0
  94. package/modules/operator-auth/page.mjs +2 -2
  95. package/modules/operator-auth/routes.mjs +2 -2
  96. package/modules/portability/README.md +37 -0
  97. package/modules/portability/bridge.mjs +14 -4
  98. package/modules/portability/catalogue.mjs +9 -2
  99. package/modules/portability/import.mjs +4 -2
  100. package/modules/portability/index.d.mts +4 -0
  101. package/modules/portability/index.mjs +1 -0
  102. package/modules/portability/lines.mjs +3 -1
  103. package/modules/release/produce.mjs +1 -1
  104. package/modules/release/source.mjs +5 -2
  105. package/onboarding/adapter-labels.mjs +1 -0
  106. package/onboarding/adapter.mjs +9 -4
  107. package/onboarding/commands.mjs +3 -1
  108. package/onboarding/dependency-environment.mjs +33 -0
  109. package/onboarding/local/owner.mjs +1 -1
  110. package/onboarding/operator-installer.mjs +10 -4
  111. package/onboarding/redaction.mjs +22 -3
  112. package/package.json +6 -1
  113. package/scripts/publish-check/README.md +22 -1
  114. package/scripts/publish-check/c2pa.py +121 -0
  115. package/scripts/publish-check/content_identities.py +3 -1
  116. package/scripts/publish-check/controller_metadata.py +31 -0
  117. package/scripts/publish-check/history_scan.py +13 -3
  118. package/scripts/publish-check/media.py +283 -0
  119. package/scripts/publish-check/png_text.py +34 -0
  120. package/scripts/publish-check/public_hygiene.py +39 -4
  121. package/scripts/publish-check/publication-config.json +7 -1
@@ -0,0 +1,76 @@
1
+ import { observeOwner } from './deploy-observation.mjs';
2
+ import { randomBytes } from 'node:crypto';
3
+
4
+ // After a healthy deploy, the hosted app's first owner is the setup owner: rer
5
+ // claims it with the one-time setup token through the seed's standard owner step
6
+ // (Base operator-auth POST /auth/bootstrap), then removes that token from the
7
+ // Worker and records the new Worker version. A Worker still holding a token rer
8
+ // did not upload this run gets a fresh one first, unless the person supplied the
9
+ // token (environment or --env-file) and inspection has not found it absent. A
10
+ // seed with its own owner step function runs its own bootstrap; identity-provider
11
+ // linking runs before retirement.
12
+ // `beforeClaim(token)` runs while the token is live (dependency approvals need it).
13
+ export async function claimHostedOwner({ hook, setupManifest, declaration, deployed, env, supplied = [], secret, addSecret, beforeClaim, recordClaim, inspect, store, signal, fetch = globalThis.fetch }) {
14
+ const claim = setupManifest?.steps?.find(step => step.type === 'owner-claim');
15
+ if (!hook || !['object', 'function'].includes(typeof hook) || Array.isArray(hook) || !deployed.origin) {
16
+ if (declaration.secrets?.some(item => item.binding === (claim?.tokenField ?? 'SETUP_TOKEN'))) throw ownerFailure('the owner bootstrap hook is unavailable');
17
+ return undefined;
18
+ }
19
+ const tokenKey = hook.tokenKey ?? claim?.tokenField ?? 'SETUP_TOKEN';
20
+ const item = (Array.isArray(declaration.secrets) ? declaration.secrets : []).find(entry => entry?.binding === tokenKey);
21
+ if (!item) { if (claim) throw ownerFailure('the deploy declaration does not name the owner setup token secret'); return undefined; }
22
+ const retire = async outcome => {
23
+ try { await recordClaim?.(outcome); await secret(['--unset', tokenKey], env); }
24
+ catch (cause) {
25
+ if (cause.secretChange) { cause.owner = outcome; cause.tokenKey = tokenKey; cause.ownerFinalization = true; throw cause; }
26
+ const error = new Error('owner-token-retirement-failed'); error.owner = outcome; error.tokenKey = tokenKey; error.cause = cause.message; throw error; }
27
+ return outcome;
28
+ };
29
+ // Only a recorded successful bootstrap proves ownership; --unset-secret alone
30
+ // can retire a token while the application is still unclaimed. A legacy hook
31
+ // can re-upload a spent token, so always remove it again after ownership proof.
32
+ if (deployed.owner || deployed.ownerClaim) { await beforeClaim?.(); return retire(deployed.owner ?? 'kept'); }
33
+ // Older direct callers only recorded token retirement, without an ownership
34
+ // record or live-secret inspection. Keep their completed deploys idempotent.
35
+ if (!deployed.liveSecrets && deployed.retiredSecrets?.includes(tokenKey)) return undefined;
36
+ const given = (!deployed.liveSecrets || deployed.liveSecrets.includes(tokenKey)) && env[item.environment]?.trim() && !supplied.includes(tokenKey);
37
+ let token = deployed.sentSecrets?.includes(tokenKey) || given ? env[item.environment] : undefined;
38
+ if (!token) {
39
+ token = randomBytes(32).toString('hex'); addSecret(token);
40
+ // The secret controller confirms readiness of the full new version identity.
41
+ await secret(['--set', tokenKey], { ...env, [item.environment]: token });
42
+ }
43
+ const linked = await beforeClaim?.(token);
44
+ if (claim?.mode === 'idp') {
45
+ if (!linked?.ownerClaimed || !['claimed', 'kept'].includes(linked.ownerOutcome)) throw ownerFailure('the identity provider has not confirmed the designated owner link');
46
+ return retire(linked.ownerOutcome);
47
+ }
48
+ if (typeof hook === 'function') {
49
+ const context = { origin: deployed.origin, vars: { ...env, [tokenKey]: token }, input: {}, store, signal, ownerClaim: claim, fail: () => ownerFailure('the seed owner hook failed') };
50
+ let observed;
51
+ try {
52
+ if (deployed.observedOwner) await hook(context);
53
+ else await observeOwner({ paths: ['/auth/bootstrap', claim?.path].filter(Boolean), inspect, addSecret }, async claims => { await hook(context); observed = claims.at(-1)?.outcome; });
54
+ } catch (cause) { if (cause.message === 'owner-claim-failed') throw cause; throw ownerFailure(cause.message); }
55
+ // Function hooks can keep existing owners without returning a value. Observe
56
+ // their actual response, instead of assuming that a resolved promise claimed.
57
+ const outcome = context.ownerClaimed ?? observed ?? deployed.observedOwner?.();
58
+ if (!['claimed', 'kept'].includes(outcome)) throw ownerFailure('the seed owner hook did not confirm its bootstrap exchange');
59
+ return retire(outcome);
60
+ }
61
+ const displayName = (typeof hook.displayName === 'function' ? hook.displayName({}) : undefined) || 'Owner';
62
+ let response, code = 'no code';
63
+ try {
64
+ response = await fetch(new URL(hook.path ?? '/auth/bootstrap', deployed.origin), { method: 'POST', redirect: 'manual', signal: AbortSignal.timeout(30000),
65
+ headers: { Origin: deployed.origin, 'Content-Type': 'application/json' }, body: JSON.stringify({ token, displayName, returnTo: hook.returnTo ?? '/' }) });
66
+ } catch { throw ownerFailure('the hosted application did not answer'); }
67
+ if (response.status !== 303) { try { const body = await response.json(); if (typeof body?.error === 'string' && /^[a-z_]{1,64}$/.test(body.error)) code = body.error; } catch {} }
68
+ else await response.body?.cancel();
69
+ const outcome = response.status === 303 ? 'claimed' : response.status === 409 && code === 'already_initialized' ? 'kept' : undefined;
70
+ if (!outcome) throw ownerFailure(`HTTP ${response.status} (${code})${code === 'invalid_setup_token' && given ? `; the ${item.environment} you supplied is not the deployed setup token: leave it unset and rer issues a fresh one` : ''}`);
71
+ return retire(outcome);
72
+ }
73
+
74
+ function ownerFailure(cause) {
75
+ const error = new Error('owner-claim-failed'); error.cause = cause; return error;
76
+ }
@@ -0,0 +1,16 @@
1
+ // Mutation readiness errors carry confirmed state separately from the hosted
2
+ // response. Only show that response through the command's existing redactor.
3
+ export function mutationReadinessFailure(error, { again, redact }) {
4
+ if (!error.secretChange) return undefined;
5
+ const { binding, present } = error.secretChange;
6
+ const last = error.attempts?.at(-1);
7
+ const status = last?.status == null ? 'no HTTP response' : `HTTP ${last.status}`;
8
+ const first = redact(String(last?.body ?? '').split(/[\r\n]/, 1)[0]).slice(0, 256);
9
+ const state = error.ownerFinalization
10
+ ? `Owner ${error.owner === 'kept' ? 'already claimed and kept' : 'claimed'}; setup token retirement was applied and its new Worker version recorded.`
11
+ : `The secret change for ${binding} was applied and its new Worker version recorded.`;
12
+ const step = error.ownerFinalization ? 'owner claim/token retirement' : `secret change for ${binding}`;
13
+ const reason = /^[a-zA-Z0-9_-]{1,120}$/.test(error.message) ? error.message : 'health-check-failed';
14
+ const retirement = error.ownerFinalization || binding === 'SETUP_TOKEN' && !present;
15
+ return redact(`${state} Hosted readiness failed after ${step}. ${status} (${reason}).${first ? ` First response line: ${first}` : ''} Update the seed/app configuration to match the changed secrets and redeploy with ${again}. ${retirement ? 'If the app requires the spent setup token, remove that requirement from its configuration before redeploying; keep the recorded owner and token retirement.' : 'If the secret input was incorrect, correct it and apply the intended secret change again.'}`);
16
+ }
package/cli/deploy.mjs CHANGED
@@ -1,24 +1,36 @@
1
1
  import { readFile } from 'node:fs/promises';
2
- import { resolve } from 'node:path';
3
2
  import { deployDiagnostic } from '../modules/deploy-kit/diagnostics.mjs';
4
3
  import { shared } from './shared.mjs';
5
4
  import { contained } from './project.mjs';
6
- import { missing, invalid, refuse } from './errors.mjs';
5
+ import { missing, invalid, refuse, RerError } from './errors.mjs';
7
6
  import { ownerPlan, ownerWorkerName, localOnlyBinding } from '../modules/deploy-kit/owner.mjs';
8
7
  import { packageBuild } from '../modules/deploy-kit/prepare.mjs';
9
8
  import { refuseLocalModels } from '../modules/deploy-kit/local-only.mjs';
9
+ import { deployEnvironment, deployAgain } from './deploy-inputs.mjs';
10
+ import { claimHostedOwner } from './deploy-owner.mjs';
11
+ import { mutationReadinessFailure } from './deploy-readiness.mjs';
10
12
 
11
13
  export function ownerDeploy(declarationPath, lifecycle) {
12
14
  // rer verify serves the build with this declaration's vars and secrets.
13
- return Object.assign(async ({ root, options, store, signal, operationToken, localOnlyKeys = [], checkBuild = async () => {} }) => {
15
+ return Object.assign(async ({ root, options, store, signal, operationToken, localOnlyKeys = [], checkBuild = async () => {}, setupManifest, linkDependencies }) => {
14
16
  try { await refuseLocalModels(root); } catch { throw refuse('Deploy refuses the local-only key file .rer/local/models.json. Remove live-dev credentials before deployment.'); }
15
17
  const path = await contained(root, declarationPath);
16
18
  let declaration; try { declaration = JSON.parse(await readFile(path, 'utf8')); } catch { throw invalid('Malformed owner-account deploy declaration.'); }
17
19
  const local = localOnlyBinding(declaration, localOnlyKeys);
18
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.`);
19
21
  const environment = options.environment ?? 'production';
20
- await lifecycle.run(root, {}, store, () => {}, signal, { verb: 'build', operationToken });
21
- await checkBuild();
22
+ const again = deployAgain(options);
23
+ const change = [options.remove && '--remove', options.setSecret && '--set-secret', options.unsetSecret && '--unset-secret'].filter(Boolean);
24
+ // The command that finishes what this run started: the same verb, never a plain deploy in its place.
25
+ const retry = options.remove ? `${again} --remove --yes` : options.setSecret ? `${again} --set-secret ${options.setSecret}` : options.unsetSecret ? `${again} --unset-secret ${options.unsetSecret}` : again;
26
+ if (change.length > 1 || change.length && options.dryRun) throw invalid(`Use ${change.join(', ')}${options.dryRun ? ' and --dry-run' : ''} on separate runs.`);
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 });
30
+ if (!change.length) {
31
+ await lifecycle.run(root, {}, store, () => {}, signal, { verb: 'build', operationToken });
32
+ await checkBuild();
33
+ }
22
34
  if (options.dryRun) {
23
35
  // Locally validate the real declaration/artifact without ownerTarget's
24
36
  // account lookup. This synthetic target is validation data, not a provider.
@@ -28,24 +40,94 @@ export function ownerDeploy(declarationPath, lifecycle) {
28
40
  store.report('deploy: dry run complete; no deployment occurred and no provider request was made.');
29
41
  return { dryRun: true, deployed: false };
30
42
  }
43
+ const { env, supplied } = await deployEnvironment({ root, options, declaration, setupManifest, settings: lifecycle.settings, localOnlyKeys });
31
44
  const names = ['CLOUDFLARE_ACCOUNT_ID', 'CLOUDFLARE_API_TOKEN'];
32
- 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.');
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.');
33
46
  const { main } = await import('../modules/deploy-kit/owner-cli.mjs');
34
- const argv = ['deploy', '--declaration', declarationPath, '--environment', environment];
35
- const {createRedaction,credentialValues} = await shared('redaction.mjs');
47
+ const { createRedaction, credentialValues } = await shared('redaction.mjs');
36
48
  const redaction = createRedaction();
37
- redaction.addSecrets([...credentialValues(process.env),process.env.CLOUDFLARE_ACCOUNT_ID,...(declaration.secrets ?? []).map(item=>process.env[item.environment]).filter(Boolean)]);
49
+ redaction.addSecrets([...credentialValues(env), env.CLOUDFLARE_ACCOUNT_ID, ...(declaration.secrets ?? []).map(item => env[item.environment]).filter(Boolean)]);
50
+ const claim = setupManifest?.steps?.find(item => item.type === 'owner-claim');
51
+ const declared = binding => (Array.isArray(declaration.secrets) ? declaration.secrets : []).find(item => item?.binding === binding);
52
+ const owner = (command, extra = [], values = env, more = {}) => main([command, '--declaration', declarationPath, '--environment', environment, ...extra], values, { cwd: root, localOnlyKeys, ...more });
38
53
  // Keep confirmed creation events across deploy and the independent health call.
39
54
  const created = [];
55
+ let step = 'deploy', deployed;
40
56
  try {
41
- const result = await main(argv, process.env, { cwd: root, localOnlyKeys, notice: line => store.report(line), onCreated: resource => created.push(redaction.redact(resource)) });
42
- await main(['health', ...argv.slice(1)], process.env, { cwd: root, localOnlyKeys });
43
- return { deployed: true, origin: result.origin };
57
+ if (options.remove) {
58
+ step = 'remove';
59
+ const result = await owner('remove');
60
+ store.report(`Removed ${result.removed.join(', ') || 'nothing (already gone)'}; read back: none of them remains.`);
61
+ return { removed: result.removed, deployed: false };
62
+ }
63
+ if (change.length) {
64
+ step = 'secrets';
65
+ const binding = options.setSecret ?? options.unsetSecret;
66
+ await owner('secret', [options.setSecret ? '--set' : '--unset', binding]);
67
+ store.report(`${options.setSecret ? `Set ${binding}` : `Removed ${binding}`} on this environment's Worker, recorded its new version and verified hosted readiness.`);
68
+ return { binding, present: Boolean(options.setSecret), deployed: false };
69
+ }
70
+ store.report(`Deploying the ${environment} environment to your Cloudflare account.`);
71
+ deployed = await owner('deploy', [], env, { notice: line => store.report(line), onCreated: resource => created.push(redaction.redact(resource)) });
72
+ const sent = supplied.filter(name => deployed.sentSecrets.includes(name));
73
+ if (sent.length) store.report(`rer supplied ${sent.join(', ')} from the local setup owner and fresh generated values.`);
74
+ step = 'health';
75
+ await owner('health');
76
+ store.report(`Your application is live on your Cloudflare account at ${deployed.origin}.`);
77
+ step = 'claim owner';
78
+ const { liveSecrets, retiredSecrets, ownerClaim } = await owner('inspect');
79
+ const linked = token => linkDependencies?.(deployed.origin, token);
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 });
82
+ // A seed that claims its owner its own way links with the token it was given.
83
+ if (claimed === undefined) await linked(env[declared(claim?.tokenField ?? 'SETUP_TOKEN')?.environment]);
84
+ // The app claims the OWNER_EMAIL the Worker holds: the local one only when this run uploaded it.
85
+ const email = declared(claim?.emailField);
86
+ const named = !email ? '' : deployed.sentSecrets.includes(email.binding) && env[email.environment] ? ` (${env[email.environment]})` : ` (the ${email.binding} already set on the Worker)`;
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}.`);
88
+ return { deployed: true, origin: deployed.origin, ...(claimed ? { owner: claimed } : {}), dependenciesLinked: true };
44
89
  } catch (error) {
45
- const diagnostic = deployDiagnostic(error,redaction.redact);
46
- const code = /^[a-zA-Z0-9_-]{1,120}$/.test(error.message) ? redaction.redact(error.message) : 'deploy-failed';
47
- const {commandFailure} = await shared('failures.mjs');
48
- throw commandFailure(`Deploy failed at ${diagnostic?.step ?? 'prepare deploy'}: ${diagnostic?.code ?? code}${diagnostic?.providerCodes?.length ? ` (provider ${diagnostic.providerCodes.join(', ')})` : ''}.\nFirst redacted output lines:\n${diagnostic?.output ?? '(no provider output)'}\nResources created this attempt: ${created.join(', ') || 'none recorded'}.`);
90
+ if (error instanceof RerError) throw error;
91
+ const { commandFailure } = await shared('failures.mjs');
92
+ throw commandFailure(failureText({ error, step, created, again, retry, secretChange: Boolean(change.length && !options.remove), deployed, redact: redaction.redact }));
49
93
  }
50
- }, { declaration: declarationPath });
94
+ }, { declaration: declarationPath, managesHostedOwner: true });
95
+ }
96
+
97
+ // The failing step, its redacted cause, what this run created and the command that continues.
98
+ function failureText({ error, step, created, again, retry, secretChange, deployed, redact }) {
99
+ const readiness = mutationReadinessFailure(error, { again, redact });
100
+ if (readiness) return readiness;
101
+ const diagnostic = deployDiagnostic(error, redact);
102
+ const code = diagnostic?.code ?? (/^[a-zA-Z0-9_-]{1,120}$/.test(error.message) ? redact(error.message) : 'deploy-failed');
103
+ const at = step === 'deploy' ? diagnostic?.step ?? 'prepare deploy' : step;
104
+ const input = /^missing-secret-([A-Z][A-Z0-9_]*)$/.exec(code)?.[1];
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(', ')}.` : ''}`;
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.`;
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.`;
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.`;
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.`;
112
+ const lines = [`Deploy failed at ${at}: ${code}${resource ? ` (${resource})` : ''}${diagnostic?.providerCodes?.length ? ` (provider ${diagnostic.providerCodes.join(', ')})` : ''}.`];
113
+ if (diagnostic?.output && diagnostic.output !== '(no provider output)') lines.push(`First redacted output lines:\n${diagnostic.output}`);
114
+ lines.push(`Resources created this attempt: ${created.join(', ') || 'none'}.`);
115
+ lines.push(recovery({ code, at, resource, again, retry, secretChange }));
116
+ return lines.join('\n');
117
+ }
118
+
119
+ function recovery({ code, at, resource, again, retry, secretChange }) {
120
+ const remove = `${again} --remove --yes`, removing = retry === remove;
121
+ if (code === 'owned-resource-replaced' || code === 'resource-name-collision') return `rer refuses to change a resource it did not create or that was replaced after it did; nothing was changed. Choose another --environment name, or remove the other resource yourself.`;
122
+ // A lost create answer: the resource may exist, and rer cannot prove it made it.
123
+ if (resource && (code === 'uncertain-resource-create' || code === 'provider-transport-failed' && at.startsWith('create '))) {
124
+ if (resource.startsWith('worker:')) return `Possibly created: ${resource}. The answer to its first upload was lost and the live Worker does not match the build rer sent, so rer cannot prove it is its own. Deploy the same commit again to recover it, or inspect it in the Cloudflare dashboard: if it is the Worker from the interrupted run and its Durable Object data is disposable, delete it, then run ${retry} again.`;
125
+ return `Possibly created: ${resource}. The provider's answer to its create was lost, so rer cannot prove it made it. Inspect it in the Cloudflare dashboard: if it exists and is the empty one from the interrupted run, delete it, then run ${retry} again.${removing || resource.startsWith('queues:') ? '' : ` Or run ${remove}: it deletes it once it confirms it is empty, together with everything else this environment created.`}`;
126
+ }
127
+ if (code === 'uncertain-resource-not-empty') return `Possibly created: ${resource}, but it holds data, so it may not be the one rer's interrupted run made and rer will not delete it. Inspect it in the Cloudflare dashboard; delete it yourself only if its data is disposable, then run ${retry} again.`;
128
+ if (code === 'uncertain-resource-unverifiable') return `Possibly created: ${resource}. rer cannot read whether a queue holds messages, so it will not delete it. Inspect it in the Cloudflare dashboard: if it is the empty one from the interrupted run, delete it, then run ${retry} again.`;
129
+ if (code === 'changed-or-uncertain-migration-history') return `${resource ?? 'A D1 database'} holds a migration rer started but could not confirm, or one changed since it was applied; rer never retries or guesses a migration. Inspect it in the Cloudflare dashboard: if it is the empty one from this interrupted first deploy, delete it with ${remove} (that deletes everything this environment created), then run ${again} again. If it holds data, resolve the interrupted migration by hand first (deploy-kit README, Migration safety).`;
130
+ if (removing) return `Fix the cause, then run ${retry} again.`;
131
+ if (secretChange) return `Fix the cause, then run ${retry} again; a plain ${again} does not make this change.`;
132
+ return `Fix the cause, then run ${again} again: it continues with what was created. To delete what this environment created instead: ${remove}.`;
51
133
  }
@@ -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/door.mjs CHANGED
@@ -36,7 +36,7 @@ export const doorChallengeHeader = 'x-rer-door-challenge';
36
36
  export const doorIdentityPath = '/__rer/door/identity';
37
37
  export const doorDefaults = Object.freeze({ serverLimit: 10, idleSeconds: 43200 });
38
38
 
39
- export const doorDirectory = (env = process.env) => resolve(env.RER_DOOR_DIR ?? join(userInfo().homedir, '.rer/door'));
39
+ export const doorDirectory = (env = process.env) => resolve(env.RER_DOOR_DIR ?? join(env.HOME || userInfo().homedir, '.rer/door'));
40
40
  export const doorOrigin = (name, port) => `http://${name}.localhost:${port}`;
41
41
  export const doorHost = (name, port) => `${name}.localhost:${port}`;
42
42
 
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/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 ?? '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.' }])),
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
@@ -17,9 +17,19 @@ 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
- let termination, operation, store, temporary, proxy;
32
+ let termination, operation, store, temporary, proxy, failed = false;
23
33
  const handlers = new Map(['SIGINT', 'SIGTERM'].map(name => [name, () => { if (!termination) { termination = name; cancellation.abort(name); } }]));
24
34
  for (const [name, handler] of handlers) process.on(name, handler);
25
35
  try {
@@ -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;
@@ -85,9 +99,8 @@ export async function runCommand(project, options, out) {
85
99
  if (['export', 'import'].includes(options.verb)) return await transfer(project, options, signal, { store, operationToken: operation.token });
86
100
  if (options.verb === 'deploy') {
87
101
  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']});
90
- return deployed;
102
+ const link = async (origin, appSetupToken) => linkDependencies({project,input:project.setupSeed?await savedSetupAnswers(project,project.setupSeed.model):undefined,origin,store,signal,appSetupToken});
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 });
91
104
  }
92
105
  // Setup writes the checkout's dev key; its temporary app and the CLI's own
93
106
  // outbox reads present it. Captured logs redact it.
@@ -112,8 +125,10 @@ export async function runCommand(project, options, out) {
112
125
  if (['setup', 'build'].includes(options.verb)) await refuseDevLayerOutput(project.root, adapter.devIdentity);
113
126
  const url = selected?.url, signInUrl = url ? url + '/__dev/outbox' : undefined;
114
127
  if (options.verb === 'setup') {
115
- await linkDependencies({project,input,origin:url,store,signal});
116
- 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) {
117
132
  const proof=await localCliSessionProof(project);
118
133
  store.addSecrets?.([proof]);
119
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});
@@ -135,10 +150,18 @@ export async function runCommand(project, options, out) {
135
150
  } else if (options.verb !== 'build') throw refuse('The requested first-party capability is unavailable.');
136
151
  return {};
137
152
  } catch (error) {
153
+ failed = true;
138
154
  if (error instanceof RerError) throw error;
139
155
  throw await lifecycleError(error, store);
140
156
  } finally {
141
- await store?.closeCommands(); await proxy?.close(); await operation?.release();
157
+ // Every closer runs: one that fails must not leave the job-queue policy socket
158
+ // listening (the process would never exit) or the local operation held, and
159
+ // must not turn a finished command into a failure or hide the real one.
160
+ const unclosed = [];
161
+ for (const close of [() => store?.closeCommands(), () => proxy?.close(), () => operation?.release()]) {
162
+ try { await close(); } catch (error) { unclosed.push(store?.redact?.(String(error?.message)) ?? 'unknown cause'); }
163
+ }
164
+ 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
165
  if (temporary) await rm(temporary, { recursive: true, force: true });
143
166
  for (const [name, handler] of handlers) process.removeListener(name, handler);
144
167
  if (termination) throw new RerError('cancelled', `Stopped by ${termination} after owned children were stopped.`, termination === 'SIGINT' ? 130 : 143);
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);