@universalcms/connect 0.13.0 → 0.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,7 +1,7 @@
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.13.0 is
4
+ package creates a website integration, not a CMS installation. Version 0.13.1 is
5
5
  prepared locally; this repository does not establish its public npm availability.
6
6
 
7
7
  ## Quick start
@@ -15,6 +15,11 @@ this package, connects pages, header/footer and settings, saves the website key
15
15
  privately in `.env.local`, then guides the restart, content import and final check.
16
16
  Run it again to continue where it stopped. Without a browser, create a one-time
17
17
  `--code` command in Developer setup. See [connector setup](../../docs/connector-setup.md#one-command-connection-recommended).
18
+ When no page can be edited automatically, the command offers a settings-only
19
+ connection (Next.js): the CMS manages menus, branding, SEO, analytics, scripts,
20
+ consent and redirects while the site renders every page. Add
21
+ `<CmsNavigation location="header" />` from `@universalcms/connect/react-website`
22
+ where CMS menus should appear.
18
23
  The individual commands below remain for developers and reviewed integrations.
19
24
 
20
25
  Supported templates: **Next.js 16 App Router on Node**, **React 19.2+ with
@@ -257,7 +262,7 @@ custom adapter still requires review, not forced overwriting.
257
262
  For an operator's downloaded artifact, the installation is:
258
263
 
259
264
  ```sh
260
- npm install --save-prod ./vendor/universalcms-connect-0.13.0.tgz
265
+ npm install --save-prod ./vendor/universalcms-connect-0.13.1.tgz
261
266
  ```
262
267
 
263
268
  Use your project's package manager. These paths are examples for downloaded
@@ -534,7 +539,7 @@ npx --no-install universalcms-connect init \
534
539
  --environment 22222222-2222-4222-8222-222222222222
535
540
  ```
536
541
 
537
- After a public release, `npx @universalcms/connect@0.13.0 init` is the equivalent
542
+ After a public release, `npx @universalcms/connect@0.13.1 init` is the equivalent
538
543
  registry entry point. Copy the real IDs/API origin from **Developer setup**.
539
544
  Missing values are prompted in a terminal; `--non-interactive` fails instead.
540
545
  Init prints a plan. Run the same command with `--apply` after reviewing it.
@@ -584,7 +589,7 @@ The CMS button reads the website's **`/api/cms-source`** endpoint. Current Next.
584
589
  prepares delivery scaffolding. React/Vite setup serves its reviewed public manifest through the normal dev command.
585
590
  Custom exporters need their explicit adapter; a delivery check alone is insufficient.
586
591
 
587
- Use the CMS's reviewed 0.13.0 package for the staged commands and current source
592
+ Use the CMS's reviewed 0.13.1 package for the staged commands and current source
588
593
  discovery. Review source diagnostics and complete public
589
594
  mappings for unsupported pages. Restart the website using its normal server command, then run:
590
595
 
package/dist/cli.mjs CHANGED
@@ -20,7 +20,7 @@ import { WEBSITE_SECTIONS } from './website-http.mjs';
20
20
  import { setupSource, applySourcePlan, syncSource, readSourceSettings } from './source-setup.mjs';
21
21
  import { processIo, runGuided } from './guided.mjs';
22
22
 
23
- const help = `Universal CMS connector 0.13.0
23
+ const help = `Universal CMS connector 0.13.1
24
24
 
25
25
  Quick start (recommended) — run in your website folder:
26
26
  npx @universalcms/connect@latest --cms https://YOUR_CMS_ADDRESS
@@ -12,7 +12,7 @@ import { CONTENT_BINDINGS_FILE, contentDigest, readContentRegistry, readFrozenCo
12
12
  import { inspectReviewedNativeOwnership } from './reviewed-native-ownership.mjs';
13
13
  import { matchesNativeRoutes } from './native-routes.mjs';
14
14
  import { inspectReviewedCommerceOwnership } from './reviewed-commerce-ownership.mjs';
15
- import { setupReviewError } from './preflight-recovery.mjs';
15
+ import { setupReviewError, withReviewPaths } from './preflight-recovery.mjs';
16
16
  const json = value => `${JSON.stringify(value, null, 2)}\n`;
17
17
  const fail = message => { throw new SetupError(message); };
18
18
  const relative = (from, to) => { const value = path.posix.relative(path.posix.dirname(from), to); return value.startsWith('.') ? value : `./${value}`; };
@@ -54,7 +54,12 @@ function rewriteNextContentConfiguration(source, filename) {
54
54
  const file = ts.createSourceFile(filename, source, ts.ScriptTarget.Latest, true, filename.endsWith('.ts') ? ts.ScriptKind.TS : ts.ScriptKind.JS);
55
55
  const unbox = value => { while (value && (ts.isAsExpression(value) || ts.isSatisfiesExpression(value) || ts.isParenthesizedExpression(value))) value = value.expression; return value; };
56
56
  const exported = file.statements.find(statement => ts.isExportAssignment(statement));
57
- let object = unbox(exported?.expression);
57
+ // CommonJS configs (`module.exports = {...}` or an inert const alias) are the
58
+ // same literal object in another export form.
59
+ const commonjs = exported ? undefined : file.statements.filter(ts.isExpressionStatement).map(statement => statement.expression)
60
+ .filter(expression => ts.isBinaryExpression(expression) && expression.operatorToken.kind === ts.SyntaxKind.EqualsToken && ts.isPropertyAccessExpression(expression.left) && ts.isIdentifier(expression.left.expression) && expression.left.expression.text === 'module' && expression.left.name.text === 'exports');
61
+ if (commonjs && commonjs.length > 1) fail('Multiple Next configuration exports need manual review.');
62
+ let object = unbox(exported?.expression ?? commonjs?.[0]?.right);
58
63
  if (object && ts.isIdentifier(object)) {
59
64
  const name = object.text;
60
65
  const declarations = file.statements.filter(ts.isVariableStatement).flatMap(statement => [...statement.declarationList.declarations]);
@@ -173,7 +178,9 @@ export async function planNextContent(directory, configuration) {
173
178
  const sourceFile = inventory.files[route.path === '/' ? 'home' : route.path.slice(1)];
174
179
  if (!sourceFile || !/\.[jt]sx$/.test(sourceFile)) fail('Automatic content bindings require a JSX/TSX source page for each route.');
175
180
  const source = await readProjectFile(root, sourceFile);
176
- const transformed = transformSourcePublication({ source, slug: route.slug, locale: route.locale, filename: sourceFile, publicSource: true });
181
+ let transformed;
182
+ try { transformed = transformSourcePublication({ source, slug: route.slug, locale: route.locale, filename: sourceFile, publicSource: true }); }
183
+ catch (error) { throw withReviewPaths(error, [sourceFile]); }
177
184
  // Projection is part of planning, so unsupported source structure cannot
178
185
  // wait until a running page serializes its client provider properties.
179
186
  try { publicBindingInventory(transformed.inventory); }
package/dist/guided.mjs CHANGED
@@ -19,6 +19,9 @@ import { readSetupEnvironment } from './setup-environment.mjs';
19
19
  import { readSourceSettings } from './source-setup.mjs';
20
20
  import { checkPublication } from './publication-check.mjs';
21
21
  import { finalizeSource } from './source-finalize.mjs';
22
+ import { preflightSetup } from './preflight.mjs';
23
+ import { planSettingsOnly, SETTINGS_ONLY_FILE } from './settings-only-setup.mjs';
24
+ import { applyEnvironment, planEnvironment } from './setup-environment.mjs';
22
25
 
23
26
  export const PAIRING_WAIT_MS = 10 * 60 * 1000;
24
27
  export const WEBSITE_WAIT_MS = 15 * 60 * 1000;
@@ -110,17 +113,125 @@ async function reviewLayout(root, manifest, framework, io) {
110
113
  } else if (framework === 'Next.js') {
111
114
  const { planSettingsLayoutReview } = await import('./next-settings-layout.mjs');
112
115
  replan = () => planSettingsLayoutReview(root);
113
- } else return;
116
+ } else return false;
114
117
  plan = await replan();
115
- } catch { return; }
118
+ } catch { return false; }
116
119
  const changes = plan.files.filter(file => ['create', 'update'].includes(file.state));
117
- if (!changes.length) return;
120
+ if (!changes.length) return false;
118
121
  const sources = [...new Set((plan.slots ?? plan.pages ?? []).map(item => item.path))];
119
122
  io.write(`\nYour website already has its own header and footer${sources.length ? ` (${sources.join(', ')})` : ''}.\nI'll keep them exactly as they are and add places where menus and settings from the CMS can appear.\n`);
120
123
  if (!yes(await io.ask('Okay? [Y/n] '))) fail('Nothing was changed. Run the same command again when you are ready.');
121
124
  if (JSON.stringify(await replan()) !== JSON.stringify(plan)) fail('Your website files changed while reviewing. Nothing was saved; run the same command again.');
122
125
  await applyPlan(plan);
123
126
  io.write('✔ Header and footer reviewed\n');
127
+ return true;
128
+ }
129
+
130
+ // Static paths that are usually interactive application screens rather than
131
+ // editable pages. They are proposed as website-owned; the person can change it.
132
+ export const ALL_APP_SCREENS = 'Every page in this website is an interactive app screen (built with state, tabs or data lists), so none of its page text can be made editable automatically yet.';
133
+ const appScreen = /(^|\/)(cart|basket|bag|checkout|payment|pay|orders?|order-history|track(ing)?|account|my-account|profile|settings|login|log-in|signin|sign-in|signup|sign-up|register|logout|auth|dashboard|admin|portal|wishlist|favou?rites|search|upload[\w-]*|book(ing)?s?|reservations?|messages|inbox|notifications|api)(\/|$)/i;
134
+
135
+ /** Propose which routes stay part of the website app; ask once; save the reviewed manifest. */
136
+ async function reviewRoutes(root, blocker, io, reason) {
137
+ const { inspectNativeRouteCandidates, planNativeRouteReview } = await import('./native-route-review.mjs');
138
+ let inventory;
139
+ try { inventory = await inspectNativeRouteCandidates(root); } catch { return false; }
140
+ const files = new Set(blocker?.paths ?? []);
141
+ const flagged = new Set([...files].map(name => name.replace(/^(?:src\/)?app/, '')));
142
+ for (const row of inventory.candidates) if (files.has(row.file)) flagged.add(row.path);
143
+ const existing = await readProjectFile(root, '.universalcms/native.routes.json').then(text => text ? JSON.parse(text) : null).catch(() => null);
144
+ for (const path of [...existing?.nativePaths ?? [], ...existing?.nativePatterns ?? []]) flagged.add(path);
145
+ // Client components render from state, so their text cannot be bound to CMS content.
146
+ const client = new Set();
147
+ for (const row of inventory.candidates) {
148
+ const text = await readProjectFile(root, row.file).catch(() => null);
149
+ if (text && /^\s*(?:\/\/[^\n]*\n|\/\*[\s\S]*?\*\/\s*)*['"]use client['"]/.test(text)) client.add(row.path);
150
+ }
151
+ let owned = inventory.candidates.filter(row => row.kind === 'dynamic' || flagged.has(row.path) || client.has(row.path) || appScreen.test(row.path)).map(row => row.path);
152
+ if (!inventory.candidates.some(row => row.kind === 'static' && !owned.includes(row.path)) && inventory.candidates.filter(row => row.kind === 'static').every(row => client.has(row.path) || flagged.has(row.path) || appScreen.test(row.path)) && inventory.candidates.some(row => client.has(row.path) || flagged.has(row.path))) throw new SetupError(ALL_APP_SCREENS);
153
+ const cmsPages = () => inventory.candidates.filter(row => row.kind === 'static' && !owned.includes(row.path));
154
+ if (!cmsPages().length) {
155
+ if (reason) throw new SetupError(ALL_APP_SCREENS);
156
+ owned = owned.filter(path => path !== '/');
157
+ }
158
+ const show = () => {
159
+ io.write('\nI looked at every page in your website:\n');
160
+ inventory.candidates.forEach((row, index) => io.write(` ${String(index + 1).padStart(2)}. ${row.path === '/' ? '/ (home page)' : row.path}${row.kind === 'dynamic' ? ' (one page per item)' : client.has(row.path) && owned.includes(row.path) ? ' (interactive)' : ''} — ${owned.includes(row.path) ? 'stays as part of your website app' : 'editable in the CMS'}\n`));
161
+ };
162
+ if (reason) io.write(`\n${reason}\n`);
163
+ show();
164
+ io.write('Pages that stay part of your website app keep working exactly as they do now (shopping, accounts, forms, product details).\nAll other pages become editable in the CMS. Header, footer and site settings are managed in the CMS either way.\n');
165
+ while (true) {
166
+ const answer = (await io.ask('Use this? [Y/n, or type page numbers to switch them, e.g. 3,5] ')).trim();
167
+ if (yes(answer)) break;
168
+ if (/^n(o)?$/i.test(answer)) fail('Nothing was changed. Run the same command again when you are ready.');
169
+ if (!/^\d+(\s*,\s*\d+)*$/.test(answer)) { io.write('Type Y, n, or page numbers separated by commas.\n'); continue; }
170
+ for (const index of answer.split(',').map(value => Number(value.trim()) - 1)) {
171
+ const row = inventory.candidates[index];
172
+ if (!row) continue;
173
+ owned = owned.includes(row.path) ? owned.filter(path => path !== row.path) : [...owned, row.path];
174
+ }
175
+ if (!cmsPages().length) { io.write('At least one page must stay editable in the CMS.\n'); owned = owned.filter(path => path !== '/'); }
176
+ show();
177
+ }
178
+ if (!owned.length) return false;
179
+ const plan = await planNativeRouteReview(root, owned);
180
+ if (JSON.stringify(await planNativeRouteReview(root, owned)) !== JSON.stringify(plan)) fail('Your website files changed while reviewing. Nothing was saved; run the same command again.');
181
+ await applyPlan(plan);
182
+ io.write('✔ Website app pages reviewed\n');
183
+ return true;
184
+ }
185
+
186
+ /** Plain-language guidance for what to change in the website when a blocker cannot be fixed automatically. */
187
+ export function siteChangeAdvice(blocker) {
188
+ const message = blocker?.message ?? '', paths = blocker?.paths ?? [];
189
+ const where = paths.length ? ` (${paths.slice(0, 6).join(', ')}${paths.length > 6 ? ', …' : ''})` : '';
190
+ if (/next\.config|Configuration needs developer inspection|Dynamic Next configuration|configuration uses values/i.test(message)) return [`Your Next.js config is computed when it runs, so it can't be read safely. Make next.config export a plain object, for example:\n module.exports = { reactStrictMode: true };`];
191
+ if (/static export|output:\s*'export'/i.test(message)) return ['Your site is built as static files only. The CMS needs your site to run its Node server: remove `output: "export"` from next.config and deploy with `next start` (or another Node host).'];
192
+ if (/Pages Router|pages\//.test(message)) return ['Your site uses the older Next.js Pages Router (pages/ folder). Move the pages you want editable into the App Router (app/ folder), or ask your developer for a custom integration.'];
193
+ if (/Workspaces|workspace/i.test(message)) return ['Run this command inside the single website folder (the one with its own package.json), not the monorepo root.'];
194
+ if (/version|Node\.js|supported/i.test(message) && /requires?|supports?|unsupported/i.test(message)) return [`A framework or Node.js version is outside the supported range. ${message}`];
195
+ if (message === ALL_APP_SCREENS) return [message, 'To make a page editable from the CMS, write its main text directly in the page file (headings, paragraphs, images as plain JSX in a server component, without \'use client\'), and keep interactive parts like carts, tabs and product lists in separate components. Then run this command again.', 'Ask your developer if you need a custom integration for data-driven pages.'];
196
+ if (blocker?.code === 'content-bindings-review' || /content adapter|bindings|repeated|data-driven|route props/i.test(message)) return [`Some pages${where} build their text from data, props or loops, so the CMS can't tell which words to make editable. Either keep those pages as part of your website app (rerun and switch them), or write the editable text directly in the page (headings, paragraphs and images as plain JSX).`];
197
+ if (/header|footer|layout|chrome/i.test(message)) return [`Your header or footer${where} is built from components or logic the CMS can't safely extend. Put the header and footer markup directly in the root layout (plain <header>/<footer> elements), then rerun.`];
198
+ if (/route|dynamic|parallel/i.test(message)) return [`Some routes${where} use patterns that need a developer: catch-all, parallel or intercepted routes. Keep them in your app and ask your developer for a reviewed adapter.`];
199
+ return ['This website uses a pattern the automatic setup does not support yet.'];
200
+ }
201
+
202
+ /**
203
+ * Check the website before anything is installed, fix what can be fixed safely
204
+ * with one plain question each, and explain the rest. Resolving one finding can
205
+ * reveal the next, so it loops a bounded number of times.
206
+ */
207
+ async function prepareWebsite(root, connection, manifest, framework, io) {
208
+ const scope = Object.fromEntries(['apiOrigin', 'siteId', 'environmentId', 'locale', 'websiteOrigin', 'adminOrigin'].map(name => [name, connection[name]]));
209
+ const attempted = new Set();
210
+ for (let round = 0; round < 6; round++) {
211
+ const result = await preflightSetup(root, scope);
212
+ if (result.status === 'planned') return 'full';
213
+ const blocker = result.blockers?.[0];
214
+ const key = `${blocker?.code}:${blocker?.message}:${(blocker?.paths ?? []).join(',')}`;
215
+ let advice = blocker;
216
+ if (blocker && !attempted.has(key)) try {
217
+ attempted.add(key);
218
+ if (blocker.code === 'next-route-ownership' && await reviewRoutes(root, blocker, io)) continue;
219
+ if (blocker.code === 'content-bindings-review' && framework === 'Next.js' && blocker.paths?.length && await reviewRoutes(root, blocker, io, `${blocker.paths.join(', ')} builds its text from data or loops, so its words can't be made editable automatically. I've switched it to stay part of your website app (it keeps working as it does now).`)) continue;
220
+ if (['native-chrome-layout', 'native-settings-layout'].includes(blocker.code) && await reviewLayout(root, manifest, framework, io)) continue;
221
+ } catch (error) { if (error?.message !== ALL_APP_SCREENS) throw error; advice = { message: ALL_APP_SCREENS }; }
222
+ // Pages that cannot be edited automatically can still have their site
223
+ // settings managed by the CMS while the website keeps rendering every page.
224
+ if (framework === 'Next.js' && (advice?.message === ALL_APP_SCREENS || ['content-bindings-review', 'next-route-ownership'].includes(blocker?.code))) {
225
+ io.write(`\n${advice?.message === ALL_APP_SCREENS ? ALL_APP_SCREENS : 'Some pages in this website can\'t be made editable automatically.'}\n`);
226
+ io.write('You can still connect it for site settings: the CMS manages your menus, logo and branding, SEO (titles, descriptions, social previews), analytics, scripts, cookie consent and redirects. Every page keeps working exactly as it does now.\n');
227
+ if (yes(await io.ask('Connect for site settings? [Y/n] '))) return 'settings-only';
228
+ }
229
+ io.write('\n✋ Your website needs a small change before it can connect:\n');
230
+ for (const line of siteChangeAdvice(advice)) io.write(` • ${line}\n`);
231
+ io.write(`\nDetails for your developer:\n ${result.message ?? blocker?.message ?? 'Preflight did not pass.'}\n`);
232
+ fail('Your website files were not changed (only the Universal CMS package was added). Make the change above, then run the same command again.');
233
+ }
234
+ fail('Setup found more items to review than it can handle automatically. Ask your developer to run the step-by-step setup in Developer setup.');
124
235
  }
125
236
 
126
237
  function pkce() {
@@ -141,7 +252,11 @@ export async function waitForBrowserApproval({ cms, request, io, timeoutMs = PAI
141
252
  const valid = returned.length === expected.length && timingSafeEqual(returned, expected) && codePattern.test(code);
142
253
  res.writeHead(valid ? 200 : 400, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store', 'Referrer-Policy': 'no-referrer' });
143
254
  res.end(valid ? page('Approved — back to your terminal', 'You can close this tab. Setup continues in your terminal.') : page('This link is not valid', 'Start the connection again from your terminal.'));
144
- if (valid) settle.resolve(code);
255
+ if (valid) {
256
+ let scope = null;
257
+ try { scope = validateConnection({ version: 1, ...Object.fromEntries(['apiOrigin', 'siteId', 'environmentId', 'locale', 'websiteOrigin', 'adminOrigin'].map(name => [name, url.searchParams.get(name) ?? undefined])) }); } catch { /* Older CMS: check after redemption. */ }
258
+ settle.resolve({ code, scope });
259
+ }
145
260
  });
146
261
  await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve); });
147
262
  const timer = setTimeout(() => settle.reject(new SetupError('No approval arrived within 10 minutes. Run the same command again to start over.')), timeoutMs);
@@ -152,7 +267,8 @@ export async function waitForBrowserApproval({ cms, request, io, timeoutMs = PAI
152
267
  io.write('\nOpening your browser to approve this website…\n');
153
268
  if (!await io.openUrl(link)) io.write('Your browser did not open. Open this link to continue:\n');
154
269
  io.write(` ${link}\n\nWaiting for approval… (no browser on this computer? In the CMS open Developer setup → “Connect without a browser”)\n`);
155
- return { code: await result, verifier };
270
+ const { code, scope } = await result;
271
+ return { code, verifier, scope };
156
272
  } finally { clearTimeout(timer); server.close(); }
157
273
  }
158
274
 
@@ -264,6 +380,10 @@ export async function runGuided(directory, options, io) {
264
380
  const saved = await readConnection(root);
265
381
  const environment = await readSetupEnvironment(root);
266
382
  const installed = await readProjectFile(root, '.universalcms/website.integration.json') !== null;
383
+ if (saved && environment.keyConfigured && !options.code && await readProjectFile(root, SETTINGS_ONLY_FILE) !== null) {
384
+ io.write(`✔ Already connected for site settings (${saved.websiteOrigin})\n`);
385
+ return finishSettingsOnly(root, saved, manifest, io);
386
+ }
267
387
  if (saved && installed && environment.keyConfigured && !options.code) {
268
388
  io.write(`✔ Already set up for ${saved.websiteOrigin}\n`);
269
389
  return goLive(root, saved, await manifestOf(root), io);
@@ -279,15 +399,25 @@ export async function runGuided(directory, options, io) {
279
399
  if (options.code !== undefined && !codePattern.test(options.code)) fail('That connection code is not valid. Copy the whole command from the CMS again.');
280
400
 
281
401
  await ensureConnector(root, manifest, io);
282
- if (!installed) await reviewLayout(root, manifest, framework, io);
283
402
  const approval = options.code
284
403
  ? { code: options.code, verifier: undefined }
285
404
  : await waitForBrowserApproval({ cms, io, request: { origin: saved?.websiteOrigin ?? suggestedOrigin(manifest, framework) ?? '', project: path.basename(root).slice(0, 80), framework: framework ?? '' } });
286
- const paired = await redeem(cms, approval.code, approval.verifier, io.fetch);
287
- io.write(`✔ Approved: ${paired.siteName || 'your website'} (${paired.connection.websiteOrigin})\n`);
288
- if (saved && (saved.siteId !== paired.connection.siteId || saved.environmentId !== paired.connection.environmentId)) {
289
- fail('This folder is already connected to a different website or environment in the CMS. Choose the same website when approving, or ask your developer to review .universalcms/connection.json. Nothing was changed.');
405
+ let mode = 'full';
406
+ const mismatch = connection => saved && (saved.siteId !== connection.siteId || saved.environmentId !== connection.environmentId);
407
+ const different = 'This folder is already connected to a different website or environment in the CMS. Choose the same website when approving, or ask your developer to review .universalcms/connection.json. Nothing was changed.';
408
+ // Check (and safely fix) the website before redeeming, so a blocked site
409
+ // never leaves an unused key behind. The approval expires on its own.
410
+ if (approval.scope && !installed) {
411
+ if (mismatch(approval.scope)) fail(different);
412
+ io.write('✔ Approved in the CMS — checking your website…\n');
413
+ mode = await prepareWebsite(root, approval.scope, manifest, framework, io);
290
414
  }
415
+ const paired = await redeem(cms, approval.code, approval.verifier, io.fetch);
416
+ io.write(`✔ Connected to ${paired.siteName || 'your website'} (${paired.connection.websiteOrigin})\n`);
417
+ if (mismatch(paired.connection)) fail(different);
418
+ if (approval.scope && JSON.stringify(approval.scope) !== JSON.stringify(paired.connection)) fail('The approved website changed before connecting. Nothing was changed; run the same command again.');
419
+ if (!approval.scope && !installed) mode = await prepareWebsite(root, paired.connection, manifest, framework, io);
420
+ if (mode === 'settings-only') return runSettingsOnly(root, paired, manifest, options, io);
291
421
 
292
422
  const adapter = setupIo(io, paired.serverKey, options.details === true);
293
423
  try {
@@ -312,6 +442,55 @@ export async function runGuided(directory, options, io) {
312
442
  return goLive(root, paired.connection, await manifestOf(root), io);
313
443
  }
314
444
 
445
+ /** Ask the running website's settings endpoint, as the CMS admin does. */
446
+ async function settingsReady(connection, io) {
447
+ try {
448
+ const url = new URL('/api/universalcms/settings', connection.websiteOrigin);
449
+ url.search = new URLSearchParams({ check: 'connection', siteId: connection.siteId, environmentId: connection.environmentId }).toString();
450
+ const response = await io.fetch(url, { headers: { Origin: connection.adminOrigin, Accept: 'application/json' }, redirect: 'error', signal: AbortSignal.timeout(10000) });
451
+ const body = response.ok ? await response.json() : null;
452
+ return body?.mode === 'settings-only' && body.siteId === connection.siteId && body.environmentId === connection.environmentId;
453
+ } catch { return false; }
454
+ }
455
+
456
+ async function finishSettingsOnly(root, connection, manifest, io) {
457
+ if (!await settingsReady(connection, io)) {
458
+ const local = /^http:\/\/(localhost|127\.0\.0\.1|\[::1\])(:|$)/.test(connection.websiteOrigin), command = await restartHint(root, manifest);
459
+ io.write(local
460
+ ? `\n→ Restart your website so the changes take effect:\n 1. In the terminal where your website is running, press Ctrl+C\n 2. Start it again${command ? ` with: ${command}` : ''}\n It must run at ${connection.websiteOrigin}\n`
461
+ : `\n→ Deploy your website the way you normally do. It must be reachable at ${connection.websiteOrigin}\n`);
462
+ io.write(` Waiting for ${connection.websiteOrigin} … (Ctrl+C stops waiting; run this command again later to continue)\n`);
463
+ const deadline = io.now() + WEBSITE_WAIT_MS;
464
+ let ready = false;
465
+ while (!ready && io.now() < deadline) { await io.sleep(3000); ready = await settingsReady(connection, io); }
466
+ if (!ready) { io.write(`\nThe website at ${connection.websiteOrigin} is not serving CMS settings yet. Run this command again once it's running.\n`); return { connected: false, mode: 'settings-only' }; }
467
+ }
468
+ const settings = `${connection.adminOrigin}/sites/${connection.siteId}`;
469
+ io.write(`✔ Your website is connected for site settings\n\n🎉 All done! Manage it from Universal CMS:\n ${settings}\n`);
470
+ io.write(' • SEO, branding, analytics, scripts, cookie consent and redirects apply automatically.\n');
471
+ io.write(' • To show the CMS menu in your own header or footer, add one line where it should appear:\n');
472
+ io.write(" import { CmsNavigation } from '@universalcms/connect/react-website';\n <CmsNavigation location=\"header\" /> (or location=\"footer\")\n");
473
+ io.write(' Other drop-ins: <CmsBranding />, <CmsSocialLinks />, and useWebsiteSettings() for anything custom.\n');
474
+ return { connected: true, mode: 'settings-only', done: true };
475
+ }
476
+
477
+ /** Install the settings-only integration and save the website key privately. */
478
+ async function runSettingsOnly(root, paired, manifest, options, io) {
479
+ const plan = await planSettingsOnly(root, Object.fromEntries(['apiOrigin', 'siteId', 'environmentId', 'locale', 'websiteOrigin', 'adminOrigin'].map(name => [name, paired.connection[name]])));
480
+ const environment = await planEnvironment(root, { ...Object.fromEntries(['apiOrigin', 'siteId', 'environmentId', 'locale', 'websiteOrigin', 'adminOrigin'].map(name => [name, paired.connection[name]])), apiKey: paired.serverKey });
481
+ const added = plan.files.filter(file => file.state === 'create').length, changed = plan.files.filter(file => file.state === 'update').length;
482
+ io.write(`\nReady to connect your website for site settings:\n • ${added} file${added === 1 ? '' : 's'} will be added${changed ? ` and ${changed} updated (your root layout gets one wrapper)` : ''} — your pages and design stay the same\n • The website key is saved privately in .env.local (never committed to Git)\n`);
483
+ if (options.details) for (const file of plan.files) io.write(` ${file.state}: ${file.path}\n`);
484
+ else io.write(' (run with --details to list every file)\n');
485
+ if (!yes(await io.ask('\nGo ahead? [Y/n] '))) { io.write('Cancelled. Nothing was changed.\n'); return { connected: false }; }
486
+ const current = await planSettingsOnly(root, plan.connection);
487
+ if (JSON.stringify(current.files) !== JSON.stringify(plan.files)) fail('Your website files changed while reviewing. Nothing was saved; run the same command again.');
488
+ await applyEnvironment(environment);
489
+ await applyPlan(plan);
490
+ io.write('✔ Website files and private key saved\n');
491
+ return finishSettingsOnly(root, paired.connection, await manifestOf(root), io);
492
+ }
493
+
315
494
  /** Process-backed effects for the CLI. */
316
495
  export function processIo({ ask, write }) {
317
496
  return {
@@ -60,6 +60,14 @@ export function setupReviewError(code, paths = []) {
60
60
  return error;
61
61
  }
62
62
 
63
+ /** Attach sanitized source paths to a fixed planner reason, keeping its message and code. */
64
+ export function withReviewPaths(error, paths) {
65
+ const fixed = error instanceof SetupError && fixedReasons.get(error.message);
66
+ if (!fixed || diagnostics.has(error)) return error;
67
+ diagnostics.set(error, { ...fixed, message: error.message, ...sanitizeSetupPaths(paths) });
68
+ return error;
69
+ }
70
+
63
71
  export function setupReviewDiagnostic(error) {
64
72
  const value = diagnostics.get(error);
65
73
  if (value) return { ...value, paths: [...value.paths] };
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Plans a settings-only connection for a Next.js App Router website: the site
3
+ * keeps every page, and the CMS manages navigation, branding, SEO, analytics,
4
+ * scripts, consent and other site settings. Only additive files plus one
5
+ * wrapper around the root layout's <body> children are written.
6
+ */
7
+ import ts from './vendor/typescript.cjs';
8
+ import { projectRoot, readProjectFile, SetupError } from './project-files.mjs';
9
+ import { inspectSourceRoutes } from './source-routes.mjs';
10
+ import { CONNECTION_FILE, validateConnection } from './staged-setup.mjs';
11
+
12
+ export const SETTINGS_ONLY_FILE = '.universalcms/settings-only.json';
13
+ const fail = message => { throw new SetupError(message); };
14
+ const json = value => `${JSON.stringify(value, null, 2)}\n`;
15
+ const relative = (from, to) => { const up = from.split('/').length - 1; return `${'../'.repeat(up) || './'}${to}`; };
16
+ const MARK = '// Generated by @universalcms/connect (settings-only).';
17
+
18
+ async function appDirectory(root) {
19
+ const inventory = await inspectSourceRoutes(root);
20
+ const first = Object.values(inventory.files)[0];
21
+ if (!first) fail('No App Router pages were found. Settings-only connection supports Next.js App Router websites.');
22
+ return first.startsWith('src/app/') ? 'src/app' : 'app';
23
+ }
24
+
25
+ function wrapBody(source, filename) {
26
+ const ast = ts.createSourceFile(filename, source, ts.ScriptTarget.Latest, true, /x$/.test(filename) ? ts.ScriptKind.TSX : ts.ScriptKind.JSX);
27
+ if (ast.parseDiagnostics.length) fail(`${filename} could not be read. Fix its syntax, then run the command again.`);
28
+ if (ast.statements[0] && ts.isExpressionStatement(ast.statements[0]) && ts.isStringLiteral(ast.statements[0].expression) && ast.statements[0].expression.text === 'use client') fail(`${filename} is a client component. The root layout must be a server component (remove 'use client' from the root layout and move interactive parts into their own components).`);
29
+ const bodies = [];
30
+ (function visit(node) { if (ts.isJsxElement(node) && node.openingElement.tagName.getText(ast) === 'body') bodies.push(node); ts.forEachChild(node, visit); })(ast);
31
+ if (bodies.length !== 1) fail(`${filename} needs exactly one literal <body> element so site settings can wrap the page. Write <body>…</body> directly in the root layout.`);
32
+ const body = bodies[0];
33
+ let insertion = 0;
34
+ for (const statement of ast.statements) { if (ts.isImportDeclaration(statement) || ts.isExpressionStatement(statement) && ts.isStringLiteral(statement.expression)) insertion = statement.end; else break; }
35
+ const edits = [
36
+ { at: body.closingElement.pos, text: '</UniversalCmsSettings>' },
37
+ { at: body.openingElement.end, text: '<UniversalCmsSettings>' },
38
+ { at: insertion, text: `\nimport UniversalCmsSettings from './universalcms.settings';` },
39
+ ];
40
+ for (const edit of edits.sort((a, b) => b.at - a.at)) source = source.slice(0, edit.at) + edit.text + source.slice(edit.at);
41
+ return source;
42
+ }
43
+
44
+ const settingsRoute = (app, file) => `${MARK} Site settings served to the website; no private value is returned.
45
+ import { createSettingsOnlyHandler } from '@universalcms/connect/settings-only';
46
+ import configuration from '${relative(file, SETTINGS_ONLY_FILE)}';
47
+ import connection from '${relative(file, CONNECTION_FILE)}';
48
+
49
+ const handle = createSettingsOnlyHandler({ configuration: configuration as Parameters<typeof createSettingsOnlyHandler>[0]['configuration'], adminOrigin: connection.adminOrigin });
50
+ export const dynamic = 'force-dynamic';
51
+ export const GET = handle;
52
+ export const HEAD = handle;
53
+ export const OPTIONS = handle;
54
+ `;
55
+
56
+ const wrapperTemplate = (file, typed) => `${MARK} Supplies CMS site settings to the whole website.
57
+ import { connection } from 'next/server';
58
+ ${typed ? "import type { ReactNode } from 'react';\n" : ''}import { WebsiteProvider } from '@universalcms/connect/react-website';
59
+ import { createSettingsOnlyRuntime } from '@universalcms/connect/settings-only';
60
+ import '@universalcms/connect/website.css';
61
+ import configuration from '${relative(file, SETTINGS_ONLY_FILE)}';
62
+
63
+ const runtime = createSettingsOnlyRuntime({ configuration${typed ? ': configuration as Parameters<typeof createSettingsOnlyRuntime>[0][\'configuration\']' : ''} });
64
+ export default async function UniversalCmsSettings({ children }${typed ? ': { children: ReactNode }' : ''}) {
65
+ await connection();
66
+ const settings = await runtime.settings();
67
+ return <WebsiteProvider scope=${'{'}${'`'}\${configuration.siteId}:\${configuration.environmentId}${'`'}${'}'} initialSettings={settings}>{children}</WebsiteProvider>;
68
+ }
69
+ `;
70
+
71
+ /** @returns {Promise<{ root: string, files: { path: string, content: string, state: string, previous?: string }[] }>} */
72
+ export async function planSettingsOnly(directory, connectionInput) {
73
+ const root = await projectRoot(directory), connection = validateConnection({ version: 1, ...connectionInput });
74
+ const app = await appDirectory(root);
75
+ const layouts = [];
76
+ for (const ext of ['tsx', 'jsx', 'js', 'ts']) if (await readProjectFile(root, `${app}/layout.${ext}`) !== null) layouts.push(`${app}/layout.${ext}`);
77
+ if (layouts.length !== 1) fail(`Settings-only connection needs one root layout file in ${app}/.`);
78
+ const layout = layouts[0], typed = /\.tsx?$/.test(layout), ext = typed ? 'ts' : 'js';
79
+ const files = [];
80
+ const add = async (path, content) => {
81
+ const current = await readProjectFile(root, path);
82
+ if (current !== null && current !== content && !current.startsWith(MARK)) fail(`${path} already exists. Rename or remove it, then run the command again.`);
83
+ files.push({ path, content, state: current === null ? 'create' : current === content ? 'unchanged' : 'update', ...(current !== null && current !== content ? { previous: current } : {}) });
84
+ };
85
+ await add(CONNECTION_FILE, json(connection));
86
+ await add(SETTINGS_ONLY_FILE, json({ version: 1, apiOrigin: connection.apiOrigin, siteId: connection.siteId, environmentId: connection.environmentId, websiteOrigin: connection.websiteOrigin, routes: [] }));
87
+ for (const name of ['settings', 'page']) { const path = `${app}/api/universalcms/${name}/route.${ext}`; await add(path, settingsRoute(app, path).replace(typed ? '' : / as Parameters<[^>]+>\[0\]\['configuration'\]/, '')); }
88
+ const wrapper = `${app}/universalcms.settings.${typed ? 'tsx' : 'jsx'}`;
89
+ await add(wrapper, wrapperTemplate(wrapper, typed));
90
+ const source = await readProjectFile(root, layout);
91
+ if (!source.includes("import UniversalCmsSettings from './universalcms.settings';")) {
92
+ const content = wrapBody(source, layout);
93
+ files.push({ path: layout, content, state: 'update', previous: source });
94
+ } else files.push({ path: layout, content: source, state: 'unchanged' });
95
+ return { root, connection, app, files };
96
+ }
97
+
98
+ export async function isSettingsOnly(directory) {
99
+ return await readProjectFile(await projectRoot(directory), SETTINGS_ONLY_FILE) !== null;
100
+ }
@@ -0,0 +1,4 @@
1
+ export const SETTINGS_ONLY_MODE: 'settings-only';
2
+ export type SettingsOnlyConfiguration = { version: 1; apiOrigin: string; siteId: string; environmentId: string; websiteOrigin: string; routes: [] };
3
+ export function createSettingsOnlyHandler(options: { configuration: SettingsOnlyConfiguration; adminOrigin: string; env?: () => Record<string, string | undefined>; fetcher?: typeof fetch }): (request: Request) => Promise<Response>;
4
+ export function createSettingsOnlyRuntime(options: { configuration: SettingsOnlyConfiguration; env?: () => Record<string, string | undefined>; fetcher?: typeof fetch }): { settings(): Promise<unknown> };
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Settings-only website runtime. The website keeps rendering every page itself;
3
+ * the CMS supplies header/footer navigation, branding, SEO, analytics, scripts,
4
+ * consent and other site settings. It serves the same settings endpoint as a
5
+ * full integration and reports every page as website-owned.
6
+ */
7
+ import { createWebsiteRuntime } from './website-runtime.mjs';
8
+ import { WEBSITE_PAGE_PATH, WEBSITE_SECTIONS, WEBSITE_SETTINGS_PATH } from './website-http.mjs';
9
+
10
+ export const SETTINGS_ONLY_MODE = 'settings-only';
11
+ const base = { 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff', 'Referrer-Policy': 'no-referrer' };
12
+
13
+ /**
14
+ * @param {{ configuration: { version: 1, apiOrigin: string, siteId: string, environmentId: string, websiteOrigin: string, routes: [] }, adminOrigin: string, env?: () => Record<string, string | undefined>, fetcher?: typeof fetch }} options
15
+ */
16
+ export function createSettingsOnlyHandler({ configuration, adminOrigin, env = () => process.env, fetcher = globalThis.fetch }) {
17
+ const runtime = createWebsiteRuntime({ configuration, settingsOnly: true, env, fetcher });
18
+ const admin = new URL(adminOrigin).origin;
19
+ if (admin !== adminOrigin) throw new Error('Invalid CMS admin origin');
20
+ return async function handle(request) {
21
+ const method = request.method, url = new URL(request.url), origin = request.headers.get('origin');
22
+ const reply = (body, status = 200, extra = {}) => new Response(method === 'HEAD' || status === 204 ? null : JSON.stringify(body), { status, headers: { ...base, 'Content-Type': 'application/json', ...extra } });
23
+ if (url.pathname === WEBSITE_PAGE_PATH) {
24
+ // Every page belongs to the website in settings-only mode.
25
+ if (!['GET', 'HEAD'].includes(method)) return reply({ error: 'Method not allowed' }, 405, { Allow: 'GET, HEAD' });
26
+ return reply({ state: 'missing' }, 404);
27
+ }
28
+ if (url.pathname !== WEBSITE_SETTINGS_PATH) return reply({ error: 'Not found' }, 404);
29
+ const diagnostic = url.searchParams.get('check') === 'connection';
30
+ if (diagnostic) {
31
+ // The CMS admin reads this without credentials; nothing private is returned.
32
+ if (origin !== admin || request.headers.has('authorization') || request.headers.has('cookie')) return reply({ error: 'Request not allowed' }, 403);
33
+ const cors = { 'Access-Control-Allow-Origin': admin, Vary: 'Origin', 'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS' };
34
+ if (method === 'OPTIONS') return request.headers.get('access-control-request-method') === 'GET' && !request.headers.has('access-control-request-headers') ? reply(null, 204, cors) : reply({ error: 'Request not allowed' }, 403);
35
+ if (url.searchParams.size !== 3 || url.searchParams.get('siteId') !== configuration.siteId || url.searchParams.get('environmentId') !== configuration.environmentId) return reply({ error: 'Website scope mismatch' }, 409, cors);
36
+ const snapshot = await runtime.settings();
37
+ return reply({ version: 1, mode: SETTINGS_ONLY_MODE, siteId: configuration.siteId, environmentId: configuration.environmentId, renderedVerified: false,
38
+ settings: Object.fromEntries(WEBSITE_SECTIONS.map(section => [section, { installed: true, state: snapshot.sections[section]?.state ?? 'unavailable', version: snapshot.sections[section]?.version ?? 0 }])) }, 200, cors);
39
+ }
40
+ if (!['GET', 'HEAD'].includes(method)) return reply({ error: 'Method not allowed' }, 405, { Allow: 'GET, HEAD' });
41
+ if (url.searchParams.size || origin && origin !== configuration.websiteOrigin || request.headers.get('sec-fetch-site') === 'cross-site') return reply({ error: 'Request not allowed' }, 403);
42
+ return reply(await runtime.settings());
43
+ };
44
+ }
45
+
46
+ /** Server-side settings snapshot for the generated root layout wrapper. */
47
+ export function createSettingsOnlyRuntime({ configuration, env = () => process.env, fetcher = globalThis.fetch }) {
48
+ return createWebsiteRuntime({ configuration, settingsOnly: true, env, fetcher });
49
+ }
@@ -197,10 +197,12 @@ function literalConfig(source, wrapper) {
197
197
  if (begin < 0) return { literals: {}, properties: {}, opaque: true, list };
198
198
  if (wrapper && list[begin]?.value === wrapper && list[begin + 1]?.value === '(') begin += 2;
199
199
  let aliasEnd = null;
200
- // create-next-app emits a typed const followed by `export default config`.
201
- // Resolve that one inert alias, never a function call, mutable assignment,
202
- // imported runtime value, second declaration or conditional configuration.
203
- if (!wrapper && list[begin]?.type === 'name' && list[begin - 1]?.value === 'default') {
200
+ // create-next-app emits a typed const followed by `export default config`
201
+ // (or `module.exports = config` in its JavaScript template). Resolve that one
202
+ // inert alias, never a function call, mutable assignment, imported runtime
203
+ // value, second declaration or conditional configuration.
204
+ const commonjs = list[begin - 1]?.value === '=' && list[begin - 2]?.value === 'exports' && list[begin - 3]?.value === '.' && list[begin - 4]?.value === 'module';
205
+ if (!wrapper && list[begin]?.type === 'name' && (list[begin - 1]?.value === 'default' || commonjs)) {
204
206
  const name = list[begin].value, declaration = list.findIndex(token => token.value === 'const');
205
207
  const prefix = list.slice(0, declaration);
206
208
  const typeImport = prefix.length === 0 || prefix[0]?.value === 'import' && prefix[1]?.value === 'type' && prefix.filter(token => token.value === 'import').length === 1 && prefix.filter(token => token.value === 'from').length === 1 && prefix.at(-1)?.value === ';' && prefix.at(-2)?.type === 'string' && prefix.at(-2)?.value === 'next' && prefix.at(-3)?.value === 'from' && prefix.slice(2,-3).every(token => token.type === 'name' || ['{','}',','].includes(token.value));
@@ -213,7 +215,7 @@ function literalConfig(source, wrapper) {
213
215
  }
214
216
  const tail = list.slice(begin + 1);
215
217
  if (declaration < 0 || !typeImport || list[declaration + 1]?.value !== name || list[initial]?.value !== '=' || list[initial + 1]?.value !== '{' || tail.some(token => token.value !== ';')) return { literals: {}, properties: {}, opaque: true, list };
216
- aliasEnd = begin - 2;
218
+ aliasEnd = commonjs ? begin - 4 : begin - 2;
217
219
  begin = initial + 1;
218
220
  }
219
221
  if (list[begin]?.value !== '{') return { literals: {}, properties: {}, opaque: true, list };
@@ -56,9 +56,16 @@ function publicEntry(raw, config) {
56
56
  }
57
57
  const summary = entry => ({ id: entry.id, kind: entry.kind, slug: entry.slug, locale: entry.locale, path: entry.path, title: entry.document.title, excerpt: entry.document.excerpt, publishedAt: entry.publishedAt });
58
58
 
59
- export function createWebsiteRuntime({ configuration, env = process.env, fetcher = globalThis.fetch, nativePaths = [], nativePatterns = [], nativePathPrefixes = [], installedSections = websiteSections, timeoutMs = 10_000, maxResponseBytes = 8 * 1024 * 1024, maxConcurrent = 16 } = {}) {
59
+ function settingsOnlyConfig(value) {
60
+ if (!value || typeof value !== 'object' || !Array.isArray(value.routes) || value.routes.length) throw new Error('Invalid settings-only configuration');
61
+ // Reuse the full publication validator for origins and scope, then drop the probe route.
62
+ return { ...validatePublicationConfig({ ...value, routes: [{ path: '/', kind: 'page', slug: 'home', locale: 'en' }] }), routes: [] };
63
+ }
64
+
65
+ export function createWebsiteRuntime({ configuration, settingsOnly = false, env = process.env, fetcher = globalThis.fetch, nativePaths = [], nativePatterns = [], nativePathPrefixes = [], installedSections = websiteSections, timeoutMs = 10_000, maxResponseBytes = 8 * 1024 * 1024, maxConcurrent = 16 } = {}) {
60
66
  if (typeof window !== 'undefined') throw new Error('Website configuration requires a private server runtime.');
61
- const config = validatePublicationConfig(configuration);
67
+ // Settings-only websites own every route; their configuration has no routes.
68
+ const config = settingsOnly ? settingsOnlyConfig(configuration) : validatePublicationConfig(configuration);
62
69
  if (typeof fetcher !== 'function' || !Array.isArray(nativePaths) || nativePaths.length > 1000 || !integer(timeoutMs, 1, 10000) || !integer(maxResponseBytes, 1, 8 * 1024 * 1024) || !integer(maxConcurrent, 1, 16)) return fail();
63
70
  const native = new Set(nativePaths.map(normalizePublicationPath));
64
71
  if (!Array.isArray(nativePatterns)) return fail();
@@ -209,7 +216,8 @@ export function createWebsiteRuntime({ configuration, env = process.env, fetcher
209
216
  const url=new URL(value,config.websiteOrigin);
210
217
  if(url.origin!==config.websiteOrigin)return {external:true};
211
218
  const path=normalizePublicationPath(url.pathname);
212
- return {path,native:isNative(path)||isExcludedPublicationPath(path)};
219
+ // In settings-only mode the website renders every path itself.
220
+ return {path,native:settingsOnly||isNative(path)||isExcludedPublicationPath(path)};
213
221
  }catch{return null;}
214
222
  }
215
223
  async function publishedNavigation(sections,inventory) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@universalcms/connect",
3
- "version": "0.13.0",
3
+ "version": "0.13.1",
4
4
  "description": "Deterministic framework-aware Universal CMS setup for reviewed native websites",
5
5
  "type": "module",
6
6
  "bin": {
@@ -30,7 +30,8 @@
30
30
  "./svelte-runtime": "./dist/svelte-runtime.mjs",
31
31
  "./static-runtime": "./dist/static-runtime.mjs",
32
32
  "./website.css": "./dist/website.css",
33
- "./public-bindings": "./dist/public-bindings.mjs"
33
+ "./public-bindings": "./dist/public-bindings.mjs",
34
+ "./settings-only": "./dist/settings-only.mjs"
34
35
  },
35
36
  "files": [
36
37
  "dist",
@@ -41,7 +42,7 @@
41
42
  },
42
43
  "scripts": {
43
44
  "build": "node scripts/build.mjs",
44
- "typecheck": "node --check src/cli.mjs && node --check src/core.mjs && node --check src/project-files.mjs && node --check src/templates.mjs && node --check src/vite-handler.mjs && node --check src/wizard.mjs && node --check src/guided.mjs && node --check src/framework.mjs && node --check src/framework-ownership.mjs && node --check src/preflight.mjs && node --check src/preflight-recovery.mjs && node --check src/native-route-review.mjs && node --check src/page-discovery.mjs && node --check src/page-mapping-review.mjs && node --check src/native-chrome-model.mjs && node --check src/native-chrome-source.mjs && node --check src/native-chrome-review.mjs && node --check src/next-settings-layout.mjs && node --check src/vite-source-discovery.mjs && node --check src/reviewed-storefront-source.mjs && node --check src/reviewed-commerce-ownership.mjs && node --check src/native-routes.mjs && node --check src/reviewed-native-ownership.mjs && node --check src/runtime-dependencies.mjs && node --check src/connector-runtime.mjs && node --check src/staged-setup.mjs && node --check src/setup-environment.mjs && node --check src/source-check.mjs && node --check src/source-finalize.mjs && node --check src/source-routes.mjs && node --check src/source-config.mjs && node --check src/source-collect.mjs && node --check src/source-extract.mjs && node --check src/source-setup.mjs && node --check src/next-source.mjs && node --check src/vite-source.mjs && node --check src/publication.mjs && node --check src/publication-verification.mjs && node --check src/publication-check.mjs && node --check src/publication-document.mjs && node --check src/publication-browser.mjs && node --check src/publication-setup.mjs && node --check src/publication-next-setup.mjs && node --check src/next-publication.mjs && node --check src/vite-publication.mjs && node --check src/vite-publication-server.mjs && node --check src/source-bindings.mjs && node --check src/content-registry.mjs && node --check src/content-registry-validation.mjs && node --check src/content-next-setup.mjs && node --check src/content-vite-setup.mjs && node --check src/content-storefront-setup.mjs && node --check src/content-setup.mjs && tsc --noEmit -p tsconfig.react.json && node --check src/website-http.mjs && node --check src/website-node.mjs && node --check src/website-next.mjs && node --check src/website-setup.mjs && node --check src/reviewed-commerce-settings-setup.mjs && node --check src/website-runtime.mjs && node --check src/website-check.mjs && node --check src/astro-source.mjs && node --check src/astro-setup.mjs && node --check src/astro-runtime.mjs && node --check src/native-adapters.mjs && node --check src/native-website-model.mjs && node --check src/vue-source.mjs && node --check src/vue-setup.mjs && node --check src/vue-runtime.mjs && node --check src/vue-server.mjs && node --check src/svelte-template.mjs && node --check src/svelte-setup.mjs && node --check src/svelte-runtime.mjs && node --check src/angular-setup.mjs && node --check src/angular-runtime.mjs && node --check src/static-setup.mjs && node --check src/static-runtime.mjs",
45
+ "typecheck": "node --check src/cli.mjs && node --check src/core.mjs && node --check src/project-files.mjs && node --check src/templates.mjs && node --check src/vite-handler.mjs && node --check src/wizard.mjs && node --check src/guided.mjs && node --check src/settings-only.mjs && node --check src/settings-only-setup.mjs && node --check src/framework.mjs && node --check src/framework-ownership.mjs && node --check src/preflight.mjs && node --check src/preflight-recovery.mjs && node --check src/native-route-review.mjs && node --check src/page-discovery.mjs && node --check src/page-mapping-review.mjs && node --check src/native-chrome-model.mjs && node --check src/native-chrome-source.mjs && node --check src/native-chrome-review.mjs && node --check src/next-settings-layout.mjs && node --check src/vite-source-discovery.mjs && node --check src/reviewed-storefront-source.mjs && node --check src/reviewed-commerce-ownership.mjs && node --check src/native-routes.mjs && node --check src/reviewed-native-ownership.mjs && node --check src/runtime-dependencies.mjs && node --check src/connector-runtime.mjs && node --check src/staged-setup.mjs && node --check src/setup-environment.mjs && node --check src/source-check.mjs && node --check src/source-finalize.mjs && node --check src/source-routes.mjs && node --check src/source-config.mjs && node --check src/source-collect.mjs && node --check src/source-extract.mjs && node --check src/source-setup.mjs && node --check src/next-source.mjs && node --check src/vite-source.mjs && node --check src/publication.mjs && node --check src/publication-verification.mjs && node --check src/publication-check.mjs && node --check src/publication-document.mjs && node --check src/publication-browser.mjs && node --check src/publication-setup.mjs && node --check src/publication-next-setup.mjs && node --check src/next-publication.mjs && node --check src/vite-publication.mjs && node --check src/vite-publication-server.mjs && node --check src/source-bindings.mjs && node --check src/content-registry.mjs && node --check src/content-registry-validation.mjs && node --check src/content-next-setup.mjs && node --check src/content-vite-setup.mjs && node --check src/content-storefront-setup.mjs && node --check src/content-setup.mjs && tsc --noEmit -p tsconfig.react.json && node --check src/website-http.mjs && node --check src/website-node.mjs && node --check src/website-next.mjs && node --check src/website-setup.mjs && node --check src/reviewed-commerce-settings-setup.mjs && node --check src/website-runtime.mjs && node --check src/website-check.mjs && node --check src/astro-source.mjs && node --check src/astro-setup.mjs && node --check src/astro-runtime.mjs && node --check src/native-adapters.mjs && node --check src/native-website-model.mjs && node --check src/vue-source.mjs && node --check src/vue-setup.mjs && node --check src/vue-runtime.mjs && node --check src/vue-server.mjs && node --check src/svelte-template.mjs && node --check src/svelte-setup.mjs && node --check src/svelte-runtime.mjs && node --check src/angular-setup.mjs && node --check src/angular-runtime.mjs && node --check src/static-setup.mjs && node --check src/static-runtime.mjs",
45
46
  "prepack": "node scripts/build.mjs"
46
47
  },
47
48
  "publishConfig": {