@runeditrun/rer 0.2.1 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/README.md +12 -2
  2. package/cli/auth-copy.mjs +25 -18
  3. package/cli/bounded.mjs +13 -4
  4. package/cli/contract.mjs +1 -1
  5. package/cli/deploy-inputs.mjs +33 -0
  6. package/cli/deploy-owner.mjs +61 -0
  7. package/cli/deploy.mjs +87 -15
  8. package/cli/door.mjs +1 -1
  9. package/cli/inspect.mjs +24 -32
  10. package/cli/lifecycle.mjs +14 -4
  11. package/cli/parse.mjs +3 -1
  12. package/cli/pnpm-probe.mjs +14 -0
  13. package/cli/pnpm-runtime.mjs +22 -22
  14. package/cli/portability-policy.mjs +55 -0
  15. package/cli/portability-worker.mjs +8 -4
  16. package/cli/portability.mjs +20 -4
  17. package/cli/project-pnpm.mjs +67 -0
  18. package/cli/release.mjs +35 -0
  19. package/cli/server-registry.mjs +2 -2
  20. package/cli/site-check.mjs +15 -6
  21. package/cli/site-data.mjs +6 -5
  22. package/cli/site-references.mjs +153 -3
  23. package/global/CHANGELOG.md +14 -0
  24. package/global/README.md +12 -2
  25. package/global/cli/install.mjs +26 -9
  26. package/global/cli/path-link.mjs +30 -0
  27. package/global/cli/status.mjs +1 -1
  28. package/global/cli/uninstall.mjs +5 -1
  29. package/global/launch/install-pinned.mjs +4 -0
  30. package/global/main.mjs +28 -3
  31. package/global/registry.mjs +2 -2
  32. package/modules/container/deploy-input.mjs +5 -9
  33. package/modules/dependencies/auth.mjs +2 -1
  34. package/modules/deploy-kit/README.md +25 -7
  35. package/modules/deploy-kit/d1-import.mjs +35 -0
  36. package/modules/deploy-kit/deploy.mjs +4 -3
  37. package/modules/deploy-kit/diagnostics.mjs +11 -1
  38. package/modules/deploy-kit/env-file.mjs +26 -0
  39. package/modules/deploy-kit/health-request.mjs +6 -1
  40. package/modules/deploy-kit/health.mjs +35 -15
  41. package/modules/deploy-kit/migration-ledger.mjs +48 -0
  42. package/modules/deploy-kit/migrations.mjs +11 -15
  43. package/modules/deploy-kit/owner-cli.mjs +20 -7
  44. package/modules/deploy-kit/owner-remove.mjs +105 -0
  45. package/modules/deploy-kit/owner-secret.mjs +49 -0
  46. package/modules/deploy-kit/owner.mjs +68 -10
  47. package/modules/deploy-kit/provider.mjs +15 -2
  48. package/modules/job-queue/config.mjs +4 -2
  49. package/modules/operator-auth/identity/oidc/README.md +18 -0
  50. package/modules/operator-auth/identity/oidc/config.mjs +4 -2
  51. package/modules/operator-auth/identity/oidc/index.d.mts +1 -1
  52. package/modules/operator-auth/identity/oidc/index.mjs +28 -9
  53. package/modules/operator-auth/identity/oidc/network.mjs +9 -4
  54. package/modules/operator-auth/identity/oidc/store.mjs +19 -3
  55. package/modules/operator-auth/index.d.mts +2 -0
  56. package/modules/operator-auth/routes.mjs +1 -1
  57. package/modules/portability/README.md +28 -0
  58. package/modules/portability/bridge.mjs +10 -3
  59. package/modules/portability/import.mjs +4 -2
  60. package/onboarding/commands.mjs +3 -1
  61. package/package.json +1 -1
  62. package/scripts/publish-check/publication-config.json +7 -1
package/README.md CHANGED
@@ -22,8 +22,11 @@ refuses archives with absolute paths, `..` entries, links or devices, then runs
22
22
  the package's own `rer cli install`. A failed or refused download leaves the
23
23
  version you had selected.
24
24
 
25
- No installer edits shell profiles. When `~/.local/bin` is not on your PATH, the
26
- install prints the exact line to add for your shell. The launcher runs `node` from
25
+ No installer edits shell profiles. The canonical launcher is `~/.local/bin/rer`.
26
+ When that directory is absent from PATH, installation also links it into an
27
+ existing writable, user-owned PATH directory if available, preserving foreign
28
+ commands. Otherwise it prints one activation line to run in the current shell:
29
+ `export PATH="$HOME/.local/bin:$PATH"` (with fish syntax for fish). The launcher runs `node` from
27
30
  PATH, so a Node upgrade does not break it. A re-run with the same build changes
28
31
  nothing; a different build is installed beside the current one, the previous one
29
32
  is kept and older ones are removed. It refuses to replace a `~/.local/bin/rer`
@@ -267,3 +270,10 @@ Anywhere: `rer list [DIR]`, `rer cli status|install|uninstall`, `rer update`,
267
270
  `rer queue NAME -- COMMAND`, `rer --version` and `rer --help`.
