create-loadout 1.0.1 → 1.0.2

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
@@ -80,7 +80,7 @@ your-app/
80
80
  | 🔥 | **Firecrawl** | Web scraping service |
81
81
  | ⏰ | **Inngest** | Background jobs |
82
82
  | 📁 | **UploadThing** | File uploads |
83
- | 💳 | **Stripe** | Checkout, webhooks, customer portal |
83
+ | 💳 | **Stripe** | Payment service with checkout + billing |
84
84
  | 📊 | **PostHog** | Product analytics |
85
85
  | 🐛 | **Sentry** | Error tracking |
86
86
 
@@ -90,27 +90,94 @@ your-app/
90
90
 
91
91
  ## How It Works
92
92
 
93
- ### 1. Run the CLI
93
+ ### Interactive Mode
94
94
 
95
95
  ```bash
96
96
  npx create-loadout
97
97
  ```
98
98
 
99
- ### 2. Answer the Prompts
99
+ Answer the prompts — project name, integrations, AI provider — and you're done.
100
100
 
101
- - Project name
102
- - Which integrations you need
103
- - AI provider (if using AI SDK)
101
+ ### Non-Interactive Mode
104
102
 
105
- ### 3. Start Building
103
+ Skip the prompts entirely with CLI flags:
104
+
105
+ ```bash
106
+ npx create-loadout my-app --clerk --neon-drizzle --stripe
107
+ ```
108
+
109
+ All available flags:
110
+
111
+ ```
112
+ --clerk Clerk authentication
113
+ --neon-drizzle Neon + Drizzle database
114
+ --ai-sdk Vercel AI SDK
115
+ --ai-provider <p> AI provider (openai, anthropic, google)
116
+ --resend Resend email
117
+ --postmark Postmark email
118
+ --firecrawl Firecrawl web scraping
119
+ --inngest Inngest background jobs
120
+ --uploadthing UploadThing file uploads
121
+ --stripe Stripe payments
122
+ --posthog PostHog analytics
123
+ --sentry Sentry error tracking
124
+ ```
125
+
126
+ Add integrations to an existing project:
127
+
128
+ ```bash
129
+ npx create-loadout --add --posthog --sentry
130
+ ```
131
+
132
+ Use a config file:
133
+
134
+ ```bash
135
+ npx create-loadout --config loadout.json
136
+ ```
137
+
138
+ ```json
139
+ {
140
+ "name": "my-app",
141
+ "integrations": ["clerk", "neon-drizzle", "stripe"],
142
+ "aiProvider": "anthropic"
143
+ }
144
+ ```
145
+
146
+ List all integrations as JSON:
147
+
148
+ ```bash
149
+ npx create-loadout --list
150
+ ```
151
+
152
+ ### Start Building
106
153
 
107
154
  ```bash
108
155
  cd your-app
109
- npm install
110
156
  npm run dev
111
157
  ```
112
158
 
113
- That's it. Fill in `.env.local` and you're live.
159
+ Fill in `.env.local` and you're live.
160
+
161
+ ---
162
+
163
+ ## MCP Server for Claude Code
164
+
165
+ Loadout ships an MCP server so Claude Code agents can scaffold and extend projects programmatically.
166
+
167
+ ### Register
168
+
169
+ ```bash
170
+ claude mcp add create-loadout -- npx -y create-loadout-mcp
171
+ ```
172
+
173
+ ### Available Tools
174
+
175
+ | Tool | Description |
176
+ |------|-------------|
177
+ | `list_integrations` | List all integrations with metadata, env vars, and constraints |
178
+ | `create_project` | Scaffold a new Next.js project with selected integrations |
179
+ | `add_integrations` | Add integrations to an existing project |
180
+ | `detect_project` | Check if a directory is a Next.js project, list installed/available integrations |
114
181
 
115
182
  ---
116
183
 
package/dist/cli.js CHANGED
@@ -1,19 +1,16 @@
1
1
  import chalk from 'chalk';
2
2
  import ora from 'ora';
3
- import fs from 'fs/promises';
4
3
  import path from 'path';
5
4
  import { getProjectConfig, getAddIntegrationConfig } from './prompts.js';
6
- import { createNextApp } from './create-next.js';
7
- import { setupShadcn } from './setup-shadcn.js';
8
- import { installIntegrations } from './integrations/index.js';
9
- import { generateClaudeMd, appendClaudeMd } from './claude-md.js';
10
- import { generateEnvFiles, appendEnvFiles } from './env.js';
11
- import { generateConfig, appendConfig } from './config.js';
12
- import { generateReadme, generateGitignore } from './generate-readme.js';
13
- import { generateInstrumentationClient, generateInstrumentation, } from './instrumentation.js';
14
- import { zustandTemplates } from './templates/zustand.js';
15
- import { generateLandingPage } from './landing-page.js';
5
+ import { createProject, addIntegrations } from './engine.js';
16
6
  import { isExistingProject, getInstalledIntegrations, getAvailableIntegrations, } from './detect.js';
