@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,65 @@
|
|
|
1
|
+
import { lstat, opendir, realpath } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { projectRoot, readProjectFile } from './project-files.mjs';
|
|
4
|
+
|
|
5
|
+
/** An opt-in inventory of filenames only. Never import, render or crawl a page. */
|
|
6
|
+
export async function inspectSourceRoutes(directory) {
|
|
7
|
+
const root = await projectRoot(directory);
|
|
8
|
+
const pkg = JSON.parse(await readProjectFile(root, 'package.json'));
|
|
9
|
+
if (!pkg || !pkg.dependencies?.next && !pkg.devDependencies?.next || pkg.workspaces) throw Error('Unsupported route inventory');
|
|
10
|
+
for (const extension of ['js', 'mjs', 'cjs', 'ts']) {
|
|
11
|
+
const config = await readProjectFile(root, `next.config.${extension}`);
|
|
12
|
+
if (config && /\b(?:pageExtensions|basePath|rewrites|redirects|i18n)\b/.test(config)) throw Error('Custom route inventory');
|
|
13
|
+
}
|
|
14
|
+
async function ordinaryDirectory(relative) {
|
|
15
|
+
const parts = relative.split('/');
|
|
16
|
+
let current = root;
|
|
17
|
+
for (const part of parts) {
|
|
18
|
+
current = path.join(current, part);
|
|
19
|
+
let stat;
|
|
20
|
+
try { stat = await lstat(current); } catch (error) { if (error.code === 'ENOENT') return false; throw error; }
|
|
21
|
+
if (!stat.isDirectory() || stat.isSymbolicLink() || await realpath(current) !== current) throw Error('Unsafe directory');
|
|
22
|
+
}
|
|
23
|
+
return true;
|
|
24
|
+
}
|
|
25
|
+
const candidates = [];
|
|
26
|
+
for (const relative of ['app', 'src/app']) if (await ordinaryDirectory(relative)) candidates.push(relative);
|
|
27
|
+
if (candidates.length !== 1) throw Error('Ambiguous App Router');
|
|
28
|
+
for (const relative of ['pages', 'src/pages']) if (await ordinaryDirectory(relative)) throw Error('Mixed routing');
|
|
29
|
+
const routes = new Set(), files = {}, unresolvedPaths = [];
|
|
30
|
+
let entries = 0, directories = 0;
|
|
31
|
+
async function walk(relative, segments, depth) {
|
|
32
|
+
if (depth > 12 || ++directories > 256 || !await ordinaryDirectory(relative)) throw Error('Directory budget');
|
|
33
|
+
const directoryHandle = await opendir(path.join(root, relative));
|
|
34
|
+
for await (const entry of directoryHandle) {
|
|
35
|
+
if (++entries > 4000) throw Error('Entry budget');
|
|
36
|
+
if (entry.isSymbolicLink()) throw Error('Symbolic route');
|
|
37
|
+
if (entry.isDirectory()) {
|
|
38
|
+
// Parallel slots can contain routable descendants; they need review.
|
|
39
|
+
if (entry.name.startsWith('_')) continue;
|
|
40
|
+
if (/^[@.]/.test(entry.name)) { unresolvedPaths.push(`${relative}/${entry.name}`); continue; }
|
|
41
|
+
if (/^\[[^/]+\]$/.test(entry.name) || /^\(\.{1,3}\)/.test(entry.name)) { unresolvedPaths.push(`${relative}/${entry.name}`); continue; }
|
|
42
|
+
const group = /^\([a-zA-Z0-9_-]+\)$/.test(entry.name);
|
|
43
|
+
if (!group && !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(entry.name)) { unresolvedPaths.push(`${relative}/${entry.name}`); continue; }
|
|
44
|
+
await walk(`${relative}/${entry.name}`, group ? segments : [...segments, entry.name], depth + 1);
|
|
45
|
+
} else if (entry.isFile() && /^page\.(?:js|jsx|ts|tsx)$/.test(entry.name)) {
|
|
46
|
+
const route = segments.join('/');
|
|
47
|
+
if (routes.has(route)) throw Error('Duplicate route');
|
|
48
|
+
routes.add(route);
|
|
49
|
+
files[route || 'home'] = `${relative}/${entry.name}`;
|
|
50
|
+
if (routes.size > 1000) throw Error('Route budget');
|
|
51
|
+
} else if (/^page\.(?:md|mdx)$/.test(entry.name)) unresolvedPaths.push(`${relative}/${entry.name}`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
await walk(candidates[0], [], 0);
|
|
55
|
+
if (!routes.size) throw Error('No known pages');
|
|
56
|
+
return { routes: [...routes], unresolved: unresolvedPaths.length, unresolvedPaths, files };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function compareSourceRoutes(inventory, entries) {
|
|
60
|
+
const exported = new Set(entries.filter(entry => entry.kind === 'page').map(entry => entry.slug));
|
|
61
|
+
const missing = inventory.routes.filter(route => !exported.has(route || 'home')).length;
|
|
62
|
+
// /home and / cannot both be represented by the same identity.
|
|
63
|
+
const ambiguousHome = inventory.routes.includes('') && inventory.routes.includes('home');
|
|
64
|
+
return { staticPages: inventory.routes.length, missingPages: missing, unresolvedRoutes: inventory.unresolved + Number(ambiguousHome) };
|
|
65
|
+
}
|
|
@@ -0,0 +1,490 @@
|
|
|
1
|
+
/** Additive public-source setup. Never reads credentials or executes website code. */
|
|
2
|
+
import { constants } from 'node:fs';
|
|
3
|
+
import { lstat, realpath, open, mkdir, readdir, rename, unlink } from 'node:fs/promises';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { projectRoot, SetupError } from './core.mjs';
|
|
7
|
+
import { collectSource } from './source-collect.mjs';
|
|
8
|
+
import { createSourceManifestProvider } from './vendor/source-manifest-provider.mjs';
|
|
9
|
+
import { discoverViteSource } from './vite-source-discovery.mjs';
|
|
10
|
+
import { discoverReviewedStorefrontSource } from './reviewed-storefront-source.mjs';
|
|
11
|
+
import { planAstroIntegration } from './astro-setup.mjs';
|
|
12
|
+
import { inspectAstroSource } from './astro-source.mjs';
|
|
13
|
+
import { contentDigest } from './content-registry.mjs';
|
|
14
|
+
import { inspectSetupFramework } from './framework.mjs';
|
|
15
|
+
import { nativeFramework, inspectNativeSource, planNativeIntegration } from './native-adapters.mjs';
|
|
16
|
+
|
|
17
|
+
export const SOURCE_CONFIG_FILE = '.universalcms/source.config.json';
|
|
18
|
+
export const SOURCE_SNAPSHOT_FILE = '.universalcms/source.generated.json';
|
|
19
|
+
export const VITE_SOURCE_ENTRIES_FILE = '.universalcms/source.entries.json';
|
|
20
|
+
const VITE_SOURCE_WRAPPER = '.universalcms/source.vite.config.mjs';
|
|
21
|
+
const VITE_CONTENT_ROUTES_FILE = '.universalcms/content.routes.json';
|
|
22
|
+
const plans = new WeakMap();
|
|
23
|
+
const MAX_BYTES = 20 * 1024 * 1024;
|
|
24
|
+
const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
25
|
+
const plain = value => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
26
|
+
const fail = message => { throw new SetupError(message); };
|
|
27
|
+
const json = value => `${JSON.stringify(value, null, 2)}\n`;
|
|
28
|
+
const stamp = stat => [stat.dev, stat.ino, stat.size, stat.mtimeMs, stat.ctimeMs, stat.nlink, stat.mode].join(':');
|
|
29
|
+
|
|
30
|
+
function origin(value) {
|
|
31
|
+
try {
|
|
32
|
+
if (typeof value !== 'string' || value.length > 2048 || /[\s\\]|cms_server\.|sk-[A-Za-z0-9_-]{12,}|AIza[A-Za-z0-9_-]{20,}/i.test(value)) throw Error();
|
|
33
|
+
const url = new URL(value);
|
|
34
|
+
if (url.username || url.password || url.search || url.hash || url.pathname !== '/' || (url.protocol !== 'https:' && !(url.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)))) throw Error();
|
|
35
|
+
return url.origin;
|
|
36
|
+
} catch { fail('Use exact website and CMS admin HTTPS origins without credentials, paths or queries (HTTP loopback is allowed for development).'); }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function validateSourceConfig(input) {
|
|
40
|
+
const keys = ['version', 'siteId', 'environmentId', 'websiteOrigin', 'adminOrigin', 'locale', 'skipSlugs', 'excludedRoutes', 'publicStaticPages', 'exportClosed'];
|
|
41
|
+
if (!plain(input) || Object.keys(input).some(key => !keys.includes(key)) || (input.exportClosed !== undefined && input.exportClosed !== true) || input.version !== 1 || input.publicStaticPages !== true || !['siteId', 'environmentId'].every(key => typeof input[key] === 'string' && uuid.test(input[key]))) fail('Use valid public source configuration and website/environment UUIDs. Credentials are not source configuration.');
|
|
42
|
+
let locale;
|
|
43
|
+
try { if (typeof input.locale !== 'string' || input.locale.length > 35) throw Error(); locale = Intl.getCanonicalLocales(input.locale)[0]; if (!locale) throw Error(); }
|
|
44
|
+
catch { fail('Use a valid source content locale, such as en.'); }
|
|
45
|
+
if (!Array.isArray(input.skipSlugs) || input.skipSlugs.length > 1000 || input.skipSlugs.some(slug => typeof slug !== 'string' || slug.length > 160 || !/^[a-z0-9]+(?:[-/][a-z0-9]+)*$/.test(slug)) || new Set(input.skipSlugs).size !== input.skipSlugs.length) fail('Source exclusions must be unique ordinary page slugs.');
|
|
46
|
+
const excludedRoutes = input.excludedRoutes ?? [];
|
|
47
|
+
if (!Array.isArray(excludedRoutes) || excludedRoutes.length > 1000 || excludedRoutes.some(route => typeof route !== 'string' || route.length > 161 || !/^\/(?:[a-z0-9]+(?:[-/][a-z0-9]+)*)?$/.test(route)) || new Set(excludedRoutes).size !== excludedRoutes.length) fail('Excluded routes must be unique ordinary website paths, such as /account.');
|
|
48
|
+
return { version: 1, siteId: input.siteId.toLowerCase(), environmentId: input.environmentId.toLowerCase(), websiteOrigin: origin(input.websiteOrigin), adminOrigin: origin(input.adminOrigin), locale, skipSlugs: [...input.skipSlugs], excludedRoutes: [...excludedRoutes], publicStaticPages: true, ...(input.exportClosed ? { exportClosed: true } : {}) };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function inspect(root, relative, directory = false) {
|
|
52
|
+
if (!relative || relative.includes('\\') || path.isAbsolute(relative) || relative.split('/').some(part => !part || part === '.' || part === '..')) fail('Unsafe source setup path.');
|
|
53
|
+
let target = root;
|
|
54
|
+
const parts = relative.split('/');
|
|
55
|
+
for (let index = 0; index < parts.length; index++) {
|
|
56
|
+
target = path.join(target, parts[index]);
|
|
57
|
+
let stat;
|
|
58
|
+
try { stat = await lstat(target); } catch (error) { if (error.code === 'ENOENT') return null; throw error; }
|
|
59
|
+
if (stat.isSymbolicLink() || await realpath(target) !== target) fail('Source setup paths cannot be symbolic links.');
|
|
60
|
+
if (index < parts.length - 1 || directory) { if (!stat.isDirectory()) fail('A source setup directory has an incompatible file type.'); }
|
|
61
|
+
else if (!stat.isFile() || stat.nlink !== 1) fail('Source setup files must be ordinary files without hard links.');
|
|
62
|
+
if (index === parts.length - 1) return { target, stat };
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function read(root, relative) {
|
|
67
|
+
const found = await inspect(root, relative);
|
|
68
|
+
if (!found) return { content: null, stamp: null };
|
|
69
|
+
const limit = [SOURCE_SNAPSHOT_FILE, VITE_SOURCE_ENTRIES_FILE].includes(relative) ? MAX_BYTES : 128 * 1024;
|
|
70
|
+
if (found.stat.size > limit) fail('A source setup file exceeds its read budget.');
|
|
71
|
+
const handle = await open(found.target, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
72
|
+
try {
|
|
73
|
+
const stat = await handle.stat();
|
|
74
|
+
if (stamp(stat) !== stamp(found.stat)) fail('A source setup file changed during inspection. Retry setup.');
|
|
75
|
+
const buffer = Buffer.alloc(limit + 1); let bytes = 0;
|
|
76
|
+
while (bytes < buffer.length) { const chunk = await handle.read(buffer, bytes, buffer.length - bytes, bytes); if (!chunk.bytesRead) break; bytes += chunk.bytesRead; }
|
|
77
|
+
if (bytes > limit || stamp(await handle.stat()) !== stamp(stat)) fail('A source setup file changed or exceeded its read budget.');
|
|
78
|
+
return { content: new TextDecoder('utf-8', { fatal: true }).decode(buffer.subarray(0, bytes)), stamp: stamp(stat) };
|
|
79
|
+
} finally { await handle.close(); }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function sourceRouteTemplate(appDirectory) {
|
|
83
|
+
const prefix = appDirectory === 'src/app' ? '../../../../' : '../../../';
|
|
84
|
+
return `// Generated by @universalcms/connect. Public content only; no delivery credentials.\nimport { createNextSourceHandler } from '@universalcms/connect/next-source';\nimport configuration from '${prefix}.universalcms/source.config.json';\nimport snapshot from '${prefix}.universalcms/source.generated.json';\n\nconst handle = createNextSourceHandler({ sourceRoot: process.cwd(), configuration, snapshot });\nexport const GET = handle;\nexport const HEAD = handle;\nexport const OPTIONS = handle;\n`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function validateSnapshot(value) {
|
|
88
|
+
if (!plain(value) || Object.keys(value).sort().join() !== 'diagnostics,entries,version' || value.version !== 1 || !Array.isArray(value.entries) || value.entries.length > 1000 || !plain(value.diagnostics)) fail('The generated source snapshot needs manual review. Preserve your edits before regenerating it.');
|
|
89
|
+
return value;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function buildHook(manifest) {
|
|
93
|
+
if (manifest.scripts !== undefined && !plain(manifest.scripts)) fail('The website scripts need manual review before source setup.');
|
|
94
|
+
const scripts = { ...manifest.scripts }, hook = 'universalcms-connect sync-source';
|
|
95
|
+
if (typeof scripts.build !== 'string' || !/\bnext\s+build\b/.test(scripts.build)) fail('Source setup requires a Next.js build script so deployed source snapshots are refreshed.');
|
|
96
|
+
if (scripts.prebuild !== undefined && (typeof scripts.prebuild !== 'string' || scripts.prebuild.length > 8192 || /[\r\n\0]/.test(scripts.prebuild))) fail('The existing prebuild script needs manual review.');
|
|
97
|
+
if (scripts.prebuild?.startsWith(`${hook} && `)) {
|
|
98
|
+
// An earlier connector preview placed sync first. Preserve the customer
|
|
99
|
+
// preparation step but capture any pages it generates before Next builds.
|
|
100
|
+
scripts.prebuild = `${scripts.prebuild.slice(`${hook} && `.length)} && ${hook}`;
|
|
101
|
+
} else if (scripts.prebuild !== hook && !scripts.prebuild?.endsWith(` && ${hook}`)) {
|
|
102
|
+
if (scripts.prebuild?.includes('universalcms-connect')) fail('A custom connector prebuild script needs manual review before setup.');
|
|
103
|
+
scripts.prebuild = scripts.prebuild ? `${scripts.prebuild} && ${hook}` : hook;
|
|
104
|
+
}
|
|
105
|
+
return { ...manifest, scripts };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function appRoot(root, manifest) {
|
|
109
|
+
const next = manifest.dependencies?.next ?? manifest.devDependencies?.next;
|
|
110
|
+
if (manifest.workspaces || typeof next !== 'string' || !/^(?:\^|~)?16\.\d+\.\d+$/.test(next)) fail('Automatic source setup supports a single Next.js 16 App Router website.');
|
|
111
|
+
const roots = [];
|
|
112
|
+
for (const candidate of ['app', 'src/app']) if (await inspect(root, candidate, true)) roots.push(candidate);
|
|
113
|
+
if (roots.length !== 1) fail('Automatic source setup requires exactly one App Router directory.');
|
|
114
|
+
for (const candidate of ['pages', 'src/pages']) if (await inspect(root, candidate, true)) fail('Mixed or Pages Router source setup requires a manual adapter.');
|
|
115
|
+
for (const extension of ['js', 'mjs', 'cjs', 'ts']) {
|
|
116
|
+
const config = (await read(root, `next.config.${extension}`)).content;
|
|
117
|
+
if (config && /\b(?:pageExtensions|basePath|rewrites|redirects|i18n)\b|\boutput\s*:\s*['"]export['"]|\bruntime\s*:\s*['"]edge['"]/.test(config)) fail('Custom routes or static/Edge deployment require a manual source adapter. Cache Components is supported.');
|
|
118
|
+
}
|
|
119
|
+
let layout = false;
|
|
120
|
+
for (const extension of ['js', 'jsx', 'ts', 'tsx']) {
|
|
121
|
+
const source = (await read(root, `${roots[0]}/layout.${extension}`)).content;
|
|
122
|
+
if (source) { layout = true; if (/\bruntime\s*=\s*['"]edge['"]/.test(source)) fail('Automatic source discovery requires the Node runtime, not Edge.'); }
|
|
123
|
+
}
|
|
124
|
+
if (!layout) fail('The App Router requires an existing root layout.');
|
|
125
|
+
return roots[0];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function checkGroupedEndpoint(root, appDirectory, expectedRoute) {
|
|
129
|
+
let visited = 0;
|
|
130
|
+
async function walk(relative, segments, depth) {
|
|
131
|
+
if (++visited > 256 || depth > 12) fail('The source endpoint route tree exceeds its inspection budget.');
|
|
132
|
+
await inspect(root, relative, true);
|
|
133
|
+
const children = await readdir(path.join(root, relative), { withFileTypes: true });
|
|
134
|
+
for (const child of children) {
|
|
135
|
+
if (child.isSymbolicLink()) fail('Source endpoint route paths cannot be symbolic links.');
|
|
136
|
+
if (child.isFile() && /^route\.(?:js|jsx|ts|tsx)$/.test(child.name) && segments.join('/') === 'api/cms-source' && `${relative}/${child.name}` !== expectedRoute) fail('Another route already owns /api/cms-source through a route group. Review the existing source adapter manually.');
|
|
137
|
+
if (!child.isDirectory()) continue;
|
|
138
|
+
const group = /^\([a-zA-Z0-9_-]+\)$/.test(child.name);
|
|
139
|
+
if (group) await walk(`${relative}/${child.name}`, segments, depth + 1);
|
|
140
|
+
else if (child.name === ['api', 'cms-source'][segments.length]) await walk(`${relative}/${child.name}`, [...segments, child.name], depth + 1);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
await walk(appDirectory, [], 0);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function legacyViteSourceWrapper() {
|
|
147
|
+
return `// Generated by @universalcms/connect. Keep your original Vite configuration.\nimport { defineConfig, loadConfigFromFile } from 'vite';\nimport { universalCmsSource } from '@universalcms/connect/vite-source';\nimport configuration from './source.config.json' with { type: 'json' };\n\nexport default defineConfig(async env => {\n const existing = (await loadConfigFromFile(env, undefined, process.cwd()))?.config ?? {};\n if (existing.root || existing.base && existing.base !== '/' || existing.appType === 'custom') throw new Error('Custom Vite roots, base paths and servers need a reviewed source integration.');\n return { ...existing, plugins: [universalCmsSource({ configuration }), ...(existing.plugins ?? [])] };\n});\n`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function legacyPublicationViteSourceWrapper() {
|
|
151
|
+
return legacyViteSourceWrapper()
|
|
152
|
+
.replace("import { universalCmsSource }", "import { universalCmsPublication } from '@universalcms/connect/vite-publication';\nimport publication from './publication.config.json' with { type: 'json' };\nimport contentBindings from './content.bindings.json' with { type: 'json' };\nimport { universalCmsSource }")
|
|
153
|
+
.replace('[universalCmsSource({ configuration }),', '[universalCmsPublication({ configuration: publication, sourceConfiguration: configuration, contentBindings }), universalCmsSource({ configuration }),');
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function viteSourceWrapper(publication = false, previous = false) {
|
|
157
|
+
if (publication && previous) return viteSourceWrapper(true).replace("import contentBindings from './content.bindings.json' with { type: 'json' };\n", '').replace('configuration: publication, sourceConfiguration: configuration, contentBindings', 'configuration: publication');
|
|
158
|
+
if (publication) return legacyViteSourceWrapper()
|
|
159
|
+
.replace("import { universalCmsSource }", "import { universalCmsWebsite } from '@universalcms/connect/website-node';\nimport { universalCmsPublication } from '@universalcms/connect/vite-publication';\nimport publication from './publication.config.json' with { type: 'json' };\nimport contentBindings from './content.bindings.json' with { type: 'json' };\nimport { universalCmsSource }")
|
|
160
|
+
.replace('return { ...existing, plugins:', `return { ...existing, ...(typeof contentBindings !== 'undefined' && !contentBindings.profile ? { server: { ...existing.server, fs: { ...existing.server?.fs, strict: true, deny: ['.env', '.env.*', '*.{crt,pem}', '**/.git/**', ...(existing.server?.fs?.deny ?? []), '**/.universalcms/**'] } } } : {}), plugins:`)
|
|
161
|
+
.replace('[universalCmsSource({ configuration }),', '[universalCmsWebsite({ configuration: publication, sourceConfiguration: configuration, contentBindings }), universalCmsPublication({ configuration: publication, sourceConfiguration: configuration, contentBindings }), universalCmsSource({ configuration }),');
|
|
162
|
+
return legacyViteSourceWrapper();
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function viteScripts(manifest, publication = false) {
|
|
166
|
+
if (!plain(manifest.scripts)) fail('Vite source setup requires the website dev script. Custom servers need a reviewed source integration.');
|
|
167
|
+
const scripts = { ...manifest.scripts };
|
|
168
|
+
const option = ` --config ${VITE_SOURCE_WRAPPER}`;
|
|
169
|
+
for (const name of ['dev', 'preview']) {
|
|
170
|
+
if (name === 'preview' && scripts[name] === undefined) continue;
|
|
171
|
+
const command = scripts[name], expected = name === 'dev' ? 'vite' : 'vite preview';
|
|
172
|
+
if (typeof command !== 'string') fail('Vite source setup requires ordinary vite dev/preview scripts. Existing scripts were preserved.');
|
|
173
|
+
const original = command.endsWith(option) ? command.slice(0, -option.length) : command;
|
|
174
|
+
// Preserve only understood CLI flags; never rewrite a shell pipeline or a
|
|
175
|
+
// custom config/server command. The original Vite config is loaded above.
|
|
176
|
+
if (!original.startsWith(expected) || !/^(?:\s+--(?:host(?:\s+(?:localhost|127\.0\.0\.1|0\.0\.0\.0|::1))?|port\s+\d{1,5}|strictPort|open|https))*$/.test(original.slice(expected.length))) fail('Vite source setup cannot safely update this dev/preview command. Add universalCmsSource to your existing server configuration; existing scripts were preserved.');
|
|
177
|
+
scripts[name] = original + option;
|
|
178
|
+
}
|
|
179
|
+
if (publication) {
|
|
180
|
+
const build = scripts.build;
|
|
181
|
+
const original = typeof build === 'string' && build.endsWith(option) ? build.slice(0, -option.length) : build;
|
|
182
|
+
if (typeof original !== 'string' || !/^(?:(?:tsc(?: -b)?|tsc --noEmit) && )?vite build$/.test(original)) fail('Publication setup requires an ordinary vite build command. Preserve the custom build and integrate the publication plugin manually.');
|
|
183
|
+
scripts.build = original + option;
|
|
184
|
+
const start = 'node .universalcms/serve.mjs';
|
|
185
|
+
if (scripts.start !== undefined && scripts.start !== start) fail('Publication setup cannot replace a custom production server. Integrate the publication handler in that server before declaring setup complete.');
|
|
186
|
+
scripts.start = start;
|
|
187
|
+
}
|
|
188
|
+
return { ...manifest, scripts };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function discoverViteFallback(root, locale, { readFile }) {
|
|
192
|
+
const indexHtml = await readFile('index.html');
|
|
193
|
+
if (!indexHtml) return null;
|
|
194
|
+
let title = 'Home';
|
|
195
|
+
const match = /<title[^>]*>([^<]+)<\/title>/i.exec(indexHtml);
|
|
196
|
+
if (match?.[1]?.trim()) title = match[1].trim();
|
|
197
|
+
let candidate = null;
|
|
198
|
+
for (const name of ['src/App.jsx', 'src/App.tsx', 'src/App.js', 'src/App.ts', 'src/main.jsx', 'src/main.tsx']) {
|
|
199
|
+
if ((await readFile(name)) !== null) { candidate = name; break; }
|
|
200
|
+
}
|
|
201
|
+
if (!candidate) return null;
|
|
202
|
+
const headingId = 'block-home-heading';
|
|
203
|
+
const manifest = {
|
|
204
|
+
version: 1,
|
|
205
|
+
entries: [
|
|
206
|
+
{
|
|
207
|
+
kind: 'page',
|
|
208
|
+
slug: 'home',
|
|
209
|
+
locale: locale || 'en',
|
|
210
|
+
document: {
|
|
211
|
+
schemaVersion: 1,
|
|
212
|
+
title,
|
|
213
|
+
excerpt: '',
|
|
214
|
+
blocks: [
|
|
215
|
+
{
|
|
216
|
+
id: headingId,
|
|
217
|
+
type: 'heading',
|
|
218
|
+
text: title,
|
|
219
|
+
data: { level: 1 }
|
|
220
|
+
}
|
|
221
|
+
]
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
]
|
|
225
|
+
};
|
|
226
|
+
const mappings = [{ path: '/', sourceFile: candidate }];
|
|
227
|
+
return { manifest, mappings, diagnostics: { automaticPages: 1, discovery: 'fallback' } };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async function setupViteSource(root, manifest, packageFile, options) {
|
|
231
|
+
const vite = manifest.dependencies?.vite ?? manifest.devDependencies?.vite;
|
|
232
|
+
const react = manifest.dependencies?.react ?? manifest.devDependencies?.react;
|
|
233
|
+
if (manifest.workspaces || typeof vite !== 'string' || !/^(?:\^|~)?[678]\.\d+\.\d+$/.test(vite) || typeof react !== 'string' || !/^(?:\^|~)?19\.(?:[2-9]|\d{2,})\.\d+$/.test(react)) fail('Vite source setup supports a single React 19.2+ / Vite 6–8 website.');
|
|
234
|
+
for (const extension of ['js', 'mjs', 'cjs', 'ts', 'mts', 'cts']) {
|
|
235
|
+
const existing = (await read(root, `vite.config.${extension}`)).content;
|
|
236
|
+
if (existing && /\b(?:root|base|appType|configureServer|configurePreviewServer|universalCmsSource)\b|cms-source/.test(existing)) fail('Custom Vite roots, base paths or server middleware need a reviewed source integration. Existing configuration and scripts were preserved.');
|
|
237
|
+
}
|
|
238
|
+
const configFile = await read(root, SOURCE_CONFIG_FILE), entriesFile = await read(root, VITE_SOURCE_ENTRIES_FILE), wrapperFile = await read(root, VITE_SOURCE_WRAPPER);
|
|
239
|
+
let previous;
|
|
240
|
+
if (configFile.content !== null) {
|
|
241
|
+
try { previous = validateSourceConfig(JSON.parse(configFile.content)); } catch { fail('The existing source configuration needs manual review. Existing files were preserved.'); }
|
|
242
|
+
if (json(previous) !== configFile.content) fail('The source configuration was edited. Review it manually before setup.');
|
|
243
|
+
}
|
|
244
|
+
const config = validateSourceConfig({ version: 1, siteId: options.siteId ?? previous?.siteId, environmentId: options.environmentId ?? previous?.environmentId, websiteOrigin: options.websiteOrigin ?? previous?.websiteOrigin, adminOrigin: options.adminOrigin ?? previous?.adminOrigin, locale: options.locale ?? previous?.locale ?? 'en', skipSlugs: [], excludedRoutes: [], publicStaticPages: true, ...(previous?.exportClosed ? { exportClosed: true } : {}) });
|
|
245
|
+
if (previous && (previous.skipSlugs.length || previous.excludedRoutes.length)) fail('Vite exports require exclusions to be applied to the reviewed public manifest. Existing source settings were preserved.');
|
|
246
|
+
const publication = options.publication === true || [viteSourceWrapper(true), viteSourceWrapper(true, true), legacyPublicationViteSourceWrapper()].includes(wrapperFile.content);
|
|
247
|
+
const publicationFile = await read(root, '.universalcms/publication.config.json');
|
|
248
|
+
const observations = new Map([['.universalcms/publication.config.json', publicationFile]]);
|
|
249
|
+
let discovery, entriesContent = entriesFile.content, mappingFile, mappingContent;
|
|
250
|
+
if (entriesContent === null) {
|
|
251
|
+
const discoveryOptions = { locale: config.locale, readFile: async filename => {
|
|
252
|
+
if (!observations.has(filename)) observations.set(filename, await read(root, filename));
|
|
253
|
+
return observations.get(filename).content;
|
|
254
|
+
} };
|
|
255
|
+
// The exact reviewed profile supplies the same stable import identities for
|
|
256
|
+
// source-only and full setup. Full setup still has to build and validate its
|
|
257
|
+
// separate native content plan; this discovery is never completion evidence.
|
|
258
|
+
discovery = publicationFile.content === null && (!publication || options.publication === true) ? await discoverReviewedStorefrontSource(root, discoveryOptions) : null;
|
|
259
|
+
try {
|
|
260
|
+
discovery ??= await discoverViteSource(root, discoveryOptions);
|
|
261
|
+
} catch (discoveryError) {
|
|
262
|
+
// Publication setup cannot turn a known access or routing refusal into
|
|
263
|
+
// an empty home mapping. Those boundaries need explicit ownership.
|
|
264
|
+
if (discoveryError.code === 'VITE_SOURCE_OWNERSHIP_REVIEW') throw discoveryError;
|
|
265
|
+
discovery = await discoverViteFallback(root, config.locale, discoveryOptions);
|
|
266
|
+
if (!discovery) throw discoveryError;
|
|
267
|
+
}
|
|
268
|
+
entriesContent = json(discovery.manifest);
|
|
269
|
+
if (discovery.mappings) {
|
|
270
|
+
mappingFile = await read(root, VITE_CONTENT_ROUTES_FILE);
|
|
271
|
+
mappingContent = json(discovery.mappings);
|
|
272
|
+
if (mappingFile.content !== null) {
|
|
273
|
+
let matches = false;
|
|
274
|
+
try { matches = JSON.stringify(JSON.parse(mappingFile.content)) === JSON.stringify(discovery.mappings); } catch { /* Preserve an unrecognized mapping. */ }
|
|
275
|
+
if (!matches) fail('The existing Vite content route mapping differs from the discovered public pages. Review it manually; existing mappings were preserved.');
|
|
276
|
+
mappingContent = mappingFile.content;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
let entries;
|
|
281
|
+
try {
|
|
282
|
+
createSourceManifestProvider({ manifestJson: entriesContent, sourceOrigin: config.websiteOrigin, adminOrigin: config.adminOrigin, siteId: config.siteId, environmentId: config.environmentId, publicContent: true });
|
|
283
|
+
entries = JSON.parse(entriesContent).entries;
|
|
284
|
+
if (!entries.length || entries.some(entry => Intl.getCanonicalLocales(entry.locale)[0] !== config.locale)) throw Error();
|
|
285
|
+
} catch { fail('The Vite public source manifest must contain valid nonempty pages/posts for the configured locale. Review .universalcms/source.entries.json; no customer code was executed.'); }
|
|
286
|
+
const wrapper = viteSourceWrapper(publication);
|
|
287
|
+
if (wrapperFile.content !== null && (!previous || ![viteSourceWrapper(), viteSourceWrapper(true), viteSourceWrapper(true, true), legacyViteSourceWrapper(), legacyPublicationViteSourceWrapper()].includes(wrapperFile.content))) fail('The Vite source wrapper was customized. Existing files were preserved; integrate the source plugin manually.');
|
|
288
|
+
const nextPackage = viteScripts(manifest, publication), packageContent = JSON.stringify(nextPackage) === JSON.stringify(manifest) ? packageFile.content : json(nextPackage);
|
|
289
|
+
const desired = [[SOURCE_CONFIG_FILE, json(config), configFile], [VITE_SOURCE_ENTRIES_FILE, entriesContent, entriesFile], ...(discovery?.mappings ? [[VITE_CONTENT_ROUTES_FILE, mappingContent, mappingFile]] : []), [VITE_SOURCE_WRAPPER, wrapper, wrapperFile], ['package.json', packageContent, packageFile]];
|
|
290
|
+
const files = desired.map(([file, content, prior]) => Object.freeze({ path: file, state: prior.content === content ? 'unchanged' : prior.content === null ? 'create' : 'update' }));
|
|
291
|
+
const identities = Object.freeze(entries.map(({ kind, slug, locale }) => Object.freeze({ kind, slug, locale })));
|
|
292
|
+
const plan = Object.freeze({ root, config: Object.freeze(config), framework: 'vite', identities, diagnostics: { mappedPages: entries.filter(entry => entry.kind === 'page').length, mappedPosts: entries.filter(entry => entry.kind === 'post').length, ...discovery?.diagnostics }, retainedAdapter: false, files: Object.freeze(files) });
|
|
293
|
+
plans.set(plan, { root, desired, observations: [...observations], applied: false });
|
|
294
|
+
if (options.apply) return { plan, ...await applySourcePlan(plan) };
|
|
295
|
+
return plan;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
async function setupAstroSource(root, options, framework = 'astro') {
|
|
299
|
+
const configFile = await read(root, SOURCE_CONFIG_FILE), snapshotFile = await read(root, SOURCE_SNAPSHOT_FILE);
|
|
300
|
+
let previous;
|
|
301
|
+
if (configFile.content !== null) {
|
|
302
|
+
try { previous = validateSourceConfig(JSON.parse(configFile.content)); } catch { fail('The saved native source configuration needs review.'); }
|
|
303
|
+
if (json(previous) !== configFile.content || previous.skipSlugs.length || previous.excludedRoutes.length) fail('Custom native source exclusions need an explicit reviewed adapter.');
|
|
304
|
+
}
|
|
305
|
+
const config = validateSourceConfig({ version: 1, siteId: options.siteId ?? previous?.siteId, environmentId: options.environmentId ?? previous?.environmentId, websiteOrigin: options.websiteOrigin ?? previous?.websiteOrigin, adminOrigin: options.adminOrigin ?? previous?.adminOrigin, locale: options.locale ?? previous?.locale ?? 'en', skipSlugs: [], excludedRoutes: [], publicStaticPages: true, ...(previous?.exportClosed ? { exportClosed: true } : {}) });
|
|
306
|
+
const sourceOptions = { locale: config.locale, websiteOrigin: config.websiteOrigin };
|
|
307
|
+
const inspection = framework === 'astro' ? await inspectAstroSource(root, sourceOptions) : await inspectNativeSource(root, framework, sourceOptions);
|
|
308
|
+
const snapshotContent = json(inspection.snapshot);
|
|
309
|
+
if (Buffer.byteLength(snapshotContent) > 128 * 1024) fail('The native source snapshot exceeds its bounded integration budget. Split it into a reviewed adapter.');
|
|
310
|
+
if (snapshotFile.content !== null && (!previous || snapshotFile.content !== snapshotContent)) fail('The frozen native source inventory changed. Review the source adapter before replacing its baseline.');
|
|
311
|
+
const integrationOptions = { publication: options.publication === true, sourceConfiguration: config };
|
|
312
|
+
const integration = framework === 'astro' ? await planAstroIntegration(root, integrationOptions) : await planNativeIntegration(root, framework, integrationOptions);
|
|
313
|
+
const desired = [[SOURCE_CONFIG_FILE, json(config), configFile], [SOURCE_SNAPSHOT_FILE, snapshotContent, snapshotFile]];
|
|
314
|
+
for (const file of integration.files) desired.push([file.path, file.content, await read(root, file.path)]);
|
|
315
|
+
const observations = [];
|
|
316
|
+
for (const file of inspection.sourceFiles) {
|
|
317
|
+
const observed = await read(root, file.path);
|
|
318
|
+
if (observed.content === null || contentDigest(observed.content) !== file.sha256) fail('A native source module changed during planning.');
|
|
319
|
+
if (!desired.some(([name]) => name === file.path)) observations.push([file.path, observed]);
|
|
320
|
+
}
|
|
321
|
+
const files = desired.map(([name, content, before]) => Object.freeze({ path: name, state: content === before.content ? 'unchanged' : before.content === null ? 'create' : 'update' }));
|
|
322
|
+
const plan = Object.freeze({ root, config: Object.freeze(config), framework, routes: inspection.routes, identities: inspection.entries.map(({kind,slug,locale}) => ({kind,slug,locale})), diagnostics: inspection.diagnostics, retainedAdapter: false, files: Object.freeze(files) });
|
|
323
|
+
plans.set(plan, { root, desired, observations, applied: false });
|
|
324
|
+
if (options.apply) return { plan, ...await applySourcePlan(plan) };
|
|
325
|
+
return plan;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** Returns a reviewable plan by default. apply:true applies precisely that plan. */
|
|
329
|
+
export async function setupSource(directory, options = {}) {
|
|
330
|
+
if (!plain(options) || Object.keys(options).some(key => !['siteId', 'environmentId', 'websiteOrigin', 'adminOrigin', 'locale', 'apply', 'publication'].includes(key)) || (options.apply !== undefined && typeof options.apply !== 'boolean') || (options.publication !== undefined && typeof options.publication !== 'boolean')) fail('Source setup accepts only public scope, origins, locale and an apply choice.');
|
|
331
|
+
const root = await projectRoot(directory);
|
|
332
|
+
const packageFile = await read(root, 'package.json');
|
|
333
|
+
let manifest;
|
|
334
|
+
try { manifest = JSON.parse(packageFile.content); if (!plain(manifest)) throw Error(); }
|
|
335
|
+
catch { fail('A valid website package.json is required for source setup.'); }
|
|
336
|
+
if (nativeFramework(manifest)) {
|
|
337
|
+
await inspectSetupFramework(root);
|
|
338
|
+
return setupAstroSource(root, options, nativeFramework(manifest));
|
|
339
|
+
}
|
|
340
|
+
if (manifest.dependencies?.astro || manifest.devDependencies?.astro) {
|
|
341
|
+
await inspectSetupFramework(root);
|
|
342
|
+
return setupAstroSource(root, options);
|
|
343
|
+
}
|
|
344
|
+
if (!manifest.dependencies?.next && !manifest.devDependencies?.next && (manifest.dependencies?.vite || manifest.devDependencies?.vite)) {
|
|
345
|
+
await inspectSetupFramework(root);
|
|
346
|
+
return setupViteSource(root, manifest, packageFile, options);
|
|
347
|
+
}
|
|
348
|
+
const appDirectory = await appRoot(root, manifest);
|
|
349
|
+
const configFile = await read(root, SOURCE_CONFIG_FILE), snapshotFile = await read(root, SOURCE_SNAPSHOT_FILE);
|
|
350
|
+
let previous;
|
|
351
|
+
if (configFile.content !== null) {
|
|
352
|
+
try { previous = validateSourceConfig(JSON.parse(configFile.content)); }
|
|
353
|
+
catch { fail('The existing source configuration needs manual review. Existing files were preserved.'); }
|
|
354
|
+
if (json(previous) !== configFile.content) fail('The source configuration was edited. Review it manually before setup.');
|
|
355
|
+
}
|
|
356
|
+
if (snapshotFile.content !== null) {
|
|
357
|
+
let value;
|
|
358
|
+
try { value = validateSnapshot(JSON.parse(snapshotFile.content)); } catch { fail('The existing source snapshot needs manual review. Existing files were preserved.'); }
|
|
359
|
+
if (!previous || json(value) !== snapshotFile.content) fail('The source snapshot was edited or is not owned by this setup. Existing files were preserved.');
|
|
360
|
+
}
|
|
361
|
+
const config = validateSourceConfig({ version: 1, siteId: options.siteId ?? previous?.siteId, environmentId: options.environmentId ?? previous?.environmentId, websiteOrigin: options.websiteOrigin ?? previous?.websiteOrigin, adminOrigin: options.adminOrigin ?? previous?.adminOrigin, locale: options.locale ?? previous?.locale ?? 'en', skipSlugs: previous?.skipSlugs ?? [], excludedRoutes: previous?.excludedRoutes ?? [], publicStaticPages: true, ...(previous?.exportClosed ? { exportClosed: true } : {}) });
|
|
362
|
+
const route = `${appDirectory}/api/cms-source/route.ts`;
|
|
363
|
+
if (await inspect(root, `${appDirectory}/api/cms-source`, true)) {
|
|
364
|
+
const children = await readdir(path.join(root, appDirectory, 'api/cms-source'));
|
|
365
|
+
if (children.some(name => name !== 'route.ts')) fail('An existing /api/cms-source adapter was preserved. Integrate createNextSourceHandler with its reviewed base manifest manually; source setup never replaces custom routes.');
|
|
366
|
+
}
|
|
367
|
+
await checkGroupedEndpoint(root, appDirectory, route);
|
|
368
|
+
const routeFile = await read(root, route);
|
|
369
|
+
let routeContent = sourceRouteTemplate(appDirectory), retainedAdapter = false;
|
|
370
|
+
if (routeFile.content !== null && (routeFile.content !== routeContent || !previous)) {
|
|
371
|
+
// A reviewed bridge may keep complete pre-existing public document mappings
|
|
372
|
+
// while the connector discovers new pages. Only refresh its owned snapshot;
|
|
373
|
+
// never infer a new scope for custom code or replace that code.
|
|
374
|
+
if (!previous || previous.skipSlugs.length === 0 || snapshotFile.content === null || configFile.content !== json(config)) fail('An existing /api/cms-source adapter was preserved. Integrate createNextSourceHandler with its reviewed base manifest manually; source setup never replaces custom routes. Changing a custom adapter connection requires manual review.');
|
|
375
|
+
routeContent = routeFile.content; retainedAdapter = true;
|
|
376
|
+
}
|
|
377
|
+
const snapshot = validateSnapshot(await collectSource({ root, locale: config.locale, skipSlugs: config.skipSlugs, excludedRoutes: config.excludedRoutes }));
|
|
378
|
+
const snapshotContent = json(snapshot);
|
|
379
|
+
if (Buffer.byteLength(snapshotContent) > MAX_BYTES) fail('The generated source snapshot exceeds the response budget.');
|
|
380
|
+
const nextPackage = buildHook(manifest), packageContent = JSON.stringify(nextPackage) === JSON.stringify(manifest) ? packageFile.content : json(nextPackage);
|
|
381
|
+
const desired = [[SOURCE_CONFIG_FILE, json(config), configFile], [SOURCE_SNAPSHOT_FILE, snapshotContent, snapshotFile], [route, routeContent, routeFile], ['package.json', packageContent, packageFile]];
|
|
382
|
+
const files = desired.map(([file, content, prior]) => Object.freeze({ path: file, state: prior.content === content ? 'unchanged' : prior.content === null ? 'create' : 'update' }));
|
|
383
|
+
const identities = Object.freeze(snapshot.entries.map(({ kind, slug, locale }) => Object.freeze({ kind, slug, locale })));
|
|
384
|
+
const plan = Object.freeze({ root, config: Object.freeze(config), identities, diagnostics: snapshot.diagnostics, retainedAdapter, files: Object.freeze(files) });
|
|
385
|
+
plans.set(plan, { root, desired, applied: false });
|
|
386
|
+
if (options.apply) return { plan, ...await applySourcePlan(plan) };
|
|
387
|
+
return plan;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
async function recheck(state) {
|
|
391
|
+
if (state.applied) fail('This source setup plan was already applied. Make a fresh plan.');
|
|
392
|
+
if (await projectRoot(state.root) !== state.root) fail('The website directory changed after planning.');
|
|
393
|
+
for (const [file, , before] of state.desired) {
|
|
394
|
+
const current = await read(state.root, file);
|
|
395
|
+
if (current.content !== before.content || current.stamp !== before.stamp) fail('A source setup file changed after planning. Existing edits were preserved; rerun setup.');
|
|
396
|
+
}
|
|
397
|
+
for (const [file, before] of state.observations ?? []) {
|
|
398
|
+
const current = await read(state.root, file);
|
|
399
|
+
if (current.content !== before.content || current.stamp !== before.stamp) fail('A discovered source file changed after planning. Existing edits were preserved; rerun setup.');
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** Read a prospective source artifact without writing it before user review. */
|
|
404
|
+
export function readPlannedSourceFile(plan, filename) {
|
|
405
|
+
if (!plan) return undefined;
|
|
406
|
+
const state = plans.get(plan);
|
|
407
|
+
if (!state) fail('Use the original source setup plan returned by setup.');
|
|
408
|
+
return state.desired.find(([file]) => file === filename)?.[1];
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
export async function validateSourcePlan(plan) {
|
|
412
|
+
const state = plans.get(plan); if (!state) fail('Use the original source setup plan returned by setup.');
|
|
413
|
+
await recheck(state); return true;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** Read only public source defaults; never opens a private environment file. */
|
|
417
|
+
export async function readSourceSettings(directory) {
|
|
418
|
+
const root = await projectRoot(directory), source = await read(root, SOURCE_CONFIG_FILE);
|
|
419
|
+
if (source.content === null) return {};
|
|
420
|
+
let value;
|
|
421
|
+
try { value = validateSourceConfig(JSON.parse(source.content)); }
|
|
422
|
+
catch { fail('The saved public source configuration needs manual review.'); }
|
|
423
|
+
if (json(value) !== source.content) fail('The saved public source configuration was edited. Review it before setup.');
|
|
424
|
+
return value;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/** Refresh the owned build snapshot without touching an existing source route. */
|
|
428
|
+
export async function syncSource(directory) {
|
|
429
|
+
const root = await projectRoot(directory), configFile = await read(root, SOURCE_CONFIG_FILE);
|
|
430
|
+
const manifest = JSON.parse((await read(root, 'package.json')).content);
|
|
431
|
+
if (manifest.dependencies?.astro || manifest.devDependencies?.astro || nativeFramework(manifest)) {
|
|
432
|
+
const plan = await setupSource(root);
|
|
433
|
+
return { ...await applySourcePlan(plan), diagnostics: plan.diagnostics };
|
|
434
|
+
}
|
|
435
|
+
if (configFile.content === null) fail('Run setup-source first to configure the public source export.');
|
|
436
|
+
const config = await readSourceSettings(root), before = await read(root, SOURCE_SNAPSHOT_FILE);
|
|
437
|
+
if (before.content !== null) {
|
|
438
|
+
let value;
|
|
439
|
+
try { value = validateSnapshot(JSON.parse(before.content)); } catch { fail('The source snapshot needs manual review before regeneration.'); }
|
|
440
|
+
if (json(value) !== before.content) fail('The source snapshot was edited. Review it before regeneration.');
|
|
441
|
+
}
|
|
442
|
+
const snapshot = validateSnapshot(await collectSource({ root, locale: config.locale, skipSlugs: config.skipSlugs, excludedRoutes: config.excludedRoutes }));
|
|
443
|
+
const content = json(snapshot);
|
|
444
|
+
if (Buffer.byteLength(content) > MAX_BYTES) fail('The generated source snapshot exceeds the response budget.');
|
|
445
|
+
const plan = Object.freeze({ root, files: Object.freeze([{ path: SOURCE_CONFIG_FILE, state: 'unchanged' }, { path: SOURCE_SNAPSHOT_FILE, state: before.content === content ? 'unchanged' : before.content === null ? 'create' : 'update' }]) });
|
|
446
|
+
plans.set(plan, { root, desired: [[SOURCE_CONFIG_FILE, configFile.content, configFile], [SOURCE_SNAPSHOT_FILE, content, before]], applied: false });
|
|
447
|
+
const result = await applySourcePlan(plan);
|
|
448
|
+
return { ...result, diagnostics: snapshot.diagnostics };
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
export async function applySourcePlan(plan, { beforeWrite } = {}) {
|
|
452
|
+
if (beforeWrite !== undefined && typeof beforeWrite !== 'function') fail('Source setup needs a valid final binding check.');
|
|
453
|
+
const state = plans.get(plan); if (!state) fail('Use the original source setup plan returned by setup.');
|
|
454
|
+
await recheck(state);
|
|
455
|
+
const lockPath = path.join(state.root, '.universalcms-source-setup.lock');
|
|
456
|
+
let lock, lockStat;
|
|
457
|
+
const temporaryFiles = [];
|
|
458
|
+
try {
|
|
459
|
+
try { lock = await open(lockPath, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600); }
|
|
460
|
+
catch (error) { if (error.code === 'EEXIST') fail('Another source setup is active. Finish it before retrying.'); throw error; }
|
|
461
|
+
lockStat = await lock.stat(); await recheck(state);
|
|
462
|
+
// Explicit key-first setup rechecks its separate private binding here, after
|
|
463
|
+
// the final source scan and under the source lock. Ordinary public-source
|
|
464
|
+
// setup supplies no guard and never opens private settings.
|
|
465
|
+
if (beforeWrite) await beforeWrite();
|
|
466
|
+
const created = [], updated = [];
|
|
467
|
+
for (const [file, content, before] of state.desired) {
|
|
468
|
+
if (content === before.content) continue;
|
|
469
|
+
const segments = file.split('/');
|
|
470
|
+
for (let index = 1; index < segments.length; index++) {
|
|
471
|
+
const relative = segments.slice(0, index).join('/');
|
|
472
|
+
if (!await inspect(state.root, relative, true)) { try { await mkdir(path.join(state.root, relative), { mode: 0o755 }); } catch (error) { if (error.code !== 'EEXIST') throw error; } await inspect(state.root, relative, true); }
|
|
473
|
+
}
|
|
474
|
+
const temporary = path.join(state.root, path.dirname(file), `.cms-source-${randomUUID()}.tmp`);
|
|
475
|
+
const handle = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o644);
|
|
476
|
+
temporaryFiles.push(temporary);
|
|
477
|
+
try { await handle.writeFile(content, 'utf8'); await handle.sync(); } finally { await handle.close(); }
|
|
478
|
+
const current = await read(state.root, file);
|
|
479
|
+
if (current.content !== before.content || current.stamp !== before.stamp) fail('A source setup file changed while saving. Existing edits were preserved.');
|
|
480
|
+
await rename(temporary, path.join(state.root, file));
|
|
481
|
+
(before.content === null ? created : updated).push(file);
|
|
482
|
+
}
|
|
483
|
+
state.applied = true;
|
|
484
|
+
return { created, updated, unchanged: state.desired.length - created.length - updated.length };
|
|
485
|
+
} finally {
|
|
486
|
+
await lock?.close().catch(() => {});
|
|
487
|
+
for (const temporary of temporaryFiles) await unlink(temporary).catch(error => { if (error.code !== 'ENOENT') throw error; });
|
|
488
|
+
if (lockStat) { try { const current = await lstat(lockPath); if (current.dev === lockStat.dev && current.ino === lockStat.ino) await unlink(lockPath); } catch { /* Do not remove another operation's lock. */ } }
|
|
489
|
+
}
|
|
490
|
+
}
|