@djangocfg/nextjs 1.0.6 → 2.1.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,457 @@
1
+ /**
2
+ * Package Installer
3
+ *
4
+ * Provides auto-installation functionality with user confirmation and progress indication.
5
+ */
6
+
7
+ import { execSync, spawn } from 'child_process';
8
+ import { createInterface } from 'readline';
9
+ import chalk from 'chalk';
10
+ import consola from 'consola';
11
+ import Conf from 'conf';
12
+ import type { MissingPackage } from './checker';
13
+ import { getMissingPackages } from './checker';
14
+ import { isCI } from '../utils/env';
15
+
16
+ // Installer preferences cache
17
+ const installerCache = new Conf<{
18
+ autoInstall?: boolean;
19
+ skipPackages?: string[];
20
+ lastPrompt?: number;
21
+ }>({
22
+ projectName: 'djangocfg-nextjs-installer',
23
+ projectVersion: '1.0.0',
24
+ });
25
+
26
+ // Don't prompt more than once per hour
27
+ const PROMPT_COOLDOWN_MS = 60 * 60 * 1000;
28
+
29
+ // Spinner frames for progress
30
+ const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
31
+
32
+ export interface InstallOptions {
33
+ /** Auto-install without prompting */
34
+ autoInstall?: boolean;
35
+ /** Skip specific packages */
36
+ skipPackages?: string[];
37
+ /** Force prompt even if recently prompted */
38
+ force?: boolean;
39
+ }
40
+
41
+ export interface InstallProgress {
42
+ current: number;
43
+ total: number;
44
+ package: string;
45
+ status: 'pending' | 'installing' | 'done' | 'error';
46
+ }
47
+
48
+ /**
49
+ * Detect package manager
50
+ */
51
+ export function detectPackageManager(): 'pnpm' | 'yarn' | 'npm' {
52
+ try {
53
+ // Check for lockfiles
54
+ const fs = require('fs');
55
+ const path = require('path');
56
+ const cwd = process.cwd();
57
+
58
+ if (fs.existsSync(path.join(cwd, 'pnpm-lock.yaml'))) return 'pnpm';
59
+ if (fs.existsSync(path.join(cwd, 'yarn.lock'))) return 'yarn';
60
+ if (fs.existsSync(path.join(cwd, 'package-lock.json'))) return 'npm';
61
+
62
+ // Check for global package manager
63
+ try {
64
+ execSync('pnpm --version', { stdio: 'ignore' });
65
+ return 'pnpm';
66
+ } catch {
67
+ try {
68
+ execSync('yarn --version', { stdio: 'ignore' });
69
+ return 'yarn';
70
+ } catch {
71
+ return 'npm';
72
+ }
73
+ }
74
+ } catch {
75
+ return 'npm';
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Build install command for a single package
81
+ */
82
+ export function buildSingleInstallCommand(
83
+ packageName: string,
84
+ isDev: boolean,
85
+ pm: 'pnpm' | 'yarn' | 'npm'
86
+ ): string {
87
+ const devFlag = isDev ? '-D ' : '';
88
+ switch (pm) {
89
+ case 'pnpm':
90
+ return `pnpm add ${devFlag}${packageName}`;
91
+ case 'yarn':
92
+ return `yarn add ${devFlag}${packageName}`;
93
+ case 'npm':
94
+ return `npm install ${devFlag}${packageName}`;
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Build install command for multiple packages (for display)
100
+ */
101
+ export function buildInstallCommand(packages: MissingPackage[], pm: 'pnpm' | 'yarn' | 'npm'): string {
102
+ const devPackages = packages.filter(p => p.devDependency).map(p => p.name);
103
+ const prodPackages = packages.filter(p => !p.devDependency).map(p => p.name);
104
+
105
+ const commands: string[] = [];
106
+
107
+ if (devPackages.length > 0) {
108
+ switch (pm) {
109
+ case 'pnpm':
110
+ commands.push(`pnpm add -D ${devPackages.join(' ')}`);
111
+ break;
112
+ case 'yarn':
113
+ commands.push(`yarn add -D ${devPackages.join(' ')}`);
114
+ break;
115
+ case 'npm':
116
+ commands.push(`npm install -D ${devPackages.join(' ')}`);
117
+ break;
118
+ }
119
+ }
120
+
121
+ if (prodPackages.length > 0) {
122
+ switch (pm) {
123
+ case 'pnpm':
124
+ commands.push(`pnpm add ${prodPackages.join(' ')}`);
125
+ break;
126
+ case 'yarn':
127
+ commands.push(`yarn add ${prodPackages.join(' ')}`);
128
+ break;
129
+ case 'npm':
130
+ commands.push(`npm install ${prodPackages.join(' ')}`);
131
+ break;
132
+ }
133
+ }
134
+
135
+ return commands.join(' && ');
136
+ }
137
+
138
+ /**
139
+ * Ask user for confirmation via readline
140
+ */
141
+ async function askConfirmation(question: string): Promise<boolean> {
142
+ // Skip prompt in CI or non-TTY environments
143
+ if (isCI || !process.stdin.isTTY) {
144
+ return false;
145
+ }
146
+
147
+ return new Promise((resolve) => {
148
+ const rl = createInterface({
149
+ input: process.stdin,
150
+ output: process.stdout,
151
+ });
152
+
153
+ rl.question(question, (answer) => {
154
+ rl.close();
155
+ const normalized = answer.toLowerCase().trim();
156
+ resolve(normalized === '' || normalized === 'y' || normalized === 'yes');
157
+ });
158
+ });
159
+ }
160
+
161
+ /**
162
+ * Create a simple spinner
163
+ */
164
+ function createSpinner(text: string) {
165
+ let frameIndex = 0;
166
+ let interval: NodeJS.Timeout | null = null;
167
+ let currentText = text;
168
+
169
+ const render = () => {
170
+ const frame = SPINNER_FRAMES[frameIndex];
171
+ process.stdout.write(`\r${chalk.cyan(frame)} ${currentText}`);
172
+ frameIndex = (frameIndex + 1) % SPINNER_FRAMES.length;
173
+ };
174
+
175
+ return {
176
+ start() {
177
+ if (process.stdout.isTTY) {
178
+ interval = setInterval(render, 80);
179
+ render();
180
+ } else {
181
+ console.log(` ${currentText}`);
182
+ }
183
+ return this;
184
+ },
185
+ text(newText: string) {
186
+ currentText = newText;
187
+ if (!process.stdout.isTTY) {
188
+ console.log(` ${newText}`);
189
+ }
190
+ return this;
191
+ },
192
+ succeed(text?: string) {
193
+ if (interval) clearInterval(interval);
194
+ if (process.stdout.isTTY) {
195
+ process.stdout.write(`\r${chalk.green('✓')} ${text || currentText}\n`);
196
+ } else {
197
+ console.log(` ${chalk.green('✓')} ${text || currentText}`);
198
+ }
199
+ return this;
200
+ },
201
+ fail(text?: string) {
202
+ if (interval) clearInterval(interval);
203
+ if (process.stdout.isTTY) {
204
+ process.stdout.write(`\r${chalk.red('✗')} ${text || currentText}\n`);
205
+ } else {
206
+ console.log(` ${chalk.red('✗')} ${text || currentText}`);
207
+ }
208
+ return this;
209
+ },
210
+ stop() {
211
+ if (interval) clearInterval(interval);
212
+ if (process.stdout.isTTY) {
213
+ process.stdout.write('\r\x1b[K'); // Clear line
214
+ }
215
+ return this;
216
+ },
217
+ };
218
+ }
219
+
220
+ /**
221
+ * Install a single package with progress
222
+ */
223
+ async function installSinglePackage(
224
+ pkg: MissingPackage,
225
+ pm: 'pnpm' | 'yarn' | 'npm',
226
+ index: number,
227
+ total: number
228
+ ): Promise<boolean> {
229
+ const command = buildSingleInstallCommand(pkg.name, pkg.devDependency, pm);
230
+ const progress = `[${index + 1}/${total}]`;
231
+ const spinner = createSpinner(`${chalk.dim(progress)} Installing ${chalk.cyan(pkg.name)}...`);
232
+
233
+ spinner.start();
234
+
235
+ return new Promise((resolve) => {
236
+ const [cmd, ...args] = command.split(' ');
237
+ const proc = spawn(cmd, args, {
238
+ shell: true,
239
+ cwd: process.cwd(),
240
+ stdio: ['ignore', 'pipe', 'pipe'],
241
+ });
242
+
243
+ let stderr = '';
244
+
245
+ proc.stderr?.on('data', (data) => {
246
+ stderr += data.toString();
247
+ });
248
+
249
+ proc.on('close', (code) => {
250
+ if (code === 0) {
251
+ spinner.succeed(`${chalk.dim(progress)} ${chalk.cyan(pkg.name)} ${chalk.green('installed')}`);
252
+ resolve(true);
253
+ } else {
254
+ spinner.fail(`${chalk.dim(progress)} ${chalk.cyan(pkg.name)} ${chalk.red('failed')}`);
255
+ if (stderr) {
256
+ console.log(chalk.dim(` ${stderr.split('\n')[0]}`));
257
+ }
258
+ resolve(false);
259
+ }
260
+ });
261
+
262
+ proc.on('error', () => {
263
+ spinner.fail(`${chalk.dim(progress)} ${chalk.cyan(pkg.name)} ${chalk.red('failed')}`);
264
+ resolve(false);
265
+ });
266
+ });
267
+ }
268
+
269
+ /**
270
+ * Install packages with progress indication
271
+ */
272
+ export async function installPackagesWithProgress(packages: MissingPackage[]): Promise<boolean> {
273
+ if (packages.length === 0) return true;
274
+
275
+ const pm = detectPackageManager();
276
+ const total = packages.length;
277
+
278
+ console.log('');
279
+ console.log(chalk.bold(` Installing ${total} package${total > 1 ? 's' : ''}...`));
280
+ console.log('');
281
+
282
+ let successCount = 0;
283
+ let failedPackages: string[] = [];
284
+
285
+ for (let i = 0; i < packages.length; i++) {
286
+ const success = await installSinglePackage(packages[i], pm, i, total);
287
+ if (success) {
288
+ successCount++;
289
+ } else {
290
+ failedPackages.push(packages[i].name);
291
+ }
292
+ }
293
+
294
+ console.log('');
295
+
296
+ if (failedPackages.length === 0) {
297
+ consola.success(`All ${total} packages installed successfully!`);
298
+ return true;
299
+ } else if (successCount > 0) {
300
+ consola.warn(`${successCount}/${total} packages installed. Failed: ${failedPackages.join(', ')}`);
301
+ return false;
302
+ } else {
303
+ consola.error(`Failed to install packages: ${failedPackages.join(', ')}`);
304
+ return false;
305
+ }
306
+ }
307
+
308
+ /**
309
+ * Install packages (simple version without per-package progress)
310
+ */
311
+ export async function installPackages(packages: MissingPackage[]): Promise<boolean> {
312
+ // Use progress version for multiple packages in TTY
313
+ if (packages.length > 1 && process.stdout.isTTY) {
314
+ return installPackagesWithProgress(packages);
315
+ }
316
+
317
+ // Simple installation for single package or non-TTY
318
+ if (packages.length === 0) return true;
319
+
320
+ const pm = detectPackageManager();
321
+ const command = buildInstallCommand(packages, pm);
322
+
323
+ consola.info(`Installing: ${chalk.cyan(packages.map(p => p.name).join(', '))}`);
324
+
325
+ const spinner = createSpinner('Installing packages...');
326
+ spinner.start();
327
+
328
+ return new Promise((resolve) => {
329
+ const proc = spawn(command, {
330
+ shell: true,
331
+ cwd: process.cwd(),
332
+ stdio: ['ignore', 'pipe', 'pipe'],
333
+ });
334
+
335
+ proc.on('close', (code) => {
336
+ if (code === 0) {
337
+ spinner.succeed('Packages installed successfully!');
338
+ resolve(true);
339
+ } else {
340
+ spinner.fail('Failed to install packages');
341
+ resolve(false);
342
+ }
343
+ });
344
+
345
+ proc.on('error', () => {
346
+ spinner.fail('Installation failed');
347
+ resolve(false);
348
+ });
349
+ });
350
+ }
351
+
352
+ /**
353
+ * Check and prompt for missing packages
354
+ *
355
+ * Returns true if all packages are available (either already installed or just installed)
356
+ */
357
+ export async function checkAndInstallPackages(options: InstallOptions = {}): Promise<boolean> {
358
+ const missing = getMissingPackages();
359
+
360
+ // Filter out skipped packages
361
+ const skipList = options.skipPackages || installerCache.get('skipPackages') || [];
362
+ const toInstall = missing.filter(p => !skipList.includes(p.name));
363
+
364
+ if (toInstall.length === 0) {
365
+ return true;
366
+ }
367
+
368
+ // Check cooldown (don't prompt too often)
369
+ const lastPrompt = installerCache.get('lastPrompt') || 0;
370
+ if (!options.force && (Date.now() - lastPrompt) < PROMPT_COOLDOWN_MS) {
371
+ // Show info but don't prompt
372
+ printMissingPackagesInfo(toInstall);
373
+ return false;
374
+ }
375
+
376
+ // Auto-install if configured
377
+ if (options.autoInstall || installerCache.get('autoInstall')) {
378
+ return installPackages(toInstall);
379
+ }
380
+
381
+ // Show missing packages
382
+ console.log('');
383
+ consola.box('📦 Missing Optional Packages');
384
+ console.log('');
385
+
386
+ for (const pkg of toInstall) {
387
+ console.log(` ${chalk.yellow('•')} ${chalk.bold(pkg.name)}`);
388
+ console.log(` ${chalk.dim(pkg.description)}`);
389
+ console.log(` ${chalk.dim(pkg.reason)}`);
390
+ console.log('');
391
+ }
392
+
393
+ // Build install command for display
394
+ const pm = detectPackageManager();
395
+ const command = buildInstallCommand(toInstall, pm);
396
+
397
+ console.log(` ${chalk.cyan('Command:')} ${command}`);
398
+ console.log('');
399
+
400
+ // Ask for confirmation
401
+ installerCache.set('lastPrompt', Date.now());
402
+
403
+ const shouldInstall = await askConfirmation(
404
+ `${chalk.green('?')} Install these packages now? ${chalk.dim('[Y/n]')} `
405
+ );
406
+
407
+ if (shouldInstall) {
408
+ const success = await installPackages(toInstall);
409
+
410
+ // Ask if user wants to enable auto-install for future
411
+ if (success) {
412
+ const enableAuto = await askConfirmation(
413
+ `${chalk.green('?')} Enable auto-install for future? ${chalk.dim('[y/N]')} `
414
+ );
415
+ if (enableAuto) {
416
+ installerCache.set('autoInstall', true);
417
+ consola.info('Auto-install enabled. Run with --no-auto-install to disable.');
418
+ }
419
+ }
420
+
421
+ return success;
422
+ }
423
+
424
+ // User declined, ask if they want to skip these packages permanently
425
+ const skipPermanently = await askConfirmation(
426
+ `${chalk.green('?')} Skip these packages in future prompts? ${chalk.dim('[y/N]')} `
427
+ );
428
+
429
+ if (skipPermanently) {
430
+ const currentSkip = installerCache.get('skipPackages') || [];
431
+ installerCache.set('skipPackages', [...currentSkip, ...toInstall.map(p => p.name)]);
432
+ consola.info('Packages added to skip list.');
433
+ }
434
+
435
+ return false;
436
+ }
437
+
438
+ /**
439
+ * Print info about missing packages without prompting
440
+ */
441
+ function printMissingPackagesInfo(packages: MissingPackage[]): void {
442
+ if (packages.length === 0) return;
443
+
444
+ const pm = detectPackageManager();
445
+ const command = buildInstallCommand(packages, pm);
446
+
447
+ consola.warn(`Missing optional packages: ${packages.map(p => p.name).join(', ')}`);
448
+ consola.info(`Install with: ${chalk.cyan(command)}`);
449
+ }
450
+
451
+ /**
452
+ * Reset installer preferences
453
+ */
454
+ export function resetInstallerPreferences(): void {
455
+ installerCache.clear();
456
+ consola.success('Installer preferences reset');
457
+ }