7
+ function oraReporter() {
8
+ const spinner = ora();
9
+ const callback = (step) => {
10
+ spinner.start(step);
11
+ };
12
+ return { spinner, callback };
13
+ }
17
14
  export async function main() {
18
15
  console.log();
19
16
  console.log(chalk.bold.cyan(' create-loadout'));
@@ -37,36 +34,8 @@ export async function main() {
37
34
  async function newProjectFlow() {
38
35
  const config = await getProjectConfig();
39
36
  console.log();
40
- const spinner = ora('Creating Next.js app...').start();
41
- const projectPath = await createNextApp(config.name);
42
- spinner.succeed('Next.js app created');
43
- spinner.start('Setting up shadcn/ui...');
44
- await setupShadcn(projectPath);
45
- spinner.succeed('shadcn/ui configured');
46
- await extendUtils(projectPath);
47
- spinner.start('Installing base packages...');
48
- const { execa } = await import('execa');
49
- await execa('npm', ['install', 'zod@^3.24', 'zustand@^5', 'luxon@^3'], { cwd: projectPath });
50
- await execa('npm', ['install', '-D', '@types/luxon'], { cwd: projectPath });
51
- spinner.succeed('Base packages installed (zod, zustand, luxon)');
52
- await fs.mkdir(path.join(projectPath, 'lib/stores'), { recursive: true });
53
- await fs.writeFile(path.join(projectPath, 'lib/stores/counter.store.ts'), zustandTemplates.exampleStore);
54
- if (config.integrations.length > 0) {
55
- spinner.start(`Setting up ${config.integrations.length} integration(s)...`);
56
- await installIntegrations(projectPath, config);
57
- spinner.succeed('Integrations configured');
58
- }
59
- await generateInstrumentationClient(projectPath, config);
60
- await generateInstrumentation(projectPath, config);
61
- spinner.start('Generating config and environment files...');
62
- await generateConfig(projectPath, config);
63
- await generateEnvFiles(projectPath, config);
64
- spinner.succeed('Config and environment files created');
65
- spinner.start('Generating project files...');
66
- await generateLandingPage(projectPath, config);
67
- await generateGitignore(projectPath);
68
- await generateReadme(projectPath, config);
69
- await generateClaudeMd(projectPath, config);
37
+ const { spinner, callback } = oraReporter();
38
+ const result = await createProject(config, callback);
70
39
  spinner.succeed('Project files created');
71
40
  console.log();
72
41
  console.log(chalk.green.bold(' Success!') + ' Created ' + chalk.cyan(config.name));
@@ -117,21 +86,14 @@ async function addIntegrationFlow(projectPath) {
117
86
  return;
118
87
  }
119
88
  console.log();
120
- const spinner = ora(`Installing ${addConfig.integrations.length} integration(s)...`).start();
121
89
  const config = {
122
90
  name: path.basename(projectPath),
123
91
  integrations: addConfig.integrations,
124
92
  aiProvider: addConfig.aiProvider,
125
93
  };
126
- await installIntegrations(projectPath, config);
94
+ const { spinner, callback } = oraReporter();
95
+ const result = await addIntegrations(projectPath, config, callback);
127
96
  spinner.succeed('Integrations installed');
128
- spinner.start('Updating config and environment files...');
129
- await appendConfig(projectPath, addConfig.integrations, addConfig.aiProvider);
130
- await appendEnvFiles(projectPath, addConfig.integrations, addConfig.aiProvider);
131
- spinner.succeed('Config and environment files updated');
132
- spinner.start('Updating CLAUDE.md...');
133
- await appendClaudeMd(projectPath, addConfig.integrations);
134
- spinner.succeed('CLAUDE.md updated');
135
97
  console.log();
136
98
  console.log(chalk.green.bold(' Success!') + ' Added integrations:');
137
99
  addConfig.integrations.forEach((integration) => {
@@ -155,32 +117,3 @@ async function addIntegrationFlow(projectPath) {
155
117
  console.log();
156
118
  }
157
119
  }
158
- async function extendUtils(projectPath) {
159
- const utilsPath = path.join(projectPath, 'lib/utils.ts');
160
- const existingContent = await fs.readFile(utilsPath, 'utf-8');
161
- const additionalUtils = `
162
- import { DateTime } from 'luxon';
163
-
164
- export function formatDate(date: Date | string, format = 'LLL d, yyyy'): string {
165
- const dt = typeof date === 'string' ? DateTime.fromISO(date) : DateTime.fromJSDate(date);
166
- return dt.toFormat(format);
167
- }
168
-
169
- export function formatRelative(date: Date | string): string {
170
- const dt = typeof date === 'string' ? DateTime.fromISO(date) : DateTime.fromJSDate(date);
171
- return dt.toRelative() ?? dt.toFormat('LLL d, yyyy');
172
- }
173
-
174
- export function debounce<P extends unknown[], R>(
175
- func: (...args: P) => R,
176
- wait: number
177
- ): (...args: P) => void {
178
- let timeout: ReturnType<typeof setTimeout>;
179
- return (...args: P) => {
180
- clearTimeout(timeout);
181
- timeout = setTimeout(() => func(...args), wait);
182
- };
183
- }
184
- `;
185
- await fs.writeFile(utilsPath, existingContent + additionalUtils);
186
- }
@@ -11,7 +11,7 @@ export async function createNextApp(name) {
11
11
  '--import-alias', '@/*',
12
12
  '--yes',
13
13
  ], {
14
- stdio: 'inherit',
14
+ stdio: 'pipe',
15
15
  });
16
16
  return path.resolve(process.cwd(), name);
17
17
  }
@@ -0,0 +1,9 @@
1
+ import type { ProjectConfig, IntegrationId } from './types.js';
2
+ export type ProgressCallback = (step: string) => void;
3
+ export interface EngineResult {
4
+ projectPath: string;
5
+ integrations: IntegrationId[];
6
+ envVarsNeeded: string[];
7
+ }
8
+ export declare function createProject(config: ProjectConfig, onProgress?: ProgressCallback): Promise<EngineResult>;
9
+ export declare function addIntegrations(projectPath: string, config: ProjectConfig, onProgress?: ProgressCallback): Promise<EngineResult>;
package/dist/engine.js ADDED
@@ -0,0 +1,84 @@
1
+ import fs from 'fs/promises';
2
+ import path from 'path';
3
+ import { createNextApp } from './create-next.js';
4
+ import { setupShadcn } from './setup-shadcn.js';
5
+ import { installIntegrations, getEnvVars } from './integrations/index.js';
6
+ import { generateClaudeMd, appendClaudeMd } from './claude-md.js';
7
+ import { generateEnvFiles, appendEnvFiles } from './env.js';
8
+ import { generateConfig, appendConfig } from './config.js';
9
+ import { generateReadme, generateGitignore } from './generate-readme.js';
10
+ import { generateInstrumentationClient, generateInstrumentation, } from './instrumentation.js';
11
+ import { zustandTemplates } from './templates/zustand.js';
12
+ import { generateLandingPage } from './landing-page.js';
13
+ export async function createProject(config, onProgress) {
14
+ onProgress?.('Creating Next.js app...');
15
+ const projectPath = await createNextApp(config.name);
16
+ onProgress?.('Setting up shadcn/ui...');
17
+ await setupShadcn(projectPath);
18
+ await extendUtils(projectPath);
19
+ onProgress?.('Installing base packages...');
20
+ const { execa } = await import('execa');
21
+ await execa('npm', ['install', 'zod@^3.24', 'zustand@^5', 'luxon@^3'], { cwd: projectPath });
22
+ await execa('npm', ['install', '-D', '@types/luxon'], { cwd: projectPath });
23
+ await fs.mkdir(path.join(projectPath, 'lib/stores'), { recursive: true });
24
+ await fs.writeFile(path.join(projectPath, 'lib/stores/counter.store.ts'), zustandTemplates.exampleStore);
25
+ if (config.integrations.length > 0) {
26
+ onProgress?.(`Setting up ${config.integrations.length} integration(s)...`);
27
+ await installIntegrations(projectPath, config);
28
+ }
29
+ await generateInstrumentationClient(projectPath, config);
30
+ await generateInstrumentation(projectPath, config);
31
+ onProgress?.('Generating config and environment files...');
32
+ await generateConfig(projectPath, config);
33
+ await generateEnvFiles(projectPath, config);
34
+ onProgress?.('Generating project files...');
35
+ await generateLandingPage(projectPath, config);
36
+ await generateGitignore(projectPath);
37
+ await generateReadme(projectPath, config);
38
+ await generateClaudeMd(projectPath, config);
39
+ const envVarsNeeded = collectEnvVars(config);
40
+ return { projectPath, integrations: config.integrations, envVarsNeeded };
41
+ }
42
+ export async function addIntegrations(projectPath, config, onProgress) {
43
+ onProgress?.(`Installing ${config.integrations.length} integration(s)...`);
44
+ await installIntegrations(projectPath, config);
45
+ onProgress?.('Updating config and environment files...');
46
+ await appendConfig(projectPath, config.integrations, config.aiProvider);
47
+ await appendEnvFiles(projectPath, config.integrations, config.aiProvider);
48
+ onProgress?.('Updating CLAUDE.md...');
49
+ await appendClaudeMd(projectPath, config.integrations);
50
+ const envVarsNeeded = collectEnvVars(config);
51
+ return { projectPath, integrations: config.integrations, envVarsNeeded };
52
+ }
53
+ async function extendUtils(projectPath) {
54
+ const utilsPath = path.join(projectPath, 'lib/utils.ts');
55
+ const existingContent = await fs.readFile(utilsPath, 'utf-8');
56
+ const additionalUtils = `
57
+ import { DateTime } from 'luxon';
58
+
59
+ export function formatDate(date: Date | string, format = 'LLL d, yyyy'): string {
60
+ const dt = typeof date === 'string' ? DateTime.fromISO(date) : DateTime.fromJSDate(date);
61
+ return dt.toFormat(format);
62
+ }
63
+
64
+ export function formatRelative(date: Date | string): string {
65
+ const dt = typeof date === 'string' ? DateTime.fromISO(date) : DateTime.fromJSDate(date);
66
+ return dt.toRelative() ?? dt.toFormat('LLL d, yyyy');
67
+ }
68
+
69
+ export function debounce<P extends unknown[], R>(
70
+ func: (...args: P) => R,
71
+ wait: number
72
+ ): (...args: P) => void {
73
+ let timeout: ReturnType<typeof setTimeout>;
74
+ return (...args: P) => {
75
+ clearTimeout(timeout);
76
+ timeout = setTimeout(() => func(...args), wait);
77
+ };
78
+ }
79
+ `;
80
+ await fs.writeFile(utilsPath, existingContent + additionalUtils);
81
+ }
82
+ function collectEnvVars(config) {
83
+ return getEnvVars(config).map((v) => v.key);
84
+ }
package/dist/index.js CHANGED
@@ -1,3 +1,158 @@
1
1
  #!/usr/bin/env node
2
+ import { Command } from 'commander';
2
3
  import { main } from './cli.js';
3
- main();
4
+ import { createProject, addIntegrations } from './engine.js';
5
+ import { validateProjectConfig, validateIntegrationSelection, ALL_INTEGRATION_IDS } from './validate.js';
6
+ import { listIntegrations } from './metadata.js';
7
+ import { isExistingProject, getInstalledIntegrations } from './detect.js';
8
+ import fs from 'fs/promises';
9
+ import path from 'path';
10
+ const program = new Command();
11
+ program
12
+ .name('create-loadout')
13
+ .description('Custom Next.js scaffolding with SaaS integrations')
14
+ .version('1.0.1')
15
+ .argument('[name]', 'Project name')
16
+ .option('--clerk', 'Add Clerk authentication')
17
+ .option('--neon-drizzle', 'Add Neon + Drizzle database')
18
+ .option('--ai-sdk', 'Add Vercel AI SDK')
19
+ .option('--ai-provider <provider>', 'AI provider (openai, anthropic, google)')
20
+ .option('--resend', 'Add Resend email')
21
+ .option('--postmark', 'Add Postmark email')
22
+ .option('--firecrawl', 'Add Firecrawl web scraping')
23
+ .option('--inngest', 'Add Inngest background jobs')
24
+ .option('--uploadthing', 'Add UploadThing file uploads')
25
+ .option('--stripe', 'Add Stripe payments')
26
+ .option('--posthog', 'Add PostHog analytics')
27
+ .option('--sentry', 'Add Sentry error tracking')
28
+ .option('--config <path>', 'Path to loadout.json config file')
29
+ .option('--add', 'Add integrations to existing project')
30
+ .option('--list', 'List all available integrations as JSON')
31
+ .action(async (name, opts) => {
32
+ // --list: dump integration metadata and exit
33
+ if (opts.list) {
34
+ console.log(JSON.stringify(listIntegrations(), null, 2));
35
+ return;
36
+ }
37
+ // Detect if running non-interactive
38
+ const hasConfig = typeof opts.config === 'string';
39
+ const hasFlags = name || hasConfig || opts.add || integrationFlagsPresent(opts);
40
+ if (!hasFlags) {
41
+ // No flags → interactive mode (existing behavior)
42
+ await main();
43
+ return;
44
+ }
45
+ // Non-interactive mode
46
+ try {
47
+ if (hasConfig) {
48
+ await runFromConfigFile(opts.config);
49
+ }
50
+ else if (opts.add) {
51
+ await runAddMode(opts);
52
+ }
53
+ else {
54
+ await runCreateMode(name, opts);
55
+ }
56
+ }
57
+ catch (error) {
58
+ console.error('Error:', error instanceof Error ? error.message : error);
59
+ process.exit(1);
60
+ }
61
+ });
62
+ function integrationFlagsPresent(opts) {
63
+ return ALL_INTEGRATION_IDS.some((id) => opts[camelCase(id)] === true);
64
+ }
65
+ function camelCase(s) {
66
+ return s.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
67
+ }
68
+ function collectIntegrations(opts) {
69
+ const integrations = [];
70
+ for (const id of ALL_INTEGRATION_IDS) {
71
+ if (opts[camelCase(id)] === true) {
72
+ integrations.push(id);
73
+ }
74
+ }
75
+ return integrations;
76
+ }
77
+ async function runCreateMode(name, opts) {
78
+ const integrations = collectIntegrations(opts);
79
+ const aiProvider = opts.aiProvider || undefined;
80
+ const config = { name, integrations, aiProvider };
81
+ const errors = validateProjectConfig(config);
82
+ if (errors.length > 0) {
83
+ console.error('Validation errors:');
84
+ errors.forEach((e) => console.error(` - ${e.field}: ${e.message}`));
85
+ process.exit(1);
86
+ }
87
+ const result = await createProject(config, (step) => console.log(step));
88
+ console.log();
89
+ console.log(`Success! Created ${config.name} at ${result.projectPath}`);
90
+ if (result.integrations.length > 0) {
91
+ console.log(`Integrations: ${result.integrations.join(', ')}`);
92
+ }
93
+ if (result.envVarsNeeded.length > 0) {
94
+ console.log(`Environment variables to configure: ${result.envVarsNeeded.join(', ')}`);
95
+ }
96
+ }
97
+ async function runAddMode(opts) {
98
+ const cwd = process.cwd();
99
+ if (!(await isExistingProject(cwd))) {
100
+ console.error('Error: Not in a Next.js project directory. --add requires an existing project.');
101
+ process.exit(1);
102
+ }
103
+ const integrations = collectIntegrations(opts);
104
+ if (integrations.length === 0) {
105
+ console.error('Error: --add requires at least one integration flag (e.g. --clerk --stripe)');
106
+ process.exit(1);
107
+ }
108
+ const valErrors = validateIntegrationSelection(integrations);
109
+ if (valErrors.length > 0) {
110
+ console.error('Validation errors:');
111
+ valErrors.forEach((e) => console.error(` - ${e.field}: ${e.message}`));
112
+ process.exit(1);
113
+ }
114
+ const installed = await getInstalledIntegrations(cwd);
115
+ const alreadyInstalled = integrations.filter((id) => installed.includes(id));
116
+ if (alreadyInstalled.length > 0) {
117
+ console.error(`Error: Already installed: ${alreadyInstalled.join(', ')}`);
118
+ process.exit(1);
119
+ }
120
+ const aiProvider = opts.aiProvider || undefined;
121
+ const config = {
122
+ name: path.basename(cwd),
123
+ integrations,
124
+ aiProvider,
125
+ };
126
+ const result = await addIntegrations(cwd, config, (step) => console.log(step));
127
+ console.log();
128
+ console.log(`Success! Added integrations: ${result.integrations.join(', ')}`);
129
+ if (result.envVarsNeeded.length > 0) {
130
+ console.log(`Environment variables to configure: ${result.envVarsNeeded.join(', ')}`);
131
+ }
132
+ }
133
+ async function runFromConfigFile(configPath) {
134
+ const resolved = path.resolve(configPath);
135
+ const raw = await fs.readFile(resolved, 'utf-8');
136
+ const parsed = JSON.parse(raw);
137
+ const config = {
138
+ name: parsed.name,
139
+ integrations: parsed.integrations || [],
140
+ aiProvider: parsed.aiProvider,
141
+ };
142
+ const errors = validateProjectConfig(config);
143
+ if (errors.length > 0) {
144
+ console.error('Config validation errors:');
145
+ errors.forEach((e) => console.error(` - ${e.field}: ${e.message}`));
146
+ process.exit(1);
147
+ }
148
+ const result = await createProject(config, (step) => console.log(step));
149
+ console.log();
150
+ console.log(`Success! Created ${config.name} at ${result.projectPath}`);
151
+ if (result.integrations.length > 0) {
152
+ console.log(`Integrations: ${result.integrations.join(', ')}`);
153
+ }
154
+ if (result.envVarsNeeded.length > 0) {
155
+ console.log(`Environment variables to configure: ${result.envVarsNeeded.join(', ')}`);
156
+ }
157
+ }
158
+ program.parse();
@@ -1,4 +1,5 @@
1
1
  import type { Integration, IntegrationId, EnvVar, ProjectConfig } from '../types.js';
2
+ export declare function getIntegration(id: IntegrationId, config: ProjectConfig): Integration;
2
3
  export declare function installIntegrations(projectPath: string, config: ProjectConfig): Promise<void>;
3
4
  export declare function getEnvVars(config: ProjectConfig): EnvVar[];
4
5
  export declare const integrations: Partial<Record<IntegrationId, Integration>>;
@@ -24,7 +24,7 @@ const staticIntegrations = {
24
24
  sentry: sentryIntegration,
25
25
  };
26
26
  // Get integration, with dynamic ones using config
27
- function getIntegration(id, config) {
27
+ export function getIntegration(id, config) {
28
28
  if (id === 'ai-sdk') {
29
29
  return createAiSdkIntegration(config.aiProvider ?? 'openai');
30
30
  }
@@ -25,21 +25,7 @@ export const stripeIntegration = {
25
25
  },
26
26
  ],
27
27
  setup: async (projectPath) => {
28
- // Create payment service
29
28
  await fs.mkdir(path.join(projectPath, 'services'), { recursive: true });
30
29
  await fs.writeFile(path.join(projectPath, 'services/payment.service.ts'), stripeTemplates.paymentService);
31
- // Create API routes
32
- await fs.mkdir(path.join(projectPath, 'app/api/stripe/checkout'), {
33
- recursive: true,
34
- });
35
- await fs.mkdir(path.join(projectPath, 'app/api/stripe/webhooks'), {
36
- recursive: true,
37
- });
38
- await fs.mkdir(path.join(projectPath, 'app/api/stripe/portal'), {
39
- recursive: true,
40
- });
41
- await fs.writeFile(path.join(projectPath, 'app/api/stripe/checkout/route.ts'), stripeTemplates.checkoutRoute);
42
- await fs.writeFile(path.join(projectPath, 'app/api/stripe/webhooks/route.ts'), stripeTemplates.webhooksRoute);
43
- await fs.writeFile(path.join(projectPath, 'app/api/stripe/portal/route.ts'), stripeTemplates.portalRoute);
44
30
  },
45
31
  };
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,161 @@
1
+ #!/usr/bin/env node
2
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
+ import { z } from 'zod';
5
+ import path from 'path';
6
+ import { createProject, addIntegrations } from './engine.js';
7
+ import { validateProjectConfig, validateIntegrationSelection, } from './validate.js';
8
+ import { listIntegrations } from './metadata.js';
9
+ import { isExistingProject, getInstalledIntegrations, getAvailableIntegrations, } from './detect.js';
10
+ const server = new McpServer({
11
+ name: 'create-loadout',
12
+ version: '1.0.1',
13
+ });
14
+ // Tool: list_integrations
15
+ server.tool('list_integrations', 'List all available integrations with metadata, env vars, and constraints', {}, async () => {
16
+ const integrations = listIntegrations();
17
+ return {
18
+ content: [{ type: 'text', text: JSON.stringify(integrations, null, 2) }],
19
+ };
20
+ });
21
+ // Tool: create_project
22
+ server.tool('create_project', 'Scaffold a new Next.js project with selected integrations', {
23
+ name: z.string().describe('Project name (lowercase, numbers, hyphens only)'),
24
+ integrations: z.array(z.string()).default([]).describe('Integration IDs to install'),
25
+ aiProvider: z.enum(['openai', 'anthropic', 'google']).optional().describe('AI provider (required if ai-sdk selected)'),
26
+ }, async ({ name, integrations, aiProvider }) => {
27
+ const config = {
28
+ name,
29
+ integrations: integrations,
30
+ aiProvider: aiProvider,
31
+ };
32
+ const errors = validateProjectConfig(config);
33
+ if (errors.length > 0) {
34
+ return {
35
+ content: [{
36
+ type: 'text',
37
+ text: JSON.stringify({ error: 'Validation failed', details: errors }),
38
+ }],
39
+ isError: true,
40
+ };
41
+ }
42
+ const log = [];
43
+ const result = await createProject(config, (step) => log.push(step));
44
+ return {
45
+ content: [{
46
+ type: 'text',
47
+ text: JSON.stringify({
48
+ success: true,
49
+ projectPath: result.projectPath,
50
+ integrations: result.integrations,
51
+ envVarsNeeded: result.envVarsNeeded,
52
+ log,
53
+ }, null, 2),
54
+ }],
55
+ };
56
+ });
57
+ // Tool: add_integrations
58
+ server.tool('add_integrations', 'Add integrations to an existing Next.js project', {
59
+ projectPath: z.string().describe('Absolute path to the existing Next.js project'),
60
+ integrations: z.array(z.string()).describe('Integration IDs to add'),
61
+ aiProvider: z.enum(['openai', 'anthropic', 'google']).optional().describe('AI provider (required if ai-sdk selected)'),
62
+ }, async ({ projectPath, integrations, aiProvider }) => {
63
+ const resolved = path.resolve(projectPath);
64
+ if (!(await isExistingProject(resolved))) {
65
+ return {
66
+ content: [{
67
+ type: 'text',
68
+ text: JSON.stringify({ error: 'Not a Next.js project', path: resolved }),
69
+ }],
70
+ isError: true,
71
+ };
72
+ }
73
+ const valErrors = validateIntegrationSelection(integrations);
74
+ if (valErrors.length > 0) {
75
+ return {
76
+ content: [{
77
+ type: 'text',
78
+ text: JSON.stringify({ error: 'Validation failed', details: valErrors }),
79
+ }],
80
+ isError: true,
81
+ };
82
+ }
83
+ const installed = await getInstalledIntegrations(resolved);
84
+ const alreadyInstalled = integrations.filter((id) => installed.includes(id));
85
+ if (alreadyInstalled.length > 0) {
86
+ return {
87
+ content: [{
88
+ type: 'text',
89
+ text: JSON.stringify({ error: 'Already installed', integrations: alreadyInstalled }),
90
+ }],
91
+ isError: true,
92
+ };
93
+ }
94
+ if (integrations.includes('ai-sdk') && !aiProvider) {
95
+ return {
96
+ content: [{
97
+ type: 'text',
98
+ text: JSON.stringify({ error: 'aiProvider required when ai-sdk is selected' }),
99
+ }],
100
+ isError: true,
101
+ };
102
+ }
103
+ const config = {
104
+ name: path.basename(resolved),
105
+ integrations: integrations,
106
+ aiProvider: aiProvider,
107
+ };
108
+ const log = [];
109
+ const result = await addIntegrations(resolved, config, (step) => log.push(step));
110
+ return {
111
+ content: [{
112
+ type: 'text',
113
+ text: JSON.stringify({
114
+ success: true,
115
+ projectPath: result.projectPath,
116
+ integrations: result.integrations,
117
+ envVarsNeeded: result.envVarsNeeded,
118
+ log,
119
+ }, null, 2),
120
+ }],
121
+ };
122
+ });
123
+ // Tool: detect_project
124
+ server.tool('detect_project', 'Check if a directory is a Next.js project and list installed/available integrations', {
125
+ projectPath: z.string().optional().describe('Path to check (defaults to cwd)'),
126
+ }, async ({ projectPath }) => {
127
+ const resolved = path.resolve(projectPath || process.cwd());
128
+ const isProject = await isExistingProject(resolved);
129
+ if (!isProject) {
130
+ return {
131
+ content: [{
132
+ type: 'text',
133
+ text: JSON.stringify({
134
+ isNextJsProject: false,
135
+ path: resolved,
136
+ }, null, 2),
137
+ }],
138
+ };
139
+ }
140
+ const installed = await getInstalledIntegrations(resolved);
141
+ const available = getAvailableIntegrations(installed);
142
+ return {
143
+ content: [{
144
+ type: 'text',
145
+ text: JSON.stringify({
146
+ isNextJsProject: true,
147
+ path: resolved,
148
+ installedIntegrations: installed,
149
+ availableIntegrations: available,
150
+ }, null, 2),
151
+ }],
152
+ };
153
+ });
154
+ async function run() {
155
+ const transport = new StdioServerTransport();
156
+ await server.connect(transport);
157
+ }
158
+ run().catch((error) => {
159
+ console.error('MCP server error:', error);
160
+ process.exit(1);
161
+ });
@@ -0,0 +1,18 @@
1
+ import type { IntegrationId } from './types.js';
2
+ export interface IntegrationInfo {
3
+ id: IntegrationId;
4
+ name: string;
5
+ description: string;
6
+ packages: string[];
7
+ envVars: {
8
+ key: string;
9
+ description: string;
10
+ example: string;
11
+ }[];
12
+ mutuallyExclusiveWith?: IntegrationId[];
13
+ requiresOption?: {
14
+ field: string;
15
+ values: string[];
16
+ };
17
+ }
18
+ export declare function listIntegrations(): IntegrationInfo[];
@@ -0,0 +1,28 @@
1
+ import { getIntegration } from './integrations/index.js';
2
+ import { ALL_INTEGRATION_IDS, AI_PROVIDERS } from './validate.js';
3
+ const EMAIL_PROVIDERS = ['resend', 'postmark'];
4
+ export function listIntegrations() {
5
+ return ALL_INTEGRATION_IDS.map((id) => {
6
+ // Use a dummy config to get integration metadata
7
+ const config = { name: 'dummy', integrations: [id], aiProvider: 'openai' };
8
+ const integration = getIntegration(id, config);
9
+ const info = {
10
+ id: integration.id,
11
+ name: integration.name,
12
+ description: integration.description,
13
+ packages: integration.packages,
14
+ envVars: integration.envVars.map((v) => ({
15
+ key: v.key,
16
+ description: v.description,
17
+ example: v.example,
18
+ })),
19
+ };
20
+ if (EMAIL_PROVIDERS.includes(id)) {
21
+ info.mutuallyExclusiveWith = EMAIL_PROVIDERS.filter((e) => e !== id);
22
+ }
23
+ if (id === 'ai-sdk') {
24
+ info.requiresOption = { field: 'aiProvider', values: [...AI_PROVIDERS] };
25
+ }
26
+ return info;
27
+ });
28
+ }
package/dist/prompts.js CHANGED
@@ -1,15 +1,12 @@
1
1
  import { input, confirm, select } from '@inquirer/prompts';
2
+ import { validateProjectName } from './validate.js';
2
3
  export async function getProjectConfig() {
3
4
  const name = await input({
4
5
  message: 'Project name:',
5
6
  default: 'my-app',
6
7
  validate: (value) => {
7
- if (!value.trim())
8
- return 'Project name is required';
9
- if (!/^[a-z0-9-]+$/.test(value)) {
10
- return 'Project name can only contain lowercase letters, numbers, and hyphens';
11
- }
12
- return true;
8
+ const errors = validateProjectName(value);
9
+ return errors.length > 0 ? errors[0].message : true;
13
10
  },
14
11
  });
15
12
  const integrations = [];
@@ -78,7 +78,7 @@ import { scrapeService } from '@/services/scrape.service';
78
78
  import { z } from 'zod';
79
79
 
80
80
  const scrapeSchema = z.object({
81
- url: z.url(),
81
+ url: z.string().url(),
82
82
  });
83
83
 
84
84
  export async function POST(req: Request) {
@@ -1,6 +1,3 @@
1
1
  export declare const stripeTemplates: {
2
2
  paymentService: string;
3
- checkoutRoute: string;
4
- webhooksRoute: string;
5
- portalRoute: string;
6
3
  };
@@ -81,135 +81,5 @@ export class PaymentService {
81
81
  export const paymentService = new PaymentService(STRIPE_SECRET_KEY);
82
82
 
83
83
  export type { Stripe };
84
- `,
85
- checkoutRoute: `import { NextResponse } from 'next/server';
86
- import { paymentService } from '@/services/payment.service';
87
- import { z } from 'zod';
88
-
89
- const checkoutSchema = z.object({
90
- priceId: z.string(),
91
- customerId: z.string().optional(),
92
- successUrl: z.url(),
93
- cancelUrl: z.url(),
94
- });
95
-
96
- export async function POST(req: Request) {
97
- try {
98
- const body = await req.json();
99
- const { priceId, customerId, successUrl, cancelUrl } = checkoutSchema.parse(body);
100
-
101
- const session = await paymentService.createCheckoutSession({
102
- priceId,
103
- customerId,
104
- successUrl,
105
- cancelUrl,
106
- });
107
-
108
- return NextResponse.json({ url: session.url });
109
- } catch (error) {
110
- if (error instanceof z.ZodError) {
111
- return NextResponse.json(
112
- { error: 'Invalid request', details: error.errors },
113
- { status: 400 }
114
- );
115
- }
116
- return NextResponse.json(
117
- { error: error instanceof Error ? error.message : 'Failed to create checkout' },
118
- { status: 500 }
119
- );
120
- }
121
- }
122
- `,
123
- webhooksRoute: `import { headers } from 'next/headers';
124
- import { NextResponse } from 'next/server';
125
- import { paymentService, type Stripe } from '@/services/payment.service';
126
- import { STRIPE_WEBHOOK_SECRET } from '@/lib/config';
127
-
128
- export async function POST(req: Request) {
129
- const body = await req.text();
130
- const headersList = await headers();
131
- const signature = headersList.get('stripe-signature')!;
132
-
133
- let event: Stripe.Event;
134
-
135
- try {
136
- event = paymentService.constructWebhookEvent(body, signature, STRIPE_WEBHOOK_SECRET);
137
- } catch (error) {
138
- console.error('Webhook signature verification failed:', error);
139
- return NextResponse.json({ error: 'Invalid signature' }, { status: 400 });
140
- }
141
-
142
- switch (event.type) {
143
- case 'checkout.session.completed': {
144
- const session = event.data.object as Stripe.Checkout.Session;
145
- console.log('Checkout completed:', session.id);
146
- break;
147
- }
148
-
149
- case 'customer.subscription.created':
150
- case 'customer.subscription.updated': {
151
- const subscription = event.data.object as Stripe.Subscription;
152
- console.log('Subscription updated:', subscription.id, subscription.status);
153
- break;
154
- }
155
-
156
- case 'customer.subscription.deleted': {
157
- const subscription = event.data.object as Stripe.Subscription;
158
- console.log('Subscription cancelled:', subscription.id);
159
- break;
160
- }
161
-
162
- case 'invoice.payment_succeeded': {
163
- const invoice = event.data.object as Stripe.Invoice;
164
- console.log('Payment succeeded:', invoice.id);
165
- break;
166
- }
167
-
168
- case 'invoice.payment_failed': {
169
- const invoice = event.data.object as Stripe.Invoice;
170
- console.log('Payment failed:', invoice.id);
171
- break;
172
- }
173
-
174
- default:
175
- console.log('Unhandled event type:', event.type);
176
- }
177
-
178
- return NextResponse.json({ received: true });
179
- }
180
- `,
181
- portalRoute: `import { NextResponse } from 'next/server';
182
- import { paymentService } from '@/services/payment.service';
183
- import { z } from 'zod';
184
-
185
- const portalSchema = z.object({
186
- customerId: z.string(),
187
- returnUrl: z.url(),
188
- });
189
-
190
- export async function POST(req: Request) {
191
- try {
192
- const body = await req.json();
193
- const { customerId, returnUrl } = portalSchema.parse(body);
194
-
195
- const session = await paymentService.createPortalSession({
196
- customerId,
197
- returnUrl,
198
- });
199
-
200
- return NextResponse.json({ url: session.url });
201
- } catch (error) {
202
- if (error instanceof z.ZodError) {
203
- return NextResponse.json(
204
- { error: 'Invalid request', details: error.errors },
205
- { status: 400 }
206
- );
207
- }
208
- return NextResponse.json(
209
- { error: error instanceof Error ? error.message : 'Failed to create portal session' },
210
- { status: 500 }
211
- );
212
- }
213
- }
214
84
  `,
215
85
  };
@@ -0,0 +1,14 @@
1
+ import type { IntegrationId, AIProviderChoice } from './types.js';
2
+ export declare const ALL_INTEGRATION_IDS: IntegrationId[];
3
+ export declare const AI_PROVIDERS: AIProviderChoice[];
4
+ export interface ValidationError {
5
+ field: string;
6
+ message: string;
7
+ }
8
+ export declare function validateProjectName(name: string): ValidationError[];
9
+ export declare function validateIntegrationSelection(ids: string[]): ValidationError[];
10
+ export declare function validateProjectConfig(config: {
11
+ name: string;
12
+ integrations: string[];
13
+ aiProvider?: string;
14
+ }): ValidationError[];
@@ -0,0 +1,66 @@
1
+ export const ALL_INTEGRATION_IDS = [
2
+ 'clerk',
3
+ 'neon-drizzle',
4
+ 'ai-sdk',
5
+ 'resend',
6
+ 'postmark',
7
+ 'firecrawl',
8
+ 'inngest',
9
+ 'uploadthing',
10
+ 'stripe',
11
+ 'posthog',
12
+ 'sentry',
13
+ ];
14
+ export const AI_PROVIDERS = ['openai', 'anthropic', 'google'];
15
+ const EMAIL_PROVIDERS = ['resend', 'postmark'];
16
+ const PROJECT_NAME_REGEX = /^[a-z0-9-]+$/;
17
+ export function validateProjectName(name) {
18
+ const errors = [];
19
+ if (!name.trim()) {
20
+ errors.push({ field: 'name', message: 'Project name is required' });
21
+ }
22
+ else if (!PROJECT_NAME_REGEX.test(name)) {
23
+ errors.push({
24
+ field: 'name',
25
+ message: 'Project name can only contain lowercase letters, numbers, and hyphens',
26
+ });
27
+ }
28
+ return errors;
29
+ }
30
+ export function validateIntegrationSelection(ids) {
31
+ const errors = [];
32
+ for (const id of ids) {
33
+ if (!ALL_INTEGRATION_IDS.includes(id)) {
34
+ errors.push({
35
+ field: 'integrations',
36
+ message: `Unknown integration: "${id}". Valid: ${ALL_INTEGRATION_IDS.join(', ')}`,
37
+ });
38
+ }
39
+ }
40
+ const selectedEmail = ids.filter((id) => EMAIL_PROVIDERS.includes(id));
41
+ if (selectedEmail.length > 1) {
42
+ errors.push({
43
+ field: 'integrations',
44
+ message: 'Only one email provider allowed (resend or postmark)',
45
+ });
46
+ }
47
+ return errors;
48
+ }
49
+ export function validateProjectConfig(config) {
50
+ const errors = [];
51
+ errors.push(...validateProjectName(config.name));
52
+ errors.push(...validateIntegrationSelection(config.integrations));
53
+ if (config.integrations.includes('ai-sdk') && !config.aiProvider) {
54
+ errors.push({
55
+ field: 'aiProvider',
56
+ message: 'aiProvider is required when ai-sdk is selected (openai, anthropic, or google)',
57
+ });
58
+ }
59
+ if (config.aiProvider && !AI_PROVIDERS.includes(config.aiProvider)) {
60
+ errors.push({
61
+ field: 'aiProvider',
62
+ message: `Invalid AI provider: "${config.aiProvider}". Valid: ${AI_PROVIDERS.join(', ')}`,
63
+ });
64
+ }
65
+ return errors;
66
+ }
package/package.json CHANGED
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "name": "create-loadout",
3
- "version": "1.0.1",
3
+ "version": "1.0.2",
4
4
  "description": "Custom Next.js scaffolding CLI with optional SaaS integrations",
5
5
  "type": "module",
6
6
  "bin": {
7
- "create-loadout": "./dist/index.js"
7
+ "create-loadout": "./dist/index.js",
8
+ "create-loadout-mcp": "./dist/mcp-server.js"
8
9
  },
9
10
  "files": [
10
11
  "dist"
@@ -12,6 +13,7 @@
12
13
  "scripts": {
13
14
  "build": "tsc",
14
15
  "dev": "tsx src/index.ts",
16
+ "create": "tsx src/index.ts",
15
17
  "prepublishOnly": "npm run build"
16
18
  },
17
19
  "keywords": [
@@ -31,14 +33,17 @@
31
33
  "license": "MIT",
32
34
  "dependencies": {
33
35
  "@inquirer/prompts": "^7.0.0",
36
+ "@modelcontextprotocol/sdk": "^1.26.0",
34
37
  "chalk": "^5.0.0",
38
+ "commander": "^13.1.0",
39
+ "execa": "^9.0.0",
35
40
  "ora": "^8.0.0",
36
- "execa": "^9.0.0"
41
+ "zod": "^4.3.6"
37
42
  },
38
43
  "devDependencies": {
39
44
  "@types/node": "^22.0.0",
40
- "typescript": "^5.0.0",
41
- "tsx": "^4.0.0"
45
+ "tsx": "^4.0.0",
46
+ "typescript": "^5.0.0"
42
47
  },
43
48
  "engines": {
44
49
  "node": ">=18.0.0"