stackpatch 1.2.0 → 1.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/stackpatch.js +1909 -0
- package/package.json +15 -9
- package/bin/stackpatch +0 -49
- package/bin/stackpatch.ts +0 -2443
|
@@ -0,0 +1,1909 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
var fs = require('fs');
|
|
5
|
+
var path = require('path');
|
|
6
|
+
var inquirer = require('inquirer');
|
|
7
|
+
var chalk = require('chalk');
|
|
8
|
+
var fse = require('fs-extra');
|
|
9
|
+
var child_process = require('child_process');
|
|
10
|
+
require('jimp');
|
|
11
|
+
|
|
12
|
+
var _documentCurrentScript = typeof document !== 'undefined' ? document.currentScript : null;
|
|
13
|
+
// This file is executed via bin/stackpatch wrapper
|
|
14
|
+
// ---------------- CONFIG ----------------
|
|
15
|
+
// Get directory path - Works with both Bun and Node.js
|
|
16
|
+
// Bun has import.meta.dir, Node.js doesn't - use fallback
|
|
17
|
+
const CLI_DIR = undefined || path.dirname(new URL((typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('stackpatch.js', document.baseURI).href))).pathname);
|
|
18
|
+
// Resolve boilerplate path - use local boilerplate inside CLI package
|
|
19
|
+
const BOILERPLATE_ROOT = path.resolve(CLI_DIR, "../boilerplate");
|
|
20
|
+
const PATCHES = {
|
|
21
|
+
auth: {
|
|
22
|
+
path: "auth",
|
|
23
|
+
dependencies: ["next-auth", "react-hot-toast"],
|
|
24
|
+
},
|
|
25
|
+
"auth-ui": {
|
|
26
|
+
path: "auth",
|
|
27
|
+
dependencies: ["next-auth", "react-hot-toast"],
|
|
28
|
+
},
|
|
29
|
+
// Example for future patches:
|
|
30
|
+
// stripe: { path: "stripe", dependencies: ["stripe"] },
|
|
31
|
+
// redux: { path: "redux", dependencies: ["@reduxjs/toolkit", "react-redux"] },
|
|
32
|
+
};
|
|
33
|
+
const MANIFEST_VERSION = "1.0.0";
|
|
34
|
+
// ---------------- Manifest & Tracking ----------------
|
|
35
|
+
// Get manifest path for a target directory
|
|
36
|
+
function getManifestPath(target) {
|
|
37
|
+
return path.join(target, ".stackpatch", "manifest.json");
|
|
38
|
+
}
|
|
39
|
+
// Read manifest if it exists
|
|
40
|
+
function readManifest(target) {
|
|
41
|
+
const manifestPath = getManifestPath(target);
|
|
42
|
+
if (!fs.existsSync(manifestPath)) {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
try {
|
|
46
|
+
const content = fs.readFileSync(manifestPath, "utf-8");
|
|
47
|
+
return JSON.parse(content);
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
// Write manifest
|
|
54
|
+
function writeManifest(target, manifest) {
|
|
55
|
+
const manifestDir = path.join(target, ".stackpatch");
|
|
56
|
+
const manifestPath = getManifestPath(target);
|
|
57
|
+
if (!fs.existsSync(manifestDir)) {
|
|
58
|
+
fs.mkdirSync(manifestDir, { recursive: true });
|
|
59
|
+
}
|
|
60
|
+
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), "utf-8");
|
|
61
|
+
}
|
|
62
|
+
// Backup a file before modifying it
|
|
63
|
+
function backupFile(filePath, target) {
|
|
64
|
+
if (!fs.existsSync(filePath)) {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
const backupDir = path.join(target, ".stackpatch", "backups");
|
|
68
|
+
if (!fs.existsSync(backupDir)) {
|
|
69
|
+
fs.mkdirSync(backupDir, { recursive: true });
|
|
70
|
+
}
|
|
71
|
+
const relativePath = path.relative(target, filePath);
|
|
72
|
+
const backupPath = path.join(backupDir, relativePath.replace(/\//g, "_").replace(/\\/g, "_"));
|
|
73
|
+
// Create directory structure in backup
|
|
74
|
+
const backupFileDir = path.dirname(backupPath);
|
|
75
|
+
if (!fs.existsSync(backupFileDir)) {
|
|
76
|
+
fs.mkdirSync(backupFileDir, { recursive: true });
|
|
77
|
+
}
|
|
78
|
+
fs.copyFileSync(filePath, backupPath);
|
|
79
|
+
return backupPath;
|
|
80
|
+
}
|
|
81
|
+
// Restore a file from backup
|
|
82
|
+
function restoreFile(backupPath, originalPath) {
|
|
83
|
+
if (!fs.existsSync(backupPath)) {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
const originalDir = path.dirname(originalPath);
|
|
87
|
+
if (!fs.existsSync(originalDir)) {
|
|
88
|
+
fs.mkdirSync(originalDir, { recursive: true });
|
|
89
|
+
}
|
|
90
|
+
fs.copyFileSync(backupPath, originalPath);
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
// ---------------- Progress & UI Helpers ----------------
|
|
94
|
+
// Show StackPatch logo (based on the actual SVG logo design)
|
|
95
|
+
function showLogo() {
|
|
96
|
+
console.log("\n");
|
|
97
|
+
// StackPatch logo ASCII art
|
|
98
|
+
const logo = [
|
|
99
|
+
chalk.magentaBright(" _________ __ __ __________ __ .__"),
|
|
100
|
+
chalk.magentaBright(" / _____// |______ ____ | | __ \\\\______ \\_____ _/ |_ ____ | |__"),
|
|
101
|
+
chalk.magentaBright(" \\_____ \\\\ __\\__ \\ _/ ___\\| |/ / | ___/\\__ \\\\ __\\/ ___\\| | \\"),
|
|
102
|
+
chalk.magentaBright(" / \\| | / __ \\\\ \\___| < | | / __ \\| | \\ \\___| Y \\"),
|
|
103
|
+
chalk.magentaBright("/_______ /|__| (____ /\\___ >__|_ \\ |____| (____ /__| \\___ >___| /"),
|
|
104
|
+
chalk.magentaBright(" \\/ \\/ \\/ \\/ \\/ \\/ \\/"),
|
|
105
|
+
"",
|
|
106
|
+
chalk.white(" Composable frontend features for modern React & Next.js"),
|
|
107
|
+
chalk.gray(" Add authentication, UI components, and more with zero configuration"),
|
|
108
|
+
"",
|
|
109
|
+
];
|
|
110
|
+
logo.forEach(line => console.log(line));
|
|
111
|
+
}
|
|
112
|
+
// Progress tracker with checkmarks
|
|
113
|
+
class ProgressTracker {
|
|
114
|
+
steps = [];
|
|
115
|
+
addStep(name) {
|
|
116
|
+
this.steps.push({ name, status: "pending" });
|
|
117
|
+
}
|
|
118
|
+
startStep(index) {
|
|
119
|
+
if (index >= 0 && index < this.steps.length) {
|
|
120
|
+
this.steps[index].status = "processing";
|
|
121
|
+
const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
122
|
+
let frameIndex = 0;
|
|
123
|
+
const step = this.steps[index];
|
|
124
|
+
const interval = setInterval(() => {
|
|
125
|
+
process.stdout.write(`\r${chalk.yellow(frames[frameIndex])} ${step.name}`);
|
|
126
|
+
frameIndex = (frameIndex + 1) % frames.length;
|
|
127
|
+
}, 100);
|
|
128
|
+
this.steps[index].interval = interval;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
completeStep(index) {
|
|
132
|
+
if (index >= 0 && index < this.steps.length) {
|
|
133
|
+
if (this.steps[index].interval) {
|
|
134
|
+
clearInterval(this.steps[index].interval);
|
|
135
|
+
this.steps[index].interval = undefined;
|
|
136
|
+
}
|
|
137
|
+
process.stdout.write(`\r${chalk.green("✓")} ${this.steps[index].name}\n`);
|
|
138
|
+
this.steps[index].status = "completed";
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
failStep(index) {
|
|
142
|
+
if (index >= 0 && index < this.steps.length) {
|
|
143
|
+
if (this.steps[index].interval) {
|
|
144
|
+
clearInterval(this.steps[index].interval);
|
|
145
|
+
this.steps[index].interval = undefined;
|
|
146
|
+
}
|
|
147
|
+
process.stdout.write(`\r${chalk.red("✗")} ${this.steps[index].name}\n`);
|
|
148
|
+
this.steps[index].status = "failed";
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
getSteps() {
|
|
152
|
+
return this.steps;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
// Helper function with spinner and checkmark
|
|
156
|
+
async function withSpinner(text, fn) {
|
|
157
|
+
const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
158
|
+
let frameIndex = 0;
|
|
159
|
+
const interval = setInterval(() => {
|
|
160
|
+
process.stdout.write(`\r${chalk.yellow(frames[frameIndex])} ${text}`);
|
|
161
|
+
frameIndex = (frameIndex + 1) % frames.length;
|
|
162
|
+
}, 100);
|
|
163
|
+
try {
|
|
164
|
+
const result = await fn();
|
|
165
|
+
clearInterval(interval);
|
|
166
|
+
process.stdout.write(`\r${chalk.green("✓")} ${text}\n`);
|
|
167
|
+
return result;
|
|
168
|
+
}
|
|
169
|
+
catch (error) {
|
|
170
|
+
clearInterval(interval);
|
|
171
|
+
process.stdout.write(`\r${chalk.red("✗")} ${text}\n`);
|
|
172
|
+
throw error;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
async function copyFiles(src, dest) {
|
|
176
|
+
const addedFiles = [];
|
|
177
|
+
if (!fs.existsSync(src)) {
|
|
178
|
+
console.log(chalk.red(`❌ Boilerplate folder not found: ${src}`));
|
|
179
|
+
return { success: false, addedFiles: [] };
|
|
180
|
+
}
|
|
181
|
+
await fse.ensureDir(dest);
|
|
182
|
+
// Detect app directory location in target
|
|
183
|
+
const appDir = detectAppDirectory(dest);
|
|
184
|
+
const appDirPath = path.join(dest, appDir);
|
|
185
|
+
const componentsDir = detectComponentsDirectory(dest);
|
|
186
|
+
const componentsDirPath = path.join(dest, componentsDir);
|
|
187
|
+
const conflicts = [];
|
|
188
|
+
// Check for conflicts before copying
|
|
189
|
+
const entries = fse.readdirSync(src, { withFileTypes: true });
|
|
190
|
+
for (const entry of entries) {
|
|
191
|
+
if (entry.name === "app") {
|
|
192
|
+
// For app directory, check conflicts in the detected app directory
|
|
193
|
+
if (fs.existsSync(appDirPath)) {
|
|
194
|
+
const appEntries = fse.readdirSync(path.join(src, "app"), { withFileTypes: true });
|
|
195
|
+
for (const appEntry of appEntries) {
|
|
196
|
+
const destAppPath = path.join(appDirPath, appEntry.name);
|
|
197
|
+
if (fs.existsSync(destAppPath)) {
|
|
198
|
+
conflicts.push(destAppPath);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
else if (entry.name === "components") {
|
|
204
|
+
// For components directory, check conflicts in the detected components directory
|
|
205
|
+
if (fs.existsSync(componentsDirPath)) {
|
|
206
|
+
const componentEntries = fse.readdirSync(path.join(src, "components"), { withFileTypes: true });
|
|
207
|
+
for (const componentEntry of componentEntries) {
|
|
208
|
+
const destComponentPath = path.join(componentsDirPath, componentEntry.name);
|
|
209
|
+
if (fs.existsSync(destComponentPath)) {
|
|
210
|
+
conflicts.push(destComponentPath);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
else {
|
|
216
|
+
// For other files/directories (middleware, etc.), check in root
|
|
217
|
+
const destPath = path.join(dest, entry.name);
|
|
218
|
+
if (fs.existsSync(destPath)) {
|
|
219
|
+
conflicts.push(destPath);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
if (conflicts.length) {
|
|
224
|
+
console.log(chalk.yellow("\n⚠️ The following files already exist:"));
|
|
225
|
+
conflicts.forEach((f) => console.log(` ${f}`));
|
|
226
|
+
const { overwrite } = await inquirer.prompt([
|
|
227
|
+
{
|
|
228
|
+
type: "confirm",
|
|
229
|
+
name: "overwrite",
|
|
230
|
+
message: "Do you want to overwrite them?",
|
|
231
|
+
default: false,
|
|
232
|
+
},
|
|
233
|
+
]);
|
|
234
|
+
if (!overwrite) {
|
|
235
|
+
console.log(chalk.red("\nAborted! No files were copied."));
|
|
236
|
+
return { success: false, addedFiles: [] };
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
// Track files from SOURCE (boilerplate) before copying
|
|
240
|
+
// This ensures we only track files that are actually from StackPatch
|
|
241
|
+
function trackSourceFiles(srcDir, baseDir, targetBase) {
|
|
242
|
+
if (!fs.existsSync(srcDir))
|
|
243
|
+
return;
|
|
244
|
+
const files = fs.readdirSync(srcDir, { withFileTypes: true });
|
|
245
|
+
for (const file of files) {
|
|
246
|
+
const srcFilePath = path.join(srcDir, file.name);
|
|
247
|
+
if (file.isDirectory()) {
|
|
248
|
+
trackSourceFiles(srcFilePath, baseDir, targetBase);
|
|
249
|
+
}
|
|
250
|
+
else {
|
|
251
|
+
const relativePath = path.relative(baseDir, srcFilePath);
|
|
252
|
+
const targetPath = targetBase
|
|
253
|
+
? path.join(targetBase, relativePath).replace(/\\/g, "/")
|
|
254
|
+
: relativePath.replace(/\\/g, "/");
|
|
255
|
+
addedFiles.push(targetPath);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
// Copy files with smart app directory handling
|
|
260
|
+
for (const entry of entries) {
|
|
261
|
+
const srcPath = path.join(src, entry.name);
|
|
262
|
+
if (entry.name === "app") {
|
|
263
|
+
// Track files from SOURCE boilerplate before copying
|
|
264
|
+
trackSourceFiles(srcPath, srcPath, appDir);
|
|
265
|
+
// Copy app directory contents to the detected app directory location
|
|
266
|
+
await fse.ensureDir(appDirPath);
|
|
267
|
+
await fse.copy(srcPath, appDirPath, { overwrite: true });
|
|
268
|
+
}
|
|
269
|
+
else if (entry.name === "components") {
|
|
270
|
+
// Track files from SOURCE boilerplate before copying
|
|
271
|
+
trackSourceFiles(srcPath, srcPath, componentsDir);
|
|
272
|
+
// Copy components directory to the detected components directory location
|
|
273
|
+
await fse.ensureDir(componentsDirPath);
|
|
274
|
+
await fse.copy(srcPath, componentsDirPath, { overwrite: true });
|
|
275
|
+
}
|
|
276
|
+
else {
|
|
277
|
+
// For root-level files/directories, track from source
|
|
278
|
+
const srcStat = fs.statSync(srcPath);
|
|
279
|
+
if (srcStat.isDirectory()) {
|
|
280
|
+
trackSourceFiles(srcPath, srcPath, "");
|
|
281
|
+
}
|
|
282
|
+
else {
|
|
283
|
+
addedFiles.push(entry.name);
|
|
284
|
+
}
|
|
285
|
+
// Copy other files/directories (middleware, etc.) to root
|
|
286
|
+
const destPath = path.join(dest, entry.name);
|
|
287
|
+
await fse.copy(srcPath, destPath, { overwrite: true });
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
// Update imports in copied files to use correct paths
|
|
291
|
+
updateImportsInFiles(dest);
|
|
292
|
+
return { success: true, addedFiles };
|
|
293
|
+
}
|
|
294
|
+
// Detect the app directory location (app/ or src/app/)
|
|
295
|
+
function detectAppDirectory(target) {
|
|
296
|
+
// Check for src/app first (more common in modern Next.js projects)
|
|
297
|
+
if (fs.existsSync(path.join(target, "src", "app"))) {
|
|
298
|
+
return "src/app";
|
|
299
|
+
}
|
|
300
|
+
// Check for app directory
|
|
301
|
+
if (fs.existsSync(path.join(target, "app"))) {
|
|
302
|
+
return "app";
|
|
303
|
+
}
|
|
304
|
+
// Check for src/pages (legacy)
|
|
305
|
+
if (fs.existsSync(path.join(target, "src", "pages"))) {
|
|
306
|
+
return "src/pages";
|
|
307
|
+
}
|
|
308
|
+
// Check for pages (legacy)
|
|
309
|
+
if (fs.existsSync(path.join(target, "pages"))) {
|
|
310
|
+
return "pages";
|
|
311
|
+
}
|
|
312
|
+
// Default to app if nothing found (will fail gracefully later)
|
|
313
|
+
return "app";
|
|
314
|
+
}
|
|
315
|
+
// Detect the components directory location (components/ or src/components/)
|
|
316
|
+
function detectComponentsDirectory(target) {
|
|
317
|
+
const appDir = detectAppDirectory(target);
|
|
318
|
+
// If app is in src/app, components should be in src/components
|
|
319
|
+
if (appDir.startsWith("src/")) {
|
|
320
|
+
// Check if src/components exists
|
|
321
|
+
if (fs.existsSync(path.join(target, "src", "components"))) {
|
|
322
|
+
return "src/components";
|
|
323
|
+
}
|
|
324
|
+
// Even if it doesn't exist yet, return src/components to match app structure
|
|
325
|
+
return "src/components";
|
|
326
|
+
}
|
|
327
|
+
// If app is in root, components should be in root
|
|
328
|
+
if (fs.existsSync(path.join(target, "components"))) {
|
|
329
|
+
return "components";
|
|
330
|
+
}
|
|
331
|
+
// Default to components
|
|
332
|
+
return "components";
|
|
333
|
+
}
|
|
334
|
+
// Detect path aliases from tsconfig.json
|
|
335
|
+
function detectPathAliases(target) {
|
|
336
|
+
const tsconfigPath = path.join(target, "tsconfig.json");
|
|
337
|
+
if (!fs.existsSync(tsconfigPath)) {
|
|
338
|
+
return null;
|
|
339
|
+
}
|
|
340
|
+
try {
|
|
341
|
+
const tsconfigContent = fs.readFileSync(tsconfigPath, "utf-8");
|
|
342
|
+
const tsconfig = JSON.parse(tsconfigContent);
|
|
343
|
+
const paths = tsconfig.compilerOptions?.paths;
|
|
344
|
+
if (!paths || typeof paths !== "object") {
|
|
345
|
+
return null;
|
|
346
|
+
}
|
|
347
|
+
// Look for common aliases like @/*, ~/*, etc.
|
|
348
|
+
for (const [alias, pathsArray] of Object.entries(paths)) {
|
|
349
|
+
if (Array.isArray(pathsArray) && pathsArray.length > 0) {
|
|
350
|
+
// Remove the /* from alias (e.g., "@/*" -> "@")
|
|
351
|
+
const cleanAlias = alias.replace(/\/\*$/, "");
|
|
352
|
+
// Get the first path and remove /* from it
|
|
353
|
+
const cleanPath = pathsArray[0].replace(/\/\*$/, "");
|
|
354
|
+
return { alias: cleanAlias, path: cleanPath };
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
catch (error) {
|
|
359
|
+
// If parsing fails, return null
|
|
360
|
+
return null;
|
|
361
|
+
}
|
|
362
|
+
return null;
|
|
363
|
+
}
|
|
364
|
+
// Generate import path for components
|
|
365
|
+
function generateComponentImportPath(target, componentName, fromFile) {
|
|
366
|
+
const pathAlias = detectPathAliases(target);
|
|
367
|
+
const componentsDir = detectComponentsDirectory(target);
|
|
368
|
+
// If we have a path alias, use it
|
|
369
|
+
if (pathAlias) {
|
|
370
|
+
// Check if the alias path matches components directory
|
|
371
|
+
const aliasPath = pathAlias.path.replace(/^\.\//, ""); // Remove leading ./
|
|
372
|
+
// If alias points to root and components is in root, use alias
|
|
373
|
+
if (aliasPath === "" && componentsDir === "components") {
|
|
374
|
+
return `${pathAlias.alias}/components/${componentName}`;
|
|
375
|
+
}
|
|
376
|
+
// If alias points to src and components is in src/components, use alias
|
|
377
|
+
if (aliasPath === "src" && componentsDir === "src/components") {
|
|
378
|
+
return `${pathAlias.alias}/components/${componentName}`;
|
|
379
|
+
}
|
|
380
|
+
// Try to match the alias path structure
|
|
381
|
+
if (componentsDir.startsWith(aliasPath)) {
|
|
382
|
+
const relativeFromAlias = componentsDir.replace(new RegExp(`^${aliasPath}/?`), "");
|
|
383
|
+
return `${pathAlias.alias}/${relativeFromAlias}/${componentName}`;
|
|
384
|
+
}
|
|
385
|
+
// If alias path is "./" (root), components should be accessible via alias
|
|
386
|
+
if (aliasPath === "" || aliasPath === ".") {
|
|
387
|
+
return `${pathAlias.alias}/components/${componentName}`;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
// Fallback: calculate relative path
|
|
391
|
+
// fromFile is the full path to the file we're importing into
|
|
392
|
+
const fromDir = path.dirname(fromFile);
|
|
393
|
+
const toComponents = path.join(target, componentsDir);
|
|
394
|
+
// Calculate relative path from the file's directory to components directory
|
|
395
|
+
const relativePath = path.relative(fromDir, toComponents).replace(/\\/g, "/");
|
|
396
|
+
const normalizedPath = relativePath.startsWith(".") ? relativePath : `./${relativePath}`;
|
|
397
|
+
return `${normalizedPath}/${componentName}`;
|
|
398
|
+
}
|
|
399
|
+
// Update imports in copied files to use correct paths
|
|
400
|
+
function updateImportsInFiles(target) {
|
|
401
|
+
const appDir = detectAppDirectory(target);
|
|
402
|
+
const appDirPath = path.join(target, appDir);
|
|
403
|
+
if (!fs.existsSync(appDirPath)) {
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
// Recursively find all .tsx and .ts files in the app directory
|
|
407
|
+
function findFiles(dir, fileList = []) {
|
|
408
|
+
const files = fs.readdirSync(dir);
|
|
409
|
+
for (const file of files) {
|
|
410
|
+
const filePath = path.join(dir, file);
|
|
411
|
+
const stat = fs.statSync(filePath);
|
|
412
|
+
if (stat.isDirectory()) {
|
|
413
|
+
findFiles(filePath, fileList);
|
|
414
|
+
}
|
|
415
|
+
else if (file.endsWith('.tsx') || file.endsWith('.ts')) {
|
|
416
|
+
fileList.push(filePath);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
return fileList;
|
|
420
|
+
}
|
|
421
|
+
const files = findFiles(appDirPath);
|
|
422
|
+
for (const filePath of files) {
|
|
423
|
+
try {
|
|
424
|
+
let content = fs.readFileSync(filePath, 'utf-8');
|
|
425
|
+
let updated = false;
|
|
426
|
+
// Match imports like: from "@/components/component-name"
|
|
427
|
+
const importRegex = /from\s+["']@\/components\/([^"']+)["']/g;
|
|
428
|
+
const matches = Array.from(content.matchAll(importRegex));
|
|
429
|
+
for (const match of matches) {
|
|
430
|
+
const componentName = match[1];
|
|
431
|
+
const oldImport = match[0];
|
|
432
|
+
const newImportPath = generateComponentImportPath(target, componentName, filePath);
|
|
433
|
+
const newImport = oldImport.replace(/@\/components\/[^"']+/, newImportPath);
|
|
434
|
+
content = content.replace(oldImport, newImport);
|
|
435
|
+
updated = true;
|
|
436
|
+
}
|
|
437
|
+
if (updated) {
|
|
438
|
+
fs.writeFileSync(filePath, content, 'utf-8');
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
catch (error) {
|
|
442
|
+
// Silently skip files that can't be processed
|
|
443
|
+
continue;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
// Check if dependency exists in package.json
|
|
448
|
+
function hasDependency(target, depName) {
|
|
449
|
+
const packageJsonPath = path.join(target, "package.json");
|
|
450
|
+
if (!fs.existsSync(packageJsonPath))
|
|
451
|
+
return false;
|
|
452
|
+
try {
|
|
453
|
+
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8"));
|
|
454
|
+
const deps = { ...packageJson.dependencies, ...packageJson.devDependencies };
|
|
455
|
+
return !!deps[depName];
|
|
456
|
+
}
|
|
457
|
+
catch {
|
|
458
|
+
return false;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
// Install dependencies (only missing ones)
|
|
462
|
+
function installDependencies(target, deps) {
|
|
463
|
+
if (deps.length === 0)
|
|
464
|
+
return;
|
|
465
|
+
const missingDeps = deps.filter(dep => !hasDependency(target, dep));
|
|
466
|
+
if (missingDeps.length === 0) {
|
|
467
|
+
return; // Already installed, spinner will show completion
|
|
468
|
+
}
|
|
469
|
+
const result = child_process.spawnSync("pnpm", ["add", ...missingDeps], {
|
|
470
|
+
cwd: target,
|
|
471
|
+
stdio: "pipe",
|
|
472
|
+
});
|
|
473
|
+
if (result.status !== 0) {
|
|
474
|
+
throw new Error(`Failed to install dependencies: ${missingDeps.join(", ")}`);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
// Remove dependencies from package.json
|
|
478
|
+
function removeDependencies(target, deps) {
|
|
479
|
+
if (deps.length === 0)
|
|
480
|
+
return true;
|
|
481
|
+
const packageJsonPath = path.join(target, "package.json");
|
|
482
|
+
if (!fs.existsSync(packageJsonPath)) {
|
|
483
|
+
return false;
|
|
484
|
+
}
|
|
485
|
+
try {
|
|
486
|
+
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8"));
|
|
487
|
+
let modified = false;
|
|
488
|
+
// Remove from dependencies
|
|
489
|
+
if (packageJson.dependencies) {
|
|
490
|
+
for (const dep of deps) {
|
|
491
|
+
if (packageJson.dependencies[dep]) {
|
|
492
|
+
delete packageJson.dependencies[dep];
|
|
493
|
+
modified = true;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
// Remove from devDependencies
|
|
498
|
+
if (packageJson.devDependencies) {
|
|
499
|
+
for (const dep of deps) {
|
|
500
|
+
if (packageJson.devDependencies[dep]) {
|
|
501
|
+
delete packageJson.devDependencies[dep];
|
|
502
|
+
modified = true;
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
if (modified) {
|
|
507
|
+
fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2) + "\n", "utf-8");
|
|
508
|
+
}
|
|
509
|
+
return modified;
|
|
510
|
+
}
|
|
511
|
+
catch {
|
|
512
|
+
return false;
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
// Get all parent directories of a file path
|
|
516
|
+
function getParentDirectories(filePath, rootPath) {
|
|
517
|
+
const dirs = [];
|
|
518
|
+
let current = path.dirname(filePath);
|
|
519
|
+
const root = path.resolve(rootPath);
|
|
520
|
+
while (current !== root && current !== path.dirname(current)) {
|
|
521
|
+
dirs.push(current);
|
|
522
|
+
current = path.dirname(current);
|
|
523
|
+
}
|
|
524
|
+
return dirs;
|
|
525
|
+
}
|
|
526
|
+
// Update layout.tsx to include Toaster
|
|
527
|
+
function updateLayoutForToaster(target) {
|
|
528
|
+
const appDir = detectAppDirectory(target);
|
|
529
|
+
const layoutPath = path.join(target, appDir, "layout.tsx");
|
|
530
|
+
if (!fs.existsSync(layoutPath)) {
|
|
531
|
+
return { success: false, modified: false, filePath: layoutPath };
|
|
532
|
+
}
|
|
533
|
+
try {
|
|
534
|
+
const originalContent = fs.readFileSync(layoutPath, "utf-8");
|
|
535
|
+
let layoutContent = originalContent;
|
|
536
|
+
// Check if already has Toaster
|
|
537
|
+
if (layoutContent.includes("Toaster")) {
|
|
538
|
+
console.log(chalk.green("✅ Layout already has Toaster!"));
|
|
539
|
+
return { success: true, modified: false, filePath: layoutPath };
|
|
540
|
+
}
|
|
541
|
+
// Generate the correct import path
|
|
542
|
+
const importPath = generateComponentImportPath(target, "toaster", layoutPath);
|
|
543
|
+
// Check if import already exists (check for various patterns)
|
|
544
|
+
const hasImport = layoutContent.includes("toaster") &&
|
|
545
|
+
(layoutContent.includes("from") || layoutContent.includes("import"));
|
|
546
|
+
if (!hasImport) {
|
|
547
|
+
// Find the last import statement
|
|
548
|
+
const lines = layoutContent.split("\n");
|
|
549
|
+
let lastImportIndex = -1;
|
|
550
|
+
for (let i = 0; i < lines.length; i++) {
|
|
551
|
+
const trimmed = lines[i].trim();
|
|
552
|
+
if (trimmed.startsWith("import ") && trimmed.endsWith(";")) {
|
|
553
|
+
lastImportIndex = i;
|
|
554
|
+
}
|
|
555
|
+
else if (trimmed && !trimmed.startsWith("//") && lastImportIndex >= 0) {
|
|
556
|
+
break;
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
if (lastImportIndex >= 0) {
|
|
560
|
+
lines.splice(lastImportIndex + 1, 0, `import { Toaster } from "${importPath}";`);
|
|
561
|
+
layoutContent = lines.join("\n");
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
// Add Toaster component before closing AuthSessionProvider
|
|
565
|
+
if (layoutContent.includes("</AuthSessionProvider>")) {
|
|
566
|
+
layoutContent = layoutContent.replace(/(<\/AuthSessionProvider>)/, '<Toaster />\n $1');
|
|
567
|
+
}
|
|
568
|
+
else if (layoutContent.includes("{children}")) {
|
|
569
|
+
// If AuthSessionProvider wraps children, add Toaster after children
|
|
570
|
+
layoutContent = layoutContent.replace(/(\{children\})/, '$1\n <Toaster />');
|
|
571
|
+
}
|
|
572
|
+
// Backup before modifying
|
|
573
|
+
backupFile(layoutPath, target);
|
|
574
|
+
fs.writeFileSync(layoutPath, layoutContent, "utf-8");
|
|
575
|
+
console.log(chalk.green("✅ Updated layout.tsx with Toaster!"));
|
|
576
|
+
const relativePath = path.relative(target, layoutPath).replace(/\\/g, "/");
|
|
577
|
+
return { success: true, modified: true, filePath: relativePath, originalContent };
|
|
578
|
+
}
|
|
579
|
+
catch (error) {
|
|
580
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
581
|
+
console.log(chalk.yellow(`⚠️ Failed to update layout with Toaster: ${errorMessage}`));
|
|
582
|
+
return { success: false, modified: false, filePath: layoutPath };
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
// Update layout.tsx to include AuthSessionProvider
|
|
586
|
+
function updateLayoutForAuth(target) {
|
|
587
|
+
const appDir = detectAppDirectory(target);
|
|
588
|
+
const layoutPath = path.join(target, appDir, "layout.tsx");
|
|
589
|
+
if (!fs.existsSync(layoutPath)) {
|
|
590
|
+
console.log(chalk.yellow("⚠️ layout.tsx not found. Skipping layout update."));
|
|
591
|
+
return { success: false, modified: false, filePath: layoutPath };
|
|
592
|
+
}
|
|
593
|
+
try {
|
|
594
|
+
const originalContent = fs.readFileSync(layoutPath, "utf-8");
|
|
595
|
+
let layoutContent = originalContent;
|
|
596
|
+
// Check if already has AuthSessionProvider
|
|
597
|
+
if (layoutContent.includes("AuthSessionProvider")) {
|
|
598
|
+
console.log(chalk.green("✅ Layout already has AuthSessionProvider!"));
|
|
599
|
+
return { success: true, modified: false, filePath: layoutPath };
|
|
600
|
+
}
|
|
601
|
+
// Generate the correct import path
|
|
602
|
+
const importPath = generateComponentImportPath(target, "session-provider", layoutPath);
|
|
603
|
+
// Check if import already exists (check for various patterns)
|
|
604
|
+
const hasImport = layoutContent.includes("session-provider") &&
|
|
605
|
+
(layoutContent.includes("from") || layoutContent.includes("import"));
|
|
606
|
+
if (!hasImport) {
|
|
607
|
+
// Find the last import statement (before the first non-import line)
|
|
608
|
+
const lines = layoutContent.split("\n");
|
|
609
|
+
let lastImportIndex = -1;
|
|
610
|
+
for (let i = 0; i < lines.length; i++) {
|
|
611
|
+
const trimmed = lines[i].trim();
|
|
612
|
+
if (trimmed.startsWith("import ") && trimmed.endsWith(";")) {
|
|
613
|
+
lastImportIndex = i;
|
|
614
|
+
}
|
|
615
|
+
else if (trimmed && !trimmed.startsWith("//") && lastImportIndex >= 0) {
|
|
616
|
+
break;
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
if (lastImportIndex >= 0) {
|
|
620
|
+
lines.splice(lastImportIndex + 1, 0, `import { AuthSessionProvider } from "${importPath}";`);
|
|
621
|
+
layoutContent = lines.join("\n");
|
|
622
|
+
}
|
|
623
|
+
else {
|
|
624
|
+
// No imports found, add after the first line
|
|
625
|
+
const firstNewline = layoutContent.indexOf("\n");
|
|
626
|
+
if (firstNewline > 0) {
|
|
627
|
+
layoutContent =
|
|
628
|
+
layoutContent.slice(0, firstNewline + 1) +
|
|
629
|
+
`import { AuthSessionProvider } from "${importPath}";\n` +
|
|
630
|
+
layoutContent.slice(firstNewline + 1);
|
|
631
|
+
}
|
|
632
|
+
else {
|
|
633
|
+
layoutContent = `import { AuthSessionProvider } from "${importPath}";\n` + layoutContent;
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
// Wrap children with AuthSessionProvider
|
|
638
|
+
// Look for {children} pattern in body tag
|
|
639
|
+
const childrenPattern = /(\s*)(\{children\})(\s*)/;
|
|
640
|
+
if (childrenPattern.test(layoutContent)) {
|
|
641
|
+
layoutContent = layoutContent.replace(childrenPattern, '$1<AuthSessionProvider>{children}</AuthSessionProvider>$3');
|
|
642
|
+
}
|
|
643
|
+
else {
|
|
644
|
+
// Try to find body tag and wrap its content
|
|
645
|
+
const bodyRegex = /(<body[^>]*>)([\s\S]*?)(<\/body>)/;
|
|
646
|
+
const bodyMatch = layoutContent.match(bodyRegex);
|
|
647
|
+
if (bodyMatch) {
|
|
648
|
+
const bodyContent = bodyMatch[2].trim();
|
|
649
|
+
if (bodyContent && !bodyContent.includes("AuthSessionProvider")) {
|
|
650
|
+
layoutContent = layoutContent.replace(bodyRegex, `$1\n <AuthSessionProvider>${bodyContent}</AuthSessionProvider>\n $3`);
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
// Backup before modifying
|
|
655
|
+
backupFile(layoutPath, target);
|
|
656
|
+
fs.writeFileSync(layoutPath, layoutContent, "utf-8");
|
|
657
|
+
console.log(chalk.green("✅ Updated layout.tsx with AuthSessionProvider!"));
|
|
658
|
+
const relativePath = path.relative(target, layoutPath).replace(/\\/g, "/");
|
|
659
|
+
return { success: true, modified: true, filePath: relativePath, originalContent };
|
|
660
|
+
}
|
|
661
|
+
catch (error) {
|
|
662
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
663
|
+
console.log(chalk.red(`❌ Failed to update layout.tsx: ${errorMessage}`));
|
|
664
|
+
return { success: false, modified: false, filePath: layoutPath };
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
// Ask user which OAuth providers to configure
|
|
668
|
+
async function askOAuthProviders() {
|
|
669
|
+
const { providers } = await inquirer.prompt([
|
|
670
|
+
{
|
|
671
|
+
type: "checkbox",
|
|
672
|
+
name: "providers",
|
|
673
|
+
message: "Which OAuth providers would you like to configure?",
|
|
674
|
+
choices: [
|
|
675
|
+
{ name: "Google", value: "google", checked: true },
|
|
676
|
+
{ name: "GitHub", value: "github", checked: true },
|
|
677
|
+
{ name: "Email/Password (Credentials)", value: "credentials", checked: true },
|
|
678
|
+
],
|
|
679
|
+
validate: (input) => {
|
|
680
|
+
if (input.length === 0) {
|
|
681
|
+
return "Please select at least one provider";
|
|
682
|
+
}
|
|
683
|
+
return true;
|
|
684
|
+
},
|
|
685
|
+
},
|
|
686
|
+
]);
|
|
687
|
+
return providers;
|
|
688
|
+
}
|
|
689
|
+
// Setup authentication with selected OAuth providers
|
|
690
|
+
async function setupAuth(target, selectedProviders) {
|
|
691
|
+
const tracker = new ProgressTracker();
|
|
692
|
+
tracker.addStep("Setting up authentication");
|
|
693
|
+
tracker.addStep("Generating environment files");
|
|
694
|
+
tracker.addStep("Configuring NextAuth with selected providers");
|
|
695
|
+
tracker.addStep("Updating UI components");
|
|
696
|
+
try {
|
|
697
|
+
const appDir = detectAppDirectory(target);
|
|
698
|
+
const nextAuthRoutePath = path.join(target, appDir, "api/auth/[...nextauth]/route.ts");
|
|
699
|
+
if (!fs.existsSync(nextAuthRoutePath)) {
|
|
700
|
+
console.log(chalk.yellow("⚠️ NextAuth route not found, skipping auth setup"));
|
|
701
|
+
return false;
|
|
702
|
+
}
|
|
703
|
+
tracker.startStep(0);
|
|
704
|
+
// Step 1: Generate .env.example file
|
|
705
|
+
tracker.startStep(1);
|
|
706
|
+
await generateEnvExample(target, selectedProviders);
|
|
707
|
+
tracker.completeStep(1);
|
|
708
|
+
// Step 2: Update NextAuth route with selected providers
|
|
709
|
+
tracker.startStep(2);
|
|
710
|
+
await updateNextAuthWithProviders(nextAuthRoutePath, selectedProviders);
|
|
711
|
+
tracker.completeStep(2);
|
|
712
|
+
// Step 3: Update UI components
|
|
713
|
+
tracker.startStep(3);
|
|
714
|
+
await updateAuthButtonWithProviders(target, selectedProviders);
|
|
715
|
+
tracker.completeStep(3);
|
|
716
|
+
tracker.completeStep(0);
|
|
717
|
+
// Show OAuth setup instructions
|
|
718
|
+
await showOAuthSetupInstructions(target, selectedProviders);
|
|
719
|
+
return true;
|
|
720
|
+
}
|
|
721
|
+
catch (error) {
|
|
722
|
+
tracker.failStep(0);
|
|
723
|
+
return false;
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
// Show OAuth setup instructions
|
|
727
|
+
async function showOAuthSetupInstructions(target, selectedProviders = ["google", "github", "credentials"]) {
|
|
728
|
+
const envLocalPath = path.join(target, ".env.local");
|
|
729
|
+
let hasGoogleCreds = false;
|
|
730
|
+
let hasGitHubCreds = false;
|
|
731
|
+
if (fs.existsSync(envLocalPath)) {
|
|
732
|
+
const envContent = fs.readFileSync(envLocalPath, "utf-8");
|
|
733
|
+
hasGoogleCreds = envContent.includes("GOOGLE_CLIENT_ID") &&
|
|
734
|
+
envContent.includes("GOOGLE_CLIENT_SECRET") &&
|
|
735
|
+
!envContent.includes("your_google_client_id_here");
|
|
736
|
+
hasGitHubCreds = envContent.includes("GITHUB_CLIENT_ID") &&
|
|
737
|
+
envContent.includes("GITHUB_CLIENT_SECRET") &&
|
|
738
|
+
!envContent.includes("your_github_client_id_here");
|
|
739
|
+
}
|
|
740
|
+
console.log(chalk.blue.bold("\n📋 OAuth Setup Instructions\n"));
|
|
741
|
+
const needsGoogle = selectedProviders.includes("google") && !hasGoogleCreds;
|
|
742
|
+
const needsGitHub = selectedProviders.includes("github") && !hasGitHubCreds;
|
|
743
|
+
if (needsGoogle || needsGitHub) {
|
|
744
|
+
console.log(chalk.yellow("⚠️ OAuth credentials not configured yet.\n"));
|
|
745
|
+
if (needsGoogle) {
|
|
746
|
+
console.log(chalk.cyan.bold("🔵 Google OAuth Setup:"));
|
|
747
|
+
console.log(chalk.white(" 1. Go to: ") + chalk.underline("https://console.cloud.google.com/"));
|
|
748
|
+
console.log(chalk.white(" 2. Create a new project or select existing one"));
|
|
749
|
+
console.log(chalk.white(" 3. Navigate to: APIs & Services > Credentials"));
|
|
750
|
+
console.log(chalk.white(" 4. Click: Create Credentials > OAuth client ID"));
|
|
751
|
+
console.log(chalk.white(" 5. Choose: Web application"));
|
|
752
|
+
console.log(chalk.white(" 6. Add Authorized redirect URI:"));
|
|
753
|
+
console.log(chalk.magentaBright(" → ") + chalk.bold("http://localhost:3000/api/auth/callback/google"));
|
|
754
|
+
console.log(chalk.white(" 7. Copy Client ID and Client Secret"));
|
|
755
|
+
console.log(chalk.white(" 8. Add them to your .env.local file:\n"));
|
|
756
|
+
console.log(chalk.gray(" GOOGLE_CLIENT_ID=your_client_id_here"));
|
|
757
|
+
console.log(chalk.gray(" GOOGLE_CLIENT_SECRET=your_client_secret_here\n"));
|
|
758
|
+
}
|
|
759
|
+
if (needsGitHub) {
|
|
760
|
+
console.log(chalk.cyan.bold("🐙 GitHub OAuth Setup:"));
|
|
761
|
+
console.log(chalk.white(" 1. Go to: ") + chalk.underline("https://github.com/settings/developers"));
|
|
762
|
+
console.log(chalk.white(" 2. Click: New OAuth App"));
|
|
763
|
+
console.log(chalk.white(" 3. Fill in the form:"));
|
|
764
|
+
console.log(chalk.white(" - Application name: Your app name"));
|
|
765
|
+
console.log(chalk.white(" - Homepage URL: http://localhost:3000"));
|
|
766
|
+
console.log(chalk.white(" - Authorization callback URL:"));
|
|
767
|
+
console.log(chalk.magentaBright(" → ") + chalk.bold("http://localhost:3000/api/auth/callback/github"));
|
|
768
|
+
console.log(chalk.white(" 4. Click: Register application"));
|
|
769
|
+
console.log(chalk.white(" 5. Copy Client ID"));
|
|
770
|
+
console.log(chalk.white(" 6. Generate a new Client Secret"));
|
|
771
|
+
console.log(chalk.white(" 7. Add them to your .env.local file:\n"));
|
|
772
|
+
console.log(chalk.gray(" GITHUB_CLIENT_ID=your_client_id_here"));
|
|
773
|
+
console.log(chalk.gray(" GITHUB_CLIENT_SECRET=your_client_secret_here\n"));
|
|
774
|
+
}
|
|
775
|
+
if (selectedProviders.includes("google") || selectedProviders.includes("github")) {
|
|
776
|
+
console.log(chalk.blue.bold("📝 Required Redirect URIs:"));
|
|
777
|
+
if (selectedProviders.includes("google")) {
|
|
778
|
+
console.log(chalk.white(" For Google: ") + chalk.bold("http://localhost:3000/api/auth/callback/google"));
|
|
779
|
+
}
|
|
780
|
+
if (selectedProviders.includes("github")) {
|
|
781
|
+
console.log(chalk.white(" For GitHub: ") + chalk.bold("http://localhost:3000/api/auth/callback/github"));
|
|
782
|
+
}
|
|
783
|
+
console.log(chalk.gray("\n For production, also add your production domain URLs\n"));
|
|
784
|
+
}
|
|
785
|
+
console.log(chalk.green("✅ Once configured, restart your dev server and test OAuth login!"));
|
|
786
|
+
}
|
|
787
|
+
else {
|
|
788
|
+
console.log(chalk.green("✅ OAuth credentials are configured!"));
|
|
789
|
+
console.log(chalk.white(" Make sure your redirect URIs are set in:"));
|
|
790
|
+
console.log(chalk.cyan(" - Google Cloud Console"));
|
|
791
|
+
console.log(chalk.cyan(" - GitHub OAuth App settings\n"));
|
|
792
|
+
}
|
|
793
|
+
console.log(chalk.blue.bold("\n📚 Documentation:"));
|
|
794
|
+
console.log(chalk.white(" - Complete guide: ") + chalk.cyan("README.md"));
|
|
795
|
+
console.log(chalk.white(" - Custom Auth: See comments in ") + chalk.cyan("app/api/auth/[...nextauth]/route.ts"));
|
|
796
|
+
console.log(chalk.white(" - Login/Signup: See comments in ") + chalk.cyan("app/auth/login/page.tsx") + " and " + chalk.cyan("app/auth/signup/page.tsx\n"));
|
|
797
|
+
}
|
|
798
|
+
// Generate .env.example file
|
|
799
|
+
async function generateEnvExample(target, providers = ["google", "github", "credentials"]) {
|
|
800
|
+
const envExamplePath = path.join(target, ".env.example");
|
|
801
|
+
const envLocalPath = path.join(target, ".env.local");
|
|
802
|
+
// Generate a random secret for NEXTAUTH_SECRET
|
|
803
|
+
const generateSecret = () => {
|
|
804
|
+
if (typeof globalThis.crypto !== 'undefined' && globalThis.crypto.getRandomValues) {
|
|
805
|
+
return Array.from(globalThis.crypto.getRandomValues(new Uint8Array(32)))
|
|
806
|
+
.map(b => b.toString(16).padStart(2, '0'))
|
|
807
|
+
.join('');
|
|
808
|
+
}
|
|
809
|
+
// Fallback: generate random bytes
|
|
810
|
+
const bytes = new Uint8Array(32);
|
|
811
|
+
for (let i = 0; i < 32; i++) {
|
|
812
|
+
bytes[i] = Math.floor(Math.random() * 256);
|
|
813
|
+
}
|
|
814
|
+
return Array.from(bytes)
|
|
815
|
+
.map(b => b.toString(16).padStart(2, '0'))
|
|
816
|
+
.join('');
|
|
817
|
+
};
|
|
818
|
+
let envContent = `# NextAuth Configuration
|
|
819
|
+
NEXTAUTH_URL=http://localhost:3000
|
|
820
|
+
NEXTAUTH_SECRET=${generateSecret()}
|
|
821
|
+
|
|
822
|
+
`;
|
|
823
|
+
if (providers.includes("google")) {
|
|
824
|
+
envContent += `# Google OAuth
|
|
825
|
+
GOOGLE_CLIENT_ID=your_google_client_id_here
|
|
826
|
+
GOOGLE_CLIENT_SECRET=your_google_client_secret_here
|
|
827
|
+
|
|
828
|
+
`;
|
|
829
|
+
}
|
|
830
|
+
if (providers.includes("github")) {
|
|
831
|
+
envContent += `# GitHub OAuth
|
|
832
|
+
GITHUB_CLIENT_ID=your_github_client_id_here
|
|
833
|
+
GITHUB_CLIENT_SECRET=your_github_client_secret_here
|
|
834
|
+
|
|
835
|
+
`;
|
|
836
|
+
}
|
|
837
|
+
// Write .env.example
|
|
838
|
+
fs.writeFileSync(envExamplePath, envContent, "utf-8");
|
|
839
|
+
console.log(chalk.green("✅ Created .env.example file"));
|
|
840
|
+
// Create .env.local if it doesn't exist
|
|
841
|
+
if (!fs.existsSync(envLocalPath)) {
|
|
842
|
+
fs.writeFileSync(envLocalPath, envContent.replace(/your_.*_here/g, ""), "utf-8");
|
|
843
|
+
console.log(chalk.green("✅ Created .env.local file (update with your credentials)"));
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
// Update NextAuth route with providers
|
|
847
|
+
async function updateNextAuthWithProviders(routePath, selectedProviders = ["google", "github", "credentials"]) {
|
|
848
|
+
// Build imports based on selected providers
|
|
849
|
+
const imports = ["import NextAuth from \"next-auth\";", "import type { NextAuthOptions } from \"next-auth\";"];
|
|
850
|
+
if (selectedProviders.includes("google")) {
|
|
851
|
+
imports.push("import GoogleProvider from \"next-auth/providers/google\";");
|
|
852
|
+
}
|
|
853
|
+
if (selectedProviders.includes("github")) {
|
|
854
|
+
imports.push("import GitHubProvider from \"next-auth/providers/github\";");
|
|
855
|
+
}
|
|
856
|
+
if (selectedProviders.includes("credentials")) {
|
|
857
|
+
imports.push("import CredentialsProvider from \"next-auth/providers/credentials\";");
|
|
858
|
+
}
|
|
859
|
+
// Build providers array
|
|
860
|
+
const providersArray = [];
|
|
861
|
+
if (selectedProviders.includes("credentials")) {
|
|
862
|
+
providersArray.push(` CredentialsProvider({
|
|
863
|
+
name: "Credentials",
|
|
864
|
+
credentials: {
|
|
865
|
+
email: { label: "Email", type: "email" },
|
|
866
|
+
password: { label: "Password", type: "password" },
|
|
867
|
+
},
|
|
868
|
+
async authorize(credentials) {
|
|
869
|
+
// TODO: Replace with your actual authentication logic
|
|
870
|
+
// This is a placeholder that accepts any email/password
|
|
871
|
+
// In production, you should:
|
|
872
|
+
// 1. Validate credentials against your database
|
|
873
|
+
// 2. Hash and compare passwords
|
|
874
|
+
// 3. Return user object or null
|
|
875
|
+
|
|
876
|
+
if (!credentials?.email || !credentials?.password) {
|
|
877
|
+
return null;
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
// Example: Check against hardcoded credentials (REMOVE IN PRODUCTION)
|
|
881
|
+
// Replace this with your database lookup
|
|
882
|
+
if (
|
|
883
|
+
credentials.email === "demo@example.com" &&
|
|
884
|
+
credentials.password === "demo123"
|
|
885
|
+
) {
|
|
886
|
+
return {
|
|
887
|
+
id: "1",
|
|
888
|
+
email: credentials.email,
|
|
889
|
+
name: "Demo User",
|
|
890
|
+
};
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
// If credentials don't match, return null
|
|
894
|
+
return null;
|
|
895
|
+
},
|
|
896
|
+
})`);
|
|
897
|
+
}
|
|
898
|
+
if (selectedProviders.includes("google")) {
|
|
899
|
+
providersArray.push(` GoogleProvider({
|
|
900
|
+
clientId: process.env.GOOGLE_CLIENT_ID!,
|
|
901
|
+
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
|
|
902
|
+
})`);
|
|
903
|
+
}
|
|
904
|
+
if (selectedProviders.includes("github")) {
|
|
905
|
+
providersArray.push(` GitHubProvider({
|
|
906
|
+
clientId: process.env.GITHUB_CLIENT_ID!,
|
|
907
|
+
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
|
|
908
|
+
})`);
|
|
909
|
+
}
|
|
910
|
+
const providerAuthContent = `${imports.join("\n")}
|
|
911
|
+
|
|
912
|
+
export const authOptions: NextAuthOptions = {
|
|
913
|
+
providers: [
|
|
914
|
+
${providersArray.join(",\n")}
|
|
915
|
+
],
|
|
916
|
+
pages: {
|
|
917
|
+
signIn: "/auth/login",
|
|
918
|
+
error: "/auth/error",
|
|
919
|
+
},
|
|
920
|
+
session: {
|
|
921
|
+
strategy: "jwt",
|
|
922
|
+
},
|
|
923
|
+
callbacks: {
|
|
924
|
+
async jwt({ token, user, account }) {
|
|
925
|
+
if (user) {
|
|
926
|
+
token.id = user.id;
|
|
927
|
+
token.email = user.email;
|
|
928
|
+
token.name = user.name;
|
|
929
|
+
}
|
|
930
|
+
if (account) {
|
|
931
|
+
token.accessToken = account.access_token;
|
|
932
|
+
token.provider = account.provider;
|
|
933
|
+
}
|
|
934
|
+
return token;
|
|
935
|
+
},
|
|
936
|
+
async session({ session, token }) {
|
|
937
|
+
if (session.user) {
|
|
938
|
+
session.user.id = token.id as string;
|
|
939
|
+
session.accessToken = token.accessToken as string;
|
|
940
|
+
session.provider = token.provider as string;
|
|
941
|
+
}
|
|
942
|
+
return session;
|
|
943
|
+
},
|
|
944
|
+
},
|
|
945
|
+
};
|
|
946
|
+
|
|
947
|
+
const handler = NextAuth(authOptions);
|
|
948
|
+
|
|
949
|
+
export { handler as GET, handler as POST };
|
|
950
|
+
`;
|
|
951
|
+
fs.writeFileSync(routePath, providerAuthContent, "utf-8");
|
|
952
|
+
}
|
|
953
|
+
// Copy protected route files
|
|
954
|
+
async function copyProtectedRouteFiles(target) {
|
|
955
|
+
const protectedRouteSrc = path.join(BOILERPLATE_ROOT, "auth/components/protected-route.tsx");
|
|
956
|
+
const middlewareSrc = path.join(BOILERPLATE_ROOT, "auth/middleware.ts");
|
|
957
|
+
const componentsDir = detectComponentsDirectory(target);
|
|
958
|
+
const componentsDirPath = path.join(target, componentsDir);
|
|
959
|
+
const protectedRouteDest = path.join(componentsDirPath, "protected-route.tsx");
|
|
960
|
+
const middlewareDest = path.join(target, "middleware.ts");
|
|
961
|
+
// Ensure components directory exists
|
|
962
|
+
if (!fs.existsSync(componentsDirPath)) {
|
|
963
|
+
fs.mkdirSync(componentsDirPath, { recursive: true });
|
|
964
|
+
}
|
|
965
|
+
// Copy protected route component
|
|
966
|
+
if (fs.existsSync(protectedRouteSrc)) {
|
|
967
|
+
fs.copyFileSync(protectedRouteSrc, protectedRouteDest);
|
|
968
|
+
}
|
|
969
|
+
// Copy middleware (only if it doesn't exist)
|
|
970
|
+
if (fs.existsSync(middlewareSrc) && !fs.existsSync(middlewareDest)) {
|
|
971
|
+
fs.copyFileSync(middlewareSrc, middlewareDest);
|
|
972
|
+
}
|
|
973
|
+
// Copy auth navbar component (demo/example - won't overwrite existing navbar)
|
|
974
|
+
const authNavbarSrc = path.join(BOILERPLATE_ROOT, "auth/components/auth-navbar.tsx");
|
|
975
|
+
const authNavbarDest = path.join(componentsDirPath, "auth-navbar.tsx");
|
|
976
|
+
if (fs.existsSync(authNavbarSrc)) {
|
|
977
|
+
// Only copy if it doesn't exist (won't overwrite)
|
|
978
|
+
if (!fs.existsSync(authNavbarDest)) {
|
|
979
|
+
fs.copyFileSync(authNavbarSrc, authNavbarDest);
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
// Copy example pages (only if they don't exist)
|
|
983
|
+
const appDir = detectAppDirectory(target);
|
|
984
|
+
const dashboardPageSrc = path.join(BOILERPLATE_ROOT, "auth/app/dashboard/page.tsx");
|
|
985
|
+
const landingPageSrc = path.join(BOILERPLATE_ROOT, "auth/app/page.tsx");
|
|
986
|
+
const dashboardPageDest = path.join(target, appDir, "dashboard/page.tsx");
|
|
987
|
+
const landingPageDest = path.join(target, appDir, "page.tsx");
|
|
988
|
+
// Create dashboard directory if needed
|
|
989
|
+
const dashboardDir = path.join(target, appDir, "dashboard");
|
|
990
|
+
if (!fs.existsSync(dashboardDir)) {
|
|
991
|
+
fs.mkdirSync(dashboardDir, { recursive: true });
|
|
992
|
+
}
|
|
993
|
+
// Copy dashboard page (only if it doesn't exist)
|
|
994
|
+
if (fs.existsSync(dashboardPageSrc) && !fs.existsSync(dashboardPageDest)) {
|
|
995
|
+
fs.copyFileSync(dashboardPageSrc, dashboardPageDest);
|
|
996
|
+
}
|
|
997
|
+
// Copy landing page (only if it doesn't exist or is default)
|
|
998
|
+
if (fs.existsSync(landingPageSrc)) {
|
|
999
|
+
// Check if current page is just a default Next.js page
|
|
1000
|
+
if (fs.existsSync(landingPageDest)) {
|
|
1001
|
+
const currentContent = fs.readFileSync(landingPageDest, "utf-8");
|
|
1002
|
+
// Only replace if it's the default Next.js page
|
|
1003
|
+
if (currentContent.includes("Get started by editing") || currentContent.length < 500) {
|
|
1004
|
+
fs.copyFileSync(landingPageSrc, landingPageDest);
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
else {
|
|
1008
|
+
fs.copyFileSync(landingPageSrc, landingPageDest);
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
// Update auth button with OAuth providers
|
|
1013
|
+
async function updateAuthButtonWithProviders(target, selectedProviders = ["google", "github", "credentials"]) {
|
|
1014
|
+
const componentsDir = detectComponentsDirectory(target);
|
|
1015
|
+
const authButtonPath = path.join(target, componentsDir, "auth-button.tsx");
|
|
1016
|
+
if (fs.existsSync(authButtonPath)) {
|
|
1017
|
+
// Build OAuth buttons based on selected providers
|
|
1018
|
+
const oauthButtons = [];
|
|
1019
|
+
if (selectedProviders.includes("google")) {
|
|
1020
|
+
oauthButtons.push(` <button
|
|
1021
|
+
onClick={() => signIn("google")}
|
|
1022
|
+
className="flex items-center gap-2 rounded-md bg-white px-4 py-2 text-sm font-medium text-zinc-900 ring-1 ring-inset ring-zinc-300 hover:bg-zinc-50 dark:bg-zinc-800 dark:text-zinc-50 dark:ring-zinc-700 dark:hover:bg-zinc-700"
|
|
1023
|
+
>
|
|
1024
|
+
<svg className="h-4 w-4" viewBox="0 0 24 24">
|
|
1025
|
+
<path
|
|
1026
|
+
fill="currentColor"
|
|
1027
|
+
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
|
1028
|
+
/>
|
|
1029
|
+
<path
|
|
1030
|
+
fill="currentColor"
|
|
1031
|
+
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
|
1032
|
+
/>
|
|
1033
|
+
<path
|
|
1034
|
+
fill="currentColor"
|
|
1035
|
+
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
|
1036
|
+
/>
|
|
1037
|
+
<path
|
|
1038
|
+
fill="currentColor"
|
|
1039
|
+
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
|
1040
|
+
/>
|
|
1041
|
+
</svg>
|
|
1042
|
+
Google
|
|
1043
|
+
</button>`);
|
|
1044
|
+
}
|
|
1045
|
+
if (selectedProviders.includes("github")) {
|
|
1046
|
+
oauthButtons.push(` <button
|
|
1047
|
+
onClick={() => signIn("github")}
|
|
1048
|
+
className="flex items-center gap-2 rounded-md bg-zinc-900 px-4 py-2 text-sm font-medium text-white hover:bg-zinc-700 dark:bg-zinc-50 dark:text-zinc-900 dark:hover:bg-zinc-200"
|
|
1049
|
+
>
|
|
1050
|
+
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 24 24">
|
|
1051
|
+
<path
|
|
1052
|
+
fillRule="evenodd"
|
|
1053
|
+
d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482C19.138 20.197 22 16.425 22 12.017 22 6.484 17.522 2 12 2z"
|
|
1054
|
+
clipRule="evenodd"
|
|
1055
|
+
/>
|
|
1056
|
+
</svg>
|
|
1057
|
+
GitHub
|
|
1058
|
+
</button>`);
|
|
1059
|
+
}
|
|
1060
|
+
const authButtonContent = `"use client";
|
|
1061
|
+
|
|
1062
|
+
import { signIn, signOut, useSession } from "next-auth/react";
|
|
1063
|
+
|
|
1064
|
+
export function AuthButton() {
|
|
1065
|
+
const { data: session, status } = useSession();
|
|
1066
|
+
|
|
1067
|
+
if (status === "loading") {
|
|
1068
|
+
return (
|
|
1069
|
+
<button
|
|
1070
|
+
disabled
|
|
1071
|
+
className="rounded-md bg-zinc-100 px-4 py-2 text-sm font-medium text-zinc-900 dark:bg-zinc-800 dark:text-zinc-50"
|
|
1072
|
+
>
|
|
1073
|
+
Loading...
|
|
1074
|
+
</button>
|
|
1075
|
+
);
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
if (session) {
|
|
1079
|
+
return (
|
|
1080
|
+
<div className="flex items-center gap-4">
|
|
1081
|
+
<span className="text-sm text-zinc-600 dark:text-zinc-400">
|
|
1082
|
+
{session.user?.email}
|
|
1083
|
+
</span>
|
|
1084
|
+
<button
|
|
1085
|
+
onClick={() => signOut()}
|
|
1086
|
+
className="rounded-md bg-zinc-900 px-4 py-2 text-sm font-medium text-white hover:bg-zinc-700 dark:bg-zinc-50 dark:text-zinc-900 dark:hover:bg-zinc-200"
|
|
1087
|
+
>
|
|
1088
|
+
Sign out
|
|
1089
|
+
</button>
|
|
1090
|
+
</div>
|
|
1091
|
+
);
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
return (
|
|
1095
|
+
<div className="flex items-center gap-2">
|
|
1096
|
+
${oauthButtons.join("\n")}
|
|
1097
|
+
</div>
|
|
1098
|
+
);
|
|
1099
|
+
}
|
|
1100
|
+
`;
|
|
1101
|
+
fs.writeFileSync(authButtonPath, authButtonContent, "utf-8");
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
// Show welcome screen with ASCII art
|
|
1105
|
+
async function showWelcome() {
|
|
1106
|
+
showLogo();
|
|
1107
|
+
}
|
|
1108
|
+
// Create a new project from template
|
|
1109
|
+
async function createProject(projectName, showWelcomeScreen = true, forceOverwrite = false) {
|
|
1110
|
+
const templatePath = path.join(BOILERPLATE_ROOT, "template");
|
|
1111
|
+
const targetPath = path.resolve(process.cwd(), projectName);
|
|
1112
|
+
if (fs.existsSync(targetPath)) {
|
|
1113
|
+
if (!forceOverwrite) {
|
|
1114
|
+
console.log(chalk.yellow(`⚠️ Directory "${projectName}" already exists!`));
|
|
1115
|
+
const { overwrite } = await inquirer.prompt([
|
|
1116
|
+
{
|
|
1117
|
+
type: "confirm",
|
|
1118
|
+
name: "overwrite",
|
|
1119
|
+
message: chalk.white("Do you want to overwrite it? (This will delete existing files)"),
|
|
1120
|
+
default: false,
|
|
1121
|
+
},
|
|
1122
|
+
]);
|
|
1123
|
+
if (!overwrite) {
|
|
1124
|
+
console.log(chalk.gray("Cancelled. Choose a different name."));
|
|
1125
|
+
process.exit(0);
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
// Remove existing directory if overwriting
|
|
1129
|
+
console.log(chalk.yellow(`Removing existing directory "${projectName}"...`));
|
|
1130
|
+
fs.rmSync(targetPath, { recursive: true, force: true });
|
|
1131
|
+
}
|
|
1132
|
+
if (showWelcomeScreen) {
|
|
1133
|
+
showLogo();
|
|
1134
|
+
}
|
|
1135
|
+
console.log(chalk.blue.bold(`🚀 Creating new StackPatch project: ${chalk.white(projectName)}\n`));
|
|
1136
|
+
const tracker = new ProgressTracker();
|
|
1137
|
+
tracker.addStep("Copying project template");
|
|
1138
|
+
tracker.addStep("Processing project files");
|
|
1139
|
+
tracker.addStep("Installing dependencies");
|
|
1140
|
+
// Step 1: Copy template
|
|
1141
|
+
tracker.startStep(0);
|
|
1142
|
+
await fse.copy(templatePath, targetPath);
|
|
1143
|
+
tracker.completeStep(0);
|
|
1144
|
+
// Step 2: Replace placeholders in files
|
|
1145
|
+
tracker.startStep(1);
|
|
1146
|
+
// Detect app directory for template processing
|
|
1147
|
+
const appDir = detectAppDirectory(targetPath);
|
|
1148
|
+
const filesToProcess = [
|
|
1149
|
+
"package.json",
|
|
1150
|
+
`${appDir}/layout.tsx`,
|
|
1151
|
+
`${appDir}/page.tsx`,
|
|
1152
|
+
"README.md",
|
|
1153
|
+
];
|
|
1154
|
+
for (const file of filesToProcess) {
|
|
1155
|
+
const filePath = path.join(targetPath, file);
|
|
1156
|
+
if (fs.existsSync(filePath)) {
|
|
1157
|
+
let content = fs.readFileSync(filePath, "utf-8");
|
|
1158
|
+
content = content.replace(/\{\{PROJECT_NAME\}\}/g, projectName);
|
|
1159
|
+
fs.writeFileSync(filePath, content, "utf-8");
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
tracker.completeStep(1);
|
|
1163
|
+
// Step 3: Install dependencies
|
|
1164
|
+
tracker.startStep(2);
|
|
1165
|
+
const installResult = child_process.spawnSync("pnpm", ["install"], {
|
|
1166
|
+
cwd: targetPath,
|
|
1167
|
+
stdio: "pipe",
|
|
1168
|
+
});
|
|
1169
|
+
if (installResult.status !== 0) {
|
|
1170
|
+
tracker.failStep(2);
|
|
1171
|
+
console.log(chalk.yellow("\n⚠️ Dependency installation had issues. You can run 'pnpm install' manually."));
|
|
1172
|
+
}
|
|
1173
|
+
else {
|
|
1174
|
+
tracker.completeStep(2);
|
|
1175
|
+
}
|
|
1176
|
+
console.log(chalk.green(`\n✅ Project "${projectName}" created successfully!`));
|
|
1177
|
+
// Automatically add auth-ui after creating the project
|
|
1178
|
+
console.log(chalk.blue.bold(`\n🔐 Adding authentication to your project...\n`));
|
|
1179
|
+
const authSrc = path.join(BOILERPLATE_ROOT, PATCHES["auth-ui"].path);
|
|
1180
|
+
const authCopyResult = await copyFiles(authSrc, targetPath);
|
|
1181
|
+
if (authCopyResult.success) {
|
|
1182
|
+
const addedFiles = authCopyResult.addedFiles;
|
|
1183
|
+
const modifiedFiles = [];
|
|
1184
|
+
// Install auth dependencies (only if missing)
|
|
1185
|
+
installDependencies(targetPath, PATCHES["auth-ui"].dependencies);
|
|
1186
|
+
// Ask which OAuth providers to configure
|
|
1187
|
+
const selectedProviders = await askOAuthProviders();
|
|
1188
|
+
// Setup authentication with selected providers
|
|
1189
|
+
const success = await setupAuth(targetPath, selectedProviders);
|
|
1190
|
+
if (success) {
|
|
1191
|
+
await withSpinner("Updating layout with AuthSessionProvider", () => {
|
|
1192
|
+
const result = updateLayoutForAuth(targetPath);
|
|
1193
|
+
if (result.modified && result.originalContent) {
|
|
1194
|
+
modifiedFiles.push({ path: result.filePath, originalContent: result.originalContent });
|
|
1195
|
+
}
|
|
1196
|
+
return true;
|
|
1197
|
+
});
|
|
1198
|
+
await withSpinner("Adding Toaster component", () => {
|
|
1199
|
+
const result = updateLayoutForToaster(targetPath);
|
|
1200
|
+
if (result.modified && result.originalContent) {
|
|
1201
|
+
modifiedFiles.push({ path: result.filePath, originalContent: result.originalContent });
|
|
1202
|
+
}
|
|
1203
|
+
return true;
|
|
1204
|
+
});
|
|
1205
|
+
await withSpinner("Setting up protected routes", () => {
|
|
1206
|
+
copyProtectedRouteFiles(targetPath);
|
|
1207
|
+
return true;
|
|
1208
|
+
});
|
|
1209
|
+
// Show OAuth setup instructions
|
|
1210
|
+
await showOAuthSetupInstructions(targetPath, selectedProviders);
|
|
1211
|
+
// Create manifest
|
|
1212
|
+
const manifest = {
|
|
1213
|
+
version: MANIFEST_VERSION,
|
|
1214
|
+
patchName: "auth-ui",
|
|
1215
|
+
target: targetPath,
|
|
1216
|
+
timestamp: new Date().toISOString(),
|
|
1217
|
+
files: {
|
|
1218
|
+
added: addedFiles,
|
|
1219
|
+
modified: modifiedFiles,
|
|
1220
|
+
backedUp: [],
|
|
1221
|
+
},
|
|
1222
|
+
dependencies: PATCHES["auth-ui"].dependencies,
|
|
1223
|
+
oauthProviders: selectedProviders,
|
|
1224
|
+
};
|
|
1225
|
+
writeManifest(targetPath, manifest);
|
|
1226
|
+
console.log(chalk.green("\n✅ Authentication added successfully!"));
|
|
1227
|
+
}
|
|
1228
|
+
else {
|
|
1229
|
+
console.log(chalk.yellow("\n⚠️ Authentication setup had some issues. You can run 'npx stackpatch add auth-ui' manually."));
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
else {
|
|
1233
|
+
console.log(chalk.yellow("\n⚠️ Could not add authentication. You can run 'npx stackpatch add auth-ui' manually."));
|
|
1234
|
+
}
|
|
1235
|
+
console.log(chalk.blue("\n📦 Next steps:"));
|
|
1236
|
+
console.log(chalk.white(` ${chalk.cyan("cd")} ${chalk.yellow(projectName)}`));
|
|
1237
|
+
console.log(chalk.white(` ${chalk.cyan("pnpm")} ${chalk.yellow("dev")}`));
|
|
1238
|
+
console.log(chalk.white(` Test authentication at: ${chalk.cyan("http://localhost:3000/auth/login")}`));
|
|
1239
|
+
console.log(chalk.gray("\n📚 See README.md for OAuth setup and protected routes\n"));
|
|
1240
|
+
}
|
|
1241
|
+
// ---------------- Main CLI ----------------
|
|
1242
|
+
async function main() {
|
|
1243
|
+
const args = process.argv.slice(2);
|
|
1244
|
+
const command = args[0];
|
|
1245
|
+
const projectName = args[1];
|
|
1246
|
+
const skipPrompts = args.includes("--yes") || args.includes("-y");
|
|
1247
|
+
// Show logo on startup
|
|
1248
|
+
showLogo();
|
|
1249
|
+
// Handle: bun create stackpatch@latest (no project name)
|
|
1250
|
+
// Show welcome and prompt for project name
|
|
1251
|
+
if (!command || command.startsWith("-")) {
|
|
1252
|
+
const { name } = await inquirer.prompt([
|
|
1253
|
+
{
|
|
1254
|
+
type: "input",
|
|
1255
|
+
name: "name",
|
|
1256
|
+
message: chalk.white("Enter your project name or path (relative to current directory)"),
|
|
1257
|
+
default: "my-stackpatch-app",
|
|
1258
|
+
validate: (input) => {
|
|
1259
|
+
if (!input.trim()) {
|
|
1260
|
+
return "Project name cannot be empty";
|
|
1261
|
+
}
|
|
1262
|
+
return true;
|
|
1263
|
+
},
|
|
1264
|
+
},
|
|
1265
|
+
]);
|
|
1266
|
+
await createProject(name.trim(), false, skipPrompts); // Don't show welcome again
|
|
1267
|
+
return;
|
|
1268
|
+
}
|
|
1269
|
+
// Handle: npx stackpatch revert
|
|
1270
|
+
if (command === "revert") {
|
|
1271
|
+
let target = process.cwd();
|
|
1272
|
+
// Auto-detect target directory
|
|
1273
|
+
const hasAppDir = fs.existsSync(path.join(target, "app")) || fs.existsSync(path.join(target, "src", "app"));
|
|
1274
|
+
const hasPagesDir = fs.existsSync(path.join(target, "pages")) || fs.existsSync(path.join(target, "src", "pages"));
|
|
1275
|
+
if (!hasAppDir && !hasPagesDir) {
|
|
1276
|
+
const parent = path.resolve(target, "..");
|
|
1277
|
+
if (fs.existsSync(path.join(parent, "app")) ||
|
|
1278
|
+
fs.existsSync(path.join(parent, "src", "app")) ||
|
|
1279
|
+
fs.existsSync(path.join(parent, "pages")) ||
|
|
1280
|
+
fs.existsSync(path.join(parent, "src", "pages"))) {
|
|
1281
|
+
target = parent;
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
const manifest = readManifest(target);
|
|
1285
|
+
if (!manifest) {
|
|
1286
|
+
console.log(chalk.red("❌ No StackPatch installation found to revert."));
|
|
1287
|
+
console.log(chalk.yellow(" Make sure you're in the correct directory where you ran 'stackpatch add'."));
|
|
1288
|
+
process.exit(1);
|
|
1289
|
+
}
|
|
1290
|
+
console.log(chalk.blue.bold("\n🔄 Reverting StackPatch installation\n"));
|
|
1291
|
+
console.log(chalk.white(` Patch: ${chalk.cyan(manifest.patchName)}`));
|
|
1292
|
+
console.log(chalk.white(` Installed: ${chalk.gray(new Date(manifest.timestamp).toLocaleString())}\n`));
|
|
1293
|
+
// Show what will be reverted
|
|
1294
|
+
console.log(chalk.white(" Files to remove: ") + chalk.cyan(`${manifest.files.added.length}`));
|
|
1295
|
+
console.log(chalk.white(" Files to restore: ") + chalk.cyan(`${manifest.files.modified.length}`));
|
|
1296
|
+
if (manifest.dependencies.length > 0) {
|
|
1297
|
+
console.log(chalk.white(" Dependencies to remove: ") + chalk.cyan(`${manifest.dependencies.join(", ")}`));
|
|
1298
|
+
}
|
|
1299
|
+
console.log();
|
|
1300
|
+
const { confirm } = await inquirer.prompt([
|
|
1301
|
+
{
|
|
1302
|
+
type: "confirm",
|
|
1303
|
+
name: "confirm",
|
|
1304
|
+
message: "Are you sure you want to revert this installation? This will remove all added files, restore modified files, and remove dependencies.",
|
|
1305
|
+
default: false,
|
|
1306
|
+
},
|
|
1307
|
+
]);
|
|
1308
|
+
if (!confirm) {
|
|
1309
|
+
console.log(chalk.yellow("\n← Revert cancelled"));
|
|
1310
|
+
return;
|
|
1311
|
+
}
|
|
1312
|
+
console.log(chalk.blue("\n🔄 Starting revert process...\n"));
|
|
1313
|
+
let removedCount = 0;
|
|
1314
|
+
let restoredCount = 0;
|
|
1315
|
+
let failedRemovals = [];
|
|
1316
|
+
let failedRestorations = [];
|
|
1317
|
+
const directoriesToCheck = new Set();
|
|
1318
|
+
// Step 1: Get list of valid StackPatch files from boilerplate
|
|
1319
|
+
// This ensures we only remove files that are actually from StackPatch
|
|
1320
|
+
const boilerplatePath = path.join(BOILERPLATE_ROOT, manifest.patchName === "auth-ui" ? "auth" : manifest.patchName);
|
|
1321
|
+
const validStackPatchFiles = new Set();
|
|
1322
|
+
function collectBoilerplateFiles(srcDir, baseDir, targetBase) {
|
|
1323
|
+
if (!fs.existsSync(srcDir))
|
|
1324
|
+
return;
|
|
1325
|
+
const files = fs.readdirSync(srcDir, { withFileTypes: true });
|
|
1326
|
+
for (const file of files) {
|
|
1327
|
+
const srcFilePath = path.join(srcDir, file.name);
|
|
1328
|
+
if (file.isDirectory()) {
|
|
1329
|
+
collectBoilerplateFiles(srcFilePath, baseDir, targetBase);
|
|
1330
|
+
}
|
|
1331
|
+
else {
|
|
1332
|
+
const relativePath = path.relative(baseDir, srcFilePath);
|
|
1333
|
+
const targetPath = targetBase
|
|
1334
|
+
? path.join(targetBase, relativePath).replace(/\\/g, "/")
|
|
1335
|
+
: relativePath.replace(/\\/g, "/");
|
|
1336
|
+
validStackPatchFiles.add(targetPath);
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
}
|
|
1340
|
+
// Collect files from boilerplate app directory
|
|
1341
|
+
const appDir = detectAppDirectory(target);
|
|
1342
|
+
const componentsDir = detectComponentsDirectory(target);
|
|
1343
|
+
const boilerplateAppPath = path.join(boilerplatePath, "app");
|
|
1344
|
+
const boilerplateComponentsPath = path.join(boilerplatePath, "components");
|
|
1345
|
+
if (fs.existsSync(boilerplateAppPath)) {
|
|
1346
|
+
collectBoilerplateFiles(boilerplateAppPath, boilerplateAppPath, appDir);
|
|
1347
|
+
}
|
|
1348
|
+
if (fs.existsSync(boilerplateComponentsPath)) {
|
|
1349
|
+
collectBoilerplateFiles(boilerplateComponentsPath, boilerplateComponentsPath, componentsDir);
|
|
1350
|
+
}
|
|
1351
|
+
// Collect root-level files
|
|
1352
|
+
if (fs.existsSync(boilerplatePath)) {
|
|
1353
|
+
const entries = fs.readdirSync(boilerplatePath, { withFileTypes: true });
|
|
1354
|
+
for (const entry of entries) {
|
|
1355
|
+
if (entry.name !== "app" && entry.name !== "components") {
|
|
1356
|
+
const srcPath = path.join(boilerplatePath, entry.name);
|
|
1357
|
+
if (entry.isDirectory()) {
|
|
1358
|
+
collectBoilerplateFiles(srcPath, srcPath, "");
|
|
1359
|
+
}
|
|
1360
|
+
else {
|
|
1361
|
+
validStackPatchFiles.add(entry.name);
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
// Step 1: Remove added files (only if they're actually from StackPatch boilerplate)
|
|
1367
|
+
console.log(chalk.white("📁 Removing added files..."));
|
|
1368
|
+
for (const filePath of manifest.files.added) {
|
|
1369
|
+
// Only remove if this file is actually in the boilerplate
|
|
1370
|
+
if (!validStackPatchFiles.has(filePath)) {
|
|
1371
|
+
console.log(chalk.gray(` ⊘ Skipped (not in boilerplate): ${filePath}`));
|
|
1372
|
+
continue;
|
|
1373
|
+
}
|
|
1374
|
+
const fullPath = path.join(target, filePath);
|
|
1375
|
+
if (fs.existsSync(fullPath)) {
|
|
1376
|
+
try {
|
|
1377
|
+
fs.unlinkSync(fullPath);
|
|
1378
|
+
console.log(chalk.green(` ✓ Removed: ${filePath}`));
|
|
1379
|
+
removedCount++;
|
|
1380
|
+
// Track parent directories for cleanup
|
|
1381
|
+
const parentDirs = getParentDirectories(fullPath, target);
|
|
1382
|
+
parentDirs.forEach(dir => directoriesToCheck.add(dir));
|
|
1383
|
+
}
|
|
1384
|
+
catch (error) {
|
|
1385
|
+
console.log(chalk.yellow(` ⚠ Could not remove: ${filePath}`));
|
|
1386
|
+
failedRemovals.push(filePath);
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
else {
|
|
1390
|
+
console.log(chalk.gray(` ⊘ Already removed: ${filePath}`));
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
// Step 2: Remove .env.local and .env.example if they were created by StackPatch
|
|
1394
|
+
console.log(chalk.white("\n🔐 Removing environment files..."));
|
|
1395
|
+
const envFilesToRemove = manifest.files.envFiles || [];
|
|
1396
|
+
// Fallback: check for common env files if not tracked in manifest (for older manifests)
|
|
1397
|
+
if (envFilesToRemove.length === 0) {
|
|
1398
|
+
const commonEnvFiles = [".env.local", ".env.example"];
|
|
1399
|
+
for (const envFile of commonEnvFiles) {
|
|
1400
|
+
const envPath = path.join(target, envFile);
|
|
1401
|
+
if (fs.existsSync(envPath)) {
|
|
1402
|
+
try {
|
|
1403
|
+
// Check if this file was created by StackPatch (contains NEXTAUTH_SECRET)
|
|
1404
|
+
const content = fs.readFileSync(envPath, "utf-8");
|
|
1405
|
+
if (content.includes("NEXTAUTH_SECRET") || content.includes("NEXTAUTH_URL")) {
|
|
1406
|
+
envFilesToRemove.push(envFile);
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1409
|
+
catch {
|
|
1410
|
+
// Ignore errors
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
1413
|
+
}
|
|
1414
|
+
}
|
|
1415
|
+
for (const envFile of envFilesToRemove) {
|
|
1416
|
+
const envPath = path.join(target, envFile);
|
|
1417
|
+
if (fs.existsSync(envPath)) {
|
|
1418
|
+
try {
|
|
1419
|
+
fs.unlinkSync(envPath);
|
|
1420
|
+
console.log(chalk.green(` ✓ Removed: ${envFile}`));
|
|
1421
|
+
removedCount++;
|
|
1422
|
+
}
|
|
1423
|
+
catch (error) {
|
|
1424
|
+
console.log(chalk.yellow(` ⚠ Could not remove: ${envFile}`));
|
|
1425
|
+
failedRemovals.push(envFile);
|
|
1426
|
+
}
|
|
1427
|
+
}
|
|
1428
|
+
else {
|
|
1429
|
+
console.log(chalk.gray(` ⊘ Already removed: ${envFile}`));
|
|
1430
|
+
}
|
|
1431
|
+
}
|
|
1432
|
+
// Step 3: Restore modified files from originalContent in manifest
|
|
1433
|
+
// This is more reliable than backups since it contains the true original content
|
|
1434
|
+
console.log(chalk.white("\n📝 Restoring modified files..."));
|
|
1435
|
+
for (const modified of manifest.files.modified) {
|
|
1436
|
+
const originalPath = path.join(target, modified.path);
|
|
1437
|
+
if (modified.originalContent !== undefined) {
|
|
1438
|
+
try {
|
|
1439
|
+
// Restore from originalContent in manifest (most reliable)
|
|
1440
|
+
const originalDir = path.dirname(originalPath);
|
|
1441
|
+
if (!fs.existsSync(originalDir)) {
|
|
1442
|
+
fs.mkdirSync(originalDir, { recursive: true });
|
|
1443
|
+
}
|
|
1444
|
+
fs.writeFileSync(originalPath, modified.originalContent, "utf-8");
|
|
1445
|
+
console.log(chalk.green(` ✓ Restored: ${modified.path}`));
|
|
1446
|
+
restoredCount++;
|
|
1447
|
+
}
|
|
1448
|
+
catch (error) {
|
|
1449
|
+
// Fallback to backup file if originalContent restore fails
|
|
1450
|
+
const backupPath = path.join(target, ".stackpatch", "backups", modified.path.replace(/\//g, "_").replace(/\\/g, "_"));
|
|
1451
|
+
if (fs.existsSync(backupPath)) {
|
|
1452
|
+
try {
|
|
1453
|
+
restoreFile(backupPath, originalPath);
|
|
1454
|
+
console.log(chalk.green(` ✓ Restored (from backup): ${modified.path}`));
|
|
1455
|
+
restoredCount++;
|
|
1456
|
+
}
|
|
1457
|
+
catch (backupError) {
|
|
1458
|
+
console.log(chalk.yellow(` ⚠ Could not restore: ${modified.path}`));
|
|
1459
|
+
failedRestorations.push(modified.path);
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1462
|
+
else {
|
|
1463
|
+
console.log(chalk.yellow(` ⚠ Could not restore: ${modified.path} (no backup found)`));
|
|
1464
|
+
failedRestorations.push(modified.path);
|
|
1465
|
+
}
|
|
1466
|
+
}
|
|
1467
|
+
}
|
|
1468
|
+
else {
|
|
1469
|
+
// Fallback: try to restore from backup file
|
|
1470
|
+
const backupPath = path.join(target, ".stackpatch", "backups", modified.path.replace(/\//g, "_").replace(/\\/g, "_"));
|
|
1471
|
+
if (fs.existsSync(backupPath)) {
|
|
1472
|
+
try {
|
|
1473
|
+
restoreFile(backupPath, originalPath);
|
|
1474
|
+
console.log(chalk.green(` ✓ Restored (from backup): ${modified.path}`));
|
|
1475
|
+
restoredCount++;
|
|
1476
|
+
}
|
|
1477
|
+
catch (error) {
|
|
1478
|
+
console.log(chalk.yellow(` ⚠ Could not restore: ${modified.path}`));
|
|
1479
|
+
failedRestorations.push(modified.path);
|
|
1480
|
+
}
|
|
1481
|
+
}
|
|
1482
|
+
else {
|
|
1483
|
+
console.log(chalk.yellow(` ⚠ Backup not found and no originalContent: ${modified.path}`));
|
|
1484
|
+
failedRestorations.push(modified.path);
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
// Safety check: If file still contains StackPatch components after restore, manually remove them
|
|
1488
|
+
if (fs.existsSync(originalPath) && modified.path.includes("layout.tsx")) {
|
|
1489
|
+
try {
|
|
1490
|
+
let content = fs.readFileSync(originalPath, "utf-8");
|
|
1491
|
+
let needsUpdate = false;
|
|
1492
|
+
// Remove AuthSessionProvider import
|
|
1493
|
+
if (content.includes("AuthSessionProvider") && content.includes("session-provider")) {
|
|
1494
|
+
content = content.replace(/import\s*{\s*AuthSessionProvider\s*}\s*from\s*["'][^"']*session-provider[^"']*["'];\s*\n?/g, "");
|
|
1495
|
+
needsUpdate = true;
|
|
1496
|
+
}
|
|
1497
|
+
// Remove Toaster import
|
|
1498
|
+
if (content.includes("Toaster") && content.includes("toaster")) {
|
|
1499
|
+
content = content.replace(/import\s*{\s*Toaster\s*}\s*from\s*["'][^"']*toaster[^"']*["'];\s*\n?/g, "");
|
|
1500
|
+
needsUpdate = true;
|
|
1501
|
+
}
|
|
1502
|
+
// Remove AuthSessionProvider wrapper
|
|
1503
|
+
if (content.includes("<AuthSessionProvider>") && content.includes("</AuthSessionProvider>")) {
|
|
1504
|
+
content = content.replace(/<AuthSessionProvider>\s*/g, "");
|
|
1505
|
+
content = content.replace(/\s*<\/AuthSessionProvider>/g, "");
|
|
1506
|
+
needsUpdate = true;
|
|
1507
|
+
}
|
|
1508
|
+
// Remove Toaster component
|
|
1509
|
+
if (content.includes("<Toaster")) {
|
|
1510
|
+
content = content.replace(/<Toaster\s*\/?>\s*\n?\s*/g, "");
|
|
1511
|
+
needsUpdate = true;
|
|
1512
|
+
}
|
|
1513
|
+
if (needsUpdate) {
|
|
1514
|
+
fs.writeFileSync(originalPath, content, "utf-8");
|
|
1515
|
+
console.log(chalk.green(` ✓ Cleaned up StackPatch components from: ${modified.path}`));
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
1518
|
+
catch (error) {
|
|
1519
|
+
// Ignore errors in cleanup
|
|
1520
|
+
}
|
|
1521
|
+
}
|
|
1522
|
+
}
|
|
1523
|
+
// Step 4: Remove dependencies from package.json
|
|
1524
|
+
if (manifest.dependencies.length > 0) {
|
|
1525
|
+
console.log(chalk.white("\n📦 Removing dependencies from package.json..."));
|
|
1526
|
+
const removed = removeDependencies(target, manifest.dependencies);
|
|
1527
|
+
if (removed) {
|
|
1528
|
+
console.log(chalk.green(` ✓ Removed dependencies: ${manifest.dependencies.join(", ")}`));
|
|
1529
|
+
console.log(chalk.yellow(" ⚠ Run 'pnpm install' to update node_modules"));
|
|
1530
|
+
}
|
|
1531
|
+
else {
|
|
1532
|
+
console.log(chalk.gray(" ⊘ Dependencies not found in package.json"));
|
|
1533
|
+
}
|
|
1534
|
+
}
|
|
1535
|
+
// Step 5: Clean up empty directories (only if they only contained StackPatch files)
|
|
1536
|
+
console.log(chalk.white("\n🧹 Cleaning up empty directories..."));
|
|
1537
|
+
const sortedDirs = Array.from(directoriesToCheck).sort((a, b) => b.length - a.length); // Sort by depth (deepest first)
|
|
1538
|
+
let removedDirCount = 0;
|
|
1539
|
+
for (const dir of sortedDirs) {
|
|
1540
|
+
if (fs.existsSync(dir)) {
|
|
1541
|
+
try {
|
|
1542
|
+
const entries = fs.readdirSync(dir);
|
|
1543
|
+
if (entries.length === 0) {
|
|
1544
|
+
// Only remove if directory is empty
|
|
1545
|
+
// We know it was created by StackPatch because we're tracking it
|
|
1546
|
+
fs.rmdirSync(dir);
|
|
1547
|
+
removedDirCount++;
|
|
1548
|
+
console.log(chalk.green(` ✓ Removed empty directory: ${path.relative(target, dir)}`));
|
|
1549
|
+
}
|
|
1550
|
+
// If directory has other files, we don't remove it (silently skip)
|
|
1551
|
+
}
|
|
1552
|
+
catch {
|
|
1553
|
+
// Ignore errors
|
|
1554
|
+
}
|
|
1555
|
+
}
|
|
1556
|
+
}
|
|
1557
|
+
if (removedDirCount === 0) {
|
|
1558
|
+
console.log(chalk.gray(" ⊘ No empty directories to remove"));
|
|
1559
|
+
}
|
|
1560
|
+
// Step 6: Remove manifest and backups
|
|
1561
|
+
console.log(chalk.white("\n🗑️ Removing StackPatch tracking files..."));
|
|
1562
|
+
const stackpatchDir = path.join(target, ".stackpatch");
|
|
1563
|
+
if (fs.existsSync(stackpatchDir)) {
|
|
1564
|
+
try {
|
|
1565
|
+
fs.rmSync(stackpatchDir, { recursive: true, force: true });
|
|
1566
|
+
console.log(chalk.green(" ✓ Removed .stackpatch directory"));
|
|
1567
|
+
}
|
|
1568
|
+
catch (error) {
|
|
1569
|
+
console.log(chalk.yellow(" ⚠ Could not remove .stackpatch directory"));
|
|
1570
|
+
}
|
|
1571
|
+
}
|
|
1572
|
+
// Step 7: Verification
|
|
1573
|
+
console.log(chalk.white("\n✅ Verification..."));
|
|
1574
|
+
const remainingManifest = readManifest(target);
|
|
1575
|
+
if (remainingManifest) {
|
|
1576
|
+
console.log(chalk.red(" ❌ Warning: Manifest still exists. Revert may be incomplete."));
|
|
1577
|
+
}
|
|
1578
|
+
else {
|
|
1579
|
+
console.log(chalk.green(" ✓ Manifest removed successfully"));
|
|
1580
|
+
}
|
|
1581
|
+
// Summary
|
|
1582
|
+
console.log(chalk.blue.bold("\n📊 Revert Summary:"));
|
|
1583
|
+
console.log(chalk.white(` Files removed: ${chalk.green(removedCount)}`));
|
|
1584
|
+
console.log(chalk.white(` Files restored: ${chalk.green(restoredCount)}`));
|
|
1585
|
+
if (failedRemovals.length > 0) {
|
|
1586
|
+
console.log(chalk.yellow(` Failed removals: ${failedRemovals.length}`));
|
|
1587
|
+
failedRemovals.forEach(file => console.log(chalk.gray(` - ${file}`)));
|
|
1588
|
+
}
|
|
1589
|
+
if (failedRestorations.length > 0) {
|
|
1590
|
+
console.log(chalk.yellow(` Failed restorations: ${failedRestorations.length}`));
|
|
1591
|
+
failedRestorations.forEach(file => console.log(chalk.gray(` - ${file}`)));
|
|
1592
|
+
}
|
|
1593
|
+
if (failedRemovals.length === 0 && failedRestorations.length === 0 && !remainingManifest) {
|
|
1594
|
+
console.log(chalk.green("\n✅ Revert complete! Your project has been fully restored to its original state."));
|
|
1595
|
+
if (manifest.dependencies.length > 0) {
|
|
1596
|
+
console.log(chalk.yellow("\n⚠️ Remember to run 'pnpm install' to update your node_modules."));
|
|
1597
|
+
}
|
|
1598
|
+
}
|
|
1599
|
+
else {
|
|
1600
|
+
console.log(chalk.yellow("\n⚠️ Revert completed with some warnings. Please review the output above."));
|
|
1601
|
+
}
|
|
1602
|
+
return;
|
|
1603
|
+
}
|
|
1604
|
+
// Handle: bun create stackpatch@latest my-app
|
|
1605
|
+
// When bun runs create, it passes project name as first arg (not "create")
|
|
1606
|
+
// Check if first arg looks like a project name (not a known command)
|
|
1607
|
+
// Always ask for project name first, even if provided
|
|
1608
|
+
if (command && !["add", "create", "revert"].includes(command) && !PATCHES[command] && !command.startsWith("-")) {
|
|
1609
|
+
// Likely called as: bun create stackpatch@latest my-app
|
|
1610
|
+
// But we'll ask for project name anyway to be consistent
|
|
1611
|
+
await showWelcome();
|
|
1612
|
+
const { name } = await inquirer.prompt([
|
|
1613
|
+
{
|
|
1614
|
+
type: "input",
|
|
1615
|
+
name: "name",
|
|
1616
|
+
message: chalk.white("Enter your project name or path (relative to current directory)"),
|
|
1617
|
+
default: command || "my-stackpatch-app", // Use provided name as default
|
|
1618
|
+
validate: (input) => {
|
|
1619
|
+
if (!input.trim()) {
|
|
1620
|
+
return "Project name cannot be empty";
|
|
1621
|
+
}
|
|
1622
|
+
return true;
|
|
1623
|
+
},
|
|
1624
|
+
},
|
|
1625
|
+
]);
|
|
1626
|
+
await createProject(name.trim(), false, skipPrompts); // Welcome already shown
|
|
1627
|
+
return;
|
|
1628
|
+
}
|
|
1629
|
+
// Handle: npx stackpatch create my-app
|
|
1630
|
+
if (command === "create") {
|
|
1631
|
+
if (!projectName) {
|
|
1632
|
+
showWelcome();
|
|
1633
|
+
const { name } = await inquirer.prompt([
|
|
1634
|
+
{
|
|
1635
|
+
type: "input",
|
|
1636
|
+
name: "name",
|
|
1637
|
+
message: chalk.white("Enter your project name or path (relative to current directory)"),
|
|
1638
|
+
default: "my-stackpatch-app",
|
|
1639
|
+
validate: (input) => {
|
|
1640
|
+
if (!input.trim()) {
|
|
1641
|
+
return "Project name cannot be empty";
|
|
1642
|
+
}
|
|
1643
|
+
return true;
|
|
1644
|
+
},
|
|
1645
|
+
},
|
|
1646
|
+
]);
|
|
1647
|
+
await createProject(name.trim(), false); // Logo already shown
|
|
1648
|
+
return;
|
|
1649
|
+
}
|
|
1650
|
+
await createProject(projectName, false, skipPrompts); // Logo already shown
|
|
1651
|
+
return;
|
|
1652
|
+
}
|
|
1653
|
+
// Handle: npx stackpatch add auth-ui
|
|
1654
|
+
const patchName = args[1];
|
|
1655
|
+
if (command === "add" && patchName) {
|
|
1656
|
+
if (!PATCHES[patchName]) {
|
|
1657
|
+
console.log(chalk.red(`❌ Unknown patch: ${patchName}`));
|
|
1658
|
+
console.log(chalk.yellow(`Available patches: ${Object.keys(PATCHES).join(", ")}`));
|
|
1659
|
+
process.exit(1);
|
|
1660
|
+
}
|
|
1661
|
+
// Auto-detect target directory (current working directory or common locations)
|
|
1662
|
+
let target = process.cwd();
|
|
1663
|
+
// Check if we're in a Next.js app (has app/, src/app/, pages/, or src/pages/ directory)
|
|
1664
|
+
const hasAppDir = fs.existsSync(path.join(target, "app")) || fs.existsSync(path.join(target, "src", "app"));
|
|
1665
|
+
const hasPagesDir = fs.existsSync(path.join(target, "pages")) || fs.existsSync(path.join(target, "src", "pages"));
|
|
1666
|
+
if (!hasAppDir && !hasPagesDir) {
|
|
1667
|
+
// Try parent directory
|
|
1668
|
+
const parent = path.resolve(target, "..");
|
|
1669
|
+
if (fs.existsSync(path.join(parent, "app")) ||
|
|
1670
|
+
fs.existsSync(path.join(parent, "src", "app")) ||
|
|
1671
|
+
fs.existsSync(path.join(parent, "pages")) ||
|
|
1672
|
+
fs.existsSync(path.join(parent, "src", "pages"))) {
|
|
1673
|
+
target = parent;
|
|
1674
|
+
}
|
|
1675
|
+
else {
|
|
1676
|
+
// Try common monorepo locations: apps/, packages/, or root
|
|
1677
|
+
const possiblePaths = [
|
|
1678
|
+
path.join(target, "apps"),
|
|
1679
|
+
path.join(parent, "apps"),
|
|
1680
|
+
path.join(target, "packages"),
|
|
1681
|
+
path.join(parent, "packages"),
|
|
1682
|
+
];
|
|
1683
|
+
let foundApp = false;
|
|
1684
|
+
for (const possiblePath of possiblePaths) {
|
|
1685
|
+
if (fs.existsSync(possiblePath)) {
|
|
1686
|
+
// Look for Next.js apps in this directory
|
|
1687
|
+
const entries = fs.readdirSync(possiblePath, { withFileTypes: true });
|
|
1688
|
+
for (const entry of entries) {
|
|
1689
|
+
if (entry.isDirectory()) {
|
|
1690
|
+
const appPath = path.join(possiblePath, entry.name);
|
|
1691
|
+
if (fs.existsSync(path.join(appPath, "app")) ||
|
|
1692
|
+
fs.existsSync(path.join(appPath, "src", "app")) ||
|
|
1693
|
+
fs.existsSync(path.join(appPath, "pages")) ||
|
|
1694
|
+
fs.existsSync(path.join(appPath, "src", "pages"))) {
|
|
1695
|
+
target = appPath;
|
|
1696
|
+
foundApp = true;
|
|
1697
|
+
break;
|
|
1698
|
+
}
|
|
1699
|
+
}
|
|
1700
|
+
}
|
|
1701
|
+
if (foundApp)
|
|
1702
|
+
break;
|
|
1703
|
+
}
|
|
1704
|
+
}
|
|
1705
|
+
if (!foundApp) {
|
|
1706
|
+
console.log(chalk.yellow("⚠️ Could not auto-detect Next.js app directory."));
|
|
1707
|
+
const { target: userTarget } = await inquirer.prompt([
|
|
1708
|
+
{
|
|
1709
|
+
type: "input",
|
|
1710
|
+
name: "target",
|
|
1711
|
+
message: "Enter the path to your Next.js app folder:",
|
|
1712
|
+
default: target,
|
|
1713
|
+
},
|
|
1714
|
+
]);
|
|
1715
|
+
target = path.resolve(userTarget);
|
|
1716
|
+
}
|
|
1717
|
+
}
|
|
1718
|
+
}
|
|
1719
|
+
const src = path.join(BOILERPLATE_ROOT, PATCHES[patchName].path);
|
|
1720
|
+
console.log(chalk.blue.bold("\n🚀 StackPatch CLI\n"));
|
|
1721
|
+
console.log(chalk.blue(`Copying ${patchName} patch to ${target}...\n`));
|
|
1722
|
+
const copyResult = await copyFiles(src, target);
|
|
1723
|
+
if (!copyResult.success)
|
|
1724
|
+
process.exit(1);
|
|
1725
|
+
const addedFiles = copyResult.addedFiles;
|
|
1726
|
+
const modifiedFiles = [];
|
|
1727
|
+
let selectedProviders = [];
|
|
1728
|
+
// Install dependencies (only if missing)
|
|
1729
|
+
installDependencies(target, PATCHES[patchName].dependencies);
|
|
1730
|
+
// For auth patches, ask for OAuth providers and setup
|
|
1731
|
+
if (patchName === "auth" || patchName === "auth-ui") {
|
|
1732
|
+
showLogo();
|
|
1733
|
+
console.log(chalk.blue.bold(`\n🔐 Setting up authentication\n`));
|
|
1734
|
+
// Ask which OAuth providers to configure
|
|
1735
|
+
selectedProviders = await askOAuthProviders();
|
|
1736
|
+
const success = await setupAuth(target, selectedProviders);
|
|
1737
|
+
if (success) {
|
|
1738
|
+
await withSpinner("Updating layout with AuthSessionProvider", () => {
|
|
1739
|
+
const result = updateLayoutForAuth(target);
|
|
1740
|
+
if (result.modified && result.originalContent) {
|
|
1741
|
+
modifiedFiles.push({ path: result.filePath, originalContent: result.originalContent });
|
|
1742
|
+
}
|
|
1743
|
+
return true;
|
|
1744
|
+
});
|
|
1745
|
+
await withSpinner("Adding Toaster component", () => {
|
|
1746
|
+
const result = updateLayoutForToaster(target);
|
|
1747
|
+
if (result.modified && result.originalContent) {
|
|
1748
|
+
modifiedFiles.push({ path: result.filePath, originalContent: result.originalContent });
|
|
1749
|
+
}
|
|
1750
|
+
return true;
|
|
1751
|
+
});
|
|
1752
|
+
await withSpinner("Setting up protected routes", () => {
|
|
1753
|
+
copyProtectedRouteFiles(target);
|
|
1754
|
+
return true;
|
|
1755
|
+
});
|
|
1756
|
+
// OAuth instructions are shown in setupAuth function
|
|
1757
|
+
}
|
|
1758
|
+
}
|
|
1759
|
+
// Create manifest for tracking
|
|
1760
|
+
const manifest = {
|
|
1761
|
+
version: MANIFEST_VERSION,
|
|
1762
|
+
patchName,
|
|
1763
|
+
target,
|
|
1764
|
+
timestamp: new Date().toISOString(),
|
|
1765
|
+
files: {
|
|
1766
|
+
added: addedFiles,
|
|
1767
|
+
modified: modifiedFiles,
|
|
1768
|
+
backedUp: [],
|
|
1769
|
+
},
|
|
1770
|
+
dependencies: PATCHES[patchName].dependencies,
|
|
1771
|
+
oauthProviders: selectedProviders,
|
|
1772
|
+
};
|
|
1773
|
+
writeManifest(target, manifest);
|
|
1774
|
+
// Final next steps
|
|
1775
|
+
console.log(chalk.blue("\n🎉 Patch setup complete!"));
|
|
1776
|
+
console.log(chalk.green("\n📝 Next Steps:"));
|
|
1777
|
+
console.log(chalk.white(" 1. Configure OAuth providers (see instructions above)"));
|
|
1778
|
+
console.log(chalk.white(" 2. Set up database for email/password auth (see comments in code)"));
|
|
1779
|
+
console.log(chalk.white(" 3. Check out the auth navbar demo in ") + chalk.cyan("components/auth-navbar.tsx"));
|
|
1780
|
+
console.log(chalk.white(" 4. Protect your routes (see README.md)"));
|
|
1781
|
+
console.log(chalk.white(" 5. Run your Next.js dev server: ") + chalk.cyan("pnpm dev"));
|
|
1782
|
+
console.log(chalk.white(" 6. Test authentication at: ") + chalk.cyan("http://localhost:3000/auth/login\n"));
|
|
1783
|
+
console.log(chalk.blue.bold("📚 Documentation:"));
|
|
1784
|
+
console.log(chalk.white(" - See ") + chalk.cyan("README.md") + chalk.white(" for complete setup guide\n"));
|
|
1785
|
+
console.log(chalk.yellow("⚠️ Important:"));
|
|
1786
|
+
console.log(chalk.white(" - Email/password auth is in DEMO mode"));
|
|
1787
|
+
console.log(chalk.white(" - Demo credentials: ") + chalk.gray("demo@example.com / demo123"));
|
|
1788
|
+
console.log(chalk.white(" - See code comments in ") + chalk.cyan("app/api/auth/[...nextauth]/route.ts") + chalk.white(" to implement real auth\n"));
|
|
1789
|
+
return;
|
|
1790
|
+
}
|
|
1791
|
+
// If no command, show help or interactive mode
|
|
1792
|
+
if (!command) {
|
|
1793
|
+
await showWelcome();
|
|
1794
|
+
console.log(chalk.yellow("Usage:"));
|
|
1795
|
+
console.log(chalk.white(" ") + chalk.cyan("npm create stackpatch@latest") + chalk.gray(" [project-name]"));
|
|
1796
|
+
console.log(chalk.white(" ") + chalk.cyan("npx create-stackpatch@latest") + chalk.gray(" [project-name]"));
|
|
1797
|
+
console.log(chalk.white(" ") + chalk.cyan("bunx create-stackpatch@latest") + chalk.gray(" [project-name]"));
|
|
1798
|
+
console.log(chalk.white(" ") + chalk.cyan("npx stackpatch create") + chalk.gray(" [project-name]"));
|
|
1799
|
+
console.log(chalk.white(" ") + chalk.cyan("npx stackpatch add") + chalk.white(" <patch-name>"));
|
|
1800
|
+
console.log(chalk.white(" ") + chalk.cyan("npx stackpatch revert") + chalk.gray(" - Revert a patch installation"));
|
|
1801
|
+
console.log(chalk.white("\nExamples:"));
|
|
1802
|
+
console.log(chalk.gray(" npm create stackpatch@latest my-app"));
|
|
1803
|
+
console.log(chalk.gray(" npx create-stackpatch@latest my-app"));
|
|
1804
|
+
console.log(chalk.gray(" bunx create-stackpatch@latest my-app"));
|
|
1805
|
+
console.log(chalk.gray(" npx stackpatch create my-app"));
|
|
1806
|
+
console.log(chalk.gray(" npx stackpatch add auth-ui"));
|
|
1807
|
+
console.log(chalk.gray("\n"));
|
|
1808
|
+
process.exit(0);
|
|
1809
|
+
}
|
|
1810
|
+
// Interactive mode (fallback)
|
|
1811
|
+
console.log(chalk.blue.bold("\n🚀 Welcome to StackPatch CLI\n"));
|
|
1812
|
+
let selectedPatch = null;
|
|
1813
|
+
let goBack = false;
|
|
1814
|
+
// 1️⃣ Select patch with back option
|
|
1815
|
+
do {
|
|
1816
|
+
const response = await inquirer.prompt([
|
|
1817
|
+
{
|
|
1818
|
+
type: "list",
|
|
1819
|
+
name: "patch",
|
|
1820
|
+
message: "Which patch do you want to add?",
|
|
1821
|
+
choices: [
|
|
1822
|
+
...Object.keys(PATCHES)
|
|
1823
|
+
.filter(p => p !== "auth-ui") // Don't show duplicate
|
|
1824
|
+
.map(p => ({ name: p, value: p })),
|
|
1825
|
+
new inquirer.Separator(),
|
|
1826
|
+
{
|
|
1827
|
+
name: chalk.gray("← Go back / Cancel"),
|
|
1828
|
+
value: "back",
|
|
1829
|
+
},
|
|
1830
|
+
],
|
|
1831
|
+
},
|
|
1832
|
+
]);
|
|
1833
|
+
if (response.patch === "back") {
|
|
1834
|
+
goBack = true;
|
|
1835
|
+
break;
|
|
1836
|
+
}
|
|
1837
|
+
selectedPatch = response.patch;
|
|
1838
|
+
} while (!selectedPatch);
|
|
1839
|
+
if (goBack || !selectedPatch) {
|
|
1840
|
+
console.log(chalk.yellow("\n← Cancelled"));
|
|
1841
|
+
return;
|
|
1842
|
+
}
|
|
1843
|
+
const patch = selectedPatch;
|
|
1844
|
+
// 2️⃣ Enter target Next.js app folder
|
|
1845
|
+
const { target } = await inquirer.prompt([
|
|
1846
|
+
{
|
|
1847
|
+
type: "input",
|
|
1848
|
+
name: "target",
|
|
1849
|
+
message: "Enter the relative path to your Next.js app folder (e.g., ../../apps/stackpatch-frontend):",
|
|
1850
|
+
default: process.cwd(),
|
|
1851
|
+
},
|
|
1852
|
+
]);
|
|
1853
|
+
const src = path.join(BOILERPLATE_ROOT, PATCHES[patch].path);
|
|
1854
|
+
const dest = path.resolve(target);
|
|
1855
|
+
console.log(chalk.blue(`\nCopying ${patch} boilerplate to ${dest}...\n`));
|
|
1856
|
+
const copyResult = await copyFiles(src, dest);
|
|
1857
|
+
if (!copyResult.success)
|
|
1858
|
+
return;
|
|
1859
|
+
const addedFiles = copyResult.addedFiles;
|
|
1860
|
+
const modifiedFiles = [];
|
|
1861
|
+
// 3️⃣ Install dependencies (only if missing)
|
|
1862
|
+
installDependencies(dest, PATCHES[patch].dependencies);
|
|
1863
|
+
// 4️⃣ For auth patches, ask for OAuth providers and setup
|
|
1864
|
+
if (patch === "auth" || patch === "auth-ui") {
|
|
1865
|
+
console.log(chalk.blue.bold(`\n🔐 Setting up authentication\n`));
|
|
1866
|
+
// Ask which OAuth providers to configure
|
|
1867
|
+
const selectedProviders = await askOAuthProviders();
|
|
1868
|
+
const success = await setupAuth(dest, selectedProviders);
|
|
1869
|
+
if (success) {
|
|
1870
|
+
await withSpinner("Updating layout with AuthSessionProvider", () => {
|
|
1871
|
+
const result = updateLayoutForAuth(dest);
|
|
1872
|
+
if (result.modified && result.originalContent) {
|
|
1873
|
+
modifiedFiles.push({ path: result.filePath, originalContent: result.originalContent });
|
|
1874
|
+
}
|
|
1875
|
+
return true;
|
|
1876
|
+
});
|
|
1877
|
+
await withSpinner("Adding Toaster component", () => {
|
|
1878
|
+
const result = updateLayoutForToaster(dest);
|
|
1879
|
+
if (result.modified && result.originalContent) {
|
|
1880
|
+
modifiedFiles.push({ path: result.filePath, originalContent: result.originalContent });
|
|
1881
|
+
}
|
|
1882
|
+
return true;
|
|
1883
|
+
});
|
|
1884
|
+
}
|
|
1885
|
+
// Create manifest
|
|
1886
|
+
const manifest = {
|
|
1887
|
+
version: MANIFEST_VERSION,
|
|
1888
|
+
patchName: patch,
|
|
1889
|
+
target: dest,
|
|
1890
|
+
timestamp: new Date().toISOString(),
|
|
1891
|
+
files: {
|
|
1892
|
+
added: addedFiles,
|
|
1893
|
+
modified: modifiedFiles,
|
|
1894
|
+
backedUp: [],
|
|
1895
|
+
},
|
|
1896
|
+
dependencies: PATCHES[patch].dependencies,
|
|
1897
|
+
oauthProviders: selectedProviders,
|
|
1898
|
+
};
|
|
1899
|
+
writeManifest(dest, manifest);
|
|
1900
|
+
}
|
|
1901
|
+
// 5️⃣ Final next steps
|
|
1902
|
+
console.log(chalk.blue("\n🎉 Patch setup complete!"));
|
|
1903
|
+
console.log(chalk.green("- Run your Next.js dev server: pnpm dev"));
|
|
1904
|
+
console.log(chalk.green("- Start building your features!\n"));
|
|
1905
|
+
}
|
|
1906
|
+
main().catch((error) => {
|
|
1907
|
+
console.error(chalk.red("❌ Error:"), error);
|
|
1908
|
+
process.exit(1);
|
|
1909
|
+
});
|