Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 26 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand All @@ -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
```

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
78 changes: 78 additions & 0 deletions scripts/setup-bootstrap.mjs
Original file line number Diff line number Diff line change
@@ -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]));
84 changes: 63 additions & 21 deletions scripts/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ interface SetupOptions {
check: boolean;
clean: boolean;
skipInstall: boolean;
update: boolean;
verbose: boolean;
}

Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -167,6 +186,7 @@ function parseArguments(): SetupOptions {
check: false,
clean: false,
skipInstall: false,
update: false,
verbose: false,
};

Expand All @@ -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;
Expand All @@ -207,14 +230,16 @@ 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

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
`);
}

Expand Down Expand Up @@ -388,7 +413,7 @@ function printResult(result: StepResult): void {
async function generateAuthSecret(): Promise<string> {
// 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 {
Expand Down Expand Up @@ -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 };
}
Expand Down Expand Up @@ -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)`,
};
}

Expand Down Expand Up @@ -848,7 +872,7 @@ async function runInteractiveSetup(options: SetupOptions): Promise<number> {
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++;
Expand Down Expand Up @@ -1022,7 +1046,7 @@ async function runInteractiveSetup(options: SetupOptions): Promise<number> {

// 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):',
Expand All @@ -1046,7 +1070,7 @@ async function runInteractiveSetup(options: SetupOptions): Promise<number> {

// 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):',
Expand Down Expand Up @@ -1186,10 +1210,16 @@ async function runInteractiveSetup(options: SetupOptions): Promise<number> {
}
}

// 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 {
Expand Down Expand Up @@ -1220,8 +1250,20 @@ async function runInteractiveSetup(options: SetupOptions): Promise<number> {
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)));
}
Expand Down
Loading
Loading