@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,469 @@
1
+ /**
2
+ * Package Updater
3
+ *
4
+ * Checks for outdated @djangocfg/* packages and offers to update them.
5
+ */
6
+
7
+ import { spawn } from 'child_process';
8
+ import { createInterface } from 'readline';
9
+ import { createRequire } from 'module';
10
+ import { join } from 'path';
11
+ import chalk from 'chalk';
12
+ import consola from 'consola';
13
+ import Conf from 'conf';
14
+ import semver from 'semver';
15
+ import { DJANGOCFG_PACKAGES, PACKAGE_NAME } from '../constants';
16
+ import { isCI } from '../utils/env';
17
+ import { detectPackageManager } from './installer';
18
+
19
+ // Updater preferences cache
20
+ const updaterCache = new Conf<{
21
+ autoUpdate?: boolean;
22
+ lastCheck?: number;
23
+ skippedVersions?: Record<string, string>;
24
+ }>({
25
+ projectName: 'djangocfg-nextjs-updater',
26
+ projectVersion: '1.0.0',
27
+ });
28
+
29
+ // Check for updates once per hour
30
+ const UPDATE_CHECK_COOLDOWN_MS = 60 * 60 * 1000;
31
+
32
+ // Spinner frames
33
+ const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
34
+
35
+ export interface PackageVersion {
36
+ name: string;
37
+ current: string | null;
38
+ latest: string | null;
39
+ hasUpdate: boolean;
40
+ }
41
+
42
+ export interface UpdateOptions {
43
+ /** Auto-update without prompting */
44
+ autoUpdate?: boolean;
45
+ /** Force check even if recently checked (ignores cooldown) */
46
+ force?: boolean;
47
+ /** Force check even for workspace:* packages (for testing) */
48
+ forceCheckWorkspace?: boolean;
49
+ }
50
+
51
+ /**
52
+ * Check if package is a workspace dependency
53
+ */
54
+ function isWorkspacePackage(packageName: string): boolean {
55
+ try {
56
+ const fs = require('fs');
57
+ const pkgJsonPath = join(process.cwd(), 'package.json');
58
+ const pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8'));
59
+
60
+ const deps = { ...pkgJson.dependencies, ...pkgJson.devDependencies };
61
+ const version = deps[packageName];
62
+
63
+ return version?.startsWith('workspace:') || false;
64
+ } catch {
65
+ return false;
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Get installed version of a package from consumer's project
71
+ * Uses multiple fallback strategies for monorepo compatibility
72
+ */
73
+ export function getInstalledVersion(packageName: string): string | null {
74
+ const fs = require('fs');
75
+ const cwd = process.cwd();
76
+
77
+ // Strategy 1: Direct read from node_modules (works with symlinks)
78
+ try {
79
+ const directPath = join(cwd, 'node_modules', packageName, 'package.json');
80
+ if (fs.existsSync(directPath)) {
81
+ const pkg = JSON.parse(fs.readFileSync(directPath, 'utf-8'));
82
+ return pkg.version || null;
83
+ }
84
+ } catch {
85
+ // Continue to next strategy
86
+ }
87
+
88
+ // Strategy 2: Use createRequire from cwd
89
+ try {
90
+ const consumerRequire = createRequire(join(cwd, 'package.json'));
91
+ const pkgPath = consumerRequire.resolve(`${packageName}/package.json`);
92
+ const pkg = require(pkgPath);
93
+ return pkg.version || null;
94
+ } catch {
95
+ // Continue to next strategy
96
+ }
97
+
98
+ // Strategy 3: Try global require
99
+ try {
100
+ const pkgPath = require.resolve(`${packageName}/package.json`);
101
+ const pkg = require(pkgPath);
102
+ return pkg.version || null;
103
+ } catch {
104
+ return null;
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Check if we should skip update checking for this package
110
+ * (e.g., workspace packages shouldn't be checked against npm)
111
+ */
112
+ export function shouldCheckUpdates(packageName: string): boolean {
113
+ return !isWorkspacePackage(packageName);
114
+ }
115
+
116
+ /**
117
+ * Fetch latest version from npm registry
118
+ */
119
+ async function fetchLatestVersion(packageName: string): Promise<string | null> {
120
+ try {
121
+ const https = await import('https');
122
+ return new Promise((resolve) => {
123
+ const req = https.get(
124
+ `https://registry.npmjs.org/${packageName}/latest`,
125
+ { timeout: 5000 },
126
+ (res: any) => {
127
+ let data = '';
128
+ res.on('data', (chunk: string) => { data += chunk; });
129
+ res.on('end', () => {
130
+ try {
131
+ const json = JSON.parse(data);
132
+ resolve(json.version || null);
133
+ } catch {
134
+ resolve(null);
135
+ }
136
+ });
137
+ }
138
+ );
139
+ req.on('error', () => resolve(null));
140
+ req.on('timeout', () => { req.destroy(); resolve(null); });
141
+ });
142
+ } catch {
143
+ return null;
144
+ }
145
+ }
146
+
147
+ /**
148
+ * Check all @djangocfg packages for updates
149
+ * All packages are aligned to the version of @djangocfg/nextjs (master package)
150
+ * Skips workspace:* packages unless forceCheckWorkspace is true
151
+ */
152
+ export async function checkForUpdates(options: { forceCheckWorkspace?: boolean } = {}): Promise<PackageVersion[]> {
153
+ const results: PackageVersion[] = [];
154
+
155
+ // First, get the latest version of master package (@djangocfg/nextjs)
156
+ // All other packages should align to this version
157
+ const masterLatest = await fetchLatestVersion(PACKAGE_NAME);
158
+ if (!masterLatest) {
159
+ // Can't determine target version, skip update check
160
+ return results;
161
+ }
162
+
163
+ // Check packages against master version
164
+ const checks = DJANGOCFG_PACKAGES.map(async (name) => {
165
+ // Skip workspace packages unless force mode
166
+ const isWorkspace = !shouldCheckUpdates(name);
167
+ if (!options.forceCheckWorkspace && isWorkspace) {
168
+ return null;
169
+ }
170
+
171
+ const current = getInstalledVersion(name);
172
+ if (!current) {
173
+ return null;
174
+ }
175
+
176
+ // All packages should update to master package version
177
+ const hasUpdate = !!(masterLatest && current && semver.gt(masterLatest, current));
178
+
179
+ return { name, current, latest: masterLatest, hasUpdate };
180
+ });
181
+
182
+ const checkResults = await Promise.all(checks);
183
+
184
+ for (const result of checkResults) {
185
+ if (result) {
186
+ results.push(result);
187
+ }
188
+ }
189
+
190
+ return results;
191
+ }
192
+
193
+ /**
194
+ * Get packages that need updating
195
+ */
196
+ export async function getOutdatedPackages(options: { forceCheckWorkspace?: boolean } = {}): Promise<PackageVersion[]> {
197
+ const all = await checkForUpdates(options);
198
+ const skipped = updaterCache.get('skippedVersions') || {};
199
+
200
+ return all.filter(pkg => {
201
+ if (!pkg.hasUpdate) return false;
202
+ // Skip if user chose to skip this specific version
203
+ if (skipped[pkg.name] === pkg.latest) return false;
204
+ return true;
205
+ });
206
+ }
207
+
208
+ /**
209
+ * Create a simple spinner
210
+ */
211
+ function createSpinner(text: string) {
212
+ let frameIndex = 0;
213
+ let interval: NodeJS.Timeout | null = null;
214
+ let currentText = text;
215
+
216
+ const render = () => {
217
+ const frame = SPINNER_FRAMES[frameIndex];
218
+ process.stdout.write(`\r${chalk.cyan(frame)} ${currentText}`);
219
+ frameIndex = (frameIndex + 1) % SPINNER_FRAMES.length;
220
+ };
221
+
222
+ return {
223
+ start() {
224
+ if (process.stdout.isTTY) {
225
+ interval = setInterval(render, 80);
226
+ render();
227
+ } else {
228
+ console.log(` ${currentText}`);
229
+ }
230
+ return this;
231
+ },
232
+ text(newText: string) {
233
+ currentText = newText;
234
+ return this;
235
+ },
236
+ succeed(text?: string) {
237
+ if (interval) clearInterval(interval);
238
+ if (process.stdout.isTTY) {
239
+ process.stdout.write(`\r${chalk.green('✓')} ${text || currentText}\n`);
240
+ } else {
241
+ console.log(` ${chalk.green('✓')} ${text || currentText}`);
242
+ }
243
+ return this;
244
+ },
245
+ fail(text?: string) {
246
+ if (interval) clearInterval(interval);
247
+ if (process.stdout.isTTY) {
248
+ process.stdout.write(`\r${chalk.red('✗')} ${text || currentText}\n`);
249
+ } else {
250
+ console.log(` ${chalk.red('✗')} ${text || currentText}`);
251
+ }
252
+ return this;
253
+ },
254
+ stop() {
255
+ if (interval) clearInterval(interval);
256
+ if (process.stdout.isTTY) {
257
+ process.stdout.write('\r\x1b[K');
258
+ }
259
+ return this;
260
+ },
261
+ };
262
+ }
263
+
264
+ /**
265
+ * Ask user for confirmation
266
+ */
267
+ async function askConfirmation(question: string): Promise<boolean> {
268
+ if (isCI || !process.stdin.isTTY) {
269
+ return false;
270
+ }
271
+
272
+ return new Promise((resolve) => {
273
+ const rl = createInterface({
274
+ input: process.stdin,
275
+ output: process.stdout,
276
+ });
277
+
278
+ rl.question(question, (answer) => {
279
+ rl.close();
280
+ const normalized = answer.toLowerCase().trim();
281
+ resolve(normalized === '' || normalized === 'y' || normalized === 'yes');
282
+ });
283
+ });
284
+ }
285
+
286
+ /**
287
+ * Update a single package with progress
288
+ */
289
+ async function updateSinglePackage(
290
+ pkg: PackageVersion,
291
+ pm: 'pnpm' | 'yarn' | 'npm',
292
+ index: number,
293
+ total: number
294
+ ): Promise<boolean> {
295
+ const progress = `[${index + 1}/${total}]`;
296
+ const versionInfo = `${chalk.red(pkg.current)} → ${chalk.green(pkg.latest)}`;
297
+ const spinner = createSpinner(
298
+ `${chalk.dim(progress)} Updating ${chalk.cyan(pkg.name)} ${versionInfo}`
299
+ );
300
+
301
+ spinner.start();
302
+
303
+ const command = pm === 'pnpm'
304
+ ? `pnpm add ${pkg.name}@${pkg.latest}`
305
+ : pm === 'yarn'
306
+ ? `yarn add ${pkg.name}@${pkg.latest}`
307
+ : `npm install ${pkg.name}@${pkg.latest}`;
308
+
309
+ return new Promise((resolve) => {
310
+ const proc = spawn(command, {
311
+ shell: true,
312
+ cwd: process.cwd(),
313
+ stdio: ['ignore', 'pipe', 'pipe'],
314
+ });
315
+
316
+ proc.on('close', (code) => {
317
+ if (code === 0) {
318
+ spinner.succeed(
319
+ `${chalk.dim(progress)} ${chalk.cyan(pkg.name)} ${chalk.green(pkg.latest!)}`
320
+ );
321
+ resolve(true);
322
+ } else {
323
+ spinner.fail(
324
+ `${chalk.dim(progress)} ${chalk.cyan(pkg.name)} ${chalk.red('failed')}`
325
+ );
326
+ resolve(false);
327
+ }
328
+ });
329
+
330
+ proc.on('error', () => {
331
+ spinner.fail(`${chalk.dim(progress)} ${chalk.cyan(pkg.name)} ${chalk.red('failed')}`);
332
+ resolve(false);
333
+ });
334
+ });
335
+ }
336
+
337
+ /**
338
+ * Update packages with progress
339
+ */
340
+ export async function updatePackagesWithProgress(packages: PackageVersion[]): Promise<boolean> {
341
+ if (packages.length === 0) return true;
342
+
343
+ const pm = detectPackageManager();
344
+ const total = packages.length;
345
+
346
+ console.log('');
347
+ console.log(chalk.bold(` Updating ${total} package${total > 1 ? 's' : ''}...`));
348
+ console.log('');
349
+
350
+ let successCount = 0;
351
+ const failedPackages: string[] = [];
352
+
353
+ for (let i = 0; i < packages.length; i++) {
354
+ const success = await updateSinglePackage(packages[i], pm, i, total);
355
+ if (success) {
356
+ successCount++;
357
+ } else {
358
+ failedPackages.push(packages[i].name);
359
+ }
360
+ }
361
+
362
+ console.log('');
363
+
364
+ if (failedPackages.length === 0) {
365
+ consola.success(`All ${total} packages updated successfully!`);
366
+ return true;
367
+ } else if (successCount > 0) {
368
+ consola.warn(`${successCount}/${total} packages updated. Failed: ${failedPackages.join(', ')}`);
369
+ return false;
370
+ } else {
371
+ consola.error(`Failed to update packages: ${failedPackages.join(', ')}`);
372
+ return false;
373
+ }
374
+ }
375
+
376
+ /**
377
+ * Check and prompt for package updates
378
+ */
379
+ export async function checkAndUpdatePackages(options: UpdateOptions = {}): Promise<boolean> {
380
+ // Check cooldown
381
+ const lastCheck = updaterCache.get('lastCheck') || 0;
382
+ if (!options.force && (Date.now() - lastCheck) < UPDATE_CHECK_COOLDOWN_MS) {
383
+ return true;
384
+ }
385
+
386
+ updaterCache.set('lastCheck', Date.now());
387
+
388
+ // Check for updates
389
+ const spinner = createSpinner('Checking for updates...');
390
+ spinner.start();
391
+
392
+ const outdated = await getOutdatedPackages({
393
+ forceCheckWorkspace: options.forceCheckWorkspace,
394
+ });
395
+
396
+ spinner.stop();
397
+
398
+ console.log(chalk.dim(` Found ${outdated.length} outdated package(s)`));
399
+
400
+ if (outdated.length === 0) {
401
+ console.log(chalk.green(' ✓ All packages are up to date'));
402
+ return true;
403
+ }
404
+
405
+ // Auto-update if configured
406
+ if (options.autoUpdate || updaterCache.get('autoUpdate')) {
407
+ return updatePackagesWithProgress(outdated);
408
+ }
409
+
410
+ // Show outdated packages
411
+ console.log('');
412
+ consola.box('📦 Updates Available');
413
+ console.log('');
414
+
415
+ for (const pkg of outdated) {
416
+ console.log(
417
+ ` ${chalk.yellow('•')} ${chalk.bold(pkg.name)} ` +
418
+ `${chalk.red(pkg.current)} → ${chalk.green(pkg.latest)}`
419
+ );
420
+ }
421
+ console.log('');
422
+
423
+ // Ask for confirmation
424
+ const shouldUpdate = await askConfirmation(
425
+ `${chalk.green('?')} Update these packages now? ${chalk.dim('[Y/n]')} `
426
+ );
427
+
428
+ if (shouldUpdate) {
429
+ const success = await updatePackagesWithProgress(outdated);
430
+
431
+ if (success) {
432
+ const enableAuto = await askConfirmation(
433
+ `${chalk.green('?')} Enable auto-update for future? ${chalk.dim('[y/N]')} `
434
+ );
435
+ if (enableAuto) {
436
+ updaterCache.set('autoUpdate', true);
437
+ consola.info('Auto-update enabled.');
438
+ }
439
+ }
440
+
441
+ return success;
442
+ }
443
+
444
+ // User declined, ask if they want to skip these versions
445
+ const skipVersions = await askConfirmation(
446
+ `${chalk.green('?')} Skip these versions in future? ${chalk.dim('[y/N]')} `
447
+ );
448
+
449
+ if (skipVersions) {
450
+ const skipped = updaterCache.get('skippedVersions') || {};
451
+ for (const pkg of outdated) {
452
+ if (pkg.latest) {
453
+ skipped[pkg.name] = pkg.latest;
454
+ }
455
+ }
456
+ updaterCache.set('skippedVersions', skipped);
457
+ consola.info('Versions skipped. Will prompt again for newer versions.');
458
+ }
459
+
460
+ return false;
461
+ }
462
+
463
+ /**
464
+ * Reset updater preferences
465
+ */
466
+ export function resetUpdaterPreferences(): void {
467
+ updaterCache.clear();
468
+ consola.success('Updater preferences reset');
469
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Compression Plugin Setup
3
+ *
4
+ * Adds Gzip and Brotli compression for static builds.
5
+ */
6
+
7
+ import type { Configuration as WebpackConfig } from 'webpack';
8
+ import { isPackageInstalled } from '../packages/checker';
9
+
10
+ export interface CompressionPluginOptions {
11
+ /** Enable gzip compression */
12
+ gzip?: boolean;
13
+ /** Enable brotli compression */
14
+ brotli?: boolean;
15
+ /** Minimum file size to compress (default: 8192) */
16
+ threshold?: number;
17
+ /** Minimum compression ratio (default: 0.8) */
18
+ minRatio?: number;
19
+ /** Brotli compression level (default: 8) */
20
+ brotliLevel?: number;
21
+ }
22
+
23
+ const DEFAULT_OPTIONS: Required<CompressionPluginOptions> = {
24
+ gzip: true,
25
+ brotli: true,
26
+ threshold: 8192,
27
+ minRatio: 0.8,
28
+ brotliLevel: 8,
29
+ };
30
+
31
+ /**
32
+ * Add compression plugins to webpack config
33
+ *
34
+ * Returns true if plugins were added, false if compression-webpack-plugin is not installed
35
+ */
36
+ export function addCompressionPlugins(
37
+ config: WebpackConfig,
38
+ options: CompressionPluginOptions = {}
39
+ ): boolean {
40
+ // Check if compression-webpack-plugin is installed
41
+ if (!isPackageInstalled('compression-webpack-plugin')) {
42
+ return false;
43
+ }
44
+
45
+ const opts = { ...DEFAULT_OPTIONS, ...options };
46
+
47
+ try {
48
+ // Dynamic import to avoid bundling if not installed
49
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
50
+ const CompressionPlugin = require('compression-webpack-plugin');
51
+
52
+ if (!config.plugins) {
53
+ config.plugins = [];
54
+ }
55
+
56
+ // Gzip compression
57
+ if (opts.gzip) {
58
+ config.plugins.push(
59
+ new CompressionPlugin({
60
+ filename: '[path][base].gz',
61
+ algorithm: 'gzip',
62
+ test: /\.(js|css|html|svg|json)$/,
63
+ threshold: opts.threshold,
64
+ minRatio: opts.minRatio,
65
+ })
66
+ );
67
+ }
68
+
69
+ // Brotli compression
70
+ if (opts.brotli) {
71
+ config.plugins.push(
72
+ new CompressionPlugin({
73
+ filename: '[path][base].br',
74
+ algorithm: 'brotliCompress',
75
+ test: /\.(js|css|html|svg|json)$/,
76
+ threshold: opts.threshold,
77
+ minRatio: opts.minRatio,
78
+ compressionOptions: {
79
+ level: opts.brotliLevel,
80
+ },
81
+ })
82
+ );
83
+ }
84
+
85
+ return true;
86
+ } catch (error) {
87
+ // compression-webpack-plugin failed to load
88
+ return false;
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Check if compression is available
94
+ */
95
+ export function isCompressionAvailable(): boolean {
96
+ return isPackageInstalled('compression-webpack-plugin');
97
+ }
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Dev Startup Webpack Plugin
3
+ *
4
+ * Handles banner display, version checking, package updates, and browser auto-open.
5
+ */
6
+
7
+ import type { Compiler } from 'webpack';
8
+ import chalk from 'chalk';
9
+ import { DJANGO_CFG_BANNER } from '../constants';
10
+ import { getCurrentVersion } from '../utils/version';
11
+ import { checkAndInstallPackages } from '../packages/installer';
12
+ import { checkAndUpdatePackages } from '../packages/updater';
13
+
14
+ // Track if startup tasks were already run (persists across HMR)
15
+ let startupDone = false;
16
+ let browserOpened = false;
17
+
18
+ export interface DevStartupPluginOptions {
19
+ /** Auto-open browser */
20
+ openBrowser?: boolean;
21
+ /** Check for missing optional packages (default: true) */
22
+ checkPackages?: boolean;
23
+ /** Auto-install missing packages without prompting */
24
+ autoInstall?: boolean;
25
+ /** Check for @djangocfg/* package updates (default: true) */
26
+ checkUpdates?: boolean;
27
+ /** Auto-update packages without prompting */
28
+ autoUpdate?: boolean;
29
+ /** Force check workspace:* packages (for testing in monorepo) */
30
+ forceCheckWorkspace?: boolean;
31
+ }
32
+
33
+ /**
34
+ * Webpack plugin for dev startup tasks
35
+ */
36
+ export class DevStartupPlugin {
37
+ private options: DevStartupPluginOptions;
38
+
39
+ constructor(options: DevStartupPluginOptions = {}) {
40
+ this.options = options;
41
+ }
42
+
43
+ apply(compiler: Compiler): void {
44
+ // Use tapPromise for proper async handling
45
+ compiler.hooks.done.tapPromise('DevStartupPlugin', async () => {
46
+ // Run startup tasks only once
47
+ if (!startupDone) {
48
+ startupDone = true;
49
+ await this.runStartupTasks();
50
+ }
51
+
52
+ // Auto-open browser if enabled
53
+ if (this.options.openBrowser && !browserOpened) {
54
+ browserOpened = true;
55
+ this.openBrowser();
56
+ }
57
+ });
58
+ }
59
+
60
+ private async runStartupTasks(): Promise<void> {
61
+ // 1. Print banner
62
+ console.log('\n' + chalk.yellowBright.bold(DJANGO_CFG_BANNER));
63
+
64
+ // 2. Print current version
65
+ const version = getCurrentVersion();
66
+ if (version) {
67
+ console.log(chalk.dim(` 📦 @djangocfg/nextjs v${version}\n`));
68
+ }
69
+
70
+ // 3. Check for package updates
71
+ if (this.options.checkUpdates !== false) {
72
+ try {
73
+ await checkAndUpdatePackages({
74
+ autoUpdate: this.options.autoUpdate,
75
+ forceCheckWorkspace: this.options.forceCheckWorkspace,
76
+ force: true, // Force check ignoring cooldown
77
+ });
78
+ } catch (err) {
79
+ console.log(chalk.red(' Update check failed:'), err);
80
+ }
81
+ }
82
+
83
+ // 4. Check for missing optional packages
84
+ if (this.options.checkPackages !== false) {
85
+ await checkAndInstallPackages({
86
+ autoInstall: this.options.autoInstall,
87
+ });
88
+ }
89
+ }
90
+
91
+ private openBrowser(): void {
92
+ // Delay to ensure server is ready
93
+ setTimeout(async () => {
94
+ try {
95
+ const { exec } = await import('child_process');
96
+ const port = process.env.PORT || '3000';
97
+ const url = `http://localhost:${port}`;
98
+
99
+ const command = process.platform === 'darwin'
100
+ ? 'open'
101
+ : process.platform === 'win32'
102
+ ? 'start'
103
+ : 'xdg-open';
104
+
105
+ exec(`${command} ${url}`, (error) => {
106
+ if (error) {
107
+ console.warn(`Failed to open browser: ${error.message}`);
108
+ }
109
+ });
110
+ } catch (error) {
111
+ console.warn('Failed to open browser');
112
+ }
113
+ }, 2000);
114
+ }
115
+ }
116
+
117
+ /**
118
+ * Reset plugin state (useful for tests)
119
+ */
120
+ export function resetDevStartupState(): void {
121
+ startupDone = false;
122
+ browserOpened = false;
123
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Plugins module exports
3
+ */
4
+
5
+ export { DevStartupPlugin, resetDevStartupState, type DevStartupPluginOptions } from './devStartup';
6
+ export { addCompressionPlugins, isCompressionAvailable, type CompressionPluginOptions } from './compression';