@runeditrun/rer 0.2.1 → 0.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -4
- package/cli/README.md +21 -0
- package/cli/auth-copy.mjs +25 -18
- package/cli/bounded.mjs +13 -4
- package/cli/container.mjs +11 -10
- package/cli/contract.mjs +2 -2
- package/cli/demo-host.mjs +47 -0
- package/cli/demo.mjs +47 -0
- package/cli/dependencies.mjs +26 -4
- package/cli/deploy-command.mjs +94 -0
- package/cli/deploy-inputs.mjs +55 -0
- package/cli/deploy-observation.mjs +28 -0
- package/cli/deploy-owner.mjs +76 -0
- package/cli/deploy-readiness.mjs +16 -0
- package/cli/deploy.mjs +99 -17
- package/cli/detached-server.mjs +1 -1
- package/cli/door.mjs +1 -1
- package/cli/export.mjs +1 -1
- package/cli/hosted-output.mjs +2 -1
- package/cli/inspect.mjs +24 -32
- package/cli/lifecycle.mjs +31 -8
- package/cli/local.mjs +7 -0
- package/cli/main.mjs +1 -1
- package/cli/managed-server.mjs +27 -18
- package/cli/parse.mjs +9 -2
- package/cli/pnpm-probe.mjs +17 -0
- package/cli/pnpm-runtime.mjs +22 -22
- package/cli/portability-policy.mjs +55 -0
- package/cli/portability-worker.mjs +8 -4
- package/cli/portability.mjs +24 -5
- package/cli/project-pnpm.mjs +67 -0
- package/cli/release.mjs +35 -0
- package/cli/scheduled.mjs +2 -2
- package/cli/server-registry.mjs +2 -2
- package/cli/setup-input.mjs +1 -1
- package/cli/site-check.mjs +15 -6
- package/cli/site-data.mjs +6 -5
- package/cli/site-references.mjs +153 -3
- package/global/CHANGELOG.md +60 -0
- package/global/README.md +21 -4
- package/global/cli/install.mjs +26 -9
- package/global/cli/path-link.mjs +30 -0
- package/global/cli/status.mjs +1 -1
- package/global/cli/uninstall.mjs +5 -1
- package/global/launch/capacity.mjs +1 -1
- package/global/launch/facts.mjs +12 -6
- package/global/launch/install-pinned.mjs +30 -2
- package/global/launch/prepare.mjs +20 -8
- package/global/launch/state.mjs +54 -1
- package/global/main.mjs +43 -20
- package/global/registry.mjs +2 -2
- package/global/update/command.mjs +2 -1
- package/modules/container/build.mjs +4 -2
- package/modules/container/deploy-input.mjs +5 -9
- package/modules/container/host.mjs +1 -1
- package/modules/container/ingress.mjs +4 -1
- package/modules/container/migrations.mjs +2 -1
- package/modules/container/worker-engine.mjs +3 -1
- package/modules/demo-host/README.md +30 -0
- package/modules/demo-host/capacity.mjs +19 -0
- package/modules/demo-host/claim.mjs +21 -0
- package/modules/demo-host/container.mjs +74 -0
- package/modules/demo-host/pages.mjs +56 -0
- package/modules/demo-host/plan.mjs +52 -0
- package/modules/demo-host/worker.mjs +72 -0
- package/modules/dependencies/auth.mjs +2 -1
- package/modules/deploy-kit/README.md +52 -7
- package/modules/deploy-kit/d1-import.mjs +35 -0
- package/modules/deploy-kit/deploy.mjs +4 -3
- package/modules/deploy-kit/diagnostics.mjs +11 -1
- package/modules/deploy-kit/env-file.mjs +27 -0
- package/modules/deploy-kit/health-request.mjs +13 -3
- package/modules/deploy-kit/health.mjs +35 -15
- package/modules/deploy-kit/migration-ledger.mjs +85 -0
- package/modules/deploy-kit/migrations.mjs +48 -16
- package/modules/deploy-kit/owner-cli.mjs +42 -8
- package/modules/deploy-kit/owner-remove.mjs +108 -0
- package/modules/deploy-kit/owner-secret.mjs +94 -0
- package/modules/deploy-kit/owner.mjs +124 -19
- package/modules/deploy-kit/prepare.mjs +3 -1
- package/modules/deploy-kit/provider.mjs +18 -2
- package/modules/erasure/README.md +2 -3
- package/modules/job-queue/config.mjs +4 -2
- package/modules/operator-auth/accounts/settings-page.mjs +8 -3
- package/modules/operator-auth/csp.mjs +26 -0
- package/modules/operator-auth/dev/badge.mjs +1 -22
- package/modules/operator-auth/identity/oidc/README.md +18 -0
- package/modules/operator-auth/identity/oidc/config.mjs +4 -2
- package/modules/operator-auth/identity/oidc/index.d.mts +1 -1
- package/modules/operator-auth/identity/oidc/index.mjs +28 -9
- package/modules/operator-auth/identity/oidc/network.mjs +9 -4
- package/modules/operator-auth/identity/oidc/store.mjs +19 -3
- package/modules/operator-auth/index.d.mts +2 -0
- package/modules/operator-auth/page.mjs +2 -2
- package/modules/operator-auth/routes.mjs +2 -2
- package/modules/portability/README.md +37 -0
- package/modules/portability/bridge.mjs +14 -4
- package/modules/portability/catalogue.mjs +9 -2
- package/modules/portability/import.mjs +4 -2
- package/modules/portability/index.d.mts +4 -0
- package/modules/portability/index.mjs +1 -0
- package/modules/portability/lines.mjs +3 -1
- package/modules/release/produce.mjs +1 -1
- package/modules/release/source.mjs +5 -2
- package/onboarding/adapter-labels.mjs +1 -0
- package/onboarding/adapter.mjs +9 -4
- package/onboarding/commands.mjs +3 -1
- package/onboarding/dependency-environment.mjs +33 -0
- package/onboarding/local/owner.mjs +1 -1
- package/onboarding/operator-installer.mjs +10 -4
- package/onboarding/redaction.mjs +22 -3
- package/package.json +6 -1
- package/scripts/publish-check/README.md +22 -1
- package/scripts/publish-check/c2pa.py +121 -0
- package/scripts/publish-check/content_identities.py +3 -1
- package/scripts/publish-check/controller_metadata.py +31 -0
- package/scripts/publish-check/history_scan.py +13 -3
- package/scripts/publish-check/media.py +283 -0
- package/scripts/publish-check/png_text.py +34 -0
- package/scripts/publish-check/public_hygiene.py +39 -4
- package/scripts/publish-check/publication-config.json +7 -1
package/cli/setup-input.mjs
CHANGED
|
@@ -87,7 +87,7 @@ export async function setupInput(project, options, vars, signal, { origin } = {}
|
|
|
87
87
|
if(savedEmail&&!mailbox(savedEmail))throw refuse('Saved owner is malformed. Restore private settings or run rer reset --yes.');
|
|
88
88
|
if(savedEmail&&savedEmail.toLowerCase()!==answers[names.email]){
|
|
89
89
|
if(owners.length)throw refuse(`Private setting ${names.email} differs from the retained database owner. Restore ${names.email} in ${project.adapter.hooks?.secrets?.file??'.dev.vars'} to ${answers[names.email]}, then run rer setup --owner-email ${answers[names.email]}; or back up data and run rer reset --yes. Private bytes were kept.`);
|
|
90
|
-
throw refuse(`Owner conflicts with the saved installation (${savedEmail}). Run rer setup --owner-email ${savedEmail}, or back up data
|
|
90
|
+
throw refuse(`Owner conflicts with the saved installation (${savedEmail}). Run rer setup --owner-email ${savedEmail}, or back up data, run rer dev --stop, then rer reset --yes followed by rer setup --owner-email ${answers[names.email]}. Reset deletes local data.`);
|
|
91
91
|
}
|
|
92
92
|
|
|
93
93
|
try{return seed.model.validateInput(answers,known);}catch{throw missing('Installer answers are incomplete. Supply the active manifest fields using rer setup --answers answers.json --non-interactive.');}
|
package/cli/site-check.mjs
CHANGED
|
@@ -3,7 +3,7 @@ import { join, posix } from 'node:path';
|
|
|
3
3
|
import { createRequire } from 'node:module';
|
|
4
4
|
import { pathToFileURL } from 'node:url';
|
|
5
5
|
import { privateDataScanner, decodedText } from './site-data.mjs';
|
|
6
|
-
import { javascriptContent } from './site-references.mjs';
|
|
6
|
+
import { javascriptContent, javascriptContext } from './site-references.mjs';
|
|
7
7
|
import { javascriptStrings } from './site-syntax.mjs';
|
|
8
8
|
import { markupReferences, markupStrings, htmlElements, cssStrings } from './site-markup.mjs';
|
|
9
9
|
import { refuse, RerError } from './errors.mjs';
|
|
@@ -77,15 +77,24 @@ export async function checkSite(folder, { privateValues = [], privateIdentities
|
|
|
77
77
|
const require = createRequire(join(root, 'package.json'));
|
|
78
78
|
({parseAst:parse} = await import(pathToFileURL(require.resolve('vite')).href));
|
|
79
79
|
}
|
|
80
|
+
const documents = [], treesByFile = new Map();
|
|
81
|
+
for (const [site, bytes] of all) {
|
|
82
|
+
const text = bytes.toString('utf8'), trees = /\.[cm]?js$/.test(site) && parse ? [parse(text)] : [];
|
|
83
|
+
if (/\.(?:html|svg)$/.test(site)) {
|
|
84
|
+
documents.push(text);
|
|
85
|
+
for (const {tag, attributes, content} of htmlElements(text)) {
|
|
86
|
+
if (tag === 'script' && !attributes.has('src') && parse && /^(?:|module|(?:text|application)\/(?:java|ecma)script)$/i.test(attributes.get('type') ?? '')) trees.push(parse(content));
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
treesByFile.set(site, trees);
|
|
90
|
+
}
|
|
91
|
+
const context = javascriptContext(documents, [...treesByFile.values()].flat());
|
|
80
92
|
const problems = [], unresolved = [];
|
|
81
93
|
for (const [site, bytes] of all) {
|
|
82
94
|
if (markers.some(marker => bytes.includes(marker))) throw refuse(`The run site file ${site} contains the local-only dev layer.`);
|
|
83
95
|
const html = /\.(?:html|svg)$/.test(site), css = site.endsWith('.css'), js = /\.[cm]?js$/.test(site);
|
|
84
|
-
const text = bytes.toString('utf8'), trees =
|
|
85
|
-
|
|
86
|
-
if (tag === 'script' && !attributes.has('src') && parse && /^(?:|module|(?:text|application)\/(?:java|ecma)script)$/i.test(attributes.get('type') ?? '')) trees.push(parse(content));
|
|
87
|
-
}
|
|
88
|
-
const scripts = trees.map(javascriptContent), strings = trees.flatMap(javascriptStrings);
|
|
96
|
+
const text = bytes.toString('utf8'), trees = treesByFile.get(site), literals = css ? cssStrings(text) : html ? markupStrings(text) : [];
|
|
97
|
+
const scripts = trees.map(tree => javascriptContent(tree, context)), strings = trees.flatMap(javascriptStrings);
|
|
89
98
|
if (containsPrivateData(bytes, [...literals, ...strings, ...strings.flatMap(markupStrings), ...scripts.flatMap(script => script.literals)])) throw new RerError('site_private_data', `The run site file ${site} contains this installation's private data (owner identity or a secret). The run site renders only edition identity.`, 4);
|
|
90
99
|
if (!html && !css && !js) continue;
|
|
91
100
|
const refs = [...(js ? [] : markupReferences(text, html)), ...scripts.flatMap(script => script.references)];
|
package/cli/site-data.mjs
CHANGED
|
@@ -22,8 +22,9 @@ export function decodedText(text) {
|
|
|
22
22
|
export function privateDataScanner(values, identities) {
|
|
23
23
|
const secrets = values.filter(value => typeof value === 'string' && value.length);
|
|
24
24
|
const owners = identities.filter(value => typeof value === 'string' && value.length).map(value => ({value, ...identityPattern(value)}));
|
|
25
|
-
const
|
|
26
|
-
const
|
|
25
|
+
const encodings = value => [Buffer.from(value).toString('base64'), Buffer.from(value).toString('base64').replace(/=+$/, ''), Buffer.from(value).toString('base64url')];
|
|
26
|
+
const encodedSecrets = secrets.flatMap(encodings);
|
|
27
|
+
const encodedOwners = owners.flatMap(({value}) => encodings(value)).map(value => new RegExp(`(?<![A-Za-z0-9+/_-])${value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?![A-Za-z0-9+/_=-])`));
|
|
27
28
|
return (bytes, literalValues = []) => {
|
|
28
29
|
if (secrets.some(value => bytes.includes(Buffer.from(value)))) return true;
|
|
29
30
|
const raw = bytes.toString('utf8');
|
|
@@ -42,10 +43,10 @@ export function privateDataScanner(values, identities) {
|
|
|
42
43
|
forms.add(plain); forms.add(decodedText(plain));
|
|
43
44
|
}
|
|
44
45
|
if (secrets.some(value => form.includes(value)) || owners.some(({pattern}) => pattern.test(form))) return true;
|
|
45
|
-
if (
|
|
46
|
+
if (encodedSecrets.some(value => form.includes(value)) || encodedOwners.some(pattern => pattern.test(form))) return true;
|
|
46
47
|
// A base64 payload may include surrounding text, changing its alignment.
|
|
47
|
-
for (const match of form.matchAll(/[A-Za-z0-9+/_-]{8,}={0,2}/g)) {
|
|
48
|
-
const plain = Buffer.from(match[0], 'base64').toString('utf8');
|
|
48
|
+
for (const match of form.matchAll(/[A-Za-z0-9+/_-]{8,}={0,2}/g)) for (let offset = 0; offset < 4; offset++) {
|
|
49
|
+
const plain = Buffer.from(match[0].slice(offset), 'base64').toString('utf8');
|
|
49
50
|
if (secrets.some(value => plain.includes(value)) || owners.some(({pattern}) => pattern.test(plain))) return true;
|
|
50
51
|
}
|
|
51
52
|
}
|
package/cli/site-references.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/global/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,65 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.4
|
|
4
|
+
|
|
5
|
+
0.2.3 was tagged but not published; its changes are in 0.2.4.
|
|
6
|
+
|
|
7
|
+
- A first `rer deploy` waits up to two minutes (it was 30 seconds) for Cloudflare's account listing to show a database, bucket, namespace or queue it has just created, whether the listing disagrees with its own page counts or leaves the new resource out; one that never appears stops the deploy before any upload, names the resource, and the same command resumes with it.
|
|
8
|
+
- A first `rer dev` no longer chooses the port another, stopped checkout was set up on, so both checkouts keep working origins.
|
|
9
|
+
- Seed recovery code can check the deploy migration ledger with one call: `migrationLedgerShape(rows)` from `@runeditrun/rer/modules/deploy-kit/migration-ledger`, given the rows of its `ledgerShapeQuery`, accepts all four ledger shapes a deployment can hold (Wrangler's `d1_migrations`, that table after a kit deploy adopted it, the kit's own `d1_migrations` since 0.2.2, and the `_rer_deploy_migrations` ledger a 0.2.1 or earlier deploy left, shape `legacy`, which wins when both tables exist) and refuses any other. A seed whose recovery checks that the ledger is exactly Wrangler's three columns refuses ledgers from 0.2.2 on (`MIGRATION_MISMATCH`); replace that check with this call.
|
|
10
|
+
- `rer import` and hosted restores refuse an archive that records a migration this installation has not applied (`schema_mismatch`), even when that migration left the schema unchanged (an index or a data-only change); such an archive is newer than the installation.
|
|
11
|
+
- After a pulled change to a checkout's `@runeditrun/rer` pin, every verb (`rer deploy` included) installs the pinned version with the frozen lockfile before it runs, instead of asking you to run `rer install` first.
|
|
12
|
+
- Two verbs started at the same moment after a pulled pin change share one install of the new pin: the second waits for the first's install, then runs, instead of racing it and failing with "The declared adapter could not be loaded".
|
|
13
|
+
- The publication scan accepts AI-generated PNGs whose C2PA manifest carries the signing certificate's email address (certificate names are metadata) and phone JPEGs that store more images after the first (every image's pixels are skipped as pixels), while an address in the rest of the manifest, XMP or EXIF is still flagged.
|
|
14
|
+
- Dependency installs receive only the environment an install needs, never your credentials: the frozen `pnpm install` that `rer install`, a fresh clone's first verb, `rer dev`, the installer and `rer release` run, the pnpm version check, `rer update`'s npm install and local data migrations get `PATH`, `HOME`, `USER`, `LOGNAME`, `SHELL`, `TMPDIR`/`TMP`/`TEMP`, `TERM`, `LANG`/`LANGUAGE`/`LC_*`, `TZ`, `CI`, `SSH_AUTH_SOCK` (git+ssh dependencies), `NODE_OPTIONS`, `NODE_EXTRA_CA_CERTS`, `XDG_*`, proxy settings without their user:password, and `npm_config_*`, `PNPM_*`, `COREPACK_*` and `RER_*` settings except credentials (by name, such as `_authToken` or `*_SESSION`, or by value: a URL with a password, a credential query or connection-string parameter, a JWT, a private key, an Authorization header or a provider token). Every other variable stays with you, so dependency build scripts (esbuild, workerd, sharp, oxide) never see vault sessions (`BW_SESSION`, `OP_SESSION_*`), connection strings, keys or tokens under any name. `rer deploy` and the seed's own `verify` still receive everything.
|
|
15
|
+
- A proxy setting reaches a dependency install without its user:password even when the password contains spaces, an extra `@` or the value starts with whitespace.
|
|
16
|
+
- `rer deploy` setting or removing a secret whose answer was lost settles against the Worker version it recorded, so a later deploy recognises the change instead of refusing it.
|
|
17
|
+
- A secret change whose answer was lost while the read-back also failed stays recorded as pending, so the next `rer deploy` recognises it instead of locking you out; replacing an existing secret after a lost answer writes it again and confirms it before reporting success.
|
|
18
|
+
- After it claims the hosted owner, retires the setup token or sets or removes a secret, `rer deploy` checks the app's health on the new Worker version and fails with the HTTP status and the response's first line, instead of reporting success, when the app is not healthy.
|
|
19
|
+
- `rer setup` finishes provider-free when a seed declares an optional dependency that you leave unconnected.
|
|
20
|
+
- A seed adapter can declare `resetPreflight`; `rer reset` runs it before listing or deleting anything, and `--yes` cannot skip it.
|
|
21
|
+
- A first `rer dev` whose chosen port is taken by another program before setup can use it moves to the next free port and says which.
|
|
22
|
+
- `rer deploy` says it is deploying to your Cloudflare account and names the live address, instead of saying the application is running locally; `rer build` ends with "Your application is built".
|
|
23
|
+
- `rer describe` in the default seed and new foundations names the release keygen, sign and receipt subcommands again.
|
|
24
|
+
- Importing an archive whose bytes are not valid UTF-8 is refused with the same `archive_json` diagnostic (exit 2) as invalid JSON.
|
|
25
|
+
- Converting a seed to package mode no longer leaves a broken queue sentence in its README or `scripts/base-cli` paths in its operations guide and erasure README.
|
|
26
|
+
- The owner's sign-in settings page (`/auth/settings/identity`) wears the seed's declared theme, like sign-in and setup.
|
|
27
|
+
- `rer demo` (the same as `rer dev --demo`) runs a seed as a throwaway local demo: signed in as the saved owner, filled with the seed's demo data, marked by a Demo bar, unable to send anything, and deleted on `--stop`, Ctrl-C or after an hour; the checkout's own state is never written.
|
|
28
|
+
- Under `rer demo`, `fetch`, redirects, `WebSocket`, `navigator.sendBeacon`, `EventSource`, `node:net`/`node:tls` and `cloudflare:sockets` reach loopback hosts only, and every refused send is listed in the Demo bar.
|
|
29
|
+
- `rer demo` refuses a seed that has not adopted it (no `withDemo` guard, no `demoSockets()` or a state path that ignores `RER_STATE_DIR`), and `rer deploy`, including `--dry-run`, refuses a deploy declaration or `--env-file` that carries `RER_DEMO` and names the key.
|
|
30
|
+
- `rer deploy --demo` hosts a seed's demo at `demo.<domain>`: each visitor passes a Turnstile check and gets their own signed-in, throwaway container of the seed that can send nothing, ended after an hour or 15 idle minutes. `export/run-demo.yml` deploys it when `RUN_DEMO_DEPLOY` is on.
|
|
31
|
+
- `rer deploy` claims the hosted owner after every deploy, including seeds whose own deploy hooks run, retires the setup token, keeps an existing native identity-provider owner and keeps a live owner token you supply.
|
|
32
|
+
- Container images built by `rer build --platform container` start again; they exited at start.
|
|
33
|
+
- The default seed's sign-in serves the signed owner claim whenever its public keys are configured.
|
|
34
|
+
- A seed can declare a reviewed rewrite of an already released migration in `migrations/rewrites.json`; deploys and local builds accept the released bytes for that migration only and still refuse every undeclared change.
|
|
35
|
+
- Seed table inventories can use the exported portability exclusion list, so native ZIP and remote export no longer refuse the deploy controller's migration ledger table.
|
|
36
|
+
- The publication scan skips only the compressed pixel and sample data of recognised images and videos; their comments, XMP, EXIF and tags are checked, and a text file behind a fake media header is checked whole.
|
|
37
|
+
- With `--public-base`, the publication scan reports a file identical to the public base's copy as pre-existing instead of a finding.
|
|
38
|
+
- Base's default seed and source import kit (outside the rer npm package) prepare and verify 100,000-row imports across requests within the Worker CPU limit and resume after a killed step; a changed re-upload under a published key is refused.
|
|
39
|
+
- Base's default seed and source import kit erase a person named in other people's imported records at seed scale, across requests, and when they never became a native record; an interrupted erasure is finished by a cron sweep within about 10 minutes.
|
|
40
|
+
|
|
41
|
+
### Upgrade notes
|
|
42
|
+
|
|
43
|
+
- Existing OIDC provider links show **not linked** until the next successful sign-in through each provider, because older stores have no verified token-exchange observation for them.
|
|
44
|
+
- The default seed's erasure queue message is now `{ redact, chain }` instead of `{ redact, operator, continuation }`; a message still queued in the old shape when you deploy is refused, so let the erasure queue drain first. Default-seed migrations 0021 to 0023 add the import-step, stream-source and erasure-progress tables, and its cron trigger sweeps erasures every 10 minutes.
|
|
45
|
+
- A seed adopting `rer demo` adds `src/demo.ts` (exporting an idempotent `seedDemo`), copies Base's `modules/demo/` and wraps its Worker entry's default export in `withDemo` as the entry's first import, and makes three `vite.config` changes: the state path reads `RER_STATE_DIR`, the dev entry's vars pass `RER_DEMO` and `RER_DEMO_EXPIRES`, and `demoSockets()` comes first in `plugins`; add `.rer/demo/` to `.gitignore`. `export/DEMO.md` has the steps.
|
|
46
|
+
- A private registry's auth must be in `~/.npmrc` (or the registry config your package manager reads from `HOME`): dependency installs now get only an allowlisted environment, so a token passed as an environment variable no longer reaches them.
|
|
47
|
+
- A seed with custom recovery code that checks the deploy migration ledger is exactly Wrangler's three columns uses `migrationLedgerShape` instead; the old check refuses ledgers from 0.2.2 on (`MIGRATION_MISMATCH`).
|
|
48
|
+
|
|
49
|
+
## 0.2.2
|
|
50
|
+
|
|
51
|
+
- `rer deploy` claims the hosted owner, exits cleanly when it finishes, changes and removes secrets safely, and removes the resources it created when you ask it to.
|
|
52
|
+
- Local restore runs the seed's own restore-validation policies, so a seed no longer needs its own patch or adapter for them.
|
|
53
|
+
- 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.
|
|
54
|
+
- 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.
|
|
55
|
+
- `rer release --help` and the job-queue help print their usage.
|
|
56
|
+
- `rer describe` reports the effects a seed declares for each command instead of generic account effects.
|
|
57
|
+
- 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.
|
|
58
|
+
- The run-site privacy check catches registered secrets inside prefixed base64 and accepts constant text that a client island imports.
|
|
59
|
+
- Fresh rer launches provision pnpm, prefer the provisioned pnpm, keep state under your HOME, and clean up their probe processes.
|
|
60
|
+
- Base can export a seed Verify workflow with source freshness checks for manual adoption.
|
|
61
|
+
- 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.
|
|
62
|
+
|
|
3
63
|
## 0.2.1
|
|
4
64
|
|
|
5
65
|
- `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.
|
|
26
|
-
|
|
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`
|
|
@@ -179,8 +182,15 @@ as an exact devDependency instead (`"@runeditrun/rer": "0.2.0"`, locked in
|
|
|
179
182
|
which inside `pnpm run` is the pinned copy in `node_modules/.bin`, so
|
|
180
183
|
`pnpm install --frozen-lockfile && pnpm run build` needs no global `rer`. A global
|
|
181
184
|
`rer` hands every verb to the pinned package, launching included. Until
|
|
182
|
-
`node_modules` holds the pinned version
|
|
183
|
-
first (the frozen `pnpm install`)
|
|
185
|
+
`node_modules` holds the pinned version (a fresh clone, or a pulled pin change),
|
|
186
|
+
every verb installs it first (the frozen `pnpm install`), then runs it; verbs
|
|
187
|
+
started at once share that one install.
|
|
188
|
+
Every dependency install, `rer release`'s included, receives an allowlisted
|
|
189
|
+
environment: the shell, locale, terminal and temporary-folder variables, `CI`,
|
|
190
|
+
`TZ`, `SSH_AUTH_SOCK`, `NODE_OPTIONS`, `NODE_EXTRA_CA_CERTS`, `XDG_*`, proxy
|
|
191
|
+
settings without their user:password, and `npm_config_*`, `PNPM_*`,
|
|
192
|
+
`COREPACK_*` and `RER_*` settings that carry no credential by name or value.
|
|
193
|
+
No other variable reaches the package manager or a dependency's build script.
|
|
184
194
|
|
|
185
195
|
Heavy package scripts wait for this computer's job queue with
|
|
186
196
|
`rer queue NAME -- COMMAND [ARGS]` (in vendored mode,
|
|
@@ -267,3 +277,10 @@ Anywhere: `rer list [DIR]`, `rer cli status|install|uninstall`, `rer update`,
|
|
|
267
277
|
`rer queue NAME -- COMMAND`, `rer --version` and `rer --help`.
|
|
268
278
|
|
|
269
279
|
Licence: MIT.
|
|
280
|
+
|
|
281
|
+
First-party commands acquire the exact `packageManager` pnpm pin when PATH lacks
|
|
282
|
+
it, verify its registry sha512 integrity and cache it under `$HOME/.rer/pnpm`.
|
|
283
|
+
No global package manager installation or sudo is needed. Independent projects
|
|
284
|
+
continue to run their declared commands with their own tools. Server and job
|
|
285
|
+
state default to `$HOME/.rer/servers` and `$HOME/.rer/jobs`; supplied
|
|
286
|
+
`RER_SERVERS_DIR` and `RER_JOBS_DIR` take precedence and are passed to older pins.
|
package/global/cli/install.mjs
CHANGED
|
@@ -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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
+
}
|
package/global/cli/status.mjs
CHANGED
|
@@ -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
|
}
|
package/global/cli/uninstall.mjs
CHANGED
|
@@ -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}.`); }
|
|
@@ -13,7 +13,7 @@ import { LaunchFailure } from './failure.mjs';
|
|
|
13
13
|
export const personDefaults = { limit: 10, idleSeconds: 43200 };
|
|
14
14
|
const unboundedLoad = '1000000';
|
|
15
15
|
// The verbs that start a server or enter the job queue on every pin.
|
|
16
|
-
export const personVerbs = new Set(['dev', 'preview', 'install', 'setup', 'build', 'verify', 'deploy', 'export', 'import']);
|
|
16
|
+
export const personVerbs = new Set(['dev', 'demo', 'preview', 'install', 'setup', 'build', 'verify', 'deploy', 'export', 'import']);
|
|
17
17
|
|
|
18
18
|
// How the pinned dispatcher counts servers, and which person defaults it lacks.
|
|
19
19
|
// describe's `defaults` (from rer-cli-2) is authoritative: the launcher supplies
|
package/global/launch/facts.mjs
CHANGED
|
@@ -76,14 +76,20 @@ async function bindable(host, port) {
|
|
|
76
76
|
finally { if (socket.listening) await new Promise(resolve => socket.close(resolve)); }
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
-
//
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
79
|
+
// Whether something now listens on a port or keeps it from binding.
|
|
80
|
+
export async function portTaken(port) {
|
|
81
|
+
return (await listeningPorts()).has(port) || !await bindable('127.0.0.1', port) || !await bindable('::1', port);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// The first port from 8787 (RER_DEV_PORT_FROM moves the start) that is not
|
|
85
|
+
// `held` (by a registered server, or saved by another checkout that is stopped),
|
|
86
|
+
// nothing listens on, binds on both 127.0.0.1 and ::1 (a browser that tries
|
|
87
|
+
// ::1 first never reaches another server) and that `claim` secures against
|
|
88
|
+
// another launch choosing it at the same time.
|
|
89
|
+
export async function freePort(ports, claim, env = process.env) {
|
|
84
90
|
const from = Number(env.RER_DEV_PORT_FROM ?? 8787);
|
|
85
91
|
if (!Number.isInteger(from) || from < 1 || from > 65535) throw new LaunchFailure(2, 'RER_DEV_PORT_FROM must be a port number.');
|
|
86
|
-
const held = new Set(
|
|
92
|
+
const held = new Set(ports.filter(Boolean)), busy = await listeningPorts();
|
|
87
93
|
for (let port = from; port <= 65535; port++) {
|
|
88
94
|
if (held.has(port) || busy.has(port)) continue;
|
|
89
95
|
if (!await bindable('127.0.0.1', port) || !await bindable('::1', port)) continue;
|
|
@@ -1,15 +1,43 @@
|
|
|
1
1
|
// A checkout that pins the @runeditrun/rer package runs its own rer once
|
|
2
2
|
// node_modules holds the pinned version; until then none of it can run (its
|
|
3
3
|
// adapter imports the package). So its first dependency install is this
|
|
4
|
-
// launcher's: the frozen pnpm install that `rer install` runs.
|
|
4
|
+
// launcher's: the frozen pnpm install that `rer install` runs. Like every
|
|
5
|
+
// dependency install, it runs with an allowlisted environment. Verbs started
|
|
6
|
+
// at once share one install: each takes the checkout's install claim and
|
|
7
|
+
// installs only if the pin is still missing once it holds it.
|
|
5
8
|
import { spawn } from 'node:child_process';
|
|
9
|
+
import { realpath } from 'node:fs/promises';
|
|
10
|
+
import { dependencyEnvironment } from '../../onboarding/dependency-environment.mjs';
|
|
6
11
|
import { pinnedPackage } from '../checkout.mjs';
|
|
7
12
|
import { packageName } from '../package-info.mjs';
|
|
13
|
+
import { ensureProjectPnpm } from '../../cli/project-pnpm.mjs';
|
|
14
|
+
import { RerError } from '../../cli/errors.mjs';
|
|
8
15
|
import { LaunchFailure } from './failure.mjs';
|
|
16
|
+
import { claimPinInstall, settledPinInstall } from './state.mjs';
|
|
17
|
+
|
|
18
|
+
const waiting = (root, output) => pid => output(`Another rer (PID ${pid}) is installing ${root}'s pinned ${packageName}; waiting for it.\n`);
|
|
9
19
|
|
|
10
20
|
export async function installPinned(root, { env = process.env, output = text => process.stderr.write(text), children, signal } = {}) {
|
|
11
21
|
signal?.throwIfAborted();
|
|
12
|
-
const
|
|
22
|
+
const release = await claimPinInstall(await realpath(root), { env, signal, onWait: waiting(root, output) });
|
|
23
|
+
try {
|
|
24
|
+
const pinned = pinnedPackage(root);
|
|
25
|
+
if (pinned?.ready) return;
|
|
26
|
+
if (pinned?.installed) output(`${root} pins ${packageName} ${pinned.declared}, but node_modules holds ${pinned.installed}; installing the pinned version first.\n`);
|
|
27
|
+
await install(root, { env, output, children, signal });
|
|
28
|
+
} finally { await release(); }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// A verb on a checkout whose pin looks installed still waits for an install
|
|
32
|
+
// another verb is running there: node_modules is not complete until it ends.
|
|
33
|
+
export async function settledPin(root, { env = process.env, output = text => process.stderr.write(text), signal } = {}) {
|
|
34
|
+
await settledPinInstall(await realpath(root), { env, signal, onWait: waiting(root, output) });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function install(root, { env, output, children, signal }) {
|
|
38
|
+
try { await ensureProjectPnpm(root, { env, signal, report: text => output(text + '\n') }); }
|
|
39
|
+
catch (error) { if (error instanceof RerError) throw new LaunchFailure(error.exitCode, error.message); throw error; }
|
|
40
|
+
const child = spawn('pnpm', ['install', '--frozen-lockfile'], { cwd: root, env: { ...dependencyEnvironment(env), CI: 'true' }, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
13
41
|
children?.add(child);
|
|
14
42
|
if (signal?.aborted) child.kill('SIGTERM');
|
|
15
43
|
let tail = '';
|