create-loadout 1.0.0 → 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 +77 -9
- package/dist/claude-md.js +9 -2
- package/dist/cli.js +12 -79
- package/dist/config.js +4 -0
- package/dist/create-next.js +1 -1
- package/dist/detect.js +11 -1
- package/dist/engine.d.ts +9 -0
- package/dist/engine.js +84 -0
- package/dist/env.js +8 -0
- package/dist/generate-readme.js +2 -1
- package/dist/index.js +156 -1
- package/dist/integrations/index.d.ts +1 -0
- package/dist/integrations/index.js +3 -1
- package/dist/integrations/postmark.d.ts +2 -0
- package/dist/integrations/postmark.js +34 -0
- package/dist/integrations/stripe.js +0 -14
- package/dist/landing-page.js +1 -0
- package/dist/mcp-server.d.ts +2 -0
- package/dist/mcp-server.js +161 -0
- package/dist/metadata.d.ts +18 -0
- package/dist/metadata.js +28 -0
- package/dist/prompts.js +33 -9
- package/dist/templates/firecrawl.js +1 -1
- package/dist/templates/postmark.d.ts +5 -0
- package/dist/templates/postmark.js +103 -0
- package/dist/templates/stripe.d.ts +0 -3
- package/dist/templates/stripe.js +0 -130
- package/dist/types.d.ts +1 -1
- package/dist/validate.d.ts +14 -0
- package/dist/validate.js +66 -0
- package/package.json +51 -46
package/README.md
CHANGED
|
@@ -76,10 +76,11 @@ your-app/
|
|
|
76
76
|
| 🗄️ | **Neon + Drizzle** | Serverless Postgres with full CRUD example |
|
|
77
77
|
| 🤖 | **AI SDK** | OpenAI / Anthropic / Google |
|
|
78
78
|
| 📧 | **Resend** | Email service + React email templates |
|
|
79
|
+
| 📬 | **Postmark** | Transactional email with top deliverability |
|
|
79
80
|
| 🔥 | **Firecrawl** | Web scraping service |
|
|
80
81
|
| ⏰ | **Inngest** | Background jobs |
|
|
81
82
|
| 📁 | **UploadThing** | File uploads |
|
|
82
|
-
| 💳 | **Stripe** |
|
|
83
|
+
| 💳 | **Stripe** | Payment service with checkout + billing |
|
|
83
84
|
| 📊 | **PostHog** | Product analytics |
|
|
84
85
|
| 🐛 | **Sentry** | Error tracking |
|
|
85
86
|
|
|
@@ -89,27 +90,94 @@ your-app/
|
|
|
89
90
|
|
|
90
91
|
## How It Works
|
|
91
92
|
|
|
92
|
-
###
|
|
93
|
+
### Interactive Mode
|
|
93
94
|
|
|
94
95
|
```bash
|
|
95
96
|
npx create-loadout
|
|
96
97
|
```
|
|
97
98
|
|
|
98
|
-
|
|
99
|
+
Answer the prompts — project name, integrations, AI provider — and you're done.
|
|
99
100
|
|
|
100
|
-
-
|
|
101
|
-
- Which integrations you need
|
|
102
|
-
- AI provider (if using AI SDK)
|
|
101
|
+
### Non-Interactive Mode
|
|
103
102
|
|
|
104
|
-
|
|
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
|
|
105
153
|
|
|
106
154
|
```bash
|
|
107
155
|
cd your-app
|
|
108
|
-
npm install
|
|
109
156
|
npm run dev
|
|
110
157
|
```
|
|
111
158
|
|
|
112
|
-
|
|
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 |
|
|
113
181
|
|
|
114
182
|
---
|
|
115
183
|
|
package/dist/claude-md.js
CHANGED
|
@@ -43,6 +43,13 @@ const stackSections = [
|
|
|
43
43
|
{ name: 'Resend', url: 'https://resend.com/docs', description: 'Email API' },
|
|
44
44
|
],
|
|
45
45
|
},
|
|
46
|
+
{
|
|
47
|
+
id: 'postmark',
|
|
48
|
+
name: 'Email',
|
|
49
|
+
items: [
|
|
50
|
+
{ name: 'Postmark', url: 'https://postmarkapp.com/developer', description: 'Transactional email' },
|
|
51
|
+
],
|
|
52
|
+
},
|
|
46
53
|
{
|
|
47
54
|
id: 'firecrawl',
|
|
48
55
|
name: 'Scraping',
|
|
@@ -142,8 +149,8 @@ npm run inngest:dev # Start Inngest dev server for local testing
|
|
|
142
149
|
├── app/ # Next.js App Router pages and API routes
|
|
143
150
|
├── components/ # React components (including shadcn/ui)
|
|
144
151
|
`;
|
|
145
|
-
if (config.integrations.includes('resend')) {
|
|
146
|
-
content += `│ └── emails/ #
|
|
152
|
+
if (config.integrations.includes('resend') || config.integrations.includes('postmark')) {
|
|
153
|
+
content += `│ └── emails/ # Email templates
|
|
147
154
|
`;
|
|
148
155
|
}
|
|
149
156
|
if (hasPostHog || hasSentry) {
|
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 {
|
|
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
|
|
41
|
-
const
|
|
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
|
-
|
|
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
|
-
}
|
package/dist/config.js
CHANGED
|
@@ -14,6 +14,10 @@ const staticConfigVars = {
|
|
|
14
14
|
{ name: 'RESEND_API_KEY', envKey: 'RESEND_API_KEY', isPublic: false },
|
|
15
15
|
{ name: 'RESEND_FROM_EMAIL', envKey: 'RESEND_FROM_EMAIL', isPublic: false, defaultValue: 'onboarding@resend.dev' },
|
|
16
16
|
],
|
|
17
|
+
postmark: [
|
|
18
|
+
{ name: 'POSTMARK_SERVER_TOKEN', envKey: 'POSTMARK_SERVER_TOKEN', isPublic: false },
|
|
19
|
+
{ name: 'POSTMARK_FROM_EMAIL', envKey: 'POSTMARK_FROM_EMAIL', isPublic: false },
|
|
20
|
+
],
|
|
17
21
|
firecrawl: [
|
|
18
22
|
{ name: 'FIRECRAWL_API_KEY', envKey: 'FIRECRAWL_API_KEY', isPublic: false },
|
|
19
23
|
],
|
package/dist/create-next.js
CHANGED
package/dist/detect.js
CHANGED
|
@@ -5,6 +5,7 @@ const integrationPackages = {
|
|
|
5
5
|
'neon-drizzle': ['drizzle-orm', '@neondatabase/serverless'],
|
|
6
6
|
'ai-sdk': ['ai'],
|
|
7
7
|
resend: ['resend'],
|
|
8
|
+
postmark: ['postmark'],
|
|
8
9
|
firecrawl: ['@mendable/firecrawl-js'],
|
|
9
10
|
inngest: ['inngest'],
|
|
10
11
|
uploadthing: ['uploadthing'],
|
|
@@ -49,6 +50,7 @@ export function getAvailableIntegrations(installed) {
|
|
|
49
50
|
'neon-drizzle',
|
|
50
51
|
'ai-sdk',
|
|
51
52
|
'resend',
|
|
53
|
+
'postmark',
|
|
52
54
|
'firecrawl',
|
|
53
55
|
'inngest',
|
|
54
56
|
'uploadthing',
|
|
@@ -56,5 +58,13 @@ export function getAvailableIntegrations(installed) {
|
|
|
56
58
|
'posthog',
|
|
57
59
|
'sentry',
|
|
58
60
|
];
|
|
59
|
-
|
|
61
|
+
const emailProviders = ['resend', 'postmark'];
|
|
62
|
+
const hasEmail = emailProviders.some((id) => installed.includes(id));
|
|
63
|
+
return all.filter((id) => {
|
|
64
|
+
if (installed.includes(id))
|
|
65
|
+
return false;
|
|
66
|
+
if (hasEmail && emailProviders.includes(id))
|
|
67
|
+
return false;
|
|
68
|
+
return true;
|
|
69
|
+
});
|
|
60
70
|
}
|
package/dist/engine.d.ts
ADDED
|
@@ -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/env.js
CHANGED
|
@@ -29,6 +29,14 @@ const staticEnvSections = {
|
|
|
29
29
|
{ key: 'RESEND_FROM_EMAIL', example: 'onboarding@resend.dev', description: 'Default from email address' },
|
|
30
30
|
],
|
|
31
31
|
},
|
|
32
|
+
postmark: {
|
|
33
|
+
name: 'POSTMARK - Email',
|
|
34
|
+
url: 'https://account.postmarkapp.com/servers',
|
|
35
|
+
vars: [
|
|
36
|
+
{ key: 'POSTMARK_SERVER_TOKEN', example: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx', description: 'Postmark server token' },
|
|
37
|
+
{ key: 'POSTMARK_FROM_EMAIL', example: 'hello@yourdomain.com', description: 'Default from email address' },
|
|
38
|
+
],
|
|
39
|
+
},
|
|
32
40
|
firecrawl: {
|
|
33
41
|
name: 'FIRECRAWL - Scraping',
|
|
34
42
|
url: 'https://firecrawl.dev',
|
package/dist/generate-readme.js
CHANGED
|
@@ -5,6 +5,7 @@ const integrationNames = {
|
|
|
5
5
|
'neon-drizzle': 'Neon + Drizzle',
|
|
6
6
|
'ai-sdk': 'Vercel AI SDK',
|
|
7
7
|
resend: 'Resend',
|
|
8
|
+
postmark: 'Postmark',
|
|
8
9
|
firecrawl: 'Firecrawl',
|
|
9
10
|
inngest: 'Inngest',
|
|
10
11
|
uploadthing: 'UploadThing',
|
|
@@ -89,7 +90,7 @@ npm run inngest:dev # Start Inngest dev server
|
|
|
89
90
|
├── lib/ # Utilities and clients
|
|
90
91
|
├── services/ # Business logic
|
|
91
92
|
`;
|
|
92
|
-
if (config.integrations.includes('resend')) {
|
|
93
|
+
if (config.integrations.includes('resend') || config.integrations.includes('postmark')) {
|
|
93
94
|
content += `├── emails/ # Email templates\n`;
|
|
94
95
|
}
|
|
95
96
|
content += `└── public/ # Static assets
|
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
|
-
|
|
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>>;
|
|
@@ -7,6 +7,7 @@ import { firecrawlIntegration } from './firecrawl.js';
|
|
|
7
7
|
import { inngestIntegration } from './inngest.js';
|
|
8
8
|
import { uploadthingIntegration } from './uploadthing.js';
|
|
9
9
|
import { stripeIntegration } from './stripe.js';
|
|
10
|
+
import { postmarkIntegration } from './postmark.js';
|
|
10
11
|
import { posthogIntegration } from './posthog.js';
|
|
11
12
|
import { sentryIntegration } from './sentry.js';
|
|
12
13
|
// Static integrations (don't need config)
|
|
@@ -18,11 +19,12 @@ const staticIntegrations = {
|
|
|
18
19
|
inngest: inngestIntegration,
|
|
19
20
|
uploadthing: uploadthingIntegration,
|
|
20
21
|
stripe: stripeIntegration,
|
|
22
|
+
postmark: postmarkIntegration,
|
|
21
23
|
posthog: posthogIntegration,
|
|
22
24
|
sentry: sentryIntegration,
|
|
23
25
|
};
|
|
24
26
|
// Get integration, with dynamic ones using config
|
|
25
|
-
function getIntegration(id, config) {
|
|
27
|
+
export function getIntegration(id, config) {
|
|
26
28
|
if (id === 'ai-sdk') {
|
|
27
29
|
return createAiSdkIntegration(config.aiProvider ?? 'openai');
|
|
28
30
|
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import fs from 'fs/promises';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { postmarkTemplates } from '../templates/postmark.js';
|
|
4
|
+
export const postmarkIntegration = {
|
|
5
|
+
id: 'postmark',
|
|
6
|
+
name: 'Postmark',
|
|
7
|
+
description: 'Transactional email',
|
|
8
|
+
packages: ['postmark'],
|
|
9
|
+
envVars: [
|
|
10
|
+
{
|
|
11
|
+
key: 'POSTMARK_SERVER_TOKEN',
|
|
12
|
+
description: 'Postmark server token',
|
|
13
|
+
example: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx',
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
key: 'POSTMARK_FROM_EMAIL',
|
|
17
|
+
description: 'Default from email address',
|
|
18
|
+
example: 'hello@yourdomain.com',
|
|
19
|
+
},
|
|
20
|
+
],
|
|
21
|
+
setup: async (projectPath) => {
|
|
22
|
+
// Create email service
|
|
23
|
+
await fs.mkdir(path.join(projectPath, 'services'), { recursive: true });
|
|
24
|
+
await fs.writeFile(path.join(projectPath, 'services/email.service.ts'), postmarkTemplates.emailService);
|
|
25
|
+
// Create email templates in components/emails
|
|
26
|
+
await fs.mkdir(path.join(projectPath, 'components/emails'), { recursive: true });
|
|
27
|
+
await fs.writeFile(path.join(projectPath, 'components/emails/welcome.tsx'), postmarkTemplates.welcomeEmail);
|
|
28
|
+
// Create API route
|
|
29
|
+
await fs.mkdir(path.join(projectPath, 'app/api/email/send'), {
|
|
30
|
+
recursive: true,
|
|
31
|
+
});
|
|
32
|
+
await fs.writeFile(path.join(projectPath, 'app/api/email/send/route.ts'), postmarkTemplates.sendRoute);
|
|
33
|
+
},
|
|
34
|
+
};
|