cs-init
Version:
534 lines (442 loc) • 22.6 kB
JavaScript
/**
* 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)
*/
;
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);
});