@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
@@ -1,12 +1,162 @@
1
1
  import { memberName, stringValue, walkSyntax } from './site-syntax.mjs';
2
2
  import { localValues } from './site-bindings.mjs';
3
3
  import { browserObjects } from './site-browser-objects.mjs';
4
- import { markupReferences, cssReferences, markupStrings, cssStrings } from './site-markup.mjs';
4
+ import { markupReferences, cssReferences, markupStrings, cssStrings, htmlElements } from './site-markup.mjs';
5
5
 
6
- export function javascriptContent(tree) {
6
+ // A firstChild expression is not a type proof: its parent may contain an
7
+ // object element. Admit CharacterData only from an unambiguous emitted text
8
+ // child, consistent across all pages, with no script that can replace it.
9
+ export function javascriptContext(documents, trees) {
10
+ const children = new Map();
11
+ // Recognize a balanced subset of ordinary HTML, rather than guessing at
12
+ // browser repair rules. Tables, foreign content, templates and formatting
13
+ // reconstruction leave every node unknown. Common page links, headings and
14
+ // buttons are safe when explicitly closed in their ordinary insertion mode.
15
+ const containers = new Set('p div span section article aside nav header footer main object a button h1 h2 h3 h4 h5 h6 ul ol li dl dt dd figure figcaption details summary'.split(' '));
16
+ const voids = new Set('br hr img input link meta wbr'.split(' '));
17
+ const raw = new Set(['script','style','title','textarea']);
18
+ const ordinary = new Set([...containers, ...voids, ...raw, 'html','head','body']);
19
+ const blocks = new Set('p div section article aside nav header footer main h1 h2 h3 h4 h5 h6 ul ol li dl dt dd figure figcaption details summary hr'.split(' '));
20
+ const unknown = () => ({textParents:new Set()});
21
+ for (const text of documents) {
22
+ const seen = new Set(), structure = new Set(), stack = [];
23
+ const tags = /<!--[\s\S]*?(?:-->|$)|<!doctype\s+html\s*>|<\/?([a-z][a-z0-9:-]*)\b((?:[^"'<>]|"[^"]*"|'[^']*')*)>|[^<]+|</gi;
24
+ for (let match; (match = tags.exec(text));) {
25
+ if (match[0] === '<') return unknown();
26
+ if (match[0].startsWith('<!--') && !/^<!--(?:[^<>-]|-(?!-))*-->$/.test(match[0])) return unknown();
27
+ if (!match[1]) continue;
28
+ const tag = match[1].toLowerCase();
29
+ if (!ordinary.has(tag)) return unknown();
30
+ if (match[0].startsWith('</')) {
31
+ if (match[2].trim() || stack.pop() !== tag) return unknown();
32
+ continue;
33
+ }
34
+ // Reject malformed attributes and non-void self-closing tags, which HTML
35
+ // parses differently from XML. Never infer text from the repaired tree.
36
+ if (!/^(?:\s+[^\s"'<>/=]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'=<>`]+))?)*\s*\/?$/.test(match[2]) || (!voids.has(tag) && /\/\s*$/.test(match[2]))) return unknown();
37
+ if (stack.includes('head') && !['meta','link','title','style','script'].includes(tag)) return unknown();
38
+ if ((blocks.has(tag) && stack.includes('p')) || (['a','button','li'].includes(tag) && stack.includes(tag)) || (/^h[1-6]$/.test(tag) && stack.some(parent => /^h[1-6]$/.test(parent))) || (['dt','dd'].includes(tag) && stack.some(parent => ['dt','dd'].includes(parent)))) return unknown();
39
+ if (['html','head','body'].includes(tag)) {
40
+ if (structure.has(tag) || stack.some(parent => parent !== 'html') || (tag === 'html' && stack.length) || (tag === 'head' && structure.has('body'))) return unknown();
41
+ structure.add(tag);
42
+ }
43
+ const {attributes} = htmlElements(match[0])[0];
44
+ if ([...attributes.keys()].some(name => name.startsWith('on'))) return unknown();
45
+ const id = attributes.get('id');
46
+ if (id !== undefined) {
47
+ const firstText = /^[^<]*/.exec(text.slice(tags.lastIndex))[0].replace(/\0/g, '');
48
+ const textChild = containers.has(tag) && firstText.length > 0;
49
+ children.set(id, textChild && !seen.has(id) && children.get(id) !== false);
50
+ seen.add(id);
51
+ }
52
+ if (raw.has(tag)) {
53
+ if (tag === 'script' && !/^(?:|module|(?:text|application)\/(?:java|ecma)script|application\/(?:ld\+)?json)$/i.test(attributes.get('type') ?? '')) return unknown();
54
+ const closing = new RegExp('</' + tag + '(?=[\\s/>])[^>]*>', 'ig'); closing.lastIndex = tags.lastIndex;
55
+ const end = closing.exec(text);
56
+ if (!end || !new RegExp('^</' + tag + '\\s*>$', 'i').test(end[0])) return unknown();
57
+ if (tag === 'script' && /<!--|<script\b/i.test(text.slice(tags.lastIndex, end.index))) return unknown();
58
+ tags.lastIndex = closing.lastIndex;
59
+ } else if (!voids.has(tag)) stack.push(tag);
60
+ }
61
+ if (stack.length) return unknown();
62
+ }
63
+ // Include every emitted script: a different island can change the selected
64
+ // element, its id or its children before this assignment runs.
65
+ const nativeMethods = new Set(['querySelector','querySelectorAll','getElementById','getElementsByTagName','getElementsByClassName','getElementsByName','createElement','createElementNS','getAttribute','hasAttribute','addEventListener','removeEventListener','setAttribute','setAttributeNS','removeAttribute','removeAttributeNS']);
66
+ const reflectiveMethods = new Set(['assign','defineProperty','defineProperties','setPrototypeOf','set','deleteProperty']);
67
+ const writes = new Set(['id','innerHTML','outerHTML','textContent','innerText','outerText','srcdoc','document','head','firstChild','__proto__',...nativeMethods,...reflectiveMethods]);
68
+ const changes = new Set(['before','after','append','prepend','appendChild','removeChild','replaceChild','replaceChildren','insertBefore','insertAdjacentHTML','insertAdjacentElement','insertAdjacentText','replaceWith','remove','write','writeln','setHTML','setHTMLUnsafe','normalize','insertNode','surroundContents','extractContents','deleteContents','adoptNode','execCommand','setAttributeNode','setAttributeNodeNS','__defineGetter__','__defineSetter__']);
69
+ let stable = true;
70
+ for (const tree of trees) {
71
+ const values = localValues(tree), browser = browserObjects(values);
72
+ // A newly created metadata link appended to the head cannot replace a
73
+ // selected text child in the body. Its resource URLs still get checked.
74
+ // Requiring fresh native nodes also excludes moving an existing child out
75
+ // of a text parent. Any id write above still invalidates this proof.
76
+ const documentObject = node => {
77
+ const alternatives = values.origins(node);
78
+ return alternatives.length > 0 && alternatives.every(browser.document);
79
+ };
80
+ const plainObject = node => {
81
+ const receivers = values.origins(node);
82
+ return receivers.length > 0 && receivers.every(value => value.type === 'ObjectExpression' && value.properties.every(property => property.type === 'Property' && property.kind === 'init'));
83
+ };
84
+ // Selector, traversal and head-link proofs depend on native DOM methods.
85
+ // Writes to those members, including prototype mutation through reflection,
86
+ // remove the proof across all emitted scripts. A known fresh plain object
87
+ // can still receive ordinary metadata without changing the native APIs.
88
+ const reflectiveMutation = node => values.names(node).some(name => reflectiveMethods.has(name))
89
+ && ['Object','Reflect'].some(name => values.global(node.object, name));
90
+ const writeTargets = node => {
91
+ if (!node) return [];
92
+ if (node.type === 'ObjectPattern') return node.properties.flatMap(property => writeTargets(property.value ?? property.argument));
93
+ if (node.type === 'ArrayPattern') return node.elements.flatMap(writeTargets);
94
+ if (node.type === 'AssignmentPattern') return writeTargets(node.left);
95
+ if (node.type === 'RestElement') return writeTargets(node.argument);
96
+ return [node];
97
+ };
98
+ const headLink = node => {
99
+ if (node?.type !== 'CallExpression' || memberName(node.callee) !== 'appendChild' || node.arguments.length !== 1) return false;
100
+ const receivers = values.origins(node.callee.object), arguments_ = values.origins(node.arguments[0]);
101
+ return receivers.length > 0 && receivers.every(receiver => memberName(receiver) === 'head' && documentObject(receiver.object))
102
+ && arguments_.length > 0 && arguments_.every(value => value.type === 'CallExpression' && memberName(value.callee) === 'createElement' && value.arguments.length === 1
103
+ && documentObject(value.callee.object) && values.resources(value.arguments[0]).every(tag => tag.complete && tag.value.toLowerCase() === 'link'));
104
+ };
105
+ walkSyntax(tree, (node, parents) => {
106
+ const target = node.type === 'AssignmentExpression' ? node.left : node.type === 'UpdateExpression' || (node.type === 'UnaryExpression' && node.operator === 'delete') ? node.argument : undefined;
107
+ for (const write of writeTargets(target)) {
108
+ if (write.type === 'Identifier' && values.global(write, 'document')) stable = false;
109
+ if (write.type !== 'MemberExpression') continue;
110
+ const fields = values.names(write);
111
+ if ((!fields.length && !plainObject(write.object)) || fields.some(name => writes.has(name))) stable = false;
112
+ }
113
+ if (node.type === 'Property' && values.names(node).some(name => writes.has(name) || changes.has(name))) stable = false;
114
+ if (node.type === 'MemberExpression' && values.names(node).some(name => name === 'attributes' || changes.has(name)) && !headLink(parents.at(-1))) stable = false;
115
+ if (node.type === 'MemberExpression' && reflectiveMutation(node)) {
116
+ const call = parents.at(-1);
117
+ if (call?.type !== 'CallExpression' || call.callee !== node || !plainObject(call.arguments[0])) stable = false;
118
+ }
119
+ if (!['CallExpression','NewExpression'].includes(node.type)) return;
120
+ if (values.global(node.callee, 'eval') || values.global(node.callee, 'Function')) stable = false;
121
+ const appendsHeadLink = headLink(node);
122
+ for (const callee of values.origins(node.callee)) {
123
+ const methods = values.names(callee);
124
+ if (reflectiveMutation(callee) && !plainObject(node.arguments[0])) stable = false;
125
+ if (methods.some(name => changes.has(name)) && !appendsHeadLink) stable = false;
126
+ if (methods.some(name => ['setAttribute','setAttributeNS','removeAttribute','removeAttributeNS'].includes(name))) {
127
+ const index = methods.some(name => name.endsWith('NS')) ? 1 : 0, attributes = values.resources(node.arguments[index]);
128
+ if (attributes.some(({value, complete}) => !complete || /^(?:id|on.*)$/i.test(value))) stable = false;
129
+ } else if (!appendsHeadLink && node.arguments.some(argument => browser.dom(argument))) stable = false;
130
+ if (!appendsHeadLink && callee.type === 'MemberExpression' && (!methods.length || (browser.dom(callee.object) && methods.some(name => !nativeMethods.has(name))))) stable = false;
131
+ }
132
+ });
133
+ }
134
+ return {textParents:new Set(stable ? [...children].filter(([,text]) => text).map(([id]) => id) : [])};
135
+ }
136
+
137
+ export function javascriptContent(tree, {textParents = new Set()} = {}) {
7
138
  const values = localValues(tree), {strings, names, resources, origins, global, localClass} = values;
8
139
  const browser = browserObjects(values), found = [], literals = [], unresolved = [];
9
140
  const properties = new Set(['src','href','srcset','action','formaction','poster','data']);
141
+ // Unknown DOM nodes retain the data URL check. A selector or traversal
142
+ // alone cannot distinguish HTMLObjectElement from CharacterData.
143
+ const characterData = node => {
144
+ const alternatives = origins(node);
145
+ return alternatives.length > 0 && alternatives.every(value => {
146
+ if (value.type !== 'MemberExpression' || memberName(value) !== 'firstChild') return false;
147
+ const parents = origins(value.object);
148
+ return parents.length > 0 && parents.every(parent => {
149
+ if (parent.type !== 'CallExpression') return false;
150
+ const receivers = origins(parent.callee.object);
151
+ if (!receivers.length || !receivers.every(browser.document)) return false;
152
+ const method = memberName(parent.callee), selectors = resources(parent.arguments[0]);
153
+ return ['querySelector','getElementById'].includes(method) && selectors.length > 0 && selectors.every(({value, complete}) => {
154
+ const id = method === 'getElementById' ? value : /^#([a-z_][\w-]*)$/i.exec(value)?.[1];
155
+ return complete && id !== undefined && textParents.has(id);
156
+ });
157
+ });
158
+ });
159
+ };
10
160
  const elementFactory = callee => {
11
161
  const methods = names(callee);
12
162
  if (!methods.some(name => ['createElement','jsx','jsxs','jsxDEV'].includes(name)) || localClass(callee.object)) return false;
@@ -38,7 +188,7 @@ export function javascriptContent(tree) {
38
188
  if (browser.location(node.left)) add(node.right, 'location.href');
39
189
  for (const property of fields) {
40
190
  const name = property.toLowerCase();
41
- if (properties.has(name) && browser.dom(object)) add(node.right, `DOM.${property}`, name === 'href' && browser.anchor(object), true, name === 'srcset');
191
+ if (properties.has(name) && browser.dom(object) && (name !== 'data' || !characterData(object))) add(node.right, `DOM.${property}`, name === 'href' && browser.anchor(object), true, name === 'srcset');
42
192
  if (property === 'href' && browser.location(object)) add(node.right, 'location.href');
43
193
  if (['innerHTML','outerHTML','srcdoc'].includes(property)) markup(node.right, browser.dom(object));
44
194
  }
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.2
4
+
5
+ - `rer deploy` claims the hosted owner, exits cleanly when it finishes, changes and removes secrets safely, and removes the resources it created when you ask it to.
6
+ - Local restore runs the seed's own restore-validation policies, so a seed no longer needs its own patch or adapter for them.
7
+ - Hosted exports stay portable: the deploy kit's migration ledger is kept out of seed archives, compound D1 migrations such as triggers apply, and an archive or import target with uncertain migration history is refused.
8
+ - Package-mode guidance in the exported docs names the commands a package-mode seed actually runs, and the local-run docs describe the sign-in outbox.
9
+ - `rer release --help` and the job-queue help print their usage.
10
+ - `rer describe` reports the effects a seed declares for each command instead of generic account effects.
11
+ - Linked OIDC providers report health from the token exchange, stay healthy when one authorization code is rejected, and send failures to the configured sign-in path.
12
+ - The run-site privacy check catches registered secrets inside prefixed base64 and accepts constant text that a client island imports.
13
+ - Fresh rer launches provision pnpm, prefer the provisioned pnpm, keep state under your HOME, and clean up their probe processes.
14
+ - Base can export a seed Verify workflow with source freshness checks for manual adoption.
15
+ - Base's default seed and source import kit erase a person in bounded steps after retiring their imported custody; this kit is outside the rer npm package.
16
+
3
17
  ## 0.2.1
4
18
 
5
19
  - `rer release keygen` prepares publisher keys and anchor entries; `rer release sign` writes a detached signature for a completed tagged release.
package/global/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.
@@ -2,10 +2,12 @@
2
2
  // version directory, switch `current` atomically, keep the previous version,
3
3
  // prune older ones and write the launcher. Never edits shell profiles.
4
4
  import { randomUUID } from 'node:crypto';
5
- import { chmod, cp, mkdir, readdir, readlink, realpath, rename, rm, symlink, writeFile } from 'node:fs/promises';
5
+ import { access, constants, chmod, cp, mkdir, readdir, readlink, realpath, rename, rm, symlink, writeFile } from 'node:fs/promises';
6
6
  import { basename, join, resolve } from 'node:path';
7
7
  import { ownManifest, ownRoot, packageDigest, packageFileList } from '../package-info.mjs';
8
8
  import { installRecord, launcherOwner, launcherScript, layout } from './layout.mjs';
9
+ import { exposeLauncher } from './path-link.mjs';
10
+ import { shellQuote } from '../../cli/node-runtime-cache.mjs';
9
11
  import { runningDoor } from '../../cli/door.mjs';
10
12
 
11
13
  async function readLink(path) { try { return await readlink(path); } catch (error) { if (['ENOENT', 'EINVAL'].includes(error.code)) return undefined; throw error; } }
@@ -38,13 +40,20 @@ async function writeLauncher(paths) {
38
40
  await rename(temporary, paths.launcher);
39
41
  }
40
42
 
41
- export function pathAdvice(paths, env = process.env) {
43
+ export async function pathAdvice(paths, env = process.env) {
42
44
  const entries = (env.PATH ?? '').split(':').filter(Boolean).map(entry => resolve(entry));
43
- if (entries.includes(resolve(paths.bin))) return undefined;
44
- const shell = basename(env.SHELL ?? '');
45
- if (shell === 'fish') return `${paths.bin} is not on your PATH. Run this once in fish, then open a new terminal:\n fish_add_path $HOME/.local/bin`;
46
- const profile = shell === 'zsh' ? '~/.zshrc' : shell === 'bash' ? (process.platform === 'darwin' ? '~/.bash_profile' : '~/.bashrc') : '~/.profile';
47
- return `${paths.bin} is not on your PATH. Add this line to ${profile}, then open a new terminal:\n export PATH="$HOME/.local/bin:$PATH"`;
45
+ for (const directory of entries) {
46
+ if (directory === resolve(paths.bin)) return undefined;
47
+ const candidate = join(directory, 'rer');
48
+ try {
49
+ await access(candidate, constants.X_OK);
50
+ const found = await launcherOwner(await realpath(candidate));
51
+ if (found.state === 'ours' && found.root === paths.data) return undefined;
52
+ break; // This command shadows the canonical launcher, even if it is later on PATH.
53
+ } catch (error) { if (!['ENOENT', 'EACCES', 'ENOTDIR'].includes(error.code)) throw error; }
54
+ }
55
+ if (basename(env.SHELL ?? '') === 'fish') return `Activate rer in this shell:\n set -gx PATH ${shellQuote(paths.bin)} $PATH`;
56
+ return `Activate rer in this shell:\n export PATH=${shellQuote(paths.bin)}:"$PATH"`;
48
57
  }
49
58
 
50
59
  export async function install() {
@@ -59,8 +68,8 @@ export async function install() {
59
68
  const script = launcherScript(paths.data);
60
69
  let present = false;
61
70
  try { present = await packageDigest(join(paths.versions, name)) === digest; } catch (error) { if (error.code !== 'ENOENT') throw error; }
62
- const advice = pathAdvice(paths);
63
71
  if (present && current === join('versions', name) && launcher.text === script && record?.channel === channel) {
72
+ const advice = await activation(paths, record);
64
73
  process.stdout.write(`rer ${manifest.version} is already current (${join(paths.versions, name)}). Nothing changed.\n${advice ? advice + '\n' : ''}`);
65
74
  return 0;
66
75
  }
@@ -72,8 +81,16 @@ export async function install() {
72
81
  const doorVersion = door?.versionDir && resolve(door.versionDir, '..') === await realpath(paths.versions).catch(() => resolve(paths.versions)) ? basename(door.versionDir) : undefined;
73
82
  const keep = new Set([name, previousName, doorVersion].filter(Boolean));
74
83
  for (const entry of await readdir(paths.versions)) if (!keep.has(entry) && !entry.startsWith('.staging-')) await rm(join(paths.versions, entry), { recursive: true, force: true });
75
- await writeFile(paths.record, JSON.stringify({ channel, previous: previousName && keep.has(previousName) ? previousName : null }, null, 2) + '\n', { mode: 0o644 });
84
+ await writeFile(paths.record, JSON.stringify({ channel, links: record?.links ?? [], previous: previousName && keep.has(previousName) ? previousName : null }, null, 2) + '\n', { mode: 0o644 });
76
85
  if (launcher.text !== script) await writeLauncher(paths);
86
+ const advice = await activation(paths, await installRecord(paths));
77
87
  process.stdout.write(`Installed rer ${manifest.version} in ${join(paths.versions, name)}.\n${paths.launcher} runs it.${previousName ? ` The previous version (${previousName}) is kept.` : ''}\n${advice ? advice + '\n' : ''}`);
78
88
  return 0;
79
89
  }
90
+
91
+ async function activation(paths, record) {
92
+ const link = await exposeLauncher(paths);
93
+ const links = [...new Set([...(record?.links ?? []), ...(link ? [link] : [])])];
94
+ if (JSON.stringify(record?.links ?? []) !== JSON.stringify(links)) await writeFile(paths.record, JSON.stringify({ ...record, links }, null, 2) + '\n', { mode: 0o644 });
95
+ return link ? undefined : await pathAdvice(paths);
96
+ }
@@ -0,0 +1,30 @@
1
+ // Offer the canonical launcher through an existing user-owned PATH directory.
2
+ // Foreign commands and links are never replaced, including during uninstall.
3
+ import { access, constants, lstat, readlink, symlink } from 'node:fs/promises';
4
+ import { delimiter, isAbsolute, join, resolve } from 'node:path';
5
+
6
+ export async function ownedPathLink(path, launcher) {
7
+ try { return (await lstat(path)).isSymbolicLink() && (await readlink(path)) === resolve(launcher); }
8
+ catch (error) { if (error.code === 'ENOENT' || error.code === 'EINVAL') return false; throw error; }
9
+ }
10
+ export async function exposeLauncher(paths, env = process.env) {
11
+ for (const entry of (env.PATH ?? '').split(delimiter).filter(Boolean)) {
12
+ const directory = resolve(entry), candidate = join(directory, 'rer');
13
+ if (directory === resolve(paths.bin)) return undefined;
14
+ try {
15
+ try {
16
+ await lstat(candidate);
17
+ if (await ownedPathLink(candidate, paths.launcher)) return candidate;
18
+ await access(candidate, constants.X_OK);
19
+ return undefined;
20
+ } catch (error) { if (error.code !== 'ENOENT') continue; }
21
+ // npm/npx add transient command directories; never install into them.
22
+ if (!isAbsolute(entry) || directory.split('/').includes('node_modules') || directory.split('/').includes('_npx')) continue;
23
+ const info = await lstat(directory);
24
+ if (!info.isDirectory() || !process.getuid || info.uid !== process.getuid() || (!(info.mode & 0o200) || (info.mode & 0o022))) continue;
25
+ await access(directory, constants.W_OK | constants.X_OK);
26
+ try { await symlink(paths.launcher, candidate); return candidate; }
27
+ catch (error) { if (error.code !== 'EEXIST' && error.code !== 'EACCES' && error.code !== 'EPERM') throw error; }
28
+ } catch (error) { if (!['ENOENT', 'EACCES', 'ENOTDIR'].includes(error.code)) throw error; }
29
+ }
30
+ }
@@ -24,7 +24,7 @@ export async function pathCommands(installRoot, env = process.env) {
24
24
  seen.add(candidate);
25
25
  try { await access(candidate, constants.X_OK); } catch { continue; }
26
26
  const target = await real(candidate);
27
- const launcher = await launcherOwner(candidate);
27
+ const launcher = await launcherOwner(target ?? candidate);
28
28
  if (target && inside(target, ownRoot) || launcher.state === 'ours' && launcher.root && await real(launcher.root) === installRoot) self.push(candidate);
29
29
  else others.push(candidate);
30
30
  }
@@ -4,7 +4,8 @@
4
4
  // root. An npm global rer leaves other channels' installs alone.
5
5
  import { lstat, readlink, rm } from 'node:fs/promises';
6
6
  import { join } from 'node:path';
7
- import { launcherOwner, layout } from './layout.mjs';
7
+ import { installRecord, launcherOwner, layout } from './layout.mjs';
8
+ import { ownedPathLink } from './path-link.mjs';
8
9
  import { channelOf } from './status.mjs';
9
10
 
10
11
  // A folder `rer cli install` made: a versions/ directory and a `current` link into it.
@@ -26,6 +27,9 @@ export async function uninstall() {
26
27
  process.stdout.write(lines.join('\n') + '\n');
27
28
  return 0;
28
29
  }
30
+ for (const link of (await installRecord(paths))?.links ?? []) {
31
+ if (await ownedPathLink(link, paths.launcher)) { await rm(link); lines.push(`Removed ${link}.`); }
32
+ }
29
33
  if (launcher.state === 'ours') { await rm(paths.launcher); lines.push(`Removed ${paths.launcher}.`); }
30
34
  else if (launcher.state === 'foreign') lines.push(`Left ${paths.launcher} in place: rer cli install did not write it.`);
31
35
  if (await installRoot(root)) { await rm(root, { recursive: true, force: true }); lines.push(`Removed ${root}.`); }
@@ -5,10 +5,14 @@
5
5
  import { spawn } from 'node:child_process';
6
6
  import { pinnedPackage } from '../checkout.mjs';
7
7
  import { packageName } from '../package-info.mjs';
8
+ import { ensureProjectPnpm } from '../../cli/project-pnpm.mjs';
9
+ import { RerError } from '../../cli/errors.mjs';
8
10
  import { LaunchFailure } from './failure.mjs';
9
11
 
10
12
  export async function installPinned(root, { env = process.env, output = text => process.stderr.write(text), children, signal } = {}) {
11
13
  signal?.throwIfAborted();
14
+ try { await ensureProjectPnpm(root, { env, signal, report: text => output(text + '\n') }); }
15
+ catch (error) { if (error instanceof RerError) throw new LaunchFailure(error.exitCode, error.message); throw error; }
12
16
  const child = spawn('pnpm', ['install', '--frozen-lockfile'], { cwd: root, env: { ...env, CI: 'true' }, stdio: ['ignore', 'pipe', 'pipe'] });
13
17
  children?.add(child);
14
18
  if (signal?.aborted) child.kill('SIGTERM');
package/global/main.mjs CHANGED
@@ -8,7 +8,7 @@ const help = `Usage: rer VERB [FLAGS]
8
8
 
9
9
  Inside a project (the nearest OPERATIONS.json at or above this folder, or --cwd DIR),
10
10
  every verb runs that checkout's own pinned rer: the @runeditrun/rer version its package.json
11
- pins (installed first by rer install or rer dev), else its vendored scripts/rer.mjs.
11
+ pins (installed first by rer install, rer setup or rer dev), else its vendored scripts/rer.mjs.
12
12
 
13
13
  rer dev Launch a first-party checkout with no setup steps: installs dependencies,
14
14
  sets up a local owner and a free port when needed, starts the app and prints
@@ -35,12 +35,16 @@ outbox, doctor, describe, check, release. Run rer VERB --help in a checkout for
35
35
  `;
36
36
 
37
37
  // The verbs that install an uninstalled pinned package before they run.
38
- const installsFirst = new Set(['install', 'check', 'describe']);
38
+ const installsFirst = new Set(['install', 'setup', 'check', 'describe']);
39
39
 
40
40
  export async function main(argv) {
41
41
  handleBrokenPipes();
42
42
  const [verb] = argv;
43
43
  if (verb === '--version' || verb === '-v') return (await import('./version.mjs')).versionCommand(argv);
44
+ if (verb === 'queue' && argv.length === 2 && argv[1] === '--help') {
45
+ process.stdout.write('Usage: rer queue NAME -- COMMAND [ARGS]\nRun COMMAND once this computer\'s job queue admits it.\n');
46
+ return 0;
47
+ }
44
48
  // Only the zero-setup launch sets this, for its own setup child; an explicit
45
49
  // verb never inherits it.
46
50
  delete process.env[localDefaults];
@@ -54,6 +58,27 @@ export async function main(argv) {
54
58
  const target = locate(argv);
55
59
  if (target.profile === 'unsupported') { process.stderr.write(unsupportedMessage(target.root, target.version) + '\n'); return 2; }
56
60
  const first = target.profile === 'first-party', mode = first ? dispatcher(target.root).mode : undefined;
61
+ if (first) {
62
+ // Carry the effective directories to older dispatchers that used account HOME.
63
+ const { serversDirectory } = await import('./registry.mjs');
64
+ const { jobsDirectory } = await import('../modules/job-queue/config.mjs');
65
+ process.env.RER_SERVERS_DIR = serversDirectory(process.env);
66
+ process.env.RER_JOBS_DIR = jobsDirectory(process.env);
67
+ const bootstrap = pinnedPackage(target.root)?.ready === false && installsFirst.has(verb);
68
+ if ((bootstrap || ['install', 'setup', 'dev', 'preview', 'build', 'verify'].includes(verb)) && !argv.includes('--help') && !argv.includes('--stop')) {
69
+ const { parse } = await import('../cli/parse.mjs');
70
+ // The door's launch marker belongs to launchDev, which consumes it before
71
+ // calling the checkout's public dispatcher. Validate the same public args
72
+ // here, before provisioning the checkout's package manager.
73
+ const publicArgs = verb === 'dev' ? target.args.filter(arg => arg !== '--door-start') : target.args;
74
+ try { parse(publicArgs, target.root); }
75
+ catch (error) { process.stderr.write(error.message + '\n'); return error.exitCode ?? 2; }
76
+ const { ensureProjectPnpm } = await import('../cli/project-pnpm.mjs');
77
+ const { RerError } = await import('../cli/errors.mjs');
78
+ try { await ensureProjectPnpm(target.root, { report: text => process.stderr.write(text + '\n') }); }
79
+ catch (error) { if (!(error instanceof RerError)) throw error; process.stderr.write(error.message + '\n'); return error.exitCode; }
80
+ }
81
+ }
57
82
  if (verb === 'queue') {
58
83
  const [, name, separator, command] = argv;
59
84
  if (!name || separator !== '--' || !command) { process.stderr.write('Usage: rer queue NAME -- COMMAND [ARGS]\n'); return 2; }
@@ -61,7 +86,7 @@ export async function main(argv) {
61
86
  return (await import('../onboarding/queued-entry.mjs')).queuedCommandLine(argv.slice(1));
62
87
  }
63
88
  // A checkout that pins its own rer package runs all of it, launching included.
64
- // Until node_modules holds the pinned version, install, check and describe
89
+ // Until node_modules holds the pinned version, install, setup, check and describe
65
90
  // install it with the frozen lockfile (then run the pinned verb, so a fresh
66
91
  // clone checks with the pinned bytes), the zero-setup launch installs it
67
92
  // first, and every other verb says to install.
@@ -1,11 +1,11 @@
1
1
  // Read-only view of the shared server registry that every pinned dispatcher
2
2
  // writes (RER_SERVERS_DIR, else the account's ~/.rer/servers).
3
3
  import { readFile } from 'node:fs/promises';
4
- import { userInfo } from 'node:os';
4
+ import { homedir } from 'node:os';
5
5
  import { join, resolve } from 'node:path';
6
6
  import { alive } from '../modules/job-queue/identity.mjs';
7
7
 
8
- export const serversDirectory = (env = process.env) => resolve(env.RER_SERVERS_DIR ?? join(userInfo().homedir, '.rer/servers'));
8
+ export const serversDirectory = (env = process.env) => resolve(env.RER_SERVERS_DIR ?? join((env.HOME || homedir()), '.rer/servers'));
9
9
 
10
10
  export async function liveServers(env = process.env) {
11
11
  let state;
@@ -1,17 +1,13 @@
1
- import { open, readFile } from 'node:fs/promises';
2
- import { constants } from 'node:fs';
3
- import { join, resolve } from 'node:path';
1
+ import { readFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
4
3
  import { refuseLocalModels } from '../deploy-kit/local-only.mjs';
5
- import { LOCAL_ONLY_KEYS, localOnlyBinding } from '../deploy-kit/owner.mjs';
4
+ import { localOnlyBinding } from '../deploy-kit/owner.mjs';
5
+ import { readDeployEnvFile } from '../deploy-kit/env-file.mjs';
6
6
  export async function deployInputs({root,envFile,localOnlyKeys=[]}) {
7
7
  await refuseLocalModels(root);
8
8
  const declaration=JSON.parse(await readFile(join(root,'deploy.json'),'utf8'));
9
9
  if(localOnlyBinding(declaration,localOnlyKeys))throw new Error('Deploy declaration contains a local-only binding');
10
10
  if(!envFile)throw new Error('Container deploy requires --env-file with private production settings');
11
- const handle=await open(resolve(root,envFile),constants.O_RDONLY|constants.O_NOFOLLOW);
12
- let encoded;
13
- try{const status=await handle.stat();if(!status.isFile()||status.nlink!==1||(status.mode&0o077)!==0||(process.getuid&&status.uid!==process.getuid()))throw new Error('Deploy env file must be an owned private regular file (0600)');encoded=await handle.readFile('utf8');}finally{await handle.close();}
14
- const settings={};for(const line of encoded.split(/\r?\n/)){if(!line.trim()||line.trim().startsWith('#'))continue;const match=/^([A-Z][A-Z0-9_]*)=(.*)$/.exec(line);if(!match)throw new Error('Invalid deploy env file');if(Object.hasOwn(settings,match[1]))throw new Error('Repeated deploy env key');settings[match[1]]=match[2].replace(/^"(.*)"$/,'$1');}
15
- if([...LOCAL_ONLY_KEYS,...localOnlyKeys].some(key=>Object.hasOwn(settings,key)))throw new Error('Local-only keys are refused for deploy');
11
+ const settings=await readDeployEnvFile(root,envFile,localOnlyKeys);
16
12
  return{declaration,settings,encoded:Object.entries(settings).map(([key,value])=>key+'='+value).join('\n')+'\n'};
17
13
  }
@@ -19,12 +19,13 @@ export function linkedProviders(options) {
19
19
  if(!await unclaimed())return null;
20
20
  if(native)return native.claimOwner?native.claimOwner({request,token,expectedPin}):null;
21
21
  return createIdpOwnerClaimHandler({expectedPin,db,authConfig:config,clientIp:()=>options.clientIp(request),clock:options.clock,fetch:options.fetch})(new Request(config.origin+'/auth/owner-claim',{method:'POST',headers:{Host:new URL(config.origin).host,Origin:config.origin,'Content-Type':'application/json'},body:JSON.stringify({token})}));
22
- }, claimScope: unclaimed, config:{origin:config.origin,local:config.local,transactionSecret:config.OIDC_TRANSACTION_SECRET ?? config.SESSION_SECRET+'-oidc',providers:[{
22
+ }, claimScope: unclaimed, config:{origin:config.origin,local:config.local,signInPath:config.branding.signInPath,transactionSecret:config.OIDC_TRANSACTION_SECRET ?? config.SESSION_SECRET+'-oidc',providers:[{
23
23
  id:'auth',preset:'auth',issuer:link.issuer,clientId:link.client_id,privateKey:JSON.parse(row.private_key),
24
24
  }]}});
25
25
  }
26
26
  if(!config.dependencies) return staticProviders;
27
27
  return {
28
+ signInPath:config.branding.signInPath,
28
29
  ids:[...new Set(['auth',...(staticProviders?.ids??[])])],
29
30
  callbackPaths:{...staticProviders?.callbackPaths,'/auth/oidc/auth/callback':'auth'},
30
31
  async begin(...args){const p=args[1]!=='auth'?staticProviders:await current();return p?p.begin(...args):Response.json({error:'provider_unlinked'},{status:403});},
@@ -56,9 +56,9 @@ D1/Queue/DO account listings fully paginate with bounded page/request budgets. R
56
56
 
57
57
  The first receipt binds source commit SHA and tree, artifact/config SHA-256, effective deployment fingerprint, account/Worker, actual resource IDs, migration hashes, secret names and active provider deployment/version/etag. It contains no secret value. The CLI creates it exclusively at mode 0600 before the first health request; it is an identity receipt, not a claim that health passed (health attempts are added afterwards, see below). A failed health request still exits nonzero, while the retained identity allows the next explicit invocation to authenticate state without another upload. For the second deployment use the **same artifact, run/attempt and receipt**. Current provider state must match the prior receipt. The kit performs no resource creation, Worker/asset upload, SQL mutation, Cron/consumer update, or secret rewrite when unchanged. Read-only D1 queries use the provider's POST query transport; a POST is not by itself a database mutation. The receipt remains byte-identical on a repeat. Missing resources, drift or stale state fail without repair during this repeat.
58
58
 
59
- Opaque existing secrets are preserved by name and strict binding inheritance; values are never fetched or compared. Missing declared secrets are supplied in the upload metadata in memory only. Rotation is a separate explicit operation; changing an input value does not overwrite a deployed secret. No temporary secret files are used.
59
+ Opaque existing secrets are preserved by name and strict binding inheritance; values are never fetched or compared. Missing declared secrets are supplied in the upload metadata in memory only. Rotation is a separate explicit operation (owner-account mode: `rer deploy --set-secret NAME`); changing an input value does not overwrite a deployed secret. No temporary secret files are used.
60
60
 
61
- Health independently reads current deployment/version/settings, resources, namespaces, schedules, consumers, secret presence and migration history. It requests only the authenticated account-derived `/health`, without credentials, rejects redirects/malformed/unhealthy/stale identity and reads deployment identity again afterward.
61
+ Health independently reads current deployment/version/settings, resources, namespaces, schedules, consumers, secret presence and migration history. It requests only the authenticated account-derived `/health`, without credentials, rejects redirects/malformed/unhealthy/stale identity and reads deployment identity again afterward. A healthy answer naming another Worker version (an edge still serving the version before a secret change) is asked again within the window.
62
62
 
63
63
  `health.mjs` is Base's one health verifier. `waitForDeploymentHealth(url, options)` (formerly `deployment/health.mjs`) gives a newly published route and its bindings one bounded window, 30 seconds by default: only 404, 503 and network failures are retried, every other status and every redirect is terminal, and the final error states the last result. The standalone `health` command probes `/health` once; transient failures are recorded as failures without a retry. The library `verifyHealth` retains its bounded readiness window. After an upload, `deploy` uses the recorded readiness wait described below.
64
64
 
@@ -103,10 +103,20 @@ Ownership lives in a state file, by default `.rer/deploy/<environment>.json` und
103
103
  - **Partial failure and resume.** The kit records each create as `pending` before the request and `created` with the provider id after it. A failed create leaves `pending`. On the next run an absent pending name is created again, and a present one is refused as `uncertain-resource-create`, because a lost response cannot prove who created it. Inspect it: if it is the empty resource from the interrupted run, delete it and re-run. A Worker upload is recorded as created when the upload succeeds, so a failure after it resumes with one fresh upload. If the response to the very first upload is lost, the Worker exists but is still `pending`. The kit records the build and its fingerprint before sending an upload. With no prior receipt, the same build may recover its pending Worker only when the live fingerprint matches that intent, then resume with one fresh upload. A different build or fingerprint is refused as `uncertain-resource-create`; recover with the original build first. If the uncertain Worker cannot be authenticated, inspect it and delete it only after confirming that it is the kit-created Worker and that its Durable Object data is disposable. Back up any needed data before deleting it. Migration claims keep the rules under Migration safety.
104
104
  - **Drift.** A created resource that has gone is refused as `owned-resource-missing`; one replaced under the same name is refused as `owned-resource-replaced`. The kit never deletes or recreates them. This covers D1, KV, Queues and R2 (a bucket is identified by its creation date; a state file written before that was recorded keeps working and is not checked for that bucket). For the Worker, the live deployment (version id, deployment id and etag) must be the last receipt's before any write; someone else's upload under the same name is refused as `owned-resource-replaced` and never overwritten. Immediately before the Worker PUT, the kit rechecks every saved resource identity, including resources omitted from the current plan, then the Worker identity. A replacement during provisioning or asset upload is refused before the upload intent or receipt is saved. An interrupted successful upload may resume only when the live version id, deployment id and etag match the identity saved immediately after that upload. A historical fingerprint alone never overrides a receipt mismatch.
105
105
  - **Lost upgrade response.** If an upgrade response is lost and the live identity no longer matches the receipt, the kit refuses rather than guessing ownership. A [Cloudflare rollback](https://developers.cloudflare.com/workers/versions-and-deployments/rollbacks/) creates a new deployment, so it does not restore the recorded identity. Recover with a new environment and a fresh state path, or back up the state and any needed data, confirm ownership, and follow the cleanup under "If the state file is lost" with a fresh `--state` path.
106
+ - **Settling listings.** Right after a create or delete, an account listing can briefly disagree with its own page counts (`provider-partial-list`: on 13 October 2026 D1's `total_count` lagged its rows by about 7 seconds after a create). Owner-mode deploy, health and removal discard such a snapshot and list again from the start for up to 30 seconds with a short backoff; a partial listing is never accepted.
106
107
  - **One run at a time.** A deploy takes an exclusive lock file, `<state file>.lock`, for the whole run and removes it at the end, even after a failure. A second run on the same state file is refused as `owner-state-locked` before any provider request. If a run was killed (power loss, `kill -9`) and no deploy is running, delete the lock file by hand.
107
108
 
108
109
  The CLI prints JSON: refusal codes and resource names only, never inputs, tokens or provider bodies.
109
110
 
111
+ ### rer deploy: inputs, the hosted owner, secret changes and removal
112
+
113
+ `rer deploy` runs these steps for a seed whose adapter uses `ownerDeploy` from `cli/deploy.mjs`.
114
+
115
+ - **Inputs.** Declared secrets come from the environment, then `--env-file FILE` (an owned 0600 file of `KEY=VALUE` lines), which wins. For a secret the Worker does not hold yet, rer supplies the setup manifest's owner email (`emailField`) from the local setup's private settings, and a fresh random value for each secret field the manifest generates (`generate`), such as the session secret and the setup token; a value the Worker already holds is kept. A still-missing secret stops the run before any write with a message that names it.
116
+ - **Hosted owner.** After deploy and health pass, rer claims the setup owner on the hosted app: it POSTs the setup token to the seed's standard owner step (`hooks.owner`, by default `/auth/bootstrap`). 303 means claimed; `409 already_initialized` means an owner was already claimed and is kept. Then rer deletes the token secret from the Worker, records the new Worker version in the state file, and lists the token in `retiredSecrets`, so later plans no longer declare it. A Worker that still holds a token rer did not upload in this run (an older deploy) first gets a fresh token through the same recorded secret change. Dependency approvals that need the token run before the claim. A seed with an owner step function, an identity-provider claim, or no declared token secret claims its owner its own way.
117
+ - **Secret changes.** `rer deploy --set-secret NAME` (value from the environment or `--env-file`, under the declaration's environment name) and `--unset-secret NAME` change one declared secret of the recorded Worker without a build. A secret change deploys a new Worker version of the same code, so rer checks the build fingerprint is unchanged and records the new version id, deployment id and etag as the receipt's provider identity: the next deploy recognises its own Worker. An unset secret is retired until it is set again. The change is recorded as pending before it is sent, and the secret listing and active deployment are read until they show it (they lag the change by seconds); a change the provider answers with an error clears that record. If a run ends before the change settles (a lost response), the next run adopts the live version only when the Worker's versions list shows exactly one version after the receipt's, it is the live one, and its script etag and build fingerprint are still the receipt's. A change made outside rer (dashboard, Wrangler), including one made after rer's own, still reads as a replacement and is refused.
118
+ - **Removal.** `rer deploy --remove --yes` deletes what the state file says rer created for this environment: the Worker (schedules first; its Durable Objects go with it) and each D1, R2, KV and queue that is still the one rer created, after checking all of them before the first delete. A replaced or uncertain resource refuses the whole removal, except a resource whose create answer was lost: under this environment's derived name it is deleted once it is confirmed empty (a D1 with no tables, a KV namespace with no keys, an R2 bucket, which the provider refuses to delete while it holds objects); one holding data is refused as `uncertain-resource-not-empty`, and a queue, whose backlog rer cannot read, as `uncertain-resource-unverifiable` (delete it by hand if it is the empty one from the interrupted run). It then reads back that none is still listed and deletes the state file. An R2 bucket must be empty; a bucket with objects stays, the state file keeps it, and a later `--remove --yes` finishes. Resources rer did not create are never touched.
119
+
110
120
  ### If the state file is lost
111
121
 
112
122
  A fresh clone or CI runner has no `.rer/` directory, so the kit can no longer tell its own resources from anyone else's and refuses every later deploy as `resource-name-collision`. It is correct to refuse: the kit never adopts what it cannot prove it created. To recover, do one of these.
@@ -181,7 +191,7 @@ No Single Redirect (dynamic redirect ruleset) permission is needed. Base's refer
181
191
 
182
192
  ## Migration safety
183
193
 
184
- SQL files have ordered unique names and SHA-256 identities. `_rer_deploy_migrations` is permanent controller-owned history. Applied entries must be an unchanged prefix of the desired set. A new migration gets an `applying` claim before its SQL and completion marker are submitted together to the D1 batch query API. Any interrupted, failed or uncertain outcome remains blocked and must be inspected explicitly; the kit never automatically retries, rolls back user data, or marks an unknown outcome successful. Readback checks every final identity.
194
+ SQL files have ordered unique names and SHA-256 identities. `d1_migrations` holds controller history with ordered IDs, names, timestamps, SHA-256 hashes and states. Native archives already exclude this installation table. Standard Wrangler name-only inserts remain possible, but their missing hashes/states block subsequent Base deployment pending reviewed adoption. Applied entries must be an unchanged prefix of the desired set. A new migration gets an `applying` claim before its SQL and completion marker are submitted together through the [Cloudflare D1 SQL import API](https://developers.cloudflare.com/api/resources/d1/subresources/database/methods/import/), using the same whole-file protocol as Wrangler to preserve compound trigger bodies and atomic SQL/completion. Uploads use bounded HTTPS R2 destinations, without account credentials; polling is bounded and signed URLs and import response text are never diagnostic output. Any interrupted, failed or uncertain outcome remains blocked and must be inspected explicitly; the kit never automatically retries, rolls back user data, or marks an unknown outcome successful. Readback checks every final identity.
185
195
 
186
196
  Completion preserves the original SQL bytes and always starts on a new line.
187
197
  Comment-only migrations are rejected before any provider request or ledger claim.
@@ -191,7 +201,7 @@ without treating quoted semicolons as terminators or changing migration hashes.
191
201
 
192
202
  A lexical guard rejects standalone transaction/resource control statements (`BEGIN`, `COMMIT`, `END`, `ROLLBACK`, `SAVEPOINT`, `RELEASE`, `ATTACH`, `DETACH`, `VACUUM`) at statement boundaries, including after a trigger. Bare `ROLLBACK` is also refused in conflict clauses and `RAISE`, which can roll back a batch without a standalone control statement. SQLite `CREATE [TEMP|TEMPORARY] TRIGGER` bodies and nested `CASE ... END` expressions are supported; quoted text and comments cannot create statement boundaries. Both ledger names are reserved in bare, double-quoted, backtick and bracket identifiers, including schema-qualified references. [SQLite also accepts single-quoted identifiers](https://www.sqlite.org/lang_keywords.html), so exact controller ledger names are reserved in every token, including data strings, relation names, virtual-table options and introspection arguments. Comments and longer descriptive strings remain allowed. This intentionally conservative policy does not attempt to authorize arbitrary SQLite name grammars. Incomplete quotes, block comments, parentheses, CASE expressions or trigger bodies and NUL bytes fail closed; trigger header expressions must close before BEGIN and body expressions before their statement semicolon. This guard protects controller custody; SQLite still validates application SQL syntax.
193
203
 
194
- Existing Wrangler `d1_migrations` records lack content hashes. Automatic adoption would fabricate historical certainty, so those installations require separately reviewed history/hash adoption before this kit proceeds. The kit can extend its own previous migrations while retaining existing application rows. Local SQLite tests prove failure rollback in the test transport; the provider's actual failure atomicity, retained data, and two-deploy no-mutation behavior remain live proof obligations. The conservative `applying` claim prevents automatic replay even if a remote batch partially commits.
204
+ The old kit `_rer_deploy_migrations` ledger is moved atomically into `d1_migrations` only after every applied name/hash/state proves an exact desired-history prefix. An ordinary deploy performs this one-time metadata transition even for unchanged code; subsequent redeploys write nothing, and health remains read-only. Existing Wrangler `d1_migrations` records lack content hashes. Automatic adoption would fabricate historical certainty, so those installations require separately reviewed history/hash adoption before this kit proceeds unless their ordered rows exactly match a prefix already attested by the old kit ledger. That transition preserves their IDs and timestamps. The kit can extend its own previous migrations while retaining existing application rows. Local SQLite tests prove failure rollback in the test transport; the provider's actual failure atomicity, retained data, and two-deploy no-mutation behavior remain live proof obligations. The conservative `applying` claim prevents automatic replay even if a remote response is lost.
195
205
 
196
206
  ## Local operations and source templates
197
207
 
@@ -247,12 +257,20 @@ After an upload that changed the Worker, `deploy` gives `/health` a total
247
257
  90-second readiness budget (`readiness.totalMs`, never above 90000). Retries wait
248
258
  1 second, doubling to a 10-second cap, and never less than the 2-second poll of
249
259
  the standalone window: 2, 2, 4, 8, 10, 10 ... seconds. Only transport failures,
250
- 404 and 5xx (including Cloudflare 52x) retry. A redirect, any other status, a
251
- malformed or unhealthy body and a deployment identity mismatch fail at once.
260
+ 404 and 5xx (including Cloudflare 52x) retry, and so does a healthy answer that
261
+ names another Worker version (the edge still serving the previous version just
262
+ after the upload). A redirect, any other status, a malformed or unhealthy body
263
+ and a wrong identity from the uploaded version itself fail at once. The one
264
+ built file then requested must answer 200; after an upload its 404, 5xx and
265
+ transport failures are retried for up to 60 seconds, since an edge still serving
266
+ the previous version does not have the new build's hashed files (seen live on
267
+ 13 October 2026).
252
268
  Each request is capped by both the 30-second request limit and the remaining
253
269
  budget, and no request starts at the deadline. An unchanged repeat uses the
254
270
  existing library window and does not record first-upload readiness attempts.
255
- The standalone CLI `health` command sends a single request. Library `verifyHealth`
271
+ The standalone CLI `health` command sends a single request; only an answer from
272
+ another Worker version (an edge still converging after a secret change) is asked
273
+ again, within 30 seconds. Library `verifyHealth`
256
274
  retains the existing window, retrying only 404, 503 and network failures for
257
275
  30 seconds by default.
258
276