stackpatch 1.1.9 → 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.
@@ -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
+ });