@universalcms/connect 0.0.0-stage → 0.1.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 CHANGED
@@ -1,3 +1,123 @@
1
- # Temporary Holding Version
1
+ # @universalcms/connect
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Local, deterministic website setup without AI. Universal CMS stays hosted; this
4
+ package creates a website integration, not a CMS installation. Version 0.1.0 is
5
+ a local prerelease; no public npm publication is claimed.
6
+
7
+ Supported templates: **Next.js 16 App Router on Node** and **React 19.2+ with
8
+ Vite 6/7/8 plus a private Node server**. Use Node 22.12 or newer (below 27).
9
+ Stable exact, caret and tilde version declarations are recognized. Workspaces,
10
+ Pages Router, static Next exports, dynamic Next configuration, Cache Components,
11
+ custom page extensions/base paths, route groups and
12
+ ambiguous framework evidence require manual review. Analyze the individual
13
+ website folder, not its parent workspace.
14
+
15
+ Install the reviewed connector tarball from your operator:
16
+
17
+ ```sh
18
+ npm install --save-prod ./vendor/universalcms-connect-0.1.0.tgz
19
+ npx --no-install universalcms-connect setup
20
+ ```
21
+
22
+ Guided setup asks for the API origin, website/environment IDs, locale and a
23
+ hidden server delivery key. Vite also needs the website origin; existing servers
24
+ that use the CMS admin origin are prompted for it. Values provided through the
25
+ CMS's generated command are already filled in. Saved values can be accepted
26
+ with Enter, and an existing saved key can be kept without displaying it.
27
+
28
+ After one file-summary confirmation it writes `.env.local` with owner-only
29
+ permissions, excludes that file from Git and creates the framework scaffold.
30
+ Reruns can replace connection values in unchanged generated files and the
31
+ private env file; unrelated env settings and custom source edits are preserved.
32
+ Already tracked secrets, unsafe links and conflicting higher-priority env files
33
+ are rejected. An optional published-page slug checks delivery after saving.
34
+ Setup never imports content, calls AI, installs dependencies or changes custom
35
+ server/router code. Install the SDK and renderer from your operator as described
36
+ below; API verification and actual website rendering are separate checks.
37
+
38
+ The original plan/apply commands remain available for advanced setup without
39
+ writing a secret file:
40
+
41
+ ```sh
42
+ npx --no-install universalcms-connect analyze
43
+ npx --no-install universalcms-connect init \
44
+ --api-origin https://cms.example.com \
45
+ --site 11111111-1111-4111-8111-111111111111 \
46
+ --environment 22222222-2222-4222-8222-222222222222
47
+ ```
48
+
49
+ After a public release, `npx @universalcms/connect@0.1.0 init` is the equivalent
50
+ registry entry point. Copy the real IDs/API origin from **Developer setup**.
51
+ Missing values are prompted in a terminal; `--non-interactive` fails instead.
52
+ Init prints a plan. Run the same command with `--apply` after reviewing it.
53
+ Use `--project <folder>`, `--locale <locale>` and a free Next `--mount /cms` as
54
+ needed. `--dry-run` explicitly selects the default plan-only behavior.
55
+
56
+ Then follow `.universalcms/SETUP.md`:
57
+
58
+ 1. Install the SDK and renderer (plus the Next adapter for Next). Before their
59
+ public release, install the supplied tarballs together. Setup never runs
60
+ package-manager installs or existing project scripts.
61
+ 2. Set `CMS_API_KEY` in your private server's secret environment using an
62
+ environment-scoped server delivery key from Developer setup. No provider key
63
+ or CMS administrator credentials are needed.
64
+ 3. Run `npx --no-install universalcms-connect check --slug YOUR_PUBLISHED_PAGE`.
65
+ The command uses the process key or a hidden terminal prompt. It honors the
66
+ same private `CMS_API_BASE_URL` override as the generated server, so run it
67
+ with that server's environment. It never saves the key.
68
+ 4. Run and build the website, verify rendering, then deploy its required server
69
+ and routing configuration. The generated guide provides exact commands.
70
+
71
+ Next renders full supported blocks at `/cms/<published-page-slug>`. The original
72
+ routes/design remain yours. Vite includes a standalone development demo, a React
73
+ component, an additive dev-proxy wrapper and a private Node handler. For Vite
74
+ production, deploy the static site **and** the private handler, and configure
75
+ the host to proxy `/api/universalcms/page`; the Vite dev proxy is insufficient.
76
+
77
+ For Vite websites with a detected existing Node server, setup prints a specific
78
+ warning and an existing-server integration guide. Keep the website's normal
79
+ startup, SSR bootstrap and routes. Register `createPageHandler` from
80
+ `@universalcms/connect/node` with its private fixed-scope SDK client before the
81
+ existing API/SSR fallback; install the connector as a production dependency if
82
+ using that entry point. The exported handler has no dependencies and never calls
83
+ AI. The standalone raw-Vite demo is diagnostic only and must use an unused port;
84
+ it does not replace the website's server. No existing server is edited by init.
85
+
86
+ The first scaffold connects published **pages** in one locale. Existing design
87
+ mapping, source import, posts/custom collections, taxonomies, navigation, forms,
88
+ preview and SEO are deliberate follow-up integrations through the SDK. It does
89
+ not silently create, import or publish CMS content. Successful API verification
90
+ is distinct from a verified website deployment.
91
+
92
+ Analysis reads exact bounded project files and installed package metadata; it
93
+ never evaluates configuration, runs website code, loads `.env`, crawls sites,
94
+ uploads source or calls AI. Its sanitized JSON report can be supplied explicitly
95
+ to Developer setup. AI provider configuration and metering belong to the platform;
96
+ this connector works when AI is unavailable or its quota is exhausted.
97
+
98
+ Init-generated files use exclusive creation; identical reruns are unchanged, conflicts
99
+ stop before writes, symlinks and unsafe paths are rejected. Do not modify the
100
+ project concurrently while setup applies: an interrupted or concurrently changed
101
+ filesystem can leave a partial set of newly created files; rerun the same setup
102
+ after inspection. Init does not replace or delete existing files. Guided setup
103
+ can replace unchanged generated files and private connection assignments after
104
+ confirmation. Each file replacement is atomic; the overall multi-file operation
105
+ can be interrupted after some settings or files have been saved.
106
+
107
+ `check` performs a bounded published-page read with fixed site/environment/locale,
108
+ no cookies and redirect rejection. It prints constant status messages, not returned
109
+ content or credentials. Keys are never accepted as CLI flags, persisted in output,
110
+ or put in `NEXT_PUBLIC_`/`VITE_` configuration. Guided `setup` is the explicit
111
+ exception for local secret persistence: it saves only to private `.env.local`,
112
+ never generated documentation or public configuration. No install/postinstall hook runs.
113
+
114
+ Development: `node scripts/build.mjs` makes the packed `dist` artifact and copies
115
+ five reviewed analyzer/checker sources from `integrations/custom`. The tarball
116
+ contains no workspace-relative imports or runtime dependencies. Rebuild whenever
117
+ those shared sources change. Run `tests/connect-package.test.ts` and the existing
118
+ project-analysis, dependency-diagnostics and check-delivery tests before release.
119
+ From the repository root, run `node integrations/connect/scripts/smoke.mjs`
120
+ for production-build/browser verification using clean consumers and a synthetic
121
+ CMS API. It defaults to offline dependency installation; `--online` permits
122
+ public registry downloads and `--vite8` selects Vite 8.3 instead of Vite 7.3.
123
+ It does not use customer records or live provider credentials.
package/dist/cli.mjs ADDED
@@ -0,0 +1,149 @@
1
+ #!/usr/bin/env node
2
+ import { createInterface } from 'node:readline/promises';
3
+ import { analyze, planSetup, applyPlan, checkConnection, SetupError } from './core.mjs';
4
+ import { runSetup } from './wizard.mjs';
5
+
6
+ const help = `Universal CMS connector 0.1.0
7
+ Usage: universalcms-connect <setup|analyze|init|check> [options]
8
+
9
+ setup Guided setup; saves entered values in private .env.local
10
+
11
+ --project <directory> Website folder (default current directory)
12
+ --framework <next|vite> Confirm a supported template
13
+ --api-origin <origin> Hosted CMS API HTTPS origin
14
+ --site <uuid> Website ID from Developer setup
15
+ --environment <uuid> Environment ID from Developer setup
16
+ --locale <locale> Published content locale (default en)
17
+ --mount </cms> Free Next.js route prefix (default /cms)
18
+ --website-origin <url> Browser-facing website origin (guided setup)
19
+ --admin-origin <url> CMS admin origin for an existing integration (guided setup)
20
+ --apply Explicitly create the reviewed additive files
21
+ --dry-run Print a plan only (the default)
22
+ --non-interactive Never ask questions
23
+ --slug <slug> Published page used by check
24
+
25
+ Analyze and init read bounded project evidence without executing project code.
26
+ Init never edits existing files, installs packages, imports content or publishes.
27
+ Check makes one private published-page request. CMS_API_KEY comes only from the
28
+ process environment or a hidden terminal prompt; it is never saved. Use an
29
+ environment-scoped server delivery key from Developer setup, not a provider key.
30
+ No AI or provider credentials are required. All analysis stays on this computer.
31
+ Setup asks for the delivery key with hidden input. It updates only connection
32
+ values in .env.local and unchanged generated files, and preserves unrelated settings.
33
+ `;
34
+
35
+ function argumentsFor(args) {
36
+ const command = args.shift();
37
+ if (!['setup', 'analyze', 'init', 'check'].includes(command)) throw new SetupError('Choose setup, analyze, init or check. Use --help for instructions.');
38
+ const flags = new Set(['--apply', '--dry-run', '--non-interactive']);
39
+ const names = new Set(['--project', '--framework', '--api-origin', '--site', '--environment', '--locale', '--mount', '--slug', '--website-origin', '--admin-origin']);
40
+ const options = {};
41
+ for (let index = 0; index < args.length; index++) {
42
+ const name = args[index];
43
+ if (Object.hasOwn(options, name) || (!flags.has(name) && !names.has(name))) throw new SetupError('Unknown or duplicate option. Secrets are not accepted as command-line options.');
44
+ if (flags.has(name)) options[name] = true;
45
+ else {
46
+ const value = args[++index];
47
+ if (!value || value.startsWith('--')) throw new SetupError('An option is missing its value.');
48
+ options[name] = value;
49
+ }
50
+ }
51
+ if (options['--apply'] && options['--dry-run']) throw new SetupError('Choose either --apply or --dry-run.');
52
+ const allowed = command === 'setup' ? ['--project', '--api-origin', '--site', '--environment', '--locale', '--mount', '--website-origin', '--admin-origin'] : command === 'analyze' ? ['--project'] : command === 'check' ? ['--project', '--slug', '--non-interactive'] : [...flags, ...names].filter(name => !['--slug', '--website-origin', '--admin-origin'].includes(name));
53
+ if (Object.keys(options).some(name => !allowed.includes(name))) throw new SetupError('An option is not supported for this command.');
54
+ return { command, options };
55
+ }
56
+
57
+ /** No echo, history, display mask, or persisted copy of the delivery secret. */
58
+ async function hiddenKey(prompt = 'Server delivery key (hidden; not saved): ') {
59
+ const input = process.stdin, output = process.stderr;
60
+ if (!input.isTTY || typeof input.setRawMode !== 'function') return undefined;
61
+ const previousRaw = input.isRaw;
62
+ input.setRawMode(true); input.resume();
63
+ output.write(prompt);
64
+ try {
65
+ return await new Promise((resolve, reject) => {
66
+ let value = '';
67
+ const finish = (error) => {
68
+ input.off('data', onData); input.off('end', onEnd);
69
+ for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) process.off(signal, onSignal);
70
+ if (error) reject(error); else resolve(value);
71
+ };
72
+ const onEnd = () => finish(new SetupError('Key input was interrupted.'));
73
+ const onSignal = () => finish(new SetupError('Key input was interrupted.'));
74
+ const onData = chunk => {
75
+ for (const character of chunk.toString('utf8')) {
76
+ if (character === '\u0003' || character === '\u0004') { finish(new SetupError('Key input was canceled.')); return; }
77
+ if (character === '\r' || character === '\n') { finish(); return; }
78
+ if (character === '\u007f' || character === '\b') value = value.slice(0, -1);
79
+ else if (/^[A-Za-z0-9_.-]$/.test(character)) value += character;
80
+ else { finish(new SetupError('Key input contains an unsupported character.')); return; }
81
+ if (value.length > 256) { finish(new SetupError('Key input exceeds the supported size.')); return; }
82
+ }
83
+ };
84
+ for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) process.once(signal, onSignal);
85
+ input.on('data', onData); input.on('end', onEnd);
86
+ });
87
+ } finally { input.setRawMode(previousRaw); input.pause(); output.write('\n'); }
88
+ }
89
+
90
+ async function ask(prompt) {
91
+ const input = createInterface({ input: process.stdin, output: process.stderr });
92
+ const controller = new AbortController();
93
+ const cancel = () => controller.abort();
94
+ input.once('SIGINT', cancel); input.once('close', cancel);
95
+ try {
96
+ const answer = await input.question(prompt, { signal: controller.signal });
97
+ if (answer.length > 2048) throw new SetupError('The entered value is too long. Setup was stopped.');
98
+ return answer;
99
+ } catch (error) {
100
+ if (error?.name === 'AbortError') throw new SetupError('Setup cancelled. No further files were changed.');
101
+ throw error;
102
+ } finally { input.off('SIGINT', cancel); input.off('close', cancel); input.close(); }
103
+ }
104
+
105
+ async function main() {
106
+ if (process.argv.slice(2).some(value => value === '--help' || value === '-h')) { process.stdout.write(help); return; }
107
+ const { command, options } = argumentsFor(process.argv.slice(2));
108
+ const project = options['--project'] ?? process.cwd();
109
+ if (command === 'setup') {
110
+ if (!process.stdin.isTTY || !process.stderr.isTTY) throw new SetupError('Guided setup needs an interactive terminal. Use init for non-interactive scaffold generation.');
111
+ await runSetup(project, {
112
+ apiOrigin: options['--api-origin'], siteId: options['--site'], environmentId: options['--environment'],
113
+ locale: options['--locale'], mount: options['--mount'], websiteOrigin: options['--website-origin'], adminOrigin: options['--admin-origin'],
114
+ }, { ask, hidden: hiddenKey, write: value => process.stdout.write(value) });
115
+ return;
116
+ }
117
+ if (command === 'analyze') { process.stdout.write(`${JSON.stringify(await analyze(project), null, 2)}\n`); return; }
118
+ if (command === 'check') {
119
+ if (!options['--slug']) throw new SetupError('Check requires --slug with a published page slug.');
120
+ const env = { CMS_API_KEY: process.env.CMS_API_KEY, CMS_API_BASE_URL: process.env.CMS_API_BASE_URL };
121
+ if (!env.CMS_API_KEY && !options['--non-interactive']) env.CMS_API_KEY = await hiddenKey();
122
+ const result = await checkConnection(project, { slug: options['--slug'], env });
123
+ process.stdout.write(`${JSON.stringify(result)}\n`);
124
+ if (!result.verified) process.exitCode = 1;
125
+ return;
126
+ }
127
+ const required = [['--api-origin', 'CMS API HTTPS origin: '], ['--site', 'Website UUID: '], ['--environment', 'Environment UUID: ']];
128
+ if (required.some(([name]) => !options[name]) && !options['--non-interactive'] && process.stdin.isTTY) {
129
+ const input = createInterface({ input: process.stdin, output: process.stderr });
130
+ try { for (const [name, question] of required) if (!options[name]) options[name] = (await input.question(question)).trim(); }
131
+ finally { input.close(); }
132
+ }
133
+ if (required.some(([name]) => !options[name])) throw new SetupError('Init requires --api-origin, --site and --environment (or an interactive terminal).');
134
+ const plan = await planSetup(project, { framework: options['--framework'], apiOrigin: options['--api-origin'], siteId: options['--site'], environmentId: options['--environment'], locale: options['--locale'], mount: options['--mount'] });
135
+ process.stdout.write(`Template: ${plan.config.framework === 'next' ? 'Next.js App Router' : 'React/Vite with a private Node server'}\n`);
136
+ if (plan.config.framework === 'vite' && plan.analysis.runtime === 'node' && plan.analysis.rendering === 'server') process.stdout.write('Existing Node server detected: keep its normal startup and SSR routing. Follow the existing-server section in .universalcms/SETUP.md; the separate Vite demo is diagnostic only.\n');
137
+ for (const file of plan.files) process.stdout.write(` ${file.state}: ${file.path}\n`);
138
+ if (options['--apply']) {
139
+ const result = await applyPlan(plan);
140
+ process.stdout.write(`Created ${result.created.length} files; preserved ${result.unchanged} identical files.\nRead .universalcms/SETUP.md for dependency installation, server secrets, routing and deployment.\nConnection and website rendering are not yet verified. Run check with a published page slug after configuring the private server.\n`);
141
+ } else process.stdout.write('Plan only: no files changed. Review the template and run the same command with --apply to create it.\n');
142
+ }
143
+
144
+ try { await main(); }
145
+ catch (error) {
146
+ // Filesystem, JSON and upstream errors can contain customer source or secrets.
147
+ process.stderr.write(`${error instanceof SetupError ? error.message : 'Setup could not complete. Check ordinary project files and permissions. Review any interrupted setup changes before retrying; private settings or generated files may have been saved.'}\n`);
148
+ process.exitCode = 1;
149
+ }
package/dist/core.mjs ADDED
@@ -0,0 +1,224 @@
1
+ import { constants } from 'node:fs';
2
+ import { lstat, realpath, open, mkdir, readdir, rename, unlink } from 'node:fs/promises';
3
+ import { randomUUID } from 'node:crypto';
4
+ import path from 'node:path';
5
+ import { analyzeProjectDirectory } from './vendor/analyze-project.mjs';
6
+ import { verifyPublishedDelivery } from './vendor/check-delivery.mjs';
7
+ import { createTemplates, legacySetupGuide } from './templates.mjs';
8
+
9
+ export class SetupError extends Error {}
10
+ const fail = message => { throw new SetupError(message); };
11
+ const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
12
+ const maxFileBytes = 128 * 1024;
13
+ export const CONFIG_FILE = '.universalcms/config.json';
14
+ const plain = value => value && typeof value === 'object' && !Array.isArray(value);
15
+
16
+ export async function projectRoot(directory) {
17
+ const requested = path.resolve(directory);
18
+ const stat = await lstat(requested);
19
+ if (!stat.isDirectory() || stat.isSymbolicLink()) fail('Choose an ordinary project directory, not a symbolic link.');
20
+ return realpath(requested);
21
+ }
22
+
23
+ /** Every segment is checked before reading or creating; external symlinks are never followed. */
24
+ async function inspect(root, relative, directory = false) {
25
+ if (typeof relative !== 'string' || !relative || relative.includes('\\') || path.isAbsolute(relative) || relative.split('/').some(part => !part || part === '.' || part === '..')) fail('Unsafe generated path.');
26
+ let target = root;
27
+ const segments = relative.split('/');
28
+ for (let index = 0; index < segments.length; index++) {
29
+ target = path.join(target, segments[index]);
30
+ let stat;
31
+ try { stat = await lstat(target); }
32
+ catch (error) { if (error.code === 'ENOENT') return null; throw error; }
33
+ if (stat.isSymbolicLink() || await realpath(target) !== target) fail('A setup path is a symbolic link. Use ordinary project files.');
34
+ const shouldBeDirectory = index < segments.length - 1 || directory;
35
+ if (shouldBeDirectory ? !stat.isDirectory() : !stat.isFile()) fail('A setup path has an incompatible file type.');
36
+ if (index === segments.length - 1) return { target, stat };
37
+ }
38
+ }
39
+
40
+ export async function readProjectFile(root, relative) {
41
+ const found = await inspect(root, relative);
42
+ if (!found) return null;
43
+ if (found.stat.size > maxFileBytes) fail('A setup file exceeds the read limit.');
44
+ const handle = await open(found.target, constants.O_RDONLY | constants.O_NOFOLLOW);
45
+ try {
46
+ const stat = await handle.stat();
47
+ if (!stat.isFile() || stat.ino !== found.stat.ino || stat.dev !== found.stat.dev || stat.size > maxFileBytes) fail('A project file changed during inspection. Retry setup.');
48
+ const buffer = Buffer.alloc(maxFileBytes + 1);
49
+ let bytes = 0;
50
+ while (bytes < buffer.length) {
51
+ const result = await handle.read(buffer, bytes, buffer.length - bytes, bytes);
52
+ if (!result.bytesRead) break;
53
+ bytes += result.bytesRead;
54
+ }
55
+ if (bytes > maxFileBytes) fail('A setup file exceeds the read limit.');
56
+ return new TextDecoder('utf-8', { fatal: true }).decode(buffer.subarray(0, bytes));
57
+ } finally { await handle.close(); }
58
+ }
59
+
60
+ function supportedVersion(value, majors, minMinor = 0) {
61
+ if (typeof value !== 'string') return false;
62
+ const match = /^(?:\^|~)?(\d+)\.(\d+)\.(\d+)$/.exec(value);
63
+ return !!match && majors.includes(Number(match[1])) && Number(match[2]) >= minMinor;
64
+ }
65
+
66
+ export function validateConfig(input) {
67
+ if (!plain(input) || Object.keys(input).some(key => !['version', 'framework', 'apiOrigin', 'siteId', 'environmentId', 'locale', 'mount', 'appDirectory'].includes(key))) fail('The connection configuration contains unsupported fields. Credentials belong only in the server environment.');
68
+ let origin, locale;
69
+ try {
70
+ const url = new URL(input.apiOrigin);
71
+ if (typeof input.apiOrigin !== 'string' || input.apiOrigin.length > 2048 || url.username || url.password || url.search || url.hash || url.pathname !== '/' || /[\s\\]|cms_server\.|sk-[A-Za-z0-9_-]{12,}|AIza[A-Za-z0-9_-]{20,}/.test(input.apiOrigin) ||
72
+ (url.protocol !== 'https:' && !(url.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)))) throw Error();
73
+ origin = url.origin;
74
+ if (typeof input.locale !== 'string' || input.locale.length > 35) throw Error();
75
+ locale = Intl.getCanonicalLocales(input.locale)[0];
76
+ } catch { fail('Use an HTTPS API origin (HTTP loopback is allowed for development) and a valid locale.'); }
77
+ if (input.version !== 1 || !['next', 'vite'].includes(input.framework) || !uuid.test(input.siteId) || !uuid.test(input.environmentId) || !locale || !/^\/[a-z][a-z0-9-]{0,39}$/.test(input.mount)) fail('Use valid site/environment UUIDs and one lowercase mount segment, such as /cms.');
78
+ if (input.framework === 'next' ? !['app', 'src/app'].includes(input.appDirectory) : input.appDirectory !== null) fail('Invalid application directory.');
79
+ if (['/api', '/_next', '/assets', '/public', '/src', '/node_modules'].includes(input.mount)) fail('This route prefix is reserved. Choose a different mount.');
80
+ return { version: 1, framework: input.framework, apiOrigin: origin, siteId: input.siteId.toLowerCase(), environmentId: input.environmentId.toLowerCase(), locale, mount: input.mount, appDirectory: input.appDirectory };
81
+ }
82
+
83
+ async function checkNextRoute(root, appDirectory, mount, existingConfig) {
84
+ const mountName = mount.slice(1);
85
+ const route = `${appDirectory}/${mountName}`;
86
+ const found = await inspect(root, route, true);
87
+ if (found && !existingConfig) fail('The chosen Next.js mount already exists. Choose a free mount or integrate it manually.');
88
+ // A sibling group/dynamic route can own the same URL. Never guess its semantics.
89
+ const entries = await readdir(path.join(root, appDirectory), { withFileTypes: true });
90
+ if (entries.some(entry => entry.name.startsWith('(') || entry.name.startsWith('[') || entry.name.startsWith('@'))) fail('Route groups, parallel routes or dynamic root routes need a manual route review before setup.');
91
+ for (const directory of ['pages', 'src/pages']) {
92
+ if (!await inspect(root, directory, true)) continue;
93
+ const siblings = await readdir(path.join(root, directory), { withFileTypes: true });
94
+ if (siblings.some(entry => entry.name === mountName || entry.name.startsWith(`${mountName}.`) || entry.name.startsWith('['))) fail('A Pages Router destination may already own this mount. Review the route manually.');
95
+ }
96
+ if (found && existingConfig) {
97
+ const children = await readdir(path.join(root, route));
98
+ if (children.some(name => !['cms.server.js', '[...slug]'].includes(name))) fail('The generated mount contains additional routes. Review the route tree manually.');
99
+ if (await inspect(root, `${route}/[...slug]`, true)) {
100
+ const children = await readdir(path.join(root, route, '[...slug]'));
101
+ if (children.some(name => !['page.jsx', 'error.jsx'].includes(name))) fail('The generated mount contains additional routes. Review the route tree manually.');
102
+ }
103
+ }
104
+ }
105
+
106
+ export async function analyze(directory) {
107
+ const root = await projectRoot(directory);
108
+ return analyzeProjectDirectory(root);
109
+ }
110
+
111
+ export async function planSetup(directory, options, { reconfigure = false } = {}) {
112
+ const root = await projectRoot(directory);
113
+ const analysis = await analyzeProjectDirectory(root);
114
+ let manifest;
115
+ try { manifest = JSON.parse(await readProjectFile(root, 'package.json')); }
116
+ catch { fail('A readable valid package.json is required.'); }
117
+ if (!plain(manifest)) fail('A readable valid package.json is required.');
118
+ const deps = { ...manifest.devDependencies, ...manifest.dependencies };
119
+ if (manifest.workspaces || analysis.warnings.some(warning => /invalid|workspace|Conflicting/.test(warning))) fail('Choose one valid website project. Conflicting, workspace or unreadable configuration needs manual review.');
120
+ const framework = options.framework ?? (deps.next ? 'next' : deps.vite && deps.react ? 'vite' : null);
121
+ if (!['next', 'vite'].includes(framework)) fail('Supported templates are Next.js App Router and React/Vite. Other frameworks need manual setup.');
122
+ if (!supportedVersion(deps.react, [19], 2)) fail('The renderer requires a declared stable React 19.2 or newer 19.x version. Upgrade deliberately or use manual integration.');
123
+ let appDirectory = null;
124
+ if (framework === 'next') {
125
+ if (!supportedVersion(deps.next, [16]) || analysis.framework !== 'Next.js' || analysis.runtime !== 'node') fail('Automatic setup requires Next.js 16 with a confirmed Node runtime. Static export and dynamic configuration need manual setup.');
126
+ for (const extension of ['js', 'mjs', 'cjs', 'ts']) {
127
+ const configSource = await readProjectFile(root, `next.config.${extension}`);
128
+ // These settings can invalidate the generated dynamic exports, filename
129
+ // routing or documented mount. Even a mention is conservatively reviewed;
130
+ // this check does not evaluate code or claim to interpret JS configuration.
131
+ if (configSource && /\b(?:cacheComponents|pageExtensions|basePath)\b/.test(configSource)) fail('Next.js Cache Components, custom page extensions or base-path configuration need manual route integration with this template.');
132
+ }
133
+ const rootApp = await inspect(root, 'app', true), srcApp = await inspect(root, 'src/app', true);
134
+ if (!!rootApp === !!srcApp) fail('Choose a project with exactly one App Router directory (app or src/app). Pages Router needs manual setup.');
135
+ appDirectory = rootApp ? 'app' : 'src/app';
136
+ if (!(await Promise.all(['js', 'jsx', 'ts', 'tsx'].map(extension => readProjectFile(root, `${appDirectory}/layout.${extension}`)))).some(Boolean)) fail('The App Router requires an existing root layout.');
137
+ } else {
138
+ if (!supportedVersion(deps.vite, [6, 7, 8]) || deps.next || analysis.frontend !== 'React' || analysis.framework !== 'React') fail('Automatic setup requires a React project with Vite 6, 7 or 8. Conflicting frameworks need manual setup.');
139
+ }
140
+ const config = validateConfig({ version: 1, framework, apiOrigin: options.apiOrigin, siteId: options.siteId, environmentId: options.environmentId, locale: options.locale ?? 'en', mount: options.mount ?? '/cms', appDirectory });
141
+ const existingConfig = await readProjectFile(root, CONFIG_FILE);
142
+ let prior;
143
+ if (existingConfig && reconfigure) {
144
+ try { prior = validateConfig(JSON.parse(existingConfig)); }
145
+ catch { fail('The saved connector configuration is invalid. Restore it before running setup.'); }
146
+ if (prior.framework !== config.framework || prior.mount !== config.mount || prior.appDirectory !== config.appDirectory) fail('Changing the generated route or framework needs a manual integration review. Existing routes were preserved.');
147
+ }
148
+ if (existingConfig && !reconfigure && existingConfig !== `${JSON.stringify(config, null, 2)}\n`) fail('This project already has a different CMS connection. Use interactive setup to update unchanged generated files, or review the integration manually.');
149
+ if (framework === 'next') await checkNextRoute(root, appDirectory, config.mount, existingConfig);
150
+ const templateOptions = { existingNodeServer: framework === 'vite' && analysis.runtime === 'node' && analysis.rendering === 'server' };
151
+ const files = await createTemplates(config, templateOptions);
152
+ files.unshift({ path: CONFIG_FILE, content: `${JSON.stringify(config, null, 2)}\n` });
153
+ const previous = prior ? new Map((await createTemplates(prior, templateOptions)).map(file => [file.path, file.content])) : new Map();
154
+ if (prior) previous.set(CONFIG_FILE, existingConfig);
155
+ for (const file of files) {
156
+ const current = await readProjectFile(root, file.path);
157
+ const old = previous.get(file.path);
158
+ const generatedBefore = old !== undefined && (old === current || (file.path === '.universalcms/SETUP.md' && legacySetupGuide(old) === current));
159
+ file.state = current === null ? 'create' : current === file.content ? 'unchanged' : reconfigure && generatedBefore ? 'update' : 'conflict';
160
+ if (file.state === 'update') file.previous = current;
161
+ }
162
+ if (files.some(file => file.state === 'conflict')) fail('A generated file already contains different content. No files were changed. Preserve your edits and finish the integration manually.');
163
+ return { root, config, analysis, files };
164
+ }
165
+
166
+ export async function validatePlan(plan) {
167
+ // Init creates exclusively. Guided reconfiguration replaces only files whose
168
+ // bytes still match the generated original captured by the reviewed plan.
169
+ if (await projectRoot(plan.root) !== plan.root) fail('The project directory changed after planning. Run setup again from the intended directory.');
170
+ for (const file of plan.files) {
171
+ const current = await readProjectFile(plan.root, file.path);
172
+ if (file.state === 'update') {
173
+ if (current !== file.previous) fail('A file changed after planning. No existing file was overwritten.');
174
+ const found = await inspect(plan.root, file.path);
175
+ if (!found || found.stat.nlink !== 1) fail('A generated file is linked or changed. Existing files were preserved.');
176
+ } else if (current !== null && current !== file.content) fail('A file changed after planning. No existing file was overwritten.');
177
+ }
178
+ }
179
+
180
+ export async function applyPlan(plan) {
181
+ await validatePlan(plan);
182
+ const created = [], updated = [];
183
+ for (const file of plan.files) {
184
+ if (await readProjectFile(plan.root, file.path) === file.content) continue;
185
+ if (file.state === 'update') {
186
+ const target = path.join(plan.root, file.path);
187
+ const temporary = path.join(path.dirname(target), `.cms-setup-${randomUUID()}.tmp`);
188
+ const handle = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o644);
189
+ try {
190
+ await handle.writeFile(file.content, 'utf8'); await handle.sync(); await handle.close();
191
+ if (await readProjectFile(plan.root, file.path) !== file.previous) fail('A file changed during setup. Existing edits were preserved; rerun after inspection.');
192
+ const found = await inspect(plan.root, file.path);
193
+ if (!found || found.stat.nlink !== 1) fail('A generated file changed during setup. Rerun after inspection.');
194
+ await rename(temporary, target); updated.push(file.path);
195
+ } finally { await handle.close().catch(() => {}); await unlink(temporary).catch(error => { if (error.code !== 'ENOENT') throw error; }); }
196
+ continue;
197
+ }
198
+ const segments = file.path.split('/');
199
+ for (let index = 1; index < segments.length; index++) {
200
+ const directory = segments.slice(0, index).join('/');
201
+ if (!await inspect(plan.root, directory, true)) {
202
+ try { await mkdir(path.join(plan.root, directory), { mode: 0o755 }); }
203
+ catch (error) { if (error.code !== 'EEXIST') throw error; }
204
+ await inspect(plan.root, directory, true);
205
+ }
206
+ }
207
+ const handle = await open(path.join(plan.root, file.path), constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o644);
208
+ try { await handle.writeFile(file.content, 'utf8'); } finally { await handle.close(); }
209
+ created.push(file.path);
210
+ }
211
+ return { created, updated, unchanged: plan.files.length - created.length - updated.length };
212
+ }
213
+
214
+ export async function checkConnection(directory, { slug, env = process.env, fetcher } = {}) {
215
+ const root = await projectRoot(directory);
216
+ let config;
217
+ try { config = validateConfig(JSON.parse(await readProjectFile(root, CONFIG_FILE))); }
218
+ catch { fail('Run init first and keep its valid connection configuration.'); }
219
+ return verifyPublishedDelivery({ slug, locale: config.locale, env: {
220
+ // Match the generated runtime's private origin override while retaining its
221
+ // immutable site/environment/locale. The verifier validates before fetching.
222
+ CMS_API_BASE_URL: env.CMS_API_BASE_URL ?? config.apiOrigin, CMS_SITE_ID: config.siteId, CMS_ENVIRONMENT_ID: config.environmentId, CMS_API_KEY: env.CMS_API_KEY,
223
+ }, ...(fetcher ? { fetcher } : {}) });
224
+ }