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/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.
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);
}