shipd 0.1.0
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/LICENSE +21 -0
- package/README.md +205 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1366 -0
- package/docs-template/README.md +255 -0
- package/docs-template/[slug]/[subslug]/page.tsx +1242 -0
- package/docs-template/[slug]/page.tsx +422 -0
- package/docs-template/api/page.tsx +47 -0
- package/docs-template/components/docs/docs-category-page.tsx +162 -0
- package/docs-template/components/docs/docs-code-card.tsx +135 -0
- package/docs-template/components/docs/docs-header.tsx +69 -0
- package/docs-template/components/docs/docs-nav.ts +95 -0
- package/docs-template/components/docs/docs-sidebar.tsx +112 -0
- package/docs-template/components/docs/docs-toc.tsx +38 -0
- package/docs-template/components/ui/badge.tsx +47 -0
- package/docs-template/components/ui/button.tsx +60 -0
- package/docs-template/components/ui/card.tsx +93 -0
- package/docs-template/components/ui/sheet.tsx +140 -0
- package/docs-template/documentation/page.tsx +80 -0
- package/docs-template/layout.tsx +27 -0
- package/docs-template/lib/utils.ts +7 -0
- package/docs-template/page.tsx +360 -0
- package/package.json +66 -0
- package/template/.env.example +45 -0
- package/template/README.md +239 -0
- package/template/app/api/auth/[...all]/route.ts +4 -0
- package/template/app/api/chat/route.ts +16 -0
- package/template/app/api/subscription/route.ts +25 -0
- package/template/app/api/upload-image/route.ts +64 -0
- package/template/app/blog/[slug]/page.tsx +314 -0
- package/template/app/blog/page.tsx +107 -0
- package/template/app/dashboard/_components/chart-interactive.tsx +289 -0
- package/template/app/dashboard/_components/chatbot.tsx +39 -0
- package/template/app/dashboard/_components/mode-toggle.tsx +46 -0
- package/template/app/dashboard/_components/navbar.tsx +84 -0
- package/template/app/dashboard/_components/section-cards.tsx +102 -0
- package/template/app/dashboard/_components/sidebar.tsx +90 -0
- package/template/app/dashboard/_components/subscribe-button.tsx +49 -0
- package/template/app/dashboard/billing/page.tsx +277 -0
- package/template/app/dashboard/chat/page.tsx +73 -0
- package/template/app/dashboard/cli/page.tsx +260 -0
- package/template/app/dashboard/layout.tsx +24 -0
- package/template/app/dashboard/page.tsx +216 -0
- package/template/app/dashboard/payment/_components/manage-subscription.tsx +22 -0
- package/template/app/dashboard/payment/page.tsx +126 -0
- package/template/app/dashboard/settings/page.tsx +613 -0
- package/template/app/dashboard/upload/page.tsx +324 -0
- package/template/app/error.tsx +78 -0
- package/template/app/favicon.ico +0 -0
- package/template/app/globals.css +126 -0
- package/template/app/layout.tsx +135 -0
- package/template/app/not-found.tsx +45 -0
- package/template/app/page.tsx +28 -0
- package/template/app/pricing/_component/pricing-table.tsx +276 -0
- package/template/app/pricing/page.tsx +23 -0
- package/template/app/privacy-policy/page.tsx +280 -0
- package/template/app/robots.txt +12 -0
- package/template/app/sign-in/page.tsx +228 -0
- package/template/app/sign-up/page.tsx +243 -0
- package/template/app/sitemap.ts +62 -0
- package/template/app/success/page.tsx +123 -0
- package/template/app/terms-of-service/page.tsx +212 -0
- package/template/auth-schema.ts +47 -0
- package/template/components/homepage/cli-workflow-section.tsx +138 -0
- package/template/components/homepage/features-section.tsx +150 -0
- package/template/components/homepage/footer.tsx +53 -0
- package/template/components/homepage/hero-section.tsx +112 -0
- package/template/components/homepage/integrations.tsx +124 -0
- package/template/components/homepage/navigation.tsx +116 -0
- package/template/components/homepage/news-section.tsx +82 -0
- package/template/components/homepage/testimonials-section.tsx +34 -0
- package/template/components/logos/BetterAuth.tsx +21 -0
- package/template/components/logos/NeonPostgres.tsx +41 -0
- package/template/components/logos/Nextjs.tsx +72 -0
- package/template/components/logos/Polar.tsx +7 -0
- package/template/components/logos/TailwindCSS.tsx +27 -0
- package/template/components/logos/index.ts +6 -0
- package/template/components/logos/shadcnui.tsx +8 -0
- package/template/components/provider.tsx +8 -0
- package/template/components/ui/avatar.tsx +53 -0
- package/template/components/ui/badge.tsx +46 -0
- package/template/components/ui/button.tsx +59 -0
- package/template/components/ui/card.tsx +92 -0
- package/template/components/ui/chart.tsx +353 -0
- package/template/components/ui/checkbox.tsx +32 -0
- package/template/components/ui/dialog.tsx +135 -0
- package/template/components/ui/dropdown-menu.tsx +257 -0
- package/template/components/ui/form.tsx +167 -0
- package/template/components/ui/input.tsx +21 -0
- package/template/components/ui/label.tsx +24 -0
- package/template/components/ui/progress.tsx +31 -0
- package/template/components/ui/resizable.tsx +56 -0
- package/template/components/ui/select.tsx +185 -0
- package/template/components/ui/separator.tsx +28 -0
- package/template/components/ui/sheet.tsx +139 -0
- package/template/components/ui/skeleton.tsx +13 -0
- package/template/components/ui/sonner.tsx +25 -0
- package/template/components/ui/switch.tsx +31 -0
- package/template/components/ui/tabs.tsx +66 -0
- package/template/components/ui/textarea.tsx +18 -0
- package/template/components/ui/toggle-group.tsx +73 -0
- package/template/components/ui/toggle.tsx +47 -0
- package/template/components/ui/tooltip.tsx +61 -0
- package/template/components/user-profile.tsx +139 -0
- package/template/components.json +21 -0
- package/template/db/drizzle.ts +14 -0
- package/template/db/migrations/0000_worried_rawhide_kid.sql +77 -0
- package/template/db/migrations/meta/0000_snapshot.json +494 -0
- package/template/db/migrations/meta/_journal.json +13 -0
- package/template/db/schema.ts +85 -0
- package/template/drizzle.config.ts +13 -0
- package/template/emails/components/layout.tsx +181 -0
- package/template/emails/password-reset.tsx +67 -0
- package/template/emails/payment-failed.tsx +167 -0
- package/template/emails/subscription-confirmation.tsx +129 -0
- package/template/emails/welcome.tsx +100 -0
- package/template/eslint.config.mjs +16 -0
- package/template/hooks/use-mobile.ts +21 -0
- package/template/lib/auth-client.ts +8 -0
- package/template/lib/auth.ts +276 -0
- package/template/lib/email.ts +118 -0
- package/template/lib/polar-products.ts +49 -0
- package/template/lib/subscription.ts +148 -0
- package/template/lib/upload-image.ts +28 -0
- package/template/lib/utils.ts +6 -0
- package/template/middleware.ts +30 -0
- package/template/next-env.d.ts +5 -0
- package/template/next.config.ts +27 -0
- package/template/package.json +99 -0
- package/template/postcss.config.mjs +5 -0
- package/template/public/add.png +0 -0
- package/template/public/favicon.svg +4 -0
- package/template/public/file.svg +1 -0
- package/template/public/globe.svg +1 -0
- package/template/public/iphone.png +0 -0
- package/template/public/logo.png +0 -0
- package/template/public/next.svg +1 -0
- package/template/public/polar-sh.svg +1 -0
- package/template/public/shadcn-ui.svg +1 -0
- package/template/public/site.webmanifest +21 -0
- package/template/public/vercel.svg +1 -0
- package/template/public/window.svg +1 -0
- package/template/tailwind.config.ts +89 -0
- package/template/template.config.json +138 -0
- package/template/tsconfig.json +27 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1366 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { Command as Command3 } from "commander";
|
|
5
|
+
import inquirer3 from "inquirer";
|
|
6
|
+
|
|
7
|
+
// src/prompts/index.ts
|
|
8
|
+
import inquirer from "inquirer";
|
|
9
|
+
|
|
10
|
+
// src/utils/validators.ts
|
|
11
|
+
import { existsSync } from "fs";
|
|
12
|
+
import { resolve } from "path";
|
|
13
|
+
function validateProjectName(name) {
|
|
14
|
+
if (!name || name.trim().length === 0) {
|
|
15
|
+
return "Project name is required";
|
|
16
|
+
}
|
|
17
|
+
const invalidChars = /[<>:"|?*\x00-\x1F]/;
|
|
18
|
+
if (invalidChars.test(name)) {
|
|
19
|
+
return "Project name contains invalid characters";
|
|
20
|
+
}
|
|
21
|
+
if (name.startsWith(".") || name.startsWith(" ") || name.endsWith(" ")) {
|
|
22
|
+
return "Project name cannot start with a dot or space, or end with a space";
|
|
23
|
+
}
|
|
24
|
+
const targetPath = resolve(process.cwd(), name);
|
|
25
|
+
if (existsSync(targetPath)) {
|
|
26
|
+
return `Directory "${name}" already exists`;
|
|
27
|
+
}
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// src/prompts/index.ts
|
|
32
|
+
async function runPrompts(projectName) {
|
|
33
|
+
if (projectName) {
|
|
34
|
+
console.log(`
|
|
35
|
+
\u{1F4C1} Project name: ${projectName}`);
|
|
36
|
+
}
|
|
37
|
+
const answers = await inquirer.prompt([
|
|
38
|
+
{
|
|
39
|
+
type: "input",
|
|
40
|
+
name: "projectName",
|
|
41
|
+
message: "Project name:",
|
|
42
|
+
default: projectName,
|
|
43
|
+
when: !projectName,
|
|
44
|
+
validate: validateProjectName
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
type: "input",
|
|
48
|
+
name: "description",
|
|
49
|
+
message: "Project description:",
|
|
50
|
+
default: "A modern SaaS application"
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
type: "checkbox",
|
|
54
|
+
name: "features",
|
|
55
|
+
message: "Select optional features to include:",
|
|
56
|
+
choices: [
|
|
57
|
+
{ name: "Polar.sh Payments & Subscriptions", value: "payments", checked: true },
|
|
58
|
+
{ name: "OpenAI Chat Integration", value: "ai-chat", checked: true },
|
|
59
|
+
{ name: "Cloudflare R2 File Uploads", value: "file-upload", checked: true },
|
|
60
|
+
{ name: "PostHog Analytics", value: "analytics", checked: true }
|
|
61
|
+
]
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
type: "list",
|
|
65
|
+
name: "packageManager",
|
|
66
|
+
message: "Package manager:",
|
|
67
|
+
choices: ["npm", "pnpm", "yarn"],
|
|
68
|
+
default: "npm"
|
|
69
|
+
}
|
|
70
|
+
]);
|
|
71
|
+
return {
|
|
72
|
+
projectName: projectName || answers.projectName,
|
|
73
|
+
description: answers.description,
|
|
74
|
+
features: ["auth", "database", ...answers.features],
|
|
75
|
+
// Always include auth & database
|
|
76
|
+
packageManager: answers.packageManager
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// src/generators/project.ts
|
|
81
|
+
import { resolve as resolve3 } from "path";
|
|
82
|
+
import ora from "ora";
|
|
83
|
+
import { execa } from "execa";
|
|
84
|
+
import fs from "fs-extra";
|
|
85
|
+
|
|
86
|
+
// src/utils/template-config.ts
|
|
87
|
+
import { readFile } from "fs/promises";
|
|
88
|
+
import { existsSync as existsSync2 } from "fs";
|
|
89
|
+
import { join, resolve as resolve2, dirname } from "path";
|
|
90
|
+
import { fileURLToPath } from "url";
|
|
91
|
+
var __filename = fileURLToPath(import.meta.url);
|
|
92
|
+
var __dirname2 = dirname(__filename);
|
|
93
|
+
function getTemplatePath() {
|
|
94
|
+
const distPath = resolve2(__dirname2, "../../template");
|
|
95
|
+
if (existsSync2(distPath)) {
|
|
96
|
+
return distPath;
|
|
97
|
+
}
|
|
98
|
+
const srcPath = resolve2(__dirname2, "../../../template");
|
|
99
|
+
if (existsSync2(srcPath)) {
|
|
100
|
+
return srcPath;
|
|
101
|
+
}
|
|
102
|
+
const absolutePath = resolve2(__dirname2, "../../../../packages/template");
|
|
103
|
+
if (existsSync2(absolutePath)) {
|
|
104
|
+
return absolutePath;
|
|
105
|
+
}
|
|
106
|
+
throw new Error(`Template directory not found. Tried paths:
|
|
107
|
+
- ${distPath}
|
|
108
|
+
- ${srcPath}
|
|
109
|
+
- ${absolutePath}`);
|
|
110
|
+
}
|
|
111
|
+
async function loadTemplateConfig() {
|
|
112
|
+
const templatePath = getTemplatePath();
|
|
113
|
+
const configPath = join(templatePath, "template.config.json");
|
|
114
|
+
if (!existsSync2(configPath)) {
|
|
115
|
+
throw new Error(`template.config.json not found at ${configPath}`);
|
|
116
|
+
}
|
|
117
|
+
const content = await readFile(configPath, "utf-8");
|
|
118
|
+
return JSON.parse(content);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// src/utils/file-system.ts
|
|
122
|
+
import { existsSync as existsSync3 } from "fs";
|
|
123
|
+
import { cp, rm, readdir, readFile as readFile2, writeFile } from "fs/promises";
|
|
124
|
+
import { join as join2, relative } from "path";
|
|
125
|
+
import { globby } from "globby";
|
|
126
|
+
var DEFAULT_EXCLUDES = [
|
|
127
|
+
"**/node_modules/**",
|
|
128
|
+
"**/.next/**",
|
|
129
|
+
"**/.git/**",
|
|
130
|
+
"**/dist/**",
|
|
131
|
+
"**/coverage/**",
|
|
132
|
+
"**/.env",
|
|
133
|
+
"**/.env.local",
|
|
134
|
+
"**/template.config.json"
|
|
135
|
+
];
|
|
136
|
+
async function copyTemplate(sourcePath, targetPath, excludePatterns = []) {
|
|
137
|
+
const allExcludes = [...DEFAULT_EXCLUDES, ...excludePatterns];
|
|
138
|
+
await cp(sourcePath, targetPath, {
|
|
139
|
+
recursive: true,
|
|
140
|
+
filter: (src, _dest) => {
|
|
141
|
+
const relativePath = relative(sourcePath, src);
|
|
142
|
+
return !shouldExclude(relativePath, allExcludes);
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
function shouldExclude(filePath, patterns) {
|
|
147
|
+
return patterns.some((pattern) => {
|
|
148
|
+
const regex = new RegExp(
|
|
149
|
+
pattern.replace(/\*\*/g, ".*").replace(/\*/g, "[^/]*").replace(/\./g, "\\.")
|
|
150
|
+
);
|
|
151
|
+
return regex.test(filePath);
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
async function removeFeatureFiles(targetPath, featureFiles) {
|
|
155
|
+
for (const filePattern of featureFiles) {
|
|
156
|
+
const files = await globby(filePattern, {
|
|
157
|
+
cwd: targetPath,
|
|
158
|
+
absolute: true,
|
|
159
|
+
dot: true
|
|
160
|
+
});
|
|
161
|
+
for (const file of files) {
|
|
162
|
+
await rm(file, { recursive: true, force: true });
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
async function removeEmptyDirectories(dirPath) {
|
|
167
|
+
if (!existsSync3(dirPath)) {
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
const entries = await readdir(dirPath, { withFileTypes: true });
|
|
171
|
+
for (const entry of entries) {
|
|
172
|
+
if (entry.isDirectory()) {
|
|
173
|
+
const fullPath = join2(dirPath, entry.name);
|
|
174
|
+
await removeEmptyDirectories(fullPath);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
const remaining = await readdir(dirPath);
|
|
178
|
+
if (remaining.length === 0) {
|
|
179
|
+
await rm(dirPath, { recursive: true, force: true });
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
async function removeConditionalBlocks(targetPath, unselectedFeatures) {
|
|
183
|
+
const featureMarkers = {
|
|
184
|
+
"ai-chat": "AI_CHAT",
|
|
185
|
+
"payments": "PAYMENTS",
|
|
186
|
+
"file-upload": "FILE_UPLOAD",
|
|
187
|
+
"analytics": "ANALYTICS"
|
|
188
|
+
};
|
|
189
|
+
const filesToCheck = await globby("**/*.{ts,tsx,js,jsx}", {
|
|
190
|
+
cwd: targetPath,
|
|
191
|
+
absolute: true,
|
|
192
|
+
ignore: ["**/node_modules/**", "**/.next/**"]
|
|
193
|
+
});
|
|
194
|
+
for (const filePath of filesToCheck) {
|
|
195
|
+
let content = await readFile2(filePath, "utf-8");
|
|
196
|
+
let modified = false;
|
|
197
|
+
for (const feature of unselectedFeatures) {
|
|
198
|
+
const marker = featureMarkers[feature];
|
|
199
|
+
if (!marker) continue;
|
|
200
|
+
const blockRegex = new RegExp(
|
|
201
|
+
`\\/\\*${marker}_START\\*\\/[\\s\\S]*?\\/\\*${marker}_END\\*\\/\\n?`,
|
|
202
|
+
"g"
|
|
203
|
+
);
|
|
204
|
+
const jsxBlockRegex = new RegExp(
|
|
205
|
+
`\\{\\s*\\/\\*${marker}_START\\*\\/\\s*\\}[\\s\\S]*?\\{\\s*\\/\\*${marker}_END\\*\\/\\s*\\}\\n?`,
|
|
206
|
+
"g"
|
|
207
|
+
);
|
|
208
|
+
if (blockRegex.test(content) || jsxBlockRegex.test(content)) {
|
|
209
|
+
content = content.replace(blockRegex, "");
|
|
210
|
+
content = content.replace(jsxBlockRegex, "");
|
|
211
|
+
modified = true;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
if (modified) {
|
|
215
|
+
await writeFile(filePath, content, "utf-8");
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
async function removeUnselectedFeatures(targetPath, selectedFeatures, templateConfig) {
|
|
220
|
+
const allFeatures = Object.keys(templateConfig.features);
|
|
221
|
+
const unselectedFeatures = allFeatures.filter(
|
|
222
|
+
(f) => !selectedFeatures.includes(f) && templateConfig.features[f].optional
|
|
223
|
+
);
|
|
224
|
+
await removeConditionalBlocks(targetPath, unselectedFeatures);
|
|
225
|
+
for (const feature of unselectedFeatures) {
|
|
226
|
+
const featureConfig = templateConfig.features[feature];
|
|
227
|
+
if (featureConfig.files.length > 0) {
|
|
228
|
+
await removeFeatureFiles(targetPath, featureConfig.files);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
await removeEmptyDirectories(targetPath);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// src/transformers/template-vars.ts
|
|
235
|
+
import { readFile as readFile3, writeFile as writeFile2 } from "fs/promises";
|
|
236
|
+
import { existsSync as existsSync4 } from "fs";
|
|
237
|
+
import { join as join3 } from "path";
|
|
238
|
+
function toKebabCase(str) {
|
|
239
|
+
return str.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
240
|
+
}
|
|
241
|
+
function toPascalCase(str) {
|
|
242
|
+
return str.replace(/[^a-z0-9]+/gi, " ").split(" ").filter((word) => word.length > 0).map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()).join("");
|
|
243
|
+
}
|
|
244
|
+
function generateVariables(config) {
|
|
245
|
+
return {
|
|
246
|
+
PROJECT_NAME: config.projectName,
|
|
247
|
+
PROJECT_NAME_KEBAB: toKebabCase(config.projectName),
|
|
248
|
+
PROJECT_NAME_PASCAL: toPascalCase(config.projectName),
|
|
249
|
+
PROJECT_DESCRIPTION: config.description,
|
|
250
|
+
APP_URL: "http://localhost:3000"
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
async function replaceInFile(filePath, variables) {
|
|
254
|
+
if (!existsSync4(filePath)) {
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
let content = await readFile3(filePath, "utf-8");
|
|
258
|
+
Object.entries(variables).forEach(([key, value]) => {
|
|
259
|
+
const regex = new RegExp(`{{${key}}}`, "g");
|
|
260
|
+
content = content.replace(regex, value);
|
|
261
|
+
});
|
|
262
|
+
await writeFile2(filePath, content, "utf-8");
|
|
263
|
+
}
|
|
264
|
+
async function replaceTemplateVariables(targetPath, config) {
|
|
265
|
+
const variables = generateVariables(config);
|
|
266
|
+
const filesToProcess = [
|
|
267
|
+
"package.json",
|
|
268
|
+
"README.md",
|
|
269
|
+
"app/layout.tsx",
|
|
270
|
+
"app/sign-in/page.tsx",
|
|
271
|
+
"app/sign-up/page.tsx",
|
|
272
|
+
".env.example"
|
|
273
|
+
];
|
|
274
|
+
for (const file of filesToProcess) {
|
|
275
|
+
const filePath = join3(targetPath, file);
|
|
276
|
+
await replaceInFile(filePath, variables);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// src/generators/package-json.ts
|
|
281
|
+
import { readFile as readFile4, writeFile as writeFile3 } from "fs/promises";
|
|
282
|
+
import { join as join4 } from "path";
|
|
283
|
+
async function generatePackageJson(targetPath, selectedFeatures, templateConfig) {
|
|
284
|
+
const packageJsonPath = join4(targetPath, "package.json");
|
|
285
|
+
const packageJson = JSON.parse(await readFile4(packageJsonPath, "utf-8"));
|
|
286
|
+
const templatePackageJsonPath = join4(getTemplatePath(), "package.json");
|
|
287
|
+
const templatePackageJson = JSON.parse(await readFile4(templatePackageJsonPath, "utf-8"));
|
|
288
|
+
const dependencies = {};
|
|
289
|
+
const devDependencies = {};
|
|
290
|
+
const coreDeps = [
|
|
291
|
+
"next",
|
|
292
|
+
"react",
|
|
293
|
+
"react-dom",
|
|
294
|
+
"typescript",
|
|
295
|
+
"tailwindcss",
|
|
296
|
+
"dotenv",
|
|
297
|
+
"@radix-ui/react-avatar",
|
|
298
|
+
"@radix-ui/react-slot",
|
|
299
|
+
"@radix-ui/react-dialog",
|
|
300
|
+
"@radix-ui/react-dropdown-menu",
|
|
301
|
+
"@radix-ui/react-label",
|
|
302
|
+
"@radix-ui/react-tabs",
|
|
303
|
+
"@radix-ui/react-separator",
|
|
304
|
+
"@radix-ui/react-checkbox",
|
|
305
|
+
"@radix-ui/react-switch",
|
|
306
|
+
"class-variance-authority",
|
|
307
|
+
"clsx",
|
|
308
|
+
"tailwind-merge",
|
|
309
|
+
"lucide-react",
|
|
310
|
+
"next-themes",
|
|
311
|
+
"sonner",
|
|
312
|
+
"tailwindcss-animate",
|
|
313
|
+
"framer-motion",
|
|
314
|
+
"motion",
|
|
315
|
+
"@vercel/analytics",
|
|
316
|
+
"zod",
|
|
317
|
+
"react-hook-form",
|
|
318
|
+
"@hookform/resolvers",
|
|
319
|
+
"drizzle-orm",
|
|
320
|
+
"postgres",
|
|
321
|
+
"better-auth",
|
|
322
|
+
"@polar-sh/better-auth",
|
|
323
|
+
"@polar-sh/sdk",
|
|
324
|
+
"@neondatabase/serverless",
|
|
325
|
+
"resend",
|
|
326
|
+
"@react-email/components",
|
|
327
|
+
"@react-email/render"
|
|
328
|
+
];
|
|
329
|
+
const coreDevDeps = [
|
|
330
|
+
"@types/node",
|
|
331
|
+
"@types/react",
|
|
332
|
+
"@types/react-dom",
|
|
333
|
+
"eslint",
|
|
334
|
+
"eslint-config-next",
|
|
335
|
+
"@eslint/eslintrc",
|
|
336
|
+
"drizzle-kit",
|
|
337
|
+
"@tailwindcss/postcss",
|
|
338
|
+
"tw-animate-css"
|
|
339
|
+
];
|
|
340
|
+
coreDeps.forEach((dep) => {
|
|
341
|
+
if (templatePackageJson.dependencies?.[dep]) {
|
|
342
|
+
dependencies[dep] = templatePackageJson.dependencies[dep];
|
|
343
|
+
}
|
|
344
|
+
});
|
|
345
|
+
coreDevDeps.forEach((dep) => {
|
|
346
|
+
if (templatePackageJson.devDependencies?.[dep]) {
|
|
347
|
+
devDependencies[dep] = templatePackageJson.devDependencies[dep];
|
|
348
|
+
}
|
|
349
|
+
});
|
|
350
|
+
for (const feature of selectedFeatures) {
|
|
351
|
+
const featureConfig = templateConfig.features[feature];
|
|
352
|
+
if (featureConfig) {
|
|
353
|
+
for (const dep of featureConfig.dependencies || []) {
|
|
354
|
+
if (templatePackageJson.dependencies?.[dep]) {
|
|
355
|
+
dependencies[dep] = templatePackageJson.dependencies[dep];
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
for (const dep of featureConfig.devDependencies || []) {
|
|
359
|
+
if (templatePackageJson.devDependencies?.[dep]) {
|
|
360
|
+
devDependencies[dep] = templatePackageJson.devDependencies[dep];
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
const sortedDeps = Object.keys(dependencies).sort().reduce((acc, key) => {
|
|
366
|
+
acc[key] = dependencies[key];
|
|
367
|
+
return acc;
|
|
368
|
+
}, {});
|
|
369
|
+
const sortedDevDeps = Object.keys(devDependencies).sort().reduce((acc, key) => {
|
|
370
|
+
acc[key] = devDependencies[key];
|
|
371
|
+
return acc;
|
|
372
|
+
}, {});
|
|
373
|
+
packageJson.dependencies = sortedDeps;
|
|
374
|
+
packageJson.devDependencies = sortedDevDeps;
|
|
375
|
+
await writeFile3(packageJsonPath, JSON.stringify(packageJson, null, 2) + "\n");
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// src/generators/env.ts
|
|
379
|
+
import { writeFile as writeFile4 } from "fs/promises";
|
|
380
|
+
import { join as join5 } from "path";
|
|
381
|
+
var ENV_DEFAULTS = {
|
|
382
|
+
// Database
|
|
383
|
+
"DATABASE_URL": "postgresql://user:password@localhost:5432/mydb",
|
|
384
|
+
// Auth
|
|
385
|
+
"BETTER_AUTH_SECRET": "please-change-this-to-a-random-32-character-string-or-longer",
|
|
386
|
+
"GOOGLE_CLIENT_ID": "your-google-client-id.apps.googleusercontent.com",
|
|
387
|
+
"GOOGLE_CLIENT_SECRET": "your-google-client-secret",
|
|
388
|
+
// Polar.sh Payments
|
|
389
|
+
"POLAR_ACCESS_TOKEN": "your-polar-access-token",
|
|
390
|
+
"POLAR_SUCCESS_URL": "http://localhost:3000/success",
|
|
391
|
+
"POLAR_WEBHOOK_SECRET": "your-polar-webhook-secret",
|
|
392
|
+
"NEXT_PUBLIC_STARTER_TIER": "your-starter-tier-id",
|
|
393
|
+
"NEXT_PUBLIC_STARTER_SLUG": "your-organization-slug",
|
|
394
|
+
// OpenAI
|
|
395
|
+
"OPENAI_API_KEY": "sk-your-openai-api-key",
|
|
396
|
+
// Cloudflare R2
|
|
397
|
+
"R2_UPLOAD_IMAGE_ACCESS_KEY_ID": "your-r2-access-key-id",
|
|
398
|
+
"R2_UPLOAD_IMAGE_SECRET_ACCESS_KEY": "your-r2-secret-access-key",
|
|
399
|
+
"CLOUDFLARE_ACCOUNT_ID": "your-cloudflare-account-id",
|
|
400
|
+
"R2_UPLOAD_IMAGE_BUCKET_NAME": "your-bucket-name",
|
|
401
|
+
// PostHog
|
|
402
|
+
"NEXT_PUBLIC_POSTHOG_KEY": "phc_your-posthog-project-key",
|
|
403
|
+
"NEXT_PUBLIC_POSTHOG_HOST": "https://app.posthog.com"
|
|
404
|
+
};
|
|
405
|
+
async function generateEnvExample(targetPath, selectedFeatures, templateConfig) {
|
|
406
|
+
const lines = [];
|
|
407
|
+
lines.push("# Environment Variables");
|
|
408
|
+
lines.push("# Copy this file to .env.local and customize the values");
|
|
409
|
+
lines.push("");
|
|
410
|
+
lines.push("# \u26A0\uFE0F REQUIRED FOR BASIC FUNCTIONALITY:");
|
|
411
|
+
lines.push("# - DATABASE_URL (for data storage)");
|
|
412
|
+
lines.push("# - BETTER_AUTH_SECRET (for authentication)");
|
|
413
|
+
lines.push("# - GOOGLE_CLIENT_ID & GOOGLE_CLIENT_SECRET (for Google login)");
|
|
414
|
+
lines.push("");
|
|
415
|
+
lines.push("# \u2139\uFE0F OPTIONAL SERVICES:");
|
|
416
|
+
lines.push("# - POLAR_* variables (only needed for subscription payments)");
|
|
417
|
+
lines.push("# - OPENAI_API_KEY (only needed for AI chat features)");
|
|
418
|
+
lines.push("# - R2_* variables (only needed for file uploads)");
|
|
419
|
+
lines.push("# - POSTHOG_* variables (only needed for analytics)");
|
|
420
|
+
lines.push("");
|
|
421
|
+
lines.push("# The app will launch with these placeholder values, but configure real services for production");
|
|
422
|
+
lines.push("");
|
|
423
|
+
lines.push("# Application");
|
|
424
|
+
lines.push("NEXT_PUBLIC_APP_URL=http://localhost:3000");
|
|
425
|
+
lines.push("");
|
|
426
|
+
for (const feature of selectedFeatures) {
|
|
427
|
+
const featureConfig = templateConfig.features[feature];
|
|
428
|
+
if (featureConfig && featureConfig.envVars.length > 0) {
|
|
429
|
+
const featureName = feature.split("-").map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(" ");
|
|
430
|
+
const isOptional = !featureConfig.required;
|
|
431
|
+
const requirementNote = isOptional ? " (Optional - only needed if using this feature)" : " (Required)";
|
|
432
|
+
lines.push(`# ${featureName}${requirementNote}`);
|
|
433
|
+
featureConfig.envVars.forEach((envVar) => {
|
|
434
|
+
const defaultValue = ENV_DEFAULTS[envVar] || "";
|
|
435
|
+
lines.push(`${envVar}=${defaultValue}`);
|
|
436
|
+
});
|
|
437
|
+
lines.push("");
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
await writeFile4(
|
|
441
|
+
join5(targetPath, ".env.example"),
|
|
442
|
+
lines.join("\n")
|
|
443
|
+
);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// src/generators/readme.ts
|
|
447
|
+
import { readFile as readFile5, writeFile as writeFile5 } from "fs/promises";
|
|
448
|
+
import { join as join6 } from "path";
|
|
449
|
+
async function generateReadme(targetPath, config, templateConfig) {
|
|
450
|
+
const readmePath = join6(targetPath, "README.md");
|
|
451
|
+
let readme = await readFile5(readmePath, "utf-8");
|
|
452
|
+
const featureSections = [];
|
|
453
|
+
if (config.features.includes("payments")) {
|
|
454
|
+
featureSections.push(`
|
|
455
|
+
### Polar.sh Setup (Payments)
|
|
456
|
+
1. Create account at [polar.sh](https://polar.sh)
|
|
457
|
+
2. Create organization and products/subscription tiers
|
|
458
|
+
3. Get your access token from Settings \u2192 API
|
|
459
|
+
4. Add to \`.env.local\`:
|
|
460
|
+
- \`POLAR_ACCESS_TOKEN\`
|
|
461
|
+
- \`POLAR_WEBHOOK_SECRET\`
|
|
462
|
+
- \`NEXT_PUBLIC_STARTER_TIER\`
|
|
463
|
+
- \`NEXT_PUBLIC_STARTER_SLUG\`
|
|
464
|
+
`);
|
|
465
|
+
}
|
|
466
|
+
if (config.features.includes("ai-chat")) {
|
|
467
|
+
featureSections.push(`
|
|
468
|
+
### OpenAI Setup (AI Chat)
|
|
469
|
+
1. Get API key from [platform.openai.com](https://platform.openai.com)
|
|
470
|
+
2. Add to \`.env.local\`:
|
|
471
|
+
- \`OPENAI_API_KEY\`
|
|
472
|
+
`);
|
|
473
|
+
}
|
|
474
|
+
if (config.features.includes("file-upload")) {
|
|
475
|
+
featureSections.push(`
|
|
476
|
+
### Cloudflare R2 Setup (File Uploads)
|
|
477
|
+
1. Create R2 bucket in Cloudflare dashboard
|
|
478
|
+
2. Generate API tokens with R2 permissions
|
|
479
|
+
3. Add to \`.env.local\`:
|
|
480
|
+
- \`R2_UPLOAD_IMAGE_ACCESS_KEY_ID\`
|
|
481
|
+
- \`R2_UPLOAD_IMAGE_SECRET_ACCESS_KEY\`
|
|
482
|
+
- \`CLOUDFLARE_ACCOUNT_ID\`
|
|
483
|
+
- \`R2_UPLOAD_IMAGE_BUCKET_NAME\`
|
|
484
|
+
`);
|
|
485
|
+
}
|
|
486
|
+
if (config.features.includes("analytics")) {
|
|
487
|
+
featureSections.push(`
|
|
488
|
+
### PostHog Setup (Analytics)
|
|
489
|
+
1. Create account at [posthog.com](https://posthog.com)
|
|
490
|
+
2. Create a new project
|
|
491
|
+
3. Add to \`.env.local\`:
|
|
492
|
+
- \`NEXT_PUBLIC_POSTHOG_KEY\`
|
|
493
|
+
- \`NEXT_PUBLIC_POSTHOG_HOST=https://app.posthog.com\`
|
|
494
|
+
`);
|
|
495
|
+
}
|
|
496
|
+
if (featureSections.length > 0) {
|
|
497
|
+
const setupSection = `
|
|
498
|
+
|
|
499
|
+
## \u{1F527} Service Configuration
|
|
500
|
+
${featureSections.join("\n")}`;
|
|
501
|
+
readme += setupSection;
|
|
502
|
+
}
|
|
503
|
+
readme += `
|
|
504
|
+
|
|
505
|
+
---
|
|
506
|
+
|
|
507
|
+
**Generated with** [Shipd](https://github.com/yourusername/shipd)
|
|
508
|
+
`;
|
|
509
|
+
await writeFile5(readmePath, readme);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// src/utils/logger.ts
|
|
513
|
+
import chalk from "chalk";
|
|
514
|
+
var SHIPD_ASCII = chalk.hex("#ff7043")(` # # ( )
|
|
515
|
+
___#_#___|__
|
|
516
|
+
_ |____________| _
|
|
517
|
+
_=====| | | | | |==== _
|
|
518
|
+
=====| |.---------------------------. | |====
|
|
519
|
+
<---------------------------' '----------------/
|
|
520
|
+
\\ .d8888. db db d888888b d8888b. d8888b. /
|
|
521
|
+
\\ 88' YP 88 88 \\\`88' 88 \\\`8D 88 \\\`8D /
|
|
522
|
+
\\ \\\`8bo. 88ooo88 88 88oodD' 88 88 /
|
|
523
|
+
\\ \\\`Y8b. 88~~~88 88 88~~~ 88 88 /
|
|
524
|
+
\\ db 8D 88 88 .88. 88 88 .8D /
|
|
525
|
+
\\\`8888Y' YP YP Y888888P 88 Y8888D' /
|
|
526
|
+
\\_____________________________________________________________________ /
|
|
527
|
+
wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww
|
|
528
|
+
wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww
|
|
529
|
+
wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww
|
|
530
|
+
`);
|
|
531
|
+
var logger = {
|
|
532
|
+
intro: (message) => {
|
|
533
|
+
console.log("\n" + chalk.bold.cyan(message) + "\n");
|
|
534
|
+
},
|
|
535
|
+
success: (message) => {
|
|
536
|
+
console.log(chalk.green("\u2713") + " " + message);
|
|
537
|
+
},
|
|
538
|
+
error: (message) => {
|
|
539
|
+
console.log(chalk.red("\u2717") + " " + message);
|
|
540
|
+
},
|
|
541
|
+
info: (message) => {
|
|
542
|
+
console.log(chalk.blue("\u2139") + " " + message);
|
|
543
|
+
},
|
|
544
|
+
step: (message) => {
|
|
545
|
+
console.log(chalk.cyan("\u2192") + " " + message);
|
|
546
|
+
},
|
|
547
|
+
warn: (message) => {
|
|
548
|
+
console.log(chalk.yellow("\u26A0") + " " + message);
|
|
549
|
+
}
|
|
550
|
+
};
|
|
551
|
+
|
|
552
|
+
// src/generators/project.ts
|
|
553
|
+
async function generateProject(config) {
|
|
554
|
+
const targetPath = resolve3(process.cwd(), config.projectName);
|
|
555
|
+
if (await fs.pathExists(targetPath)) {
|
|
556
|
+
const files = await fs.readdir(targetPath);
|
|
557
|
+
if (files.length > 0) {
|
|
558
|
+
const hasPkgJson = await fs.pathExists(resolve3(targetPath, "package.json"));
|
|
559
|
+
const hasGit = await fs.pathExists(resolve3(targetPath, ".git"));
|
|
560
|
+
const hasNodeModules = await fs.pathExists(resolve3(targetPath, "node_modules"));
|
|
561
|
+
if (hasPkgJson || hasGit || hasNodeModules) {
|
|
562
|
+
throw new Error(
|
|
563
|
+
`Directory "${config.projectName}" appears to contain an existing project.
|
|
564
|
+
- package.json: ${hasPkgJson ? "\u2713 found" : "\u2717 not found"}
|
|
565
|
+
- .git: ${hasGit ? "\u2713 found" : "\u2717 not found"}
|
|
566
|
+
- node_modules: ${hasNodeModules ? "\u2713 found" : "\u2717 not found"}
|
|
567
|
+
|
|
568
|
+
To avoid data loss, shipd init will not overwrite existing projects.
|
|
569
|
+
Please:
|
|
570
|
+
1. Choose a different project name, or
|
|
571
|
+
2. Delete the existing directory first, or
|
|
572
|
+
3. Use 'shipd append' to add features to the existing project`
|
|
573
|
+
);
|
|
574
|
+
}
|
|
575
|
+
logger.warn(`Directory "${config.projectName}" is not empty but doesn't appear to be a project.`);
|
|
576
|
+
logger.warn("Existing files may be overwritten. Press Ctrl+C to cancel or wait 3 seconds to continue...");
|
|
577
|
+
await new Promise((resolve5) => setTimeout(resolve5, 3e3));
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
const templateConfig = await loadTemplateConfig();
|
|
581
|
+
const templatePath = getTemplatePath();
|
|
582
|
+
const copySpinner = ora("Copying template files...").start();
|
|
583
|
+
try {
|
|
584
|
+
await copyTemplate(templatePath, targetPath);
|
|
585
|
+
copySpinner.succeed("Template files copied");
|
|
586
|
+
} catch (error) {
|
|
587
|
+
copySpinner.fail("Failed to copy template files");
|
|
588
|
+
throw error;
|
|
589
|
+
}
|
|
590
|
+
const removeSpinner = ora("Removing unselected features...").start();
|
|
591
|
+
try {
|
|
592
|
+
await removeUnselectedFeatures(targetPath, config.features, templateConfig);
|
|
593
|
+
removeSpinner.succeed("Features filtered");
|
|
594
|
+
} catch (error) {
|
|
595
|
+
removeSpinner.fail("Failed to remove feature files");
|
|
596
|
+
throw error;
|
|
597
|
+
}
|
|
598
|
+
const varsSpinner = ora("Replacing template variables...").start();
|
|
599
|
+
try {
|
|
600
|
+
await replaceTemplateVariables(targetPath, config);
|
|
601
|
+
varsSpinner.succeed("Variables replaced");
|
|
602
|
+
} catch (error) {
|
|
603
|
+
varsSpinner.fail("Failed to replace variables");
|
|
604
|
+
throw error;
|
|
605
|
+
}
|
|
606
|
+
const pkgSpinner = ora("Generating package.json...").start();
|
|
607
|
+
try {
|
|
608
|
+
await generatePackageJson(targetPath, config.features, templateConfig);
|
|
609
|
+
pkgSpinner.succeed("package.json generated");
|
|
610
|
+
} catch (error) {
|
|
611
|
+
pkgSpinner.fail("Failed to generate package.json");
|
|
612
|
+
throw error;
|
|
613
|
+
}
|
|
614
|
+
const envSpinner = ora("Generating .env.example...").start();
|
|
615
|
+
try {
|
|
616
|
+
await generateEnvExample(targetPath, config.features, templateConfig);
|
|
617
|
+
envSpinner.succeed(".env.example generated");
|
|
618
|
+
} catch (error) {
|
|
619
|
+
envSpinner.fail("Failed to generate .env.example");
|
|
620
|
+
throw error;
|
|
621
|
+
}
|
|
622
|
+
const readmeSpinner = ora("Updating README...").start();
|
|
623
|
+
try {
|
|
624
|
+
await generateReadme(targetPath, config, templateConfig);
|
|
625
|
+
readmeSpinner.succeed("README updated");
|
|
626
|
+
} catch (error) {
|
|
627
|
+
readmeSpinner.fail("Failed to update README");
|
|
628
|
+
throw error;
|
|
629
|
+
}
|
|
630
|
+
const gitSpinner = ora("Initializing git repository...").start();
|
|
631
|
+
try {
|
|
632
|
+
await execa("git", ["init"], { cwd: targetPath });
|
|
633
|
+
await execa("git", ["add", "."], { cwd: targetPath });
|
|
634
|
+
await execa("git", ["commit", "-m", "Initial commit from Shipd"], { cwd: targetPath });
|
|
635
|
+
gitSpinner.succeed("Git repository initialized");
|
|
636
|
+
} catch (error) {
|
|
637
|
+
gitSpinner.fail("Failed to initialize git");
|
|
638
|
+
throw error;
|
|
639
|
+
}
|
|
640
|
+
let installInterval;
|
|
641
|
+
const installSpinner = ora("Preparing to install dependencies...").start();
|
|
642
|
+
try {
|
|
643
|
+
const pkgJson = await fs.readJson(resolve3(targetPath, "package.json"));
|
|
644
|
+
const deps = Object.keys(pkgJson.dependencies || {}).length;
|
|
645
|
+
const devDeps = Object.keys(pkgJson.devDependencies || {}).length;
|
|
646
|
+
const totalDeps = deps + devDeps;
|
|
647
|
+
installSpinner.text = `Installing ${totalDeps} dependencies with ${config.packageManager}...
|
|
648
|
+
This may take 2-5 minutes depending on your connection`;
|
|
649
|
+
const startTime = Date.now();
|
|
650
|
+
installInterval = setInterval(() => {
|
|
651
|
+
const elapsed = Math.floor((Date.now() - startTime) / 1e3);
|
|
652
|
+
installSpinner.text = `Installing ${totalDeps} dependencies (${elapsed}s elapsed)...
|
|
653
|
+
${config.packageManager} install is running in background`;
|
|
654
|
+
}, 2e3);
|
|
655
|
+
await execa(config.packageManager, ["install"], {
|
|
656
|
+
cwd: targetPath,
|
|
657
|
+
stdio: "pipe"
|
|
658
|
+
});
|
|
659
|
+
clearInterval(installInterval);
|
|
660
|
+
const totalTime = Math.floor((Date.now() - startTime) / 1e3);
|
|
661
|
+
installSpinner.succeed(`Dependencies installed (${totalDeps} packages in ${totalTime}s)`);
|
|
662
|
+
} catch (error) {
|
|
663
|
+
if (installInterval) {
|
|
664
|
+
clearInterval(installInterval);
|
|
665
|
+
}
|
|
666
|
+
installSpinner.fail("Failed to install dependencies");
|
|
667
|
+
throw error;
|
|
668
|
+
}
|
|
669
|
+
logger.success(`
|
|
670
|
+
Project "${config.projectName}" created successfully at:
|
|
671
|
+
${targetPath}
|
|
672
|
+
`);
|
|
673
|
+
console.log("\nNext steps:");
|
|
674
|
+
logger.step(`cd "${targetPath}"`);
|
|
675
|
+
logger.step("Copy .env.example to .env.local and configure your services");
|
|
676
|
+
logger.step(`${config.packageManager} run dev`);
|
|
677
|
+
logger.info('Tip: If you see "Could not read package.json", you are not in the project directory yet.');
|
|
678
|
+
if (config.features.includes("payments")) {
|
|
679
|
+
console.log("\n\u{1F4B3} Polar.sh Setup Required:");
|
|
680
|
+
logger.info("Visit https://polar.sh to create your account and configure payment tiers");
|
|
681
|
+
}
|
|
682
|
+
if (config.features.includes("ai-chat")) {
|
|
683
|
+
console.log("\n\u{1F916} OpenAI Setup Required:");
|
|
684
|
+
logger.info("Get your API key from https://platform.openai.com");
|
|
685
|
+
}
|
|
686
|
+
if (config.features.includes("file-upload")) {
|
|
687
|
+
console.log("\n\u{1F4C1} Cloudflare R2 Setup Required:");
|
|
688
|
+
logger.info("Create an R2 bucket in your Cloudflare dashboard");
|
|
689
|
+
}
|
|
690
|
+
if (config.features.includes("analytics")) {
|
|
691
|
+
console.log("\n\u{1F4CA} PostHog Setup Required:");
|
|
692
|
+
logger.info("Create a project at https://posthog.com");
|
|
693
|
+
}
|
|
694
|
+
console.log("\n");
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// src/auth/token-storage.ts
|
|
698
|
+
import fs3 from "fs-extra";
|
|
699
|
+
|
|
700
|
+
// src/config/paths.ts
|
|
701
|
+
import os from "os";
|
|
702
|
+
import path from "path";
|
|
703
|
+
import fs2 from "fs-extra";
|
|
704
|
+
function getConfigDir() {
|
|
705
|
+
const homeDir = os.homedir();
|
|
706
|
+
const configDir = path.join(homeDir, ".shipd");
|
|
707
|
+
fs2.ensureDirSync(configDir);
|
|
708
|
+
return configDir;
|
|
709
|
+
}
|
|
710
|
+
function getAuthPath() {
|
|
711
|
+
return path.join(getConfigDir(), "auth.json");
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// src/auth/token-storage.ts
|
|
715
|
+
function loadAuth() {
|
|
716
|
+
const authPath = getAuthPath();
|
|
717
|
+
try {
|
|
718
|
+
if (!fs3.existsSync(authPath)) {
|
|
719
|
+
return null;
|
|
720
|
+
}
|
|
721
|
+
const data = fs3.readJsonSync(authPath);
|
|
722
|
+
return data;
|
|
723
|
+
} catch (error) {
|
|
724
|
+
console.error("Failed to load auth data:", error);
|
|
725
|
+
return null;
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
function saveAuth(authData) {
|
|
729
|
+
const authPath = getAuthPath();
|
|
730
|
+
try {
|
|
731
|
+
fs3.writeJsonSync(authPath, authData, { spaces: 2 });
|
|
732
|
+
} catch (error) {
|
|
733
|
+
console.error("Failed to save auth data:", error);
|
|
734
|
+
throw new Error("Failed to save authentication data");
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
function clearAuth() {
|
|
738
|
+
const authPath = getAuthPath();
|
|
739
|
+
try {
|
|
740
|
+
if (fs3.existsSync(authPath)) {
|
|
741
|
+
fs3.removeSync(authPath);
|
|
742
|
+
}
|
|
743
|
+
} catch (error) {
|
|
744
|
+
console.error("Failed to clear auth data:", error);
|
|
745
|
+
throw new Error("Failed to clear authentication data");
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
function isAuthenticated() {
|
|
749
|
+
const auth = loadAuth();
|
|
750
|
+
if (!auth) {
|
|
751
|
+
return false;
|
|
752
|
+
}
|
|
753
|
+
const expiresAt = new Date(auth.expiresAt);
|
|
754
|
+
const now = /* @__PURE__ */ new Date();
|
|
755
|
+
if (now >= expiresAt) {
|
|
756
|
+
clearAuth();
|
|
757
|
+
return false;
|
|
758
|
+
}
|
|
759
|
+
return true;
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
// src/auth/token-validator.ts
|
|
763
|
+
var API_BASE_URL = process.env.SAAS_SCAFFOLD_API_URL || "https://www.shipd.dev";
|
|
764
|
+
async function validateToken(token) {
|
|
765
|
+
const authToken = token || loadAuth()?.token;
|
|
766
|
+
if (!authToken) {
|
|
767
|
+
return { valid: false, error: "No authentication token found" };
|
|
768
|
+
}
|
|
769
|
+
try {
|
|
770
|
+
const response = await fetch(`${API_BASE_URL}/api/cli/validate`, {
|
|
771
|
+
method: "POST",
|
|
772
|
+
headers: {
|
|
773
|
+
"Content-Type": "application/json"
|
|
774
|
+
},
|
|
775
|
+
body: JSON.stringify({ token: authToken })
|
|
776
|
+
});
|
|
777
|
+
if (!response.ok) {
|
|
778
|
+
clearAuth();
|
|
779
|
+
return { valid: false, error: "Invalid or expired token" };
|
|
780
|
+
}
|
|
781
|
+
const data = await response.json();
|
|
782
|
+
if (data.subscriptionStatus && data.subscriptionStatus !== "active") {
|
|
783
|
+
clearAuth();
|
|
784
|
+
return {
|
|
785
|
+
valid: false,
|
|
786
|
+
error: `Subscription ${data.subscriptionStatus}. Please renew at ${API_BASE_URL}/pricing`
|
|
787
|
+
};
|
|
788
|
+
}
|
|
789
|
+
return data;
|
|
790
|
+
} catch (error) {
|
|
791
|
+
console.error("Token validation failed:", error);
|
|
792
|
+
return {
|
|
793
|
+
valid: false,
|
|
794
|
+
error: "Unable to validate token. Please check your internet connection."
|
|
795
|
+
};
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
function isDevelopmentMode() {
|
|
799
|
+
return process.env.SAAS_SCAFFOLD_DEV === "true";
|
|
800
|
+
}
|
|
801
|
+
async function ensureAuthenticated(mockMode = false) {
|
|
802
|
+
if (mockMode || isDevelopmentMode()) {
|
|
803
|
+
console.log("\x1B[33m\u26A0\x1B[0m Running in mock mode (authentication bypassed)");
|
|
804
|
+
return;
|
|
805
|
+
}
|
|
806
|
+
const auth = loadAuth();
|
|
807
|
+
if (!auth) {
|
|
808
|
+
console.error("\n\u274C Not authenticated.");
|
|
809
|
+
console.error("\nPlease run: \x1B[36mshipd login\x1B[0m\n");
|
|
810
|
+
console.error("\x1B[2mTip: Use --mock flag to skip authentication during development\x1B[0m\n");
|
|
811
|
+
process.exit(1);
|
|
812
|
+
}
|
|
813
|
+
const expiresAt = new Date(auth.expiresAt);
|
|
814
|
+
const now = /* @__PURE__ */ new Date();
|
|
815
|
+
if (now >= expiresAt) {
|
|
816
|
+
clearAuth();
|
|
817
|
+
console.error("\n\u274C Session expired.");
|
|
818
|
+
console.error("\nPlease run: \x1B[36mshipd login\x1B[0m\n");
|
|
819
|
+
process.exit(1);
|
|
820
|
+
}
|
|
821
|
+
const validation = await validateToken(auth.token);
|
|
822
|
+
if (!validation.valid) {
|
|
823
|
+
console.error(`
|
|
824
|
+
\u274C Authentication failed: ${validation.error}`);
|
|
825
|
+
console.error("\nPlease run: \x1B[36mshipd login\x1B[0m\n");
|
|
826
|
+
process.exit(1);
|
|
827
|
+
}
|
|
828
|
+
console.log("\x1B[32m\u2713\x1B[0m Authenticated");
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
// src/commands/init.ts
|
|
832
|
+
async function initCommand(projectName, options) {
|
|
833
|
+
try {
|
|
834
|
+
await ensureAuthenticated(options?.mock || false);
|
|
835
|
+
console.log(SHIPD_ASCII);
|
|
836
|
+
logger.intro("Welcome aboard! Let's get shipping");
|
|
837
|
+
const config = await runPrompts(projectName);
|
|
838
|
+
await generateProject(config);
|
|
839
|
+
} catch (error) {
|
|
840
|
+
logger.error("Project generation failed");
|
|
841
|
+
if (error instanceof Error) {
|
|
842
|
+
console.error("\n" + error.message);
|
|
843
|
+
}
|
|
844
|
+
process.exit(1);
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
// src/commands/list.ts
|
|
849
|
+
import chalk2 from "chalk";
|
|
850
|
+
async function listCommand() {
|
|
851
|
+
logger.intro("Available Features");
|
|
852
|
+
console.log("\n" + chalk2.bold("Core (Always Included):"));
|
|
853
|
+
console.log(" \u2022 Next.js 15 with App Router");
|
|
854
|
+
console.log(" \u2022 Better Auth + Google OAuth");
|
|
855
|
+
console.log(" \u2022 Neon PostgreSQL + Drizzle ORM");
|
|
856
|
+
console.log(" \u2022 shadcn/ui components (24 components)");
|
|
857
|
+
console.log(" \u2022 Dark/Light mode");
|
|
858
|
+
console.log(" \u2022 User dashboard");
|
|
859
|
+
console.log("\n" + chalk2.bold("Optional Features:"));
|
|
860
|
+
console.log(" \u2022 Polar.sh payments & subscriptions");
|
|
861
|
+
console.log(" \u2022 OpenAI chat integration");
|
|
862
|
+
console.log(" \u2022 Cloudflare R2 file uploads");
|
|
863
|
+
console.log(" \u2022 PostHog analytics");
|
|
864
|
+
console.log("\n" + chalk2.bold("Usage:"));
|
|
865
|
+
console.log(" npx shipd init <project-name>");
|
|
866
|
+
console.log("");
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
// src/commands/login.ts
|
|
870
|
+
import { Command } from "commander";
|
|
871
|
+
|
|
872
|
+
// src/auth/auth-manager.ts
|
|
873
|
+
import { nanoid } from "nanoid";
|
|
874
|
+
import open from "open";
|
|
875
|
+
import ora2 from "ora";
|
|
876
|
+
var API_BASE_URL2 = process.env.SAAS_SCAFFOLD_API_URL || "https://www.shipd.dev";
|
|
877
|
+
var POLL_INTERVAL = 2e3;
|
|
878
|
+
var MAX_POLL_TIME = 3e5;
|
|
879
|
+
function generateDeviceCode() {
|
|
880
|
+
return nanoid(10).toUpperCase().match(/.{1,4}/g)?.join("-") || nanoid(10);
|
|
881
|
+
}
|
|
882
|
+
async function openAuthPage(deviceCode) {
|
|
883
|
+
const authUrl = `${API_BASE_URL2}/cli/auth?code=${deviceCode}`;
|
|
884
|
+
console.log("\n\x1B[36m\u2192\x1B[0m Opening browser for authentication...");
|
|
885
|
+
console.log(`\x1B[2m ${authUrl}\x1B[0m
|
|
886
|
+
`);
|
|
887
|
+
try {
|
|
888
|
+
await open(authUrl);
|
|
889
|
+
} catch (error) {
|
|
890
|
+
console.log("\n\x1B[33m\u26A0\x1B[0m Could not open browser automatically.");
|
|
891
|
+
console.log("Please open this URL manually:\n");
|
|
892
|
+
console.log(`\x1B[36m${authUrl}\x1B[0m
|
|
893
|
+
`);
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
async function pollForAuth(deviceCode) {
|
|
897
|
+
const startTime = Date.now();
|
|
898
|
+
while (Date.now() - startTime < MAX_POLL_TIME) {
|
|
899
|
+
try {
|
|
900
|
+
const response = await fetch(`${API_BASE_URL2}/api/cli/poll?code=${deviceCode}`, {
|
|
901
|
+
method: "GET"
|
|
902
|
+
});
|
|
903
|
+
if (!response.ok) {
|
|
904
|
+
if (response.status === 410) {
|
|
905
|
+
throw new Error("Authentication code expired. Please try again.");
|
|
906
|
+
}
|
|
907
|
+
if (response.status === 404) {
|
|
908
|
+
throw new Error("Invalid authentication code.");
|
|
909
|
+
}
|
|
910
|
+
await sleep(POLL_INTERVAL);
|
|
911
|
+
continue;
|
|
912
|
+
}
|
|
913
|
+
const data = await response.json();
|
|
914
|
+
if (data.status === "success" && data.token) {
|
|
915
|
+
return {
|
|
916
|
+
token: data.token,
|
|
917
|
+
userId: data.userId,
|
|
918
|
+
email: data.email,
|
|
919
|
+
expiresAt: data.expiresAt,
|
|
920
|
+
subscriptionStatus: data.subscriptionStatus || "active"
|
|
921
|
+
};
|
|
922
|
+
}
|
|
923
|
+
if (data.status === "expired") {
|
|
924
|
+
throw new Error("Authentication session expired. Please try again.");
|
|
925
|
+
}
|
|
926
|
+
await sleep(POLL_INTERVAL);
|
|
927
|
+
} catch (error) {
|
|
928
|
+
if (error instanceof Error && error.message.includes("expired")) {
|
|
929
|
+
throw error;
|
|
930
|
+
}
|
|
931
|
+
await sleep(POLL_INTERVAL);
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
throw new Error("Authentication timeout. Please try again.");
|
|
935
|
+
}
|
|
936
|
+
function sleep(ms) {
|
|
937
|
+
return new Promise((resolve5) => setTimeout(resolve5, ms));
|
|
938
|
+
}
|
|
939
|
+
async function login() {
|
|
940
|
+
console.log("\n\x1B[1m\u{1F680} Welcome to shipd!\x1B[0m\n");
|
|
941
|
+
const deviceCode = generateDeviceCode();
|
|
942
|
+
console.log(`\x1B[2mDevice code: ${deviceCode}\x1B[0m`);
|
|
943
|
+
await openAuthPage(deviceCode);
|
|
944
|
+
const spinner = ora2({
|
|
945
|
+
text: "Waiting for authentication...",
|
|
946
|
+
color: "cyan"
|
|
947
|
+
}).start();
|
|
948
|
+
try {
|
|
949
|
+
const authData = await pollForAuth(deviceCode);
|
|
950
|
+
spinner.succeed("Authentication successful!");
|
|
951
|
+
saveAuth(authData);
|
|
952
|
+
console.log("\n\x1B[32m\u2713\x1B[0m Logged in as \x1B[1m" + authData.email + "\x1B[0m");
|
|
953
|
+
console.log("\x1B[32m\u2713\x1B[0m Subscription status: \x1B[1m" + authData.subscriptionStatus + "\x1B[0m\n");
|
|
954
|
+
console.log("You can now use shipd CLI commands.\n");
|
|
955
|
+
} catch (error) {
|
|
956
|
+
spinner.fail("Authentication failed");
|
|
957
|
+
if (error instanceof Error) {
|
|
958
|
+
console.error("\n\x1B[31m\u2717\x1B[0m " + error.message + "\n");
|
|
959
|
+
} else {
|
|
960
|
+
console.error("\n\x1B[31m\u2717\x1B[0m Unknown error occurred\n");
|
|
961
|
+
}
|
|
962
|
+
process.exit(1);
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
// src/commands/login.ts
|
|
967
|
+
function createLoginCommand() {
|
|
968
|
+
const command = new Command("login");
|
|
969
|
+
command.description("Authenticate with shipd").action(async () => {
|
|
970
|
+
if (isAuthenticated()) {
|
|
971
|
+
const auth = loadAuth();
|
|
972
|
+
console.log("\n\x1B[32m\u2713\x1B[0m Already logged in as \x1B[1m" + auth?.email + "\x1B[0m");
|
|
973
|
+
console.log("\nTo logout, run: \x1B[36mshipd logout\x1B[0m\n");
|
|
974
|
+
return;
|
|
975
|
+
}
|
|
976
|
+
await login();
|
|
977
|
+
});
|
|
978
|
+
return command;
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
// src/commands/logout.ts
|
|
982
|
+
import { Command as Command2 } from "commander";
|
|
983
|
+
function createLogoutCommand() {
|
|
984
|
+
const command = new Command2("logout");
|
|
985
|
+
command.description("Logout from shipd").action(async () => {
|
|
986
|
+
if (!isAuthenticated()) {
|
|
987
|
+
console.log("\n\x1B[33m\u26A0\x1B[0m Not currently logged in.\n");
|
|
988
|
+
return;
|
|
989
|
+
}
|
|
990
|
+
const auth = loadAuth();
|
|
991
|
+
const email = auth?.email;
|
|
992
|
+
clearAuth();
|
|
993
|
+
console.log("\n\x1B[32m\u2713\x1B[0m Successfully logged out");
|
|
994
|
+
if (email) {
|
|
995
|
+
console.log(`\x1B[2m (${email})\x1B[0m`);
|
|
996
|
+
}
|
|
997
|
+
console.log("\nTo login again, run: \x1B[36mshipd login\x1B[0m\n");
|
|
998
|
+
});
|
|
999
|
+
return command;
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
// src/commands/append.ts
|
|
1003
|
+
import inquirer2 from "inquirer";
|
|
1004
|
+
import { existsSync as existsSync5 } from "fs";
|
|
1005
|
+
import { join as join7, resolve as resolve4 } from "path";
|
|
1006
|
+
import ora3 from "ora";
|
|
1007
|
+
import fs4 from "fs-extra";
|
|
1008
|
+
async function detectProject(cwd) {
|
|
1009
|
+
const pkgPath = join7(cwd, "package.json");
|
|
1010
|
+
if (!existsSync5(pkgPath)) {
|
|
1011
|
+
throw new Error("No package.json found. Make sure you're in a Next.js project directory.");
|
|
1012
|
+
}
|
|
1013
|
+
const pkg = await fs4.readJson(pkgPath);
|
|
1014
|
+
const isNextJs = !!(pkg.dependencies?.next || pkg.devDependencies?.next);
|
|
1015
|
+
if (!isNextJs) {
|
|
1016
|
+
throw new Error("This doesn't appear to be a Next.js project. shipd append requires a Next.js project.");
|
|
1017
|
+
}
|
|
1018
|
+
const hasAppRouter = existsSync5(join7(cwd, "app"));
|
|
1019
|
+
if (!hasAppRouter) {
|
|
1020
|
+
throw new Error("App Router not detected. shipd append requires Next.js App Router (app directory).");
|
|
1021
|
+
}
|
|
1022
|
+
let packageManager = "npm";
|
|
1023
|
+
if (existsSync5(join7(cwd, "pnpm-lock.yaml"))) {
|
|
1024
|
+
packageManager = "pnpm";
|
|
1025
|
+
} else if (existsSync5(join7(cwd, "yarn.lock"))) {
|
|
1026
|
+
packageManager = "yarn";
|
|
1027
|
+
}
|
|
1028
|
+
const installedFeatures = [];
|
|
1029
|
+
const shipdManifest = join7(cwd, ".shipd", "manifest.json");
|
|
1030
|
+
if (existsSync5(shipdManifest)) {
|
|
1031
|
+
const manifest = await fs4.readJson(shipdManifest);
|
|
1032
|
+
installedFeatures.push(...Object.keys(manifest.features || {}));
|
|
1033
|
+
}
|
|
1034
|
+
return {
|
|
1035
|
+
isNextJs,
|
|
1036
|
+
hasAppRouter,
|
|
1037
|
+
packageManager,
|
|
1038
|
+
installedFeatures
|
|
1039
|
+
};
|
|
1040
|
+
}
|
|
1041
|
+
async function appendDocsFeature(targetPath, dryRun = false) {
|
|
1042
|
+
const docsSourcePath = resolve4(__dirname, "../../../docs-template");
|
|
1043
|
+
if (!existsSync5(docsSourcePath)) {
|
|
1044
|
+
throw new Error("Docs template not found. This might be a development environment issue.");
|
|
1045
|
+
}
|
|
1046
|
+
const spinner = ora3("Copying docs feature (pages + components)...").start();
|
|
1047
|
+
try {
|
|
1048
|
+
if (dryRun) {
|
|
1049
|
+
spinner.info("DRY RUN: Would copy docs pages and components from template");
|
|
1050
|
+
return;
|
|
1051
|
+
}
|
|
1052
|
+
const docsTargetPath = join7(targetPath, "app", "docs");
|
|
1053
|
+
await fs4.ensureDir(docsTargetPath);
|
|
1054
|
+
await fs4.copy(docsSourcePath, docsTargetPath, {
|
|
1055
|
+
overwrite: false,
|
|
1056
|
+
errorOnExist: false,
|
|
1057
|
+
filter: (src) => {
|
|
1058
|
+
if (src.includes("/components/")) {
|
|
1059
|
+
return false;
|
|
1060
|
+
}
|
|
1061
|
+
const relativePath = src.replace(docsSourcePath, "");
|
|
1062
|
+
const targetFile = join7(docsTargetPath, relativePath);
|
|
1063
|
+
if (existsSync5(targetFile)) {
|
|
1064
|
+
logger.warn(`Skipping existing file: ${relativePath}`);
|
|
1065
|
+
return false;
|
|
1066
|
+
}
|
|
1067
|
+
return true;
|
|
1068
|
+
}
|
|
1069
|
+
});
|
|
1070
|
+
const componentsSourcePath = join7(docsSourcePath, "components", "docs");
|
|
1071
|
+
const componentsTargetPath = join7(targetPath, "components", "docs");
|
|
1072
|
+
if (existsSync5(componentsSourcePath)) {
|
|
1073
|
+
await fs4.ensureDir(componentsTargetPath);
|
|
1074
|
+
await fs4.copy(componentsSourcePath, componentsTargetPath, {
|
|
1075
|
+
overwrite: false,
|
|
1076
|
+
errorOnExist: false,
|
|
1077
|
+
filter: (src) => {
|
|
1078
|
+
const relativePath = src.replace(componentsSourcePath, "");
|
|
1079
|
+
const targetFile = join7(componentsTargetPath, relativePath);
|
|
1080
|
+
if (existsSync5(targetFile)) {
|
|
1081
|
+
logger.warn(`Skipping existing component: ${relativePath}`);
|
|
1082
|
+
return false;
|
|
1083
|
+
}
|
|
1084
|
+
return true;
|
|
1085
|
+
}
|
|
1086
|
+
});
|
|
1087
|
+
}
|
|
1088
|
+
const uiComponentsSourcePath = join7(docsSourcePath, "components", "ui");
|
|
1089
|
+
const uiComponentsTargetPath = join7(targetPath, "components", "ui");
|
|
1090
|
+
if (existsSync5(uiComponentsSourcePath)) {
|
|
1091
|
+
await fs4.ensureDir(uiComponentsTargetPath);
|
|
1092
|
+
await fs4.copy(uiComponentsSourcePath, uiComponentsTargetPath, {
|
|
1093
|
+
overwrite: false,
|
|
1094
|
+
errorOnExist: false,
|
|
1095
|
+
filter: (src) => {
|
|
1096
|
+
const relativePath = src.replace(uiComponentsSourcePath, "");
|
|
1097
|
+
const targetFile = join7(uiComponentsTargetPath, relativePath);
|
|
1098
|
+
if (existsSync5(targetFile)) {
|
|
1099
|
+
return false;
|
|
1100
|
+
}
|
|
1101
|
+
return true;
|
|
1102
|
+
}
|
|
1103
|
+
});
|
|
1104
|
+
}
|
|
1105
|
+
const utilsSourcePath = join7(docsSourcePath, "lib", "utils.ts");
|
|
1106
|
+
const utilsTargetPath = join7(targetPath, "lib", "utils.ts");
|
|
1107
|
+
if (existsSync5(utilsSourcePath) && !existsSync5(utilsTargetPath)) {
|
|
1108
|
+
await fs4.ensureDir(join7(targetPath, "lib"));
|
|
1109
|
+
await fs4.copy(utilsSourcePath, utilsTargetPath);
|
|
1110
|
+
} else if (existsSync5(utilsTargetPath)) {
|
|
1111
|
+
}
|
|
1112
|
+
spinner.succeed("Docs feature copied (pages + components)");
|
|
1113
|
+
const shipdDir = join7(targetPath, ".shipd");
|
|
1114
|
+
await fs4.ensureDir(shipdDir);
|
|
1115
|
+
const manifestPath = join7(shipdDir, "manifest.json");
|
|
1116
|
+
let manifest = { version: "1.0.0", features: {} };
|
|
1117
|
+
if (existsSync5(manifestPath)) {
|
|
1118
|
+
manifest = await fs4.readJson(manifestPath);
|
|
1119
|
+
}
|
|
1120
|
+
manifest.features.docs = {
|
|
1121
|
+
version: "1.0.0",
|
|
1122
|
+
installedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1123
|
+
files: [
|
|
1124
|
+
"app/docs/**/*",
|
|
1125
|
+
"components/docs/**/*",
|
|
1126
|
+
"components/ui/**/*",
|
|
1127
|
+
"lib/utils.ts"
|
|
1128
|
+
]
|
|
1129
|
+
};
|
|
1130
|
+
await fs4.writeJson(manifestPath, manifest, { spaces: 2 });
|
|
1131
|
+
const pkgPath = join7(targetPath, "package.json");
|
|
1132
|
+
if (existsSync5(pkgPath)) {
|
|
1133
|
+
const pkg = await fs4.readJson(pkgPath);
|
|
1134
|
+
let updated = false;
|
|
1135
|
+
const requiredDeps = {
|
|
1136
|
+
"@radix-ui/react-slot": "^1.1.0",
|
|
1137
|
+
"@radix-ui/react-dialog": "^1.1.0",
|
|
1138
|
+
"class-variance-authority": "^0.7.0",
|
|
1139
|
+
"clsx": "^2.1.1",
|
|
1140
|
+
"tailwind-merge": "^2.5.4",
|
|
1141
|
+
"lucide-react": "^0.469.0"
|
|
1142
|
+
};
|
|
1143
|
+
if (!pkg.dependencies) {
|
|
1144
|
+
pkg.dependencies = {};
|
|
1145
|
+
}
|
|
1146
|
+
for (const [dep, version] of Object.entries(requiredDeps)) {
|
|
1147
|
+
if (!pkg.dependencies[dep] && !pkg.devDependencies?.[dep]) {
|
|
1148
|
+
pkg.dependencies[dep] = version;
|
|
1149
|
+
updated = true;
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
if (updated) {
|
|
1153
|
+
await fs4.writeJson(pkgPath, pkg, { spaces: 2 });
|
|
1154
|
+
logger.info("Added missing dependencies to package.json");
|
|
1155
|
+
logger.info("Run npm install (or your package manager) to install them");
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
const featureDocs = join7(shipdDir, "features");
|
|
1159
|
+
await fs4.ensureDir(featureDocs);
|
|
1160
|
+
const docsReadme = `# Docs Feature - Integration Guide
|
|
1161
|
+
|
|
1162
|
+
**Installed:** ${(/* @__PURE__ */ new Date()).toISOString()}
|
|
1163
|
+
**Version:** 1.0.0
|
|
1164
|
+
|
|
1165
|
+
## Overview
|
|
1166
|
+
|
|
1167
|
+
This feature adds a complete, standalone documentation section to your Next.js application. All required components are included - no external dependencies needed.
|
|
1168
|
+
|
|
1169
|
+
## Files Added
|
|
1170
|
+
|
|
1171
|
+
### Pages
|
|
1172
|
+
- \`app/docs/page.tsx\` - Docs landing page
|
|
1173
|
+
- \`app/docs/layout.tsx\` - Docs layout with header and sidebar
|
|
1174
|
+
- \`app/docs/[slug]/page.tsx\` - Dynamic doc pages
|
|
1175
|
+
- \`app/docs/[slug]/[subslug]/page.tsx\` - Nested doc pages
|
|
1176
|
+
- \`app/docs/api/page.tsx\` - API documentation page
|
|
1177
|
+
- \`app/docs/documentation/page.tsx\` - Documentation page
|
|
1178
|
+
|
|
1179
|
+
### Components (Standalone Package)
|
|
1180
|
+
All required components are included:
|
|
1181
|
+
- \`components/docs/docs-header.tsx\` - Docs header with navigation
|
|
1182
|
+
- \`components/docs/docs-sidebar.tsx\` - Sidebar navigation
|
|
1183
|
+
- \`components/docs/docs-toc.tsx\` - Table of contents component
|
|
1184
|
+
- \`components/docs/docs-category-page.tsx\` - Category page layout
|
|
1185
|
+
- \`components/docs/docs-code-card.tsx\` - Code snippet display
|
|
1186
|
+
- \`components/docs/docs-nav.ts\` - Navigation data structure
|
|
1187
|
+
|
|
1188
|
+
## Dependencies
|
|
1189
|
+
|
|
1190
|
+
This feature is **completely standalone** and includes:
|
|
1191
|
+
- \u2705 All required UI components (\`components/ui/*\`)
|
|
1192
|
+
- \u2705 Utility functions (\`lib/utils.ts\`)
|
|
1193
|
+
- \u2705 All docs-specific components (\`components/docs/*\`)
|
|
1194
|
+
|
|
1195
|
+
**Smart Deduplication:** If UI components already exist in your project, they won't be overwritten.
|
|
1196
|
+
|
|
1197
|
+
**Package Dependencies:** The following will be added to your \`package.json\` if missing:
|
|
1198
|
+
- \`@radix-ui/react-slot\` - For Button component
|
|
1199
|
+
- \`@radix-ui/react-dialog\` - For Sheet component
|
|
1200
|
+
- \`class-variance-authority\` - For component variants
|
|
1201
|
+
- \`clsx\` & \`tailwind-merge\` - For className utilities
|
|
1202
|
+
- \`lucide-react\` - For icons
|
|
1203
|
+
|
|
1204
|
+
**Note:** Run \`npm install\` (or your package manager) after appending to install any new dependencies.
|
|
1205
|
+
|
|
1206
|
+
## Setup Instructions
|
|
1207
|
+
|
|
1208
|
+
1. **Access the docs**
|
|
1209
|
+
- Visit \`/docs\` in your application
|
|
1210
|
+
- The docs section is fully functional with navigation and layout
|
|
1211
|
+
|
|
1212
|
+
2. **Customize content**
|
|
1213
|
+
- Edit pages in \`app/docs/\` to customize content
|
|
1214
|
+
- Update navigation in \`components/docs/docs-nav.ts\`
|
|
1215
|
+
- Modify components in \`components/docs/\` to change styling/behavior
|
|
1216
|
+
|
|
1217
|
+
3. **Add your own documentation**
|
|
1218
|
+
- Create new pages in \`app/docs/\`
|
|
1219
|
+
- Follow the existing page structure
|
|
1220
|
+
- Add entries to \`docs-nav.ts\` to include in sidebar
|
|
1221
|
+
|
|
1222
|
+
## Next Steps
|
|
1223
|
+
|
|
1224
|
+
- Customize the docs landing page in \`app/docs/page.tsx\`
|
|
1225
|
+
- Update navigation structure in \`components/docs/docs-nav.ts\`
|
|
1226
|
+
- Add your own documentation pages
|
|
1227
|
+
- Customize styling in components if needed
|
|
1228
|
+
|
|
1229
|
+
## Feature Status
|
|
1230
|
+
|
|
1231
|
+
\u2705 **Standalone Package** - All components included
|
|
1232
|
+
\u2705 **No Missing Dependencies** - Everything needed is bundled
|
|
1233
|
+
\u2705 **Ready to Use** - Works immediately after append
|
|
1234
|
+
`;
|
|
1235
|
+
await fs4.writeFile(join7(featureDocs, "docs.md"), docsReadme);
|
|
1236
|
+
} catch (error) {
|
|
1237
|
+
spinner.fail("Failed to copy docs pages");
|
|
1238
|
+
throw error;
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
async function appendCommand(options) {
|
|
1242
|
+
try {
|
|
1243
|
+
const cwd = process.cwd();
|
|
1244
|
+
logger.intro("Adding Shipd features to your project");
|
|
1245
|
+
const detectSpinner = ora3("Detecting project...").start();
|
|
1246
|
+
const projectInfo = await detectProject(cwd);
|
|
1247
|
+
detectSpinner.succeed("Project validated");
|
|
1248
|
+
logger.info(`\u2713 Next.js App Router project detected`);
|
|
1249
|
+
logger.info(`\u2713 Package manager: ${projectInfo.packageManager}`);
|
|
1250
|
+
if (projectInfo.installedFeatures.length > 0) {
|
|
1251
|
+
logger.info(`\u2713 Installed features: ${projectInfo.installedFeatures.join(", ")}`);
|
|
1252
|
+
}
|
|
1253
|
+
const availableFeatures = ["docs"];
|
|
1254
|
+
const alreadyInstalled = projectInfo.installedFeatures.filter((f) => availableFeatures.includes(f));
|
|
1255
|
+
if (alreadyInstalled.includes("docs")) {
|
|
1256
|
+
logger.warn("Docs feature is already installed!");
|
|
1257
|
+
return;
|
|
1258
|
+
}
|
|
1259
|
+
let selectedFeatures;
|
|
1260
|
+
if (options.features) {
|
|
1261
|
+
selectedFeatures = options.features.split(",").map((f) => f.trim());
|
|
1262
|
+
} else {
|
|
1263
|
+
const answers = await inquirer2.prompt([
|
|
1264
|
+
{
|
|
1265
|
+
type: "checkbox",
|
|
1266
|
+
name: "features",
|
|
1267
|
+
message: "Select features to add:",
|
|
1268
|
+
choices: [
|
|
1269
|
+
{ name: "Documentation Pages", value: "docs", checked: true }
|
|
1270
|
+
]
|
|
1271
|
+
}
|
|
1272
|
+
]);
|
|
1273
|
+
selectedFeatures = answers.features;
|
|
1274
|
+
}
|
|
1275
|
+
if (selectedFeatures.length === 0) {
|
|
1276
|
+
logger.warn("No features selected. Exiting.");
|
|
1277
|
+
return;
|
|
1278
|
+
}
|
|
1279
|
+
console.log("\n\u{1F4CB} Changes Preview:");
|
|
1280
|
+
console.log(" Features to add:", selectedFeatures.join(", "));
|
|
1281
|
+
console.log(" Mode:", options.dryRun ? "DRY RUN" : "LIVE");
|
|
1282
|
+
if (!options.dryRun) {
|
|
1283
|
+
const confirm = await inquirer2.prompt([
|
|
1284
|
+
{
|
|
1285
|
+
type: "confirm",
|
|
1286
|
+
name: "proceed",
|
|
1287
|
+
message: "Proceed with installation?",
|
|
1288
|
+
default: true
|
|
1289
|
+
}
|
|
1290
|
+
]);
|
|
1291
|
+
if (!confirm.proceed) {
|
|
1292
|
+
logger.warn("Installation cancelled");
|
|
1293
|
+
return;
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
console.log();
|
|
1297
|
+
for (const feature of selectedFeatures) {
|
|
1298
|
+
if (feature === "docs") {
|
|
1299
|
+
await appendDocsFeature(cwd, options.dryRun);
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1302
|
+
console.log();
|
|
1303
|
+
logger.success("\u2728 Features added successfully!");
|
|
1304
|
+
console.log();
|
|
1305
|
+
console.log("\u{1F4D6} Next steps:");
|
|
1306
|
+
console.log(" 1. Visit /docs in your application");
|
|
1307
|
+
console.log(" 2. Customize the docs in app/docs/");
|
|
1308
|
+
console.log(" 3. Check .shipd/features/docs.md for details");
|
|
1309
|
+
console.log();
|
|
1310
|
+
} catch (error) {
|
|
1311
|
+
logger.error("Failed to append features");
|
|
1312
|
+
if (error instanceof Error) {
|
|
1313
|
+
console.error("\n" + error.message);
|
|
1314
|
+
}
|
|
1315
|
+
process.exit(1);
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
// src/index.ts
|
|
1320
|
+
var program = new Command3();
|
|
1321
|
+
program.name("shipd").description("Generate production-ready SaaS applications").version("0.1.0");
|
|
1322
|
+
program.addCommand(createLoginCommand());
|
|
1323
|
+
program.addCommand(createLogoutCommand());
|
|
1324
|
+
program.command("init [project-name]").description("Initialize a new SaaS project").option("--mock", "Skip authentication (for development/testing)").action((projectName, options) => initCommand(projectName, options));
|
|
1325
|
+
program.command("list").description("List available features").action(listCommand);
|
|
1326
|
+
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));
|
|
1327
|
+
async function interactiveMode() {
|
|
1328
|
+
console.log(SHIPD_ASCII);
|
|
1329
|
+
console.log("Welcome to Shipd! \u{1F680}\n");
|
|
1330
|
+
const { action } = await inquirer3.prompt([
|
|
1331
|
+
{
|
|
1332
|
+
type: "list",
|
|
1333
|
+
name: "action",
|
|
1334
|
+
message: "What would you like to do?",
|
|
1335
|
+
choices: [
|
|
1336
|
+
{ name: "\u{1F195} Initialize a new SaaS project", value: "init" },
|
|
1337
|
+
{ name: "\u2795 Add features to an existing project", value: "append" },
|
|
1338
|
+
{ name: "\u{1F4CB} List available features", value: "list" },
|
|
1339
|
+
{ name: "\u274C Exit", value: "exit" }
|
|
1340
|
+
]
|
|
1341
|
+
}
|
|
1342
|
+
]);
|
|
1343
|
+
if (action === "init") {
|
|
1344
|
+
await initCommand(void 0, {});
|
|
1345
|
+
} else if (action === "append") {
|
|
1346
|
+
await appendCommand({});
|
|
1347
|
+
} else if (action === "list") {
|
|
1348
|
+
await listCommand();
|
|
1349
|
+
} else {
|
|
1350
|
+
console.log("Goodbye! \u{1F44B}");
|
|
1351
|
+
process.exit(0);
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1354
|
+
var args = process.argv.slice(2);
|
|
1355
|
+
if (args.length === 0 || args.length === 1 && (args[0] === "--help" || args[0] === "-h")) {
|
|
1356
|
+
if (args.length === 0) {
|
|
1357
|
+
interactiveMode().catch((error) => {
|
|
1358
|
+
console.error("Error:", error.message);
|
|
1359
|
+
process.exit(1);
|
|
1360
|
+
});
|
|
1361
|
+
} else {
|
|
1362
|
+
program.parse();
|
|
1363
|
+
}
|
|
1364
|
+
} else {
|
|
1365
|
+
program.parse();
|
|
1366
|
+
}
|