@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 +122 -2
- package/dist/cli.mjs +149 -0
- package/dist/core.mjs +224 -0
- package/dist/setup-environment.mjs +237 -0
- package/dist/templates.mjs +84 -0
- package/dist/vendor/analyze-project.mjs +82 -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 +446 -0
- package/dist/vite-handler.mjs +54 -0
- package/dist/wizard.mjs +99 -0
- package/package.json +23 -3
package/README.md
CHANGED
|
@@ -1,3 +1,123 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @universalcms/connect
|
|
2
2
|
|
|
3
|
-
|
|
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
|
+
}
|