@runeditrun/rer 0.2.0 → 0.2.1
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 +3 -2
- package/cli/README.md +4 -2
- package/cli/contract.mjs +2 -2
- package/cli/deploy.mjs +17 -3
- package/cli/detached-server.mjs +51 -0
- package/cli/hosted-output.mjs +2 -2
- package/cli/hydration/check.mjs +2 -2
- package/cli/inspect.mjs +1 -1
- package/cli/main.mjs +1 -0
- package/cli/managed-server.mjs +12 -5
- package/cli/parse.mjs +3 -2
- package/cli/release.mjs +1 -0
- package/cli/site-bindings.mjs +291 -0
- package/cli/site-browser-objects.mjs +36 -0
- package/cli/site-check.mjs +46 -31
- package/cli/site-client.mjs +50 -0
- package/cli/site-data.mjs +54 -0
- package/cli/site-entities.json +1 -0
- package/cli/site-input-assets.mjs +58 -0
- package/cli/site-inputs.mjs +79 -0
- package/cli/site-markup.mjs +102 -0
- package/cli/site-private.mjs +36 -0
- package/cli/site-references.mjs +93 -0
- package/cli/site-render.mjs +7 -5
- package/cli/site-syntax.mjs +21 -0
- package/cli/site.mjs +32 -11
- package/global/CHANGELOG.md +25 -0
- package/global/README.md +3 -2
- package/global/main.mjs +2 -0
- package/global/output.mjs +12 -0
- package/global/rer.mjs +3 -0
- package/modules/deploy-kit/deploy.mjs +5 -2
- package/modules/deploy-kit/diagnostics.mjs +55 -0
- package/modules/deploy-kit/owner-cli.mjs +10 -4
- package/modules/deploy-kit/owner.mjs +3 -0
- package/modules/deploy-kit/provider.mjs +45 -6
- package/modules/operator-auth/accounts/native.mjs +1 -1
- package/modules/operator-auth/dev/badge.mjs +4 -1
- package/modules/operator-auth/index.d.mts +4 -9
- package/modules/operator-auth/page.mjs +1 -1
- package/modules/operator-auth/service.mjs +1 -0
- package/modules/operator-auth/signin/index.d.mts +11 -0
- package/modules/operator-auth/signin/index.mjs +10 -7
- package/modules/release/README.md +63 -0
- package/modules/release/keygen.mjs +18 -0
- package/modules/release/produce.mjs +9 -0
- package/modules/release/sign.mjs +44 -0
- package/modules/release/signature.mjs +336 -0
- package/modules/release/signing-cli.mjs +56 -0
- package/modules/release/signing-io.mjs +61 -0
- package/modules/release/signing-key.mjs +22 -0
- package/modules/release/statement.mjs +80 -0
- package/modules/release/vectors/generate.mjs +381 -0
- package/modules/release/vectors/release-signature-vectors.json +2305 -0
- package/modules/release/verify.mjs +27 -0
- package/onboarding/production-assets.mjs +23 -0
- package/onboarding/production-runtime.mjs +8 -4
- package/package.json +2 -1
- package/scripts/publish-check/README.md +27 -17
- package/scripts/publish-check/content_identities.py +14 -233
- package/scripts/publish-check/git_history.py +2 -2
- package/scripts/publish-check/gitleaks.toml +1 -1
- package/scripts/publish-check/history_scan.py +6 -4
- package/scripts/publish-check/hygiene_config.py +3 -1
- package/scripts/publish-check/public_hygiene.py +29 -5
- package/scripts/publish-check/publication-config.json +3 -1
- package/scripts/publish-check/source_metadata.py +62 -0
- package/scripts/publish-check/source_shapes.py +91 -0
- package/scripts/publish-check/tree_scan.py +2 -2
- package/scripts/publish-check/source_hashes.mjs +0 -6
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { decodedText } from './site-data.mjs';
|
|
2
|
+
|
|
3
|
+
// CSS strings and identifiers have their own escapes; comments can separate an
|
|
4
|
+
// at-rule or a function from its value. Tokenize them before selecting URLs.
|
|
5
|
+
export const decodeCSS = text => text.replace(/\\(?:([\da-f]{1,6})\s?|([^\r\n\f]))|\\(?:\r\n|[\r\n\f])/gi, (_, hex, char) => hex ? String.fromCodePoint(parseInt(hex, 16) > 0x10ffff ? 0xfffd : parseInt(hex, 16) || 0xfffd) : char ?? '');
|
|
6
|
+
function cssTokens(text) {
|
|
7
|
+
const tokens = [];
|
|
8
|
+
for (let i = 0; i < text.length;) {
|
|
9
|
+
if (/\s/.test(text[i])) { i++; continue; }
|
|
10
|
+
if (text.slice(i, i + 2) === '/*') { const end = text.indexOf('*/', i + 2); i = end < 0 ? text.length : end + 2; continue; }
|
|
11
|
+
const start = i, quote = text[i] === '"' || text[i] === "'" ? text[i++] : null;
|
|
12
|
+
if (quote) {
|
|
13
|
+
const begin = i;
|
|
14
|
+
while (i < text.length && text[i] !== quote) { if (text[i] === '\\') i++; i++; }
|
|
15
|
+
tokens.push({type:'string', value:decodeCSS(text.slice(begin, i))}); i++;
|
|
16
|
+
} else if (/[a-z0-9_@#./%~-]/i.test(text[i]) || text[i] === '\\') {
|
|
17
|
+
while (i < text.length && !/[\s(){};:,"']/.test(text[i])) {
|
|
18
|
+
if (text[i] === '\\') { const escape = /^\\(?:[\da-f]{1,6}\s?|[^\r\n\f])/i.exec(text.slice(i)); if (escape) { i += escape[0].length; continue; } }
|
|
19
|
+
if (text.slice(i, i + 2) === '/*') break;
|
|
20
|
+
i++;
|
|
21
|
+
}
|
|
22
|
+
tokens.push({type:'word', value:decodeCSS(text.slice(start, i))});
|
|
23
|
+
} else tokens.push({type:text[i], value:text[i++]});
|
|
24
|
+
}
|
|
25
|
+
return tokens;
|
|
26
|
+
}
|
|
27
|
+
export const cssStrings = text => cssTokens(text).filter(token => token.type === 'string').map(token => token.value);
|
|
28
|
+
export function cssReferences(text) {
|
|
29
|
+
const tokens = cssTokens(text), found = [];
|
|
30
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
31
|
+
const token = tokens[i], name = token.value.toLowerCase();
|
|
32
|
+
if (token.type === 'word' && ['@import','@use','@forward'].includes(name) && tokens[i + 1]?.type === 'string') found.push({url:tokens[i + 1].value, import:true, navigates:false});
|
|
33
|
+
if (token.type === 'word' && name === 'url' && tokens[i + 1]?.type === '(') {
|
|
34
|
+
let j = i + 2, url = '';
|
|
35
|
+
while (j < tokens.length && tokens[j].type !== ')') url += tokens[j++].value;
|
|
36
|
+
found.push({url, import:tokens[i - 1]?.value.toLowerCase() === '@import', navigates:false});
|
|
37
|
+
}
|
|
38
|
+
if (token.type === 'word' && ['image-set','-webkit-image-set'].includes(name) && tokens[i + 1]?.type === '(') {
|
|
39
|
+
for (let j = i + 2, depth = 1; j < tokens.length && depth; j++) {
|
|
40
|
+
if (tokens[j].type === '(') depth++;
|
|
41
|
+
if (tokens[j].type === ')') depth--;
|
|
42
|
+
if (depth === 1 && tokens[j].type === 'string') found.push({url:tokens[j].value,navigates:false});
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return found;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Attribute values may be quoted or unquoted. Raw script/style content is not
|
|
50
|
+
// parsed as tags, and a quoted greater-than sign does not end its tag.
|
|
51
|
+
export function htmlElements(text) {
|
|
52
|
+
const elements = [], tags = /<!--[\s\S]*?(?:-->|$)|<([a-z][a-z0-9:-]*)\b((?:[^"'<>]|"[^"]*"|'[^']*')*)>/gi;
|
|
53
|
+
for (let match; (match = tags.exec(text));) {
|
|
54
|
+
if (!match[1]) continue;
|
|
55
|
+
const tag = match[1].toLowerCase(), attributes = new Map();
|
|
56
|
+
for (const attribute of match[2].matchAll(/([^\s=/>]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g)) {
|
|
57
|
+
const name = attribute[1].toLowerCase();
|
|
58
|
+
if (!attributes.has(name)) attributes.set(name, decodedText(attribute[2] ?? attribute[3] ?? attribute[4] ?? ''));
|
|
59
|
+
}
|
|
60
|
+
let content;
|
|
61
|
+
if (['script','style','textarea','title'].includes(tag)) {
|
|
62
|
+
const end = new RegExp('</' + tag + '\\s*>', 'ig'); end.lastIndex = tags.lastIndex;
|
|
63
|
+
const closing = end.exec(text);
|
|
64
|
+
content = text.slice(tags.lastIndex, closing?.index ?? text.length);
|
|
65
|
+
tags.lastIndex = closing ? end.lastIndex : text.length;
|
|
66
|
+
}
|
|
67
|
+
elements.push({tag, attributes, content});
|
|
68
|
+
}
|
|
69
|
+
return elements;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Preserve markup/CSS string boundaries when those languages occur inside a
|
|
73
|
+
// JavaScript string. A quote delimiting an attribute is not part of its value.
|
|
74
|
+
export function markupStrings(text) {
|
|
75
|
+
const values = [];
|
|
76
|
+
for (const {tag, attributes, content} of htmlElements(text)) {
|
|
77
|
+
values.push(...attributes.values());
|
|
78
|
+
if (tag === 'style') values.push(...cssStrings(content));
|
|
79
|
+
if (attributes.has('style')) values.push(...cssStrings(attributes.get('style')));
|
|
80
|
+
if (attributes.has('srcdoc')) values.push(...markupStrings(attributes.get('srcdoc')));
|
|
81
|
+
}
|
|
82
|
+
return values;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Shared for complete pages and markup created by a browser island.
|
|
86
|
+
export function markupReferences(text, html) {
|
|
87
|
+
const found = [];
|
|
88
|
+
if (!html) return cssReferences(text);
|
|
89
|
+
for (const {tag, attributes, content} of htmlElements(text)) {
|
|
90
|
+
const rel = attributes.get('rel') ?? '';
|
|
91
|
+
if (tag === 'style') found.push(...cssReferences(content));
|
|
92
|
+
for (const [attribute, value] of attributes) {
|
|
93
|
+
const name = attribute === 'xlink:href' ? 'href' : attribute;
|
|
94
|
+
if (name === 'style') found.push(...cssReferences(value));
|
|
95
|
+
if (!['href','src','srcset','action','formaction','poster','data'].includes(name)) continue;
|
|
96
|
+
const navigates = name === 'href' && (['a','area'].includes(tag) || (tag === 'link' && /\b(?:canonical|alternate|author|license|help|me)\b/i.test(rel)));
|
|
97
|
+
const values = name === 'srcset' ? value.split(',').map(part => part.trim().split(/\s+/)[0]).filter(Boolean) : [value];
|
|
98
|
+
for (const url of values) found.push({url, navigates});
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return found;
|
|
102
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { readFile, realpath } from 'node:fs/promises';
|
|
3
|
+
import { relative, resolve, sep } from 'node:path';
|
|
4
|
+
import { promisify } from 'node:util';
|
|
5
|
+
import { identityPattern } from './site-check.mjs';
|
|
6
|
+
const run = promisify(execFile);
|
|
7
|
+
|
|
8
|
+
// An identity is a public example only when the entire source file is the
|
|
9
|
+
// committed blob, unchanged in both index and working tree. Secrets never qualify.
|
|
10
|
+
export async function unpublishedIdentities(root, identities, privateFiles = []) {
|
|
11
|
+
identities = [...new Set(identities)].filter(value => typeof value === 'string' && value.length);
|
|
12
|
+
if (!identities.length) return [];
|
|
13
|
+
const git = args => run('git', args, {cwd:root, maxBuffer:32 * 1024 * 1024, encoding:'buffer'});
|
|
14
|
+
let paths, prefix;
|
|
15
|
+
try {
|
|
16
|
+
prefix = (await git(['rev-parse', '--show-prefix'])).stdout.toString().trim();
|
|
17
|
+
paths = (await git(['ls-files', '--cached', '-z'])).stdout.toString().split('\0').filter(Boolean);
|
|
18
|
+
await git(['rev-parse', '--verify', 'HEAD']);
|
|
19
|
+
} catch (error) { if (error.code === 128) return identities; throw error; }
|
|
20
|
+
const patterns = identities.map(identityPattern), published = new Set();
|
|
21
|
+
for (const path of paths) {
|
|
22
|
+
if (!/\.(?:[cm]?[jt]sx?|md|html|css|json|jsonc|toml|txt|ya?ml)$/.test(path) || path.split('/').some(part => part.startsWith('.')) || privateFiles.some(file => path === file || path.startsWith(file + '/'))) continue;
|
|
23
|
+
const file = resolve(root, path), physical = await realpath(file).catch(() => null);
|
|
24
|
+
if (!physical || relative(resolve(root), physical).split(sep).includes('..') || physical !== file) continue;
|
|
25
|
+
const bytes = await readFile(file), text = bytes.toString('utf8');
|
|
26
|
+
const matches = patterns.map(({pattern}, index) => !published.has(index) && pattern.test(text) ? index : -1).filter(index => index >= 0);
|
|
27
|
+
if (!matches.length) continue;
|
|
28
|
+
try {
|
|
29
|
+
await git(['diff', '--quiet', 'HEAD', '--', path]);
|
|
30
|
+
const indexed = (await git(['show', ':' + prefix + path])).stdout;
|
|
31
|
+
const committed = (await git(['show', 'HEAD:' + prefix + path])).stdout;
|
|
32
|
+
if (bytes.equals(indexed) && bytes.equals(committed)) for (const index of matches) published.add(index);
|
|
33
|
+
} catch (error) { if (![1,128].includes(error.code)) throw error; }
|
|
34
|
+
}
|
|
35
|
+
return identities.filter((_, index) => !published.has(index));
|
|
36
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { memberName, stringValue, walkSyntax } from './site-syntax.mjs';
|
|
2
|
+
import { localValues } from './site-bindings.mjs';
|
|
3
|
+
import { browserObjects } from './site-browser-objects.mjs';
|
|
4
|
+
import { markupReferences, cssReferences, markupStrings, cssStrings } from './site-markup.mjs';
|
|
5
|
+
|
|
6
|
+
export function javascriptContent(tree) {
|
|
7
|
+
const values = localValues(tree), {strings, names, resources, origins, global, localClass} = values;
|
|
8
|
+
const browser = browserObjects(values), found = [], literals = [], unresolved = [];
|
|
9
|
+
const properties = new Set(['src','href','srcset','action','formaction','poster','data']);
|
|
10
|
+
const elementFactory = callee => {
|
|
11
|
+
const methods = names(callee);
|
|
12
|
+
if (!methods.some(name => ['createElement','jsx','jsxs','jsxDEV'].includes(name)) || localClass(callee.object)) return false;
|
|
13
|
+
const receivers = origins(callee.object);
|
|
14
|
+
// Native document construction takes options, whereas framework factories
|
|
15
|
+
// render props. Mixed receiver alternatives must retain their framework use.
|
|
16
|
+
return !(methods.every(name => name === 'createElement') && receivers.length && receivers.every(browser.document));
|
|
17
|
+
};
|
|
18
|
+
const add = (node, kind, navigates = false, documentRelative = true, srcset = false) => {
|
|
19
|
+
const targets = resources(node);
|
|
20
|
+
if (targets.some(target => !target.complete)) unresolved.push({kind});
|
|
21
|
+
for (const {value, complete} of targets) for (const url of srcset ? value.split(',').map(part => part.trim().split(/\s+/)[0]) : [value]) found.push({url, navigates, documentRelative, dynamic:!complete});
|
|
22
|
+
};
|
|
23
|
+
const css = (node, emitted) => {for (const {value, complete} of resources(node)) {
|
|
24
|
+
literals.push(...cssStrings(value));
|
|
25
|
+
if (!emitted) continue;
|
|
26
|
+
const references = cssReferences(value);
|
|
27
|
+
found.push(...references.map(reference => ({...reference,documentRelative:true,dynamic:!complete})));
|
|
28
|
+
if (!complete && references.length) unresolved.push({kind:'CSS.url'});
|
|
29
|
+
}};
|
|
30
|
+
const markup = (node, emitted) => { for (const value of strings(node)) {
|
|
31
|
+
literals.push(...markupStrings(value));
|
|
32
|
+
if (emitted) found.push(...markupReferences(value, true).map(reference => ({...reference, documentRelative:true})));
|
|
33
|
+
}};
|
|
34
|
+
walkSyntax(tree, node => {
|
|
35
|
+
if (['ImportDeclaration','ExportNamedDeclaration','ExportAllDeclaration','ImportExpression'].includes(node.type) && node.source) add(node.source, node.type === 'ImportExpression' ? 'import()' : 'import', false, false);
|
|
36
|
+
if (node.type === 'AssignmentExpression') {
|
|
37
|
+
const object = node.left.object, fields = names(node.left);
|
|
38
|
+
if (browser.location(node.left)) add(node.right, 'location.href');
|
|
39
|
+
for (const property of fields) {
|
|
40
|
+
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');
|
|
42
|
+
if (property === 'href' && browser.location(object)) add(node.right, 'location.href');
|
|
43
|
+
if (['innerHTML','outerHTML','srcdoc'].includes(property)) markup(node.right, browser.dom(object));
|
|
44
|
+
}
|
|
45
|
+
// String extraction retains its privacy scope even for inert object fields.
|
|
46
|
+
if (fields.some(property => ['style','cssText'].includes(property)) || origins(object).some(value => memberName(value) === 'style')) css(node.right, browser.style(object) || (fields.includes('style') && browser.dom(object)));
|
|
47
|
+
}
|
|
48
|
+
if (node.type === 'Property') {
|
|
49
|
+
const property = node.computed ? stringValue(node.key) : node.key.name ?? node.key.value;
|
|
50
|
+
// Keep extracting private text from stored markup; resource use is checked
|
|
51
|
+
// where the value reaches a DOM write or a recognized element factory.
|
|
52
|
+
if (property === '__html') markup(node.value, false);
|
|
53
|
+
}
|
|
54
|
+
if (!['CallExpression','NewExpression'].includes(node.type)) return;
|
|
55
|
+
const args = node.arguments;
|
|
56
|
+
const createsElement = node.type === 'CallExpression' && origins(node.callee).some(elementFactory);
|
|
57
|
+
const tags = createsElement ? strings(args[0]) : [];
|
|
58
|
+
if (tags.length) for (const props of origins(args[1])) {
|
|
59
|
+
if (props.type !== 'ObjectExpression') continue;
|
|
60
|
+
for (const field of props.properties) for (const property of names(field)) {
|
|
61
|
+
if (properties.has(property.toLowerCase())) add(field.value, `DOM.${property}`, property === 'href' && tags.some(tag => ['a','area'].includes(tag)), true, property.toLowerCase() === 'srcset');
|
|
62
|
+
if (property === 'dangerouslySetInnerHTML') for (const html of origins(field.value)) {
|
|
63
|
+
if (html.type === 'ObjectExpression') for (const content of html.properties) if (names(content).includes('__html')) markup(content.value, true);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
for (const name of ['fetch','Worker','SharedWorker','WebSocket','EventSource']) if (global(node.callee, name)) add(args[0], name);
|
|
68
|
+
for (const callee of origins(node.callee)) for (const method of names(callee)) {
|
|
69
|
+
const object = callee.object;
|
|
70
|
+
if (method === 'sendBeacon' && global(object, 'navigator')) add(args[0], 'navigator.sendBeacon');
|
|
71
|
+
// URL and Request construct data. Their arguments are resolved only when
|
|
72
|
+
// the resulting value reaches a resource operation such as fetch.
|
|
73
|
+
if (method === 'setProperty' && origins(object).some(value => memberName(value) === 'style')) css(args[1], browser.style(object));
|
|
74
|
+
if (method === 'open') {
|
|
75
|
+
if (browser.window(object)) add(args[0], 'window.open');
|
|
76
|
+
else if (browser.constructed(object, 'XMLHttpRequest')) add(args[1], 'XMLHttpRequest.open');
|
|
77
|
+
}
|
|
78
|
+
if (['assign','replace'].includes(method) && browser.location(object)) add(args[0], `location.${method}`);
|
|
79
|
+
if (['setAttribute','setAttributeNS'].includes(method)) {
|
|
80
|
+
const index = method === 'setAttributeNS' ? 1 : 0;
|
|
81
|
+
for (const attribute of strings(args[index])) {
|
|
82
|
+
const property = attribute.toLowerCase();
|
|
83
|
+
if (properties.has(property) && browser.dom(object)) add(args[index + 1], `DOM.${method}(${property})`, property === 'href' && browser.anchor(object), true, property === 'srcset');
|
|
84
|
+
if (property === 'srcdoc') markup(args[index + 1], browser.dom(object));
|
|
85
|
+
if (property === 'style') css(args[index + 1], browser.dom(object));
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (method === 'insertAdjacentHTML') markup(args[1], browser.dom(object));
|
|
89
|
+
if (method === 'write' || method === 'writeln') for (const argument of args) markup(argument, browser.document(object));
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
return {references:found, unresolved, literals};
|
|
93
|
+
}
|
package/cli/site-render.mjs
CHANGED
|
@@ -9,12 +9,13 @@
|
|
|
9
9
|
// toolchain); the app's own Vite config is never loaded, because its Worker
|
|
10
10
|
// plugins do not run in Node. It reads no installation data.
|
|
11
11
|
// Usage: node site-render.mjs ROOT ENTRY OUTPUT [CONFIG]
|
|
12
|
-
import { cp, lstat, mkdir, rm, writeFile } from 'node:fs/promises';
|
|
12
|
+
import { cp, lstat, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
13
13
|
import { createRequire } from 'node:module';
|
|
14
14
|
import { dirname, join, resolve, sep } from 'node:path';
|
|
15
15
|
import { pathToFileURL } from 'node:url';
|
|
16
|
+
import { buildClients, writeSiteAsset } from './site-client.mjs';
|
|
16
17
|
|
|
17
|
-
const [root, entry, output, config] = process.argv.slice(2);
|
|
18
|
+
const [root, entry, output, config, clientJSON] = process.argv.slice(2);
|
|
18
19
|
const fail = message => { process.stderr.write(`${message}\n`); process.exit(2); };
|
|
19
20
|
if (!root || !entry || !output) fail('Usage: site-render.mjs ROOT ENTRY OUTPUT [CONFIG]');
|
|
20
21
|
const inside = (base, path) => { const target = resolve(base, path); return target === base || target.startsWith(base + sep) ? target : null; };
|
|
@@ -46,7 +47,9 @@ try {
|
|
|
46
47
|
await writeFile(join(server, 'package.json'), '{"type":"module"}\n');
|
|
47
48
|
module = await import(pathToFileURL(join(server, chunk.fileName)).href);
|
|
48
49
|
if (typeof module.sitePages !== 'function') fail(`${entry} must export sitePages(), returning { pages: [{ path, html }], assets: [public paths] }.`);
|
|
49
|
-
const
|
|
50
|
+
const clientBuild = await buildClients(vite, root, output, clientJSON ? JSON.parse(clientJSON) : {}), clients = clientBuild.clients;
|
|
51
|
+
styles = [...new Set([...styles,...clientBuild.styles])];
|
|
52
|
+
const build = await module.sitePages({ styles, clients });
|
|
50
53
|
if (!Array.isArray(build?.pages) || !Array.isArray(build.assets)) fail('sitePages() returns { pages, assets }.');
|
|
51
54
|
|
|
52
55
|
const written = new Set();
|
|
@@ -68,8 +71,7 @@ try {
|
|
|
68
71
|
}
|
|
69
72
|
// Only the emitted files: the Node chunks share the folder and never ship.
|
|
70
73
|
for (const file of files) {
|
|
71
|
-
await
|
|
72
|
-
await cp(join(server, file), join(output, file), { errorOnExist: true, force: false });
|
|
74
|
+
await writeSiteAsset(output,file,await readFile(join(server,file)));
|
|
73
75
|
}
|
|
74
76
|
process.stdout.write(`${JSON.stringify({ pages: build.pages.length, assets: build.assets.length, styles: styles.length })}\n`);
|
|
75
77
|
} finally { await rm(server, { recursive: true, force: true }); }
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Shared ESTree helpers. Walk actual syntax so comments, string delimiters and
|
|
2
|
+
// formatting do not change which values the site checks.
|
|
3
|
+
export function walkSyntax(node, visit, parents = []) {
|
|
4
|
+
if (!node || typeof node !== 'object' || !node.type) return;
|
|
5
|
+
visit(node, parents);
|
|
6
|
+
for (const value of Object.values(node)) {
|
|
7
|
+
if (Array.isArray(value)) for (const child of value) walkSyntax(child, visit, [...parents, node]);
|
|
8
|
+
else if (value?.type) walkSyntax(value, visit, [...parents, node]);
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
export const memberName = node => node?.type === 'MemberExpression' ? node.computed ? stringValue(node.property) : node.property.name : undefined;
|
|
12
|
+
export const stringValue = node => node?.type === 'Literal' && typeof node.value === 'string' ? node.value : node?.type === 'TemplateLiteral' && !node.expressions.length ? node.quasis[0].value.cooked : undefined;
|
|
13
|
+
export function javascriptStrings(tree) {
|
|
14
|
+
const values = [];
|
|
15
|
+
walkSyntax(tree, node => {
|
|
16
|
+
const value = stringValue(node);
|
|
17
|
+
if (value !== undefined) values.push(value);
|
|
18
|
+
if (node.type === 'TemplateElement' && node.value.cooked !== null) values.push(node.value.cooked);
|
|
19
|
+
});
|
|
20
|
+
return values;
|
|
21
|
+
}
|
package/cli/site.mjs
CHANGED
|
@@ -6,6 +6,8 @@ import { invalid, missing, refuse, RerError } from './errors.mjs';
|
|
|
6
6
|
import { checkSite } from './site-check.mjs';
|
|
7
7
|
import { readDevKey } from './dev-identity.mjs';
|
|
8
8
|
import { shared } from './shared.mjs';
|
|
9
|
+
import { unpublishedIdentities } from './site-private.mjs';
|
|
10
|
+
import { privateSitePaths } from './site-inputs.mjs';
|
|
9
11
|
|
|
10
12
|
// The run site: a seed's static marketing pages, /run and docs, built from
|
|
11
13
|
// edition identity with no installation data. A seed opts in with
|
|
@@ -18,21 +20,28 @@ const relativePath = value => typeof value === 'string' && /^[A-Za-z0-9._/-]+$/.
|
|
|
18
20
|
export function declaredSite(project) {
|
|
19
21
|
const site = project.adapter?.site;
|
|
20
22
|
if (!site) throw refuse('This checkout declares no run site. A seed opts in with site: { entry, output, declaration } in its command adapter; see SEED-COMMAND-CONTRACT.md.');
|
|
21
|
-
if (!relativePath(site.entry) || !relativePath(site.output) || !relativePath(site.declaration) || (site.config !== undefined && !relativePath(site.config)) || ['src', 'public', 'node_modules', 'migrations'].some(top => site.output === top || site.output.startsWith(top + '/'))) throw invalid('The declared run site needs
|
|
23
|
+
if (!relativePath(site.entry) || !relativePath(site.output) || !relativePath(site.declaration) || (site.config !== undefined && !relativePath(site.config)) || (site.clientConfig !== undefined && !relativePath(site.clientConfig)) || ['src', 'public', 'node_modules', 'migrations'].some(top => site.output === top || site.output.startsWith(top + '/'))) throw invalid('The declared run site needs relative entries/configs, an output folder outside src, public and node_modules, and a deploy declaration.');
|
|
24
|
+
if (site.clients !== undefined && (!site.clients || Array.isArray(site.clients) || typeof site.clients !== 'object' || Object.entries(site.clients).some(([name, entry]) => !/^[a-z][a-z0-9-]*$/i.test(name) || !relativePath(entry)))) throw invalid('Run site clients is a map of island names to relative client entry paths.');
|
|
22
25
|
return site;
|
|
23
26
|
}
|
|
24
27
|
|
|
25
28
|
// Owner identity, secrets and the dev key: none may appear in the run site.
|
|
26
29
|
async function privateValues(project) {
|
|
27
|
-
const
|
|
30
|
+
const privateValues = [], privateIdentities = [], names = project.adapter.input?.names ?? {};
|
|
28
31
|
const { localVariables } = await shared('local/environment.mjs');
|
|
32
|
+
const manifest = await readFile(join(project.root, project.adapter.setup?.manifestFile ?? 'rer-setup.json'), 'utf8').then(JSON.parse, () => ({}));
|
|
33
|
+
const secretNames = new Set((manifest.fields ?? []).filter(field => field.kind === 'secret').map(field => field.name));
|
|
29
34
|
const variables = await readFile(join(project.root, project.adapter.hooks?.secrets?.file ?? '.dev.vars'), 'utf8').then(localVariables, () => ({}));
|
|
30
|
-
for (const [key, value] of Object.entries(variables))
|
|
35
|
+
for (const [key, value] of Object.entries(variables)) {
|
|
36
|
+
if ([names.email, names.name].includes(key)) privateIdentities.push(value);
|
|
37
|
+
else if (secretNames.has(key) || /SECRET|TOKEN|PASSWORD|_KEY$/.test(key)) privateValues.push(value);
|
|
38
|
+
}
|
|
31
39
|
const answers = await readFile(join(project.root, '.seed/installer/setup-answers.json'), 'utf8').then(JSON.parse, () => ({}));
|
|
32
|
-
for (const key of
|
|
40
|
+
for (const [key, value] of Object.entries(answers)) if (secretNames.has(key) || /SECRET|TOKEN|PASSWORD|_KEY$/.test(key)) privateValues.push(value);
|
|
41
|
+
for (const key of [names.email, names.name]) if (typeof answers?.[key] === 'string') privateIdentities.push(answers[key]);
|
|
33
42
|
const key = await readDevKey(project.root).catch(() => null);
|
|
34
|
-
if (key)
|
|
35
|
-
return
|
|
43
|
+
if (key) privateValues.push(key);
|
|
44
|
+
return { privateValues, privateIdentities: await unpublishedIdentities(project.root, privateIdentities, privateSitePaths(project.adapter)) };
|
|
36
45
|
}
|
|
37
46
|
|
|
38
47
|
/** Render the run site into its declared folder, check it and replace the previous build. */
|
|
@@ -40,18 +49,26 @@ export async function buildSite(project, store, signal) {
|
|
|
40
49
|
const site = declaredSite(project);
|
|
41
50
|
await contained(project.root, site.entry);
|
|
42
51
|
if (site.config) await contained(project.root, site.config);
|
|
52
|
+
if (site.clientConfig) await contained(project.root, site.clientConfig);
|
|
53
|
+
for (const entry of Object.values(site.clients ?? {})) await contained(project.root, entry);
|
|
43
54
|
const folder = await contained(project.root, site.output, { optional: true });
|
|
44
55
|
await mkdir(dirname(folder), { recursive: true });
|
|
45
56
|
const staging = `${folder}.building-${process.pid}`;
|
|
46
57
|
await rm(staging, { recursive: true, force: true });
|
|
47
58
|
try {
|
|
48
59
|
await mkdir(staging);
|
|
49
|
-
await store.run('Building the run site', process.execPath, [fileURLToPath(new URL('./site-render.mjs', import.meta.url)), project.root, site.entry, staging,
|
|
50
|
-
|
|
60
|
+
try { await store.run('Building the run site', process.execPath, [fileURLToPath(new URL('./site-render.mjs', import.meta.url)), project.root, site.entry, staging, site.config ?? '', JSON.stringify({entries:site.clients ?? {}, config:site.clientConfig, privatePaths:privateSitePaths(project.adapter)})], { cwd: project.root, signal, heavy: false }); }
|
|
61
|
+
catch (error) {
|
|
62
|
+
const code = await readFile(`${staging}.refusal`, 'utf8').catch(() => '');
|
|
63
|
+
if (code === 'site_private_input') throw new RerError(code, 'Run site client imports a private installation file or a file outside the seed and its declared dependencies.', 4);
|
|
64
|
+
if (code === 'site_client_server_import') throw new RerError(code, 'Run site client imports a server-only module. Use browser dependencies in every reachable island module.', 4);
|
|
65
|
+
throw error;
|
|
66
|
+
}
|
|
67
|
+
const checked = await checkSite(staging, {root:project.root, ...await privateValues(project)});
|
|
51
68
|
await rm(folder, { recursive: true, force: true });
|
|
52
69
|
await rename(staging, folder);
|
|
53
70
|
return { folder, ...checked };
|
|
54
|
-
} finally { await Promise.all([staging, `${staging}.render`].map(path => rm(path, { recursive: true, force: true }))); }
|
|
71
|
+
} finally { await Promise.all([staging, `${staging}.render`, `${staging}.refusal`].map(path => rm(path, { recursive: true, force: true }))); }
|
|
55
72
|
}
|
|
56
73
|
|
|
57
74
|
async function siteDeployment(project, options, folder) {
|
|
@@ -66,17 +83,21 @@ async function siteDeployment(project, options, folder) {
|
|
|
66
83
|
/** `rer build --site` and `rer deploy --site [--dry-run]`. */
|
|
67
84
|
export async function siteCommand(project, options, store, signal, out) {
|
|
68
85
|
const built = await buildSite(project, store, signal);
|
|
86
|
+
if (!options.json && built.references.unresolved.length) {
|
|
87
|
+
out.human(`Run site references: ${built.references.unresolved.length} runtime resource target(s) could not be resolved statically:`);
|
|
88
|
+
for (const {asset, kind} of built.references.unresolved) out.human(` ${asset}: ${kind}`);
|
|
89
|
+
}
|
|
69
90
|
if (options.verb === 'build') {
|
|
70
91
|
// Output redaction masks an inherited PWD (a credential-shaped name), so the
|
|
71
92
|
// checkout-relative folder is named too.
|
|
72
93
|
const path = relative(project.root, built.folder);
|
|
73
94
|
if (!options.json) out.human(`Run site written to ${path} (${built.pages} pages, ${built.files} files): ${built.folder}`);
|
|
74
|
-
return { site: { folder: built.folder, path, pages: built.pages, files: built.files } };
|
|
95
|
+
return { site: { folder: built.folder, path, pages: built.pages, files: built.files, references:built.references } };
|
|
75
96
|
}
|
|
76
97
|
const plan = await siteDeployment(project, options, built.folder);
|
|
77
98
|
const human = [`Run site plan: site Worker ${plan.worker}, Base-generated, its only binding the assets (the app's Worker ${plan.appWorker} is not touched)`, ` assets: ${plan.assets} (${built.files} files; HTML ${plan.assetsConfig.html_handling}, not found ${plan.assetsConfig.not_found_handling}; the Worker runs first)`,
|
|
78
99
|
` custom domains: ${plan.hostnames.join(', ')}; workers.dev off`, ...plan.redirects.map(rule => ` redirect in the Worker: ${rule.from} -> ${rule.to} (${rule.status}, keeps ${rule.keeps.join(' and ')})`)];
|
|
79
|
-
const summary = { worker: plan.worker, appWorker: plan.appWorker, module: plan.module, bindings: plan.bindings, assets: plan.assets, files: built.files, domain: plan.domain, hostnames: plan.hostnames, workersDev: false, runWorkerFirst: true, assetsConfig: plan.assetsConfig, redirects: plan.redirects.map(({ from, to, status }) => ({ from, to, status })) };
|
|
100
|
+
const summary = { worker: plan.worker, appWorker: plan.appWorker, module: plan.module, bindings: plan.bindings, assets: plan.assets, files: built.files, references:built.references, domain: plan.domain, hostnames: plan.hostnames, workersDev: false, runWorkerFirst: true, assetsConfig: plan.assetsConfig, redirects: plan.redirects.map(({ from, to, status }) => ({ from, to, status })) };
|
|
80
101
|
if (options.dryRun) {
|
|
81
102
|
if (options.json) out.event('site', summary); else for (const line of human) out.human(line);
|
|
82
103
|
store.report('deploy --site: dry run complete; no deployment occurred and no provider request was made.');
|
package/global/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,30 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.1
|
|
4
|
+
|
|
5
|
+
- `rer release keygen` prepares publisher keys and anchor entries; `rer release sign` writes a detached signature for a completed tagged release.
|
|
6
|
+
- `rer release receipt` verifies signed artifacts against local anchors offline, using the shared verifier and vectors exported for reuse.
|
|
7
|
+
- Tagged releases record the annotated tag object, public commit and public tree needed to sign their artifacts.
|
|
8
|
+
- Package-mode conversion removes obsolete vendored-copy checks, component selections and packaging globs, and rewrites test launches to the installed rer entry with foreground development.
|
|
9
|
+
- Hosted-output and hydration checks run when invoked through pnpm's package symlinks.
|
|
10
|
+
- Run sites support seed-declared browser client islands, with bundled scripts and styles checked during the build.
|
|
11
|
+
- Run-site privacy checks match complete owner identifiers and accept examples already committed in source; `rer dev` warns on a run-site privacy refusal while `rer build --site` still refuses.
|
|
12
|
+
- `rer preview --detach` starts a background preview, refreshes stale build output after source changes and serves scheduled trigger routes.
|
|
13
|
+
- `rer dev --foreground --help` shows the command's help.
|
|
14
|
+
- Deploy failures name the failed step and error code, show redacted provider output and list resources created during that attempt.
|
|
15
|
+
- Sign-in pages hide the email form when mail is unavailable and expose typed handler options.
|
|
16
|
+
- Dev badge and sign-in outbox controls have larger click targets.
|
|
17
|
+
- The Lobby stays script-free; `RER_OPEN` exchanges the development key on `/__dev/key` under its own content security policy.
|
|
18
|
+
- `rer --version` handles a closed output pipe without an EPIPE stack.
|
|
19
|
+
- The publication scan accepts recognised vendor no-reply co-author trailers while continuing to flag personal email addresses.
|
|
20
|
+
- Local-run instructions give the stop, export, reset, import and setup order.
|
|
21
|
+
- Base provides an optional run-site workflow template for manual adoption.
|
|
22
|
+
- Base's source and reference import kit can erase selected personal fields from retained shared records and undo images; this kit is outside the rer npm package.
|
|
23
|
+
|
|
24
|
+
### Known limitations
|
|
25
|
+
|
|
26
|
+
the run-site privacy check does not detect deliberately obfuscated secrets, such as base64 concatenated to other text, in client islands
|
|
27
|
+
|
|
3
28
|
## 0.2.0
|
|
4
29
|
|
|
5
30
|
What changes for a user since 0.1.1.
|
package/global/README.md
CHANGED
|
@@ -247,7 +247,7 @@ line that still names it. A later refresh is a version bump:
|
|
|
247
247
|
| `@runeditrun/rer/server-resources` | managed server resource readings (runtime journeys) | `scripts/base-cli/cli/server-resources.mjs` |
|
|
248
248
|
| `@runeditrun/rer/operations` | OPERATIONS describe and check | `scripts/base-cli/operations/operations.mjs` |
|
|
249
249
|
| `@runeditrun/rer/modules/portability`, `/catalogue`, `/cli`, `/cli-options`, `/files`, `/format`, `/http`, `/import`, `/journal`, `/legacy-safety`, `/migration-history`, `/storage` | portability module | `scripts/base-cli/modules/portability/*.mjs` |
|
|
250
|
-
| `@runeditrun/rer/modules/release/export`, `/publication-scan`, `/source` | release export
|
|
250
|
+
| `@runeditrun/rer/modules/release/export`, `/publication-scan`, `/source`, `/signature` | release export, publication scan and shared signature verifier | `scripts/base-cli/modules/release/*.mjs` |
|
|
251
251
|
| `@runeditrun/rer/modules/deploy-kit/owner`, `/owner-cli`, `/prepare`, `/provider` | deploy kit | `scripts/base-cli/modules/deploy-kit/*.mjs` |
|
|
252
252
|
| `@runeditrun/rer/modules/operator-auth`, `/dev`, `/dev/badge`, `/dev-admission`, `/accounts/native`, `/identity/email-link` | operator auth | `scripts/base-cli/modules/operator-auth/index.mjs`, `dev/index.mjs`, `dev/badge.mjs`, `dev-admission.mjs`, `accounts/native.mjs`, `identity/email-link/index.mjs` |
|
|
253
253
|
| `@runeditrun/rer/modules/erasure/fence` | erasure fence statements | `scripts/base-cli/modules/erasure/fence.mjs` |
|
|
@@ -260,7 +260,8 @@ In a checkout, every verb runs that checkout's own pinned `rer`; `rer VERB --hel
|
|
|
260
260
|
there lists its flags:
|
|
261
261
|
|
|
262
262
|
`install`, `setup`, `dev`, `preview`, `build`, `verify`, `reset --yes`, `deploy`,
|
|
263
|
-
`export`, `import`, `outbox`, `doctor`, `describe`, `check`, `release
|
|
263
|
+
`export`, `import`, `outbox`, `doctor`, `describe`, `check`, `release`,
|
|
264
|
+
`release sign`, `release receipt` and `release keygen`.
|
|
264
265
|
|
|
265
266
|
Anywhere: `rer list [DIR]`, `rer cli status|install|uninstall`, `rer update`,
|
|
266
267
|
`rer queue NAME -- COMMAND`, `rer --version` and `rer --help`.
|
package/global/main.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { dispatcher, locate, pinnedPackage, unsupportedMessage } from './checkout.mjs';
|
|
2
2
|
import { delegate } from './delegate.mjs';
|
|
3
3
|
import { localDefaults } from './launch/local-defaults.mjs';
|
|
4
|
+
import { handleBrokenPipes } from './output.mjs';
|
|
4
5
|
import { packageName as pinnedName } from './package-info.mjs';
|
|
5
6
|
|
|
6
7
|
const help = `Usage: rer VERB [FLAGS]
|
|
@@ -37,6 +38,7 @@ outbox, doctor, describe, check, release. Run rer VERB --help in a checkout for
|
|
|
37
38
|
const installsFirst = new Set(['install', 'check', 'describe']);
|
|
38
39
|
|
|
39
40
|
export async function main(argv) {
|
|
41
|
+
handleBrokenPipes();
|
|
40
42
|
const [verb] = argv;
|
|
41
43
|
if (verb === '--version' || verb === '-v') return (await import('./version.mjs')).versionCommand(argv);
|
|
42
44
|
// Only the zero-setup launch sets this, for its own setup child; an explicit
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
let installed = false;
|
|
2
|
+
|
|
3
|
+
export function handleBrokenPipes() {
|
|
4
|
+
if (installed) return;
|
|
5
|
+
installed = true;
|
|
6
|
+
for (const stream of [process.stdout, process.stderr]) {
|
|
7
|
+
stream.on('error', error => {
|
|
8
|
+
if (error.code === 'EPIPE') return;
|
|
9
|
+
throw error;
|
|
10
|
+
});
|
|
11
|
+
}
|
|
12
|
+
}
|
package/global/rer.mjs
CHANGED
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
// The installed `rer` command. This file avoids newer syntax so that an old
|
|
3
3
|
// Node still reaches the one-line minimum-version message.
|
|
4
4
|
import { readFileSync } from 'node:fs';
|
|
5
|
+
import { handleBrokenPipes } from './output.mjs';
|
|
6
|
+
|
|
7
|
+
handleBrokenPipes();
|
|
5
8
|
|
|
6
9
|
function manifest() {
|
|
7
10
|
// Packed: the package's own package.json. From a Base checkout: the template.
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { recordDeployFailure } from './diagnostics.mjs';
|
|
1
2
|
import { verifyServiceGrants } from './service-grants.mjs';
|
|
2
3
|
import { assert, hash, validatePlan } from './validation.mjs';
|
|
3
4
|
import { validateArtifact, publicAssetPaths } from './artifact.mjs';
|
|
@@ -55,8 +56,10 @@ export async function deploy({api,plan,artifact,artifactSha256,previousReceipt,r
|
|
|
55
56
|
if (onReceipt) await onReceipt(receipt);
|
|
56
57
|
// One built file proves the Worker hands static assets to the browser (assets run behind it).
|
|
57
58
|
const asset = publicAssetPaths(artifact.assets.map(x => x.path))[0];
|
|
58
|
-
|
|
59
|
-
|
|
59
|
+
try {
|
|
60
|
+
if (unchanged) await verifyHealth({...healthOptions,api,plan,receipt,fetch,asset});
|
|
61
|
+
else await firstHealth({api,plan,receipt,fetch,healthOptions,readiness,onHealth,asset});
|
|
62
|
+
} catch (error) { throw recordDeployFailure(error,{step:'health',...(error.code ? {code:error.code,output:error.message} : {})}); }
|
|
60
63
|
return receipt;
|
|
61
64
|
}
|
|
62
65
|
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Provider text is private until the CLI's existing credential redactor approves
|
|
2
|
+
// it. Identity, rather than mutable Error properties, carries this diagnostic.
|
|
3
|
+
const failures = new WeakMap();
|
|
4
|
+
export function recordDeployFailure(error, details) {
|
|
5
|
+
failures.set(error,{...failures.get(error),...details});
|
|
6
|
+
return error;
|
|
7
|
+
}
|
|
8
|
+
export function deployDiagnostic(error, redact) {
|
|
9
|
+
const detail = failures.get(error);
|
|
10
|
+
if (!detail) return undefined;
|
|
11
|
+
const clean = value => (redact ?? String)(String(value)).replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/g,'').replace(/\b[a-f0-9]{32}\b/gi,'[REDACTED]');
|
|
12
|
+
const failureCode = detail.code ?? error.message;
|
|
13
|
+
const code = /^[a-zA-Z0-9_-]{1,120}$/.test(failureCode) ? clean(failureCode) : 'deploy-failed';
|
|
14
|
+
return {code,step:detail.step ?? 'deploy',created:(detail.created ?? []).map(clean),
|
|
15
|
+
...(typeof error.resource==='string' && /^(?:d1|r2|kv|queues|worker|service):[a-z0-9-]+$/.test(error.resource) ? {resource:clean(error.resource)} : {}),
|
|
16
|
+
...(detail.providerCodes?.length ? {providerCodes:detail.providerCodes} : {}),
|
|
17
|
+
output:(detail.output ? (redact ? clean(detail.output) : '(provider output withheld; rer deploy provides redacted details)') : '(no provider output)').split(/\r?\n/).slice(0,8).map(line=>line.slice(0,500)).join('\n')};
|
|
18
|
+
}
|
|
19
|
+
function operation(path, options = {}) {
|
|
20
|
+
const write = options.method && options.method !== 'GET';
|
|
21
|
+
if (/\/d1\/database\/[^/]+\/query/.test(path)) return 'migrate D1';
|
|
22
|
+
for (const [prefix,kind] of [['/d1/database','D1'],['/r2/buckets','R2'],['/queues','queue'],['/storage/kv/namespaces','KV']]) {
|
|
23
|
+
if (path === prefix && options.method === 'POST') return `create ${kind}`;
|
|
24
|
+
if (path.startsWith(prefix)) return `check ${kind}`;
|
|
25
|
+
}
|
|
26
|
+
if (path.includes('/secrets')) return 'secrets';
|
|
27
|
+
if (path.includes('/subdomain') || path.includes('/routes') || path.includes('/domains')) return 'route';
|
|
28
|
+
if (path.includes('/schedules')) return 'scheduled triggers';
|
|
29
|
+
if (write && path.startsWith('/workers/')) return 'upload Worker';
|
|
30
|
+
return 'check Worker';
|
|
31
|
+
}
|
|
32
|
+
export function deploymentDiagnostics(api, {onCreated} = {}) {
|
|
33
|
+
const created = [];
|
|
34
|
+
const recordCreated = resource => { created.push(resource); onCreated?.(resource); };
|
|
35
|
+
let currentStep = 'prepare deploy';
|
|
36
|
+
const guard = method => async (path, options) => {
|
|
37
|
+
const step = operation(path,options);
|
|
38
|
+
currentStep = step;
|
|
39
|
+
try {
|
|
40
|
+
const result = await api[method](path,options);
|
|
41
|
+
if (step.startsWith('create ')) recordCreated(`${step.slice(7)}:${options.body.name ?? options.body.queue_name ?? options.body.title}`);
|
|
42
|
+
return result;
|
|
43
|
+
} catch (error) { throw recordDeployFailure(error,{step,created:[...created]}); }
|
|
44
|
+
};
|
|
45
|
+
return {...api,request:guard('request'),list:guard('list'),envelope:guard('envelope'),recordCreated,
|
|
46
|
+
annotateFailure(error, step = currentStep) {
|
|
47
|
+
const prior = failures.get(error);
|
|
48
|
+
// Node filesystem/CLI codes are structural; their messages can hold paths
|
|
49
|
+
// and inputs, so retain them privately for the caller's redaction boundary.
|
|
50
|
+
const cliCode = typeof error.code === 'string' && /^(?:E[A-Z0-9_]{1,63}|health-[a-z-]+)$/.test(error.code) ? error.code : undefined;
|
|
51
|
+
recordDeployFailure(error,{step:prior?.step ?? step,created:prior?.created ?? [...created],
|
|
52
|
+
...(!prior?.code && cliCode ? {code:cliCode,...(!prior?.output ? {output:error.message} : {})} : {})});
|
|
53
|
+
return error;
|
|
54
|
+
}};
|
|
55
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { deploymentDiagnostics, deployDiagnostic } from './diagnostics.mjs';
|
|
1
2
|
import { execFileSync } from 'node:child_process';
|
|
2
3
|
import { resolve, join } from 'node:path';
|
|
3
4
|
import { pathToFileURL } from 'node:url';
|
|
@@ -21,7 +22,7 @@ async function ownerArtifact(root, product) {
|
|
|
21
22
|
}
|
|
22
23
|
|
|
23
24
|
/** Inputs: CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_API_TOKEN, plus the secrets the declaration names on first deploy. */
|
|
24
|
-
export async function main(argv = process.argv.slice(2), env = process.env, {fetch = globalThis.fetch, cwd = process.cwd(), healthOptions, localOnlyKeys = [], notice = line => console.error(line)} = {}) {
|
|
25
|
+
export async function main(argv = process.argv.slice(2), env = process.env, {fetch = globalThis.fetch, cwd = process.cwd(), healthOptions, localOnlyKeys = [], notice = line => console.error(line), onCreated} = {}) {
|
|
25
26
|
let parsed;
|
|
26
27
|
try { parsed = parseArgs({args:argv,allowPositionals:true,strict:true,options:{declaration:{type:'string'},environment:{type:'string'},product:{type:'string',default:''},state:{type:'string'}}}); }
|
|
27
28
|
catch { throw new Error('usage'); }
|
|
@@ -36,23 +37,28 @@ export async function main(argv = process.argv.slice(2), env = process.env, {fet
|
|
|
36
37
|
try { declaration = JSON.parse(await regularBytes(resolve(root,values.declaration),256 * 1024)); }
|
|
37
38
|
catch (error) { throw new Error(error.code === 'ENOENT' ? 'missing-owner-declaration' : 'invalid-owner-declaration'); }
|
|
38
39
|
assert(typeof declaration?.project === 'string','missing-owner-project');
|
|
39
|
-
const api = cloudflare({accountId:env.CLOUDFLARE_ACCOUNT_ID,token:env.CLOUDFLARE_API_TOKEN,fetch});
|
|
40
|
+
const api = deploymentDiagnostics(cloudflare({accountId:env.CLOUDFLARE_ACCOUNT_ID,token:env.CLOUDFLARE_API_TOKEN,fetch}),{onCreated});
|
|
40
41
|
const target = await ownerTarget(api,{project:declaration.project,environment:values.environment});
|
|
41
42
|
const statePath = values.state ? resolve(root,values.state) : join(root,'.rer','deploy',`${target.environment}.json`);
|
|
42
43
|
const artifact = await ownerArtifact(root,values.product);
|
|
43
44
|
const plan = ownerPlan(target,declaration,artifact,{localOnlyKeys});
|
|
44
45
|
if (command === 'health') {
|
|
45
|
-
await ownerHealth({api,target,plan,statePath,fetch,healthOptions:{...healthOptions,readiness:{single:true,totalMs:30000}}});
|
|
46
|
+
await ownerHealth({api,target,plan,statePath,fetch,healthOptions:{...healthOptions,readiness:{single:true,totalMs:30000}}}).catch(error=>{throw api.annotateFailure(error,'health');});
|
|
46
47
|
return {ok:true,worker:target.worker,origin:target.origin};
|
|
47
48
|
}
|
|
48
49
|
// Dispatch is the provider's: on some accounts a new Cron starts only at the account scheduler's next refresh.
|
|
49
50
|
if (plan.crons.length) notice(cronNotice);
|
|
50
|
-
const {receipt,created} = await ownerDeploy({api,target,plan,artifact,statePath,env,fetch,healthOptions})
|
|
51
|
+
const {receipt,created} = await ownerDeploy({api,target,plan,artifact,statePath,env,fetch,healthOptions}).catch(error => {
|
|
52
|
+
api.annotateFailure(error,error.message.startsWith('missing-secret-') ? 'secrets' : undefined);
|
|
53
|
+
throw error;
|
|
54
|
+
});
|
|
51
55
|
return {ok:true,changed:receipt.changed,worker:target.worker,origin:receipt.origin,versionId:receipt.provider.versionId,created,state:statePath};
|
|
52
56
|
}
|
|
53
57
|
|
|
54
58
|
/** Prints refusal codes and resource names only; never inputs, tokens or provider bodies. */
|
|
55
59
|
export function report(error) {
|
|
60
|
+
const diagnostic = deployDiagnostic(error);
|
|
61
|
+
if (diagnostic) return {ok:false,...diagnostic};
|
|
56
62
|
if (error.message === 'missing-input') return {ok:false,code:'missing-input',inputs:error.inputs};
|
|
57
63
|
if (error.message === 'usage') return {ok:false,code:'usage',usage};
|
|
58
64
|
return {ok:false,code:/^[a-zA-Z0-9_-]{1,120}$/.test(error.message) ? error.message : 'owner-deploy-failed',...(error.resource ? {resource:error.resource} : {})};
|
|
@@ -273,6 +273,9 @@ async function deployLocked({api, target, plan, artifact, statePath, env, fetch,
|
|
|
273
273
|
}
|
|
274
274
|
const result = await api.request(path, options);
|
|
275
275
|
if (sending) {
|
|
276
|
+
// A successful upload creates a Worker only when ownership preflight found
|
|
277
|
+
// it absent. Record it before readback, which can fail after creation.
|
|
278
|
+
if (!exists) api.recordCreated?.(`Worker:${plan.worker}`);
|
|
276
279
|
const active = await activeWorker(api, plan.worker);
|
|
277
280
|
assert(active && deployedFingerprint(active.settings) === state.pendingUpload.fingerprint, 'uploaded-worker-drift');
|
|
278
281
|
state.pendingUpload.provider = {versionId:active.versionId,deploymentId:active.deploymentId,etag:active.etag};
|