create-loadout 1.0.1 → 1.0.4

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/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,165 @@
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
+ const nodeBinDir = path.dirname(process.execPath);
7
+ if (!process.env.PATH?.includes(nodeBinDir)) {
8
+ process.env.PATH = `${nodeBinDir}:${process.env.PATH || ''}`;
9
+ }
10
+ import { createProject, addIntegrations } from './engine.js';
11
+ import { validateProjectConfig, validateIntegrationSelection, } from './validate.js';
12
+ import { listIntegrations } from './metadata.js';
13
+ import { isExistingProject, getInstalledIntegrations, getAvailableIntegrations, } from './detect.js';
14
+ const server = new McpServer({
15
+ name: 'create-loadout',
16
+ version: '1.0.1',
17
+ });
18
+ // Tool: list_integrations
19
+ server.tool('list_integrations', 'List all available integrations with metadata, env vars, and constraints', {}, async () => {
20
+ const integrations = listIntegrations();
21
+ return {
22
+ content: [{ type: 'text', text: JSON.stringify(integrations, null, 2) }],
23
+ };
24
+ });
25
+ // Tool: create_project
26
+ server.tool('create_project', 'Scaffold a new Next.js project with selected integrations', {
27
+ name: z.string().describe('Project name (lowercase, numbers, hyphens only)'),
28
+ integrations: z.array(z.string()).default([]).describe('Integration IDs to install'),
29
+ aiProvider: z.enum(['openai', 'anthropic', 'google']).optional().describe('AI provider (required if ai-sdk selected)'),
30
+ }, async ({ name, integrations, aiProvider }) => {
31
+ const config = {
32
+ name,
33
+ integrations: integrations,
34
+ aiProvider: aiProvider,
35
+ };
36
+ const errors = validateProjectConfig(config);
37
+ if (errors.length > 0) {
38
+ return {
39
+ content: [{
40
+ type: 'text',
41
+ text: JSON.stringify({ error: 'Validation failed', details: errors }),
42
+ }],
43
+ isError: true,
44
+ };
45
+ }
46
+ const log = [];
47
+ const result = await createProject(config, (step) => log.push(step));
48
+ return {
49
+ content: [{
50
+ type: 'text',
51
+ text: JSON.stringify({
52
+ success: true,
53
+ projectPath: result.projectPath,
54
+ integrations: result.integrations,
55
+ envVarsNeeded: result.envVarsNeeded,
56
+ log,
57
+ }, null, 2),
58
+ }],
59
+ };
60
+ });
61
+ // Tool: add_integrations
62
+ server.tool('add_integrations', 'Add integrations to an existing Next.js project', {
63
+ projectPath: z.string().describe('Absolute path to the existing Next.js project'),
64
+ integrations: z.array(z.string()).describe('Integration IDs to add'),
65
+ aiProvider: z.enum(['openai', 'anthropic', 'google']).optional().describe('AI provider (required if ai-sdk selected)'),
66
+ }, async ({ projectPath, integrations, aiProvider }) => {
67
+ const resolved = path.resolve(projectPath);
68
+ if (!(await isExistingProject(resolved))) {
69
+ return {
70
+ content: [{
71
+ type: 'text',
72
+ text: JSON.stringify({ error: 'Not a Next.js project', path: resolved }),
73
+ }],
74
+ isError: true,
75
+ };
76
+ }
77
+ const valErrors = validateIntegrationSelection(integrations);
78
+ if (valErrors.length > 0) {
79
+ return {
80
+ content: [{
81
+ type: 'text',
82
+ text: JSON.stringify({ error: 'Validation failed', details: valErrors }),
83
+ }],
84
+ isError: true,
85
+ };
86
+ }
87
+ const installed = await getInstalledIntegrations(resolved);
88
+ const alreadyInstalled = integrations.filter((id) => installed.includes(id));
89
+ if (alreadyInstalled.length > 0) {
90
+ return {
91
+ content: [{
92
+ type: 'text',
93
+ text: JSON.stringify({ error: 'Already installed', integrations: alreadyInstalled }),
94
+ }],
95
+ isError: true,
96
+ };
97
+ }
98
+ if (integrations.includes('ai-sdk') && !aiProvider) {
99
+ return {
100
+ content: [{
101
+ type: 'text',
102
+ text: JSON.stringify({ error: 'aiProvider required when ai-sdk is selected' }),
103
+ }],
104
+ isError: true,
105
+ };
106
+ }
107
+ const config = {
108
+ name: path.basename(resolved),
109
+ integrations: integrations,
110
+ aiProvider: aiProvider,
111
+ };
112
+ const log = [];
113
+ const result = await addIntegrations(resolved, config, (step) => log.push(step));
114
+ return {
115
+ content: [{
116
+ type: 'text',
117
+ text: JSON.stringify({
118
+ success: true,
119
+ projectPath: result.projectPath,
120
+ integrations: result.integrations,
121
+ envVarsNeeded: result.envVarsNeeded,
122
+ log,
123
+ }, null, 2),
124
+ }],
125
+ };
126
+ });
127
+ // Tool: detect_project
128
+ server.tool('detect_project', 'Check if a directory is a Next.js project and list installed/available integrations', {
129
+ projectPath: z.string().optional().describe('Path to check (defaults to cwd)'),
130
+ }, async ({ projectPath }) => {
131
+ const resolved = path.resolve(projectPath || process.cwd());
132
+ const isProject = await isExistingProject(resolved);
133
+ if (!isProject) {
134
+ return {
135
+ content: [{
136
+ type: 'text',
137
+ text: JSON.stringify({
138
+ isNextJsProject: false,
139
+ path: resolved,
140
+ }, null, 2),
141
+ }],
142
+ };
143
+ }
144
+ const installed = await getInstalledIntegrations(resolved);
145
+ const available = getAvailableIntegrations(installed);
146
+ return {
147
+ content: [{
148
+ type: 'text',
149
+ text: JSON.stringify({
150
+ isNextJsProject: true,
151
+ path: resolved,
152
+ installedIntegrations: installed,
153
+ availableIntegrations: available,
154
+ }, null, 2),
155
+ }],
156
+ };
157
+ });
158
+ async function run() {
159
+ const transport = new StdioServerTransport();
160
+ await server.connect(transport);
161
+ }
162
+ run().catch((error) => {
163
+ console.error('MCP server error:', error);
164
+ process.exit(1);
165
+ });
@@ -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) {