268
271
 
269
272
  Licence: MIT.
273
+
274
+ First-party commands acquire the exact `packageManager` pnpm pin when PATH lacks
275
+ it, verify its registry sha512 integrity and cache it under `$HOME/.rer/pnpm`.
276
+ No global package manager installation or sudo is needed. Independent projects
277
+ continue to run their declared commands with their own tools. Server and job
278
+ state default to `$HOME/.rer/servers` and `$HOME/.rer/jobs`; supplied
279
+ `RER_SERVERS_DIR` and `RER_JOBS_DIR` take precedence and are passed to older pins.
package/cli/auth-copy.mjs CHANGED
@@ -7,16 +7,17 @@ import { contained } from './project.mjs';
7
7
  // bytes of the Base revision it pinned. A seed records its copies in
8
8
  // base-copies.json (edition authoring) or composition.lock.json /
9
9
  // foundation.lock.json (composed foundations); each records the SHA-256 of the
10
- // Base bytes it copied. Two groups are checked on their own: the application,
11
- // runtime and schema copies (one revision pins them all, since they are coupled)
12
- // and the CLI's bundled tooling under scripts/base-cli (one revision of its
13
- // own, which a CLI refresh moves without touching the application's auth). Each
14
- // drifted, missing or unrecorded file in either group is named. A seed that
15
- // imports Base in place has no copy.
10
+ // Base bytes it copied. Application runtime and schema sources share one pin;
11
+ // the CLI's bundled tooling has its own. Numbered SQL under migrations keeps
12
+ // its historical revision independently: refreshing runtime must not rewrite
13
+ // SQL a database may already have applied. Every copy still needs a valid pin
14
+ // and exact bytes. A seed that imports Base in place has no copy.
16
15
  const ORIGIN = 'modules/operator-auth';
17
16
  const CLI_ROOT = 'scripts/base-cli';
18
17
  const within = (path, prefix) => path === prefix || path.startsWith(`${prefix}/`);
19
- const groupOf = path => typeof path === 'string' && within(path, CLI_ROOT) ? 'cli' : 'application';
18
+ const groupOf = path => typeof path === 'string' && within(path, CLI_ROOT) ? 'cli'
19
+ : /(?:^|\/)migrations\/(?:[^/]+\/)*\d+[^/]*\.sql$/.test(path) ? 'migration' : 'application';
20
+ const pinned = revision => typeof revision === 'string' && /^[a-f0-9]{40}$/.test(revision);
20
21
  const sha256 = bytes => createHash('sha256').update(bytes).digest('hex');
21
22
  async function json(root, path) { try { return JSON.parse(await readFile(await contained(root, path), 'utf8')); } catch { return null; } }
22
23
 
@@ -30,22 +31,24 @@ async function walk(root, path) {
30
31
  return files;
31
32
  }
32
33
 
