diff --git a/README.md b/README.md index 6e37d9c..b022dcd 100644 --- a/README.md +++ b/README.md @@ -94,36 +94,39 @@ Two layers, used by different surfaces: ## Prerequisites - **Node.js**: v20.9 or higher (required by Next.js 16; CI runs on Node 22) -- **Package Manager**: **pnpm 10.x** (the `preinstall` guard refuses `npm install` / `yarn install`) +- **Package Manager**: **pnpm 10.x** (the `preinstall` guard refuses `npm install` / `yarn install`). The version is pinned via `packageManager` in `package.json` — enable it with **`corepack enable`** (Corepack ships with Node) and the correct pnpm is fetched automatically. No manual install needed. - **Ministry Platform**: Active instance with API credentials and an OAuth client configured (see [OAuth Setup](#oauth-setup)) ## Getting Started -### Quick Setup with Claude Code +### Quick Setup (Automated) -If you have [Claude Code](https://claude.ai/code) installed, the setup process is automated: +An interactive setup command walks you through the whole process. It bootstraps itself — if pnpm or `node_modules` are missing, it installs them first, so this works on a clean clone: ```bash git clone https://github.com/MinistryPlatform-Community/MPNext-Widgets.git cd MPNext-Widgets +corepack enable # one-time: makes the pinned pnpm available pnpm setup ``` The interactive setup command will: -1. Verify Node.js version (v18+ required) -2. Check git status -3. Create `.env.local` from `.env.example` (if needed) -4. Prompt for missing environment variables (MP host, OAuth client, secrets) -5. Auto-generate `BETTER_AUTH_SECRET` and `EMBED_JWT_SECRET` (optional) -6. Install workspace dependencies -7. Generate Ministry Platform types -8. Run a production build to verify configuration +1. Verify Node.js version (v20.9+ required) +2. Check the git origin (offer to fork or re-init if it's still the template repo) +3. Check git status (warn on uncommitted changes) +4. Create `.env.local` from `.env.example` (if needed) +5. Prompt for environment variables (MP host, OAuth client, secrets) and auto-generate `BETTER_AUTH_SECRET` / `EMBED_JWT_SECRET` +6. Install workspace dependencies (`pnpm install`) +7. Optionally update dependencies (only with `--update`; skipped by default to preserve the lockfile) +8. Generate Ministry Platform types (a warning, not a failure, when committed types already exist) +9. Run a production build to verify configuration **Additional setup options:** ```bash pnpm setup:check # Validation only (no changes) pnpm setup -- --clean # Clean install (delete node_modules first) -pnpm setup -- --skip-install # Skip pnpm install/update +pnpm setup -- --skip-install # Skip pnpm install +pnpm setup -- --update # Also run pnpm update (mutates the lockfile) pnpm setup -- --verbose # Extra output pnpm setup -- --help # Show all options ``` @@ -145,7 +148,10 @@ cd MPNext-Widgets #### 2. Install Dependencies +If you don't already have pnpm, enable it via Corepack (installs the version pinned in `package.json`): + ```bash +corepack enable pnpm install ``` @@ -176,6 +182,10 @@ MINISTRY_PLATFORM_BASE_URL=https://your-instance.ministryplatform.com/ministrypl NEXT_PUBLIC_MINISTRY_PLATFORM_FILE_URL=https://your-instance.ministryplatform.com/ministryplatformapi/files NEXT_PUBLIC_APP_NAME=MPNext-Widgets +# Organization name baked into the embed SDK at build time (e.g. SMS opt-in +# consent text). Unset falls back to "our organization". Consumed by Vite. +VITE_ORG_NAME= + # Embed Widgets # Generate with: node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))" EMBED_JWT_SECRET=your_generated_secret @@ -245,7 +255,9 @@ Copy the generated values to your `.env.local` as `BETTER_AUTH_SECRET` and `EMBE ### 4. Generate Ministry Platform Types -Before running the application, generate TypeScript types from your Ministry Platform database schema: +> **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. + +To regenerate TypeScript types from your Ministry Platform database schema: ```bash pnpm mp:generate:models @@ -300,6 +312,7 @@ pnpm dev - **"Invalid client"**: Check OAuth client ID and secret - **Widget 401 / CORS error**: Confirm `EMBED_ALLOWED_ORIGINS` includes the host page origin and `EMBED_JWT_SECRET` is set - **Auto-login after logout**: Verify post-logout redirect URIs are configured in the MP OAuth client (OIDC RP-initiated logout requires these) +- **Native build script errors (esbuild / sharp / unrs-resolver)**: pnpm 10 blocks dependency build scripts by default. If a postinstall step is required (e.g. `sharp` for some Next.js image paths), approve them with `pnpm approve-builds` ### Production Deployment diff --git a/package.json b/package.json index 8c9c61c..50dbd62 100644 --- a/package.json +++ b/package.json @@ -23,8 +23,8 @@ "mp:generate": "tsx src/lib/providers/ministry-platform/scripts/generate-types.ts", "mp:generate:models": "tsx src/lib/providers/ministry-platform/scripts/generate-types.ts -o src/lib/providers/ministry-platform/models --zod --clean", "dev:demo": "pnpm build:sdk && next dev", - "setup": "tsx scripts/setup.ts", - "setup:check": "tsx scripts/setup.ts --check", + "setup": "node scripts/setup-bootstrap.mjs", + "setup:check": "node scripts/setup-bootstrap.mjs --check", "test": "vitest", "test:run": "vitest run", "test:coverage": "vitest run --coverage", diff --git a/scripts/setup-bootstrap.mjs b/scripts/setup-bootstrap.mjs new file mode 100644 index 0000000..ac70713 --- /dev/null +++ b/scripts/setup-bootstrap.mjs @@ -0,0 +1,78 @@ +#!/usr/bin/env node + +/** + * MPNext Setup Bootstrap + * + * Zero-dependency entry point for `pnpm setup` / `pnpm setup:check`. + * + * The full setup CLI (scripts/setup.ts) depends on devDependencies (chalk, + * @inquirer/prompts) that do not exist on a fresh clone. Running it directly + * crashes with "Cannot find module 'chalk'". This bootstrap uses only Node + * built-ins, so it always runs, ensures pnpm + dependencies are present, then + * hands off to setup.ts once its devDependencies are available. + * + * Usage (via package.json scripts): + * pnpm setup -> node scripts/setup-bootstrap.mjs + * pnpm setup:check -> node scripts/setup-bootstrap.mjs --check + * pnpm setup -- --clean -> node scripts/setup-bootstrap.mjs --clean + */ + +import { existsSync } from 'node:fs'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import * as path from 'node:path'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const PROJECT_ROOT = path.resolve(__dirname, '..'); +const NODE_MODULES_PATH = path.join(PROJECT_ROOT, 'node_modules'); +const forwardedArgs = process.argv.slice(2); + +function run(command, args) { + const result = spawnSync(command, args, { + cwd: PROJECT_ROOT, + stdio: 'inherit', + shell: true, + }); + return result.status ?? 1; +} + +// 1. Node version gate (matches package.json "engines": ">=20.9"). +const major = Number(process.version.replace(/^v/, '').split('.')[0]); +if (Number.isNaN(major) || major < 20) { + console.error( + `\nMPNext setup requires Node.js v20.9 or later (found ${process.version}).\n` + + `Install a newer Node (e.g. via nvm) and re-run.\n` + ); + process.exit(1); +} + +// 2. Ensure pnpm is available. corepack ships with Node and installs the +// version pinned in package.json "packageManager". +const pnpmCheck = spawnSync('pnpm', ['--version'], { + shell: true, + stdio: 'ignore', +}); +if (pnpmCheck.status !== 0) { + console.log('pnpm not found on PATH — enabling it via corepack...'); + if (run('corepack', ['enable']) !== 0) { + console.error( + '\nCould not enable pnpm via corepack. Install pnpm manually, e.g.:\n' + + ' corepack enable (recommended — uses the pinned version)\n' + + ' npm install -g pnpm\n' + ); + process.exit(1); + } +} + +// 3. Install dependencies if missing, so setup.ts can load its devDependencies. +if (!existsSync(NODE_MODULES_PATH)) { + console.log('Installing dependencies (first run, this can take a minute)...'); + const code = run('pnpm', ['install']); + if (code !== 0) { + console.error('\npnpm install failed — see the output above.\n'); + process.exit(code); + } +} + +// 4. Hand off to the full setup CLI; it is now safe to import chalk/inquirer. +process.exit(run('pnpm', ['exec', 'tsx', 'scripts/setup.ts', ...forwardedArgs])); diff --git a/scripts/setup.ts b/scripts/setup.ts index 6d5ba66..5c6232f 100644 --- a/scripts/setup.ts +++ b/scripts/setup.ts @@ -30,6 +30,7 @@ interface SetupOptions { check: boolean; clean: boolean; skipInstall: boolean; + update: boolean; verbose: boolean; } @@ -76,25 +77,43 @@ const NEXT_BUILD_PATH = path.join(PROJECT_ROOT, '.next'); const REQUIRED_NODE_VERSION = 20; -// Patterns to detect if this is a clone of the MPNext template repository +// `owner/repo` slugs that identify a clone still pointing at an upstream +// template repository (rather than the developer's own fork). const TEMPLATE_REPO_PATTERNS = [ + 'MinistryPlatform-Community/MPNext-Widgets', 'MinistryPlatform-Community/mpnext', 'MinistryPlatform-Community/ccm-pwa', ]; +// Extract a normalized `owner/repo` slug from any git remote URL form: +// https://github.com/Owner/Repo.git +// git@github.com:Owner/Repo.git +// ssh://git@github.com/Owner/Repo +function parseRepoSlug(remoteUrl: string): string | null { + const cleaned = remoteUrl.trim().replace(/\.git$/i, ''); + // Take the last two path segments (owner + repo), splitting on / or : + const segments = cleaned.split(/[/:]/).filter(Boolean); + if (segments.length < 2) { + return null; + } + return segments.slice(-2).join('/').toLowerCase(); +} + const ENV_VARS: EnvVar[] = [ // Required variables { name: 'OIDC_CLIENT_ID', - required: true, + required: false, sensitive: false, - description: 'OAuth client ID for user authentication', + description: + 'OAuth client ID for user login (optional — falls back to MINISTRY_PLATFORM_CLIENT_ID)', }, { name: 'OIDC_CLIENT_SECRET', - required: true, + required: false, sensitive: true, - description: 'OAuth client secret for user authentication', + description: + 'OAuth client secret for user login (optional — falls back to MINISTRY_PLATFORM_CLIENT_SECRET)', }, { name: 'MINISTRY_PLATFORM_CLIENT_ID', @@ -167,6 +186,7 @@ function parseArguments(): SetupOptions { check: false, clean: false, skipInstall: false, + update: false, verbose: false, }; @@ -181,6 +201,9 @@ function parseArguments(): SetupOptions { case '--skip-install': options.skipInstall = true; break; + case '--update': + options.update = true; + break; case '--verbose': options.verbose = true; break; @@ -207,7 +230,8 @@ Usage: pnpm run setup [options] Options: --check Validation-only mode (no modifications) --clean Delete node_modules before install - --skip-install Skip pnpm install/update steps + --skip-install Skip the pnpm install step + --update Run "pnpm update" after install (mutates the lockfile; off by default) --verbose Extra output -h, --help Show this help message @@ -215,6 +239,7 @@ Examples: pnpm run setup # Interactive setup pnpm run setup:check # Check configuration only pnpm run setup -- --clean # Clean install + pnpm run setup -- --update # Also update dependencies `); } @@ -388,7 +413,7 @@ function printResult(result: StepResult): void { async function generateAuthSecret(): Promise { // Generate a random secret using Node.js crypto const { randomBytes } = await import('node:crypto'); - return randomBytes(32).toString('base64'); + return randomBytes(32).toString('base64url'); } function normalizeMPHost(input: string): string { @@ -428,12 +453,11 @@ function detectTemplateClone(): CloneDetectionResult { const remoteUrl = result.output.trim(); - // Check if the remote URL matches any of the template repo patterns - const isClone = TEMPLATE_REPO_PATTERNS.some( - (pattern) => - remoteUrl.includes(pattern) || - remoteUrl.toLowerCase().includes(pattern.toLowerCase()) - ); + // Compare the parsed owner/repo slug against the template patterns so a + // partial substring (e.g. "mpnext" inside an unrelated URL) cannot trip it. + const slug = parseRepoSlug(remoteUrl); + const isClone = slug !== null && + TEMPLATE_REPO_PATTERNS.some((pattern) => pattern.toLowerCase() === slug); return { isClone, remoteUrl, hasGit: true, hasOrigin: true }; } @@ -560,14 +584,14 @@ function checkNodeVersion(): StepResult { if (version < REQUIRED_NODE_VERSION) { return { success: false, - message: `Node.js v${version} is below minimum required v${REQUIRED_NODE_VERSION}`, + message: `Node.js v${version} is below the required v20.9`, details: 'Please upgrade Node.js to v20.9 or later', }; } return { success: true, - message: `Node.js ${process.version} (meets v${REQUIRED_NODE_VERSION}+ requirement)`, + message: `Node.js ${process.version} (meets v20.9+ requirement)`, }; } @@ -848,7 +872,7 @@ async function runInteractiveSetup(options: SetupOptions): Promise { printResult(nodeResult); if (!nodeResult.success) { - console.log(chalk.red('\nSetup cannot continue without Node.js v20.9 or later.')); + console.log(chalk.red('\nSetup cannot continue without Node.js v20.9 or later.\n')); return 1; } passedSteps++; @@ -1022,7 +1046,7 @@ async function runInteractiveSetup(options: SetupOptions): Promise { // Always ask for OIDC_CLIENT_ID with default console.log(chalk.yellow('\n OAuth Client Configuration')); - const currentOidcClientId = currentEnv.get('OIDC_CLIENT_ID') || 'TM.Widgets'; + const currentOidcClientId = currentEnv.get('OIDC_CLIENT_ID') || 'MPNextWidgets'; const oidcClientId = await input({ message: 'Enter OIDC_CLIENT_ID (OAuth client ID for user authentication):', @@ -1046,7 +1070,7 @@ async function runInteractiveSetup(options: SetupOptions): Promise { // 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') || 'MPNext'; + const currentMpClientId = currentEnv.get('MINISTRY_PLATFORM_CLIENT_ID') || 'MPNextWidgets'; const mpClientId = await input({ message: 'Enter MINISTRY_PLATFORM_CLIENT_ID (API client ID for data access):', @@ -1186,10 +1210,16 @@ async function runInteractiveSetup(options: SetupOptions): Promise { } } - // Step 7: pnpm update + // Step 7: pnpm update (opt-in — mutating the lockfile breaks the + // reproducible install the committed pnpm-lock.yaml provides) printStepHeader(7, totalSteps, 'Updating dependencies'); - if (options.skipInstall) { + if (!options.update) { + console.log( + chalk.gray(' Skipped (committed lockfile kept; pass --update to refresh)') + ); + passedSteps++; + } else if (options.skipInstall) { console.log(chalk.gray(' Skipped (--skip-install)')); passedSteps++; } else { @@ -1220,8 +1250,20 @@ async function runInteractiveSetup(options: SetupOptions): Promise { const fileCount = countFilesInDir(MODELS_PATH); console.log(chalk.green(` ✓ ${fileCount} files generated`)); passedSteps++; + } 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. + console.log( + chalk.yellow(' ⚠ Type generation failed; using committed models/ (regen later)') + ); + if (!options.verbose && generateResult.output) { + console.log(chalk.gray(generateResult.output.slice(0, 500))); + } + warnings++; + passedSteps++; } else { - console.log(chalk.red(' ✗ Type generation failed')); + console.log(chalk.red(' ✗ Type generation failed (no committed models found)')); if (!options.verbose && generateResult.output) { console.log(chalk.gray(generateResult.output.slice(0, 500))); } diff --git a/src/app/api/embed/session/route.ts b/src/app/api/embed/session/route.ts index 7b42f3f..2459317 100644 --- a/src/app/api/embed/session/route.ts +++ b/src/app/api/embed/session/route.ts @@ -25,7 +25,15 @@ export async function POST(req: NextRequest) { const fallbackCors = buildFallbackCorsHeaders(origin); try { - const body: SessionRequest = await req.json(); + let body: SessionRequest; + try { + body = (await req.json()) as SessionRequest; + } catch { + return NextResponse.json( + { error: "Invalid or empty JSON body" }, + { status: 400, headers: fallbackCors }, + ); + } const { wid, mpUserToken } = body; if (!wid) {