UNPKG

cs-init

Version:
534 lines (442 loc) 22.6 kB
#!/usr/bin/env node /** * cs-init: Local Dev Setup * ------------------------ * Checks and configures a local DNS entry and HTTPS certificate * for the webpack dev server. Supports Windows and macOS/Linux. * * Usage: * node setup-local-dev.js [--hostname=my.local.dev] [--ip=127.0.0.1] [--force] * * Flags: * --hostname Local hostname to use (default: reads from .env or prompts) * --ip IP to map (default: 127.0.0.1) * --force Re-run even if already configured * --add-dns Only add the DNS entry (skip cert generation) * --add-cert Only generate the certificate (skip DNS check) */ 'use strict'; const fs = require('fs'); const path = require('path'); const os = require('os'); const { execSync, spawnSync } = require('child_process'); const readline = require('readline'); // ─── Config ────────────────────────────────────────────────────────────────── const rootDir = process.env.INIT_CWD || process.cwd(); const CONFIG_FILE = path.join(rootDir, '.env'); const CERTS_DIR = path.join(rootDir, 'certs'); const CERT_PATH = path.join(CERTS_DIR, 'server.crt'); const KEY_PATH = path.join(CERTS_DIR, 'server.key'); const isWindows = os.platform() === 'win32'; const isMac = os.platform() === 'darwin'; const HOSTS_FILE = isWindows ? 'C:\\Windows\\System32\\drivers\\etc\\hosts' : '/etc/hosts'; // ─── CLI Args ──────────────────────────────────────────────────────────────── const args = process.argv.slice(2); const getArg = (name) => { const arg = args.find(a => a.startsWith(`--${name}=`)); return arg ? arg.split('=').slice(1).join('=') : null; }; const hasFlag = (name) => args.includes(`--${name}`); const FLAG_FORCE = hasFlag('force'); const FLAG_DNS_ONLY = hasFlag('add-dns'); const FLAG_CERT_ONLY = hasFlag('add-cert'); // ─── Helpers ───────────────────────────────────────────────────────────────── const colors = { reset: '\x1b[0m', bright: '\x1b[1m', red: '\x1b[31m', green: '\x1b[32m', yellow: '\x1b[33m', cyan: '\x1b[36m', gray: '\x1b[90m', }; const log = { info: (msg) => console.log(`${colors.cyan}${colors.reset} ${msg}`), success: (msg) => console.log(`${colors.green}${colors.reset} ${msg}`), warn: (msg) => console.log(`${colors.yellow}${colors.reset} ${msg}`), error: (msg) => console.log(`${colors.red}${colors.reset} ${msg}`), step: (msg) => console.log(`\n${colors.bright}${msg}${colors.reset}`), hint: (msg) => console.log(`${colors.gray} ${msg}${colors.reset}`), }; function prompt(question) { const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); return new Promise((resolve) => { rl.question(question, (answer) => { rl.close(); resolve(answer.trim()); }); }); } function commandExists(cmd) { try { const result = spawnSync(isWindows ? 'where' : 'which', [cmd], { stdio: 'pipe' }); return result.status === 0; } catch { return false; } } // ─── Config file (.env) ─────────────────────────────────────────────────────── function readConfig() { if (!fs.existsSync(CONFIG_FILE)) return {}; const lines = fs.readFileSync(CONFIG_FILE, 'utf8').split('\n'); return lines.reduce((acc, line) => { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith('#')) return acc; const eqIndex = trimmed.indexOf('='); if (eqIndex === -1) return acc; const key = trimmed.slice(0, eqIndex).trim(); const value = trimmed.slice(eqIndex + 1).trim().replace(/^["']|["']$/g, ''); acc[key] = value; return acc; }, {}); } function writeConfig(data) { // Map our internal keys to .env variable names const ENV_KEYS = { hostname: 'CS_DEV_HOSTNAME', port: 'CS_DEV_PORT', ip: 'CS_DEV_IP', ssl: 'CS_DEV_SSL', }; let content = fs.existsSync(CONFIG_FILE) ? fs.readFileSync(CONFIG_FILE, 'utf8') : ''; for (const [key, value] of Object.entries(data)) { const envKey = ENV_KEYS[key] || key.toUpperCase(); const line = `${envKey}=${value}`; const regex = new RegExp(`^${envKey}=.*$`, 'm'); if (regex.test(content)) { content = content.replace(regex, line); } else { content = content ? `${content.trimEnd()}\n${line}\n` : `${line}\n`; } } fs.writeFileSync(CONFIG_FILE, content); } // ─── DNS / hosts file ───────────────────────────────────────────────────────── function readHostsFile() { try { return fs.readFileSync(HOSTS_FILE, 'utf8'); } catch (err) { log.error(`Cannot read hosts file: ${HOSTS_FILE}`); log.hint(`You may need to run this script with elevated privileges.`); throw err; } } const BLOCK_START = '### codeSchmiede - Start ###'; const BLOCK_END = '### codeSchmiede - End ###'; function isDnsEntryPresent(hostname) { const content = readHostsFile(); const lines = content.split('\n'); return lines.some(line => { const trimmed = line.trim(); if (trimmed.startsWith('#') || !trimmed) return false; const parts = trimmed.split(/\s+/); return parts.length >= 2 && parts[1] === hostname; }); } function buildNewHostsContent(currentContent, entry) { if (currentContent.includes(BLOCK_START) && currentContent.includes(BLOCK_END)) { return currentContent.replace(BLOCK_END, `${entry}\n${BLOCK_END}`); } return `${currentContent.trimEnd()}\n\n${BLOCK_START}\n\n${entry}\n\n${BLOCK_END}\n`; } function writeHostsFile(newContent) { if (isWindows) { const tmpContent = path.join(os.tmpdir(), 'cs-init-hosts.txt'); const tmpScript = path.join(os.tmpdir(), 'cs-init-hosts.ps1'); fs.writeFileSync(tmpContent, newContent, 'utf8'); fs.writeFileSync(tmpScript, `Copy-Item -Path '${tmpContent}' -Destination '${HOSTS_FILE}' -Force\r\n`, 'utf8'); log.info('Requesting admin rights to modify hosts file on Windows...'); const result = spawnSync('powershell', [ '-NoProfile', '-Command', `Start-Process powershell -ArgumentList '-NoProfile -ExecutionPolicy Bypass -File "${tmpScript}"' -Verb RunAs -Wait`, ], { stdio: 'inherit' }); try { fs.unlinkSync(tmpScript); } catch {} try { fs.unlinkSync(tmpContent); } catch {} if (result.status !== 0) { throw new Error('Failed to write hosts file. Please run the terminal as Administrator and retry.'); } } else { const tmpContent = path.join(os.tmpdir(), 'cs-init-hosts.txt'); fs.writeFileSync(tmpContent, newContent, 'utf8'); log.info('sudo is required to modify /etc/hosts. You may be prompted for your password.'); const result = spawnSync('sudo', ['cp', tmpContent, HOSTS_FILE], { stdio: 'inherit' }); try { fs.unlinkSync(tmpContent); } catch {} if (result.status !== 0) { throw new Error('Failed to write hosts file via sudo.'); } } } function addDnsEntry(hostname, ip) { const entry = `${ip}\t${hostname}\t# added by cs-init`; const newContent = buildNewHostsContent(readHostsFile(), entry); writeHostsFile(newContent); } // ─── Certificate generation ─────────────────────────────────────────────────── function hasMkcert() { return commandExists('mkcert'); } function generateCertWithMkcert(hostname) { if (!fs.existsSync(CERTS_DIR)) fs.mkdirSync(CERTS_DIR, { recursive: true }); log.info('Running mkcert to generate a locally-trusted certificate...'); // Ensure mkcert CA is installed in the system trust store const install = spawnSync('mkcert', ['-install'], { stdio: 'inherit' }); if (install.status !== 0) { throw new Error('mkcert -install failed. Try running it manually.'); } // Generate cert for hostname + localhost + 127.0.0.1 const result = spawnSync('mkcert', [ '-key-file', KEY_PATH, '-cert-file', CERT_PATH, hostname, 'localhost', '127.0.0.1', ], { cwd: rootDir, stdio: 'inherit', }); if (result.status !== 0) { throw new Error('mkcert certificate generation failed.'); } } function generateCertWithOpenssl(hostname) { if (!commandExists('openssl')) { throw new Error('Neither mkcert nor openssl found. Please install mkcert: https://github.com/FiloSottile/mkcert'); } if (!fs.existsSync(CERTS_DIR)) fs.mkdirSync(CERTS_DIR, { recursive: true }); log.warn('mkcert not found — falling back to openssl (self-signed, browser will show a warning).'); log.hint('For a fully trusted cert, install mkcert: https://github.com/FiloSottile/mkcert'); const subj = `/CN=${hostname}/O=cs-init/C=DE`; const san = `subjectAltName=DNS:${hostname},DNS:localhost,IP:127.0.0.1`; const cmd = [ 'openssl req -x509 -newkey rsa:2048 -nodes', `-keyout "${KEY_PATH}"`, `-out "${CERT_PATH}"`, `-days 3650`, `-subj "${subj}"`, `-addext "${san}"`, ].join(' '); execSync(cmd, { stdio: 'inherit' }); } function certIsValidForHostname(hostname) { if (!fs.existsSync(CERT_PATH)) return false; if (!commandExists('openssl')) { // Can't verify without openssl — assume invalid so cert gets regenerated return false; } try { const output = execSync( `openssl x509 -in "${CERT_PATH}" -noout -text`, { encoding: 'utf8', stdio: 'pipe' } ); return output.includes(`DNS:${hostname}`) || output.includes(`CN = ${hostname}`) || output.includes(`CN=${hostname}`); } catch { return false; } } // ─── webpack.config.js patching ─────────────────────────────────────────────── function updateWebpackConfig(hostname, port) { const webpackConfigPath = path.join(rootDir, 'webpack.config.js'); if (!fs.existsSync(webpackConfigPath)) { log.warn('webpack.config.js not found — skipping devServer update.'); log.hint('Run npm install again or check that postinstall.js has run.'); return; } let content = fs.readFileSync(webpackConfigPath, 'utf8'); const originalContent = content; // Update port if different if (port && port !== 8080) { content = content.replace(/port:\s*\d+/, `port: ${port}`); } // Ensure devServer host is set if (!content.includes('host:') && hostname !== 'localhost') { content = content.replace( /devServer:\s*\{/, `devServer: {\n host: '${hostname}',` ); } else if (content.includes("host:") && hostname !== 'localhost') { content = content.replace(/host:\s*['"][^'"]+['"]/, `host: '${hostname}'`); } if (content !== originalContent) { fs.writeFileSync(webpackConfigPath, content); log.success('webpack.config.js updated with new hostname/port.'); } else { log.info('webpack.config.js already up to date.'); } } // ─── .gitignore patching ────────────────────────────────────────────────────── function ensureGitignore() { const gitignorePath = path.join(rootDir, '.gitignore'); const entriesToAdd = ['certs/', '.env']; let content = fs.existsSync(gitignorePath) ? fs.readFileSync(gitignorePath, 'utf8') : ''; // Use exact line match to avoid false positives (e.g. ".env" matching ".env.example") const lines = content.split('\n').map(l => l.trim()); let changed = false; for (const entry of entriesToAdd) { if (!lines.includes(entry)) { content = `${content.trimEnd()}\n${entry}\n`; changed = true; } } if (changed) { fs.writeFileSync(gitignorePath, content.trimStart()); log.success('.gitignore updated (certs/ and .env added).'); } } // ─── Main ───────────────────────────────────────────────────────────────────── async function main() { console.log(`\n${colors.bright}╔══════════════════════════════════════╗`); console.log(`║ cs-init · Local Dev Setup ║`); console.log(`╚══════════════════════════════════════╝${colors.reset}\n`); const config = readConfig(); // ── Determine settings ────────────────────────────────────────────────── const ipAddress = getArg('ip') || config.CS_DEV_IP || '127.0.0.1'; let hostname = getArg('hostname') || config.CS_DEV_HOSTNAME; if (!hostname) { log.info('No local hostname configured yet.'); const input = await prompt( ` Enter the local hostname to use [local.codeschmiede.de]: ` ); hostname = input || 'local.codeschmiede.de'; } if (isMac && hostname.endsWith('.local')) { log.warn(`".local"-Domains werden auf macOS via mDNS/Bonjour aufgelöst.`); log.hint('Das kann dazu führen, dass der Dev-Server bis zu 30s hängt.'); log.hint(`Empfehlung: Verwende z.B. "${hostname.replace(/\.local$/, '.test')}" statt "${hostname}"`); const confirm = await prompt(` Trotzdem mit "${hostname}" fortfahren? [y/N]: `); if (!confirm || confirm.toLowerCase() !== 'y') { log.info('Abgebrochen. Starte das Setup erneut und wähle einen anderen Hostnamen.'); process.exit(0); } } let port = getArg('port') || config.CS_DEV_PORT || 8080; port = parseInt(port, 10); log.info(`Hostname: ${colors.bright}${hostname}${colors.reset}`); log.info(`IP: ${colors.bright}${ipAddress}${colors.reset}`); log.info(`Port: ${colors.bright}${port}${colors.reset}`); // ── DNS check & setup ─────────────────────────────────────────────────── if (!FLAG_CERT_ONLY) { log.step('① DNS / hosts file'); if (hostname === 'localhost') { log.info('Using localhost — no hosts file entry needed.'); } else { const dnsPresent = isDnsEntryPresent(hostname); if (dnsPresent && !FLAG_FORCE) { log.success(`DNS entry for "${hostname}" already exists in ${HOSTS_FILE}`); } else { if (dnsPresent) { log.warn(`DNS entry exists but --force flag is set — skipping (no duplicates).`); } else { log.warn(`No DNS entry found for "${hostname}" in ${HOSTS_FILE}`); const answer = await prompt( ` Add "${ipAddress} ${hostname}" to hosts file? [Y/n]: ` ); if (!answer || answer.toLowerCase() === 'y') { try { addDnsEntry(hostname, ipAddress); log.success(`DNS entry added: ${ipAddress}${hostname}`); } catch (err) { log.error(err.message); log.hint(`Manual fix: add the following line to ${HOSTS_FILE}`); log.hint(` ${ipAddress} ${hostname}`); process.exitCode = 1; } } else { log.warn('DNS entry skipped. The dev server may not be reachable via the custom hostname.'); } } } } } // ── Certificate setup ─────────────────────────────────────────────────── if (!FLAG_DNS_ONLY) { log.step('② SSL Certificate'); const certExists = fs.existsSync(CERT_PATH) && fs.existsSync(KEY_PATH); const certValid = certExists && certIsValidForHostname(hostname); if (certValid && !FLAG_FORCE) { log.success(`Certificate already exists and covers "${hostname}".`); log.hint(` ${CERT_PATH}`); log.hint(` ${KEY_PATH}`); } else { if (certExists && !certValid) { log.warn(`Existing certificate does not cover "${hostname}" — regenerating...`); } else if (FLAG_FORCE) { log.info('--force: regenerating certificate.'); } else { log.info('No certificate found — generating now.'); } if (hasMkcert()) { log.info('mkcert detected — generating locally-trusted certificate.'); generateCertWithMkcert(hostname); log.success('Certificate generated and trusted via mkcert.'); } else { log.warn('mkcert not found.'); const install = await prompt( ` mkcert is recommended for a trusted local cert. Install mkcert first, or proceed with a self-signed cert (browser warning)? [mkcert/openssl]: ` ); if (install.toLowerCase() === 'mkcert') { if (isMac) { log.info('Installing mkcert via Homebrew...'); try { execSync('brew install mkcert nss', { stdio: 'inherit' }); generateCertWithMkcert(hostname); log.success('mkcert installed and certificate generated.'); } catch { log.error('Homebrew install failed. Please install mkcert manually:'); log.hint(' https://github.com/FiloSottile/mkcert'); process.exitCode = 1; return; } } else if (isWindows) { log.info('Trying to install mkcert via winget...'); const winget = spawnSync('winget', ['install', '--id', 'FiloSottile.mkcert', '-e', '--silent'], { stdio: 'inherit' }); if (winget.status === 0) { log.success('mkcert installed via winget.'); generateCertWithMkcert(hostname); log.success('Certificate generated.'); } else { log.warn('winget install failed. Install mkcert manually:'); log.hint(' winget install --id FiloSottile.mkcert'); log.hint(' choco install mkcert'); log.hint(' scoop bucket add extras && scoop install mkcert'); log.hint('Then re-run: npm run dns'); process.exitCode = 1; return; } } else { log.hint('Please install mkcert: https://github.com/FiloSottile/mkcert'); log.hint('Then re-run: npm run dns'); process.exitCode = 1; return; } } else { generateCertWithOpenssl(hostname); log.success('Self-signed certificate generated (openssl).'); log.warn('You will need to accept the browser security warning, or trust the cert manually.'); if (isMac) { log.hint('To trust on macOS: open Keychain Access → import certs/server.crt → set to "Always Trust"'); } else if (isWindows) { log.hint('To trust on Windows: double-click certs/server.crt → Install Certificate → Local Machine → Trusted Root'); } } } } } // ── Save config ───────────────────────────────────────────────────────── writeConfig({ hostname, port, ip: ipAddress, ssl: 'true' }); // ── Update webpack.config.js ──────────────────────────────────────────── log.step('③ webpack.config.js'); updateWebpackConfig(hostname, port); // ── .gitignore ────────────────────────────────────────────────────────── ensureGitignore(); // ── Summary ───────────────────────────────────────────────────────────── console.log(`\n${colors.green}${colors.bright}✔ SSL-Setup abgeschlossen!${colors.reset}`); console.log(`\n Dev-Server läuft auf: ${colors.cyan}${colors.bright}https://${hostname}:${port}${colors.reset}`); console.log(` Starten mit: ${colors.bright}npm run dev${colors.reset}\n`); } main().catch((err) => { log.error(`Unexpected error: ${err.message}`); process.exit(1); });