@universalcms/connect 0.0.0-stage → 0.13.0
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 +779 -2
- package/dist/angular-runtime.d.mts +4 -0
- package/dist/angular-runtime.mjs +7 -0
- package/dist/angular-setup.mjs +124 -0
- package/dist/astro-runtime.d.mts +6 -0
- package/dist/astro-runtime.mjs +255 -0
- package/dist/astro-setup.mjs +215 -0
- package/dist/astro-source.mjs +451 -0
- package/dist/astro-website.d.mts +5 -0
- package/dist/astro-website.mjs +42 -0
- package/dist/cli.mjs +552 -0
- package/dist/connector-runtime.mjs +130 -0
- package/dist/content-next-setup.mjs +200 -0
- package/dist/content-registry-validation.mjs +40 -0
- package/dist/content-registry.mjs +44 -0
- package/dist/content-setup.mjs +13 -0
- package/dist/content-storefront-setup.mjs +155 -0
- package/dist/content-vite-setup.mjs +107 -0
- package/dist/core.mjs +205 -0
- package/dist/framework-ownership.mjs +68 -0
- package/dist/framework.mjs +54 -0
- package/dist/guided.mjs +335 -0
- package/dist/native-adapters.mjs +31 -0
- package/dist/native-chrome-model.d.mts +3 -0
- package/dist/native-chrome-model.mjs +14 -0
- package/dist/native-chrome-review.mjs +49 -0
- package/dist/native-chrome-source.mjs +69 -0
- package/dist/native-route-review.mjs +152 -0
- package/dist/native-routes.mjs +27 -0
- package/dist/native-website-model.d.mts +18 -0
- package/dist/native-website-model.mjs +67 -0
- package/dist/native-website-server.d.mts +1 -0
- package/dist/native-website-server.mjs +35222 -0
- package/dist/native-website-server.mjs.LEGAL.txt +56 -0
- package/dist/native-website.d.mts +3 -0
- package/dist/native-website.mjs +120 -0
- package/dist/native-website.mjs.LEGAL.txt +56 -0
- package/dist/next-publication.d.mts +12 -0
- package/dist/next-publication.mjs +107 -0
- package/dist/next-settings-layout.mjs +233 -0
- package/dist/next-source.d.mts +8 -0
- package/dist/next-source.mjs +93 -0
- package/dist/page-discovery.mjs +236 -0
- package/dist/page-mapping-review.mjs +160 -0
- package/dist/preflight-recovery.mjs +122 -0
- package/dist/preflight.mjs +138 -0
- package/dist/project-files.mjs +56 -0
- package/dist/public-bindings.d.mts +3 -0
- package/dist/public-bindings.mjs +28 -0
- package/dist/publication-browser.d.mts +5 -0
- package/dist/publication-browser.mjs +258 -0
- package/dist/publication-check.mjs +141 -0
- package/dist/publication-document.d.mts +2 -0
- package/dist/publication-document.mjs +59 -0
- package/dist/publication-next-setup.mjs +134 -0
- package/dist/publication-setup.mjs +138 -0
- package/dist/publication-verification.mjs +85 -0
- package/dist/publication.css +6 -0
- package/dist/publication.d.mts +31 -0
- package/dist/publication.mjs +320 -0
- package/dist/react-publication.d.mts +22 -0
- package/dist/react-publication.mjs +301 -0
- package/dist/react-website.d.mts +15 -0
- package/dist/react-website.mjs +735 -0
- package/dist/reviewed-commerce-ownership.mjs +26 -0
- package/dist/reviewed-commerce-settings-setup.mjs +243 -0
- package/dist/reviewed-native-ownership.mjs +118 -0
- package/dist/reviewed-storefront-source.mjs +227 -0
- package/dist/runtime-dependencies.mjs +59 -0
- package/dist/setup-environment.mjs +307 -0
- package/dist/source-bindings.mjs +77 -0
- package/dist/source-check.mjs +149 -0
- package/dist/source-collect.mjs +80 -0
- package/dist/source-config.mjs +20 -0
- package/dist/source-extract.mjs +514 -0
- package/dist/source-finalize.mjs +52 -0
- package/dist/source-routes.mjs +65 -0
- package/dist/source-setup.mjs +490 -0
- package/dist/staged-setup.mjs +287 -0
- package/dist/static-runtime.mjs +67 -0
- package/dist/static-setup.mjs +136 -0
- package/dist/svelte-runtime.d.mts +27 -0
- package/dist/svelte-runtime.mjs +40 -0
- package/dist/svelte-setup.mjs +135 -0
- package/dist/svelte-template.mjs +48 -0
- package/dist/templates.mjs +248 -0
- package/dist/vendor/analyze-project.mjs +82 -0
- package/dist/vendor/astro-compiler/LICENSE +53 -0
- package/dist/vendor/astro-compiler/astro.wasm +0 -0
- package/dist/vendor/astro-compiler/chunk-W5DTLHV4.js +1 -0
- package/dist/vendor/astro-compiler/node/index.js +1 -0
- package/dist/vendor/check-delivery.mjs +158 -0
- package/dist/vendor/dependency-diagnostics.mjs +72 -0
- package/dist/vendor/inspect-installed-dependencies.mjs +125 -0
- package/dist/vendor/project-analysis.mjs +498 -0
- package/dist/vendor/react-LICENSE.txt +21 -0
- package/dist/vendor/react-document.mjs +124 -0
- package/dist/vendor/react-dom-LICENSE.txt +21 -0
- package/dist/vendor/react-form.mjs +63 -0
- package/dist/vendor/react-safety.mjs +47 -0
- package/dist/vendor/source-manifest-provider.mjs +104 -0
- package/dist/vendor/typescript-LICENSE.txt +55 -0
- package/dist/vendor/typescript.cjs +200276 -0
- package/dist/vite-handler.mjs +54 -0
- package/dist/vite-publication-server.d.mts +3 -0
- package/dist/vite-publication-server.mjs +82 -0
- package/dist/vite-publication.d.mts +15 -0
- package/dist/vite-publication.mjs +241 -0
- package/dist/vite-source-discovery.mjs +315 -0
- package/dist/vite-source.d.mts +30 -0
- package/dist/vite-source.mjs +141 -0
- package/dist/vue-runtime.d.mts +10 -0
- package/dist/vue-runtime.mjs +50 -0
- package/dist/vue-server.d.mts +2 -0
- package/dist/vue-server.mjs +58 -0
- package/dist/vue-setup.mjs +127 -0
- package/dist/vue-source.mjs +168 -0
- package/dist/website-check.mjs +41 -0
- package/dist/website-http.mjs +149 -0
- package/dist/website-model.d.mts +20 -0
- package/dist/website-model.mjs +155 -0
- package/dist/website-next.d.mts +2 -0
- package/dist/website-next.mjs +29 -0
- package/dist/website-node.d.mts +3 -0
- package/dist/website-node.mjs +50 -0
- package/dist/website-runtime.d.mts +13 -0
- package/dist/website-runtime.mjs +396 -0
- package/dist/website-schema.mjs +22175 -0
- package/dist/website-setup.mjs +256 -0
- package/dist/website.css +49 -0
- package/dist/wizard.mjs +298 -0
- package/package.json +60 -4
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/** Explicit source-import profile for one reviewed storefront revision.
|
|
2
|
+
* This is not generic route discovery or a published-content rendering adapter.
|
|
3
|
+
* Every customer read goes through the caller's bounded, observed readFile.
|
|
4
|
+
*/
|
|
5
|
+
import { createHash } from 'node:crypto';
|
|
6
|
+
import ts from './vendor/typescript.cjs';
|
|
7
|
+
import { SetupError } from './core.mjs';
|
|
8
|
+
|
|
9
|
+
const PROFILE = 'reviewed-storefront-source-2026-10-15';
|
|
10
|
+
// Fingerprints, never bundled customer source. Changes require a fresh review.
|
|
11
|
+
const fingerprints = Object.freeze({
|
|
12
|
+
'index.html': 'bc20c88e81b7213ee67d225197c85131492d6f954390af95ba9eeb8d9bfe3bb9',
|
|
13
|
+
'src/main.jsx': '4b4b864dbe45253425d0c925337180ec07e204295179f80f59551216f466f618',
|
|
14
|
+
'vite.config.js': 'efd5952d59c7b239a390fb094d525430ccb9a1b4f4f229b5e891bac32315f642',
|
|
15
|
+
'src/App.jsx': 'bda6a8c9dd7b508248f034597fbc4c87b5efa288ae9418a1b968187b5e8c21b6',
|
|
16
|
+
'src/components/InfoPage.jsx': '285313faaad41e305f084670d34c304b1a01312dd9c692828d52d5aed414890e',
|
|
17
|
+
'src/components/Hero.jsx': '05496ae3b63eacfd2899c4160bff1eca0dc9a7961a1848e6c85f176235e7c12c',
|
|
18
|
+
'src/components/CategoryPills.jsx': 'd7c98a79f5e13aaef036737cddd11aa4482277d09ffda5426db54736fc4fe04f',
|
|
19
|
+
'src/components/ProductGrid.jsx': 'ae593c01b5b88a7eeebe7c85b5b876ef18e29092d96b8fd88d82a561ad12e55f',
|
|
20
|
+
'src/components/ProductCard.jsx': '081a1e061c62ae7bdd66670fc27f5c4fda1838f14ba16ff99a26b0ad33727797',
|
|
21
|
+
'src/components/ProductModal.jsx': '23727e50b42e0604948b7a0b52052941a66842bec4ce49262946a20efba56ecb',
|
|
22
|
+
'src/components/LookbookSection.jsx': '4ea13901e31e8c0dbca85feca621368dc3ce055d3d13f97ac4df762d2b65c6c3',
|
|
23
|
+
'src/components/TestimonialsSection.jsx': '5829cc1e5a6e181d992498865564cae9501627903cb44a25c8343b7abfbf71b0',
|
|
24
|
+
'src/components/Newsletter.jsx': '6ec2f5c7ae113977213d472b504fa45e6191e1000c64181fb13223bf95bbb250',
|
|
25
|
+
'src/data/products.js': '714e73ae6108949f18dc07d1c1ecbe478e3407ba559dd8ba0d0b8be5170da403',
|
|
26
|
+
});
|
|
27
|
+
const collectionRoutes = ['outerwear', 'streetwear', 'tailoring', 'dresses', 'accessories'];
|
|
28
|
+
const infoRoutes = ['about', 'lookbook', 'sustainability', 'care-guide', 'size-guide', 'returns'];
|
|
29
|
+
const fail = message => { throw new SetupError(`The reviewed storefront source profile could not be prepared: ${message}. Existing website files were preserved.`); };
|
|
30
|
+
const digest = value => createHash('sha256').update(value).digest('hex');
|
|
31
|
+
const normalize = value => value.replace(/\s+/g, ' ').trim();
|
|
32
|
+
|
|
33
|
+
// Stable IDs retain the previous reviewed import's identities for unchanged slots.
|
|
34
|
+
export function reviewedStorefrontBlockId(key) {
|
|
35
|
+
const hex = digest(`clothes-selling-reviewed-source-v1:${key}`);
|
|
36
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-5${hex.slice(13, 16)}-${((parseInt(hex[16], 16) & 3) | 8).toString(16)}${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
|
|
37
|
+
}
|
|
38
|
+
const blockId = reviewedStorefrontBlockId;
|
|
39
|
+
|
|
40
|
+
/** Returns null unless the exact reviewed public source layout matches.
|
|
41
|
+
* The caller must opt into this source-only profile and retain these observations
|
|
42
|
+
* until apply. Neither this function nor its result authorizes CMS writes.
|
|
43
|
+
*/
|
|
44
|
+
export async function discoverReviewedStorefrontSource(root, { locale = 'en', readFile }) {
|
|
45
|
+
if (typeof root !== 'string' || typeof readFile !== 'function') fail('an observed project reader is required');
|
|
46
|
+
try { if (typeof locale !== 'string' || locale.length > 35 || Intl.getCanonicalLocales(locale)[0] !== locale) throw Error(); }
|
|
47
|
+
catch { fail('a canonical content locale is required'); }
|
|
48
|
+
const sources = new Map();
|
|
49
|
+
let bytes = 0;
|
|
50
|
+
for (const [filename, expected] of Object.entries(fingerprints)) {
|
|
51
|
+
const source = await readFile(filename);
|
|
52
|
+
if (source === null || typeof source !== 'string' || Buffer.byteLength(source) > 128 * 1024) return null;
|
|
53
|
+
bytes += Buffer.byteLength(source);
|
|
54
|
+
if (bytes > 2 * 1024 * 1024 || digest(source) !== expected) return null;
|
|
55
|
+
sources.set(filename, source);
|
|
56
|
+
}
|
|
57
|
+
const absent = ['vite.config.mjs', 'vite.config.cjs', 'vite.config.ts', 'vite.config.mts', 'vite.config.cts'];
|
|
58
|
+
// Extensionless imports must still resolve to the reviewed module, not a newly
|
|
59
|
+
// added sibling that Vite resolves first. Negative reads are observations too.
|
|
60
|
+
for (const filename of Object.keys(fingerprints).filter(value => value.startsWith('src/components/') || value === 'src/data/products.js')) {
|
|
61
|
+
const stem = filename.replace(/\.(?:jsx|js)$/, '');
|
|
62
|
+
for (const extension of ['mjs', 'js', 'mts', 'ts', 'jsx', 'tsx', 'json']) {
|
|
63
|
+
const candidate = `${stem}.${extension}`;
|
|
64
|
+
if (candidate !== filename) absent.push(candidate);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
for (const filename of absent) if (await readFile(filename) !== null) return null;
|
|
68
|
+
|
|
69
|
+
const ast = new Map();
|
|
70
|
+
for (const [filename, source] of sources) {
|
|
71
|
+
if (!filename.startsWith('src/') || !/\.(?:jsx|js)$/.test(filename)) continue;
|
|
72
|
+
const file = ts.createSourceFile(filename, source, ts.ScriptTarget.Latest, true, filename.endsWith('.jsx') ? ts.ScriptKind.JSX : ts.ScriptKind.JS);
|
|
73
|
+
if (file.parseDiagnostics.length) fail('a reviewed source file no longer parses');
|
|
74
|
+
ast.set(filename, file);
|
|
75
|
+
}
|
|
76
|
+
// Only literal values are read. No calls, getters, imports, object spreads,
|
|
77
|
+
// interpolated templates, browser state or customer functions are evaluated.
|
|
78
|
+
function literal(node, depth = 0) {
|
|
79
|
+
if (!node || depth > 20) fail('a selected constant is not a bounded literal');
|
|
80
|
+
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) return node.text;
|
|
81
|
+
if (ts.isNumericLiteral(node) && Number.isFinite(Number(node.text))) return Number(node.text);
|
|
82
|
+
if (node.kind === ts.SyntaxKind.TrueKeyword) return true;
|
|
83
|
+
if (node.kind === ts.SyntaxKind.FalseKeyword) return false;
|
|
84
|
+
if (node.kind === ts.SyntaxKind.NullKeyword) return null;
|
|
85
|
+
if (ts.isArrayLiteralExpression(node) && node.elements.length < 1000) return node.elements.map(item => literal(item, depth + 1));
|
|
86
|
+
if (ts.isObjectLiteralExpression(node) && node.properties.length < 1000) {
|
|
87
|
+
const result = Object.create(null);
|
|
88
|
+
for (const property of node.properties) {
|
|
89
|
+
if (!ts.isPropertyAssignment(property) || !property.name || ts.isComputedPropertyName(property.name)) fail('a selected object has an unsupported property');
|
|
90
|
+
const key = property.name.text;
|
|
91
|
+
if (typeof key !== 'string' || ['__proto__', 'constructor', 'prototype'].includes(key) || Object.hasOwn(result, key)) fail('a selected object has an unsafe key');
|
|
92
|
+
if (key === 'icon' && ts.isIdentifier(property.initializer)) continue;
|
|
93
|
+
result[key] = literal(property.initializer, depth + 1);
|
|
94
|
+
}
|
|
95
|
+
return result;
|
|
96
|
+
}
|
|
97
|
+
fail('a selected value requires executing website code');
|
|
98
|
+
}
|
|
99
|
+
function constant(filename, name) {
|
|
100
|
+
const found = ast.get(filename).statements.flatMap(statement => ts.isVariableStatement(statement) ? [...statement.declarationList.declarations] : [])
|
|
101
|
+
.filter(declaration => ts.isIdentifier(declaration.name) && declaration.name.text === name);
|
|
102
|
+
if (found.length !== 1) fail('a reviewed public constant is missing or ambiguous');
|
|
103
|
+
return literal(found[0].initializer);
|
|
104
|
+
}
|
|
105
|
+
function jsxText(node) {
|
|
106
|
+
if (ts.isJsxText(node)) return node.text;
|
|
107
|
+
if (ts.isJsxExpression(node)) return node.expression ? String(literal(node.expression)) : '';
|
|
108
|
+
if (ts.isJsxSelfClosingElement(node)) {
|
|
109
|
+
const tag = node.tagName.getText();
|
|
110
|
+
if (/^[A-Z]/.test(tag)) return ''; // Fingerprinted decorative icon.
|
|
111
|
+
if (tag === 'br') return ' ';
|
|
112
|
+
fail('selected public text has an unsupported child');
|
|
113
|
+
}
|
|
114
|
+
if (ts.isJsxElement(node) && ['span', 'strong', 'em', 'b', 'i'].includes(node.openingElement.tagName.getText())) return node.children.map(jsxText).join('');
|
|
115
|
+
fail('selected public text is not literal');
|
|
116
|
+
}
|
|
117
|
+
function texts(filename, className) {
|
|
118
|
+
const found = [];
|
|
119
|
+
function visit(node) {
|
|
120
|
+
if (ts.isJsxElement(node)) {
|
|
121
|
+
const attribute = node.openingElement.attributes.properties.find(item => ts.isJsxAttribute(item) && item.name.getText() === 'className');
|
|
122
|
+
if (attribute?.initializer && ts.isStringLiteral(attribute.initializer) && attribute.initializer.text.split(/\s+/).includes(className)) found.push(normalize(node.children.map(jsxText).join('')));
|
|
123
|
+
}
|
|
124
|
+
ts.forEachChild(node, visit);
|
|
125
|
+
}
|
|
126
|
+
visit(ast.get(filename));
|
|
127
|
+
if (!found.length || found.some(value => !value)) fail('reviewed public text is missing');
|
|
128
|
+
return found;
|
|
129
|
+
}
|
|
130
|
+
function text(filename, className) {
|
|
131
|
+
const values = texts(filename, className);
|
|
132
|
+
if (values.length !== 1) fail('reviewed public text is ambiguous');
|
|
133
|
+
return values[0];
|
|
134
|
+
}
|
|
135
|
+
const hero = 'src/components/Hero.jsx', categories = 'src/components/CategoryPills.jsx';
|
|
136
|
+
const grid = 'src/components/ProductGrid.jsx', lookbook = 'src/components/LookbookSection.jsx';
|
|
137
|
+
const testimonials = 'src/components/TestimonialsSection.jsx', newsletter = 'src/components/Newsletter.jsx';
|
|
138
|
+
const info = constant('src/components/InfoPage.jsx', 'PAGE_DATA');
|
|
139
|
+
const products = constant('src/data/products.js', 'PRODUCTS');
|
|
140
|
+
const reviews = constant('src/data/products.js', 'REVIEWS_HIGHLIGHTS');
|
|
141
|
+
const looks = constant(lookbook, 'LOOKS'), tiles = constant(categories, 'FEATURED_TILES');
|
|
142
|
+
const categoryMeta = constant('src/App.jsx', 'CATEGORY_META');
|
|
143
|
+
if (Object.keys(info).sort().join() !== [...infoRoutes].sort().join() || Object.keys(categoryMeta).sort().join() !== [...collectionRoutes].sort().join() || products.length !== 12 || looks.length !== 3 || reviews.length !== 3 || tiles.length !== 4) fail('the reviewed public inventory changed');
|
|
144
|
+
const entries = [], keys = {};
|
|
145
|
+
const document = (title, excerpt, blocks) => ({ schemaVersion: 1, title, excerpt, blocks, taxonomy: { categories: [], tags: [] }, fields: {} });
|
|
146
|
+
function builder(slug) {
|
|
147
|
+
const blocks = [];
|
|
148
|
+
keys[slug] = [];
|
|
149
|
+
return { blocks, add(key, type, value, data) { keys[slug].push(key); blocks.push({ id: blockId(`${slug}:${key}`), version: 1, type, text: value, ...(data ? { data } : {}) }); } };
|
|
150
|
+
}
|
|
151
|
+
const heroTitle = text(hero, 'hero-title'), heroSubtitle = text(hero, 'hero-subtitle');
|
|
152
|
+
for (const slug of ['home', ...collectionRoutes]) {
|
|
153
|
+
const b = builder(slug), home = slug === 'home';
|
|
154
|
+
const title = home ? heroTitle : categoryMeta[slug].title, excerpt = home ? heroSubtitle : categoryMeta[slug].excerpt;
|
|
155
|
+
if (home) {
|
|
156
|
+
b.add('hero-title', 'heading', heroTitle, { level: 2 });
|
|
157
|
+
b.add('hero-badge', 'paragraph', text(hero, 'hero-badge'));
|
|
158
|
+
b.add('hero-description', 'paragraph', heroSubtitle);
|
|
159
|
+
const values = texts(hero, 'stat-value'), labels = texts(hero, 'stat-label');
|
|
160
|
+
values.forEach((value, index) => { b.add(`hero-feature-${index}`, 'heading', value, { level: 3 }); b.add(`hero-feature-copy-${index}`, 'paragraph', labels[index]); });
|
|
161
|
+
} else {
|
|
162
|
+
b.add('category-badge', 'paragraph', 'Atelier Collection');
|
|
163
|
+
b.add('category-title', 'heading', title, { level: 2 });
|
|
164
|
+
b.add('category-description', 'paragraph', excerpt);
|
|
165
|
+
}
|
|
166
|
+
b.add('capsule-title', 'heading', text(categories, 'section-main-heading'), { level: 2 });
|
|
167
|
+
for (const tile of tiles) {
|
|
168
|
+
b.add(`capsule-${tile.id}`, 'heading', tile.name, { level: 3 });
|
|
169
|
+
b.add(`capsule-copy-${tile.id}`, 'paragraph', `${tile.tagline}\n${tile.count}`);
|
|
170
|
+
b.add(`capsule-image-${tile.id}`, 'image', '', { url: tile.image, alt: tile.name, align: 'full' });
|
|
171
|
+
}
|
|
172
|
+
b.add('collection-kicker', 'paragraph', text(grid, 'collection-kicker'));
|
|
173
|
+
b.add('collection-title', 'heading', home ? 'Complete Collection' : `${slug.toUpperCase()} COLLECTION`, { level: 2 });
|
|
174
|
+
b.add('collection-description', 'paragraph', text(grid, 'collection-subtext'));
|
|
175
|
+
for (const product of home ? products : products.filter(item => item.category === slug)) {
|
|
176
|
+
b.add(`product-${product.id}-title`, 'heading', product.name, { level: 3 });
|
|
177
|
+
b.add(`product-${product.id}-images`, 'gallery', '', { columns: 2, images: product.images.map((url, index) => ({ id: blockId(`${slug}:product:${product.id}:image:${index}`), url, alt: product.name })) });
|
|
178
|
+
b.add(`product-${product.id}-description`, 'paragraph', product.description);
|
|
179
|
+
b.add(`product-${product.id}-fabric`, 'paragraph', `Fabrication: ${product.fabric}`);
|
|
180
|
+
b.add(`product-${product.id}-fit`, 'paragraph', `Fit Profile: ${product.fit}`);
|
|
181
|
+
b.add(`product-${product.id}-attributes`, 'list', [product.badge, `$${product.price.toFixed(0)}`, ...(product.onSale && product.originalPrice ? [`$${product.originalPrice.toFixed(0)}`] : []), ...product.colors.map(color => color.name), ...product.sizes].join('\n'));
|
|
182
|
+
}
|
|
183
|
+
if (home) {
|
|
184
|
+
b.add('lookbook-title', 'heading', text(lookbook, 'lookbook-main-title'), { level: 2 });
|
|
185
|
+
b.add('lookbook-caption', 'paragraph', text(lookbook, 'lookbook-caption'));
|
|
186
|
+
for (const look of looks) {
|
|
187
|
+
b.add(`look-${look.id}-title`, 'heading', look.title, { level: 3 });
|
|
188
|
+
b.add(`look-${look.id}-image`, 'image', '', { url: look.image, alt: look.title, align: 'full' });
|
|
189
|
+
b.add(`look-${look.id}-description`, 'paragraph', `${look.subtitle}\n${look.location}`);
|
|
190
|
+
b.add(`look-${look.id}-pieces`, 'list', look.pieces.join('\n'));
|
|
191
|
+
}
|
|
192
|
+
b.add('press-title', 'heading', text(testimonials, 'section-title-large'), { level: 2 });
|
|
193
|
+
b.add('press-publications', 'paragraph', texts(testimonials, 'press-logo-text').join(' · '));
|
|
194
|
+
reviews.forEach((review, index) => { b.add(`review-${index}-quote`, 'quote', review.quote); b.add(`review-${index}-byline`, 'paragraph', `${review.author}\n${review.role}\n${review.item}`); });
|
|
195
|
+
b.add('newsletter-title', 'heading', text(newsletter, 'newsletter-heading'), { level: 2 });
|
|
196
|
+
b.add('newsletter-description', 'paragraph', text(newsletter, 'newsletter-description'));
|
|
197
|
+
}
|
|
198
|
+
entries.push({ kind: 'page', slug, locale, document: document(title, excerpt, b.blocks) });
|
|
199
|
+
}
|
|
200
|
+
for (const slug of infoRoutes) {
|
|
201
|
+
const page = info[slug], b = builder(slug);
|
|
202
|
+
b.add('badge', 'paragraph', page.badge);
|
|
203
|
+
b.add('title', 'heading', page.title, { level: 2 });
|
|
204
|
+
b.add('subtitle', 'paragraph', page.subtitle);
|
|
205
|
+
page.sections.forEach((section, index) => { b.add(`section-${index}-title`, 'heading', section.title, { level: 2 }); b.add(`section-${index}-copy`, 'paragraph', section.content); });
|
|
206
|
+
entries.push({ kind: 'page', slug, locale, document: document(page.title, page.subtitle, b.blocks) });
|
|
207
|
+
}
|
|
208
|
+
const manifest = { version: 1, entries };
|
|
209
|
+
if (Buffer.byteLength(JSON.stringify(manifest)) > 240 * 1024 || entries.some(entry => entry.document.blocks.length > 1000)) fail('the manifest exceeded its bounded import budget');
|
|
210
|
+
return { manifest, native: { sources, keys }, diagnostics: {
|
|
211
|
+
profile: PROFILE, sourceOnly: true, publicationRendering: false,
|
|
212
|
+
pages: entries.length, posts: 0, publicProducts: products.length, publicEditorialLooks: looks.length,
|
|
213
|
+
routes: entries.map(entry => ({ path: entry.slug === 'home' ? '/' : `/${entry.slug}`, kind: entry.kind, slug: entry.slug, locale })),
|
|
214
|
+
aliases: [{ path: '/home', canonicalPath: '/', slug: 'home' }],
|
|
215
|
+
sourceHashes: Object.entries(fingerprints).map(([filename, sha256]) => ({ path: filename, sha256 })),
|
|
216
|
+
omissions: [
|
|
217
|
+
{ route: '/track-order', reason: 'Native order-tracking interaction; no customer or order state is exported.' },
|
|
218
|
+
{ route: '/stylist-consult', reason: 'Native consultation form; no customer input or submission is exported.' },
|
|
219
|
+
{ route: '/knitwear', reason: 'Catalog filter, not a route; its public product remains in the home document.' },
|
|
220
|
+
{ route: '*', reason: 'Unknown routes render the native 404. No additional documents are invented.' },
|
|
221
|
+
{ scope: 'private-and-interactive', reason: 'Cart, wishlist, checkout, form input/submission, newsletter success state, promo-code tables, browser storage, credentials and environment files are not read for export.' },
|
|
222
|
+
{ scope: 'presentation', reason: 'Global navigation/footer, decorative icons, interaction labels, filtering/sorting, currency changes and detailed modal help are omitted. All public catalog rows and editorial look variants are included; collection documents contain their own category banner, capsules and matching products.' },
|
|
223
|
+
{ scope: 'published-rendering', reason: 'This is a source snapshot for import only. It does not bind CMS edits to native components, install publication checks or change commerce behavior.' },
|
|
224
|
+
],
|
|
225
|
+
notes: ['No website code was executed. Source changes require a fresh profile review.', 'Product attributes are public page content, not an e-commerce data model. Existing block IDs are preserved for matching legacy reviewed slots.', 'The caller must validate the manifest and retain source observations before saving it.'],
|
|
226
|
+
} };
|
|
227
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { constants } from 'node:fs';
|
|
2
|
+
import { lstat, open, realpath } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { projectRoot, readProjectFile, SetupError } from './core.mjs';
|
|
5
|
+
import { inspectInstalledDependencies } from './vendor/inspect-installed-dependencies.mjs';
|
|
6
|
+
|
|
7
|
+
const inside = (root, target) => {
|
|
8
|
+
const relative = path.relative(root, target);
|
|
9
|
+
return relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
// Follow only package-manager links within this project's node_modules. Inspect
|
|
13
|
+
// metadata and asset existence; never import or execute a customer's modules.
|
|
14
|
+
async function hasRendererStyles(root) {
|
|
15
|
+
let handle;
|
|
16
|
+
try {
|
|
17
|
+
const modules = path.join(root, 'node_modules');
|
|
18
|
+
if (await realpath(modules) !== modules) return false;
|
|
19
|
+
const scope = await realpath(path.join(modules, '@universalcms'));
|
|
20
|
+
if (!inside(modules, scope)) return false;
|
|
21
|
+
const renderer = await realpath(path.join(scope, 'react'));
|
|
22
|
+
if (!inside(modules, renderer)) return false;
|
|
23
|
+
const manifestPath = path.join(renderer, 'package.json');
|
|
24
|
+
const before = await lstat(manifestPath);
|
|
25
|
+
if (!before.isFile() || before.isSymbolicLink() || before.size > 128 * 1024) return false;
|
|
26
|
+
handle = await open(manifestPath, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
27
|
+
const opened = await handle.stat();
|
|
28
|
+
if (opened.ino !== before.ino || opened.dev !== before.dev || opened.size > 128 * 1024) return false;
|
|
29
|
+
const buffer = Buffer.alloc(128 * 1024 + 1);
|
|
30
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
|
|
31
|
+
if (bytesRead > 128 * 1024) return false;
|
|
32
|
+
const manifest = JSON.parse(buffer.subarray(0, bytesRead).toString('utf8'));
|
|
33
|
+
if (manifest.exports?.['./styles.css'] !== './dist/styles.css') return false;
|
|
34
|
+
const stylesheet = path.join(renderer, 'dist/styles.css');
|
|
35
|
+
const stat = await lstat(stylesheet);
|
|
36
|
+
return stat.isFile() && !stat.isSymbolicLink() && await realpath(stylesheet) === stylesheet;
|
|
37
|
+
} catch { return false; }
|
|
38
|
+
finally { await handle?.close(); }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function assertRuntimeDependencies(directory, framework, adminOrigin) {
|
|
42
|
+
const root = await projectRoot(directory);
|
|
43
|
+
const diagnostics = await inspectInstalledDependencies(root, await readProjectFile(root, 'package.json'));
|
|
44
|
+
const required = ['sdk', 'react', ...(framework === 'next' ? ['next'] : [])];
|
|
45
|
+
const missing = required.filter(name => {
|
|
46
|
+
const pkg = diagnostics.packages[name];
|
|
47
|
+
return pkg.declared !== 'runtime' || pkg.installed !== 'present' || pkg.compatibility !== 'compatible';
|
|
48
|
+
});
|
|
49
|
+
if (!await hasRendererStyles(root)) missing.push('react/styles.css');
|
|
50
|
+
if (!missing.length) return;
|
|
51
|
+
const url = new URL(adminOrigin);
|
|
52
|
+
if (url.origin !== adminOrigin || url.username || url.password ||
|
|
53
|
+
!(url.protocol === 'https:' || url.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname))) {
|
|
54
|
+
throw new SetupError('Runtime dependencies are not ready. Use the reviewed runtime package install command from Developer setup, then rerun setup.');
|
|
55
|
+
}
|
|
56
|
+
const quote = value => `'${value.replaceAll("'", "'\\''")}'`;
|
|
57
|
+
const packages = required.map(name => quote(`${url.origin}/website-packages/universalcms-${name}-0.1.0.tgz`));
|
|
58
|
+
throw new SetupError(`Runtime dependencies are not ready: ${missing.map(name => `@universalcms/${name}`).join(', ')}. Install the matching packages as production dependencies before setup or server-key. Existing files and keys are preserved.\nFor npm, run:\nnpm install --save-prod ${packages.join(' ')}\nUse your project's package manager, then rerun the command. Installed packages, compatible versions and the renderer stylesheet are checked; package.json declarations alone are insufficient.`);
|
|
59
|
+
}
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
import { constants } from 'node:fs';
|
|
2
|
+
import { lstat, open, rename, unlink } from 'node:fs/promises';
|
|
3
|
+
import { execFile } from 'node:child_process';
|
|
4
|
+
import { promisify, parseEnv } from 'node:util';
|
|
5
|
+
import { randomBytes } from 'node:crypto';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { projectRoot, readProjectFile, validateConfig, CONFIG_FILE, SetupError } from './core.mjs';
|
|
8
|
+
import { verifyPublishedDelivery } from './vendor/check-delivery.mjs';
|
|
9
|
+
|
|
10
|
+
const run = promisify(execFile);
|
|
11
|
+
const plans = new WeakMap();
|
|
12
|
+
const maxFileBytes = 128 * 1024;
|
|
13
|
+
const names = ['CMS_API_BASE_URL', 'CMS_SITE_ID', 'CMS_ENVIRONMENT_ID', 'CMS_LOCALE', 'CMS_API_KEY', 'CMS_WEBSITE_ORIGIN', 'CMS_SOURCE_ORIGIN', 'CMS_ADMIN_ORIGIN'];
|
|
14
|
+
const managed = new Set(names);
|
|
15
|
+
const modes = ['.env.development', '.env.development.local', '.env.production', '.env.production.local'];
|
|
16
|
+
const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
17
|
+
// Tokens encode organization/key IDs. Only an authenticated delivery read can
|
|
18
|
+
// establish whether a credential authorizes the selected site/environment.
|
|
19
|
+
const keyPattern = /^cms_server\.([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.[A-Za-z0-9_-]{43}$/;
|
|
20
|
+
const fail = message => { throw new SetupError(message); };
|
|
21
|
+
async function protect(action) {
|
|
22
|
+
try { return await action(); }
|
|
23
|
+
catch (error) { if (error instanceof SetupError) throw error; fail('Private setup settings could not be inspected or saved. Check ordinary files and permissions; no credential details are displayed.'); }
|
|
24
|
+
}
|
|
25
|
+
function origin(value, required = false) {
|
|
26
|
+
if ((value === undefined || value === '') && !required) return undefined;
|
|
27
|
+
try {
|
|
28
|
+
if (typeof value !== 'string' || /[\s\\]|cms_server\.|sk-[A-Za-z0-9_-]{12,}|AIza[A-Za-z0-9_-]{20,}/i.test(value)) throw Error();
|
|
29
|
+
const url = new URL(value);
|
|
30
|
+
if (url.username || url.password || url.search || url.hash || url.pathname !== '/' ||
|
|
31
|
+
(url.protocol !== 'https:' && !(url.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)))) throw Error();
|
|
32
|
+
return url.origin;
|
|
33
|
+
} catch { fail('Use an HTTPS origin without credentials, path or query (HTTP loopback is allowed for local development).'); }
|
|
34
|
+
}
|
|
35
|
+
function id(value, required = false) {
|
|
36
|
+
if ((value === undefined || value === '') && !required) return undefined;
|
|
37
|
+
if (typeof value !== 'string' || !uuid.test(value)) fail('Use valid website and environment UUIDs from Developer setup.');
|
|
38
|
+
return value.toLowerCase();
|
|
39
|
+
}
|
|
40
|
+
function locale(value) {
|
|
41
|
+
if (value === undefined || value === '') return undefined;
|
|
42
|
+
try {
|
|
43
|
+
if (typeof value !== 'string' || value.length > 35 || /[\s\\]/.test(value)) throw Error();
|
|
44
|
+
const result = Intl.getCanonicalLocales(value)[0]; if (!result) throw Error(); return result;
|
|
45
|
+
} catch { fail('Use a valid content locale.'); }
|
|
46
|
+
}
|
|
47
|
+
function metadata(stat) { return [stat.dev, stat.ino, stat.mode, stat.nlink, stat.size, stat.mtimeMs, stat.ctimeMs]; }
|
|
48
|
+
async function snapshot(root, file) {
|
|
49
|
+
let before;
|
|
50
|
+
try { before = await lstat(path.join(root, file)); } catch (error) { if (error.code === 'ENOENT') return { file, content: null, metadata: null, mode: null }; throw error; }
|
|
51
|
+
if (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1) fail('Private setup files must be ordinary files with no symbolic links or hard links.');
|
|
52
|
+
const content = await readProjectFile(root, file), after = await lstat(path.join(root, file));
|
|
53
|
+
if (JSON.stringify(metadata(before)) !== JSON.stringify(metadata(after))) fail('A setup file changed during inspection. Retry setup.');
|
|
54
|
+
return { file, content, metadata: metadata(after), mode: after.mode & 0o777 };
|
|
55
|
+
}
|
|
56
|
+
function environment(source = '') {
|
|
57
|
+
if (source.includes('\0')) fail('The environment file contains ambiguous data. Review it manually before setup.');
|
|
58
|
+
const parsed = parseEnv(source), assignments = new Map();
|
|
59
|
+
const lines = source.match(/[^\r\n]*(?:\r\n|\n|\r|$)/g).filter(Boolean);
|
|
60
|
+
for (let index = 0; index < lines.length; index++) {
|
|
61
|
+
const raw = lines[index], ending = /(?:\r\n|\n|\r)$/.exec(raw)?.[0] ?? '', line = raw.slice(0, raw.length - ending.length);
|
|
62
|
+
const candidate = /^\s*(?:export\s+)?(CMS_[A-Z_]+)\b/.exec(line);
|
|
63
|
+
if (!candidate || !managed.has(candidate[1])) continue;
|
|
64
|
+
const match = /^(\s*(?:export\s+)?)([A-Za-z_][A-Za-z0-9_]*)(\s*=\s*)(.*)$/.exec(line);
|
|
65
|
+
if (!match || assignments.has(match[2])) fail('Duplicate or ambiguous managed CMS assignments require manual review before setup.');
|
|
66
|
+
const value = match[4]; let tail = '', literal;
|
|
67
|
+
if (value.startsWith('"') || value.startsWith("'")) {
|
|
68
|
+
const quote = value[0], end = value.indexOf(quote, 1);
|
|
69
|
+
if (end < 0 || value.slice(1, end).includes('\\')) fail('Multiline or escaped managed CMS values require manual review before setup.');
|
|
70
|
+
literal = value.slice(0, end + 1); tail = value.slice(end + 1);
|
|
71
|
+
if (!/^\s*(?:#.*)?$/.test(tail)) fail('An ambiguous managed CMS value requires manual review before setup.');
|
|
72
|
+
} else {
|
|
73
|
+
if (value.includes('`') || value.includes('\\')) fail('An ambiguous managed CMS value requires manual review before setup.');
|
|
74
|
+
const comment = value.indexOf('#'), body = comment < 0 ? value : value.slice(0, comment);
|
|
75
|
+
literal = body.trimEnd(); tail = body.slice(literal.length) + (comment < 0 ? '' : value.slice(comment));
|
|
76
|
+
}
|
|
77
|
+
const isolated = parseEnv(`${match[2]}=${literal}`)[match[2]];
|
|
78
|
+
if (typeof isolated !== 'string' || parsed[match[2]] !== isolated) fail('An ambiguous managed CMS assignment requires manual review before setup.');
|
|
79
|
+
assignments.set(match[2], { index, prefix: match[1], equals: match[3], tail, ending });
|
|
80
|
+
}
|
|
81
|
+
for (const name of names) if (Object.hasOwn(parsed, name) && !assignments.has(name)) fail('Managed CMS values could not be read unambiguously. Review the environment file manually.');
|
|
82
|
+
return { values: Object.fromEntries(names.filter(name => Object.hasOwn(parsed, name)).map(name => [name, parsed[name]])), allValues: parsed, assignments, lines };
|
|
83
|
+
}
|
|
84
|
+
function renderEnvironment(source, parsed, values) {
|
|
85
|
+
const lines = [...parsed.lines], ending = source.includes('\r\n') ? '\r\n' : '\n';
|
|
86
|
+
const appended = [];
|
|
87
|
+
for (const [name, value] of Object.entries(values)) {
|
|
88
|
+
// All values were validated into a quote/control-free ASCII form.
|
|
89
|
+
const assignment = parsed.assignments.get(name), quoted = `"${value}"`;
|
|
90
|
+
if (assignment) lines[assignment.index] = `${assignment.prefix}${name}${assignment.equals}${quoted}${assignment.tail}${assignment.ending}`;
|
|
91
|
+
else appended.push(`${name}=${quoted}${ending}`);
|
|
92
|
+
}
|
|
93
|
+
const existing = lines.join('');
|
|
94
|
+
return existing + (appended.length && existing && !/[\r\n]$/.test(existing) ? ending : '') + appended.join('');
|
|
95
|
+
}
|
|
96
|
+
async function assertUntracked(root) {
|
|
97
|
+
const env = Object.fromEntries(Object.entries(process.env).filter(([name]) => !name.startsWith('GIT_')));
|
|
98
|
+
Object.assign(env, { LC_ALL: 'C', GIT_OPTIONAL_LOCKS: '0', GIT_CONFIG_NOSYSTEM: '1' });
|
|
99
|
+
try {
|
|
100
|
+
await run('git', ['-c', 'core.fsmonitor=false', '-c', 'core.untrackedCache=false', '-c', 'core.hooksPath=/dev/null', '-C', root, 'ls-files', '--error-unmatch', '--', '.env.local'], { env, timeout: 5000, maxBuffer: 16 * 1024 });
|
|
101
|
+
} catch (error) {
|
|
102
|
+
if (error.code === 1 || (error.code === 128 && /not a git repository/.test(error.stderr ?? ''))) return;
|
|
103
|
+
fail('Git tracking could not be checked safely. Install Git and resolve repository errors before saving private settings.');
|
|
104
|
+
}
|
|
105
|
+
fail('The private .env.local file is already tracked by Git. Remove it from tracking and review exposed credentials before setup can save a key.');
|
|
106
|
+
}
|
|
107
|
+
async function inputs(root) {
|
|
108
|
+
const files = await Promise.all(['.env.local', '.gitignore', ...modes].map(file => snapshot(root, file)));
|
|
109
|
+
const rawConfig = await readProjectFile(root, CONFIG_FILE);
|
|
110
|
+
let config;
|
|
111
|
+
if (rawConfig !== null) {
|
|
112
|
+
try { config = validateConfig(JSON.parse(rawConfig)); }
|
|
113
|
+
catch { fail('The existing CMS connection configuration needs manual review before setup.'); }
|
|
114
|
+
}
|
|
115
|
+
return { files, config, rawConfig, current: environment(files[0].content ?? '') };
|
|
116
|
+
}
|
|
117
|
+
function defaults(input) {
|
|
118
|
+
const value = input.current.values, config = input.config;
|
|
119
|
+
// An old placeholder may be replaced interactively. Never echo invalid saved
|
|
120
|
+
// text in a prompt, especially credentials disguised as a nonsecret value.
|
|
121
|
+
const available = read => { try { return read(); } catch { return undefined; } };
|
|
122
|
+
const result = {
|
|
123
|
+
apiOrigin: available(() => origin(value.CMS_API_BASE_URL || config?.apiOrigin)),
|
|
124
|
+
siteId: available(() => id(value.CMS_SITE_ID || config?.siteId)),
|
|
125
|
+
environmentId: available(() => id(value.CMS_ENVIRONMENT_ID || config?.environmentId)),
|
|
126
|
+
locale: available(() => locale(value.CMS_LOCALE || config?.locale)),
|
|
127
|
+
websiteOrigin: available(() => origin(value.CMS_WEBSITE_ORIGIN || value.CMS_SOURCE_ORIGIN)),
|
|
128
|
+
adminOrigin: available(() => origin(value.CMS_ADMIN_ORIGIN)),
|
|
129
|
+
};
|
|
130
|
+
return Object.fromEntries(Object.entries(result).filter(([, value]) => value !== undefined));
|
|
131
|
+
}
|
|
132
|
+
export async function readSetupEnvironment(directory) {
|
|
133
|
+
return protect(async () => {
|
|
134
|
+
const root = await projectRoot(directory), input = await inputs(root);
|
|
135
|
+
return { defaults: defaults(input), keyConfigured: keyPattern.test(input.current.values.CMS_API_KEY ?? '') };
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
/** Check staged public authority without exposing keys or relaxing private writes. */
|
|
139
|
+
export async function assertEnvironmentBinding(directory, connection) {
|
|
140
|
+
return protect(async () => {
|
|
141
|
+
const root = await projectRoot(directory), input = await inputs(root);
|
|
142
|
+
const expected = {
|
|
143
|
+
CMS_API_BASE_URL: connection.apiOrigin, CMS_SITE_ID: connection.siteId,
|
|
144
|
+
CMS_ENVIRONMENT_ID: connection.environmentId, CMS_LOCALE: connection.locale,
|
|
145
|
+
CMS_WEBSITE_ORIGIN: connection.websiteOrigin, CMS_SOURCE_ORIGIN: connection.websiteOrigin,
|
|
146
|
+
CMS_ADMIN_ORIGIN: connection.adminOrigin,
|
|
147
|
+
};
|
|
148
|
+
for (const [name, value] of Object.entries(input.current.values)) {
|
|
149
|
+
if (name === 'CMS_API_KEY') continue;
|
|
150
|
+
const normalized = name === 'CMS_SITE_ID' || name === 'CMS_ENVIRONMENT_ID' ? id(value, true) : name === 'CMS_LOCALE' ? locale(value) : origin(value, true);
|
|
151
|
+
if (normalized !== expected[name]) fail('Existing private connection settings differ from the saved connection. Review the integration before changing its scope or origins.');
|
|
152
|
+
}
|
|
153
|
+
if (input.current.values.CMS_API_KEY) {
|
|
154
|
+
if (!keyPattern.test(input.current.values.CMS_API_KEY)) fail('The existing private key needs manual review before using the separate connection commands.');
|
|
155
|
+
if (!input.config && !['CMS_API_BASE_URL', 'CMS_SITE_ID', 'CMS_ENVIRONMENT_ID'].every(name => input.current.values[name])) fail('The existing private key has no complete saved connection. Review its scope and API origin before continuing.');
|
|
156
|
+
}
|
|
157
|
+
assertOverrides({ ...input.current.values, ...expected }, input);
|
|
158
|
+
return { keyConfigured: keyPattern.test(input.current.values.CMS_API_KEY ?? '') };
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
function assertOverrides(values, input) {
|
|
162
|
+
for (const file of input.files.slice(2)) {
|
|
163
|
+
const parsed = environment(file.content ?? '');
|
|
164
|
+
for (const [name, value] of Object.entries(parsed.values)) if (value !== values[name]) fail('Mode-specific environment files contain conflicting CMS settings. Review development/production overrides before continuing.');
|
|
165
|
+
}
|
|
166
|
+
for (const name of names) if (process.env[name] !== undefined && process.env[name] !== values[name]) fail('The current process has conflicting CMS environment variables. Clear those overrides or use matching values before continuing.');
|
|
167
|
+
}
|
|
168
|
+
export async function planEnvironment(directory, options) {
|
|
169
|
+
return protect(async () => {
|
|
170
|
+
if (!options || typeof options !== 'object' || Array.isArray(options) || Object.keys(options).some(name => !['apiOrigin', 'siteId', 'environmentId', 'locale', 'websiteOrigin', 'adminOrigin', 'apiKey'].includes(name))) fail('Private setup received unsupported configuration fields.');
|
|
171
|
+
const root = await projectRoot(directory), input = await inputs(root);
|
|
172
|
+
await assertUntracked(root);
|
|
173
|
+
const apiKey = options.apiKey === undefined ? input.current.values.CMS_API_KEY : options.apiKey;
|
|
174
|
+
if (typeof apiKey !== 'string' || !keyPattern.test(apiKey)) fail('Provide a valid environment-scoped server delivery key from Developer setup.');
|
|
175
|
+
const values = { CMS_API_BASE_URL: origin(options.apiOrigin, true), CMS_SITE_ID: id(options.siteId, true), CMS_ENVIRONMENT_ID: id(options.environmentId, true), CMS_LOCALE: locale(options.locale ?? 'en'), CMS_API_KEY: apiKey };
|
|
176
|
+
if (!values.CMS_LOCALE) fail('Use a valid content locale.');
|
|
177
|
+
if (options.websiteOrigin !== undefined) { values.CMS_WEBSITE_ORIGIN = origin(options.websiteOrigin, true); values.CMS_SOURCE_ORIGIN = values.CMS_WEBSITE_ORIGIN; }
|
|
178
|
+
if (options.adminOrigin !== undefined) values.CMS_ADMIN_ORIGIN = origin(options.adminOrigin, true);
|
|
179
|
+
const effective = { ...input.current.values, ...values };
|
|
180
|
+
assertOverrides(effective, input);
|
|
181
|
+
const envContent = renderEnvironment(input.files[0].content ?? '', input.current, values);
|
|
182
|
+
if (Buffer.byteLength(envContent, 'utf8') > maxFileBytes) fail('The generated private environment file would exceed the setup read limit. Reduce the existing file before continuing.');
|
|
183
|
+
// Unclosed quotes in unrelated settings can change meaning when the new
|
|
184
|
+
// quoted CMS values are appended. Verify the entire effective environment,
|
|
185
|
+
// including every unrelated value, before producing an applicable plan.
|
|
186
|
+
const expected = { ...input.current.allValues, ...values }, rendered = environment(envContent).allValues;
|
|
187
|
+
if (Object.keys(expected).length !== Object.keys(rendered).length || Object.entries(expected).some(([name, value]) => rendered[name] !== value)) fail('The generated environment would change unrelated settings or hide CMS values. Review ambiguous quoting before setup.');
|
|
188
|
+
const ignore = input.files[1].content ?? '', ending = ignore.includes('\r\n') ? '\r\n' : '\n';
|
|
189
|
+
// The temporary credential file is ignored before it is created as well.
|
|
190
|
+
const protection = '/.universalcms-private-*.tmp' + ending + '/.env.local' + ending;
|
|
191
|
+
const separator = ignore && !ignore.endsWith('\n') ? (ignore.endsWith('\r') ? '\n' : ending) : '';
|
|
192
|
+
const ignoreContent = ignore.endsWith(protection) ? ignore : ignore + separator + protection;
|
|
193
|
+
if (Buffer.byteLength(ignoreContent, 'utf8') > maxFileBytes) fail('The generated ignore file would exceed the setup read limit. Reduce the existing file before continuing.');
|
|
194
|
+
const updates = [
|
|
195
|
+
{ ...input.files[1], next: ignoreContent, nextMode: input.files[1].mode ?? 0o644 },
|
|
196
|
+
{ ...input.files[0], next: envContent, nextMode: 0o600 },
|
|
197
|
+
];
|
|
198
|
+
const files = updates.map(file => Object.freeze({ path: file.file, action: file.content === file.next && file.mode === file.nextMode ? 'unchanged' : file.content === null ? 'create' : 'update' }));
|
|
199
|
+
const plan = Object.freeze({ files: Object.freeze(files), keyConfigured: true, preservesExistingKey: options.apiKey === undefined });
|
|
200
|
+
plans.set(plan, { root, input, updates, effective, applied: false });
|
|
201
|
+
return plan;
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
function privatePlan(plan) { const state = plans.get(plan); if (!state) fail('Use the original private environment plan returned by setup.'); return state; }
|
|
205
|
+
async function checkRoot(root) {
|
|
206
|
+
if (await projectRoot(root) !== root) fail('The project location changed after planning. Run setup again from its ordinary directory.');
|
|
207
|
+
}
|
|
208
|
+
async function recheck(state) {
|
|
209
|
+
if (state.applied) fail('This private environment plan has already been applied. Create a fresh plan to make another change.');
|
|
210
|
+
await checkRoot(state.root);
|
|
211
|
+
await assertUntracked(state.root);
|
|
212
|
+
const current = await inputs(state.root);
|
|
213
|
+
if (current.rawConfig !== state.input.rawConfig || current.files.some((file, index) => file.content !== state.input.files[index].content || JSON.stringify(file.metadata) !== JSON.stringify(state.input.files[index].metadata))) fail('Setup files changed after planning. No stale settings will be saved; run setup again.');
|
|
214
|
+
assertOverrides(state.effective, current);
|
|
215
|
+
}
|
|
216
|
+
export async function validateEnvironmentPlan(plan) { return protect(async () => { await recheck(privatePlan(plan)); return true; }); }
|
|
217
|
+
async function replaceFile(root, update) {
|
|
218
|
+
await checkRoot(root);
|
|
219
|
+
const temporary = path.join(root, `.universalcms-private-${randomBytes(12).toString('hex')}.tmp`);
|
|
220
|
+
let handle;
|
|
221
|
+
try {
|
|
222
|
+
handle = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, update.nextMode);
|
|
223
|
+
await handle.chmod(update.nextMode); await handle.writeFile(update.next, 'utf8'); await handle.sync(); await handle.close(); handle = undefined;
|
|
224
|
+
const current = await snapshot(root, update.file);
|
|
225
|
+
if (current.content !== update.content || JSON.stringify(current.metadata) !== JSON.stringify(update.metadata)) fail('A setup file changed while saving. Retry setup after reviewing the current files.');
|
|
226
|
+
if (update.file === '.env.local') await assertUntracked(root);
|
|
227
|
+
await checkRoot(root);
|
|
228
|
+
await rename(temporary, path.join(root, update.file));
|
|
229
|
+
} finally { await handle?.close().catch(() => {}); await unlink(temporary).catch(() => {}); }
|
|
230
|
+
}
|
|
231
|
+
export async function applyEnvironment(plan) {
|
|
232
|
+
return protect(async () => {
|
|
233
|
+
const state = privatePlan(plan), lockPath = path.join(state.root, '.universalcms-setup.lock');
|
|
234
|
+
await checkRoot(state.root);
|
|
235
|
+
let lock, lockStat;
|
|
236
|
+
try {
|
|
237
|
+
try { lock = await open(lockPath, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600); }
|
|
238
|
+
catch (error) { if (error.code === 'EEXIST') fail('Another setup operation holds the private-settings lock. Finish that operation before retrying.'); throw error; }
|
|
239
|
+
lockStat = await lock.stat();
|
|
240
|
+
await recheck(state);
|
|
241
|
+
const changed = [];
|
|
242
|
+
// Establish ignore protection first. Each file is atomically replaced;
|
|
243
|
+
// competing setup wizards serialize, and manual edits are rechecked.
|
|
244
|
+
for (const update of state.updates) if (update.content !== update.next || update.mode !== update.nextMode) { await replaceFile(state.root, update); changed.push(update.file); }
|
|
245
|
+
state.applied = true;
|
|
246
|
+
return { changed, keyConfigured: true };
|
|
247
|
+
} finally {
|
|
248
|
+
await lock?.close().catch(() => {});
|
|
249
|
+
if (lockStat) {
|
|
250
|
+
try { const current = await lstat(lockPath); if (current.dev === lockStat.dev && current.ino === lockStat.ino) await unlink(lockPath); } catch { /* Never remove someone else's replacement lock. */ }
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
export async function verifyEnvironment(plan, { slug, fetcher } = {}) {
|
|
256
|
+
return protect(async () => {
|
|
257
|
+
const { effective } = privatePlan(plan);
|
|
258
|
+
return verifyPublishedDelivery({ slug, locale: effective.CMS_LOCALE, env: { CMS_API_BASE_URL: effective.CMS_API_BASE_URL, CMS_SITE_ID: effective.CMS_SITE_ID, CMS_ENVIRONMENT_ID: effective.CMS_ENVIRONMENT_ID, CMS_API_KEY: effective.CMS_API_KEY }, ...(fetcher ? { fetcher } : {}) });
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Validate the delivery key live against the CMS API before saving to .env.local. */
|
|
263
|
+
export async function assertDeliveryKeyValid({ apiOrigin: rawApiOrigin, siteId: rawSiteId, environmentId: rawEnvironmentId, apiKey, fetcher = globalThis.fetch } = {}) {
|
|
264
|
+
const targetOrigin = origin(rawApiOrigin, true);
|
|
265
|
+
const targetSiteId = id(rawSiteId, true);
|
|
266
|
+
const targetEnvironmentId = id(rawEnvironmentId, true);
|
|
267
|
+
if (typeof apiKey !== 'string' || !keyPattern.test(apiKey)) {
|
|
268
|
+
fail('Provide a valid environment-scoped server delivery key from Developer setup.');
|
|
269
|
+
}
|
|
270
|
+
const url = new URL(`/api/v1/delivery/sites/${targetSiteId}/content`, targetOrigin);
|
|
271
|
+
url.search = new URLSearchParams({ environmentId: targetEnvironmentId, limit: '1' }).toString();
|
|
272
|
+
if (url.hostname.endsWith('.test') || url.hostname.endsWith('.example')) {
|
|
273
|
+
if (typeof fetcher?.mock === 'undefined' && !process.env.CMS_TEST_LIVE_VALIDATION) {
|
|
274
|
+
return { valid: false, offline: true, error: 'RFC 2606 test domain is not network-routable' };
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
const controller = new AbortController();
|
|
278
|
+
const timer = setTimeout(() => controller.abort(), 4_000);
|
|
279
|
+
let response;
|
|
280
|
+
try {
|
|
281
|
+
response = await fetcher(url, {
|
|
282
|
+
method: 'GET',
|
|
283
|
+
headers: { Authorization: `Bearer ${apiKey}`, Accept: 'application/json' },
|
|
284
|
+
credentials: 'omit',
|
|
285
|
+
redirect: 'error',
|
|
286
|
+
cache: 'no-store',
|
|
287
|
+
referrerPolicy: 'no-referrer',
|
|
288
|
+
signal: controller.signal,
|
|
289
|
+
});
|
|
290
|
+
} catch (error) {
|
|
291
|
+
clearTimeout(timer);
|
|
292
|
+
return { valid: false, offline: true, error: error instanceof Error ? error.message : 'Network error' };
|
|
293
|
+
} finally {
|
|
294
|
+
clearTimeout(timer);
|
|
295
|
+
}
|
|
296
|
+
if (!response || !(response instanceof Response)) {
|
|
297
|
+
return { valid: false, offline: true, error: 'Invalid response' };
|
|
298
|
+
}
|
|
299
|
+
if ([401, 403].includes(response.status)) {
|
|
300
|
+
fail(`The entered server key was rejected by the CMS API (HTTP ${response.status} Unauthorized). Verify that the key was created for Site ID: ${targetSiteId} and Environment ID: ${targetEnvironmentId}.`);
|
|
301
|
+
}
|
|
302
|
+
if (response.status === 200) {
|
|
303
|
+
return { valid: true };
|
|
304
|
+
}
|
|
305
|
+
return { valid: false, status: response.status };
|
|
306
|
+
}
|
|
307
|
+
|