33
- // [{ group, path, revision, files: { local path: sha256 } }] for every recorded auth copy.
34
+ // [{ path, revision, files: { local path: sha256 } }] for every recorded auth copy.
34
35
  async function recordedCopies(root) {
35
36
  const copies = [];
36
37
  const record = await json(root, 'base-copies.json');
37
38
  for (const copy of record?.copies ?? []) {
38
- if (typeof copy?.originPath === 'string' && within(copy.originPath, ORIGIN) && copy.files && typeof copy.files === 'object') copies.push({ path: copy.path, revision: copy.revision, files: copy.files, directory: copy.originPath === ORIGIN, group: groupOf(copy.path) });
39
+ if (typeof copy?.originPath !== 'string' || !within(copy.originPath, ORIGIN)) continue;
40
+ const files = copy.files && typeof copy.files === 'object' && !Array.isArray(copy.files) ? copy.files : {};
41
+ copies.push({ path: copy.path, revision: copy.revision, files, directory: copy.originPath === ORIGIN || Object.keys(files).some(path => path !== copy.path && within(path, copy.path)) });
39
42
  }
40
43
  for (const name of ['composition.lock.json', 'foundation.lock.json']) {
41
44
  const lock = await json(root, name);
42
45
  if (!lock?.files) continue;
43
46
  const revision = lock.repositories?.base?.commit ?? lock.base?.commit;
44
- for (const group of ['application', 'cli']) {
45
- const entries = Object.entries(lock.files).filter(([path, file]) => groupOf(path) === group && typeof file?.source === 'string' && within(file.source, ORIGIN));
46
- const files = Object.fromEntries(entries.filter(([, file]) => typeof file.sha256 === 'string').map(([path, file]) => [path, file.sha256]));
47
+ for (const cli of [false, true]) {
48
+ const entries = Object.entries(lock.files).filter(([path, file]) => (groupOf(path) === 'cli') === cli && typeof file?.source === 'string' && within(file.source, ORIGIN));
49
+ const files = Object.fromEntries(entries.map(([path, file]) => [path, file.sha256]));
47
50
  const directories = [...new Set(entries.filter(([, file]) => !file.source.endsWith('.sql')).map(([path, file]) => path.slice(0, path.length - (file.source.length - ORIGIN.length))))];
48
- if (Object.keys(files).length) copies.push({ path: directories[0] ?? null, revision, files, directory: directories.length === 1, group });
51
+ if (Object.keys(files).length) copies.push({ path: directories[0] ?? null, revision, files, directory: directories.length === 1 });
49
52
  }
50
53
  }
51
54
  return copies;
@@ -55,18 +58,22 @@ export async function authCopyClauses(root) {
55
58
  const copies = await recordedCopies(root);
56
59
  if (!copies.length) return [];
57
60
  const clauses = [];
58
- for (const [group, id, scope] of [['application', 'SC-05.auth-copy.pin', 'the application, runtime and schema copies'], ['cli', 'SC-05.auth-copy.cli-pin', `the CLI's bundled copy under ${CLI_ROOT}`]]) {
59
- const grouped = copies.filter(copy => copy.group === group);
61
+ for (const [group, id, scope] of [['application', 'SC-05.auth-copy.pin', 'the application runtime and schema sources'], ['cli', 'SC-05.auth-copy.cli-pin', `the CLI's bundled copy under ${CLI_ROOT}`]]) {
62
+ const grouped = copies.filter(copy => Object.keys(copy.files).some(path => groupOf(path) === group));
60
63
  if (!grouped.length) continue;
61
64
  const revisions = [...new Set(grouped.map(copy => copy.revision))];
62
- clauses.push([id, revisions.length === 1 && /^[a-f0-9]{40}$/.test(revisions[0] ?? ''), `Copy ${scope} of operator-auth from one pinned Base revision; the seed records ${revisions.join(', ') || 'none'}.`]);
65
+ clauses.push([id, revisions.length === 1 && pinned(revisions[0]), `Copy ${scope} of operator-auth from one pinned Base revision; the seed records ${revisions.join(', ') || 'none'}.`]);
63
66
  }
64
67
  for (const copy of copies) {
68
+ if (!Object.keys(copy.files).length) clauses.push([`SC-05.auth-copy.${copy.path}`, false, `${copy.path} has no recorded bytes; record its copied files and their SHA-256 digests.`]);
65
69
  for (const [path, expected] of Object.entries(copy.files)) {
70
+ if (groupOf(path) === 'migration') clauses.push([`SC-05.auth-copy.migration-pin.${path}`, pinned(copy.revision), `${path} must retain a valid pinned Base revision for its historical SQL; the seed records ${copy.revision ?? 'none'}.`]);
66
71
  let actual = null; try { actual = sha256(await readFile(await contained(root, path))); } catch {}
67
- clauses.push([`SC-05.auth-copy.${path}`, actual === expected, actual === null
72
+ clauses.push([`SC-05.auth-copy.${path}`, actual !== null && actual === expected, actual === null
68
73
  ? `${path} is missing; restore it from Base ${copy.revision}.`
69
- : `${path} differs from Base ${copy.revision}; restore the copied bytes or record a new pin with refresh-base.`]);
74
+ : groupOf(path) === 'migration'
75
+ ? `${path} differs from Base ${copy.revision}; restore its historical bytes and put schema changes in a new migration.`
76
+ : `${path} differs from Base ${copy.revision}; restore the copied bytes or record a new pin with refresh-base.`]);
70
77
  }
71
78
  if (copy.directory && copy.path) {
72
79
  for (const path of await walk(root, copy.path)) {
package/cli/bounded.mjs CHANGED
@@ -16,7 +16,7 @@ export async function boundedCommand(project,options,argv,out){
16
16
  const policy=await commandPolicy();
17
17
  const childArgs=[...argv];const cwdIndex=childArgs.indexOf('--cwd');if(cwdIndex>=0&&(childArgs.indexOf('--')<0||cwdIndex<childArgs.indexOf('--')))childArgs[cwdIndex+1]=project.root;
18
18
  const directory=await mkdtemp(join(tmpdir(),'rer-bounded-'));
19
- let store,result,finalEvent,errorEvent,buffer='';
19
+ let store,result,finalEvent,errorEvent,buffer='';const unclosed=[];
20
20
  const cancellation=new AbortController();
21
21
  const handlers=new Map(['SIGINT','SIGTERM'].map(name=>[name,()=>cancellation.abort(name)]));
22
22
  for(const [name,handler] of handlers)process.on(name,handler);
@@ -47,11 +47,18 @@ export async function boundedCommand(project,options,argv,out){
47
47
  const held=await recoverInterrupted(project.root);await held?.release();
48
48
  }
49
49
  }
50
+ }).catch(error=>{
51
+ // The workflow already reported its outcome; only stopping its process group
52
+ // failed. Keep that outcome, and say so, instead of losing it.
53
+ if(!finalEvent||cancellation.signal.aborted)throw error;
54
+ unclosed.push(error);return {code:finalEvent.exitCode};
50
55
  });
51
56
  }finally{
52
- try{await store?.closeCommands();await policy.close();await rm(directory,{recursive:true,force:true});}
53
- finally{for(const [name,handler] of handlers)process.off(name,handler);}
57
+ // Every closer runs: a policy socket left listening keeps this process alive.
58
+ for(const close of [()=>store?.closeCommands(),()=>policy.close(),()=>rm(directory,{recursive:true,force:true})]){try{await close();}catch(error){unclosed.push(error);}}
59
+ for(const [name,handler] of handlers)process.off(name,handler);
54
60
  }
61
+ if(unclosed.length)out.progress(`Local cleanup could not confirm that every helper process stopped (${unclosed.map(error=>store?.redact?.(String(error?.message))??'unknown cause').join('; ')}). The ${options.verb} itself finished.\n`);
55
62
  const termination=cancellation.signal.aborted?cancellation.signal.reason:undefined;
56
63
  if(!result&&!termination)return null;
57
64
  const exitCode=termination?(termination==='SIGINT'?130:143):result.code;
@@ -61,7 +68,9 @@ export async function boundedCommand(project,options,argv,out){
61
68
  }else if(finalEvent&&finalEvent.exitCode===exitCode){
62
69
  if(errorEvent)out.error(new RerError(errorEvent.code,store.redact(errorEvent.message),exitCode));
63
70
  const {version,verb,event,ok,exitCode:childExit,...details}=finalEvent;
64
- out.result(exitCode===0,exitCode,JSON.parse(store.redact(JSON.stringify(details))));
71
+ // Redaction can cut through a value that matches a credential; the outcome stands without its details.
72
+ let shown;try{shown=JSON.parse(store.redact(JSON.stringify(details)));}catch{shown={};}
73
+ out.result(exitCode===0,exitCode,shown);
65
74
  }else{
66
75
  const error=new RerError('command_failed',result.supervisorEnded?store.redact(result.error):'Bounded command ended before its result; owned children and private files were recovered.',exitCode||1);
67
76
  out.error(error);out.result(false,error.exitCode);return {exitCode:error.exitCode};
package/cli/contract.mjs CHANGED
@@ -11,7 +11,7 @@ const definitions = {
11
11
  build: [['--site'], 'Production build with private local settings excluded and restored. --site instead writes the static run site (homepage, /run, docs) to its declared folder and prints that folder.'],
12
12
  verify: [[], 'Run the complete internal verification target.'],
13
13
  reset: [['--yes'], 'Confirmed removal of the declared local inventory only.'],
14
- deploy: [['--environment', '--dry-run', '--site', '--host', '--env-file', '--image', '--bind-address', '--http-port', '--https-port'], 'Owner-account deployment and health; dry run performs local validation/build only. --site deploys the run site instead: an assets-only Worker on its custom domain with www redirected; with --dry-run it builds the site and prints the plan with no provider request.'],
14
+ deploy: [['--environment', '--dry-run', '--site', '--host', '--env-file', '--image', '--bind-address', '--http-port', '--https-port', '--remove', '--yes', '--set-secret', '--unset-secret'], 'Owner-account deployment and health; dry run performs local validation/build only. Declared secrets come from the environment or --env-file (an owned 0600 KEY=VALUE file); a new Worker gets the local setup owner and fresh generated secrets when they are not given. After a healthy deploy rer claims the setup owner on the hosted app with the one-time setup token, then removes the token. --set-secret NAME (value from the environment or --env-file) and --unset-secret NAME change one secret of the deployed Worker without a build and record its new version. --remove --yes deletes what this environment\'s deploy record says rer created (Worker, D1, R2, KV, queues), reads back that each is gone, and touches nothing else. --site deploys the run site instead: an assets-only Worker on its custom domain with www redirected; with --dry-run it builds the site and prints the plan with no provider request.'],
15
15
  export: [['--target', '--output', '--origin', '--session-file'], 'Complete supported installation archive with integrity and exclusions.'],
16
16
  import: [['--target', '--input', '--journal', '--origin', '--session-file', '--yes', '--legacy-v1', '--acknowledge-unverified-metadata', '--output'], 'Confirmed atomic restore into an empty destination; --journal LATER_ARCHIVE also fences rows that installation erased since. Backup-only local v1 recovery requires --legacy-v1 --acknowledge-unverified-metadata and a new --output v2 archive; --journal requires normal v2 import.'],
17
17
  outbox: [['--port', '--host', '--owner-email', '--open'], 'Credential-free local outbox URL; optional ordinary sign-in request and explicit browser opening.'],
@@ -0,0 +1,33 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { shared } from './shared.mjs';
3
+ import { invalid } from './errors.mjs';
4
+ import { readDeployEnvFile } from '../modules/deploy-kit/env-file.mjs';
5
+
6
+ // The values a Workers deploy reads for its declared secrets: this process's
7
+ // environment, then the --env-file (which wins), then what rer supplies itself:
8
+ // the local setup owner's email, and a fresh value for each secret the setup
9
+ // manifest generates (a session secret, the one-time setup token). A value only
10
+ // reaches a Worker that lacks that secret; an existing one is kept unchanged.
11
+ export async function deployEnvironment({ root, options, declaration, setupManifest, settings, localOnlyKeys = [] }) {
12
+ const env = { ...process.env }, supplied = [];
13
+ if (options.envFile) {
14
+ let values;
15
+ try { values = await readDeployEnvFile(root, options.envFile, localOnlyKeys); }
16
+ catch (error) { throw invalid(`The --env-file ${options.envFile} could not be used: ${error.code === 'ENOENT' ? 'no such file' : error.message}.`); }
17
+ Object.assign(env, values);
18
+ }
19
+ const secrets = Array.isArray(declaration.secrets) ? declaration.secrets : [];
20
+ const absent = item => item && !env[item.environment]?.trim();
21
+ const emailField = setupManifest?.steps?.find(step => step.type === 'owner-claim')?.emailField;
22
+ const email = secrets.find(item => item?.binding === emailField);
23
+ if (absent(email) && settings) {
24
+ const { readSettings } = await shared('adapter-settings.mjs');
25
+ const owner = (await readSettings(root, settings)).values[emailField];
26
+ if (typeof owner === 'string' && owner.includes('@')) { env[email.environment] = owner; supplied.push(email.binding); }
27
+ }
28
+ for (const field of setupManifest?.fields ?? []) {
29
+ const item = field.kind === 'secret' && field.generate ? secrets.find(entry => entry?.binding === field.name) : undefined;
30
+ if (absent(item)) { env[item.environment] = randomBytes(32).toString(field.generate); supplied.push(item.binding); }
31
+ }
32
+ return { env, supplied };
33
+ }
@@ -0,0 +1,61 @@
1
+ import { randomBytes } from 'node:crypto';
2
+
3
+ // After a healthy deploy, the hosted app's first owner is the setup owner: rer
4
+ // claims it with the one-time setup token through the seed's standard owner step
5
+ // (Base operator-auth POST /auth/bootstrap), then removes that token from the
6
+ // Worker and records the new Worker version. A Worker still holding a token rer
7
+ // did not upload this run gets a fresh one first, unless the person supplied the
8
+ // token (environment or --env-file): that value is the deployed token and is used. A seed with its own owner step
9
+ // function, an identity-provider claim, or no declared token secret is left to it.
10
+ // `beforeClaim(token)` runs while the token is live (dependency approvals need it).
11
+ export async function claimHostedOwner({ hook, setupManifest, declaration, deployed, env, supplied = [], secret, addSecret, beforeClaim, fetch = globalThis.fetch, sleep = ms => new Promise(resolve => setTimeout(resolve, ms)), boundMs = 90000 }) {
12
+ if (!hook || typeof hook !== 'object' || Array.isArray(hook) || !deployed.origin) return undefined;
13
+ const claim = setupManifest?.steps?.find(step => step.type === 'owner-claim');
14
+ if (claim?.mode === 'idp') return undefined;
15
+ const tokenKey = hook.tokenKey ?? claim?.tokenField ?? 'SETUP_TOKEN';
16
+ const item = (Array.isArray(declaration.secrets) ? declaration.secrets : []).find(entry => entry?.binding === tokenKey);
17
+ if (!item || deployed.retiredSecrets?.includes(tokenKey)) return undefined;
18
+ const given = env[item.environment]?.trim() && !supplied.includes(tokenKey);
19
+ let token = deployed.sentSecrets?.includes(tokenKey) || given ? env[item.environment] : undefined;
20
+ if (!token) {
21
+ token = randomBytes(32).toString('hex'); addSecret(token);
22
+ const { versionId } = await secret(['--set', tokenKey], { ...env, [item.environment]: token });
23
+ // A secret change is a new Worker version; claim once the app answers from it.
24
+ await servingVersion({ origin: deployed.origin, versionId, fetch, sleep, boundMs });
25
+ }
26
+ await beforeClaim?.(token);
27
+ const displayName = (typeof hook.displayName === 'function' ? hook.displayName({}) : undefined) || 'Owner';
28
+ let response, code;
29
+ // Edge locations can briefly answer from the previous version, which lacks this token.
30
+ for (let attempt = 1; ; attempt++) {
31
+ try {
32
+ response = await fetch(new URL(hook.path ?? '/auth/bootstrap', deployed.origin), { method: 'POST', redirect: 'manual', signal: AbortSignal.timeout(30000),
33
+ headers: { Origin: deployed.origin, 'Content-Type': 'application/json' }, body: JSON.stringify({ token, displayName, returnTo: hook.returnTo ?? '/' }) });
34
+ } catch { throw ownerFailure('the hosted application did not answer'); }
35
+ code = 'no code';
36
+ 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 {} }
37
+ else await response.body?.cancel();
38
+ if (response.status !== 403 || code !== 'invalid_setup_token' || attempt === 3) break;
39
+ await sleep(5000);
40
+ }
41
+ const outcome = response.status === 303 ? 'claimed' : response.status === 409 && code === 'already_initialized' ? 'kept' : undefined;
42
+ 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` : ''}`);
43
+ await secret(['--unset', tokenKey], env);
44
+ return outcome;
45
+ }
46
+
47
+ async function servingVersion({ origin, versionId, fetch, sleep, boundMs }) {
48
+ const deadline = Date.now() + boundMs;
49
+ while (true) {
50
+ try {
51
+ const response = await fetch(new URL('/health', origin), { redirect: 'manual', signal: AbortSignal.timeout(10000), headers: { 'Cache-Control': 'no-cache', Accept: 'application/json' } });
52
+ if ((await response.json().catch(() => null))?.deployment?.versionId === versionId) return;
53
+ } catch {}
54
+ if (Date.now() >= deadline) throw ownerFailure(`the hosted app did not answer from the Worker version holding the new setup token within ${Math.round(boundMs / 1000)} seconds`);
55
+ await sleep(2000);
56
+ }
57
+ }
58
+
59
+ function ownerFailure(cause) {
60
+ const error = new Error('owner-claim-failed'); error.cause = cause; return error;
61
+ }
package/cli/deploy.mjs CHANGED
@@ -1,24 +1,33 @@
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 } from './deploy-inputs.mjs';
10
+ import { claimHostedOwner } from './deploy-owner.mjs';
10
11
 
11
12
  export function ownerDeploy(declarationPath, lifecycle) {
12
13
  // 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 () => {} }) => {
14
+ return Object.assign(async ({ root, options, store, signal, operationToken, localOnlyKeys = [], checkBuild = async () => {}, setupManifest, linkDependencies }) => {
14
15
  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
16
  const path = await contained(root, declarationPath);
16
17
  let declaration; try { declaration = JSON.parse(await readFile(path, 'utf8')); } catch { throw invalid('Malformed owner-account deploy declaration.'); }
17
18
  const local = localOnlyBinding(declaration, localOnlyKeys);
18
19
  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
20
  const environment = options.environment ?? 'production';
20
- await lifecycle.run(root, {}, store, () => {}, signal, { verb: 'build', operationToken });
21
- await checkBuild();
21
+ const again = `rer deploy --environment ${environment}`;
22
+ const change = [options.remove && '--remove', options.setSecret && '--set-secret', options.unsetSecret && '--unset-secret'].filter(Boolean);
23
+ // The command that finishes what this run started: the same verb, never a plain deploy in its place.
24
+ const retry = options.remove ? `${again} --remove --yes` : options.setSecret ? `${again} --set-secret ${options.setSecret}` : options.unsetSecret ? `${again} --unset-secret ${options.unsetSecret}` : again;
25
+ if (change.length > 1 || change.length && options.dryRun) throw invalid(`Use ${change.join(', ')}${options.dryRun ? ' and --dry-run' : ''} on separate runs.`);
26
+ 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.`);
27
+ if (!change.length) {
28
+ await lifecycle.run(root, {}, store, () => {}, signal, { verb: 'build', operationToken });
29
+ await checkBuild();
30
+ }
22
31
  if (options.dryRun) {
23
32
  // Locally validate the real declaration/artifact without ownerTarget's
24
33
  // account lookup. This synthetic target is validation data, not a provider.
@@ -30,22 +39,85 @@ export function ownerDeploy(declarationPath, lifecycle) {
30
39
  }
31
40
  const names = ['CLOUDFLARE_ACCOUNT_ID', 'CLOUDFLARE_API_TOKEN'];
32
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
+ const { env, supplied } = await deployEnvironment({ root, options, declaration, setupManifest, settings: lifecycle.settings, localOnlyKeys });
33
43
  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');
44
+ const { createRedaction, credentialValues } = await shared('redaction.mjs');
36
45
  const redaction = createRedaction();
37
- redaction.addSecrets([...credentialValues(process.env),process.env.CLOUDFLARE_ACCOUNT_ID,...(declaration.secrets ?? []).map(item=>process.env[item.environment]).filter(Boolean)]);
46
+ redaction.addSecrets([...credentialValues(env), env.CLOUDFLARE_ACCOUNT_ID, ...(declaration.secrets ?? []).map(item => env[item.environment]).filter(Boolean)]);
47
+ const claim = setupManifest?.steps?.find(item => item.type === 'owner-claim');
48
+ const declared = binding => (Array.isArray(declaration.secrets) ? declaration.secrets : []).find(item => item?.binding === binding);
49
+ const owner = (command, extra = [], values = env, more = {}) => main([command, '--declaration', declarationPath, '--environment', environment, ...extra], values, { cwd: root, localOnlyKeys, ...more });
38
50
  // Keep confirmed creation events across deploy and the independent health call.
39
51
  const created = [];
52
+ let step = 'deploy', deployed;
40
53
  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 };
54
+ if (options.remove) {
55
+ step = 'remove';
56
+ const result = await owner('remove');
57
+ store.report(`Removed ${result.removed.join(', ') || 'nothing (already gone)'}; read back: none of them remains.`);
58
+ return { removed: result.removed, deployed: false };
59
+ }
60
+ if (change.length) {
61
+ step = 'secrets';
62
+ const binding = options.setSecret ?? options.unsetSecret;
63
+ 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.`);
65
+ return { binding, present: Boolean(options.setSecret), deployed: false };
66
+ }
67
+ deployed = await owner('deploy', [], env, { notice: line => store.report(line), onCreated: resource => created.push(redaction.redact(resource)) });
68
+ const sent = supplied.filter(name => deployed.sentSecrets.includes(name));
69
+ if (sent.length) store.report(`rer supplied ${sent.join(', ')} from the local setup owner and fresh generated values.`);
70
+ step = 'health';
71
+ await owner('health');
72
+ step = 'claim owner';
73
+ 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 });
76
+ // A seed that claims its owner its own way links with the token it was given.
77
+ if (claimed === undefined) await linked(env[declared(claim?.tokenField ?? 'SETUP_TOKEN')?.environment]);
78
+ // The app claims the OWNER_EMAIL the Worker holds: the local one only when this run uploaded it.
79
+ const email = declared(claim?.emailField);
80
+ 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}.`);
82
+ return { deployed: true, origin: deployed.origin, ...(claimed ? { owner: claimed } : {}), dependenciesLinked: true };
44
83
  } 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'}.`);
