From 4c1b93f2b51926cb71e4ce2c361e65c8f6e6af39 Mon Sep 17 00:00:00 2001 From: Chris Kehayias Date: Sun, 31 May 2026 11:58:26 -0400 Subject: [PATCH 1/2] fix(setup): headless mode, fork type-staleness warning, doc drift MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses a second clean-environment evaluation. - TOC: fix stale "#quick-setup-with-claude-code" anchor to match the renamed "Quick Setup (Automated)" heading; drop the remaining "don't have Claude Code" framing from the Manual Setup intro. - Project Structure: add scripts/setup-bootstrap.mjs (the actual entry point `pnpm setup` runs) to the diagram. - Headless/CI: add a non-interactive mode (-y/--yes/--non-interactive), also auto-enabled when stdin is not a TTY, so @inquirer/prompts no longer throws in a pipeline. It keeps existing .env.local values, auto-generates missing secrets, and applies defaults; all step 2/4/5/6 prompts are guarded. Documented under Quick Setup. - Fork type-staleness footgun: when type generation fails and committed models are used as a fallback, the warning now loudly states the committed models come from a reference tenant and may not match the user's instance, and to run `pnpm mp:generate:models` before relying on them. README's committed-models note expanded to say the same. (The reported "v18+ / Claude Code" GitHub web view was stale CDN cache; the clone is already correct — confirmed no v18 references remain.) Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 19 +++- scripts/setup.ts | 280 +++++++++++++++++++++++++++++------------------ 2 files changed, 190 insertions(+), 109 deletions(-) diff --git a/README.md b/README.md index 3a54533..2ea31df 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ Embeddable Web Component widgets for [Ministry Platform](https://www.ministrypla - [Architecture](#architecture) - [Prerequisites](#prerequisites) - [Getting Started](#getting-started) - - [Quick Setup with Claude Code](#quick-setup-with-claude-code) + - [Quick Setup (Automated)](#quick-setup-automated) - [Manual Setup](#manual-setup) - [OAuth Setup](#oauth-setup) - [Project Structure](#project-structure) @@ -132,13 +132,19 @@ pnpm setup -- --verbose # Extra output pnpm setup -- --help # Show all options ``` +**Headless / CI:** `--yes` (or `--non-interactive`) runs the full flow without prompts — it keeps existing `.env.local` values, auto-generates any missing secrets, and applies defaults. It's also auto-enabled when stdin isn't a TTY, so it won't hang in a pipeline. Provide MP credentials via `.env.local` (or your CI secret store) beforehand: + +```bash +node scripts/setup-bootstrap.mjs --yes +``` + Once setup completes, run `pnpm dev` and visit http://localhost:3000 (host app) and http://localhost:5173 (widget demo gallery). --- ### Manual Setup -If you prefer manual setup or don't have Claude Code: +If you prefer to run each step yourself instead of the automated setup: #### 1. Clone the Repository @@ -256,7 +262,13 @@ Copy the generated values to your `.env.local` as `BETTER_AUTH_SECRET` and `EMBE ### 4. Generate Ministry Platform Types -> **Note**: A full set of generated models is **committed to the repo**, so the project builds and runs without a live MP connection. Regenerating is only needed to match your own tenant's schema or after a schema change — it is not a prerequisite for a first build. +> **Note**: A full set of generated models is **committed to the repo**, so the project builds and runs without a live MP connection. They come from a **reference tenant**, so a first build always succeeds — but on a fork pointed at a different MP instance they may not match your schema (custom fields, table differences). The build will compile against the committed types either way, so **regenerate against your own instance before relying on the types**: +> +> ```bash +> pnpm mp:generate:models +> ``` +> +> The automated setup attempts this for you; if it fails (unreachable/throttled tenant) it warns and keeps the committed models rather than aborting. To regenerate TypeScript types from your Ministry Platform database schema: @@ -404,6 +416,7 @@ MPNext-Widgets/ │ ├── public/embed-sdk/ # Deployed widget bundles (hashed) + brand CSS ├── scripts/ +│ ├── setup-bootstrap.mjs # Zero-dep entry for `pnpm setup` (ensures pnpm + deps, then runs setup.ts) │ ├── setup.ts # Interactive setup CLI │ ├── hash-sdk.js # Hash + rewrite SDK bundle filenames │ └── copy-sdk.js # Copy build output into public/ diff --git a/scripts/setup.ts b/scripts/setup.ts index 5c6232f..4a3ac9a 100644 --- a/scripts/setup.ts +++ b/scripts/setup.ts @@ -32,6 +32,10 @@ interface SetupOptions { skipInstall: boolean; update: boolean; verbose: boolean; + // No prompts: use existing .env.local, auto-generate missing secrets, apply + // defaults. Set explicitly via --yes/--non-interactive, or auto-enabled when + // stdin is not a TTY (e.g. CI) so prompts don't throw. + nonInteractive: boolean; } interface StepResult { @@ -188,6 +192,7 @@ function parseArguments(): SetupOptions { skipInstall: false, update: false, verbose: false, + nonInteractive: false, }; for (const arg of args) { @@ -204,6 +209,11 @@ function parseArguments(): SetupOptions { case '--update': options.update = true; break; + case '-y': + case '--yes': + case '--non-interactive': + options.nonInteractive = true; + break; case '--verbose': options.verbose = true; break; @@ -232,6 +242,8 @@ Options: --clean Delete node_modules before install --skip-install Skip the pnpm install step --update Run "pnpm update" after install (mutates the lockfile; off by default) + -y, --yes Non-interactive: no prompts; use existing .env.local, auto-generate + --non-interactive missing secrets, apply defaults. Auto-enabled when stdin is not a TTY. --verbose Extra output -h, --help Show this help message @@ -240,6 +252,7 @@ Examples: pnpm run setup:check # Check configuration only pnpm run setup -- --clean # Clean install pnpm run setup -- --update # Also update dependencies + pnpm run setup -- --yes # Headless / CI (no prompts) `); } @@ -881,7 +894,15 @@ async function runInteractiveSetup(options: SetupOptions): Promise { printStepHeader(2, totalSteps, 'Checking project origin'); const detection = detectTemplateClone(); - if (detection.isClone) { + if (detection.isClone && options.nonInteractive) { + console.log(chalk.yellow(' ⚠ Still connected to the MPNext template repository')); + if (detection.remoteUrl) { + console.log(chalk.gray(` ${detection.remoteUrl}`)); + } + console.log(chalk.gray(' Keeping git config as-is (non-interactive)')); + warnings++; + passedSteps++; + } else if (detection.isClone) { console.log(chalk.yellow(' ⚠ This appears to be a clone of the MPNext template')); if (detection.remoteUrl) { console.log(chalk.gray(` ${detection.remoteUrl}`)); @@ -939,6 +960,10 @@ async function runInteractiveSetup(options: SetupOptions): Promise { console.log(chalk.green(' ✓ Keeping current git configuration')); passedSteps++; } + } else if (!detection.hasGit && options.nonInteractive) { + console.log(chalk.gray(' ⚠ No git repository found (skipped init, non-interactive)')); + warnings++; + passedSteps++; } else if (!detection.hasGit) { console.log(chalk.yellow(' ⚠ No git repository found')); @@ -982,10 +1007,12 @@ async function runInteractiveSetup(options: SetupOptions): Promise { if (!envFileResult.success && fs.existsSync(ENV_EXAMPLE_PATH)) { printResult(envFileResult); - const shouldCreate = await confirm({ - message: 'Create .env.local from .env.example?', - default: true, - }); + const shouldCreate = options.nonInteractive + ? true + : await confirm({ + message: 'Create .env.local from .env.example?', + default: true, + }); if (shouldCreate) { envFileResult = await createEnvFile(); @@ -1027,130 +1054,151 @@ async function runInteractiveSetup(options: SetupOptions): Promise { } } - console.log(chalk.yellow('\n Ministry Platform Configuration')); - console.log(chalk.gray(' The OIDC, API, and File URLs will be derived from your MP host')); + if (options.nonInteractive) { + // No prompts — keep whatever .env.local already provides, generate any + // missing secrets, and apply documented defaults so a CI run can proceed. + console.log( + chalk.gray(' Non-interactive — keeping .env.local values; filling secrets/defaults') + ); - const mpHost = await input({ - message: 'Enter your Ministry Platform host (e.g., mpi.ministryplatform.com):', - default: currentHost || undefined, - }); + for (const varDef of ENV_VARS) { + const existing = currentEnv.get(varDef.name); + if (existing !== undefined && existing !== '') { + continue; + } + if (varDef.autoGenerate) { + updates.set(varDef.name, await generateAuthSecret()); + console.log(chalk.green(` ✓ Generated ${varDef.name}`)); + } else if (varDef.defaultValue) { + updates.set(varDef.name, varDef.defaultValue); + console.log(chalk.green(` ✓ ${varDef.name} = ${varDef.defaultValue}`)); + } + } + } else { + console.log(chalk.yellow('\n Ministry Platform Configuration')); + console.log(chalk.gray(' The OIDC, API, and File URLs will be derived from your MP host')); - if (mpHost) { - const derived = deriveMPUrls(mpHost); - updates.set('MINISTRY_PLATFORM_BASE_URL', derived.baseUrl); - updates.set('NEXT_PUBLIC_MINISTRY_PLATFORM_FILE_URL', derived.fileUrl); + const mpHost = await input({ + message: 'Enter your Ministry Platform host (e.g., mpi.ministryplatform.com):', + default: currentHost || undefined, + }); - console.log(chalk.green(` ✓ MINISTRY_PLATFORM_BASE_URL = ${derived.baseUrl}`)); - console.log(chalk.green(` ✓ NEXT_PUBLIC_MINISTRY_PLATFORM_FILE_URL = ${derived.fileUrl}`)); - } + if (mpHost) { + const derived = deriveMPUrls(mpHost); + updates.set('MINISTRY_PLATFORM_BASE_URL', derived.baseUrl); + updates.set('NEXT_PUBLIC_MINISTRY_PLATFORM_FILE_URL', derived.fileUrl); - // Always ask for OIDC_CLIENT_ID with default - console.log(chalk.yellow('\n OAuth Client Configuration')); - const currentOidcClientId = currentEnv.get('OIDC_CLIENT_ID') || 'MPNextWidgets'; + console.log(chalk.green(` ✓ MINISTRY_PLATFORM_BASE_URL = ${derived.baseUrl}`)); + console.log(chalk.green(` ✓ NEXT_PUBLIC_MINISTRY_PLATFORM_FILE_URL = ${derived.fileUrl}`)); + } - const oidcClientId = await input({ - message: 'Enter OIDC_CLIENT_ID (OAuth client ID for user authentication):', - default: currentOidcClientId, - }); + // Always ask for OIDC_CLIENT_ID with default + console.log(chalk.yellow('\n OAuth Client Configuration')); + const currentOidcClientId = currentEnv.get('OIDC_CLIENT_ID') || 'MPNextWidgets'; - if (oidcClientId) { - updates.set('OIDC_CLIENT_ID', oidcClientId); - console.log(chalk.green(` ✓ OIDC_CLIENT_ID = ${oidcClientId}`)); - } + const oidcClientId = await input({ + message: 'Enter OIDC_CLIENT_ID (OAuth client ID for user authentication):', + default: currentOidcClientId, + }); - // Ask for OIDC_CLIENT_SECRET, showing the client ID for reference - const oidcClientSecret = await password({ - message: `Enter OIDC_CLIENT_SECRET (${oidcClientId}):`, - }); + if (oidcClientId) { + updates.set('OIDC_CLIENT_ID', oidcClientId); + console.log(chalk.green(` ✓ OIDC_CLIENT_ID = ${oidcClientId}`)); + } - if (oidcClientSecret) { - updates.set('OIDC_CLIENT_SECRET', oidcClientSecret); - console.log(chalk.green(` ✓ OIDC_CLIENT_SECRET = ********`)); - } + // Ask for OIDC_CLIENT_SECRET, showing the client ID for reference + const oidcClientSecret = await password({ + message: `Enter OIDC_CLIENT_SECRET (${oidcClientId}):`, + }); - // Always ask for MINISTRY_PLATFORM_CLIENT_ID with default - console.log(chalk.yellow('\n Ministry Platform API Client Configuration')); - const currentMpClientId = currentEnv.get('MINISTRY_PLATFORM_CLIENT_ID') || 'MPNextWidgets'; + if (oidcClientSecret) { + updates.set('OIDC_CLIENT_SECRET', oidcClientSecret); + console.log(chalk.green(` ✓ OIDC_CLIENT_SECRET = ********`)); + } - const mpClientId = await input({ - message: 'Enter MINISTRY_PLATFORM_CLIENT_ID (API client ID for data access):', - default: currentMpClientId, - }); + // Always ask for MINISTRY_PLATFORM_CLIENT_ID with default + console.log(chalk.yellow('\n Ministry Platform API Client Configuration')); + const currentMpClientId = currentEnv.get('MINISTRY_PLATFORM_CLIENT_ID') || 'MPNextWidgets'; - if (mpClientId) { - updates.set('MINISTRY_PLATFORM_CLIENT_ID', mpClientId); - console.log(chalk.green(` ✓ MINISTRY_PLATFORM_CLIENT_ID = ${mpClientId}`)); - } + const mpClientId = await input({ + message: 'Enter MINISTRY_PLATFORM_CLIENT_ID (API client ID for data access):', + default: currentMpClientId, + }); - // Ask for MINISTRY_PLATFORM_CLIENT_SECRET, showing the client ID for reference - const mpClientSecret = await password({ - message: `Enter MINISTRY_PLATFORM_CLIENT_SECRET (${mpClientId}):`, - }); + if (mpClientId) { + updates.set('MINISTRY_PLATFORM_CLIENT_ID', mpClientId); + console.log(chalk.green(` ✓ MINISTRY_PLATFORM_CLIENT_ID = ${mpClientId}`)); + } - if (mpClientSecret) { - updates.set('MINISTRY_PLATFORM_CLIENT_SECRET', mpClientSecret); - console.log(chalk.green(` ✓ MINISTRY_PLATFORM_CLIENT_SECRET = ********`)); - } + // Ask for MINISTRY_PLATFORM_CLIENT_SECRET, showing the client ID for reference + const mpClientSecret = await password({ + message: `Enter MINISTRY_PLATFORM_CLIENT_SECRET (${mpClientId}):`, + }); - // Variables handled specially (skip in regular loop) - const speciallyHandledVars = [ - ...mpDerivedVars, - 'OIDC_CLIENT_ID', - 'OIDC_CLIENT_SECRET', - 'MINISTRY_PLATFORM_CLIENT_ID', - 'MINISTRY_PLATFORM_CLIENT_SECRET', - ]; + if (mpClientSecret) { + updates.set('MINISTRY_PLATFORM_CLIENT_SECRET', mpClientSecret); + console.log(chalk.green(` ✓ MINISTRY_PLATFORM_CLIENT_SECRET = ********`)); + } - // Now check for other missing/empty required variables - // eslint-disable-next-line prefer-const - let { result: envVarsResult, missing, empty } = validateEnvVars(); + // Variables handled specially (skip in regular loop) + const speciallyHandledVars = [ + ...mpDerivedVars, + 'OIDC_CLIENT_ID', + 'OIDC_CLIENT_SECRET', + 'MINISTRY_PLATFORM_CLIENT_ID', + 'MINISTRY_PLATFORM_CLIENT_SECRET', + ]; - if (!envVarsResult.success) { - printResult(envVarsResult); + // Now check for other missing/empty required variables + const { result: prelim, missing, empty } = validateEnvVars(); - const issues = [...missing, ...empty]; + if (!prelim.success) { + printResult(prelim); - // Process remaining variables (skip the ones we already handled) - for (const varDef of issues) { - // Skip variables that were handled specially - if (speciallyHandledVars.includes(varDef.name)) { - continue; - } + const issues = [...missing, ...empty]; - console.log(chalk.yellow(`\n ${varDef.name}: ${varDef.description}`)); + // Process remaining variables (skip the ones we already handled) + for (const varDef of issues) { + // Skip variables that were handled specially + if (speciallyHandledVars.includes(varDef.name)) { + continue; + } - if (varDef.autoGenerate) { - const shouldGenerate = await confirm({ - message: `Auto-generate ${varDef.name}?`, - default: true, - }); + console.log(chalk.yellow(`\n ${varDef.name}: ${varDef.description}`)); - if (shouldGenerate) { - const secret = await generateAuthSecret(); - updates.set(varDef.name, secret); - console.log(chalk.green(` ✓ Generated ${varDef.name}`)); - } else { + if (varDef.autoGenerate) { + const shouldGenerate = await confirm({ + message: `Auto-generate ${varDef.name}?`, + default: true, + }); + + if (shouldGenerate) { + const secret = await generateAuthSecret(); + updates.set(varDef.name, secret); + console.log(chalk.green(` ✓ Generated ${varDef.name}`)); + } else { + const value = await password({ + message: `Enter ${varDef.name}:`, + }); + if (value) { + updates.set(varDef.name, value); + } + } + } else if (varDef.sensitive) { const value = await password({ message: `Enter ${varDef.name}:`, }); if (value) { updates.set(varDef.name, value); } - } - } else if (varDef.sensitive) { - const value = await password({ - message: `Enter ${varDef.name}:`, - }); - if (value) { - updates.set(varDef.name, value); - } - } else { - const value = await input({ - message: `Enter ${varDef.name}:`, - default: varDef.defaultValue, - }); - if (value) { - updates.set(varDef.name, value); + } else { + const value = await input({ + message: `Enter ${varDef.name}:`, + default: varDef.defaultValue, + }); + if (value) { + updates.set(varDef.name, value); + } } } } @@ -1163,7 +1211,7 @@ async function runInteractiveSetup(options: SetupOptions): Promise { // Re-validate after all updates const revalidation = validateEnvVars(); - envVarsResult = revalidation.result; + const envVarsResult = revalidation.result; if (envVarsResult.success) { printResult(envVarsResult); passedSteps++; @@ -1182,7 +1230,7 @@ async function runInteractiveSetup(options: SetupOptions): Promise { if (options.clean || !fs.existsSync(NODE_MODULES_PATH)) { let doClean = options.clean; - if (!options.clean && fs.existsSync(NODE_MODULES_PATH)) { + if (!options.clean && !options.nonInteractive && fs.existsSync(NODE_MODULES_PATH)) { doClean = await confirm({ message: 'Perform clean install (delete node_modules)?', default: false, @@ -1253,9 +1301,21 @@ async function runInteractiveSetup(options: SetupOptions): Promise { } else if (countFilesInDir(MODELS_PATH) > 0) { // Models are committed to the repo, so the build does not depend on a // live MP connection. Treat a failed regen (e.g. unreachable tenant) as a - // warning rather than a fatal error. + // warning rather than a fatal error — but make the staleness risk loud: + // the committed models come from a reference tenant and may not match a + // fork's own instance (custom fields, etc.) until a successful regen. + console.log( + chalk.yellow(' ⚠ Type generation failed — falling back to committed models/') + ); + console.log( + chalk.yellow( + ' These are from a reference tenant and may NOT match your instance.' + ) + ); console.log( - chalk.yellow(' ⚠ Type generation failed; using committed models/ (regen later)') + chalk.yellow( + ' Run `pnpm mp:generate:models` against your MP instance before relying on the types.' + ) ); if (!options.verbose && generateResult.output) { console.log(chalk.gray(generateResult.output.slice(0, 500))); @@ -1331,6 +1391,14 @@ async function main(): Promise { if (options.check) { exitCode = runCheckMode(); } else { + // Prompts (inquirer) throw without a TTY, so auto-enable non-interactive + // mode in CI / piped shells rather than crashing mid-run. + if (!options.nonInteractive && !process.stdin.isTTY) { + options.nonInteractive = true; + console.log( + chalk.yellow('No interactive terminal detected — running in non-interactive mode.') + ); + } exitCode = await runInteractiveSetup(options); } From 5e7f25586f74e4c6ab835655245258275c77457f Mon Sep 17 00:00:00 2001 From: Chris Kehayias Date: Sun, 31 May 2026 12:00:14 -0400 Subject: [PATCH 2/2] chore(next): sync generated next-env.d.ts to .next/dev/types path Next.js 16 (Turbopack) regenerates next-env.d.ts to import from ./.next/dev/types/routes.d.ts. Committing the regenerated reference so the tracked file matches the installed Next version and the working tree stays clean (this file is generated, not hand-edited). Co-Authored-By: Claude Opus 4.8 (1M context) --- next-env.d.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/next-env.d.ts b/next-env.d.ts index 9edff1c..c4b7818 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/types/routes.d.ts"; +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.