@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
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { constants } from 'node:fs';
|
|
2
|
+
import { lstat, open, rename, unlink } from 'node:fs/promises';
|
|
3
|
+
import { execFile } from 'node:child_process';
|
|
4
|
+
import { promisify, parseEnv } from 'node:util';
|
|
5
|
+
import { randomBytes } from 'node:crypto';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { projectRoot, readProjectFile, validateConfig, CONFIG_FILE, SetupError } from './core.mjs';
|
|
8
|
+
import { verifyPublishedDelivery } from './vendor/check-delivery.mjs';
|
|
9
|
+
|
|
10
|
+
const run = promisify(execFile);
|
|
11
|
+
const plans = new WeakMap();
|
|
12
|
+
const maxFileBytes = 128 * 1024;
|
|
13
|
+
const names = ['CMS_API_BASE_URL', 'CMS_SITE_ID', 'CMS_ENVIRONMENT_ID', 'CMS_LOCALE', 'CMS_API_KEY', 'CMS_WEBSITE_ORIGIN', 'CMS_SOURCE_ORIGIN', 'CMS_ADMIN_ORIGIN'];
|
|
14
|
+
const managed = new Set(names);
|
|
15
|
+
const modes = ['.env.development', '.env.development.local', '.env.production', '.env.production.local'];
|
|
16
|
+
const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
17
|
+
// Tokens encode organization/key IDs. Only an authenticated delivery read can
|
|
18
|
+
// establish whether a credential authorizes the selected site/environment.
|
|
19
|
+
const keyPattern = /^cms_server\.([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.[A-Za-z0-9_-]{43}$/;
|
|
20
|
+
const fail = message => { throw new SetupError(message); };
|
|
21
|
+
async function protect(action) {
|
|
22
|
+
try { return await action(); }
|
|
23
|
+
catch (error) { if (error instanceof SetupError) throw error; fail('Private setup settings could not be inspected or saved. Check ordinary files and permissions; no credential details are displayed.'); }
|
|
24
|
+
}
|
|
25
|
+
function origin(value, required = false) {
|
|
26
|
+
if ((value === undefined || value === '') && !required) return undefined;
|
|
27
|
+
try {
|
|
28
|
+
if (typeof value !== 'string' || /[\s\\]|cms_server\.|sk-[A-Za-z0-9_-]{12,}|AIza[A-Za-z0-9_-]{20,}/i.test(value)) throw Error();
|
|
29
|
+
const url = new URL(value);
|
|
30
|
+
if (url.username || url.password || url.search || url.hash || url.pathname !== '/' ||
|
|
31
|
+
(url.protocol !== 'https:' && !(url.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)))) throw Error();
|
|
32
|
+
return url.origin;
|
|
33
|
+
} catch { fail('Use an HTTPS origin without credentials, path or query (HTTP loopback is allowed for local development).'); }
|
|
34
|
+
}
|
|
35
|
+
function id(value, required = false) {
|
|
36
|
+
if ((value === undefined || value === '') && !required) return undefined;
|
|
37
|
+
if (typeof value !== 'string' || !uuid.test(value)) fail('Use valid website and environment UUIDs from Developer setup.');
|
|
38
|
+
return value.toLowerCase();
|
|
39
|
+
}
|
|
40
|
+
function locale(value) {
|
|
41
|
+
if (value === undefined || value === '') return undefined;
|
|
42
|
+
try {
|
|
43
|
+
if (typeof value !== 'string' || value.length > 35 || /[\s\\]/.test(value)) throw Error();
|
|
44
|
+
const result = Intl.getCanonicalLocales(value)[0]; if (!result) throw Error(); return result;
|
|
45
|
+
} catch { fail('Use a valid content locale.'); }
|
|
46
|
+
}
|
|
47
|
+
function metadata(stat) { return [stat.dev, stat.ino, stat.mode, stat.nlink, stat.size, stat.mtimeMs, stat.ctimeMs]; }
|
|
48
|
+
async function snapshot(root, file) {
|
|
49
|
+
let before;
|
|
50
|
+
try { before = await lstat(path.join(root, file)); } catch (error) { if (error.code === 'ENOENT') return { file, content: null, metadata: null, mode: null }; throw error; }
|
|
51
|
+
if (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1) fail('Private setup files must be ordinary files with no symbolic links or hard links.');
|
|
52
|
+
const content = await readProjectFile(root, file), after = await lstat(path.join(root, file));
|
|
53
|
+
if (JSON.stringify(metadata(before)) !== JSON.stringify(metadata(after))) fail('A setup file changed during inspection. Retry setup.');
|
|
54
|
+
return { file, content, metadata: metadata(after), mode: after.mode & 0o777 };
|
|
55
|
+
}
|
|
56
|
+
function environment(source = '') {
|
|
57
|
+
if (source.includes('\0')) fail('The environment file contains ambiguous data. Review it manually before setup.');
|
|
58
|
+
const parsed = parseEnv(source), assignments = new Map();
|
|
59
|
+
const lines = source.match(/[^\r\n]*(?:\r\n|\n|\r|$)/g).filter(Boolean);
|
|
60
|
+
for (let index = 0; index < lines.length; index++) {
|
|
61
|
+
const raw = lines[index], ending = /(?:\r\n|\n|\r)$/.exec(raw)?.[0] ?? '', line = raw.slice(0, raw.length - ending.length);
|
|
62
|
+
const candidate = /^\s*(?:export\s+)?(CMS_[A-Z_]+)\b/.exec(line);
|
|
63
|
+
if (!candidate || !managed.has(candidate[1])) continue;
|
|
64
|
+
const match = /^(\s*(?:export\s+)?)([A-Za-z_][A-Za-z0-9_]*)(\s*=\s*)(.*)$/.exec(line);
|
|
65
|
+
if (!match || assignments.has(match[2])) fail('Duplicate or ambiguous managed CMS assignments require manual review before setup.');
|
|
66
|
+
const value = match[4]; let tail = '', literal;
|
|
67
|
+
if (value.startsWith('"') || value.startsWith("'")) {
|
|
68
|
+
const quote = value[0], end = value.indexOf(quote, 1);
|
|
69
|
+
if (end < 0 || value.slice(1, end).includes('\\')) fail('Multiline or escaped managed CMS values require manual review before setup.');
|
|
70
|
+
literal = value.slice(0, end + 1); tail = value.slice(end + 1);
|
|
71
|
+
if (!/^\s*(?:#.*)?$/.test(tail)) fail('An ambiguous managed CMS value requires manual review before setup.');
|
|
72
|
+
} else {
|
|
73
|
+
if (value.includes('`') || value.includes('\\')) fail('An ambiguous managed CMS value requires manual review before setup.');
|
|
74
|
+
const comment = value.indexOf('#'), body = comment < 0 ? value : value.slice(0, comment);
|
|
75
|
+
literal = body.trimEnd(); tail = body.slice(literal.length) + (comment < 0 ? '' : value.slice(comment));
|
|
76
|
+
}
|
|
77
|
+
const isolated = parseEnv(`${match[2]}=${literal}`)[match[2]];
|
|
78
|
+
if (typeof isolated !== 'string' || parsed[match[2]] !== isolated) fail('An ambiguous managed CMS assignment requires manual review before setup.');
|
|
79
|
+
assignments.set(match[2], { index, prefix: match[1], equals: match[3], tail, ending });
|
|
80
|
+
}
|
|
81
|
+
for (const name of names) if (Object.hasOwn(parsed, name) && !assignments.has(name)) fail('Managed CMS values could not be read unambiguously. Review the environment file manually.');
|
|
82
|
+
return { values: Object.fromEntries(names.filter(name => Object.hasOwn(parsed, name)).map(name => [name, parsed[name]])), allValues: parsed, assignments, lines };
|
|
83
|
+
}
|
|
84
|
+
function renderEnvironment(source, parsed, values) {
|
|
85
|
+
const lines = [...parsed.lines], ending = source.includes('\r\n') ? '\r\n' : '\n';
|
|
86
|
+
const appended = [];
|
|
87
|
+
for (const [name, value] of Object.entries(values)) {
|
|
88
|
+
// All values were validated into a quote/control-free ASCII form.
|
|
89
|
+
const assignment = parsed.assignments.get(name), quoted = `"${value}"`;
|
|
90
|
+
if (assignment) lines[assignment.index] = `${assignment.prefix}${name}${assignment.equals}${quoted}${assignment.tail}${assignment.ending}`;
|
|
91
|
+
else appended.push(`${name}=${quoted}${ending}`);
|
|
92
|
+
}
|
|
93
|
+
const existing = lines.join('');
|
|
94
|
+
return existing + (appended.length && existing && !/[\r\n]$/.test(existing) ? ending : '') + appended.join('');
|
|
95
|
+
}
|
|
96
|
+
async function assertUntracked(root) {
|
|
97
|
+
const env = Object.fromEntries(Object.entries(process.env).filter(([name]) => !name.startsWith('GIT_')));
|
|
98
|
+
Object.assign(env, { LC_ALL: 'C', GIT_OPTIONAL_LOCKS: '0', GIT_CONFIG_NOSYSTEM: '1' });
|
|
99
|
+
try {
|
|
100
|
+
await run('git', ['-c', 'core.fsmonitor=false', '-c', 'core.untrackedCache=false', '-c', 'core.hooksPath=/dev/null', '-C', root, 'ls-files', '--error-unmatch', '--', '.env.local'], { env, timeout: 5000, maxBuffer: 16 * 1024 });
|
|
101
|
+
} catch (error) {
|
|
102
|
+
if (error.code === 1 || (error.code === 128 && /not a git repository/.test(error.stderr ?? ''))) return;
|
|
103
|
+
fail('Git tracking could not be checked safely. Install Git and resolve repository errors before saving private settings.');
|
|
104
|
+
}
|
|
105
|
+
fail('The private .env.local file is already tracked by Git. Remove it from tracking and review exposed credentials before setup can save a key.');
|
|
106
|
+
}
|
|
107
|
+
async function inputs(root) {
|
|
108
|
+
const files = await Promise.all(['.env.local', '.gitignore', ...modes].map(file => snapshot(root, file)));
|
|
109
|
+
const rawConfig = await readProjectFile(root, CONFIG_FILE);
|
|
110
|
+
let config;
|
|
111
|
+
if (rawConfig !== null) {
|
|
112
|
+
try { config = validateConfig(JSON.parse(rawConfig)); }
|
|
113
|
+
catch { fail('The existing CMS connection configuration needs manual review before setup.'); }
|
|
114
|
+
}
|
|
115
|
+
return { files, config, rawConfig, current: environment(files[0].content ?? '') };
|
|
116
|
+
}
|
|
117
|
+
function defaults(input) {
|
|
118
|
+
const value = input.current.values, config = input.config;
|
|
119
|
+
// An old placeholder may be replaced interactively. Never echo invalid saved
|
|
120
|
+
// text in a prompt, especially credentials disguised as a nonsecret value.
|
|
121
|
+
const available = read => { try { return read(); } catch { return undefined; } };
|
|
122
|
+
const result = {
|
|
123
|
+
apiOrigin: available(() => origin(value.CMS_API_BASE_URL || config?.apiOrigin)),
|
|
124
|
+
siteId: available(() => id(value.CMS_SITE_ID || config?.siteId)),
|
|
125
|
+
environmentId: available(() => id(value.CMS_ENVIRONMENT_ID || config?.environmentId)),
|
|
126
|
+
locale: available(() => locale(value.CMS_LOCALE || config?.locale)),
|
|
127
|
+
websiteOrigin: available(() => origin(value.CMS_WEBSITE_ORIGIN || value.CMS_SOURCE_ORIGIN)),
|
|
128
|
+
adminOrigin: available(() => origin(value.CMS_ADMIN_ORIGIN)),
|
|
129
|
+
};
|
|
130
|
+
return Object.fromEntries(Object.entries(result).filter(([, value]) => value !== undefined));
|
|
131
|
+
}
|
|
132
|
+
export async function readSetupEnvironment(directory) {
|
|
133
|
+
return protect(async () => {
|
|
134
|
+
const root = await projectRoot(directory), input = await inputs(root);
|
|
135
|
+
return { defaults: defaults(input), keyConfigured: keyPattern.test(input.current.values.CMS_API_KEY ?? '') };
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
function assertOverrides(values, input) {
|
|
139
|
+
for (const file of input.files.slice(2)) {
|
|
140
|
+
const parsed = environment(file.content ?? '');
|
|
141
|
+
for (const [name, value] of Object.entries(parsed.values)) if (value !== values[name]) fail('Mode-specific environment files contain conflicting CMS settings. Review development/production overrides before continuing.');
|
|
142
|
+
}
|
|
143
|
+
for (const name of names) if (process.env[name] !== undefined && process.env[name] !== values[name]) fail('The current process has conflicting CMS environment variables. Clear those overrides or use matching values before continuing.');
|
|
144
|
+
}
|
|
145
|
+
export async function planEnvironment(directory, options) {
|
|
146
|
+
return protect(async () => {
|
|
147
|
+
if (!options || typeof options !== 'object' || Array.isArray(options) || Object.keys(options).some(name => !['apiOrigin', 'siteId', 'environmentId', 'locale', 'websiteOrigin', 'adminOrigin', 'apiKey'].includes(name))) fail('Private setup received unsupported configuration fields.');
|
|
148
|
+
const root = await projectRoot(directory), input = await inputs(root);
|
|
149
|
+
await assertUntracked(root);
|
|
150
|
+
const apiKey = options.apiKey === undefined ? input.current.values.CMS_API_KEY : options.apiKey;
|
|
151
|
+
if (typeof apiKey !== 'string' || !keyPattern.test(apiKey)) fail('Provide a valid environment-scoped server delivery key from Developer setup.');
|
|
152
|
+
const values = { CMS_API_BASE_URL: origin(options.apiOrigin, true), CMS_SITE_ID: id(options.siteId, true), CMS_ENVIRONMENT_ID: id(options.environmentId, true), CMS_LOCALE: locale(options.locale ?? 'en'), CMS_API_KEY: apiKey };
|
|
153
|
+
if (!values.CMS_LOCALE) fail('Use a valid content locale.');
|
|
154
|
+
if (options.websiteOrigin !== undefined) { values.CMS_WEBSITE_ORIGIN = origin(options.websiteOrigin, true); values.CMS_SOURCE_ORIGIN = values.CMS_WEBSITE_ORIGIN; }
|
|
155
|
+
if (options.adminOrigin !== undefined) values.CMS_ADMIN_ORIGIN = origin(options.adminOrigin, true);
|
|
156
|
+
const effective = { ...input.current.values, ...values };
|
|
157
|
+
assertOverrides(effective, input);
|
|
158
|
+
const envContent = renderEnvironment(input.files[0].content ?? '', input.current, values);
|
|
159
|
+
if (Buffer.byteLength(envContent, 'utf8') > maxFileBytes) fail('The generated private environment file would exceed the setup read limit. Reduce the existing file before continuing.');
|
|
160
|
+
// Unclosed quotes in unrelated settings can change meaning when the new
|
|
161
|
+
// quoted CMS values are appended. Verify the entire effective environment,
|
|
162
|
+
// including every unrelated value, before producing an applicable plan.
|
|
163
|
+
const expected = { ...input.current.allValues, ...values }, rendered = environment(envContent).allValues;
|
|
164
|
+
if (Object.keys(expected).length !== Object.keys(rendered).length || Object.entries(expected).some(([name, value]) => rendered[name] !== value)) fail('The generated environment would change unrelated settings or hide CMS values. Review ambiguous quoting before setup.');
|
|
165
|
+
const ignore = input.files[1].content ?? '', ending = ignore.includes('\r\n') ? '\r\n' : '\n';
|
|
166
|
+
// The temporary credential file is ignored before it is created as well.
|
|
167
|
+
const protection = '/.universalcms-private-*.tmp' + ending + '/.env.local' + ending;
|
|
168
|
+
const separator = ignore && !ignore.endsWith('\n') ? (ignore.endsWith('\r') ? '\n' : ending) : '';
|
|
169
|
+
const ignoreContent = ignore.endsWith(protection) ? ignore : ignore + separator + protection;
|
|
170
|
+
if (Buffer.byteLength(ignoreContent, 'utf8') > maxFileBytes) fail('The generated ignore file would exceed the setup read limit. Reduce the existing file before continuing.');
|
|
171
|
+
const updates = [
|
|
172
|
+
{ ...input.files[1], next: ignoreContent, nextMode: input.files[1].mode ?? 0o644 },
|
|
173
|
+
{ ...input.files[0], next: envContent, nextMode: 0o600 },
|
|
174
|
+
];
|
|
175
|
+
const files = updates.map(file => Object.freeze({ path: file.file, action: file.content === file.next && file.mode === file.nextMode ? 'unchanged' : file.content === null ? 'create' : 'update' }));
|
|
176
|
+
const plan = Object.freeze({ files: Object.freeze(files), keyConfigured: true, preservesExistingKey: options.apiKey === undefined });
|
|
177
|
+
plans.set(plan, { root, input, updates, effective, applied: false });
|
|
178
|
+
return plan;
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
function privatePlan(plan) { const state = plans.get(plan); if (!state) fail('Use the original private environment plan returned by setup.'); return state; }
|
|
182
|
+
async function checkRoot(root) {
|
|
183
|
+
if (await projectRoot(root) !== root) fail('The project location changed after planning. Run setup again from its ordinary directory.');
|
|
184
|
+
}
|
|
185
|
+
async function recheck(state) {
|
|
186
|
+
if (state.applied) fail('This private environment plan has already been applied. Create a fresh plan to make another change.');
|
|
187
|
+
await checkRoot(state.root);
|
|
188
|
+
await assertUntracked(state.root);
|
|
189
|
+
const current = await inputs(state.root);
|
|
190
|
+
if (current.rawConfig !== state.input.rawConfig || current.files.some((file, index) => file.content !== state.input.files[index].content || JSON.stringify(file.metadata) !== JSON.stringify(state.input.files[index].metadata))) fail('Setup files changed after planning. No stale settings will be saved; run setup again.');
|
|
191
|
+
assertOverrides(state.effective, current);
|
|
192
|
+
}
|
|
193
|
+
export async function validateEnvironmentPlan(plan) { return protect(async () => { await recheck(privatePlan(plan)); return true; }); }
|
|
194
|
+
async function replaceFile(root, update) {
|
|
195
|
+
await checkRoot(root);
|
|
196
|
+
const temporary = path.join(root, `.universalcms-private-${randomBytes(12).toString('hex')}.tmp`);
|
|
197
|
+
let handle;
|
|
198
|
+
try {
|
|
199
|
+
handle = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, update.nextMode);
|
|
200
|
+
await handle.chmod(update.nextMode); await handle.writeFile(update.next, 'utf8'); await handle.sync(); await handle.close(); handle = undefined;
|
|
201
|
+
const current = await snapshot(root, update.file);
|
|
202
|
+
if (current.content !== update.content || JSON.stringify(current.metadata) !== JSON.stringify(update.metadata)) fail('A setup file changed while saving. Retry setup after reviewing the current files.');
|
|
203
|
+
if (update.file === '.env.local') await assertUntracked(root);
|
|
204
|
+
await checkRoot(root);
|
|
205
|
+
await rename(temporary, path.join(root, update.file));
|
|
206
|
+
} finally { await handle?.close().catch(() => {}); await unlink(temporary).catch(() => {}); }
|
|
207
|
+
}
|
|
208
|
+
export async function applyEnvironment(plan) {
|
|
209
|
+
return protect(async () => {
|
|
210
|
+
const state = privatePlan(plan), lockPath = path.join(state.root, '.universalcms-setup.lock');
|
|
211
|
+
await checkRoot(state.root);
|
|
212
|
+
let lock, lockStat;
|
|
213
|
+
try {
|
|
214
|
+
try { lock = await open(lockPath, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600); }
|
|
215
|
+
catch (error) { if (error.code === 'EEXIST') fail('Another setup operation holds the private-settings lock. Finish that operation before retrying.'); throw error; }
|
|
216
|
+
lockStat = await lock.stat();
|
|
217
|
+
await recheck(state);
|
|
218
|
+
const changed = [];
|
|
219
|
+
// Establish ignore protection first. Each file is atomically replaced;
|
|
220
|
+
// competing setup wizards serialize, and manual edits are rechecked.
|
|
221
|
+
for (const update of state.updates) if (update.content !== update.next || update.mode !== update.nextMode) { await replaceFile(state.root, update); changed.push(update.file); }
|
|
222
|
+
state.applied = true;
|
|
223
|
+
return { changed, keyConfigured: true };
|
|
224
|
+
} finally {
|
|
225
|
+
await lock?.close().catch(() => {});
|
|
226
|
+
if (lockStat) {
|
|
227
|
+
try { const current = await lstat(lockPath); if (current.dev === lockStat.dev && current.ino === lockStat.ino) await unlink(lockPath); } catch { /* Never remove someone else's replacement lock. */ }
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
export async function verifyEnvironment(plan, { slug, fetcher } = {}) {
|
|
233
|
+
return protect(async () => {
|
|
234
|
+
const { effective } = privatePlan(plan);
|
|
235
|
+
return verifyPublishedDelivery({ slug, locale: effective.CMS_LOCALE, env: { CMS_API_BASE_URL: effective.CMS_API_BASE_URL, CMS_SITE_ID: effective.CMS_SITE_ID, CMS_ENVIRONMENT_ID: effective.CMS_ENVIRONMENT_ID, CMS_API_KEY: effective.CMS_API_KEY }, ...(fetcher ? { fetcher } : {}) });
|
|
236
|
+
});
|
|
237
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
|
|
3
|
+
export async function createTemplates(config, { existingNodeServer = false } = {}) {
|
|
4
|
+
const json = value => JSON.stringify(value);
|
|
5
|
+
const env = `# Set these in your PRIVATE hosting/server environment. This file contains no key.\nCMS_API_BASE_URL=${config.apiOrigin}\nCMS_SITE_ID=${config.siteId}\nCMS_ENVIRONMENT_ID=${config.environmentId}\nCMS_API_KEY=\n${config.framework === 'vite' ? 'CMS_WEBSITE_ORIGIN=http://localhost:5173\nCMS_CONNECT_PORT=4318\nCMS_CONNECT_HOST=127.0.0.1\n' : ''}`;
|
|
6
|
+
const common = [
|
|
7
|
+
{ path: '.universalcms/server.env.example', content: env },
|
|
8
|
+
{ path: '.universalcms/SETUP.md', content: instructions(config, existingNodeServer) },
|
|
9
|
+
];
|
|
10
|
+
const clientOptions = `baseUrl: process.env.CMS_API_BASE_URL ?? ${json(config.apiOrigin)},\n siteId: ${json(config.siteId)},\n environmentId: ${json(config.environmentId)},\n apiKey: process.env.CMS_API_KEY,\n retries: 0,`;
|
|
11
|
+
if (config.framework === 'next') {
|
|
12
|
+
const route = `${config.appDirectory}${config.mount}`;
|
|
13
|
+
return [...common,
|
|
14
|
+
{ path: `${route}/cms.server.js`, content: `import 'server-only';\nimport { createNextCmsClient } from '@universalcms/next';\n\n// Called during a server request; never imported into a client component.\nexport function getCms() {\n return createNextCmsClient({\n ${clientOptions}\n });\n}\n` },
|
|
15
|
+
{ path: `${route}/[...slug]/page.jsx`, content: `import { notFound } from 'next/navigation';\nimport { CmsDocument } from '@universalcms/react';\nimport '@universalcms/react/styles.css';\nimport { getCms } from '../cms.server.js';\n\nexport const runtime = 'nodejs';\nexport const dynamic = 'force-dynamic';\n\nexport default async function CmsPage({ params }) {\n const { slug: segments } = await params;\n const slug = Array.isArray(segments) ? segments.join('/') : '';\n if (slug.length > 160 || !/^[a-z0-9]+(?:[-/][a-z0-9]+)*$/.test(slug)) notFound();\n const entry = await getCms().content.getBySlug(slug, { kind: 'page', locale: ${json(config.locale)} });\n if (!entry) notFound();\n return <article><h1>{entry.document.title}</h1>\n <CmsDocument document={entry.document} entryId={entry.id} />\n </article>;\n}\n` },
|
|
16
|
+
{ path: `${route}/[...slug]/error.jsx`, content: `'use client';\nexport default function CmsError({ reset }) {\n return <section role="alert"><h1>Content is temporarily unavailable</h1>\n <button type="button" onClick={reset}>Try again</button></section>;\n}\n` },
|
|
17
|
+
];
|
|
18
|
+
}
|
|
19
|
+
const handler = await readFile(new URL('./vite-handler.mjs', import.meta.url), 'utf8');
|
|
20
|
+
return [...common,
|
|
21
|
+
{ path: '.universalcms/page-handler.mjs', content: handler },
|
|
22
|
+
{ path: '.universalcms/server.mjs', content: `import { createServer } from 'node:http';\nimport { createCmsClient } from '@universalcms/sdk';\nimport { createPageHandler } from './page-handler.mjs';\n\ntry {\n const cms = createCmsClient({\n ${clientOptions}\n });\n const port = Number(process.env.CMS_CONNECT_PORT ?? '4318');\n if (!Number.isInteger(port) || port < 1 || port > 65535) throw Error();\n const handler = createPageHandler(cms, { locale: ${json(config.locale)}, websiteOrigin: process.env.CMS_WEBSITE_ORIGIN });\n const server = createServer({ requestTimeout: 15_000, headersTimeout: 10_000, maxHeaderSize: 8192 }, (req, res) => { void handler(req, res); });\n server.on('error', () => { console.error('CMS connector could not start. Check server configuration.'); process.exitCode = 1; });\n server.listen(port, process.env.CMS_CONNECT_HOST ?? '127.0.0.1', () => console.log('CMS connector listening. Route /api/universalcms/page through your website proxy.'));\n for (const signal of ['SIGINT', 'SIGTERM']) process.once(signal, () => { server.close(); server.closeAllConnections(); });\n} catch { console.error('CMS connector requires valid private server configuration.'); process.exitCode = 1; }\n` },
|
|
23
|
+
{ path: '.universalcms/CmsPage.jsx', content: `import React, { useEffect, useState } from 'react';\nimport { CmsDocument } from '@universalcms/react';\nimport '@universalcms/react/styles.css';\n\n// Mount in your existing router; this module contains no CMS credentials.\nexport default function CmsPage({ slug = 'home' }) {\n const [result, setResult] = useState({ slug: '', state: 'loading', entry: null });\n useEffect(() => {\n const controller = new AbortController();\n let current = true;\n setResult({ slug, state: 'loading', entry: null });\n async function load() {\n try {\n const response = await fetch('/api/universalcms/page?slug=' + encodeURIComponent(slug), { credentials: 'omit', cache: 'no-store', redirect: 'error', signal: controller.signal });\n if (response.status === 404) { if (current) setResult({ slug, state: 'missing', entry: null }); return; }\n if (!response.ok || !response.headers.get('content-type')?.startsWith('application/json')) throw Error();\n const body = await response.json();\n if (!body.entry?.document || typeof body.entry.document.title !== 'string' || !Array.isArray(body.entry.document.blocks)) throw Error();\n if (current) setResult({ slug, state: 'ready', entry: body.entry });\n } catch { if (current) setResult({ slug, state: 'error', entry: null }); }\n }\n void load();\n return () => { current = false; controller.abort(); };\n }, [slug]);\n if (result.slug !== slug || result.state === 'loading') return <p role="status">Loading content…</p>;\n if (result.state === 'missing') return <h1>Page not found</h1>;\n if (result.state === 'error') return <p role="alert">Content is temporarily unavailable.</p>;\n return <article><h1>{result.entry.document.title}</h1><CmsDocument document={result.entry.document} entryId={result.entry.id} /></article>;\n}\n` },
|
|
24
|
+
{ path: '.universalcms/vite.config.mjs', content: `import { defineConfig, loadConfigFromFile } from 'vite';\n\n// Explicit wrapper: preserves the project's config without editing it.\n// Run only when you choose: npx vite --config .universalcms/vite.config.mjs\nexport default defineConfig(async env => {\n const existing = (await loadConfigFromFile(env, undefined, process.cwd()))?.config ?? {};\n const proxies = existing.server?.proxy ?? {};\n const endpoint = '^/api/universalcms/page(?:[?]|$)';\n if (Object.hasOwn(proxies, '/api/universalcms/page') || Object.hasOwn(proxies, endpoint)) throw new Error('The CMS proxy endpoint already exists. Review it manually before using this wrapper.');\n // Vite selects the first matching proxy. The exact CMS endpoint must precede\n // an existing broad /api proxy; all unrelated routes retain their destinations.\n return { ...existing, server: { ...existing.server, proxy: { [endpoint]: { target: 'http://127.0.0.1:4318', changeOrigin: false }, ...proxies } } };\n});\n` },
|
|
25
|
+
{ path: 'universalcms-demo.html', content: '<!doctype html>\n<html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>CMS connection demo</title><meta name="robots" content="noindex,nofollow"></head><body><div id="universalcms-demo"></div><script type="module" src="/.universalcms/demo.jsx"></script></body></html>\n' },
|
|
26
|
+
{ path: '.universalcms/demo.jsx', content: `import React from 'react';\nimport { createRoot } from 'react-dom/client';\nimport CmsPage from './CmsPage.jsx';\nconst slug = new URLSearchParams(window.location.search).get('slug') || 'home';\ncreateRoot(document.getElementById('universalcms-demo')).render(<CmsPage slug={slug} />);\n` },
|
|
27
|
+
];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Exact prior prerelease guide variant, used only to recognize an untouched
|
|
31
|
+
// generated document during guided upgrades. Custom bytes still fail closed.
|
|
32
|
+
export function legacySetupGuide(content) {
|
|
33
|
+
return content
|
|
34
|
+
.replace('Guided setup saves the entered values in private .env.local. The analyze, init and check commands do not load that file automatically.', 'The CLI neither saves nor reads an .env file.')
|
|
35
|
+
.replace('node --env-file=.env.local .universalcms/server.mjs', 'node .universalcms/server.mjs')
|
|
36
|
+
.replace('Run npx --no-install universalcms-connect setup to enter and save connection values locally. For hosted deployment, copy the nonsecret values in server.env.example into the private server/hosting', 'Copy the nonsecret values in server.env.example into the private server/hosting')
|
|
37
|
+
.replace('Init preserves existing project files. Guided setup can update its unchanged generated files and the entered connection values in .env.local; unrelated settings and custom edits are preserved.', 'Existing project configuration and source are never overwritten.');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function instructions(config, existingNodeServer) {
|
|
41
|
+
const next = config.framework === 'next';
|
|
42
|
+
return `# Connect this website to Universal CMS\n\nThe scaffold is created, but connection and rendering are not yet verified. No\nexisting source was replaced and no content was imported or published.\n\n## Install the runtime packages\n\nAfter the platform operator publishes the 0.1.0 packages, run:\n\n\`\`\`sh\nnpm install @universalcms/sdk@0.1.0 @universalcms/react@0.1.0${next ? ' @universalcms/next@0.1.0' : ''}\n\`\`\`\n\nFor a prerelease, install the operator's SDK and React${next ? ' and Next' : ''} tarballs together instead.\nDo not run the registry command until those reviewed packages are available.\nUse your project's package manager and commit its reviewed lockfile.\n\n## Configure server secrets and verify\n\nRun npx --no-install universalcms-connect setup to enter and save connection values locally. For hosted deployment, copy the nonsecret values in server.env.example into the private server/hosting\nenvironment. Obtain an environment-scoped server delivery key from this site's\nDeveloper setup and set CMS_API_KEY using your secret manager. Never use a\nNEXT_PUBLIC_ or VITE_ variable. Guided setup saves the entered values in private .env.local. The analyze, init and check commands do not load that file automatically.\nIf using a local env file, keep it ignored by Git and outside public directories.\nThe site/environment in generated server code are fixed; create separate reviewed\nconfiguration for another environment. CMS_API_BASE_URL may override the API\norigin only in the private server environment.\n\nRun \`npx --no-install universalcms-connect check --slug YOUR_PUBLISHED_PAGE\` from this project.\nIt uses CMS_API_KEY from the process environment or a hidden terminal prompt.\nIt honors the same private CMS_API_BASE_URL override as the generated server.\nRun it with the same private environment used by that server.\nA successful check verifies one published API response, not deployment or rendering.\n\n${next ? `## Next.js\n\nStart your existing Next.js app. Visit \`${config.mount}/YOUR_PUBLISHED_PAGE\`. Nested\nslugs retain their slash separators. The generated page uses request-time delivery\nand no persistent cache; missing/unpublished pages return 404, upstream failures\nshow a retry state. This route is an additive connection demo under ${config.mount};\nit does not replace your existing routes or imply the CMS's canonical paths changed.\nProduction requires a Node-capable Next deployment and private server secrets.\nStatic export and Edge runtimes are unsupported by this scaffold.\n` : `## React/Vite\n\n${existingNodeServer ? existingServerInstructions(config) : ""}Set CMS_WEBSITE_ORIGIN to the exact browser-facing origin (localhost:5173 during\ndevelopment). Start the private server in one terminal:\n\n\`\`\`sh\nnode --env-file=.env.local .universalcms/server.mjs\n\`\`\`\n\nStart Vite with the supplied wrapper in another terminal:\n\n\`\`\`sh\nnpx vite --config .universalcms/vite.config.mjs\n\`\`\`\n\nThe wrapper merges your existing Vite configuration and proxies only\n/api/universalcms/page to port 4318. If changing the connector port, update the\nreviewed wrapper target too. Visit\n/universalcms-demo.html?slug=YOUR_PUBLISHED_PAGE to verify rendering. The wrapper\nloads your normal Vite config only when you explicitly run Vite, never during\nanalysis/setup. No private key is passed to Vite or included in the demo.\n\nImport .universalcms/CmsPage.jsx into your existing React router and give it the\npage slug, or map the returned public document to your own components. The demo\nHTML is a development entry and is not automatically included in Vite's production\nbuild. Production needs both your normal Vite static build AND this Node server\nor an equivalent serverless handler. The host/reverse proxy must route the exact\n/api/universalcms/page path to the private connector and other paths to your static\nsite; Vite dev proxy settings do not configure a production proxy. Keep the server\non a private network, set the exact HTTPS CMS_WEBSITE_ORIGIN, apply ingress request\nlimits, and do not expose a general-purpose CMS proxy or server environment.\nThe handler provides no CORS, bounds input/concurrency, and forwards only a fixed\nsite/environment/locale published page lookup. It returns 404 for a missing page\nand 503 for upstream failure; the client clears stale content. It does not provide\nSSR or production HTTP 404 status for a client-only router.\n`}\n## What remains deliberate\n\nThis first scaffold renders published pages in one configured locale. Posts,\ncustom collections, taxonomy archive routes, navigation, forms, previews, SEO\nmetadata and existing design/content mapping use the SDK's documented APIs and\nneed explicit integration. Form blocks are displayed disabled until a reviewed\nsubmission endpoint is supplied. No existing source is sent to the platform.\n\nRun your website typecheck/build and review rendered pages before deployment.\nA backend connection does not mean existing content has been imported. Use the\nCMS's authorized import-as-drafts flow for that separate step. Nothing here\npublishes content or installs the Universal CMS backend on the website.\n\n## Optional assistance\n\n\`npx --no-install universalcms-connect analyze\` prints a sanitized local report. You may choose\nto supply that report in the CMS's setup assistant. This command never uploads\nsource or calls a model. Platform AI provider keys, limits and metering remain on\nthe platform; the deterministic/manual instructions above work without AI.\n\n## Reruns and removal\n\nThe same init command preserves identical files. Differing files stop setup before\nwriting; inspect and merge your edits manually. Remove only the generated files\nlisted by init to uninstall this scaffold, then review any router/proxy changes\nyou made. Init preserves existing project files. Guided setup can update its unchanged generated files and the entered connection values in .env.local; unrelated settings and custom edits are preserved.\n`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function existingServerInstructions(config) {
|
|
46
|
+
return `### Existing Node server detected
|
|
47
|
+
|
|
48
|
+
Keep your current website startup (for example, npm run dev), SSR bootstrap and
|
|
49
|
+
production server. Starting raw Vite replaces that server and can break an SSR
|
|
50
|
+
website. The separate server/Vite commands below are optional connection-demo
|
|
51
|
+
diagnostics on unused ports, not a replacement for your website startup.
|
|
52
|
+
|
|
53
|
+
For the real website, register the published-page handler inside its existing
|
|
54
|
+
private server before a generic /api handler or SSR catch-all. Reuse that server's
|
|
55
|
+
fixed site/environment SDK client and deploy @universalcms/connect as a production
|
|
56
|
+
dependency when importing its Node entry point. For this prerelease, install the
|
|
57
|
+
reviewed connector tarball with npm install --save-prod (not --save-dev).
|
|
58
|
+
|
|
59
|
+
\`\`\`js
|
|
60
|
+
import { createPageHandler } from '@universalcms/connect/node';
|
|
61
|
+
// cms is the existing private, fixed-scope SDK client, never a browser client.
|
|
62
|
+
const cmsPage = createPageHandler(cms, {
|
|
63
|
+
locale: ${JSON.stringify(config.locale)},
|
|
64
|
+
websiteOrigin: process.env.CMS_WEBSITE_ORIGIN,
|
|
65
|
+
});
|
|
66
|
+
// In the existing request listener, BEFORE its /api or SSR fallback:
|
|
67
|
+
if (req.url?.split('?')[0] === '/api/universalcms/page') {
|
|
68
|
+
await cmsPage(req, res);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
\`\`\`
|
|
72
|
+
|
|
73
|
+
Mount the supplied CmsPage component explicitly through your router, or keep your
|
|
74
|
+
existing CMS renderer. The CLI does not replace an existing integration. Confirm
|
|
75
|
+
both direct page loads and client navigation using the normal website server.
|
|
76
|
+
The standalone demo verifies rendering separately; it does not prove SSR,
|
|
77
|
+
existing routes, navigation, or production routing work.
|
|
78
|
+
|
|
79
|
+
If using the optional raw-Vite demo, choose an unused port explicitly and set
|
|
80
|
+
CMS_WEBSITE_ORIGIN to that exact origin. Do not visit the SSR website root through
|
|
81
|
+
that diagnostic server; use /universalcms-demo.html?slug=YOUR_PUBLISHED_PAGE.
|
|
82
|
+
|
|
83
|
+
`;
|
|
84
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/** Local read-only inspection; neither installs dependencies nor runs project code. */
|
|
3
|
+
import { constants } from 'node:fs';
|
|
4
|
+
import { lstat, realpath, open, writeFile } from 'node:fs/promises';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { analyzeProjectFiles, PROJECT_ANALYSIS_FILES, MAX_ANALYSIS_FILE_BYTES, MAX_ANALYSIS_TOTAL_BYTES } from './project-analysis.mjs';
|
|
8
|
+
import { inspectInstalledDependencies } from './inspect-installed-dependencies.mjs';
|
|
9
|
+
|
|
10
|
+
function inside(root, target) { const relative = path.relative(root, target); return relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative); }
|
|
11
|
+
|
|
12
|
+
/** A missing file is fine; unreadable, symlinked or excessive evidence is never silently trusted. */
|
|
13
|
+
export async function analyzeProjectDirectory(directory) {
|
|
14
|
+
const requested = path.resolve(directory);
|
|
15
|
+
const rootStat = await lstat(requested);
|
|
16
|
+
if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) throw Error('Select an ordinary project directory, not a symbolic link.');
|
|
17
|
+
const root = await realpath(requested), files = Object.create(null);
|
|
18
|
+
let total = 0;
|
|
19
|
+
for (const name of PROJECT_ANALYSIS_FILES) {
|
|
20
|
+
const segments = name.split('/');
|
|
21
|
+
let target = root, missing = false, unsafe = false;
|
|
22
|
+
for (let index = 0; index < segments.length; index++) {
|
|
23
|
+
target = path.join(target, segments[index]);
|
|
24
|
+
let info;
|
|
25
|
+
try { info = await lstat(target); }
|
|
26
|
+
catch (error) { if (error.code === 'ENOENT' || error.code === 'ENOTDIR') { missing = true; break; } unsafe = true; break; }
|
|
27
|
+
if (info.isSymbolicLink() || (index < segments.length - 1 && !info.isDirectory()) || (index === segments.length - 1 && !info.isFile())) { unsafe = true; break; }
|
|
28
|
+
if (index === segments.length - 1 && (info.size > MAX_ANALYSIS_FILE_BYTES || total + info.size > MAX_ANALYSIS_TOTAL_BYTES)) { unsafe = true; break; }
|
|
29
|
+
}
|
|
30
|
+
if (missing) continue;
|
|
31
|
+
if (unsafe) { files[name] = null; continue; }
|
|
32
|
+
let handle;
|
|
33
|
+
try {
|
|
34
|
+
const actual = await realpath(target);
|
|
35
|
+
if (!inside(root, actual) || actual !== target) { files[name] = null; continue; }
|
|
36
|
+
handle = await open(target, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
37
|
+
const opened = await handle.stat(), current = await lstat(target);
|
|
38
|
+
if (!opened.isFile() || opened.ino !== current.ino || opened.dev !== current.dev || current.isSymbolicLink() || opened.size > MAX_ANALYSIS_FILE_BYTES || total + opened.size > MAX_ANALYSIS_TOTAL_BYTES || await realpath(target) !== target) { files[name] = null; continue; }
|
|
39
|
+
// Read at most the budget plus one, even if the file grows after stat.
|
|
40
|
+
const buffer = Buffer.alloc(MAX_ANALYSIS_FILE_BYTES + 1);
|
|
41
|
+
let count = 0;
|
|
42
|
+
while (count < buffer.length) {
|
|
43
|
+
const { bytesRead } = await handle.read(buffer, count, buffer.length - count, count);
|
|
44
|
+
if (!bytesRead) break;
|
|
45
|
+
count += bytesRead;
|
|
46
|
+
}
|
|
47
|
+
total += count;
|
|
48
|
+
if (count > MAX_ANALYSIS_FILE_BYTES || total > MAX_ANALYSIS_TOTAL_BYTES) files[name] = null;
|
|
49
|
+
else files[name] = new TextDecoder('utf-8', { fatal: true }).decode(buffer.subarray(0, count));
|
|
50
|
+
} catch { files[name] = null; }
|
|
51
|
+
finally { await handle?.close(); }
|
|
52
|
+
}
|
|
53
|
+
const core = analyzeProjectFiles(files);
|
|
54
|
+
const diagnostics = await inspectInstalledDependencies(root, files['package.json']);
|
|
55
|
+
return { ...core, version: 2, diagnostics };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function main(args) {
|
|
59
|
+
if (args.includes('--help') || args.includes('-h')) {
|
|
60
|
+
process.stdout.write('Usage: node analyze-project.mjs [project-directory] [--output report.json]\nReads allowlisted project files and exact installed package metadata locally. Does not execute code, install packages, read .env, contact websites, or upload source. Reports contain timestamped, sanitized setup evidence and dependency diagnostics, without package versions. Existing output files are never overwritten.\n');
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
let directory = null, output = null;
|
|
64
|
+
for (let i = 0; i < args.length; i++) {
|
|
65
|
+
const arg = args[i];
|
|
66
|
+
if (arg === '--output' && !output && args[i + 1] && !args[i + 1].startsWith('-')) output = args[++i];
|
|
67
|
+
else if (!arg.startsWith('-') && directory === null) directory = arg;
|
|
68
|
+
else throw Error('Use one project directory and an optional --output filename.');
|
|
69
|
+
}
|
|
70
|
+
const report = await analyzeProjectDirectory(directory ?? process.cwd());
|
|
71
|
+
const result = `${JSON.stringify(report, null, 2)}\n`;
|
|
72
|
+
if (output) await writeFile(path.resolve(output), result, { flag: 'wx', mode: 0o600 });
|
|
73
|
+
else process.stdout.write(result);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
77
|
+
main(process.argv.slice(2)).catch(() => {
|
|
78
|
+
// Filesystem and parse errors can contain paths or source. Never echo them.
|
|
79
|
+
process.stderr.write('Project analysis could not finish. Check the directory, file permissions and output filename. Existing output files are not overwritten.\n');
|
|
80
|
+
process.exitCode = 1;
|
|
81
|
+
});
|
|
82
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/** Private, read-only delivery check. Never reads .env or imports website code. */
|
|
3
|
+
import { pathToFileURL } from 'node:url';
|
|
4
|
+
|
|
5
|
+
export const DELIVERY_CHECK_TIMEOUT_MS = 10_000;
|
|
6
|
+
export const DELIVERY_CHECK_MAX_BYTES = 8 * 1024 * 1024;
|
|
7
|
+
export const DELIVERY_CHECK_MESSAGES = Object.freeze({
|
|
8
|
+
verified: 'The requested published page was returned. Website rendering, installed dependencies and content updates need separate checks.',
|
|
9
|
+
'not-published': 'No matching published page was returned. Verify the slug, locale and publication in the selected environment.',
|
|
10
|
+
'missing-key': 'Delivery was not checked. Set CMS_API_KEY in the private verification process.',
|
|
11
|
+
'configuration-error': 'Delivery was not checked. Configure a valid API origin, site, environment, server key, page slug and locale.',
|
|
12
|
+
'authorization-rejected': 'Delivery authorization was rejected. Check the server key, its environment, expiry, revocation and current issuer access.',
|
|
13
|
+
'invalid-response': 'Delivery was not verified. The API returned an unexpected, redirected, ambiguous or oversized response.',
|
|
14
|
+
'http-error': 'Delivery was not verified. The API rejected the read request or is unavailable.',
|
|
15
|
+
'transport-error': 'Delivery was not verified. The private process could not complete the API request.',
|
|
16
|
+
timeout: 'Delivery was not verified. The API request exceeded the verification deadline.',
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
20
|
+
const keyPattern = /^cms_server\.([0-9a-f-]{36})\.([0-9a-f-]{36})\.[A-Za-z0-9_-]{43}$/;
|
|
21
|
+
const object = value => !!value && typeof value === 'object' && !Array.isArray(value);
|
|
22
|
+
const invalid = Symbol('Invalid delivery response');
|
|
23
|
+
const result = status => ({ status, verified: status === 'verified', message: DELIVERY_CHECK_MESSAGES[status] });
|
|
24
|
+
const positiveBoundedInteger = (value, limit) => Number.isSafeInteger(value) && value > 0 && value <= limit;
|
|
25
|
+
|
|
26
|
+
function apiOrigin(value) {
|
|
27
|
+
if (typeof value !== 'string') return null;
|
|
28
|
+
try {
|
|
29
|
+
const url = new URL(value);
|
|
30
|
+
const loopback = ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname);
|
|
31
|
+
if (!url.username && !url.password && !url.search && !url.hash && url.pathname === '/' &&
|
|
32
|
+
(url.protocol === 'https:' || (url.protocol === 'http:' && loopback))) return url.origin;
|
|
33
|
+
} catch { /* Configuration is never echoed. */ }
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function untilAborted(pending, signal) {
|
|
38
|
+
return new Promise((resolve, reject) => {
|
|
39
|
+
const cancel = () => reject(new Error('Delivery check canceled'));
|
|
40
|
+
if (signal.aborted) { cancel(); return; }
|
|
41
|
+
signal.addEventListener('abort', cancel, { once: true });
|
|
42
|
+
pending.then(value => { signal.removeEventListener('abort', cancel); resolve(value); }, error => { signal.removeEventListener('abort', cancel); reject(error); });
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function cancelBody(response) {
|
|
47
|
+
try { if (response?.body && !response.body.locked) void response.body.cancel().catch(() => {}); }
|
|
48
|
+
catch { /* Cleanup must not replace the sanitized outcome. */ }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function boundedBody(response, maxBytes, signal, checkDeadline) {
|
|
52
|
+
const declared = response.headers.get('content-length');
|
|
53
|
+
if (declared !== null && (!/^\d+$/.test(declared) || Number(declared) > maxBytes)) throw invalid;
|
|
54
|
+
if (!response.body) throw invalid;
|
|
55
|
+
const reader = response.body.getReader(), body = new Uint8Array(maxBytes);
|
|
56
|
+
let bytes = 0;
|
|
57
|
+
try {
|
|
58
|
+
while (true) {
|
|
59
|
+
const chunk = await untilAborted(reader.read(), signal);
|
|
60
|
+
checkDeadline();
|
|
61
|
+
if (chunk.done) break;
|
|
62
|
+
bytes += chunk.value.byteLength;
|
|
63
|
+
if (bytes > maxBytes) throw invalid;
|
|
64
|
+
body.set(chunk.value, bytes - chunk.value.byteLength);
|
|
65
|
+
}
|
|
66
|
+
try { return new TextDecoder('utf-8', { fatal: true }).decode(body.subarray(0, bytes)); }
|
|
67
|
+
catch { throw invalid; }
|
|
68
|
+
} finally {
|
|
69
|
+
// Even a stalled cancellation cannot hold the verification deadline open.
|
|
70
|
+
void reader.cancel().catch(() => {});
|
|
71
|
+
reader.releaseLock();
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function publishedPage(raw, slug, locale) {
|
|
76
|
+
let envelope;
|
|
77
|
+
try { envelope = JSON.parse(raw); } catch { throw invalid; }
|
|
78
|
+
const data = envelope?.data;
|
|
79
|
+
if (!object(envelope) || !object(data) || !Array.isArray(data.items) || !Number.isSafeInteger(data.total) || data.total < 0) throw invalid;
|
|
80
|
+
if (data.items.length === 0 && data.total === 0) return 'not-published';
|
|
81
|
+
if (data.items.length !== 1 || data.total !== 1) throw invalid;
|
|
82
|
+
const item = data.items[0], document = item?.document;
|
|
83
|
+
// Check the published-delivery envelope, not renderer or full block compatibility.
|
|
84
|
+
if (!object(item) || !uuid.test(item.id) || item.kind !== 'page' || item.slug !== slug || item.locale !== locale ||
|
|
85
|
+
!Number.isSafeInteger(item.version) || item.version < 0 || typeof item.publishedAt !== 'string' || !Number.isFinite(Date.parse(item.publishedAt)) ||
|
|
86
|
+
item.preview === true || (item.status !== undefined && item.status !== 'published') || !object(document) || document.schemaVersion !== 1 ||
|
|
87
|
+
typeof document.title !== 'string' || !document.title.trim() || document.title.length > 200 ||
|
|
88
|
+
typeof document.excerpt !== 'string' || document.excerpt.length > 1000 || !Array.isArray(document.blocks) || document.blocks.length > 200) throw invalid;
|
|
89
|
+
return 'verified';
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Returns constant outcomes only; neither configuration nor response content is returned or printed. */
|
|
93
|
+
export async function verifyPublishedDelivery({ env = process.env, slug, locale = 'en', fetcher = globalThis.fetch, timeoutMs = DELIVERY_CHECK_TIMEOUT_MS, maxResponseBytes = DELIVERY_CHECK_MAX_BYTES } = {}) {
|
|
94
|
+
let origin, siteId, environmentId, key, canonicalLocale;
|
|
95
|
+
try {
|
|
96
|
+
key = env?.CMS_API_KEY;
|
|
97
|
+
if (typeof key !== 'string' || !key.trim()) return result('missing-key');
|
|
98
|
+
origin = apiOrigin(env?.CMS_API_BASE_URL); siteId = env?.CMS_SITE_ID; environmentId = env?.CMS_ENVIRONMENT_ID;
|
|
99
|
+
const keyParts = keyPattern.exec(key);
|
|
100
|
+
if (!origin || typeof siteId !== 'string' || !uuid.test(siteId) || typeof environmentId !== 'string' || !uuid.test(environmentId) ||
|
|
101
|
+
!keyParts || !uuid.test(keyParts[1]) || !uuid.test(keyParts[2]) || typeof slug !== 'string' || slug.length > 160 || !/^[a-z0-9]+(?:[-/][a-z0-9]+)*$/.test(slug) ||
|
|
102
|
+
typeof locale !== 'string' || locale.length < 2 || locale.length > 35 || typeof fetcher !== 'function' ||
|
|
103
|
+
!positiveBoundedInteger(timeoutMs, DELIVERY_CHECK_TIMEOUT_MS) || !positiveBoundedInteger(maxResponseBytes, DELIVERY_CHECK_MAX_BYTES)) return result('configuration-error');
|
|
104
|
+
canonicalLocale = Intl.getCanonicalLocales(locale)[0];
|
|
105
|
+
if (!canonicalLocale) return result('configuration-error');
|
|
106
|
+
} catch { return result('configuration-error'); }
|
|
107
|
+
const url = new URL(`/api/v1/delivery/sites/${siteId}/content`, origin);
|
|
108
|
+
url.search = new URLSearchParams({ environmentId, kind: 'page', slug, locale: canonicalLocale, limit: '2' }).toString();
|
|
109
|
+
const controller = new AbortController();
|
|
110
|
+
let timedOut = false, response;
|
|
111
|
+
const deadline = Date.now() + timeoutMs;
|
|
112
|
+
const checkDeadline = () => { if (Date.now() >= deadline) { timedOut = true; controller.abort(); throw new Error('Delivery check deadline'); } };
|
|
113
|
+
const timer = setTimeout(() => { timedOut = true; controller.abort(); }, timeoutMs);
|
|
114
|
+
try {
|
|
115
|
+
response = await untilAborted(fetcher(url, {
|
|
116
|
+
method: 'GET', headers: { Authorization: `Bearer ${key}`, Accept: 'application/json' },
|
|
117
|
+
credentials: 'omit', redirect: 'error', cache: 'no-store', referrerPolicy: 'no-referrer', signal: controller.signal,
|
|
118
|
+
}), controller.signal);
|
|
119
|
+
checkDeadline();
|
|
120
|
+
if (!(response instanceof Response) || response.redirected || ['opaque', 'opaqueredirect'].includes(response.type) ||
|
|
121
|
+
(response.url && response.url !== url.href) || (response.status >= 300 && response.status < 400)) return result('invalid-response');
|
|
122
|
+
if ([401, 403].includes(response.status)) return result('authorization-rejected');
|
|
123
|
+
if (!response.ok) return result('http-error');
|
|
124
|
+
if (response.status !== 200 || response.headers.get('content-type')?.split(';')[0]?.trim().toLowerCase() !== 'application/json') throw invalid;
|
|
125
|
+
const status = publishedPage(await boundedBody(response, maxResponseBytes, controller.signal, checkDeadline), slug, canonicalLocale);
|
|
126
|
+
checkDeadline();
|
|
127
|
+
return result(status);
|
|
128
|
+
} catch (error) {
|
|
129
|
+
return result(timedOut ? 'timeout' : error === invalid ? 'invalid-response' : 'transport-error');
|
|
130
|
+
} finally { clearTimeout(timer); controller.abort(); cancelBody(response); }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function parseDeliveryArguments(args) {
|
|
134
|
+
if (!Array.isArray(args)) return null;
|
|
135
|
+
const options = {};
|
|
136
|
+
for (let index = 0; index < args.length; index += 2) {
|
|
137
|
+
const name = args[index], value = args[index + 1];
|
|
138
|
+
if (!['--slug', '--locale'].includes(name) || typeof value !== 'string' || !value || value.startsWith('-') || Object.hasOwn(options, name.slice(2))) return null;
|
|
139
|
+
options[name.slice(2)] = value;
|
|
140
|
+
}
|
|
141
|
+
return options.slug ? options : null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function main() {
|
|
145
|
+
if (process.argv.length === 3 && process.argv[2] === '--help') {
|
|
146
|
+
process.stdout.write('Usage: check-delivery --slug <published-page-slug> [--locale <locale>]\nUses private CMS_API_BASE_URL, CMS_SITE_ID, CMS_ENVIRONMENT_ID and CMS_API_KEY. Reads published content only; does not read .env or verify website rendering.\n');
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
let outcome;
|
|
150
|
+
try {
|
|
151
|
+
const options = parseDeliveryArguments(process.argv.slice(2));
|
|
152
|
+
outcome = options ? await verifyPublishedDelivery(options) : result('configuration-error');
|
|
153
|
+
} catch { outcome = result('configuration-error'); }
|
|
154
|
+
process.stdout.write(JSON.stringify(outcome) + '\n');
|
|
155
|
+
if (!outcome.verified) process.exitCode = 1;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) await main();
|