84
+ if (error instanceof RerError) throw error;
85
+ const { commandFailure } = await shared('failures.mjs');
86
+ throw commandFailure(failureText({ error, step, created, again, retry, secretChange: Boolean(change.length && !options.remove), deployed, redact: redaction.redact }));
49
87
  }
50
88
  }, { declaration: declarationPath });
51
89
  }
90
+
91
+ // The failing step, its redacted cause, what this run created and the command that continues.
92
+ function failureText({ error, step, created, again, retry, secretChange, deployed, redact }) {
93
+ const diagnostic = deployDiagnostic(error, redact);
94
+ const code = diagnostic?.code ?? (/^[a-zA-Z0-9_-]{1,120}$/.test(error.message) ? redact(error.message) : 'deploy-failed');
95
+ const at = step === 'deploy' ? diagnostic?.step ?? 'prepare deploy' : step;
96
+ const input = /^missing-secret-([A-Z][A-Z0-9_]*)$/.exec(code)?.[1];
97
+ 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
+ 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
+ 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.`;
101
+ const resource = diagnostic?.resource ?? (error.resource ? redact(String(error.resource)) : undefined);
102
+ const lines = [`Deploy failed at ${at}: ${code}${resource ? ` (${resource})` : ''}${diagnostic?.providerCodes?.length ? ` (provider ${diagnostic.providerCodes.join(', ')})` : ''}.`];
103
+ if (diagnostic?.output && diagnostic.output !== '(no provider output)') lines.push(`First redacted output lines:\n${diagnostic.output}`);
104
+ lines.push(`Resources created this attempt: ${created.join(', ') || 'none'}.`);
105
+ lines.push(recovery({ code, at, resource, again, retry, secretChange }));
106
+ return lines.join('\n');
107
+ }
108
+
109
+ function recovery({ code, at, resource, again, retry, secretChange }) {
110
+ const remove = `${again} --remove --yes`, removing = retry === remove;
111
+ 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.`;
112
+ // A lost create answer: the resource may exist, and rer cannot prove it made it.
113
+ if (resource && (code === 'uncertain-resource-create' || code === 'provider-transport-failed' && at.startsWith('create '))) {
114
+ 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.`;
115
+ 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.`}`;
116
+ }
117
+ 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.`;
118
+ 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.`;
119
+ 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).`;
120
+ if (removing) return `Fix the cause, then run ${retry} again.`;
121
+ if (secretChange) return `Fix the cause, then run ${retry} again; a plain ${again} does not make this change.`;
122
+ return `Fix the cause, then run ${again} again: it continues with what was created. To delete what this environment created instead: ${remove}.`;
123
+ }
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/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
@@ -19,7 +19,7 @@ import { announceDoorRoute, routeRunning, verifyDoorFor } from './door-mode.mjs'
19
19
  export async function runCommand(project, options, out) {
20
20
  if(project.profile==='first-party'&&['dev','preview'].includes(options.verb))return (await import('./managed-server.mjs')).managedServer(project,options,out);
21
21
  const cancellation = new AbortController(), signal = cancellation.signal;
22
- let termination, operation, store, temporary, proxy;
22
+ let termination, operation, store, temporary, proxy, failed = false;
23
23
  const handlers = new Map(['SIGINT', 'SIGTERM'].map(name => [name, () => { if (!termination) { termination = name; cancellation.abort(name); } }]));
24
24
  for (const [name, handler] of handlers) process.on(name, handler);
25
25
  try {
@@ -85,8 +85,10 @@ export async function runCommand(project, options, out) {
85
85
  if (['export', 'import'].includes(options.verb)) return await transfer(project, options, signal, { store, operationToken: operation.token });
86
86
  if (options.verb === 'deploy') {
87
87
  if (typeof adapter.deploy !== 'function') throw refuse('Owner-account deploy declaration is missing.');
88
- const deployed = await adapter.deploy({ root: project.root, options, store, signal, operationToken: operation.token, localOnlyKeys: adapter.localOnlyKeys ?? [], checkBuild: () => refuseDevLayerOutput(project.root, adapter.devIdentity) });
89
- if(deployed.deployed && !options.dryRun) await linkDependencies({project,input:await savedSetupAnswers(project,project.setupSeed.model),origin:deployed.origin,store,signal,appSetupToken:process.env[project.setupManifest?.steps.find(step=>step.type==='owner-claim')?.tokenField??'SETUP_TOKEN']});
88
+ const link = async (origin, appSetupToken) => linkDependencies({project,input:project.setupSeed?await savedSetupAnswers(project,project.setupSeed.model):undefined,origin,store,signal,appSetupToken});
89
+ const deployed = await adapter.deploy({ root: project.root, options, store, signal, operationToken: operation.token, localOnlyKeys: adapter.localOnlyKeys ?? [], checkBuild: () => refuseDevLayerOutput(project.root, adapter.devIdentity), setupManifest: project.setupManifest, linkDependencies: link });
90
+ // Base's owner-account deploy links while its setup token is live; another deploy hook is linked here.
91
+ if(deployed.deployed && !options.dryRun && !deployed.dependenciesLinked) await link(deployed.origin,process.env[project.setupManifest?.steps.find(step=>step.type==='owner-claim')?.tokenField??'SETUP_TOKEN']);
90
92
  return deployed;
91
93
  }
92
94
  // Setup writes the checkout's dev key; its temporary app and the CLI's own
@@ -135,10 +137,18 @@ export async function runCommand(project, options, out) {
135
137
  } else if (options.verb !== 'build') throw refuse('The requested first-party capability is unavailable.');
136
138
  return {};
137
139
  } catch (error) {
140
+ failed = true;
138
141
  if (error instanceof RerError) throw error;
139
142
  throw await lifecycleError(error, store);
140
143
  } finally {
141
- await store?.closeCommands(); await proxy?.close(); await operation?.release();
144
+ // Every closer runs: one that fails must not leave the job-queue policy socket
145
+ // listening (the process would never exit) or the local operation held, and
146
+ // must not turn a finished command into a failure or hide the real one.
147
+ const unclosed = [];
148
+ for (const close of [() => store?.closeCommands(), () => proxy?.close(), () => operation?.release()]) {
149
+ try { await close(); } catch (error) { unclosed.push(store?.redact?.(String(error?.message)) ?? 'unknown cause'); }
150
+ }
151
+ if (unclosed.length && !failed) out.progress(`Local cleanup could not confirm that every helper process stopped (${unclosed.join('; ')}). The ${options.verb} itself finished.\n`);
142
152
  if (temporary) await rm(temporary, { recursive: true, force: true });
143
153
  for (const [name, handler] of handlers) process.removeListener(name, handler);
144
154
  if (termination) throw new RerError('cancelled', `Stopped by ${termination} after owned children were stopped.`, termination === 'SIGINT' ? 130 : 143);
package/cli/parse.mjs CHANGED
@@ -2,7 +2,7 @@ import { resolve } from 'node:path';
2
2
  import { commonFlags, serverDefaults, verbs } from './contract.mjs';
3
3
  import { invalid, refuse } from './errors.mjs';
4
4
  import { isLoopbackHost } from '../modules/operator-auth/loopback.mjs';
5
- const booleans = new Set(['--help', '--json', '--non-interactive', '--yes', '--dry-run', '--open', '--stop', '--detach', '--foreground', '--no-queue', '--login', '--legacy-v1', '--acknowledge-unverified-metadata', '--door', '--direct', '--site']);
5
+ const booleans = new Set(['--help', '--json', '--non-interactive', '--yes', '--dry-run', '--open', '--stop', '--detach', '--foreground', '--no-queue', '--login', '--legacy-v1', '--acknowledge-unverified-metadata', '--door', '--direct', '--site', '--remove']);
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.
@@ -72,6 +72,8 @@ export function parse(argv, cwd = process.cwd(), { independent = false, deferTai
72
72
  if (options.target !== undefined && !['local', 'remote'].includes(options.target)) throw invalid('Target must be local or remote.');
73
73
  if (options.profile !== undefined && options.profile !== 'first-party') throw invalid('The supported explicit profile is first-party.');
74
74
  if (options.environment !== undefined && !/^[a-z][a-z0-9-]{0,39}$/.test(options.environment)) throw invalid('Provide a valid deployment environment name.');
75
+ for (const key of ['setSecret', 'unsetSecret']) if (options[key] !== undefined && !/^[A-Z][A-Z0-9_]{0,63}$/.test(options[key])) throw invalid('Name a declared secret binding, such as SESSION_SECRET.');
76
+ if ((options.remove || options.setSecret || options.unsetSecret) && options.site) throw invalid('--remove, --set-secret and --unset-secret act on the application Worker, not the run site.');
75
77
  if (options.origin !== undefined) {
76
78
  let url; try { url = new URL(options.origin); } catch { throw invalid('Provide a valid credential-free origin.'); }
77
79
  if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash || url.pathname !== '/') throw invalid('Provide a credential-free HTTP origin without a path.');