shipd 0.1.3 → 0.1.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/base-package/app/globals.css +126 -0
- package/base-package/app/layout.tsx +53 -0
- package/base-package/app/page.tsx +15 -0
- package/base-package/base.config.json +57 -0
- package/base-package/components/ui/avatar.tsx +53 -0
- package/base-package/components/ui/badge.tsx +46 -0
- package/base-package/components/ui/button.tsx +59 -0
- package/base-package/components/ui/card.tsx +92 -0
- package/base-package/components/ui/chart.tsx +353 -0
- package/base-package/components/ui/checkbox.tsx +32 -0
- package/base-package/components/ui/dialog.tsx +135 -0
- package/base-package/components/ui/dropdown-menu.tsx +257 -0
- package/base-package/components/ui/form.tsx +167 -0
- package/base-package/components/ui/input.tsx +21 -0
- package/base-package/components/ui/label.tsx +24 -0
- package/base-package/components/ui/progress.tsx +31 -0
- package/base-package/components/ui/resizable.tsx +56 -0
- package/base-package/components/ui/select.tsx +185 -0
- package/base-package/components/ui/separator.tsx +28 -0
- package/base-package/components/ui/sheet.tsx +139 -0
- package/base-package/components/ui/skeleton.tsx +13 -0
- package/base-package/components/ui/sonner.tsx +25 -0
- package/base-package/components/ui/switch.tsx +31 -0
- package/base-package/components/ui/tabs.tsx +66 -0
- package/base-package/components/ui/textarea.tsx +18 -0
- package/base-package/components/ui/toggle-group.tsx +73 -0
- package/base-package/components/ui/toggle.tsx +47 -0
- package/base-package/components/ui/tooltip.tsx +61 -0
- package/base-package/components.json +21 -0
- package/base-package/eslint.config.mjs +16 -0
- package/base-package/lib/utils.ts +6 -0
- package/base-package/middleware.ts +12 -0
- package/base-package/next.config.ts +27 -0
- package/base-package/package.json +49 -0
- package/base-package/postcss.config.mjs +5 -0
- package/base-package/public/favicon.svg +4 -0
- package/base-package/tailwind.config.ts +89 -0
- package/base-package/tsconfig.json +27 -0
- package/dist/index.js +1858 -956
- package/features/ai-chat/README.md +258 -0
- package/features/ai-chat/app/api/chat/route.ts +16 -0
- package/features/ai-chat/app/dashboard/_components/chatbot.tsx +39 -0
- package/features/ai-chat/app/dashboard/chat/page.tsx +73 -0
- package/features/ai-chat/feature.config.json +22 -0
- package/features/analytics/README.md +308 -0
- package/features/analytics/feature.config.json +20 -0
- package/features/analytics/lib/posthog.ts +36 -0
- package/features/auth/README.md +336 -0
- package/features/auth/app/api/auth/[...all]/route.ts +4 -0
- package/features/auth/app/dashboard/layout.tsx +15 -0
- package/features/auth/app/dashboard/page.tsx +140 -0
- package/features/auth/app/sign-in/page.tsx +228 -0
- package/features/auth/app/sign-up/page.tsx +243 -0
- package/features/auth/auth-schema.ts +47 -0
- package/features/auth/components/auth/setup-instructions.tsx +123 -0
- package/features/auth/feature.config.json +33 -0
- package/features/auth/lib/auth-client.ts +8 -0
- package/features/auth/lib/auth.ts +295 -0
- package/features/auth/lib/email-stub.ts +55 -0
- package/features/auth/lib/email.ts +47 -0
- package/features/auth/middleware.patch.ts +43 -0
- package/features/database/README.md +256 -0
- package/features/database/db/drizzle.ts +48 -0
- package/features/database/db/schema.ts +21 -0
- package/features/database/drizzle.config.ts +13 -0
- package/features/database/feature.config.json +30 -0
- package/features/email/README.md +282 -0
- package/features/email/emails/components/layout.tsx +181 -0
- package/features/email/emails/password-reset.tsx +67 -0
- package/features/email/emails/payment-failed.tsx +167 -0
- package/features/email/emails/subscription-confirmation.tsx +129 -0
- package/features/email/emails/welcome.tsx +100 -0
- package/features/email/feature.config.json +22 -0
- package/features/email/lib/email.ts +118 -0
- package/features/file-upload/README.md +271 -0
- package/features/file-upload/app/api/upload-image/route.ts +64 -0
- package/features/file-upload/app/dashboard/upload/page.tsx +324 -0
- package/features/file-upload/feature.config.json +23 -0
- package/features/file-upload/lib/upload-image.ts +28 -0
- package/features/marketing-landing/README.md +266 -0
- package/features/marketing-landing/app/page.tsx +25 -0
- package/features/marketing-landing/components/homepage/cli-workflow-section.tsx +231 -0
- package/features/marketing-landing/components/homepage/features-section.tsx +152 -0
- package/features/marketing-landing/components/homepage/footer.tsx +53 -0
- package/features/marketing-landing/components/homepage/hero-section.tsx +112 -0
- package/features/marketing-landing/components/homepage/integrations.tsx +124 -0
- package/features/marketing-landing/components/homepage/navigation.tsx +116 -0
- package/features/marketing-landing/components/homepage/news-section.tsx +82 -0
- package/features/marketing-landing/components/homepage/pricing-section.tsx +98 -0
- package/features/marketing-landing/components/homepage/testimonials-section.tsx +34 -0
- package/features/marketing-landing/components/logos/BetterAuth.tsx +21 -0
- package/features/marketing-landing/components/logos/NeonPostgres.tsx +41 -0
- package/features/marketing-landing/components/logos/Nextjs.tsx +72 -0
- package/features/marketing-landing/components/logos/Polar.tsx +7 -0
- package/features/marketing-landing/components/logos/TailwindCSS.tsx +27 -0
- package/features/marketing-landing/components/logos/index.ts +6 -0
- package/features/marketing-landing/components/logos/shadcnui.tsx +8 -0
- package/features/marketing-landing/feature.config.json +23 -0
- package/features/payments/README.md +306 -0
- package/features/payments/app/api/subscription/route.ts +25 -0
- package/features/payments/app/dashboard/payment/_components/manage-subscription.tsx +22 -0
- package/features/payments/app/dashboard/payment/page.tsx +126 -0
- package/features/payments/app/success/page.tsx +123 -0
- package/features/payments/feature.config.json +31 -0
- package/features/payments/lib/polar-products.ts +49 -0
- package/features/payments/lib/subscription.ts +148 -0
- package/features/payments/payments-schema.ts +30 -0
- package/features/seo/README.md +244 -0
- package/features/seo/app/blog/[slug]/page.tsx +314 -0
- package/features/seo/app/blog/page.tsx +107 -0
- package/features/seo/app/robots.txt +13 -0
- package/features/seo/app/sitemap.ts +70 -0
- package/features/seo/feature.config.json +19 -0
- package/features/seo/lib/seo-utils.ts +163 -0
- package/package.json +3 -1
package/dist/index.js
CHANGED
|
@@ -29,12 +29,27 @@ function validateProjectName(name) {
|
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
// src/prompts/index.ts
|
|
32
|
-
async function runPrompts(projectName) {
|
|
32
|
+
async function runPrompts(projectName, options) {
|
|
33
33
|
if (projectName) {
|
|
34
34
|
console.log(`
|
|
35
35
|
\u{1F4C1} Project name: ${projectName}`);
|
|
36
36
|
}
|
|
37
|
-
|
|
37
|
+
let selectedFeatures = [];
|
|
38
|
+
if (options?.features) {
|
|
39
|
+
selectedFeatures = options.features.split(",").map((f) => f.trim());
|
|
40
|
+
console.log(`
|
|
41
|
+
\u{1F4E6} Selected features: ${selectedFeatures.join(", ")}`);
|
|
42
|
+
}
|
|
43
|
+
const skipPrompts = projectName && options?.features && options?.description && options?.packageManager;
|
|
44
|
+
if (skipPrompts) {
|
|
45
|
+
return {
|
|
46
|
+
projectName,
|
|
47
|
+
description: options.description,
|
|
48
|
+
features: selectedFeatures,
|
|
49
|
+
packageManager: options.packageManager
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
const prompts = [
|
|
38
53
|
{
|
|
39
54
|
type: "input",
|
|
40
55
|
name: "projectName",
|
|
@@ -47,41 +62,52 @@ async function runPrompts(projectName) {
|
|
|
47
62
|
type: "input",
|
|
48
63
|
name: "description",
|
|
49
64
|
message: "Project description:",
|
|
50
|
-
default: "A modern SaaS application"
|
|
51
|
-
|
|
52
|
-
|
|
65
|
+
default: options?.description || "A modern SaaS application",
|
|
66
|
+
when: !options?.description
|
|
67
|
+
}
|
|
68
|
+
];
|
|
69
|
+
if (!options?.features) {
|
|
70
|
+
prompts.push({
|
|
53
71
|
type: "checkbox",
|
|
54
72
|
name: "features",
|
|
55
73
|
message: "Select optional features to include:",
|
|
56
74
|
choices: [
|
|
57
|
-
{ name: "
|
|
58
|
-
{ name: "
|
|
59
|
-
{ name: "
|
|
60
|
-
{ name: "
|
|
75
|
+
{ name: "Marketing Landing Page", value: "marketing-landing", checked: true },
|
|
76
|
+
{ name: "Documentation Pages", value: "docs", checked: false },
|
|
77
|
+
{ name: "Database (PostgreSQL + Drizzle)", value: "database", checked: false },
|
|
78
|
+
{ name: "Authentication (Better Auth)", value: "auth", checked: false },
|
|
79
|
+
{ name: "Payments (Polar.sh)", value: "payments", checked: false },
|
|
80
|
+
{ name: "Email (Resend)", value: "email", checked: false },
|
|
81
|
+
{ name: "AI Chat (OpenAI)", value: "ai-chat", checked: false },
|
|
82
|
+
{ name: "File Upload (Cloudflare R2)", value: "file-upload", checked: false },
|
|
83
|
+
{ name: "Analytics (PostHog)", value: "analytics", checked: false },
|
|
84
|
+
{ name: "SEO (Sitemap, Robots, Blog)", value: "seo", checked: false }
|
|
61
85
|
]
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
prompts.push({
|
|
89
|
+
type: "list",
|
|
90
|
+
name: "packageManager",
|
|
91
|
+
message: "Package manager:",
|
|
92
|
+
choices: ["npm", "pnpm", "yarn"],
|
|
93
|
+
default: options?.packageManager || "npm",
|
|
94
|
+
when: !options?.packageManager
|
|
95
|
+
});
|
|
96
|
+
const answers = await inquirer.prompt(prompts);
|
|
71
97
|
return {
|
|
72
98
|
projectName: projectName || answers.projectName,
|
|
73
|
-
description: answers.description,
|
|
74
|
-
features:
|
|
75
|
-
|
|
76
|
-
packageManager: answers.packageManager
|
|
99
|
+
description: options?.description || answers.description,
|
|
100
|
+
features: options?.features ? selectedFeatures : answers.features || [],
|
|
101
|
+
packageManager: options?.packageManager || answers.packageManager
|
|
77
102
|
};
|
|
78
103
|
}
|
|
79
104
|
|
|
80
105
|
// src/generators/project.ts
|
|
81
|
-
import { resolve as
|
|
82
|
-
import
|
|
83
|
-
import
|
|
84
|
-
import
|
|
106
|
+
import { resolve as resolve4, dirname as dirname3 } from "path";
|
|
107
|
+
import { fileURLToPath as fileURLToPath3 } from "url";
|
|
108
|
+
import ora2 from "ora";
|
|
109
|
+
import { execa as execa2 } from "execa";
|
|
110
|
+
import fs2 from "fs-extra";
|
|
85
111
|
|
|
86
112
|
// src/utils/template-config.ts
|
|
87
113
|
import { readFile } from "fs/promises";
|
|
@@ -90,56 +116,25 @@ import { join, resolve as resolve2, dirname } from "path";
|
|
|
90
116
|
import { fileURLToPath } from "url";
|
|
91
117
|
var __filename = fileURLToPath(import.meta.url);
|
|
92
118
|
var __dirname = dirname(__filename);
|
|
93
|
-
function
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
let currentDir = __dirname;
|
|
111
|
-
for (let i = 0; i < 10; i++) {
|
|
112
|
-
const pkgJsonPath = join(currentDir, "package.json");
|
|
113
|
-
if (existsSync2(pkgJsonPath)) {
|
|
114
|
-
const templatePath = join(currentDir, "template");
|
|
115
|
-
triedPaths.push(templatePath);
|
|
116
|
-
if (existsSync2(templatePath)) {
|
|
117
|
-
return templatePath;
|
|
118
|
-
}
|
|
119
|
-
break;
|
|
120
|
-
}
|
|
121
|
-
const parentDir = resolve2(currentDir, "..");
|
|
122
|
-
if (parentDir === currentDir) {
|
|
123
|
-
break;
|
|
124
|
-
}
|
|
125
|
-
currentDir = parentDir;
|
|
126
|
-
}
|
|
127
|
-
const absolutePath = resolve2(__dirname, "../../../../packages/template");
|
|
128
|
-
triedPaths.push(absolutePath);
|
|
129
|
-
if (existsSync2(absolutePath)) {
|
|
130
|
-
return absolutePath;
|
|
119
|
+
function getBasePath() {
|
|
120
|
+
const candidates = [
|
|
121
|
+
// bundled: dist/utils -> ../base-package
|
|
122
|
+
resolve2(__dirname, "../base-package"),
|
|
123
|
+
// bundled alt: dist/utils -> ../../base-package
|
|
124
|
+
resolve2(__dirname, "../../base-package"),
|
|
125
|
+
// source: src/utils -> ../../base-package
|
|
126
|
+
resolve2(__dirname, "../../../base-package"),
|
|
127
|
+
// monorepo absolute fallback
|
|
128
|
+
resolve2(__dirname, "../../../../packages/cli/base-package")
|
|
129
|
+
];
|
|
130
|
+
const found = candidates.find((p) => existsSync2(p));
|
|
131
|
+
if (!found) {
|
|
132
|
+
throw new Error(
|
|
133
|
+
`Base package directory not found. Tried paths:
|
|
134
|
+
${candidates.map((p) => ` - ${p}`).join("\n")}`
|
|
135
|
+
);
|
|
131
136
|
}
|
|
132
|
-
|
|
133
|
-
${triedPaths.map((p) => ` - ${p}`).join("\n")}`);
|
|
134
|
-
}
|
|
135
|
-
async function loadTemplateConfig() {
|
|
136
|
-
const templatePath = getTemplatePath();
|
|
137
|
-
const configPath = join(templatePath, "template.config.json");
|
|
138
|
-
if (!existsSync2(configPath)) {
|
|
139
|
-
throw new Error(`template.config.json not found at ${configPath}`);
|
|
140
|
-
}
|
|
141
|
-
const content = await readFile(configPath, "utf-8");
|
|
142
|
-
return JSON.parse(content);
|
|
137
|
+
return found;
|
|
143
138
|
}
|
|
144
139
|
|
|
145
140
|
// src/utils/file-system.ts
|
|
@@ -175,85 +170,6 @@ function shouldExclude(filePath, patterns) {
|
|
|
175
170
|
return regex.test(filePath);
|
|
176
171
|
});
|
|
177
172
|
}
|
|
178
|
-
async function removeFeatureFiles(targetPath, featureFiles) {
|
|
179
|
-
for (const filePattern of featureFiles) {
|
|
180
|
-
const files = await globby(filePattern, {
|
|
181
|
-
cwd: targetPath,
|
|
182
|
-
absolute: true,
|
|
183
|
-
dot: true
|
|
184
|
-
});
|
|
185
|
-
for (const file of files) {
|
|
186
|
-
await rm(file, { recursive: true, force: true });
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
async function removeEmptyDirectories(dirPath) {
|
|
191
|
-
if (!existsSync3(dirPath)) {
|
|
192
|
-
return;
|
|
193
|
-
}
|
|
194
|
-
const entries = await readdir(dirPath, { withFileTypes: true });
|
|
195
|
-
for (const entry of entries) {
|
|
196
|
-
if (entry.isDirectory()) {
|
|
197
|
-
const fullPath = join2(dirPath, entry.name);
|
|
198
|
-
await removeEmptyDirectories(fullPath);
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
const remaining = await readdir(dirPath);
|
|
202
|
-
if (remaining.length === 0) {
|
|
203
|
-
await rm(dirPath, { recursive: true, force: true });
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
async function removeConditionalBlocks(targetPath, unselectedFeatures) {
|
|
207
|
-
const featureMarkers = {
|
|
208
|
-
"ai-chat": "AI_CHAT",
|
|
209
|
-
"payments": "PAYMENTS",
|
|
210
|
-
"file-upload": "FILE_UPLOAD",
|
|
211
|
-
"analytics": "ANALYTICS"
|
|
212
|
-
};
|
|
213
|
-
const filesToCheck = await globby("**/*.{ts,tsx,js,jsx}", {
|
|
214
|
-
cwd: targetPath,
|
|
215
|
-
absolute: true,
|
|
216
|
-
ignore: ["**/node_modules/**", "**/.next/**"]
|
|
217
|
-
});
|
|
218
|
-
for (const filePath of filesToCheck) {
|
|
219
|
-
let content = await readFile2(filePath, "utf-8");
|
|
220
|
-
let modified = false;
|
|
221
|
-
for (const feature of unselectedFeatures) {
|
|
222
|
-
const marker = featureMarkers[feature];
|
|
223
|
-
if (!marker) continue;
|
|
224
|
-
const blockRegex = new RegExp(
|
|
225
|
-
`\\/\\*${marker}_START\\*\\/[\\s\\S]*?\\/\\*${marker}_END\\*\\/\\n?`,
|
|
226
|
-
"g"
|
|
227
|
-
);
|
|
228
|
-
const jsxBlockRegex = new RegExp(
|
|
229
|
-
`\\{\\s*\\/\\*${marker}_START\\*\\/\\s*\\}[\\s\\S]*?\\{\\s*\\/\\*${marker}_END\\*\\/\\s*\\}\\n?`,
|
|
230
|
-
"g"
|
|
231
|
-
);
|
|
232
|
-
if (blockRegex.test(content) || jsxBlockRegex.test(content)) {
|
|
233
|
-
content = content.replace(blockRegex, "");
|
|
234
|
-
content = content.replace(jsxBlockRegex, "");
|
|
235
|
-
modified = true;
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
if (modified) {
|
|
239
|
-
await writeFile(filePath, content, "utf-8");
|
|
240
|
-
}
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
async function removeUnselectedFeatures(targetPath, selectedFeatures, templateConfig) {
|
|
244
|
-
const allFeatures = Object.keys(templateConfig.features);
|
|
245
|
-
const unselectedFeatures = allFeatures.filter(
|
|
246
|
-
(f) => !selectedFeatures.includes(f) && templateConfig.features[f].optional
|
|
247
|
-
);
|
|
248
|
-
await removeConditionalBlocks(targetPath, unselectedFeatures);
|
|
249
|
-
for (const feature of unselectedFeatures) {
|
|
250
|
-
const featureConfig = templateConfig.features[feature];
|
|
251
|
-
if (featureConfig.files.length > 0) {
|
|
252
|
-
await removeFeatureFiles(targetPath, featureConfig.files);
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
await removeEmptyDirectories(targetPath);
|
|
256
|
-
}
|
|
257
173
|
|
|
258
174
|
// src/transformers/template-vars.ts
|
|
259
175
|
import { readFile as readFile3, writeFile as writeFile2 } from "fs/promises";
|
|
@@ -291,6 +207,7 @@ async function replaceTemplateVariables(targetPath, config) {
|
|
|
291
207
|
"package.json",
|
|
292
208
|
"README.md",
|
|
293
209
|
"app/layout.tsx",
|
|
210
|
+
"app/page.tsx",
|
|
294
211
|
"app/sign-in/page.tsx",
|
|
295
212
|
"app/sign-up/page.tsx",
|
|
296
213
|
".env.example"
|
|
@@ -301,238 +218,6 @@ async function replaceTemplateVariables(targetPath, config) {
|
|
|
301
218
|
}
|
|
302
219
|
}
|
|
303
220
|
|
|
304
|
-
// src/generators/package-json.ts
|
|
305
|
-
import { readFile as readFile4, writeFile as writeFile3 } from "fs/promises";
|
|
306
|
-
import { join as join4 } from "path";
|
|
307
|
-
async function generatePackageJson(targetPath, selectedFeatures, templateConfig) {
|
|
308
|
-
const packageJsonPath = join4(targetPath, "package.json");
|
|
309
|
-
const packageJson = JSON.parse(await readFile4(packageJsonPath, "utf-8"));
|
|
310
|
-
const templatePackageJsonPath = join4(getTemplatePath(), "package.json");
|
|
311
|
-
const templatePackageJson = JSON.parse(await readFile4(templatePackageJsonPath, "utf-8"));
|
|
312
|
-
const dependencies = {};
|
|
313
|
-
const devDependencies = {};
|
|
314
|
-
const coreDeps = [
|
|
315
|
-
"next",
|
|
316
|
-
"react",
|
|
317
|
-
"react-dom",
|
|
318
|
-
"typescript",
|
|
319
|
-
"tailwindcss",
|
|
320
|
-
"dotenv",
|
|
321
|
-
"@radix-ui/react-avatar",
|
|
322
|
-
"@radix-ui/react-slot",
|
|
323
|
-
"@radix-ui/react-dialog",
|
|
324
|
-
"@radix-ui/react-dropdown-menu",
|
|
325
|
-
"@radix-ui/react-label",
|
|
326
|
-
"@radix-ui/react-tabs",
|
|
327
|
-
"@radix-ui/react-separator",
|
|
328
|
-
"@radix-ui/react-checkbox",
|
|
329
|
-
"@radix-ui/react-switch",
|
|
330
|
-
"class-variance-authority",
|
|
331
|
-
"clsx",
|
|
332
|
-
"tailwind-merge",
|
|
333
|
-
"lucide-react",
|
|
334
|
-
"next-themes",
|
|
335
|
-
"sonner",
|
|
336
|
-
"tailwindcss-animate",
|
|
337
|
-
"framer-motion",
|
|
338
|
-
"motion",
|
|
339
|
-
"@vercel/analytics",
|
|
340
|
-
"zod",
|
|
341
|
-
"react-hook-form",
|
|
342
|
-
"@hookform/resolvers",
|
|
343
|
-
"drizzle-orm",
|
|
344
|
-
"postgres",
|
|
345
|
-
"better-auth",
|
|
346
|
-
"@polar-sh/better-auth",
|
|
347
|
-
"@polar-sh/sdk",
|
|
348
|
-
"@neondatabase/serverless",
|
|
349
|
-
"resend",
|
|
350
|
-
"@react-email/components",
|
|
351
|
-
"@react-email/render"
|
|
352
|
-
];
|
|
353
|
-
const coreDevDeps = [
|
|
354
|
-
"@types/node",
|
|
355
|
-
"@types/react",
|
|
356
|
-
"@types/react-dom",
|
|
357
|
-
"eslint",
|
|
358
|
-
"eslint-config-next",
|
|
359
|
-
"@eslint/eslintrc",
|
|
360
|
-
"drizzle-kit",
|
|
361
|
-
"@tailwindcss/postcss",
|
|
362
|
-
"tw-animate-css"
|
|
363
|
-
];
|
|
364
|
-
coreDeps.forEach((dep) => {
|
|
365
|
-
if (templatePackageJson.dependencies?.[dep]) {
|
|
366
|
-
dependencies[dep] = templatePackageJson.dependencies[dep];
|
|
367
|
-
}
|
|
368
|
-
});
|
|
369
|
-
coreDevDeps.forEach((dep) => {
|
|
370
|
-
if (templatePackageJson.devDependencies?.[dep]) {
|
|
371
|
-
devDependencies[dep] = templatePackageJson.devDependencies[dep];
|
|
372
|
-
}
|
|
373
|
-
});
|
|
374
|
-
for (const feature of selectedFeatures) {
|
|
375
|
-
const featureConfig = templateConfig.features[feature];
|
|
376
|
-
if (featureConfig) {
|
|
377
|
-
for (const dep of featureConfig.dependencies || []) {
|
|
378
|
-
if (templatePackageJson.dependencies?.[dep]) {
|
|
379
|
-
dependencies[dep] = templatePackageJson.dependencies[dep];
|
|
380
|
-
}
|
|
381
|
-
}
|
|
382
|
-
for (const dep of featureConfig.devDependencies || []) {
|
|
383
|
-
if (templatePackageJson.devDependencies?.[dep]) {
|
|
384
|
-
devDependencies[dep] = templatePackageJson.devDependencies[dep];
|
|
385
|
-
}
|
|
386
|
-
}
|
|
387
|
-
}
|
|
388
|
-
}
|
|
389
|
-
const sortedDeps = Object.keys(dependencies).sort().reduce((acc, key) => {
|
|
390
|
-
acc[key] = dependencies[key];
|
|
391
|
-
return acc;
|
|
392
|
-
}, {});
|
|
393
|
-
const sortedDevDeps = Object.keys(devDependencies).sort().reduce((acc, key) => {
|
|
394
|
-
acc[key] = devDependencies[key];
|
|
395
|
-
return acc;
|
|
396
|
-
}, {});
|
|
397
|
-
packageJson.dependencies = sortedDeps;
|
|
398
|
-
packageJson.devDependencies = sortedDevDeps;
|
|
399
|
-
await writeFile3(packageJsonPath, JSON.stringify(packageJson, null, 2) + "\n");
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
// src/generators/env.ts
|
|
403
|
-
import { writeFile as writeFile4 } from "fs/promises";
|
|
404
|
-
import { join as join5 } from "path";
|
|
405
|
-
var ENV_DEFAULTS = {
|
|
406
|
-
// Database
|
|
407
|
-
"DATABASE_URL": "postgresql://user:password@localhost:5432/mydb",
|
|
408
|
-
// Auth
|
|
409
|
-
"BETTER_AUTH_SECRET": "please-change-this-to-a-random-32-character-string-or-longer",
|
|
410
|
-
"GOOGLE_CLIENT_ID": "your-google-client-id.apps.googleusercontent.com",
|
|
411
|
-
"GOOGLE_CLIENT_SECRET": "your-google-client-secret",
|
|
412
|
-
// Polar.sh Payments
|
|
413
|
-
"POLAR_ACCESS_TOKEN": "your-polar-access-token",
|
|
414
|
-
"POLAR_SUCCESS_URL": "http://localhost:3000/success",
|
|
415
|
-
"POLAR_WEBHOOK_SECRET": "your-polar-webhook-secret",
|
|
416
|
-
"NEXT_PUBLIC_STARTER_TIER": "your-starter-tier-id",
|
|
417
|
-
"NEXT_PUBLIC_STARTER_SLUG": "your-organization-slug",
|
|
418
|
-
// OpenAI
|
|
419
|
-
"OPENAI_API_KEY": "sk-your-openai-api-key",
|
|
420
|
-
// Cloudflare R2
|
|
421
|
-
"R2_UPLOAD_IMAGE_ACCESS_KEY_ID": "your-r2-access-key-id",
|
|
422
|
-
"R2_UPLOAD_IMAGE_SECRET_ACCESS_KEY": "your-r2-secret-access-key",
|
|
423
|
-
"CLOUDFLARE_ACCOUNT_ID": "your-cloudflare-account-id",
|
|
424
|
-
"R2_UPLOAD_IMAGE_BUCKET_NAME": "your-bucket-name",
|
|
425
|
-
// PostHog
|
|
426
|
-
"NEXT_PUBLIC_POSTHOG_KEY": "phc_your-posthog-project-key",
|
|
427
|
-
"NEXT_PUBLIC_POSTHOG_HOST": "https://app.posthog.com"
|
|
428
|
-
};
|
|
429
|
-
async function generateEnvExample(targetPath, selectedFeatures, templateConfig) {
|
|
430
|
-
const lines = [];
|
|
431
|
-
lines.push("# Environment Variables");
|
|
432
|
-
lines.push("# Copy this file to .env.local and customize the values");
|
|
433
|
-
lines.push("");
|
|
434
|
-
lines.push("# \u26A0\uFE0F REQUIRED FOR BASIC FUNCTIONALITY:");
|
|
435
|
-
lines.push("# - DATABASE_URL (for data storage)");
|
|
436
|
-
lines.push("# - BETTER_AUTH_SECRET (for authentication)");
|
|
437
|
-
lines.push("# - GOOGLE_CLIENT_ID & GOOGLE_CLIENT_SECRET (for Google login)");
|
|
438
|
-
lines.push("");
|
|
439
|
-
lines.push("# \u2139\uFE0F OPTIONAL SERVICES:");
|
|
440
|
-
lines.push("# - POLAR_* variables (only needed for subscription payments)");
|
|
441
|
-
lines.push("# - OPENAI_API_KEY (only needed for AI chat features)");
|
|
442
|
-
lines.push("# - R2_* variables (only needed for file uploads)");
|
|
443
|
-
lines.push("# - POSTHOG_* variables (only needed for analytics)");
|
|
444
|
-
lines.push("");
|
|
445
|
-
lines.push("# The app will launch with these placeholder values, but configure real services for production");
|
|
446
|
-
lines.push("");
|
|
447
|
-
lines.push("# Application");
|
|
448
|
-
lines.push("NEXT_PUBLIC_APP_URL=http://localhost:3000");
|
|
449
|
-
lines.push("");
|
|
450
|
-
for (const feature of selectedFeatures) {
|
|
451
|
-
const featureConfig = templateConfig.features[feature];
|
|
452
|
-
if (featureConfig && featureConfig.envVars.length > 0) {
|
|
453
|
-
const featureName = feature.split("-").map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(" ");
|
|
454
|
-
const isOptional = !featureConfig.required;
|
|
455
|
-
const requirementNote = isOptional ? " (Optional - only needed if using this feature)" : " (Required)";
|
|
456
|
-
lines.push(`# ${featureName}${requirementNote}`);
|
|
457
|
-
featureConfig.envVars.forEach((envVar) => {
|
|
458
|
-
const defaultValue = ENV_DEFAULTS[envVar] || "";
|
|
459
|
-
lines.push(`${envVar}=${defaultValue}`);
|
|
460
|
-
});
|
|
461
|
-
lines.push("");
|
|
462
|
-
}
|
|
463
|
-
}
|
|
464
|
-
await writeFile4(
|
|
465
|
-
join5(targetPath, ".env.example"),
|
|
466
|
-
lines.join("\n")
|
|
467
|
-
);
|
|
468
|
-
}
|
|
469
|
-
|
|
470
|
-
// src/generators/readme.ts
|
|
471
|
-
import { readFile as readFile5, writeFile as writeFile5 } from "fs/promises";
|
|
472
|
-
import { join as join6 } from "path";
|
|
473
|
-
async function generateReadme(targetPath, config, templateConfig) {
|
|
474
|
-
const readmePath = join6(targetPath, "README.md");
|
|
475
|
-
let readme = await readFile5(readmePath, "utf-8");
|
|
476
|
-
const featureSections = [];
|
|
477
|
-
if (config.features.includes("payments")) {
|
|
478
|
-
featureSections.push(`
|
|
479
|
-
### Polar.sh Setup (Payments)
|
|
480
|
-
1. Create account at [polar.sh](https://polar.sh)
|
|
481
|
-
2. Create organization and products/subscription tiers
|
|
482
|
-
3. Get your access token from Settings \u2192 API
|
|
483
|
-
4. Add to \`.env.local\`:
|
|
484
|
-
- \`POLAR_ACCESS_TOKEN\`
|
|
485
|
-
- \`POLAR_WEBHOOK_SECRET\`
|
|
486
|
-
- \`NEXT_PUBLIC_STARTER_TIER\`
|
|
487
|
-
- \`NEXT_PUBLIC_STARTER_SLUG\`
|
|
488
|
-
`);
|
|
489
|
-
}
|
|
490
|
-
if (config.features.includes("ai-chat")) {
|
|
491
|
-
featureSections.push(`
|
|
492
|
-
### OpenAI Setup (AI Chat)
|
|
493
|
-
1. Get API key from [platform.openai.com](https://platform.openai.com)
|
|
494
|
-
2. Add to \`.env.local\`:
|
|
495
|
-
- \`OPENAI_API_KEY\`
|
|
496
|
-
`);
|
|
497
|
-
}
|
|
498
|
-
if (config.features.includes("file-upload")) {
|
|
499
|
-
featureSections.push(`
|
|
500
|
-
### Cloudflare R2 Setup (File Uploads)
|
|
501
|
-
1. Create R2 bucket in Cloudflare dashboard
|
|
502
|
-
2. Generate API tokens with R2 permissions
|
|
503
|
-
3. Add to \`.env.local\`:
|
|
504
|
-
- \`R2_UPLOAD_IMAGE_ACCESS_KEY_ID\`
|
|
505
|
-
- \`R2_UPLOAD_IMAGE_SECRET_ACCESS_KEY\`
|
|
506
|
-
- \`CLOUDFLARE_ACCOUNT_ID\`
|
|
507
|
-
- \`R2_UPLOAD_IMAGE_BUCKET_NAME\`
|
|
508
|
-
`);
|
|
509
|
-
}
|
|
510
|
-
if (config.features.includes("analytics")) {
|
|
511
|
-
featureSections.push(`
|
|
512
|
-
### PostHog Setup (Analytics)
|
|
513
|
-
1. Create account at [posthog.com](https://posthog.com)
|
|
514
|
-
2. Create a new project
|
|
515
|
-
3. Add to \`.env.local\`:
|
|
516
|
-
- \`NEXT_PUBLIC_POSTHOG_KEY\`
|
|
517
|
-
- \`NEXT_PUBLIC_POSTHOG_HOST=https://app.posthog.com\`
|
|
518
|
-
`);
|
|
519
|
-
}
|
|
520
|
-
if (featureSections.length > 0) {
|
|
521
|
-
const setupSection = `
|
|
522
|
-
|
|
523
|
-
## \u{1F527} Service Configuration
|
|
524
|
-
${featureSections.join("\n")}`;
|
|
525
|
-
readme += setupSection;
|
|
526
|
-
}
|
|
527
|
-
readme += `
|
|
528
|
-
|
|
529
|
-
---
|
|
530
|
-
|
|
531
|
-
**Generated with** [Shipd](https://github.com/yourusername/shipd)
|
|
532
|
-
`;
|
|
533
|
-
await writeFile5(readmePath, readme);
|
|
534
|
-
}
|
|
535
|
-
|
|
536
221
|
// src/utils/logger.ts
|
|
537
222
|
import chalk from "chalk";
|
|
538
223
|
var SHIPD_ASCII = chalk.hex("#ff7043")(` # # ( )
|
|
@@ -573,162 +258,1707 @@ var logger = {
|
|
|
573
258
|
}
|
|
574
259
|
};
|
|
575
260
|
|
|
576
|
-
// src/
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
const hasNodeModules = await fs.pathExists(resolve3(targetPath, "node_modules"));
|
|
585
|
-
if (hasPkgJson || hasGit || hasNodeModules) {
|
|
586
|
-
throw new Error(
|
|
587
|
-
`Directory "${config.projectName}" appears to contain an existing project.
|
|
588
|
-
- package.json: ${hasPkgJson ? "\u2713 found" : "\u2717 not found"}
|
|
589
|
-
- .git: ${hasGit ? "\u2713 found" : "\u2717 not found"}
|
|
590
|
-
- node_modules: ${hasNodeModules ? "\u2713 found" : "\u2717 not found"}
|
|
261
|
+
// src/commands/append.ts
|
|
262
|
+
import inquirer2 from "inquirer";
|
|
263
|
+
import { existsSync as existsSync6 } from "fs";
|
|
264
|
+
import { join as join5, resolve as resolve3, dirname as dirname2 } from "path";
|
|
265
|
+
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
266
|
+
import ora from "ora";
|
|
267
|
+
import fs from "fs-extra";
|
|
268
|
+
import { execa } from "execa";
|
|
591
269
|
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
270
|
+
// src/utils/env-vars.ts
|
|
271
|
+
import { readFile as readFile4, writeFile as writeFile3 } from "fs/promises";
|
|
272
|
+
import { existsSync as existsSync5 } from "fs";
|
|
273
|
+
import { join as join4 } from "path";
|
|
274
|
+
var ENV_DEFAULTS = {
|
|
275
|
+
// Database
|
|
276
|
+
"DATABASE_URL": "postgresql://user:password@localhost:5432/mydb",
|
|
277
|
+
// Auth
|
|
278
|
+
"BETTER_AUTH_SECRET": "please-change-this-to-a-random-32-character-string-or-longer",
|
|
279
|
+
"GOOGLE_CLIENT_ID": "your-google-client-id.apps.googleusercontent.com",
|
|
280
|
+
"GOOGLE_CLIENT_SECRET": "your-google-client-secret",
|
|
281
|
+
"NEXT_PUBLIC_APP_URL": "http://localhost:3000",
|
|
282
|
+
// Polar.sh Payments
|
|
283
|
+
"POLAR_ACCESS_TOKEN": "your-polar-access-token",
|
|
284
|
+
"POLAR_SUCCESS_URL": "success",
|
|
285
|
+
"POLAR_WEBHOOK_SECRET": "your-polar-webhook-secret",
|
|
286
|
+
"NEXT_PUBLIC_STARTER_TIER": "your-starter-tier-id",
|
|
287
|
+
"NEXT_PUBLIC_STARTER_SLUG": "your-organization-slug",
|
|
288
|
+
// Email (Resend)
|
|
289
|
+
"RESEND_API_KEY": "re_xxxxxxxxxxxxx",
|
|
290
|
+
"EMAIL_FROM": "Your App <noreply@yourdomain.com>",
|
|
291
|
+
// OpenAI
|
|
292
|
+
"OPENAI_API_KEY": "sk-your-openai-api-key",
|
|
293
|
+
// Cloudflare R2
|
|
294
|
+
"R2_UPLOAD_IMAGE_ACCESS_KEY_ID": "your-r2-access-key-id",
|
|
295
|
+
"R2_UPLOAD_IMAGE_SECRET_ACCESS_KEY": "your-r2-secret-access-key",
|
|
296
|
+
"CLOUDFLARE_ACCOUNT_ID": "your-cloudflare-account-id",
|
|
297
|
+
"R2_UPLOAD_IMAGE_BUCKET_NAME": "your-bucket-name",
|
|
298
|
+
// PostHog
|
|
299
|
+
"NEXT_PUBLIC_POSTHOG_KEY": "phc_your-posthog-project-key",
|
|
300
|
+
"NEXT_PUBLIC_POSTHOG_HOST": "https://app.posthog.com"
|
|
301
|
+
};
|
|
302
|
+
var FEATURE_NAMES = {
|
|
303
|
+
"database": "Database",
|
|
304
|
+
"auth": "Authentication",
|
|
305
|
+
"payments": "Payments (Polar.sh)",
|
|
306
|
+
"email": "Email (Resend)",
|
|
307
|
+
"docs": "Documentation",
|
|
308
|
+
"marketing-landing": "Marketing Landing"
|
|
309
|
+
};
|
|
310
|
+
async function addEnvVarsToExample(targetPath, featureName, envVars) {
|
|
311
|
+
if (envVars.length === 0) {
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
const envExamplePath = join4(targetPath, ".env.example");
|
|
315
|
+
let existingContent = "";
|
|
316
|
+
if (existsSync5(envExamplePath)) {
|
|
317
|
+
existingContent = await readFile4(envExamplePath, "utf-8");
|
|
318
|
+
} else {
|
|
319
|
+
existingContent = `# Environment Variables
|
|
320
|
+
# Copy this file to .env.local and customize the values
|
|
321
|
+
|
|
322
|
+
# Application
|
|
323
|
+
NEXT_PUBLIC_APP_URL=http://localhost:3000
|
|
324
|
+
|
|
325
|
+
`;
|
|
326
|
+
}
|
|
327
|
+
const existingVars = /* @__PURE__ */ new Set();
|
|
328
|
+
const lines = existingContent.split("\n");
|
|
329
|
+
for (const line of lines) {
|
|
330
|
+
const match = line.match(/^([A-Z_][A-Z0-9_]*)\s*=/);
|
|
331
|
+
if (match) {
|
|
332
|
+
existingVars.add(match[1]);
|
|
602
333
|
}
|
|
603
334
|
}
|
|
604
|
-
const
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
try {
|
|
608
|
-
await copyTemplate(templatePath, targetPath);
|
|
609
|
-
copySpinner.succeed("Template files copied");
|
|
610
|
-
} catch (error) {
|
|
611
|
-
copySpinner.fail("Failed to copy template files");
|
|
612
|
-
throw error;
|
|
335
|
+
const newVars = envVars.filter((v) => !existingVars.has(v));
|
|
336
|
+
if (newVars.length === 0) {
|
|
337
|
+
return;
|
|
613
338
|
}
|
|
614
|
-
const
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
throw error;
|
|
339
|
+
const displayName = FEATURE_NAMES[featureName] || featureName.split("-").map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(" ");
|
|
340
|
+
const newSection = [];
|
|
341
|
+
newSection.push(`# ${displayName}`);
|
|
342
|
+
for (const envVar of newVars) {
|
|
343
|
+
const defaultValue = ENV_DEFAULTS[envVar] || "";
|
|
344
|
+
newSection.push(`${envVar}=${defaultValue}`);
|
|
621
345
|
}
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
346
|
+
newSection.push("");
|
|
347
|
+
const updatedContent = existingContent.trimEnd() + "\n\n" + newSection.join("\n");
|
|
348
|
+
await writeFile3(envExamplePath, updatedContent, "utf-8");
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// src/commands/append.ts
|
|
352
|
+
var __filename2 = fileURLToPath2(import.meta.url);
|
|
353
|
+
var __dirname2 = dirname2(__filename2);
|
|
354
|
+
async function detectProject(cwd) {
|
|
355
|
+
const pkgPath = join5(cwd, "package.json");
|
|
356
|
+
if (!existsSync6(pkgPath)) {
|
|
357
|
+
throw new Error("No package.json found. Make sure you're in a Next.js project directory.");
|
|
629
358
|
}
|
|
630
|
-
const
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
} catch (error) {
|
|
635
|
-
pkgSpinner.fail("Failed to generate package.json");
|
|
636
|
-
throw error;
|
|
359
|
+
const pkg = await fs.readJson(pkgPath);
|
|
360
|
+
const isNextJs = !!(pkg.dependencies?.next || pkg.devDependencies?.next);
|
|
361
|
+
if (!isNextJs) {
|
|
362
|
+
throw new Error("This doesn't appear to be a Next.js project. shipd append requires a Next.js project.");
|
|
637
363
|
}
|
|
638
|
-
const
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
envSpinner.succeed(".env.example generated");
|
|
642
|
-
} catch (error) {
|
|
643
|
-
envSpinner.fail("Failed to generate .env.example");
|
|
644
|
-
throw error;
|
|
364
|
+
const hasAppRouter = existsSync6(join5(cwd, "app"));
|
|
365
|
+
if (!hasAppRouter) {
|
|
366
|
+
throw new Error("App Router not detected. shipd append requires Next.js App Router (app directory).");
|
|
645
367
|
}
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
readmeSpinner.fail("Failed to update README");
|
|
652
|
-
throw error;
|
|
368
|
+
let packageManager = "npm";
|
|
369
|
+
if (existsSync6(join5(cwd, "pnpm-lock.yaml"))) {
|
|
370
|
+
packageManager = "pnpm";
|
|
371
|
+
} else if (existsSync6(join5(cwd, "yarn.lock"))) {
|
|
372
|
+
packageManager = "yarn";
|
|
653
373
|
}
|
|
654
|
-
const
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
gitSpinner.succeed("Git repository initialized");
|
|
660
|
-
} catch (error) {
|
|
661
|
-
gitSpinner.fail("Failed to initialize git");
|
|
662
|
-
throw error;
|
|
374
|
+
const installedFeatures = [];
|
|
375
|
+
const shipdManifest = join5(cwd, ".shipd", "manifest.json");
|
|
376
|
+
if (existsSync6(shipdManifest)) {
|
|
377
|
+
const manifest = await fs.readJson(shipdManifest);
|
|
378
|
+
installedFeatures.push(...Object.keys(manifest.features || {}));
|
|
663
379
|
}
|
|
664
|
-
|
|
665
|
-
|
|
380
|
+
return {
|
|
381
|
+
isNextJs,
|
|
382
|
+
hasAppRouter,
|
|
383
|
+
packageManager,
|
|
384
|
+
installedFeatures
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
async function appendDocsFeature(targetPath, dryRun = false) {
|
|
388
|
+
const docsSourceCandidates = [
|
|
389
|
+
resolve3(__dirname2, "../docs-template"),
|
|
390
|
+
resolve3(__dirname2, "../../docs-template"),
|
|
391
|
+
resolve3(__dirname2, "../../../docs-template")
|
|
392
|
+
];
|
|
393
|
+
const docsSourcePath = docsSourceCandidates.find((p) => existsSync6(p));
|
|
394
|
+
if (!docsSourcePath) {
|
|
395
|
+
throw new Error(
|
|
396
|
+
`Docs template not found. Tried paths:
|
|
397
|
+
${docsSourceCandidates.map((p) => ` - ${p}`).join("\n")}`
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
const spinner = ora("Copying docs feature (pages + components)...").start();
|
|
666
401
|
try {
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
const totalDeps = deps + devDeps;
|
|
671
|
-
installSpinner.text = `Installing ${totalDeps} dependencies with ${config.packageManager}...
|
|
672
|
-
This may take 2-5 minutes depending on your connection`;
|
|
673
|
-
const startTime = Date.now();
|
|
674
|
-
installInterval = setInterval(() => {
|
|
675
|
-
const elapsed = Math.floor((Date.now() - startTime) / 1e3);
|
|
676
|
-
installSpinner.text = `Installing ${totalDeps} dependencies (${elapsed}s elapsed)...
|
|
677
|
-
${config.packageManager} install is running in background`;
|
|
678
|
-
}, 2e3);
|
|
679
|
-
await execa(config.packageManager, ["install"], {
|
|
680
|
-
cwd: targetPath,
|
|
681
|
-
stdio: "pipe"
|
|
682
|
-
});
|
|
683
|
-
clearInterval(installInterval);
|
|
684
|
-
const totalTime = Math.floor((Date.now() - startTime) / 1e3);
|
|
685
|
-
installSpinner.succeed(`Dependencies installed (${totalDeps} packages in ${totalTime}s)`);
|
|
686
|
-
} catch (error) {
|
|
687
|
-
if (installInterval) {
|
|
688
|
-
clearInterval(installInterval);
|
|
402
|
+
if (dryRun) {
|
|
403
|
+
spinner.info("DRY RUN: Would copy docs pages and components from template");
|
|
404
|
+
return;
|
|
689
405
|
}
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
406
|
+
const docsTargetPath = join5(targetPath, "app", "docs");
|
|
407
|
+
await fs.ensureDir(docsTargetPath);
|
|
408
|
+
const pagesToCopy = [
|
|
409
|
+
"page.tsx",
|
|
410
|
+
"layout.tsx",
|
|
411
|
+
"[slug]",
|
|
412
|
+
"api",
|
|
413
|
+
"documentation"
|
|
414
|
+
];
|
|
415
|
+
for (const item of pagesToCopy) {
|
|
416
|
+
const sourceItem = join5(docsSourcePath, item);
|
|
417
|
+
const targetItem = join5(docsTargetPath, item);
|
|
418
|
+
if (existsSync6(sourceItem)) {
|
|
419
|
+
const stats = await fs.stat(sourceItem);
|
|
420
|
+
if (stats.isDirectory()) {
|
|
421
|
+
await fs.ensureDir(targetItem);
|
|
422
|
+
await fs.copy(sourceItem, targetItem, {
|
|
423
|
+
overwrite: false,
|
|
424
|
+
errorOnExist: false
|
|
425
|
+
});
|
|
426
|
+
} else {
|
|
427
|
+
if (!existsSync6(targetItem)) {
|
|
428
|
+
await fs.copyFile(sourceItem, targetItem);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
const componentsSourcePath = join5(docsSourcePath, "components", "docs");
|
|
434
|
+
const componentsTargetPath = join5(targetPath, "components", "docs");
|
|
435
|
+
if (existsSync6(componentsSourcePath)) {
|
|
436
|
+
await fs.ensureDir(componentsTargetPath);
|
|
437
|
+
await fs.copy(componentsSourcePath, componentsTargetPath, {
|
|
438
|
+
overwrite: false,
|
|
439
|
+
errorOnExist: false
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
const uiComponentsSourcePath = join5(docsSourcePath, "components", "ui");
|
|
443
|
+
const uiComponentsTargetPath = join5(targetPath, "components", "ui");
|
|
444
|
+
if (existsSync6(uiComponentsSourcePath)) {
|
|
445
|
+
await fs.ensureDir(uiComponentsTargetPath);
|
|
446
|
+
await fs.copy(uiComponentsSourcePath, uiComponentsTargetPath, {
|
|
447
|
+
overwrite: false,
|
|
448
|
+
errorOnExist: false,
|
|
449
|
+
filter: (src) => {
|
|
450
|
+
const relativePath = src.replace(uiComponentsSourcePath, "");
|
|
451
|
+
const targetFile = join5(uiComponentsTargetPath, relativePath);
|
|
452
|
+
if (existsSync6(targetFile)) {
|
|
453
|
+
return false;
|
|
454
|
+
}
|
|
455
|
+
return true;
|
|
456
|
+
}
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
const utilsSourcePath = join5(docsSourcePath, "lib", "utils.ts");
|
|
460
|
+
const utilsTargetPath = join5(targetPath, "lib", "utils.ts");
|
|
461
|
+
if (existsSync6(utilsSourcePath) && !existsSync6(utilsTargetPath)) {
|
|
462
|
+
await fs.ensureDir(join5(targetPath, "lib"));
|
|
463
|
+
await fs.copy(utilsSourcePath, utilsTargetPath);
|
|
464
|
+
} else if (existsSync6(utilsTargetPath)) {
|
|
465
|
+
}
|
|
466
|
+
spinner.succeed("Docs feature copied (pages + components)");
|
|
467
|
+
const shipdDir = join5(targetPath, ".shipd");
|
|
468
|
+
await fs.ensureDir(shipdDir);
|
|
469
|
+
const manifestPath = join5(shipdDir, "manifest.json");
|
|
470
|
+
let manifest = { version: "1.0.0", features: {} };
|
|
471
|
+
if (existsSync6(manifestPath)) {
|
|
472
|
+
manifest = await fs.readJson(manifestPath);
|
|
473
|
+
}
|
|
474
|
+
manifest.features.docs = {
|
|
475
|
+
version: "1.0.0",
|
|
476
|
+
installedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
477
|
+
files: [
|
|
478
|
+
"app/docs/**/*",
|
|
479
|
+
"components/docs/**/*",
|
|
480
|
+
"components/ui/**/*",
|
|
481
|
+
"lib/utils.ts"
|
|
482
|
+
]
|
|
483
|
+
};
|
|
484
|
+
await fs.writeJson(manifestPath, manifest, { spaces: 2 });
|
|
485
|
+
const pkgPath = join5(targetPath, "package.json");
|
|
486
|
+
if (existsSync6(pkgPath)) {
|
|
487
|
+
const pkg = await fs.readJson(pkgPath);
|
|
488
|
+
let updated = false;
|
|
489
|
+
const requiredDeps = {
|
|
490
|
+
"@radix-ui/react-slot": "^1.1.0",
|
|
491
|
+
"@radix-ui/react-dialog": "^1.1.0",
|
|
492
|
+
"class-variance-authority": "^0.7.0",
|
|
493
|
+
"clsx": "^2.1.1",
|
|
494
|
+
"tailwind-merge": "^2.5.4",
|
|
495
|
+
"lucide-react": "^0.469.0",
|
|
496
|
+
"react-markdown": "^9.0.0",
|
|
497
|
+
"remark-gfm": "^4.0.0"
|
|
498
|
+
};
|
|
499
|
+
if (!pkg.dependencies) {
|
|
500
|
+
pkg.dependencies = {};
|
|
501
|
+
}
|
|
502
|
+
for (const [dep, version] of Object.entries(requiredDeps)) {
|
|
503
|
+
if (!pkg.dependencies[dep] && !pkg.devDependencies?.[dep]) {
|
|
504
|
+
pkg.dependencies[dep] = version;
|
|
505
|
+
updated = true;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
if (updated) {
|
|
509
|
+
await fs.writeJson(pkgPath, pkg, { spaces: 2 });
|
|
510
|
+
logger.info("Added missing dependencies to package.json");
|
|
511
|
+
logger.info("Run npm install (or your package manager) to install them");
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
const featureDocs = join5(shipdDir, "features");
|
|
515
|
+
await fs.ensureDir(featureDocs);
|
|
516
|
+
const docsReadme = `# Docs Feature - Integration Guide
|
|
517
|
+
|
|
518
|
+
**Installed:** ${(/* @__PURE__ */ new Date()).toISOString()}
|
|
519
|
+
**Version:** 1.0.0
|
|
520
|
+
|
|
521
|
+
## Overview
|
|
522
|
+
|
|
523
|
+
This feature adds a complete, standalone documentation section to your Next.js application. All required components are included - no external dependencies needed.
|
|
524
|
+
|
|
525
|
+
## Files Added
|
|
526
|
+
|
|
527
|
+
### Pages
|
|
528
|
+
- \`app/docs/page.tsx\` - Docs landing page
|
|
529
|
+
- \`app/docs/layout.tsx\` - Docs layout with header and sidebar
|
|
530
|
+
- \`app/docs/[slug]/page.tsx\` - Dynamic doc pages
|
|
531
|
+
- \`app/docs/[slug]/[subslug]/page.tsx\` - Nested doc pages
|
|
532
|
+
- \`app/docs/api/page.tsx\` - API documentation page
|
|
533
|
+
- \`app/docs/documentation/page.tsx\` - Documentation page
|
|
534
|
+
|
|
535
|
+
### Components (Standalone Package)
|
|
536
|
+
All required components are included:
|
|
537
|
+
- \`components/docs/docs-header.tsx\` - Docs header with navigation
|
|
538
|
+
- \`components/docs/docs-sidebar.tsx\` - Sidebar navigation
|
|
539
|
+
- \`components/docs/docs-toc.tsx\` - Table of contents component
|
|
540
|
+
- \`components/docs/docs-category-page.tsx\` - Category page layout
|
|
541
|
+
- \`components/docs/docs-code-card.tsx\` - Code snippet display
|
|
542
|
+
- \`components/docs/docs-nav.ts\` - Navigation data structure
|
|
543
|
+
|
|
544
|
+
## Dependencies
|
|
545
|
+
|
|
546
|
+
This feature is **completely standalone** and includes:
|
|
547
|
+
- \u2705 All required UI components (\`components/ui/*\`)
|
|
548
|
+
- \u2705 Utility functions (\`lib/utils.ts\`)
|
|
549
|
+
- \u2705 All docs-specific components (\`components/docs/*\`)
|
|
550
|
+
|
|
551
|
+
**Smart Deduplication:** If UI components already exist in your project, they won't be overwritten.
|
|
552
|
+
|
|
553
|
+
**Package Dependencies:** The following will be added to your \`package.json\` if missing:
|
|
554
|
+
- \`@radix-ui/react-slot\` - For Button component
|
|
555
|
+
- \`@radix-ui/react-dialog\` - For Sheet component
|
|
556
|
+
- \`class-variance-authority\` - For component variants
|
|
557
|
+
- \`clsx\` & \`tailwind-merge\` - For className utilities
|
|
558
|
+
- \`lucide-react\` - For icons
|
|
559
|
+
|
|
560
|
+
**Note:** Run \`npm install\` (or your package manager) after appending to install any new dependencies.
|
|
561
|
+
|
|
562
|
+
## Setup Instructions
|
|
563
|
+
|
|
564
|
+
1. **Access the docs**
|
|
565
|
+
- Visit \`/docs\` in your application
|
|
566
|
+
- The docs section is fully functional with navigation and layout
|
|
567
|
+
|
|
568
|
+
2. **Customize content**
|
|
569
|
+
- Edit pages in \`app/docs/\` to customize content
|
|
570
|
+
- Update navigation in \`components/docs/docs-nav.ts\`
|
|
571
|
+
- Modify components in \`components/docs/\` to change styling/behavior
|
|
572
|
+
|
|
573
|
+
3. **Add your own documentation**
|
|
574
|
+
- Create new pages in \`app/docs/\`
|
|
575
|
+
- Follow the existing page structure
|
|
576
|
+
- Add entries to \`docs-nav.ts\` to include in sidebar
|
|
577
|
+
|
|
578
|
+
## Next Steps
|
|
579
|
+
|
|
580
|
+
- Customize the docs landing page in \`app/docs/page.tsx\`
|
|
581
|
+
- Update navigation structure in \`components/docs/docs-nav.ts\`
|
|
582
|
+
- Add your own documentation pages
|
|
583
|
+
- Customize styling in components if needed
|
|
584
|
+
|
|
585
|
+
## Feature Status
|
|
586
|
+
|
|
587
|
+
\u2705 **Standalone Package** - All components included
|
|
588
|
+
\u2705 **No Missing Dependencies** - Everything needed is bundled
|
|
589
|
+
\u2705 **Ready to Use** - Works immediately after append
|
|
590
|
+
`;
|
|
591
|
+
await fs.writeFile(join5(featureDocs, "docs.md"), docsReadme);
|
|
592
|
+
} catch (error) {
|
|
593
|
+
spinner.fail("Failed to copy docs pages");
|
|
594
|
+
throw error;
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
async function appendMarketingFeature(targetPath, dryRun = false) {
|
|
598
|
+
const marketingSourceCandidates = [
|
|
599
|
+
resolve3(__dirname2, "../features/marketing-landing"),
|
|
600
|
+
resolve3(__dirname2, "../../features/marketing-landing"),
|
|
601
|
+
resolve3(__dirname2, "../../../features/marketing-landing")
|
|
602
|
+
];
|
|
603
|
+
const marketingSourcePath = marketingSourceCandidates.find((p) => existsSync6(p));
|
|
604
|
+
if (!marketingSourcePath) {
|
|
605
|
+
throw new Error(
|
|
606
|
+
`Marketing landing template not found. Tried paths:
|
|
607
|
+
${marketingSourceCandidates.map((p) => ` - ${p}`).join("\n")}`
|
|
608
|
+
);
|
|
609
|
+
}
|
|
610
|
+
const spinner = ora("Copying marketing landing feature...").start();
|
|
611
|
+
try {
|
|
612
|
+
if (dryRun) {
|
|
613
|
+
spinner.info("DRY RUN: Would copy marketing landing pages and components");
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
const sourcePage = join5(marketingSourcePath, "app", "page.tsx");
|
|
617
|
+
const targetPage = join5(targetPath, "app", "page.tsx");
|
|
618
|
+
if (!existsSync6(targetPage)) {
|
|
619
|
+
await fs.ensureDir(join5(targetPath, "app"));
|
|
620
|
+
await fs.copyFile(sourcePage, targetPage);
|
|
621
|
+
} else {
|
|
622
|
+
const existingContent = await fs.readFile(targetPage, "utf-8");
|
|
623
|
+
const isBaseTemplate = existingContent.includes("Welcome to") && (existingContent.includes("{{PROJECT_NAME}}") || existingContent.includes("my-test-app"));
|
|
624
|
+
if (isBaseTemplate) {
|
|
625
|
+
await fs.copyFile(sourcePage, targetPage);
|
|
626
|
+
logger.info("Replaced base template page.tsx with marketing landing page");
|
|
627
|
+
} else {
|
|
628
|
+
logger.warn("app/page.tsx already exists with custom content. Skipping overwrite.");
|
|
629
|
+
logger.warn("To use the marketing landing page, manually merge or replace app/page.tsx");
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
const homepageSource = join5(marketingSourcePath, "components", "homepage");
|
|
633
|
+
const homepageTarget = join5(targetPath, "components", "homepage");
|
|
634
|
+
if (existsSync6(homepageSource)) {
|
|
635
|
+
await fs.ensureDir(homepageTarget);
|
|
636
|
+
await fs.copy(homepageSource, homepageTarget, {
|
|
637
|
+
overwrite: false,
|
|
638
|
+
errorOnExist: false
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
const logosSource = join5(marketingSourcePath, "components", "logos");
|
|
642
|
+
const logosTarget = join5(targetPath, "components", "logos");
|
|
643
|
+
if (existsSync6(logosSource)) {
|
|
644
|
+
await fs.ensureDir(logosTarget);
|
|
645
|
+
await fs.copy(logosSource, logosTarget, {
|
|
646
|
+
overwrite: false,
|
|
647
|
+
errorOnExist: false
|
|
648
|
+
});
|
|
649
|
+
}
|
|
650
|
+
const shipdDir = join5(targetPath, ".shipd");
|
|
651
|
+
const featureDocs = join5(shipdDir, "features");
|
|
652
|
+
await fs.ensureDir(featureDocs);
|
|
653
|
+
const featureNote = `# Marketing Landing Feature
|
|
654
|
+
|
|
655
|
+
Installed: ${(/* @__PURE__ */ new Date()).toISOString()}
|
|
656
|
+
|
|
657
|
+
Includes:
|
|
658
|
+
- app/page.tsx (skipped if already present)
|
|
659
|
+
- components/homepage/*
|
|
660
|
+
- components/logos/*
|
|
661
|
+
|
|
662
|
+
Notes:
|
|
663
|
+
- Pricing section is static and has no external dependencies.
|
|
664
|
+
- If app/page.tsx already existed, merge the content manually if you want this landing page.
|
|
665
|
+
`;
|
|
666
|
+
await fs.writeFile(join5(featureDocs, "marketing-landing.md"), featureNote);
|
|
667
|
+
spinner.succeed("Marketing landing feature copied");
|
|
668
|
+
} catch (error) {
|
|
669
|
+
spinner.fail("Failed to copy marketing landing feature");
|
|
670
|
+
throw error;
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
async function appendDatabaseFeature(targetPath, dryRun = false) {
|
|
674
|
+
const dbSourceCandidates = [
|
|
675
|
+
resolve3(__dirname2, "../features/database"),
|
|
676
|
+
resolve3(__dirname2, "../../features/database"),
|
|
677
|
+
resolve3(__dirname2, "../../../features/database")
|
|
678
|
+
];
|
|
679
|
+
const dbSourcePath = dbSourceCandidates.find((p) => existsSync6(p));
|
|
680
|
+
if (!dbSourcePath) {
|
|
681
|
+
throw new Error(`Database module not found. Tried paths:
|
|
682
|
+
${dbSourceCandidates.map((p) => ` - ${p}`).join("\n")}`);
|
|
683
|
+
}
|
|
684
|
+
const spinner = ora("Copying database feature...").start();
|
|
685
|
+
try {
|
|
686
|
+
if (dryRun) {
|
|
687
|
+
spinner.info("DRY RUN: Would copy database configuration and setup");
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
const drizzleConfigSource = join5(dbSourcePath, "drizzle.config.ts");
|
|
691
|
+
const drizzleConfigTarget = join5(targetPath, "drizzle.config.ts");
|
|
692
|
+
if (!existsSync6(drizzleConfigTarget)) {
|
|
693
|
+
await fs.copyFile(drizzleConfigSource, drizzleConfigTarget);
|
|
694
|
+
}
|
|
695
|
+
const dbSource = join5(dbSourcePath, "db");
|
|
696
|
+
const dbTarget = join5(targetPath, "db");
|
|
697
|
+
if (existsSync6(dbSource)) {
|
|
698
|
+
await fs.ensureDir(dbTarget);
|
|
699
|
+
await fs.copy(dbSource, dbTarget, {
|
|
700
|
+
overwrite: false,
|
|
701
|
+
errorOnExist: false
|
|
702
|
+
});
|
|
703
|
+
}
|
|
704
|
+
const pkgPath = join5(targetPath, "package.json");
|
|
705
|
+
if (existsSync6(pkgPath)) {
|
|
706
|
+
const pkg = await fs.readJson(pkgPath);
|
|
707
|
+
if (!pkg.scripts) {
|
|
708
|
+
pkg.scripts = {};
|
|
709
|
+
}
|
|
710
|
+
const dbScripts = {
|
|
711
|
+
"db:push": "drizzle-kit push",
|
|
712
|
+
"db:generate": "drizzle-kit generate",
|
|
713
|
+
"db:migrate": "drizzle-kit migrate",
|
|
714
|
+
"db:studio": "drizzle-kit studio"
|
|
715
|
+
};
|
|
716
|
+
let scriptsUpdated = false;
|
|
717
|
+
for (const [script, command] of Object.entries(dbScripts)) {
|
|
718
|
+
if (!pkg.scripts[script]) {
|
|
719
|
+
pkg.scripts[script] = command;
|
|
720
|
+
scriptsUpdated = true;
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
if (scriptsUpdated) {
|
|
724
|
+
await fs.writeJson(pkgPath, pkg, { spaces: 2 });
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
if (existsSync6(pkgPath)) {
|
|
728
|
+
const pkg = await fs.readJson(pkgPath);
|
|
729
|
+
let updated = false;
|
|
730
|
+
const requiredDeps = {
|
|
731
|
+
"postgres": "^3.4.4",
|
|
732
|
+
"drizzle-orm": "^0.41.0"
|
|
733
|
+
};
|
|
734
|
+
const requiredDevDeps = {
|
|
735
|
+
"drizzle-kit": "^0.31.0"
|
|
736
|
+
};
|
|
737
|
+
if (!pkg.dependencies) {
|
|
738
|
+
pkg.dependencies = {};
|
|
739
|
+
}
|
|
740
|
+
if (!pkg.devDependencies) {
|
|
741
|
+
pkg.devDependencies = {};
|
|
742
|
+
}
|
|
743
|
+
for (const [dep, version] of Object.entries(requiredDeps)) {
|
|
744
|
+
if (!pkg.dependencies[dep] && !pkg.devDependencies?.[dep]) {
|
|
745
|
+
pkg.dependencies[dep] = version;
|
|
746
|
+
updated = true;
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
for (const [dep, version] of Object.entries(requiredDevDeps)) {
|
|
750
|
+
if (!pkg.devDependencies[dep]) {
|
|
751
|
+
pkg.devDependencies[dep] = version;
|
|
752
|
+
updated = true;
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
if (updated) {
|
|
756
|
+
await fs.writeJson(pkgPath, pkg, { spaces: 2 });
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
const shipdDir = join5(targetPath, ".shipd");
|
|
760
|
+
const featureDocs = join5(shipdDir, "features");
|
|
761
|
+
await fs.ensureDir(featureDocs);
|
|
762
|
+
const featureNote = `# Database Feature
|
|
763
|
+
Installed: ${(/* @__PURE__ */ new Date()).toISOString()}
|
|
764
|
+
|
|
765
|
+
Includes:
|
|
766
|
+
- drizzle.config.ts
|
|
767
|
+
- db/drizzle.ts (connection)
|
|
768
|
+
- db/schema.ts (base schema)
|
|
769
|
+
- db/migrations/ (migration directory)
|
|
770
|
+
- Package scripts (db:push, db:generate, etc.)
|
|
771
|
+
|
|
772
|
+
Notes:
|
|
773
|
+
- Base schema is empty. Other modules (auth, payments) will add tables.
|
|
774
|
+
- Set DATABASE_URL in .env.local to connect to your database.
|
|
775
|
+
`;
|
|
776
|
+
await fs.writeFile(join5(featureDocs, "database.md"), featureNote);
|
|
777
|
+
const dbConfig = await fs.readJson(join5(dbSourcePath, "feature.config.json")).catch(() => null);
|
|
778
|
+
if (dbConfig?.envVars) {
|
|
779
|
+
await addEnvVarsToExample(targetPath, "database", dbConfig.envVars);
|
|
780
|
+
}
|
|
781
|
+
spinner.succeed("Database feature copied");
|
|
782
|
+
} catch (error) {
|
|
783
|
+
spinner.fail("Failed to copy database feature");
|
|
784
|
+
throw error;
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
async function appendAuthFeature(targetPath, dryRun = false) {
|
|
788
|
+
const authSourceCandidates = [
|
|
789
|
+
resolve3(__dirname2, "../features/auth"),
|
|
790
|
+
resolve3(__dirname2, "../../features/auth"),
|
|
791
|
+
resolve3(__dirname2, "../../../features/auth")
|
|
792
|
+
];
|
|
793
|
+
const authSourcePath = authSourceCandidates.find((p) => existsSync6(p));
|
|
794
|
+
if (!authSourcePath) {
|
|
795
|
+
throw new Error(`Auth module not found. Tried paths:
|
|
796
|
+
${authSourceCandidates.map((p) => ` - ${p}`).join("\n")}`);
|
|
797
|
+
}
|
|
798
|
+
const spinner = ora("Copying auth feature...").start();
|
|
799
|
+
try {
|
|
800
|
+
if (dryRun) {
|
|
801
|
+
spinner.info("DRY RUN: Would copy auth pages, API routes, and configuration");
|
|
802
|
+
return;
|
|
803
|
+
}
|
|
804
|
+
const dbSchemaPath = join5(targetPath, "db", "schema.ts");
|
|
805
|
+
if (!existsSync6(dbSchemaPath)) {
|
|
806
|
+
spinner.info("Database module not found. Installing database first...");
|
|
807
|
+
await appendDatabaseFeature(targetPath, dryRun);
|
|
808
|
+
}
|
|
809
|
+
const signInSource = join5(authSourcePath, "app", "sign-in", "page.tsx");
|
|
810
|
+
const signInTarget = join5(targetPath, "app", "sign-in", "page.tsx");
|
|
811
|
+
await fs.ensureDir(join5(targetPath, "app", "sign-in"));
|
|
812
|
+
await fs.copyFile(signInSource, signInTarget);
|
|
813
|
+
const signUpSource = join5(authSourcePath, "app", "sign-up", "page.tsx");
|
|
814
|
+
const signUpTarget = join5(targetPath, "app", "sign-up", "page.tsx");
|
|
815
|
+
await fs.ensureDir(join5(targetPath, "app", "sign-up"));
|
|
816
|
+
await fs.copyFile(signUpSource, signUpTarget);
|
|
817
|
+
const dashboardSource = join5(authSourcePath, "app", "dashboard");
|
|
818
|
+
const dashboardTarget = join5(targetPath, "app", "dashboard");
|
|
819
|
+
if (existsSync6(dashboardSource)) {
|
|
820
|
+
await fs.ensureDir(dashboardTarget);
|
|
821
|
+
await fs.copy(dashboardSource, dashboardTarget, {
|
|
822
|
+
overwrite: false,
|
|
823
|
+
errorOnExist: false
|
|
824
|
+
});
|
|
825
|
+
}
|
|
826
|
+
const authApiSource = join5(authSourcePath, "app", "api", "auth");
|
|
827
|
+
const authApiTarget = join5(targetPath, "app", "api", "auth");
|
|
828
|
+
if (existsSync6(authApiSource)) {
|
|
829
|
+
await fs.ensureDir(authApiTarget);
|
|
830
|
+
await fs.copy(authApiSource, authApiTarget, { overwrite: true });
|
|
831
|
+
}
|
|
832
|
+
const authLibSource = join5(authSourcePath, "lib");
|
|
833
|
+
const authLibTarget = join5(targetPath, "lib");
|
|
834
|
+
if (existsSync6(authLibSource)) {
|
|
835
|
+
await fs.ensureDir(authLibTarget);
|
|
836
|
+
const authFiles = ["auth.ts", "auth-client.ts"];
|
|
837
|
+
for (const file of authFiles) {
|
|
838
|
+
const sourceFile = join5(authLibSource, file);
|
|
839
|
+
const targetFile = join5(authLibTarget, file);
|
|
840
|
+
if (existsSync6(sourceFile)) {
|
|
841
|
+
await fs.copyFile(sourceFile, targetFile);
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
const emailStubSource = join5(authLibSource, "email.ts");
|
|
845
|
+
const emailTarget = join5(authLibTarget, "email.ts");
|
|
846
|
+
if (existsSync6(emailStubSource) && !existsSync6(emailTarget)) {
|
|
847
|
+
await fs.copyFile(emailStubSource, emailTarget);
|
|
848
|
+
logger.info("Created email.ts stub (install email module for production)");
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
const authSchemaSource = join5(authSourcePath, "auth-schema.ts");
|
|
852
|
+
const authSchemaTarget = join5(targetPath, "auth-schema.ts");
|
|
853
|
+
await fs.copyFile(authSchemaSource, authSchemaTarget);
|
|
854
|
+
const dbSchemaTarget = join5(targetPath, "db", "schema.ts");
|
|
855
|
+
if (existsSync6(dbSchemaTarget)) {
|
|
856
|
+
const dbSchemaContent = await fs.readFile(dbSchemaTarget, "utf-8");
|
|
857
|
+
const authSchemaContent = await fs.readFile(authSchemaSource, "utf-8");
|
|
858
|
+
const tableRegex = /export const \w+ = pgTable\([^}]+}\);/gs;
|
|
859
|
+
const tableExports = [];
|
|
860
|
+
let match;
|
|
861
|
+
while ((match = tableRegex.exec(authSchemaContent)) !== null) {
|
|
862
|
+
tableExports.push(match[0]);
|
|
863
|
+
}
|
|
864
|
+
if (tableExports.length === 0) {
|
|
865
|
+
const tableNames = ["user", "session", "account", "verification"];
|
|
866
|
+
for (const tableName of tableNames) {
|
|
867
|
+
const tableRegex2 = new RegExp(`export const ${tableName} = pgTable\\([\\s\\S]*?\\)};`, "g");
|
|
868
|
+
const match2 = authSchemaContent.match(tableRegex2);
|
|
869
|
+
if (match2) {
|
|
870
|
+
tableExports.push(match2[0]);
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
let updatedSchema = dbSchemaContent;
|
|
875
|
+
if (!updatedSchema.includes('from "drizzle-orm/pg-core"')) {
|
|
876
|
+
const importLine = 'import { pgTable, text, timestamp, boolean, integer } from "drizzle-orm/pg-core";';
|
|
877
|
+
updatedSchema = importLine + "\n\n" + updatedSchema;
|
|
878
|
+
}
|
|
879
|
+
const tablesToAdd = [];
|
|
880
|
+
for (const tableExport of tableExports) {
|
|
881
|
+
const tableNameMatch = tableExport.match(/export const (\w+) = pgTable/);
|
|
882
|
+
if (tableNameMatch) {
|
|
883
|
+
const tableName = tableNameMatch[1];
|
|
884
|
+
if (!updatedSchema.includes(`export const ${tableName} = pgTable`)) {
|
|
885
|
+
tablesToAdd.push(tableExport);
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
if (tablesToAdd.length > 0) {
|
|
890
|
+
updatedSchema += "\n\n// Auth tables\n" + tablesToAdd.join("\n\n");
|
|
891
|
+
}
|
|
892
|
+
await fs.writeFile(dbSchemaTarget, updatedSchema, "utf-8");
|
|
893
|
+
}
|
|
894
|
+
const middlewarePath = join5(targetPath, "middleware.ts");
|
|
895
|
+
if (existsSync6(middlewarePath)) {
|
|
896
|
+
const middlewareContent = await fs.readFile(middlewarePath, "utf-8");
|
|
897
|
+
if (!middlewareContent.includes("getSessionCookie")) {
|
|
898
|
+
const authMiddlewarePatch = await fs.readFile(join5(authSourcePath, "middleware.patch.ts"), "utf-8");
|
|
899
|
+
const updatedMiddleware = middlewareContent.replace(
|
|
900
|
+
/export async function middleware\(request: NextRequest\) \{[\s\S]*?return NextResponse\.next\(\);?\s*\}/,
|
|
901
|
+
`export async function middleware(request: NextRequest) {
|
|
902
|
+
const sessionCookie = getSessionCookie(request);
|
|
903
|
+
const { pathname } = request.nextUrl;
|
|
904
|
+
|
|
905
|
+
// Don't redirect from sign-in/sign-up pages
|
|
906
|
+
if (["/sign-in", "/sign-up"].includes(pathname)) {
|
|
907
|
+
return NextResponse.next();
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
// Redirect unauthenticated users from protected routes
|
|
911
|
+
const protectedRoutes = ["/dashboard"];
|
|
912
|
+
const isProtectedRoute = protectedRoutes.some(route => pathname.startsWith(route));
|
|
913
|
+
|
|
914
|
+
if (isProtectedRoute && !sessionCookie) {
|
|
915
|
+
const returnTo = encodeURIComponent(pathname);
|
|
916
|
+
return NextResponse.redirect(new URL(\`/sign-in?returnTo=\${returnTo}\`, request.url));
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
return NextResponse.next();
|
|
920
|
+
}`
|
|
921
|
+
);
|
|
922
|
+
let finalMiddleware = updatedMiddleware;
|
|
923
|
+
if (!finalMiddleware.includes('from "better-auth/cookies"')) {
|
|
924
|
+
const importMatch = finalMiddleware.match(/^import .+ from .+;\n/gm);
|
|
925
|
+
if (importMatch) {
|
|
926
|
+
const lastImportIndex = finalMiddleware.lastIndexOf(importMatch[importMatch.length - 1]) + importMatch[importMatch.length - 1].length;
|
|
927
|
+
finalMiddleware = finalMiddleware.slice(0, lastImportIndex) + 'import { getSessionCookie } from "better-auth/cookies";\n' + finalMiddleware.slice(lastImportIndex);
|
|
928
|
+
} else {
|
|
929
|
+
finalMiddleware = 'import { getSessionCookie } from "better-auth/cookies";\n' + finalMiddleware;
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
if (finalMiddleware.includes("matcher: []")) {
|
|
933
|
+
finalMiddleware = finalMiddleware.replace(
|
|
934
|
+
/export const config = \{[\s\S]*?\};/,
|
|
935
|
+
'export const config = {\n matcher: ["/dashboard/:path*", "/sign-in", "/sign-up"],\n};'
|
|
936
|
+
);
|
|
937
|
+
}
|
|
938
|
+
await fs.writeFile(middlewarePath, finalMiddleware, "utf-8");
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
const pkgPath = join5(targetPath, "package.json");
|
|
942
|
+
if (existsSync6(pkgPath)) {
|
|
943
|
+
const pkg = await fs.readJson(pkgPath);
|
|
944
|
+
let updated = false;
|
|
945
|
+
const requiredDeps = {
|
|
946
|
+
"better-auth": "^1.2.8",
|
|
947
|
+
"@polar-sh/better-auth": "^1.0.1",
|
|
948
|
+
"sonner": "^2.0.3"
|
|
949
|
+
// For toast notifications in sign-in/sign-up
|
|
950
|
+
};
|
|
951
|
+
if (!pkg.dependencies) {
|
|
952
|
+
pkg.dependencies = {};
|
|
953
|
+
}
|
|
954
|
+
for (const [dep, version] of Object.entries(requiredDeps)) {
|
|
955
|
+
if (!pkg.dependencies[dep] && !pkg.devDependencies?.[dep]) {
|
|
956
|
+
pkg.dependencies[dep] = version;
|
|
957
|
+
updated = true;
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
if (updated) {
|
|
961
|
+
await fs.writeJson(pkgPath, pkg, { spaces: 2 });
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
const shipdDir = join5(targetPath, ".shipd");
|
|
965
|
+
const featureDocs = join5(shipdDir, "features");
|
|
966
|
+
await fs.ensureDir(featureDocs);
|
|
967
|
+
const featureNote = `# Auth Feature
|
|
968
|
+
Installed: ${(/* @__PURE__ */ new Date()).toISOString()}
|
|
969
|
+
|
|
970
|
+
Includes:
|
|
971
|
+
- app/sign-in/page.tsx
|
|
972
|
+
- app/sign-up/page.tsx
|
|
973
|
+
- app/api/auth/[...all]/route.ts
|
|
974
|
+
- lib/auth.ts
|
|
975
|
+
- lib/auth-client.ts
|
|
976
|
+
- auth-schema.ts (merged into db/schema.ts)
|
|
977
|
+
- Middleware updates for route protection
|
|
978
|
+
|
|
979
|
+
Notes:
|
|
980
|
+
- Database module was automatically installed (required dependency)
|
|
981
|
+
- Auth tables added to db/schema.ts
|
|
982
|
+
- Set BETTER_AUTH_SECRET and Google OAuth credentials in .env.local
|
|
983
|
+
`;
|
|
984
|
+
await fs.writeFile(join5(featureDocs, "auth.md"), featureNote);
|
|
985
|
+
const authConfig = await fs.readJson(join5(authSourcePath, "feature.config.json")).catch(() => null);
|
|
986
|
+
if (authConfig?.envVars) {
|
|
987
|
+
await addEnvVarsToExample(targetPath, "auth", authConfig.envVars);
|
|
988
|
+
}
|
|
989
|
+
spinner.succeed("Auth feature copied");
|
|
990
|
+
} catch (error) {
|
|
991
|
+
spinner.fail("Failed to copy auth feature");
|
|
992
|
+
throw error;
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
async function appendPaymentsFeature(targetPath, dryRun = false) {
|
|
996
|
+
const paymentsSourceCandidates = [
|
|
997
|
+
resolve3(__dirname2, "../features/payments"),
|
|
998
|
+
resolve3(__dirname2, "../../features/payments"),
|
|
999
|
+
resolve3(__dirname2, "../../../features/payments")
|
|
1000
|
+
];
|
|
1001
|
+
const paymentsSourcePath = paymentsSourceCandidates.find((p) => existsSync6(p));
|
|
1002
|
+
if (!paymentsSourcePath) {
|
|
1003
|
+
throw new Error(`Payments module not found. Tried paths:
|
|
1004
|
+
${paymentsSourceCandidates.map((p) => ` - ${p}`).join("\n")}`);
|
|
1005
|
+
}
|
|
1006
|
+
const spinner = ora("Copying payments feature...").start();
|
|
1007
|
+
try {
|
|
1008
|
+
if (dryRun) {
|
|
1009
|
+
spinner.info("DRY RUN: Would copy payment pages, API routes, and utilities");
|
|
1010
|
+
return;
|
|
1011
|
+
}
|
|
1012
|
+
const dbSchemaPath = join5(targetPath, "db", "schema.ts");
|
|
1013
|
+
const authLibPath = join5(targetPath, "lib", "auth.ts");
|
|
1014
|
+
if (!existsSync6(dbSchemaPath)) {
|
|
1015
|
+
spinner.info("Database module not found. Installing database first...");
|
|
1016
|
+
await appendDatabaseFeature(targetPath, dryRun);
|
|
1017
|
+
}
|
|
1018
|
+
if (!existsSync6(authLibPath)) {
|
|
1019
|
+
spinner.info("Auth module not found. Installing auth first...");
|
|
1020
|
+
await appendAuthFeature(targetPath, dryRun);
|
|
1021
|
+
}
|
|
1022
|
+
const paymentDashboardSource = join5(paymentsSourcePath, "app", "dashboard", "payment");
|
|
1023
|
+
const paymentDashboardTarget = join5(targetPath, "app", "dashboard", "payment");
|
|
1024
|
+
if (existsSync6(paymentDashboardSource)) {
|
|
1025
|
+
await fs.ensureDir(paymentDashboardTarget);
|
|
1026
|
+
await fs.copy(paymentDashboardSource, paymentDashboardTarget, { overwrite: true });
|
|
1027
|
+
}
|
|
1028
|
+
const successSource = join5(paymentsSourcePath, "app", "success");
|
|
1029
|
+
const successTarget = join5(targetPath, "app", "success");
|
|
1030
|
+
if (existsSync6(successSource)) {
|
|
1031
|
+
await fs.ensureDir(successTarget);
|
|
1032
|
+
await fs.copy(successSource, successTarget, { overwrite: true });
|
|
1033
|
+
}
|
|
1034
|
+
const subscriptionApiSource = join5(paymentsSourcePath, "app", "api", "subscription");
|
|
1035
|
+
const subscriptionApiTarget = join5(targetPath, "app", "api", "subscription");
|
|
1036
|
+
if (existsSync6(subscriptionApiSource)) {
|
|
1037
|
+
await fs.ensureDir(subscriptionApiTarget);
|
|
1038
|
+
await fs.copy(subscriptionApiSource, subscriptionApiTarget, { overwrite: true });
|
|
1039
|
+
}
|
|
1040
|
+
const paymentsLibSource = join5(paymentsSourcePath, "lib");
|
|
1041
|
+
const paymentsLibTarget = join5(targetPath, "lib");
|
|
1042
|
+
if (existsSync6(paymentsLibSource)) {
|
|
1043
|
+
await fs.ensureDir(paymentsLibTarget);
|
|
1044
|
+
await fs.copy(paymentsLibSource, paymentsLibTarget, {
|
|
1045
|
+
overwrite: false,
|
|
1046
|
+
errorOnExist: false
|
|
1047
|
+
});
|
|
1048
|
+
}
|
|
1049
|
+
const paymentsSchemaSource = join5(paymentsSourcePath, "payments-schema.ts");
|
|
1050
|
+
const paymentsSchemaTarget = join5(targetPath, "payments-schema.ts");
|
|
1051
|
+
await fs.copyFile(paymentsSchemaSource, paymentsSchemaTarget);
|
|
1052
|
+
const dbSchemaTarget = join5(targetPath, "db", "schema.ts");
|
|
1053
|
+
if (existsSync6(dbSchemaTarget)) {
|
|
1054
|
+
const dbSchemaContent = await fs.readFile(dbSchemaTarget, "utf-8");
|
|
1055
|
+
const paymentsSchemaContent = await fs.readFile(paymentsSchemaSource, "utf-8");
|
|
1056
|
+
const subscriptionTableRegex = /export const subscription = pgTable\([\s\S]*?}\);?/;
|
|
1057
|
+
const subscriptionTableMatch = paymentsSchemaContent.match(subscriptionTableRegex);
|
|
1058
|
+
if (subscriptionTableMatch && !dbSchemaContent.includes("export const subscription = pgTable")) {
|
|
1059
|
+
let subscriptionTable = subscriptionTableMatch[0];
|
|
1060
|
+
if (!subscriptionTable.includes("references") && dbSchemaContent.includes("export const user = pgTable")) {
|
|
1061
|
+
subscriptionTable = subscriptionTable.replace(
|
|
1062
|
+
/userId: text\("userId"\),/,
|
|
1063
|
+
'userId: text("userId").references(() => user.id),'
|
|
1064
|
+
);
|
|
1065
|
+
}
|
|
1066
|
+
const updatedSchema = dbSchemaContent + "\n\n// Subscription table (from payments module)\n" + subscriptionTable;
|
|
1067
|
+
await fs.writeFile(dbSchemaTarget, updatedSchema, "utf-8");
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
const pkgPath = join5(targetPath, "package.json");
|
|
1071
|
+
if (existsSync6(pkgPath)) {
|
|
1072
|
+
const pkg = await fs.readJson(pkgPath);
|
|
1073
|
+
let updated = false;
|
|
1074
|
+
const requiredDeps = {
|
|
1075
|
+
"@polar-sh/sdk": "^0.42.1"
|
|
1076
|
+
};
|
|
1077
|
+
if (!pkg.dependencies) {
|
|
1078
|
+
pkg.dependencies = {};
|
|
1079
|
+
}
|
|
1080
|
+
for (const [dep, version] of Object.entries(requiredDeps)) {
|
|
1081
|
+
if (!pkg.dependencies[dep] && !pkg.devDependencies?.[dep]) {
|
|
1082
|
+
pkg.dependencies[dep] = version;
|
|
1083
|
+
updated = true;
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
if (updated) {
|
|
1087
|
+
await fs.writeJson(pkgPath, pkg, { spaces: 2 });
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
const shipdDir = join5(targetPath, ".shipd");
|
|
1091
|
+
const featureDocs = join5(shipdDir, "features");
|
|
1092
|
+
await fs.ensureDir(featureDocs);
|
|
1093
|
+
const featureNote = `# Payments Feature
|
|
1094
|
+
Installed: ${(/* @__PURE__ */ new Date()).toISOString()}
|
|
1095
|
+
|
|
1096
|
+
Includes:
|
|
1097
|
+
- app/dashboard/payment/page.tsx (subscription management)
|
|
1098
|
+
- app/success/page.tsx (payment success page)
|
|
1099
|
+
- app/api/subscription/route.ts (subscription API)
|
|
1100
|
+
- lib/subscription.ts (subscription utilities)
|
|
1101
|
+
- lib/polar-products.ts (Polar product fetching)
|
|
1102
|
+
- payments-schema.ts (merged into db/schema.ts)
|
|
1103
|
+
|
|
1104
|
+
Notes:
|
|
1105
|
+
- Auth and Database modules were automatically installed (required dependencies)
|
|
1106
|
+
- Subscription table added to db/schema.ts
|
|
1107
|
+
- Set Polar.sh credentials in .env.local
|
|
1108
|
+
- Webhook handling is in lib/auth.ts (from auth module)
|
|
1109
|
+
`;
|
|
1110
|
+
await fs.writeFile(join5(featureDocs, "payments.md"), featureNote);
|
|
1111
|
+
const paymentsConfig = await fs.readJson(join5(paymentsSourcePath, "feature.config.json")).catch(() => null);
|
|
1112
|
+
if (paymentsConfig?.envVars) {
|
|
1113
|
+
await addEnvVarsToExample(targetPath, "payments", paymentsConfig.envVars);
|
|
1114
|
+
}
|
|
1115
|
+
spinner.succeed("Payments feature copied");
|
|
1116
|
+
} catch (error) {
|
|
1117
|
+
spinner.fail("Failed to copy payments feature");
|
|
1118
|
+
throw error;
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
async function appendEmailFeature(targetPath, dryRun = false) {
|
|
1122
|
+
const emailSourceCandidates = [
|
|
1123
|
+
resolve3(__dirname2, "../features/email"),
|
|
1124
|
+
resolve3(__dirname2, "../../features/email"),
|
|
1125
|
+
resolve3(__dirname2, "../../../features/email")
|
|
1126
|
+
];
|
|
1127
|
+
const emailSourcePath = emailSourceCandidates.find((p) => existsSync6(p));
|
|
1128
|
+
if (!emailSourcePath) {
|
|
1129
|
+
throw new Error(`Email module not found. Tried paths:
|
|
1130
|
+
${emailSourceCandidates.map((p) => ` - ${p}`).join("\n")}`);
|
|
1131
|
+
}
|
|
1132
|
+
const spinner = ora("Copying email feature...").start();
|
|
1133
|
+
try {
|
|
1134
|
+
if (dryRun) {
|
|
1135
|
+
spinner.info("DRY RUN: Would copy email utilities and templates");
|
|
1136
|
+
return;
|
|
1137
|
+
}
|
|
1138
|
+
const emailLibSource = join5(emailSourcePath, "lib", "email.ts");
|
|
1139
|
+
const emailLibTarget = join5(targetPath, "lib", "email.ts");
|
|
1140
|
+
await fs.ensureDir(join5(targetPath, "lib"));
|
|
1141
|
+
await fs.copyFile(emailLibSource, emailLibTarget);
|
|
1142
|
+
const existingContent = await fs.readFile(emailLibTarget, "utf-8").catch(() => "");
|
|
1143
|
+
if (existingContent.includes("Email Stub")) {
|
|
1144
|
+
logger.info("Replaced email stub with full email module");
|
|
1145
|
+
}
|
|
1146
|
+
const emailsSource = join5(emailSourcePath, "emails");
|
|
1147
|
+
const emailsTarget = join5(targetPath, "emails");
|
|
1148
|
+
if (existsSync6(emailsSource)) {
|
|
1149
|
+
await fs.ensureDir(emailsTarget);
|
|
1150
|
+
await fs.copy(emailsSource, emailsTarget, { overwrite: true });
|
|
1151
|
+
}
|
|
1152
|
+
const pkgPath = join5(targetPath, "package.json");
|
|
1153
|
+
if (existsSync6(pkgPath)) {
|
|
1154
|
+
const pkg = await fs.readJson(pkgPath);
|
|
1155
|
+
let updated = false;
|
|
1156
|
+
const requiredDeps = {
|
|
1157
|
+
"resend": "^6.6.0",
|
|
1158
|
+
"@react-email/components": "^1.0.2",
|
|
1159
|
+
"@react-email/render": "^2.0.0"
|
|
1160
|
+
};
|
|
1161
|
+
if (!pkg.dependencies) {
|
|
1162
|
+
pkg.dependencies = {};
|
|
1163
|
+
}
|
|
1164
|
+
for (const [dep, version] of Object.entries(requiredDeps)) {
|
|
1165
|
+
if (!pkg.dependencies[dep] && !pkg.devDependencies?.[dep]) {
|
|
1166
|
+
pkg.dependencies[dep] = version;
|
|
1167
|
+
updated = true;
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
if (updated) {
|
|
1171
|
+
await fs.writeJson(pkgPath, pkg, { spaces: 2 });
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
const shipdDir = join5(targetPath, ".shipd");
|
|
1175
|
+
const featureDocs = join5(shipdDir, "features");
|
|
1176
|
+
await fs.ensureDir(featureDocs);
|
|
1177
|
+
const featureNote = `# Email Feature
|
|
1178
|
+
Installed: ${(/* @__PURE__ */ new Date()).toISOString()}
|
|
1179
|
+
|
|
1180
|
+
Includes:
|
|
1181
|
+
- lib/email.ts (full email sending utilities)
|
|
1182
|
+
- emails/welcome.tsx (welcome email template)
|
|
1183
|
+
- emails/password-reset.tsx (password reset template)
|
|
1184
|
+
- emails/subscription-confirmation.tsx (subscription confirmation template)
|
|
1185
|
+
- emails/payment-failed.tsx (payment failed template)
|
|
1186
|
+
- emails/components/layout.tsx (shared email layout)
|
|
1187
|
+
|
|
1188
|
+
Notes:
|
|
1189
|
+
- Replaces email stub from auth module (if installed)
|
|
1190
|
+
- Set RESEND_API_KEY in .env.local to enable email sending
|
|
1191
|
+
- Works without configuration (logs to console in development)
|
|
1192
|
+
- Auth and Payments modules will automatically use real email sending
|
|
1193
|
+
`;
|
|
1194
|
+
await fs.writeFile(join5(featureDocs, "email.md"), featureNote);
|
|
1195
|
+
const emailConfig = await fs.readJson(join5(emailSourcePath, "feature.config.json")).catch(() => null);
|
|
1196
|
+
if (emailConfig?.envVars) {
|
|
1197
|
+
await addEnvVarsToExample(targetPath, "email", emailConfig.envVars);
|
|
1198
|
+
}
|
|
1199
|
+
spinner.succeed("Email feature copied");
|
|
1200
|
+
} catch (error) {
|
|
1201
|
+
spinner.fail("Failed to copy email feature");
|
|
1202
|
+
throw error;
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
async function appendAiChatFeature(targetPath, dryRun = false) {
|
|
1206
|
+
const aiChatSourceCandidates = [
|
|
1207
|
+
resolve3(__dirname2, "../features/ai-chat"),
|
|
1208
|
+
resolve3(__dirname2, "../../features/ai-chat"),
|
|
1209
|
+
resolve3(__dirname2, "../../../features/ai-chat")
|
|
1210
|
+
];
|
|
1211
|
+
const aiChatSourcePath = aiChatSourceCandidates.find((p) => existsSync6(p));
|
|
1212
|
+
if (!aiChatSourcePath) {
|
|
1213
|
+
throw new Error(`AI Chat module not found. Tried paths:
|
|
1214
|
+
${aiChatSourceCandidates.map((p) => ` - ${p}`).join("\n")}`);
|
|
1215
|
+
}
|
|
1216
|
+
const spinner = ora("Copying AI Chat feature...").start();
|
|
1217
|
+
try {
|
|
1218
|
+
if (dryRun) {
|
|
1219
|
+
spinner.info("DRY RUN: Would copy chat API route, chat page, and chatbot component");
|
|
1220
|
+
return;
|
|
1221
|
+
}
|
|
1222
|
+
const chatApiSource = join5(aiChatSourcePath, "app", "api", "chat");
|
|
1223
|
+
const chatApiTarget = join5(targetPath, "app", "api", "chat");
|
|
1224
|
+
if (existsSync6(chatApiSource)) {
|
|
1225
|
+
await fs.ensureDir(chatApiTarget);
|
|
1226
|
+
await fs.copy(chatApiSource, chatApiTarget, { overwrite: true });
|
|
1227
|
+
}
|
|
1228
|
+
const chatPageSource = join5(aiChatSourcePath, "app", "dashboard", "chat");
|
|
1229
|
+
const chatPageTarget = join5(targetPath, "app", "dashboard", "chat");
|
|
1230
|
+
if (existsSync6(chatPageSource)) {
|
|
1231
|
+
await fs.ensureDir(chatPageTarget);
|
|
1232
|
+
await fs.copy(chatPageSource, chatPageTarget, { overwrite: true });
|
|
1233
|
+
}
|
|
1234
|
+
const chatbotSource = join5(aiChatSourcePath, "app", "dashboard", "_components", "chatbot.tsx");
|
|
1235
|
+
const chatbotTarget = join5(targetPath, "app", "dashboard", "_components", "chatbot.tsx");
|
|
1236
|
+
if (existsSync6(chatbotSource)) {
|
|
1237
|
+
await fs.ensureDir(join5(targetPath, "app", "dashboard", "_components"));
|
|
1238
|
+
await fs.copyFile(chatbotSource, chatbotTarget);
|
|
1239
|
+
}
|
|
1240
|
+
const pkgPath = join5(targetPath, "package.json");
|
|
1241
|
+
if (existsSync6(pkgPath)) {
|
|
1242
|
+
const pkg = await fs.readJson(pkgPath);
|
|
1243
|
+
let updated = false;
|
|
1244
|
+
const requiredDeps = {
|
|
1245
|
+
"@ai-sdk/openai": "^1.3.22",
|
|
1246
|
+
"ai": "^4.3.16",
|
|
1247
|
+
"@ai-sdk/react": "^1.0.0"
|
|
1248
|
+
};
|
|
1249
|
+
if (!pkg.dependencies?.["react-markdown"] && !pkg.devDependencies?.["react-markdown"]) {
|
|
1250
|
+
requiredDeps["react-markdown"] = "^10.1.0";
|
|
1251
|
+
updated = true;
|
|
1252
|
+
}
|
|
1253
|
+
if (!pkg.dependencies?.["remark-gfm"] && !pkg.devDependencies?.["remark-gfm"]) {
|
|
1254
|
+
requiredDeps["remark-gfm"] = "^4.0.1";
|
|
1255
|
+
updated = true;
|
|
1256
|
+
}
|
|
1257
|
+
if (!pkg.dependencies) {
|
|
1258
|
+
pkg.dependencies = {};
|
|
1259
|
+
}
|
|
1260
|
+
for (const [dep, version] of Object.entries(requiredDeps)) {
|
|
1261
|
+
if (!pkg.dependencies[dep] && !pkg.devDependencies?.[dep]) {
|
|
1262
|
+
pkg.dependencies[dep] = version;
|
|
1263
|
+
updated = true;
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
1266
|
+
if (updated) {
|
|
1267
|
+
await fs.writeJson(pkgPath, pkg, { spaces: 2 });
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
const shipdDir = join5(targetPath, ".shipd");
|
|
1271
|
+
const featureDocs = join5(shipdDir, "features");
|
|
1272
|
+
await fs.ensureDir(featureDocs);
|
|
1273
|
+
const featureNote = `# AI Chat Feature
|
|
1274
|
+
Installed: ${(/* @__PURE__ */ new Date()).toISOString()}
|
|
1275
|
+
|
|
1276
|
+
Includes:
|
|
1277
|
+
- app/api/chat/route.ts (OpenAI streaming chat API)
|
|
1278
|
+
- app/dashboard/chat/page.tsx (full chat interface)
|
|
1279
|
+
- app/dashboard/_components/chatbot.tsx (chatbot widget component)
|
|
1280
|
+
|
|
1281
|
+
Notes:
|
|
1282
|
+
- Set OPENAI_API_KEY in .env.local to enable chat functionality
|
|
1283
|
+
- Uses GPT-4o model with web search preview tool
|
|
1284
|
+
- Streaming responses with markdown rendering
|
|
1285
|
+
- Chatbot component can be integrated into dashboard layout
|
|
1286
|
+
`;
|
|
1287
|
+
await fs.writeFile(join5(featureDocs, "ai-chat.md"), featureNote);
|
|
1288
|
+
const aiChatConfig = await fs.readJson(join5(aiChatSourcePath, "feature.config.json")).catch(() => null);
|
|
1289
|
+
if (aiChatConfig?.envVars) {
|
|
1290
|
+
await addEnvVarsToExample(targetPath, "ai-chat", aiChatConfig.envVars);
|
|
1291
|
+
}
|
|
1292
|
+
spinner.succeed("AI Chat feature copied");
|
|
1293
|
+
} catch (error) {
|
|
1294
|
+
spinner.fail("Failed to copy AI Chat feature");
|
|
1295
|
+
throw error;
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
async function appendFileUploadFeature(targetPath, dryRun = false) {
|
|
1299
|
+
const fileUploadSourceCandidates = [
|
|
1300
|
+
resolve3(__dirname2, "../features/file-upload"),
|
|
1301
|
+
resolve3(__dirname2, "../../features/file-upload"),
|
|
1302
|
+
resolve3(__dirname2, "../../../features/file-upload")
|
|
1303
|
+
];
|
|
1304
|
+
const fileUploadSourcePath = fileUploadSourceCandidates.find((p) => existsSync6(p));
|
|
1305
|
+
if (!fileUploadSourcePath) {
|
|
1306
|
+
throw new Error(`File Upload module not found. Tried paths:
|
|
1307
|
+
${fileUploadSourceCandidates.map((p) => ` - ${p}`).join("\n")}`);
|
|
1308
|
+
}
|
|
1309
|
+
const spinner = ora("Copying File Upload feature...").start();
|
|
1310
|
+
try {
|
|
1311
|
+
if (dryRun) {
|
|
1312
|
+
spinner.info("DRY RUN: Would copy upload API route, upload page, and upload utilities");
|
|
1313
|
+
return;
|
|
1314
|
+
}
|
|
1315
|
+
const uploadApiSource = join5(fileUploadSourcePath, "app", "api", "upload-image");
|
|
1316
|
+
const uploadApiTarget = join5(targetPath, "app", "api", "upload-image");
|
|
1317
|
+
if (existsSync6(uploadApiSource)) {
|
|
1318
|
+
await fs.ensureDir(uploadApiTarget);
|
|
1319
|
+
await fs.copy(uploadApiSource, uploadApiTarget, { overwrite: true });
|
|
1320
|
+
}
|
|
1321
|
+
const uploadPageSource = join5(fileUploadSourcePath, "app", "dashboard", "upload");
|
|
1322
|
+
const uploadPageTarget = join5(targetPath, "app", "dashboard", "upload");
|
|
1323
|
+
if (existsSync6(uploadPageSource)) {
|
|
1324
|
+
await fs.ensureDir(uploadPageTarget);
|
|
1325
|
+
await fs.copy(uploadPageSource, uploadPageTarget, { overwrite: true });
|
|
1326
|
+
}
|
|
1327
|
+
const uploadLibSource = join5(fileUploadSourcePath, "lib", "upload-image.ts");
|
|
1328
|
+
const uploadLibTarget = join5(targetPath, "lib", "upload-image.ts");
|
|
1329
|
+
if (existsSync6(uploadLibSource)) {
|
|
1330
|
+
await fs.ensureDir(join5(targetPath, "lib"));
|
|
1331
|
+
await fs.copyFile(uploadLibSource, uploadLibTarget);
|
|
1332
|
+
}
|
|
1333
|
+
const pkgPath = join5(targetPath, "package.json");
|
|
1334
|
+
if (existsSync6(pkgPath)) {
|
|
1335
|
+
const pkg = await fs.readJson(pkgPath);
|
|
1336
|
+
let updated = false;
|
|
1337
|
+
const requiredDeps = {
|
|
1338
|
+
"@aws-sdk/client-s3": "^3.800.0"
|
|
1339
|
+
};
|
|
1340
|
+
if (!pkg.dependencies) {
|
|
1341
|
+
pkg.dependencies = {};
|
|
1342
|
+
}
|
|
1343
|
+
for (const [dep, version] of Object.entries(requiredDeps)) {
|
|
1344
|
+
if (!pkg.dependencies[dep] && !pkg.devDependencies?.[dep]) {
|
|
1345
|
+
pkg.dependencies[dep] = version;
|
|
1346
|
+
updated = true;
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
if (updated) {
|
|
1350
|
+
await fs.writeJson(pkgPath, pkg, { spaces: 2 });
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
const shipdDir = join5(targetPath, ".shipd");
|
|
1354
|
+
const featureDocs = join5(shipdDir, "features");
|
|
1355
|
+
await fs.ensureDir(featureDocs);
|
|
1356
|
+
const featureNote = `# File Upload Feature
|
|
1357
|
+
Installed: ${(/* @__PURE__ */ new Date()).toISOString()}
|
|
1358
|
+
|
|
1359
|
+
Includes:
|
|
1360
|
+
- app/api/upload-image/route.ts (R2 upload API)
|
|
1361
|
+
- app/dashboard/upload/page.tsx (upload interface with drag-and-drop)
|
|
1362
|
+
- lib/upload-image.ts (R2 upload utilities)
|
|
1363
|
+
|
|
1364
|
+
Notes:
|
|
1365
|
+
- Set Cloudflare R2 credentials in .env.local
|
|
1366
|
+
- Configure public domain for R2 bucket
|
|
1367
|
+
- Update public URL in lib/upload-image.ts
|
|
1368
|
+
- Supports image uploads up to 5MB (client) / 10MB (server)
|
|
1369
|
+
`;
|
|
1370
|
+
await fs.writeFile(join5(featureDocs, "file-upload.md"), featureNote);
|
|
1371
|
+
const fileUploadConfig = await fs.readJson(join5(fileUploadSourcePath, "feature.config.json")).catch(() => null);
|
|
1372
|
+
if (fileUploadConfig?.envVars) {
|
|
1373
|
+
await addEnvVarsToExample(targetPath, "file-upload", fileUploadConfig.envVars);
|
|
1374
|
+
}
|
|
1375
|
+
spinner.succeed("File Upload feature copied");
|
|
1376
|
+
} catch (error) {
|
|
1377
|
+
spinner.fail("Failed to copy File Upload feature");
|
|
1378
|
+
throw error;
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
async function appendAnalyticsFeature(targetPath, dryRun = false) {
|
|
1382
|
+
const analyticsSourceCandidates = [
|
|
1383
|
+
resolve3(__dirname2, "../features/analytics"),
|
|
1384
|
+
resolve3(__dirname2, "../../features/analytics"),
|
|
1385
|
+
resolve3(__dirname2, "../../../features/analytics")
|
|
1386
|
+
];
|
|
1387
|
+
const analyticsSourcePath = analyticsSourceCandidates.find((p) => existsSync6(p));
|
|
1388
|
+
if (!analyticsSourcePath) {
|
|
1389
|
+
throw new Error(`Analytics module not found. Tried paths:
|
|
1390
|
+
${analyticsSourceCandidates.map((p) => ` - ${p}`).join("\n")}`);
|
|
1391
|
+
}
|
|
1392
|
+
const spinner = ora("Copying Analytics feature...").start();
|
|
1393
|
+
try {
|
|
1394
|
+
if (dryRun) {
|
|
1395
|
+
spinner.info("DRY RUN: Would copy PostHog provider and utilities");
|
|
1396
|
+
return;
|
|
1397
|
+
}
|
|
1398
|
+
const analyticsLibSource = join5(analyticsSourcePath, "lib", "posthog.ts");
|
|
1399
|
+
const analyticsLibTarget = join5(targetPath, "lib", "posthog.ts");
|
|
1400
|
+
if (existsSync6(analyticsLibSource)) {
|
|
1401
|
+
await fs.ensureDir(join5(targetPath, "lib"));
|
|
1402
|
+
await fs.copyFile(analyticsLibSource, analyticsLibTarget);
|
|
1403
|
+
}
|
|
1404
|
+
const pkgPath = join5(targetPath, "package.json");
|
|
1405
|
+
if (existsSync6(pkgPath)) {
|
|
1406
|
+
const pkg = await fs.readJson(pkgPath);
|
|
1407
|
+
let updated = false;
|
|
1408
|
+
const requiredDeps = {
|
|
1409
|
+
"posthog-js": "^1.248.1",
|
|
1410
|
+
"posthog-node": "^4.18.0"
|
|
1411
|
+
};
|
|
1412
|
+
if (!pkg.dependencies) {
|
|
1413
|
+
pkg.dependencies = {};
|
|
1414
|
+
}
|
|
1415
|
+
for (const [dep, version] of Object.entries(requiredDeps)) {
|
|
1416
|
+
if (!pkg.dependencies[dep] && !pkg.devDependencies?.[dep]) {
|
|
1417
|
+
pkg.dependencies[dep] = version;
|
|
1418
|
+
updated = true;
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
if (updated) {
|
|
1422
|
+
await fs.writeJson(pkgPath, pkg, { spaces: 2 });
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1425
|
+
const shipdDir = join5(targetPath, ".shipd");
|
|
1426
|
+
const featureDocs = join5(shipdDir, "features");
|
|
1427
|
+
await fs.ensureDir(featureDocs);
|
|
1428
|
+
const featureNote = `# Analytics Feature
|
|
1429
|
+
Installed: ${(/* @__PURE__ */ new Date()).toISOString()}
|
|
1430
|
+
|
|
1431
|
+
Includes:
|
|
1432
|
+
- lib/posthog.ts (PostHog provider and initialization)
|
|
1433
|
+
|
|
1434
|
+
Notes:
|
|
1435
|
+
- Set NEXT_PUBLIC_POSTHOG_KEY in .env.local to enable analytics
|
|
1436
|
+
- Add PostHogProviderWrapper to app/layout.tsx
|
|
1437
|
+
- Automatic pageview tracking enabled
|
|
1438
|
+
- Custom event tracking available via usePostHog hook
|
|
1439
|
+
`;
|
|
1440
|
+
await fs.writeFile(join5(featureDocs, "analytics.md"), featureNote);
|
|
1441
|
+
const analyticsConfig = await fs.readJson(join5(analyticsSourcePath, "feature.config.json")).catch(() => null);
|
|
1442
|
+
if (analyticsConfig?.envVars) {
|
|
1443
|
+
await addEnvVarsToExample(targetPath, "analytics", analyticsConfig.envVars);
|
|
1444
|
+
}
|
|
1445
|
+
spinner.succeed("Analytics feature copied");
|
|
1446
|
+
} catch (error) {
|
|
1447
|
+
spinner.fail("Failed to copy Analytics feature");
|
|
1448
|
+
throw error;
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
async function appendSeoFeature(targetPath, dryRun = false) {
|
|
1452
|
+
const seoSourceCandidates = [
|
|
1453
|
+
resolve3(__dirname2, "../features/seo"),
|
|
1454
|
+
resolve3(__dirname2, "../../features/seo"),
|
|
1455
|
+
resolve3(__dirname2, "../../../features/seo")
|
|
1456
|
+
];
|
|
1457
|
+
const seoSourcePath = seoSourceCandidates.find((p) => existsSync6(p));
|
|
1458
|
+
if (!seoSourcePath) {
|
|
1459
|
+
throw new Error(
|
|
1460
|
+
`SEO feature not found. Tried paths:
|
|
1461
|
+
${seoSourceCandidates.map((p) => ` - ${p}`).join("\n")}`
|
|
1462
|
+
);
|
|
1463
|
+
}
|
|
1464
|
+
const spinner = ora("Copying SEO feature...").start();
|
|
1465
|
+
try {
|
|
1466
|
+
if (dryRun) {
|
|
1467
|
+
spinner.info("DRY RUN: Would copy SEO files");
|
|
1468
|
+
return;
|
|
1469
|
+
}
|
|
1470
|
+
const sitemapSource = join5(seoSourcePath, "app", "sitemap.ts");
|
|
1471
|
+
const sitemapTarget = join5(targetPath, "app", "sitemap.ts");
|
|
1472
|
+
if (existsSync6(sitemapSource)) {
|
|
1473
|
+
await fs.ensureDir(dirname2(sitemapTarget));
|
|
1474
|
+
await fs.copyFile(sitemapSource, sitemapTarget);
|
|
1475
|
+
}
|
|
1476
|
+
const robotsSource = join5(seoSourcePath, "app", "robots.txt");
|
|
1477
|
+
const robotsTarget = join5(targetPath, "app", "robots.txt");
|
|
1478
|
+
if (existsSync6(robotsSource)) {
|
|
1479
|
+
await fs.ensureDir(dirname2(robotsTarget));
|
|
1480
|
+
await fs.copyFile(robotsSource, robotsTarget);
|
|
1481
|
+
}
|
|
1482
|
+
const blogSource = join5(seoSourcePath, "app", "blog");
|
|
1483
|
+
const blogTarget = join5(targetPath, "app", "blog");
|
|
1484
|
+
if (existsSync6(blogSource)) {
|
|
1485
|
+
await fs.copy(blogSource, blogTarget, {
|
|
1486
|
+
overwrite: false,
|
|
1487
|
+
errorOnExist: false
|
|
1488
|
+
});
|
|
1489
|
+
}
|
|
1490
|
+
const seoUtilsSource = join5(seoSourcePath, "lib", "seo-utils.ts");
|
|
1491
|
+
const seoUtilsTarget = join5(targetPath, "lib", "seo-utils.ts");
|
|
1492
|
+
if (existsSync6(seoUtilsSource)) {
|
|
1493
|
+
await fs.ensureDir(dirname2(seoUtilsTarget));
|
|
1494
|
+
await fs.copyFile(seoUtilsSource, seoUtilsTarget);
|
|
1495
|
+
}
|
|
1496
|
+
const pkgPath = join5(targetPath, "package.json");
|
|
1497
|
+
if (existsSync6(pkgPath)) {
|
|
1498
|
+
const pkg = await fs.readJson(pkgPath);
|
|
1499
|
+
let updated = false;
|
|
1500
|
+
const requiredDeps = {
|
|
1501
|
+
"react-markdown": "^10.1.0",
|
|
1502
|
+
"remark-gfm": "^4.0.1"
|
|
1503
|
+
};
|
|
1504
|
+
if (!pkg.dependencies) {
|
|
1505
|
+
pkg.dependencies = {};
|
|
1506
|
+
}
|
|
1507
|
+
for (const [dep, version] of Object.entries(requiredDeps)) {
|
|
1508
|
+
if (!pkg.dependencies[dep] && !pkg.devDependencies?.[dep]) {
|
|
1509
|
+
pkg.dependencies[dep] = version;
|
|
1510
|
+
updated = true;
|
|
1511
|
+
}
|
|
1512
|
+
}
|
|
1513
|
+
if (updated) {
|
|
1514
|
+
await fs.writeJson(pkgPath, pkg, { spaces: 2 });
|
|
1515
|
+
}
|
|
1516
|
+
}
|
|
1517
|
+
const shipdDir = join5(targetPath, ".shipd");
|
|
1518
|
+
const featureDocs = join5(shipdDir, "features");
|
|
1519
|
+
await fs.ensureDir(featureDocs);
|
|
1520
|
+
const featureNote = `# SEO Feature
|
|
1521
|
+
Installed: ${(/* @__PURE__ */ new Date()).toISOString()}
|
|
1522
|
+
|
|
1523
|
+
Includes:
|
|
1524
|
+
- app/sitemap.ts (Automated sitemap generation)
|
|
1525
|
+
- app/robots.txt (Search engine crawler instructions)
|
|
1526
|
+
- app/blog/ (Complete blog structure)
|
|
1527
|
+
- lib/seo-utils.ts (Structured data helpers)
|
|
1528
|
+
|
|
1529
|
+
Notes:
|
|
1530
|
+
- Set NEXT_PUBLIC_APP_URL in .env.local
|
|
1531
|
+
- Update sitemap.ts with your custom routes
|
|
1532
|
+
- Customize blog posts in app/blog/
|
|
1533
|
+
- Add structured data to your pages using lib/seo-utils.ts
|
|
1534
|
+
`;
|
|
1535
|
+
await fs.writeFile(join5(featureDocs, "seo.md"), featureNote);
|
|
1536
|
+
const readmeSource = join5(seoSourcePath, "README.md");
|
|
1537
|
+
if (existsSync6(readmeSource)) {
|
|
1538
|
+
await fs.copyFile(readmeSource, join5(featureDocs, "seo.md"));
|
|
1539
|
+
}
|
|
1540
|
+
const seoConfig = await fs.readJson(join5(seoSourcePath, "feature.config.json")).catch(() => null);
|
|
1541
|
+
if (seoConfig?.envVars) {
|
|
1542
|
+
await addEnvVarsToExample(targetPath, "seo", seoConfig.envVars);
|
|
1543
|
+
}
|
|
1544
|
+
spinner.succeed("SEO feature copied");
|
|
1545
|
+
} catch (error) {
|
|
1546
|
+
spinner.fail("Failed to copy SEO feature");
|
|
1547
|
+
throw error;
|
|
1548
|
+
}
|
|
1549
|
+
}
|
|
1550
|
+
async function appendCommand(options) {
|
|
1551
|
+
try {
|
|
1552
|
+
const cwd = process.cwd();
|
|
1553
|
+
logger.intro("Adding Shipd features to your project");
|
|
1554
|
+
const detectSpinner = ora("Detecting project...").start();
|
|
1555
|
+
const projectInfo = await detectProject(cwd);
|
|
1556
|
+
detectSpinner.succeed("Project validated");
|
|
1557
|
+
logger.info(`\u2713 Next.js App Router project detected`);
|
|
1558
|
+
logger.info(`\u2713 Package manager: ${projectInfo.packageManager}`);
|
|
1559
|
+
if (projectInfo.installedFeatures.length > 0) {
|
|
1560
|
+
logger.info(`\u2713 Installed features: ${projectInfo.installedFeatures.join(", ")}`);
|
|
1561
|
+
}
|
|
1562
|
+
const availableFeatures = ["docs", "marketing-landing", "database", "auth", "payments", "email", "ai-chat", "file-upload", "analytics", "seo"];
|
|
1563
|
+
const alreadyInstalled = projectInfo.installedFeatures.filter((f) => availableFeatures.includes(f));
|
|
1564
|
+
if (alreadyInstalled.length > 0) {
|
|
1565
|
+
logger.warn(`Already installed: ${alreadyInstalled.join(", ")}`);
|
|
1566
|
+
}
|
|
1567
|
+
let selectedFeatures;
|
|
1568
|
+
if (options.features) {
|
|
1569
|
+
selectedFeatures = options.features.split(",").map((f) => f.trim());
|
|
1570
|
+
} else {
|
|
1571
|
+
const answers = await inquirer2.prompt([
|
|
1572
|
+
{
|
|
1573
|
+
type: "checkbox",
|
|
1574
|
+
name: "features",
|
|
1575
|
+
message: "Select features to add:",
|
|
1576
|
+
choices: [
|
|
1577
|
+
{ name: "Documentation Pages", value: "docs", checked: false },
|
|
1578
|
+
{ name: "Marketing Landing Page", value: "marketing-landing", checked: false },
|
|
1579
|
+
{ name: "Database (PostgreSQL + Drizzle)", value: "database", checked: false },
|
|
1580
|
+
{ name: "Authentication (Better Auth)", value: "auth", checked: false },
|
|
1581
|
+
{ name: "Payments (Polar.sh)", value: "payments", checked: false },
|
|
1582
|
+
{ name: "Email (Resend)", value: "email", checked: false },
|
|
1583
|
+
{ name: "AI Chat (OpenAI)", value: "ai-chat", checked: false },
|
|
1584
|
+
{ name: "File Upload (Cloudflare R2)", value: "file-upload", checked: false },
|
|
1585
|
+
{ name: "Analytics (PostHog)", value: "analytics", checked: false },
|
|
1586
|
+
{ name: "SEO (Sitemap, Robots, Blog)", value: "seo", checked: false }
|
|
1587
|
+
]
|
|
1588
|
+
}
|
|
1589
|
+
]);
|
|
1590
|
+
selectedFeatures = answers.features;
|
|
1591
|
+
if (selectedFeatures.includes("auth") && !selectedFeatures.includes("database")) {
|
|
1592
|
+
selectedFeatures.push("database");
|
|
1593
|
+
logger.info("\u2713 Database will be included automatically (required by auth)");
|
|
1594
|
+
}
|
|
1595
|
+
if (selectedFeatures.includes("payments")) {
|
|
1596
|
+
if (!selectedFeatures.includes("auth")) {
|
|
1597
|
+
selectedFeatures.push("auth");
|
|
1598
|
+
logger.info("\u2713 Auth will be included automatically (required by payments)");
|
|
1599
|
+
}
|
|
1600
|
+
if (!selectedFeatures.includes("database")) {
|
|
1601
|
+
selectedFeatures.push("database");
|
|
1602
|
+
logger.info("\u2713 Database will be included automatically (required by payments)");
|
|
1603
|
+
}
|
|
1604
|
+
}
|
|
1605
|
+
}
|
|
1606
|
+
if (selectedFeatures.length === 0) {
|
|
1607
|
+
logger.warn("No features selected. Exiting.");
|
|
1608
|
+
return;
|
|
1609
|
+
}
|
|
1610
|
+
console.log("\n\u{1F4CB} Changes Preview:");
|
|
1611
|
+
console.log(" Features to add:", selectedFeatures.join(", "));
|
|
1612
|
+
console.log(" Mode:", options.dryRun ? "DRY RUN" : "LIVE");
|
|
1613
|
+
if (!options.dryRun) {
|
|
1614
|
+
const confirm = await inquirer2.prompt([
|
|
1615
|
+
{
|
|
1616
|
+
type: "confirm",
|
|
1617
|
+
name: "proceed",
|
|
1618
|
+
message: "Proceed with installation?",
|
|
1619
|
+
default: true
|
|
1620
|
+
}
|
|
1621
|
+
]);
|
|
1622
|
+
if (!confirm.proceed) {
|
|
1623
|
+
logger.warn("Installation cancelled");
|
|
1624
|
+
return;
|
|
1625
|
+
}
|
|
1626
|
+
}
|
|
1627
|
+
console.log();
|
|
1628
|
+
const featuresToInstall = [...selectedFeatures];
|
|
1629
|
+
featuresToInstall.sort((a, b) => {
|
|
1630
|
+
const order = ["database", "auth", "payments"];
|
|
1631
|
+
const aIndex = order.indexOf(a);
|
|
1632
|
+
const bIndex = order.indexOf(b);
|
|
1633
|
+
if (aIndex !== -1 && bIndex !== -1) return aIndex - bIndex;
|
|
1634
|
+
if (aIndex !== -1) return -1;
|
|
1635
|
+
if (bIndex !== -1) return 1;
|
|
1636
|
+
return 0;
|
|
1637
|
+
});
|
|
1638
|
+
for (const feature of featuresToInstall) {
|
|
1639
|
+
if (feature === "docs") {
|
|
1640
|
+
await appendDocsFeature(cwd, options.dryRun);
|
|
1641
|
+
} else if (feature === "marketing-landing") {
|
|
1642
|
+
await appendMarketingFeature(cwd, options.dryRun);
|
|
1643
|
+
} else if (feature === "database") {
|
|
1644
|
+
await appendDatabaseFeature(cwd, options.dryRun);
|
|
1645
|
+
} else if (feature === "auth") {
|
|
1646
|
+
await appendAuthFeature(cwd, options.dryRun);
|
|
1647
|
+
} else if (feature === "payments") {
|
|
1648
|
+
await appendPaymentsFeature(cwd, options.dryRun);
|
|
1649
|
+
} else if (feature === "email") {
|
|
1650
|
+
await appendEmailFeature(cwd, options.dryRun);
|
|
1651
|
+
} else if (feature === "ai-chat") {
|
|
1652
|
+
await appendAiChatFeature(cwd, options.dryRun);
|
|
1653
|
+
} else if (feature === "file-upload") {
|
|
1654
|
+
await appendFileUploadFeature(cwd, options.dryRun);
|
|
1655
|
+
} else if (feature === "analytics") {
|
|
1656
|
+
await appendAnalyticsFeature(cwd, options.dryRun);
|
|
1657
|
+
} else if (feature === "seo") {
|
|
1658
|
+
await appendSeoFeature(cwd, options.dryRun);
|
|
1659
|
+
}
|
|
1660
|
+
}
|
|
1661
|
+
if (!options.dryRun) {
|
|
1662
|
+
const pkgPath = join5(cwd, "package.json");
|
|
1663
|
+
if (existsSync6(pkgPath)) {
|
|
1664
|
+
const pkg = await fs.readJson(pkgPath);
|
|
1665
|
+
const hasNewDeps = selectedFeatures.some((f) => {
|
|
1666
|
+
return true;
|
|
1667
|
+
});
|
|
1668
|
+
if (hasNewDeps) {
|
|
1669
|
+
const installSpinner = ora("Installing dependencies...").start();
|
|
1670
|
+
try {
|
|
1671
|
+
await execa(projectInfo.packageManager, ["install"], {
|
|
1672
|
+
cwd,
|
|
1673
|
+
stdio: "pipe"
|
|
1674
|
+
});
|
|
1675
|
+
installSpinner.succeed("Dependencies installed");
|
|
1676
|
+
} catch (error) {
|
|
1677
|
+
installSpinner.fail("Failed to install dependencies");
|
|
1678
|
+
logger.warn("You may need to run npm install (or your package manager) manually");
|
|
1679
|
+
}
|
|
1680
|
+
}
|
|
1681
|
+
}
|
|
1682
|
+
}
|
|
1683
|
+
console.log();
|
|
1684
|
+
logger.success("\u2728 Features added successfully!");
|
|
1685
|
+
console.log();
|
|
1686
|
+
console.log("\u{1F4D6} Next steps:");
|
|
1687
|
+
if (selectedFeatures.includes("docs")) {
|
|
1688
|
+
console.log(" \u2713 Visit /docs in your application");
|
|
1689
|
+
console.log(" - Customize the docs in app/docs/");
|
|
1690
|
+
console.log(" - Check .shipd/features/docs.md for details");
|
|
1691
|
+
}
|
|
1692
|
+
if (selectedFeatures.includes("marketing-landing")) {
|
|
1693
|
+
console.log(" \u2713 Visit / (landing page) in your application");
|
|
1694
|
+
console.log(" - Check .shipd/features/marketing-landing.md for details");
|
|
1695
|
+
}
|
|
1696
|
+
if (selectedFeatures.includes("database")) {
|
|
1697
|
+
console.log(" \u2713 Database setup complete");
|
|
1698
|
+
console.log(" - Set DATABASE_URL in .env.local");
|
|
1699
|
+
console.log(" - Run npm run db:push to initialize database");
|
|
1700
|
+
console.log(" - Check .shipd/features/database.md for details");
|
|
1701
|
+
}
|
|
1702
|
+
if (selectedFeatures.includes("auth")) {
|
|
1703
|
+
console.log(" \u2713 Visit /sign-in and /sign-up in your application");
|
|
1704
|
+
console.log(" - Set BETTER_AUTH_SECRET and Google OAuth credentials");
|
|
1705
|
+
console.log(" - Check .shipd/features/auth.md for details");
|
|
1706
|
+
}
|
|
1707
|
+
if (selectedFeatures.includes("payments")) {
|
|
1708
|
+
console.log(" \u2713 Visit /dashboard/payment for subscription management");
|
|
1709
|
+
console.log(" - Set Polar.sh credentials in .env.local");
|
|
1710
|
+
console.log(" - Run npm run db:push to create subscription table");
|
|
1711
|
+
console.log(" - Check .shipd/features/payments.md for details");
|
|
1712
|
+
}
|
|
1713
|
+
if (selectedFeatures.includes("email")) {
|
|
1714
|
+
console.log(" \u2713 Email sending enabled");
|
|
1715
|
+
console.log(" - Set RESEND_API_KEY in .env.local");
|
|
1716
|
+
console.log(" - Auth and Payments modules will use real email sending");
|
|
1717
|
+
console.log(" - Check .shipd/features/email.md for details");
|
|
1718
|
+
}
|
|
1719
|
+
if (selectedFeatures.includes("ai-chat")) {
|
|
1720
|
+
console.log(" \u2713 AI Chat enabled");
|
|
1721
|
+
console.log(" - Visit /dashboard/chat to use the chat interface");
|
|
1722
|
+
console.log(" - Set OPENAI_API_KEY in .env.local");
|
|
1723
|
+
console.log(" - Check .shipd/features/ai-chat.md for details");
|
|
1724
|
+
}
|
|
1725
|
+
if (selectedFeatures.includes("file-upload")) {
|
|
1726
|
+
console.log(" \u2713 File Upload enabled");
|
|
1727
|
+
console.log(" - Visit /dashboard/upload to use the upload interface");
|
|
1728
|
+
console.log(" - Set Cloudflare R2 credentials in .env.local");
|
|
1729
|
+
console.log(" - Configure public domain for R2 bucket");
|
|
1730
|
+
console.log(" - Check .shipd/features/file-upload.md for details");
|
|
1731
|
+
}
|
|
1732
|
+
if (selectedFeatures.includes("analytics")) {
|
|
1733
|
+
console.log(" \u2713 Analytics enabled");
|
|
1734
|
+
console.log(" - Set NEXT_PUBLIC_POSTHOG_KEY in .env.local");
|
|
1735
|
+
console.log(" - Add PostHogProviderWrapper to app/layout.tsx");
|
|
1736
|
+
console.log(" - Check .shipd/features/analytics.md for details");
|
|
1737
|
+
}
|
|
1738
|
+
console.log();
|
|
1739
|
+
console.log("\u{1F680} Ready to build:");
|
|
1740
|
+
logger.step(`${projectInfo.packageManager} run build`);
|
|
1741
|
+
logger.step(`${projectInfo.packageManager} run dev`);
|
|
1742
|
+
console.log();
|
|
1743
|
+
} catch (error) {
|
|
1744
|
+
logger.error("Failed to append features");
|
|
1745
|
+
if (error instanceof Error) {
|
|
1746
|
+
console.error("\n" + error.message);
|
|
1747
|
+
}
|
|
1748
|
+
process.exit(1);
|
|
1749
|
+
}
|
|
1750
|
+
}
|
|
1751
|
+
|
|
1752
|
+
// src/generators/project.ts
|
|
1753
|
+
var __filename3 = fileURLToPath3(import.meta.url);
|
|
1754
|
+
var __dirname3 = dirname3(__filename3);
|
|
1755
|
+
async function generateProject(config) {
|
|
1756
|
+
const targetPath = resolve4(process.cwd(), config.projectName);
|
|
1757
|
+
if (await fs2.pathExists(targetPath)) {
|
|
1758
|
+
const files = await fs2.readdir(targetPath);
|
|
1759
|
+
if (files.length > 0) {
|
|
1760
|
+
const hasPkgJson = await fs2.pathExists(resolve4(targetPath, "package.json"));
|
|
1761
|
+
const hasGit = await fs2.pathExists(resolve4(targetPath, ".git"));
|
|
1762
|
+
const hasNodeModules = await fs2.pathExists(resolve4(targetPath, "node_modules"));
|
|
1763
|
+
if (hasPkgJson || hasGit || hasNodeModules) {
|
|
1764
|
+
throw new Error(
|
|
1765
|
+
`Directory "${config.projectName}" appears to contain an existing project.
|
|
1766
|
+
- package.json: ${hasPkgJson ? "\u2713 found" : "\u2717 not found"}
|
|
1767
|
+
- .git: ${hasGit ? "\u2713 found" : "\u2717 not found"}
|
|
1768
|
+
- node_modules: ${hasNodeModules ? "\u2713 found" : "\u2717 not found"}
|
|
1769
|
+
|
|
1770
|
+
To avoid data loss, shipd init will not overwrite existing projects.
|
|
1771
|
+
Please:
|
|
1772
|
+
1. Choose a different project name, or
|
|
1773
|
+
2. Delete the existing directory first, or
|
|
1774
|
+
3. Use 'shipd append' to add features to the existing project`
|
|
1775
|
+
);
|
|
1776
|
+
}
|
|
1777
|
+
logger.warn(`Directory "${config.projectName}" is not empty but doesn't appear to be a project.`);
|
|
1778
|
+
logger.warn("Existing files may be overwritten. Press Ctrl+C to cancel or wait 3 seconds to continue...");
|
|
1779
|
+
await new Promise((resolve5) => setTimeout(resolve5, 3e3));
|
|
1780
|
+
}
|
|
1781
|
+
}
|
|
1782
|
+
const basePath = getBasePath();
|
|
1783
|
+
const copySpinner = ora2("Copying base package...").start();
|
|
1784
|
+
try {
|
|
1785
|
+
await copyTemplate(basePath, targetPath);
|
|
1786
|
+
copySpinner.succeed("Base package copied");
|
|
1787
|
+
} catch (error) {
|
|
1788
|
+
copySpinner.fail("Failed to copy base package");
|
|
1789
|
+
throw error;
|
|
1790
|
+
}
|
|
1791
|
+
if (config.features.length > 0) {
|
|
1792
|
+
const modulesSpinner = ora2("Adding feature modules...").start();
|
|
1793
|
+
try {
|
|
1794
|
+
const featuresToInstall = [...config.features];
|
|
1795
|
+
if (featuresToInstall.includes("auth") && !featuresToInstall.includes("database")) {
|
|
1796
|
+
featuresToInstall.push("database");
|
|
1797
|
+
logger.info("\u2713 Database will be included automatically (required by auth)");
|
|
1798
|
+
}
|
|
1799
|
+
if (featuresToInstall.includes("payments")) {
|
|
1800
|
+
if (!featuresToInstall.includes("auth")) {
|
|
1801
|
+
featuresToInstall.push("auth");
|
|
1802
|
+
logger.info("\u2713 Auth will be included automatically (required by payments)");
|
|
1803
|
+
}
|
|
1804
|
+
if (!featuresToInstall.includes("database")) {
|
|
1805
|
+
featuresToInstall.push("database");
|
|
1806
|
+
logger.info("\u2713 Database will be included automatically (required by payments)");
|
|
1807
|
+
}
|
|
1808
|
+
}
|
|
1809
|
+
featuresToInstall.sort((a, b) => {
|
|
1810
|
+
const order = ["database", "auth", "payments"];
|
|
1811
|
+
const aIndex = order.indexOf(a);
|
|
1812
|
+
const bIndex = order.indexOf(b);
|
|
1813
|
+
if (aIndex !== -1 && bIndex !== -1) return aIndex - bIndex;
|
|
1814
|
+
if (aIndex !== -1) return -1;
|
|
1815
|
+
if (bIndex !== -1) return 1;
|
|
1816
|
+
return 0;
|
|
1817
|
+
});
|
|
1818
|
+
for (const feature of featuresToInstall) {
|
|
1819
|
+
if (feature === "marketing-landing") {
|
|
1820
|
+
await appendMarketingFeature(targetPath, false);
|
|
1821
|
+
} else if (feature === "docs") {
|
|
1822
|
+
await appendDocsFeature(targetPath, false);
|
|
1823
|
+
} else if (feature === "database") {
|
|
1824
|
+
await appendDatabaseFeature(targetPath, false);
|
|
1825
|
+
} else if (feature === "auth") {
|
|
1826
|
+
await appendAuthFeature(targetPath, false);
|
|
1827
|
+
} else if (feature === "payments") {
|
|
1828
|
+
await appendPaymentsFeature(targetPath, false);
|
|
1829
|
+
} else if (feature === "email") {
|
|
1830
|
+
await appendEmailFeature(targetPath, false);
|
|
1831
|
+
} else if (feature === "ai-chat") {
|
|
1832
|
+
await appendAiChatFeature(targetPath, false);
|
|
1833
|
+
} else if (feature === "file-upload") {
|
|
1834
|
+
await appendFileUploadFeature(targetPath, false);
|
|
1835
|
+
} else if (feature === "analytics") {
|
|
1836
|
+
await appendAnalyticsFeature(targetPath, false);
|
|
1837
|
+
} else if (feature === "seo") {
|
|
1838
|
+
await appendSeoFeature(targetPath, false);
|
|
1839
|
+
}
|
|
1840
|
+
}
|
|
1841
|
+
modulesSpinner.succeed(`Added ${featuresToInstall.length} feature module(s)`);
|
|
1842
|
+
} catch (error) {
|
|
1843
|
+
modulesSpinner.fail("Failed to add feature modules");
|
|
1844
|
+
throw error;
|
|
1845
|
+
}
|
|
1846
|
+
}
|
|
1847
|
+
const varsSpinner = ora2("Replacing template variables...").start();
|
|
1848
|
+
try {
|
|
1849
|
+
await replaceTemplateVariables(targetPath, config);
|
|
1850
|
+
varsSpinner.succeed("Variables replaced");
|
|
1851
|
+
} catch (error) {
|
|
1852
|
+
varsSpinner.fail("Failed to replace variables");
|
|
1853
|
+
throw error;
|
|
1854
|
+
}
|
|
1855
|
+
const gitSpinner = ora2("Initializing git repository...").start();
|
|
1856
|
+
try {
|
|
1857
|
+
await execa2("git", ["init"], { cwd: targetPath });
|
|
1858
|
+
await execa2("git", ["add", "."], { cwd: targetPath });
|
|
1859
|
+
await execa2("git", ["commit", "-m", "Initial commit from Shipd"], { cwd: targetPath });
|
|
1860
|
+
gitSpinner.succeed("Git repository initialized");
|
|
1861
|
+
} catch (error) {
|
|
1862
|
+
gitSpinner.fail("Failed to initialize git");
|
|
1863
|
+
throw error;
|
|
1864
|
+
}
|
|
1865
|
+
let installInterval;
|
|
1866
|
+
const installSpinner = ora2("Preparing to install dependencies...").start();
|
|
1867
|
+
try {
|
|
1868
|
+
const pkgJson = await fs2.readJson(resolve4(targetPath, "package.json"));
|
|
1869
|
+
const deps = Object.keys(pkgJson.dependencies || {}).length;
|
|
1870
|
+
const devDeps = Object.keys(pkgJson.devDependencies || {}).length;
|
|
1871
|
+
const totalDeps = deps + devDeps;
|
|
1872
|
+
installSpinner.text = `Installing ${totalDeps} dependencies with ${config.packageManager}...
|
|
1873
|
+
This may take 2-5 minutes depending on your connection`;
|
|
1874
|
+
const startTime = Date.now();
|
|
1875
|
+
installInterval = setInterval(() => {
|
|
1876
|
+
const elapsed = Math.floor((Date.now() - startTime) / 1e3);
|
|
1877
|
+
installSpinner.text = `Installing ${totalDeps} dependencies (${elapsed}s elapsed)...
|
|
1878
|
+
${config.packageManager} install is running in background`;
|
|
1879
|
+
}, 2e3);
|
|
1880
|
+
await execa2(config.packageManager, ["install"], {
|
|
1881
|
+
cwd: targetPath,
|
|
1882
|
+
stdio: "pipe"
|
|
1883
|
+
});
|
|
1884
|
+
clearInterval(installInterval);
|
|
1885
|
+
const totalTime = Math.floor((Date.now() - startTime) / 1e3);
|
|
1886
|
+
installSpinner.succeed(`Dependencies installed (${totalDeps} packages in ${totalTime}s)`);
|
|
1887
|
+
} catch (error) {
|
|
1888
|
+
if (installInterval) {
|
|
1889
|
+
clearInterval(installInterval);
|
|
1890
|
+
}
|
|
1891
|
+
installSpinner.fail("Failed to install dependencies");
|
|
1892
|
+
throw error;
|
|
1893
|
+
}
|
|
1894
|
+
logger.success(`
|
|
1895
|
+
Project "${config.projectName}" created successfully at:
|
|
1896
|
+
${targetPath}
|
|
1897
|
+
`);
|
|
697
1898
|
console.log("\nNext steps:");
|
|
698
1899
|
logger.step(`cd "${targetPath}"`);
|
|
699
1900
|
logger.step("Copy .env.example to .env.local and configure your services");
|
|
700
1901
|
logger.step(`${config.packageManager} run dev`);
|
|
701
1902
|
logger.info('Tip: If you see "Could not read package.json", you are not in the project directory yet.');
|
|
1903
|
+
if (config.features.includes("marketing-landing")) {
|
|
1904
|
+
console.log("\n\u{1F3A8} Marketing Landing Page:");
|
|
1905
|
+
logger.info("Visit / to see your landing page");
|
|
1906
|
+
}
|
|
1907
|
+
if (config.features.includes("docs")) {
|
|
1908
|
+
console.log("\n\u{1F4DA} Documentation Pages:");
|
|
1909
|
+
logger.info("Visit /docs to see your documentation");
|
|
1910
|
+
}
|
|
1911
|
+
if (config.features.includes("database")) {
|
|
1912
|
+
console.log("\n\u{1F4BE} Database:");
|
|
1913
|
+
logger.info("Set DATABASE_URL in .env.local and run npm run db:push");
|
|
1914
|
+
}
|
|
1915
|
+
if (config.features.includes("auth")) {
|
|
1916
|
+
console.log("\n\u{1F510} Authentication:");
|
|
1917
|
+
logger.info("Set BETTER_AUTH_SECRET and Google OAuth credentials in .env.local");
|
|
1918
|
+
logger.info("Visit /sign-in or /sign-up to test authentication");
|
|
1919
|
+
}
|
|
702
1920
|
if (config.features.includes("payments")) {
|
|
703
|
-
console.log("\n\u{1F4B3}
|
|
704
|
-
logger.info("
|
|
1921
|
+
console.log("\n\u{1F4B3} Payments:");
|
|
1922
|
+
logger.info("Set Polar.sh credentials in .env.local");
|
|
1923
|
+
logger.info("Visit /dashboard/payment for subscription management");
|
|
1924
|
+
}
|
|
1925
|
+
if (config.features.includes("email")) {
|
|
1926
|
+
console.log("\n\u{1F4E7} Email:");
|
|
1927
|
+
logger.info("Set RESEND_API_KEY in .env.local to enable email sending");
|
|
705
1928
|
}
|
|
706
1929
|
if (config.features.includes("ai-chat")) {
|
|
707
|
-
console.log("\n\u{1F916}
|
|
708
|
-
logger.info("
|
|
1930
|
+
console.log("\n\u{1F916} AI Chat:");
|
|
1931
|
+
logger.info("Set OPENAI_API_KEY in .env.local");
|
|
1932
|
+
logger.info("Visit /dashboard/chat to use the chat interface");
|
|
709
1933
|
}
|
|
710
1934
|
if (config.features.includes("file-upload")) {
|
|
711
|
-
console.log("\n\u{
|
|
712
|
-
logger.info("
|
|
1935
|
+
console.log("\n\u{1F4E4} File Upload:");
|
|
1936
|
+
logger.info("Set Cloudflare R2 credentials in .env.local");
|
|
1937
|
+
logger.info("Visit /dashboard/upload to use the upload interface");
|
|
713
1938
|
}
|
|
714
1939
|
if (config.features.includes("analytics")) {
|
|
715
|
-
console.log("\n\u{1F4CA}
|
|
716
|
-
logger.info("
|
|
1940
|
+
console.log("\n\u{1F4CA} Analytics:");
|
|
1941
|
+
logger.info("Set NEXT_PUBLIC_POSTHOG_KEY in .env.local");
|
|
1942
|
+
logger.info("Add PostHogProviderWrapper to app/layout.tsx");
|
|
1943
|
+
}
|
|
1944
|
+
if (config.features.length === 0) {
|
|
1945
|
+
console.log("\n\u{1F4A1} Tip:");
|
|
1946
|
+
logger.info("You can add more features later using: shipd append");
|
|
717
1947
|
}
|
|
718
1948
|
console.log("\n");
|
|
719
1949
|
}
|
|
720
1950
|
|
|
721
1951
|
// src/auth/token-storage.ts
|
|
722
|
-
import
|
|
1952
|
+
import fs4 from "fs-extra";
|
|
723
1953
|
|
|
724
1954
|
// src/config/paths.ts
|
|
725
1955
|
import os from "os";
|
|
726
1956
|
import path from "path";
|
|
727
|
-
import
|
|
1957
|
+
import fs3 from "fs-extra";
|
|
728
1958
|
function getConfigDir() {
|
|
729
1959
|
const homeDir = os.homedir();
|
|
730
1960
|
const configDir = path.join(homeDir, ".shipd");
|
|
731
|
-
|
|
1961
|
+
fs3.ensureDirSync(configDir);
|
|
732
1962
|
return configDir;
|
|
733
1963
|
}
|
|
734
1964
|
function getAuthPath() {
|
|
@@ -739,10 +1969,10 @@ function getAuthPath() {
|
|
|
739
1969
|
function loadAuth() {
|
|
740
1970
|
const authPath = getAuthPath();
|
|
741
1971
|
try {
|
|
742
|
-
if (!
|
|
1972
|
+
if (!fs4.existsSync(authPath)) {
|
|
743
1973
|
return null;
|
|
744
1974
|
}
|
|
745
|
-
const data =
|
|
1975
|
+
const data = fs4.readJsonSync(authPath);
|
|
746
1976
|
return data;
|
|
747
1977
|
} catch (error) {
|
|
748
1978
|
console.error("Failed to load auth data:", error);
|
|
@@ -752,7 +1982,7 @@ function loadAuth() {
|
|
|
752
1982
|
function saveAuth(authData) {
|
|
753
1983
|
const authPath = getAuthPath();
|
|
754
1984
|
try {
|
|
755
|
-
|
|
1985
|
+
fs4.writeJsonSync(authPath, authData, { spaces: 2 });
|
|
756
1986
|
} catch (error) {
|
|
757
1987
|
console.error("Failed to save auth data:", error);
|
|
758
1988
|
throw new Error("Failed to save authentication data");
|
|
@@ -761,8 +1991,8 @@ function saveAuth(authData) {
|
|
|
761
1991
|
function clearAuth() {
|
|
762
1992
|
const authPath = getAuthPath();
|
|
763
1993
|
try {
|
|
764
|
-
if (
|
|
765
|
-
|
|
1994
|
+
if (fs4.existsSync(authPath)) {
|
|
1995
|
+
fs4.removeSync(authPath);
|
|
766
1996
|
}
|
|
767
1997
|
} catch (error) {
|
|
768
1998
|
console.error("Failed to clear auth data:", error);
|
|
@@ -783,128 +2013,18 @@ function isAuthenticated() {
|
|
|
783
2013
|
return true;
|
|
784
2014
|
}
|
|
785
2015
|
|
|
786
|
-
// src/auth/token-validator.ts
|
|
787
|
-
var API_BASE_URL = process.env.SAAS_SCAFFOLD_API_URL || "https://www.shipd.dev";
|
|
788
|
-
async function validateToken(token) {
|
|
789
|
-
const authToken = token || loadAuth()?.token;
|
|
790
|
-
if (!authToken) {
|
|
791
|
-
return { valid: false, error: "No authentication token found" };
|
|
792
|
-
}
|
|
793
|
-
try {
|
|
794
|
-
const response = await fetch(`${API_BASE_URL}/api/cli/validate`, {
|
|
795
|
-
method: "POST",
|
|
796
|
-
headers: {
|
|
797
|
-
"Content-Type": "application/json"
|
|
798
|
-
},
|
|
799
|
-
body: JSON.stringify({ token: authToken })
|
|
800
|
-
});
|
|
801
|
-
if (!response.ok) {
|
|
802
|
-
clearAuth();
|
|
803
|
-
return { valid: false, error: "Invalid or expired token" };
|
|
804
|
-
}
|
|
805
|
-
const data = await response.json();
|
|
806
|
-
if (data.subscriptionStatus && data.subscriptionStatus !== "active") {
|
|
807
|
-
clearAuth();
|
|
808
|
-
return {
|
|
809
|
-
valid: false,
|
|
810
|
-
error: `Subscription ${data.subscriptionStatus}. Please renew at ${API_BASE_URL}/pricing`
|
|
811
|
-
};
|
|
812
|
-
}
|
|
813
|
-
return data;
|
|
814
|
-
} catch (error) {
|
|
815
|
-
console.error("Token validation failed:", error);
|
|
816
|
-
return {
|
|
817
|
-
valid: false,
|
|
818
|
-
error: "Unable to validate token. Please check your internet connection."
|
|
819
|
-
};
|
|
820
|
-
}
|
|
821
|
-
}
|
|
822
|
-
function isDevelopmentMode() {
|
|
823
|
-
return process.env.SAAS_SCAFFOLD_DEV === "true";
|
|
824
|
-
}
|
|
825
|
-
async function ensureAuthenticated(mockMode = false) {
|
|
826
|
-
if (mockMode || isDevelopmentMode()) {
|
|
827
|
-
console.log("\x1B[33m\u26A0\x1B[0m Running in mock mode (authentication bypassed)");
|
|
828
|
-
return;
|
|
829
|
-
}
|
|
830
|
-
const auth = loadAuth();
|
|
831
|
-
if (!auth) {
|
|
832
|
-
console.error("\n\u274C Not authenticated.");
|
|
833
|
-
console.error("\nPlease run: \x1B[36mshipd login\x1B[0m\n");
|
|
834
|
-
console.error("\x1B[2mTip: Use --mock flag to skip authentication during development\x1B[0m\n");
|
|
835
|
-
process.exit(1);
|
|
836
|
-
}
|
|
837
|
-
const expiresAt = new Date(auth.expiresAt);
|
|
838
|
-
const now = /* @__PURE__ */ new Date();
|
|
839
|
-
if (now >= expiresAt) {
|
|
840
|
-
clearAuth();
|
|
841
|
-
console.error("\n\u274C Session expired.");
|
|
842
|
-
console.error("\nPlease run: \x1B[36mshipd login\x1B[0m\n");
|
|
843
|
-
process.exit(1);
|
|
844
|
-
}
|
|
845
|
-
const validation = await validateToken(auth.token);
|
|
846
|
-
if (!validation.valid) {
|
|
847
|
-
console.error(`
|
|
848
|
-
\u274C Authentication failed: ${validation.error}`);
|
|
849
|
-
console.error("\nPlease run: \x1B[36mshipd login\x1B[0m\n");
|
|
850
|
-
process.exit(1);
|
|
851
|
-
}
|
|
852
|
-
console.log("\x1B[32m\u2713\x1B[0m Authenticated");
|
|
853
|
-
}
|
|
854
|
-
|
|
855
|
-
// src/commands/init.ts
|
|
856
|
-
async function initCommand(projectName, options) {
|
|
857
|
-
try {
|
|
858
|
-
await ensureAuthenticated(options?.mock || false);
|
|
859
|
-
console.log(SHIPD_ASCII);
|
|
860
|
-
logger.intro("Welcome aboard! Let's get shipping");
|
|
861
|
-
const config = await runPrompts(projectName);
|
|
862
|
-
await generateProject(config);
|
|
863
|
-
} catch (error) {
|
|
864
|
-
logger.error("Project generation failed");
|
|
865
|
-
if (error instanceof Error) {
|
|
866
|
-
console.error("\n" + error.message);
|
|
867
|
-
}
|
|
868
|
-
process.exit(1);
|
|
869
|
-
}
|
|
870
|
-
}
|
|
871
|
-
|
|
872
|
-
// src/commands/list.ts
|
|
873
|
-
import chalk2 from "chalk";
|
|
874
|
-
async function listCommand() {
|
|
875
|
-
logger.intro("Available Features");
|
|
876
|
-
console.log("\n" + chalk2.bold("Core (Always Included):"));
|
|
877
|
-
console.log(" \u2022 Next.js 15 with App Router");
|
|
878
|
-
console.log(" \u2022 Better Auth + Google OAuth");
|
|
879
|
-
console.log(" \u2022 Neon PostgreSQL + Drizzle ORM");
|
|
880
|
-
console.log(" \u2022 shadcn/ui components (24 components)");
|
|
881
|
-
console.log(" \u2022 Dark/Light mode");
|
|
882
|
-
console.log(" \u2022 User dashboard");
|
|
883
|
-
console.log("\n" + chalk2.bold("Optional Features:"));
|
|
884
|
-
console.log(" \u2022 Polar.sh payments & subscriptions");
|
|
885
|
-
console.log(" \u2022 OpenAI chat integration");
|
|
886
|
-
console.log(" \u2022 Cloudflare R2 file uploads");
|
|
887
|
-
console.log(" \u2022 PostHog analytics");
|
|
888
|
-
console.log("\n" + chalk2.bold("Usage:"));
|
|
889
|
-
console.log(" npx shipd init <project-name>");
|
|
890
|
-
console.log("");
|
|
891
|
-
}
|
|
892
|
-
|
|
893
|
-
// src/commands/login.ts
|
|
894
|
-
import { Command } from "commander";
|
|
895
|
-
|
|
896
2016
|
// src/auth/auth-manager.ts
|
|
897
2017
|
import { nanoid } from "nanoid";
|
|
898
2018
|
import open from "open";
|
|
899
|
-
import
|
|
900
|
-
var
|
|
2019
|
+
import ora3 from "ora";
|
|
2020
|
+
var API_BASE_URL = process.env.SAAS_SCAFFOLD_API_URL || "https://www.shipd.dev";
|
|
901
2021
|
var POLL_INTERVAL = 2e3;
|
|
902
2022
|
var MAX_POLL_TIME = 3e5;
|
|
903
2023
|
function generateDeviceCode() {
|
|
904
2024
|
return nanoid(10).toUpperCase().match(/.{1,4}/g)?.join("-") || nanoid(10);
|
|
905
2025
|
}
|
|
906
2026
|
async function openAuthPage(deviceCode) {
|
|
907
|
-
const authUrl = `${
|
|
2027
|
+
const authUrl = `${API_BASE_URL}/cli/auth?code=${deviceCode}`;
|
|
908
2028
|
console.log("\n\x1B[36m\u2192\x1B[0m Opening browser for authentication...");
|
|
909
2029
|
console.log(`\x1B[2m ${authUrl}\x1B[0m
|
|
910
2030
|
`);
|
|
@@ -921,7 +2041,7 @@ async function pollForAuth(deviceCode) {
|
|
|
921
2041
|
const startTime = Date.now();
|
|
922
2042
|
while (Date.now() - startTime < MAX_POLL_TIME) {
|
|
923
2043
|
try {
|
|
924
|
-
const response = await fetch(`${
|
|
2044
|
+
const response = await fetch(`${API_BASE_URL}/api/cli/poll?code=${deviceCode}`, {
|
|
925
2045
|
method: "GET"
|
|
926
2046
|
});
|
|
927
2047
|
if (!response.ok) {
|
|
@@ -965,7 +2085,7 @@ async function login() {
|
|
|
965
2085
|
const deviceCode = generateDeviceCode();
|
|
966
2086
|
console.log(`\x1B[2mDevice code: ${deviceCode}\x1B[0m`);
|
|
967
2087
|
await openAuthPage(deviceCode);
|
|
968
|
-
const spinner =
|
|
2088
|
+
const spinner = ora3({
|
|
969
2089
|
text: "Waiting for authentication...",
|
|
970
2090
|
color: "cyan"
|
|
971
2091
|
}).start();
|
|
@@ -987,7 +2107,118 @@ async function login() {
|
|
|
987
2107
|
}
|
|
988
2108
|
}
|
|
989
2109
|
|
|
2110
|
+
// src/auth/token-validator.ts
|
|
2111
|
+
var API_BASE_URL2 = process.env.SAAS_SCAFFOLD_API_URL || "https://www.shipd.dev";
|
|
2112
|
+
async function validateToken(token) {
|
|
2113
|
+
const authToken = token || loadAuth()?.token;
|
|
2114
|
+
if (!authToken) {
|
|
2115
|
+
return { valid: false, error: "No authentication token found" };
|
|
2116
|
+
}
|
|
2117
|
+
try {
|
|
2118
|
+
const response = await fetch(`${API_BASE_URL2}/api/cli/validate`, {
|
|
2119
|
+
method: "POST",
|
|
2120
|
+
headers: {
|
|
2121
|
+
"Content-Type": "application/json"
|
|
2122
|
+
},
|
|
2123
|
+
body: JSON.stringify({ token: authToken })
|
|
2124
|
+
});
|
|
2125
|
+
if (!response.ok) {
|
|
2126
|
+
clearAuth();
|
|
2127
|
+
return { valid: false, error: "Invalid or expired token" };
|
|
2128
|
+
}
|
|
2129
|
+
const data = await response.json();
|
|
2130
|
+
if (data.subscriptionStatus && data.subscriptionStatus !== "active") {
|
|
2131
|
+
clearAuth();
|
|
2132
|
+
return {
|
|
2133
|
+
valid: false,
|
|
2134
|
+
error: `Subscription ${data.subscriptionStatus}. Please renew at ${API_BASE_URL2}/pricing`
|
|
2135
|
+
};
|
|
2136
|
+
}
|
|
2137
|
+
return data;
|
|
2138
|
+
} catch (error) {
|
|
2139
|
+
console.error("Token validation failed:", error);
|
|
2140
|
+
return {
|
|
2141
|
+
valid: false,
|
|
2142
|
+
error: "Unable to validate token. Please check your internet connection."
|
|
2143
|
+
};
|
|
2144
|
+
}
|
|
2145
|
+
}
|
|
2146
|
+
function isDevelopmentMode() {
|
|
2147
|
+
return process.env.SAAS_SCAFFOLD_DEV === "true";
|
|
2148
|
+
}
|
|
2149
|
+
async function ensureAuthenticated(mockMode = false) {
|
|
2150
|
+
if (mockMode || isDevelopmentMode()) {
|
|
2151
|
+
console.log("\x1B[33m\u26A0\x1B[0m Running in mock mode (authentication bypassed)");
|
|
2152
|
+
return;
|
|
2153
|
+
}
|
|
2154
|
+
const auth = loadAuth();
|
|
2155
|
+
if (!auth) {
|
|
2156
|
+
console.log("\n\u{1F510} Authentication required");
|
|
2157
|
+
console.log("Starting login flow...\n");
|
|
2158
|
+
await login();
|
|
2159
|
+
return;
|
|
2160
|
+
}
|
|
2161
|
+
const expiresAt = new Date(auth.expiresAt);
|
|
2162
|
+
const now = /* @__PURE__ */ new Date();
|
|
2163
|
+
if (now >= expiresAt) {
|
|
2164
|
+
clearAuth();
|
|
2165
|
+
console.log("\n\u{1F510} Session expired");
|
|
2166
|
+
console.log("Starting login flow...\n");
|
|
2167
|
+
await login();
|
|
2168
|
+
return;
|
|
2169
|
+
}
|
|
2170
|
+
const validation = await validateToken(auth.token);
|
|
2171
|
+
if (!validation.valid) {
|
|
2172
|
+
clearAuth();
|
|
2173
|
+
console.log(`
|
|
2174
|
+
\u{1F510} ${validation.error || "Authentication failed"}`);
|
|
2175
|
+
console.log("Starting login flow...\n");
|
|
2176
|
+
await login();
|
|
2177
|
+
return;
|
|
2178
|
+
}
|
|
2179
|
+
console.log("\x1B[32m\u2713\x1B[0m Authenticated");
|
|
2180
|
+
}
|
|
2181
|
+
|
|
2182
|
+
// src/commands/init.ts
|
|
2183
|
+
async function initCommand(projectName, options) {
|
|
2184
|
+
try {
|
|
2185
|
+
await ensureAuthenticated(options?.mock || false);
|
|
2186
|
+
console.log(SHIPD_ASCII);
|
|
2187
|
+
logger.intro("Welcome aboard! Let's get shipping");
|
|
2188
|
+
const config = await runPrompts(projectName, options);
|
|
2189
|
+
await generateProject(config);
|
|
2190
|
+
} catch (error) {
|
|
2191
|
+
logger.error("Project generation failed");
|
|
2192
|
+
if (error instanceof Error) {
|
|
2193
|
+
console.error("\n" + error.message);
|
|
2194
|
+
}
|
|
2195
|
+
process.exit(1);
|
|
2196
|
+
}
|
|
2197
|
+
}
|
|
2198
|
+
|
|
2199
|
+
// src/commands/list.ts
|
|
2200
|
+
import chalk2 from "chalk";
|
|
2201
|
+
async function listCommand() {
|
|
2202
|
+
logger.intro("Available Features");
|
|
2203
|
+
console.log("\n" + chalk2.bold("Core (Always Included):"));
|
|
2204
|
+
console.log(" \u2022 Next.js 15 with App Router");
|
|
2205
|
+
console.log(" \u2022 Better Auth + Google OAuth");
|
|
2206
|
+
console.log(" \u2022 Neon PostgreSQL + Drizzle ORM");
|
|
2207
|
+
console.log(" \u2022 shadcn/ui components (24 components)");
|
|
2208
|
+
console.log(" \u2022 Dark/Light mode");
|
|
2209
|
+
console.log(" \u2022 User dashboard");
|
|
2210
|
+
console.log("\n" + chalk2.bold("Optional Features:"));
|
|
2211
|
+
console.log(" \u2022 Polar.sh payments & subscriptions");
|
|
2212
|
+
console.log(" \u2022 OpenAI chat integration");
|
|
2213
|
+
console.log(" \u2022 Cloudflare R2 file uploads");
|
|
2214
|
+
console.log(" \u2022 PostHog analytics");
|
|
2215
|
+
console.log("\n" + chalk2.bold("Usage:"));
|
|
2216
|
+
console.log(" npx shipd init <project-name>");
|
|
2217
|
+
console.log("");
|
|
2218
|
+
}
|
|
2219
|
+
|
|
990
2220
|
// src/commands/login.ts
|
|
2221
|
+
import { Command } from "commander";
|
|
991
2222
|
function createLoginCommand() {
|
|
992
2223
|
const command = new Command("login");
|
|
993
2224
|
command.description("Authenticate with shipd").action(async () => {
|
|
@@ -1023,341 +2254,12 @@ function createLogoutCommand() {
|
|
|
1023
2254
|
return command;
|
|
1024
2255
|
}
|
|
1025
2256
|
|
|
1026
|
-
// src/commands/append.ts
|
|
1027
|
-
import inquirer2 from "inquirer";
|
|
1028
|
-
import { existsSync as existsSync5 } from "fs";
|
|
1029
|
-
import { join as join7, resolve as resolve4, dirname as dirname2 } from "path";
|
|
1030
|
-
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
1031
|
-
import ora3 from "ora";
|
|
1032
|
-
import fs4 from "fs-extra";
|
|
1033
|
-
var __filename2 = fileURLToPath2(import.meta.url);
|
|
1034
|
-
var __dirname2 = dirname2(__filename2);
|
|
1035
|
-
async function detectProject(cwd) {
|
|
1036
|
-
const pkgPath = join7(cwd, "package.json");
|
|
1037
|
-
if (!existsSync5(pkgPath)) {
|
|
1038
|
-
throw new Error("No package.json found. Make sure you're in a Next.js project directory.");
|
|
1039
|
-
}
|
|
1040
|
-
const pkg = await fs4.readJson(pkgPath);
|
|
1041
|
-
const isNextJs = !!(pkg.dependencies?.next || pkg.devDependencies?.next);
|
|
1042
|
-
if (!isNextJs) {
|
|
1043
|
-
throw new Error("This doesn't appear to be a Next.js project. shipd append requires a Next.js project.");
|
|
1044
|
-
}
|
|
1045
|
-
const hasAppRouter = existsSync5(join7(cwd, "app"));
|
|
1046
|
-
if (!hasAppRouter) {
|
|
1047
|
-
throw new Error("App Router not detected. shipd append requires Next.js App Router (app directory).");
|
|
1048
|
-
}
|
|
1049
|
-
let packageManager = "npm";
|
|
1050
|
-
if (existsSync5(join7(cwd, "pnpm-lock.yaml"))) {
|
|
1051
|
-
packageManager = "pnpm";
|
|
1052
|
-
} else if (existsSync5(join7(cwd, "yarn.lock"))) {
|
|
1053
|
-
packageManager = "yarn";
|
|
1054
|
-
}
|
|
1055
|
-
const installedFeatures = [];
|
|
1056
|
-
const shipdManifest = join7(cwd, ".shipd", "manifest.json");
|
|
1057
|
-
if (existsSync5(shipdManifest)) {
|
|
1058
|
-
const manifest = await fs4.readJson(shipdManifest);
|
|
1059
|
-
installedFeatures.push(...Object.keys(manifest.features || {}));
|
|
1060
|
-
}
|
|
1061
|
-
return {
|
|
1062
|
-
isNextJs,
|
|
1063
|
-
hasAppRouter,
|
|
1064
|
-
packageManager,
|
|
1065
|
-
installedFeatures
|
|
1066
|
-
};
|
|
1067
|
-
}
|
|
1068
|
-
async function appendDocsFeature(targetPath, dryRun = false) {
|
|
1069
|
-
let docsSourcePath = resolve4(__dirname2, "../docs-template");
|
|
1070
|
-
if (!existsSync5(docsSourcePath)) {
|
|
1071
|
-
docsSourcePath = resolve4(__dirname2, "../../docs-template");
|
|
1072
|
-
}
|
|
1073
|
-
if (!existsSync5(docsSourcePath)) {
|
|
1074
|
-
docsSourcePath = resolve4(__dirname2, "../../../docs-template");
|
|
1075
|
-
}
|
|
1076
|
-
if (!existsSync5(docsSourcePath)) {
|
|
1077
|
-
throw new Error(`Docs template not found. Tried paths:
|
|
1078
|
-
- ${resolve4(__dirname2, "../docs-template")}
|
|
1079
|
-
- ${resolve4(__dirname2, "../../docs-template")}
|
|
1080
|
-
- ${resolve4(__dirname2, "../../../docs-template")}`);
|
|
1081
|
-
}
|
|
1082
|
-
const spinner = ora3("Copying docs feature (pages + components)...").start();
|
|
1083
|
-
try {
|
|
1084
|
-
if (dryRun) {
|
|
1085
|
-
spinner.info("DRY RUN: Would copy docs pages and components from template");
|
|
1086
|
-
return;
|
|
1087
|
-
}
|
|
1088
|
-
const docsTargetPath = join7(targetPath, "app", "docs");
|
|
1089
|
-
await fs4.ensureDir(docsTargetPath);
|
|
1090
|
-
await fs4.copy(docsSourcePath, docsTargetPath, {
|
|
1091
|
-
overwrite: false,
|
|
1092
|
-
errorOnExist: false,
|
|
1093
|
-
filter: (src) => {
|
|
1094
|
-
if (src.includes("/components/")) {
|
|
1095
|
-
return false;
|
|
1096
|
-
}
|
|
1097
|
-
const relativePath = src.replace(docsSourcePath, "");
|
|
1098
|
-
const targetFile = join7(docsTargetPath, relativePath);
|
|
1099
|
-
if (existsSync5(targetFile)) {
|
|
1100
|
-
logger.warn(`Skipping existing file: ${relativePath}`);
|
|
1101
|
-
return false;
|
|
1102
|
-
}
|
|
1103
|
-
return true;
|
|
1104
|
-
}
|
|
1105
|
-
});
|
|
1106
|
-
const componentsSourcePath = join7(docsSourcePath, "components", "docs");
|
|
1107
|
-
const componentsTargetPath = join7(targetPath, "components", "docs");
|
|
1108
|
-
if (existsSync5(componentsSourcePath)) {
|
|
1109
|
-
await fs4.ensureDir(componentsTargetPath);
|
|
1110
|
-
await fs4.copy(componentsSourcePath, componentsTargetPath, {
|
|
1111
|
-
overwrite: false,
|
|
1112
|
-
errorOnExist: false,
|
|
1113
|
-
filter: (src) => {
|
|
1114
|
-
const relativePath = src.replace(componentsSourcePath, "");
|
|
1115
|
-
const targetFile = join7(componentsTargetPath, relativePath);
|
|
1116
|
-
if (existsSync5(targetFile)) {
|
|
1117
|
-
logger.warn(`Skipping existing component: ${relativePath}`);
|
|
1118
|
-
return false;
|
|
1119
|
-
}
|
|
1120
|
-
return true;
|
|
1121
|
-
}
|
|
1122
|
-
});
|
|
1123
|
-
}
|
|
1124
|
-
const uiComponentsSourcePath = join7(docsSourcePath, "components", "ui");
|
|
1125
|
-
const uiComponentsTargetPath = join7(targetPath, "components", "ui");
|
|
1126
|
-
if (existsSync5(uiComponentsSourcePath)) {
|
|
1127
|
-
await fs4.ensureDir(uiComponentsTargetPath);
|
|
1128
|
-
await fs4.copy(uiComponentsSourcePath, uiComponentsTargetPath, {
|
|
1129
|
-
overwrite: false,
|
|
1130
|
-
errorOnExist: false,
|
|
1131
|
-
filter: (src) => {
|
|
1132
|
-
const relativePath = src.replace(uiComponentsSourcePath, "");
|
|
1133
|
-
const targetFile = join7(uiComponentsTargetPath, relativePath);
|
|
1134
|
-
if (existsSync5(targetFile)) {
|
|
1135
|
-
return false;
|
|
1136
|
-
}
|
|
1137
|
-
return true;
|
|
1138
|
-
}
|
|
1139
|
-
});
|
|
1140
|
-
}
|
|
1141
|
-
const utilsSourcePath = join7(docsSourcePath, "lib", "utils.ts");
|
|
1142
|
-
const utilsTargetPath = join7(targetPath, "lib", "utils.ts");
|
|
1143
|
-
if (existsSync5(utilsSourcePath) && !existsSync5(utilsTargetPath)) {
|
|
1144
|
-
await fs4.ensureDir(join7(targetPath, "lib"));
|
|
1145
|
-
await fs4.copy(utilsSourcePath, utilsTargetPath);
|
|
1146
|
-
} else if (existsSync5(utilsTargetPath)) {
|
|
1147
|
-
}
|
|
1148
|
-
spinner.succeed("Docs feature copied (pages + components)");
|
|
1149
|
-
const shipdDir = join7(targetPath, ".shipd");
|
|
1150
|
-
await fs4.ensureDir(shipdDir);
|
|
1151
|
-
const manifestPath = join7(shipdDir, "manifest.json");
|
|
1152
|
-
let manifest = { version: "1.0.0", features: {} };
|
|
1153
|
-
if (existsSync5(manifestPath)) {
|
|
1154
|
-
manifest = await fs4.readJson(manifestPath);
|
|
1155
|
-
}
|
|
1156
|
-
manifest.features.docs = {
|
|
1157
|
-
version: "1.0.0",
|
|
1158
|
-
installedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1159
|
-
files: [
|
|
1160
|
-
"app/docs/**/*",
|
|
1161
|
-
"components/docs/**/*",
|
|
1162
|
-
"components/ui/**/*",
|
|
1163
|
-
"lib/utils.ts"
|
|
1164
|
-
]
|
|
1165
|
-
};
|
|
1166
|
-
await fs4.writeJson(manifestPath, manifest, { spaces: 2 });
|
|
1167
|
-
const pkgPath = join7(targetPath, "package.json");
|
|
1168
|
-
if (existsSync5(pkgPath)) {
|
|
1169
|
-
const pkg = await fs4.readJson(pkgPath);
|
|
1170
|
-
let updated = false;
|
|
1171
|
-
const requiredDeps = {
|
|
1172
|
-
"@radix-ui/react-slot": "^1.1.0",
|
|
1173
|
-
"@radix-ui/react-dialog": "^1.1.0",
|
|
1174
|
-
"class-variance-authority": "^0.7.0",
|
|
1175
|
-
"clsx": "^2.1.1",
|
|
1176
|
-
"tailwind-merge": "^2.5.4",
|
|
1177
|
-
"lucide-react": "^0.469.0"
|
|
1178
|
-
};
|
|
1179
|
-
if (!pkg.dependencies) {
|
|
1180
|
-
pkg.dependencies = {};
|
|
1181
|
-
}
|
|
1182
|
-
for (const [dep, version] of Object.entries(requiredDeps)) {
|
|
1183
|
-
if (!pkg.dependencies[dep] && !pkg.devDependencies?.[dep]) {
|
|
1184
|
-
pkg.dependencies[dep] = version;
|
|
1185
|
-
updated = true;
|
|
1186
|
-
}
|
|
1187
|
-
}
|
|
1188
|
-
if (updated) {
|
|
1189
|
-
await fs4.writeJson(pkgPath, pkg, { spaces: 2 });
|
|
1190
|
-
logger.info("Added missing dependencies to package.json");
|
|
1191
|
-
logger.info("Run npm install (or your package manager) to install them");
|
|
1192
|
-
}
|
|
1193
|
-
}
|
|
1194
|
-
const featureDocs = join7(shipdDir, "features");
|
|
1195
|
-
await fs4.ensureDir(featureDocs);
|
|
1196
|
-
const docsReadme = `# Docs Feature - Integration Guide
|
|
1197
|
-
|
|
1198
|
-
**Installed:** ${(/* @__PURE__ */ new Date()).toISOString()}
|
|
1199
|
-
**Version:** 1.0.0
|
|
1200
|
-
|
|
1201
|
-
## Overview
|
|
1202
|
-
|
|
1203
|
-
This feature adds a complete, standalone documentation section to your Next.js application. All required components are included - no external dependencies needed.
|
|
1204
|
-
|
|
1205
|
-
## Files Added
|
|
1206
|
-
|
|
1207
|
-
### Pages
|
|
1208
|
-
- \`app/docs/page.tsx\` - Docs landing page
|
|
1209
|
-
- \`app/docs/layout.tsx\` - Docs layout with header and sidebar
|
|
1210
|
-
- \`app/docs/[slug]/page.tsx\` - Dynamic doc pages
|
|
1211
|
-
- \`app/docs/[slug]/[subslug]/page.tsx\` - Nested doc pages
|
|
1212
|
-
- \`app/docs/api/page.tsx\` - API documentation page
|
|
1213
|
-
- \`app/docs/documentation/page.tsx\` - Documentation page
|
|
1214
|
-
|
|
1215
|
-
### Components (Standalone Package)
|
|
1216
|
-
All required components are included:
|
|
1217
|
-
- \`components/docs/docs-header.tsx\` - Docs header with navigation
|
|
1218
|
-
- \`components/docs/docs-sidebar.tsx\` - Sidebar navigation
|
|
1219
|
-
- \`components/docs/docs-toc.tsx\` - Table of contents component
|
|
1220
|
-
- \`components/docs/docs-category-page.tsx\` - Category page layout
|
|
1221
|
-
- \`components/docs/docs-code-card.tsx\` - Code snippet display
|
|
1222
|
-
- \`components/docs/docs-nav.ts\` - Navigation data structure
|
|
1223
|
-
|
|
1224
|
-
## Dependencies
|
|
1225
|
-
|
|
1226
|
-
This feature is **completely standalone** and includes:
|
|
1227
|
-
- \u2705 All required UI components (\`components/ui/*\`)
|
|
1228
|
-
- \u2705 Utility functions (\`lib/utils.ts\`)
|
|
1229
|
-
- \u2705 All docs-specific components (\`components/docs/*\`)
|
|
1230
|
-
|
|
1231
|
-
**Smart Deduplication:** If UI components already exist in your project, they won't be overwritten.
|
|
1232
|
-
|
|
1233
|
-
**Package Dependencies:** The following will be added to your \`package.json\` if missing:
|
|
1234
|
-
- \`@radix-ui/react-slot\` - For Button component
|
|
1235
|
-
- \`@radix-ui/react-dialog\` - For Sheet component
|
|
1236
|
-
- \`class-variance-authority\` - For component variants
|
|
1237
|
-
- \`clsx\` & \`tailwind-merge\` - For className utilities
|
|
1238
|
-
- \`lucide-react\` - For icons
|
|
1239
|
-
|
|
1240
|
-
**Note:** Run \`npm install\` (or your package manager) after appending to install any new dependencies.
|
|
1241
|
-
|
|
1242
|
-
## Setup Instructions
|
|
1243
|
-
|
|
1244
|
-
1. **Access the docs**
|
|
1245
|
-
- Visit \`/docs\` in your application
|
|
1246
|
-
- The docs section is fully functional with navigation and layout
|
|
1247
|
-
|
|
1248
|
-
2. **Customize content**
|
|
1249
|
-
- Edit pages in \`app/docs/\` to customize content
|
|
1250
|
-
- Update navigation in \`components/docs/docs-nav.ts\`
|
|
1251
|
-
- Modify components in \`components/docs/\` to change styling/behavior
|
|
1252
|
-
|
|
1253
|
-
3. **Add your own documentation**
|
|
1254
|
-
- Create new pages in \`app/docs/\`
|
|
1255
|
-
- Follow the existing page structure
|
|
1256
|
-
- Add entries to \`docs-nav.ts\` to include in sidebar
|
|
1257
|
-
|
|
1258
|
-
## Next Steps
|
|
1259
|
-
|
|
1260
|
-
- Customize the docs landing page in \`app/docs/page.tsx\`
|
|
1261
|
-
- Update navigation structure in \`components/docs/docs-nav.ts\`
|
|
1262
|
-
- Add your own documentation pages
|
|
1263
|
-
- Customize styling in components if needed
|
|
1264
|
-
|
|
1265
|
-
## Feature Status
|
|
1266
|
-
|
|
1267
|
-
\u2705 **Standalone Package** - All components included
|
|
1268
|
-
\u2705 **No Missing Dependencies** - Everything needed is bundled
|
|
1269
|
-
\u2705 **Ready to Use** - Works immediately after append
|
|
1270
|
-
`;
|
|
1271
|
-
await fs4.writeFile(join7(featureDocs, "docs.md"), docsReadme);
|
|
1272
|
-
} catch (error) {
|
|
1273
|
-
spinner.fail("Failed to copy docs pages");
|
|
1274
|
-
throw error;
|
|
1275
|
-
}
|
|
1276
|
-
}
|
|
1277
|
-
async function appendCommand(options) {
|
|
1278
|
-
try {
|
|
1279
|
-
const cwd = process.cwd();
|
|
1280
|
-
logger.intro("Adding Shipd features to your project");
|
|
1281
|
-
const detectSpinner = ora3("Detecting project...").start();
|
|
1282
|
-
const projectInfo = await detectProject(cwd);
|
|
1283
|
-
detectSpinner.succeed("Project validated");
|
|
1284
|
-
logger.info(`\u2713 Next.js App Router project detected`);
|
|
1285
|
-
logger.info(`\u2713 Package manager: ${projectInfo.packageManager}`);
|
|
1286
|
-
if (projectInfo.installedFeatures.length > 0) {
|
|
1287
|
-
logger.info(`\u2713 Installed features: ${projectInfo.installedFeatures.join(", ")}`);
|
|
1288
|
-
}
|
|
1289
|
-
const availableFeatures = ["docs"];
|
|
1290
|
-
const alreadyInstalled = projectInfo.installedFeatures.filter((f) => availableFeatures.includes(f));
|
|
1291
|
-
if (alreadyInstalled.includes("docs")) {
|
|
1292
|
-
logger.warn("Docs feature is already installed!");
|
|
1293
|
-
return;
|
|
1294
|
-
}
|
|
1295
|
-
let selectedFeatures;
|
|
1296
|
-
if (options.features) {
|
|
1297
|
-
selectedFeatures = options.features.split(",").map((f) => f.trim());
|
|
1298
|
-
} else {
|
|
1299
|
-
const answers = await inquirer2.prompt([
|
|
1300
|
-
{
|
|
1301
|
-
type: "checkbox",
|
|
1302
|
-
name: "features",
|
|
1303
|
-
message: "Select features to add:",
|
|
1304
|
-
choices: [
|
|
1305
|
-
{ name: "Documentation Pages", value: "docs", checked: true }
|
|
1306
|
-
]
|
|
1307
|
-
}
|
|
1308
|
-
]);
|
|
1309
|
-
selectedFeatures = answers.features;
|
|
1310
|
-
}
|
|
1311
|
-
if (selectedFeatures.length === 0) {
|
|
1312
|
-
logger.warn("No features selected. Exiting.");
|
|
1313
|
-
return;
|
|
1314
|
-
}
|
|
1315
|
-
console.log("\n\u{1F4CB} Changes Preview:");
|
|
1316
|
-
console.log(" Features to add:", selectedFeatures.join(", "));
|
|
1317
|
-
console.log(" Mode:", options.dryRun ? "DRY RUN" : "LIVE");
|
|
1318
|
-
if (!options.dryRun) {
|
|
1319
|
-
const confirm = await inquirer2.prompt([
|
|
1320
|
-
{
|
|
1321
|
-
type: "confirm",
|
|
1322
|
-
name: "proceed",
|
|
1323
|
-
message: "Proceed with installation?",
|
|
1324
|
-
default: true
|
|
1325
|
-
}
|
|
1326
|
-
]);
|
|
1327
|
-
if (!confirm.proceed) {
|
|
1328
|
-
logger.warn("Installation cancelled");
|
|
1329
|
-
return;
|
|
1330
|
-
}
|
|
1331
|
-
}
|
|
1332
|
-
console.log();
|
|
1333
|
-
for (const feature of selectedFeatures) {
|
|
1334
|
-
if (feature === "docs") {
|
|
1335
|
-
await appendDocsFeature(cwd, options.dryRun);
|
|
1336
|
-
}
|
|
1337
|
-
}
|
|
1338
|
-
console.log();
|
|
1339
|
-
logger.success("\u2728 Features added successfully!");
|
|
1340
|
-
console.log();
|
|
1341
|
-
console.log("\u{1F4D6} Next steps:");
|
|
1342
|
-
console.log(" 1. Visit /docs in your application");
|
|
1343
|
-
console.log(" 2. Customize the docs in app/docs/");
|
|
1344
|
-
console.log(" 3. Check .shipd/features/docs.md for details");
|
|
1345
|
-
console.log();
|
|
1346
|
-
} catch (error) {
|
|
1347
|
-
logger.error("Failed to append features");
|
|
1348
|
-
if (error instanceof Error) {
|
|
1349
|
-
console.error("\n" + error.message);
|
|
1350
|
-
}
|
|
1351
|
-
process.exit(1);
|
|
1352
|
-
}
|
|
1353
|
-
}
|
|
1354
|
-
|
|
1355
2257
|
// src/index.ts
|
|
1356
2258
|
var program = new Command3();
|
|
1357
2259
|
program.name("shipd").description("Generate production-ready SaaS applications").version("0.1.0");
|
|
1358
2260
|
program.addCommand(createLoginCommand());
|
|
1359
2261
|
program.addCommand(createLogoutCommand());
|
|
1360
|
-
program.command("init [project-name]").description("Initialize a new SaaS project").option("--mock", "Skip authentication (for development/testing)").action((projectName, options) => initCommand(projectName, options));
|
|
2262
|
+
program.command("init [project-name]").description("Initialize a new SaaS project").option("--mock", "Skip authentication (for development/testing)").option("--features <features>", "Comma-separated list of features to include (e.g., docs,auth,payments)").option("--description <description>", "Project description").option("--package-manager <manager>", "Package manager (npm, pnpm, yarn)").action((projectName, options) => initCommand(projectName, options));
|
|
1361
2263
|
program.command("list").description("List available features").action(listCommand);
|
|
1362
2264
|
program.command("append").description("Add Shipd features to an existing Next.js project").option("--features <features>", "Comma-separated list of features to add (e.g., docs)").option("--dry-run", "Preview changes without applying them").action((options) => appendCommand(options));
|
|
1363
2265
|
async function interactiveMode() {
|