#!/usr/bin/env node // Proxy Bridge installer — idempotent, interactive where information is missing. // Replaces the retired legacy scripts (install_proxy.sh, update_apt_proxy.sh, // update_bashrc_proxy.sh, update_service_proxy.sh, get_proxy.sh, setup.js). // // node install.js interactive install/repair (re-runnable) // node install.js --dry-run print the plan, change nothing // node install.js --non-interactive use existing/defaults; fail listing missing info // node install.js --no-systemd skip systemd user service + system service steps // // Hard rules honored here: // - Passwords are NEVER printed, logged, or stored in a file. Username goes to // user.json; the password goes to the keyring via secret-tool only AFTER a // single successful verification against the upstream (never on failure). // - Keyring presence is probed by `secret-tool lookup`'s exit code only. const fs = require('fs'); const os = require('os'); const path = require('path'); const readline = require('readline'); const { spawnSync, execFileSync } = require('child_process'); // --------------------------------------------------------------------------- // CLI parsing + paths // --------------------------------------------------------------------------- const args = process.argv.slice(2); const FLAGS = { dryRun: args.includes('--dry-run'), nonInteractive: args.includes('--non-interactive'), noSystemd: args.includes('--no-systemd'), }; const BRIDGE_DIR = process.env.PROXY_BRIDGE_DIR || '/opt/proxy-bridge'; const BIN_DIR = process.env.PROXY_BRIDGE_BIN_DIR || path.join(os.homedir(), '.local', 'bin'); const SYSTEMD_USER_DIR = process.env.PROXY_BRIDGE_SYSTEMD_USER_DIR || path.join(os.homedir(), '.config', 'systemd', 'user'); const SRC_DIR = __dirname; // install.js lives next to bridge.js/proxyctl.js in the repo const BRIDGE_URL = 'http://127.0.0.1:8888/'; const BASHRC = process.env.PROXY_BRIDGE_BASHRC || path.join(os.homedir(), '.bashrc'); const APT_CONF = process.env.PROXY_BRIDGE_APT_CONF || '/etc/apt/apt.conf.d/80proxy-bridge'; const RUNTIME_FILES = ['config.json', 'user.json', 'profile.json', 'overrides.json', 'remotes.json']; const CODE_FILES = ['bridge.js', 'proxyctl.js']; const sectionResults = []; // { section, status: CHANGED|OK|SKIPPED|FAILED, detail } function report(section, status, detail = '') { sectionResults.push({ section, status, detail }); const color = { CHANGED: '\x1b[33m', OK: '\x1b[32m', SKIPPED: '\x1b[36m', FAILED: '\x1b[31m' }[status] || ''; console.log(`${color}[${status}]\x1b[0m ${section}${detail ? ` — ${detail}` : ''}`); } function run(cmd, args, opts = {}) { if (FLAGS.dryRun && !opts.readOnly) { return { status: 0, stdout: '', stderr: '', dryRun: true }; } return spawnSync(cmd, args, { encoding: 'utf8', ...opts }); } function sudoOk() { const r = run('sudo', ['-n', 'true'], { readOnly: true }); return r.status === 0; } // --------------------------------------------------------------------------- // Interactive prompting // --------------------------------------------------------------------------- let rl = null; function getRl() { if (!rl) rl = readline.createInterface({ input: process.stdin, output: process.stdout }); return rl; } function ask(question, defaultValue = '') { return new Promise((resolve) => { getRl().question(question + (defaultValue ? ` [${defaultValue}]: ` : ': '), (a) => { resolve(a.trim() || defaultValue); }); }); } function askSecret(question) { return new Promise((resolve) => { const r = getRl(); const oldWrite = r._writeToOutput; r._writeToOutput = function (s) { if (s === '\n' || s === '\r' || s === '\r\n') r.output.write(s); else if (s.includes(question)) r.output.write(s); // initial prompt else r.output.write('*'); }; r.question(question + ': ', (a) => { r._writeToOutput = oldWrite; r.output.write('\n'); resolve(a.trim()); }); }); } async function askConfirm(question, defaultYes = true) { if (FLAGS.nonInteractive) return defaultYes; const a = (await ask(`${question} (y/n)`, defaultYes ? 'y' : 'n')).toLowerCase(); return a === 'y'; } function requireInfo(name, value) { if (value) return value; throw new Error(`MISSING_INFO:${name}`); } // --------------------------------------------------------------------------- // Section: preflight // --------------------------------------------------------------------------- function sectionPreflight() { const S = 'Preflight'; if (process.getuid && process.getuid() === 0) { report(S, 'FAILED', 'run as your user, not root — sudo is invoked per-step when needed'); return false; } const nodeV = run('node', ['--version'], { readOnly: true }); if (nodeV.status !== 0) { report(S, 'FAILED', 'node not on PATH'); return false; } const st = run('which', ['secret-tool'], { readOnly: true }); if (st.status !== 0) { if (FLAGS.nonInteractive) { report(S, 'FAILED', 'libsecret-tools missing (apt install -y libsecret-tools)'); return false; } report(S, 'CHANGED', 'libsecret-tools missing — will attempt install (sudo apt-get install -y libsecret-tools)'); run('sudo', ['apt-get', 'install', '-y', 'libsecret-tools', '-qq']); if (run('which', ['secret-tool'], { readOnly: true }).status !== 0) { report(S, 'FAILED', 'libsecret-tools install failed'); return false; } return true; } report(S, 'OK', `node ${nodeV.stdout.trim()}, secret-tool present`); return true; } // --------------------------------------------------------------------------- // Section: deploy code to BRIDGE_DIR // --------------------------------------------------------------------------- function sectionDeploy() { const S = 'Deploy code'; let changed = false; if (!FLAGS.dryRun) { fs.mkdirSync(BRIDGE_DIR, { recursive: true }); } for (const f of CODE_FILES) { const src = path.join(SRC_DIR, f); const dst = path.join(BRIDGE_DIR, f); const srcBuf = fs.readFileSync(src); let dstBuf = null; try { dstBuf = fs.readFileSync(dst); } catch {} if (!dstBuf || !srcBuf.equals(dstBuf)) { changed = true; if (!FLAGS.dryRun) { fs.copyFileSync(src, dst); if (f === 'proxyctl.js') fs.chmodSync(dst, 0o755); } } } const cliLink = path.join(BIN_DIR, 'proxy-bridge'); const wantTarget = path.join(BRIDGE_DIR, 'proxyctl.js'); let current = null; try { current = fs.readlinkSync(cliLink); } catch {} if (current !== wantTarget) { changed = true; if (!FLAGS.dryRun) { fs.mkdirSync(BIN_DIR, { recursive: true }); try { fs.unlinkSync(cliLink); } catch {} fs.symlinkSync(wantTarget, cliLink); } } report(S, changed ? 'CHANGED' : 'OK', changed ? 'code deployed to ' + BRIDGE_DIR : 'bridge.js/proxyctl.js identical, CLI symlink in place'); return true; } // --------------------------------------------------------------------------- // Section: import / create runtime config (non-destructive) // --------------------------------------------------------------------------- function readJson(p) { try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return null; } } function fileExists(p) { try { fs.accessSync(p); return true; } catch { return false; } } function keyringHasPassword(username) { if (!username) return false; const r = run('secret-tool', ['lookup', 'service', 'proxy-bridge', 'account', username], { readOnly: true }); // Never inspect r.stdout — just the exit code. return r.status === 0 && !r.error; } async function sectionConfig() { const S = 'Runtime config'; const configPath = path.join(BRIDGE_DIR, 'config.json'); const userPath = path.join(BRIDGE_DIR, 'user.json'); const existingConfig = readJson(configPath); const existingUser = readJson(userPath); // Import existing — show, ask before changing. if (existingConfig && existingConfig.host) { const enabled = existingConfig.enabled !== false; report(S, 'OK', `imported existing: ${enabled ? 'enabled' : 'disabled'} upstream ${existingConfig.host}:${existingConfig.port} (kept)`); if (!enabled) return { enabled: false }; const username = existingUser && existingUser.username ? existingUser.username : null; return { enabled: true, host: existingConfig.host, port: String(existingConfig.port), username }; } // Nothing to import — ask. if (FLAGS.nonInteractive) { report(S, 'FAILED', 'no existing config.json to import and --non-interactive set (missing: upstream host/port or profiles mode)'); return null; } console.log('\n--- Upstream proxy setup (no existing configuration found) ---'); const enable = await askConfirm('Enable corporate upstream proxy?', true); if (!enable) { const cfg = { enabled: false }; if (!FLAGS.dryRun) fs.writeFileSync(configPath, JSON.stringify(cfg, null, 2)); report(S, 'CHANGED', 'wrote config.json with upstream disabled'); return { enabled: false }; } const host = requireInfo('upstream-host', await ask('Corporate proxy hostname')); const port = await ask('Corporate proxy port', '8080'); const username = requireInfo('username', await ask('Corporate username')); const cfg = { enabled: true, host, port }; if (!FLAGS.dryRun) { fs.writeFileSync(configPath, JSON.stringify(cfg, null, 2)); fs.writeFileSync(userPath, JSON.stringify({ username })); } report(S, 'CHANGED', `wrote config.json (${host}:${port}) + user.json`); return { enabled: true, host, port: String(port), username }; } // --------------------------------------------------------------------------- // Section: credentials — verify before storing (anti-lockout) // --------------------------------------------------------------------------- // One single CONNECT attempt with candidate creds directly against the upstream. // NEVER retries, NEVER prints the password, resolves: ok | authfail | unreachable function verifyCredentials(host, port, username, password) { return new Promise((resolve) => { const net = require('net'); const socket = net.connect(Number(port), host); let buf = Buffer.alloc(0); const finish = (r) => { try { socket.destroy(); } catch {} resolve(r); }; socket.setTimeout(10000, () => finish('unreachable')); socket.once('connect', () => { const auth = 'Basic ' + Buffer.from(`${username}:${password}`).toString('base64'); socket.write(`CONNECT 127.0.0.1:443 HTTP/1.1\r\nHost: 127.0.0.1:443\r\nProxy-Authorization: ${auth}\r\n\r\n`); }); socket.on('data', (d) => { buf = Buffer.concat([buf, d]); const head = buf.toString('latin1').split('\r\n')[0] || ''; const m = head.match(/^HTTP\/\d\.\d\s+(\d{3})/i); if (m || buf.includes('\r\n\r\n')) { const code = m ? Number(m[1]) : 0; // 200/4xx non-407 (e.g. 403 blocked target) = creds accepted; 407 = rejected. finish(code === 407 ? 'authfail' : (code > 0 ? 'ok' : 'unreachable')); } }); socket.once('error', () => finish('unreachable')); socket.once('end', () => { if (buf.length === 0) finish('unreachable'); }); }); } async function sectionCredentials(config) { const S = 'Credentials'; if (!config || !config.enabled) { report(S, 'SKIPPED', 'upstream disabled'); return true; } const username = config.username; if (username && keyringHasPassword(username)) { report(S, 'OK', `keyring entry present for account (not displayed)`); return true; } if (FLAGS.nonInteractive) { report(S, 'FAILED', `keyring has no password for the configured account and --non-interactive set (missing: password)`); return false; } console.log('\n--- Credential setup (one verification round-trip, result never stored on failure) ---'); const user = username || requireInfo('username', await ask('Corporate username')); for (let attempt = 1; attempt <= 3; attempt++) { const pass = await askSecret(`Corporate password for ${user} (attempt ${attempt}/3)`); if (!pass) { console.log(' empty password, try again'); continue; } const verdict = await verifyCredentials(config.host, config.port, user, pass); if (verdict === 'ok') { if (!FLAGS.dryRun) { execFileSync('secret-tool', ['store', '--label=Proxy Bridge Credentials', 'service', 'proxy-bridge', 'account', user], { input: pass }); } report(S, attempt > 1 ? 'CHANGED' : 'OK', 'credentials verified against upstream and stored in keyring'); return true; } if (verdict === 'authfail') console.log(' upstream rejected these credentials (407). Not stored. Check the password.'); else console.log(' could not reach the upstream proxy to verify. Credentials NOT stored (retry when online).'); if (verdict === 'unreachable') return false; // don't re-prompt if we can't even verify } report(S, 'FAILED', 'credentials could not be verified after 3 attempts — nothing stored'); return false; } // --------------------------------------------------------------------------- // Section: legacy artifact migration // --------------------------------------------------------------------------- async function sectionLegacy() { const S = 'Legacy migration'; const findings = []; // bashrc: legacy marker sourcing ~/.http_proxy (cred-bearing file from update_bashrc_proxy.sh) let bashrc = ''; try { bashrc = fs.readFileSync(BASHRC, 'utf8'); } catch {} const legacyLineRe = /^.*#PROXY_A93JK2.*$/m; const legacyMatch = bashrc.match(legacyLineRe); const credFiles = ['.http_proxy', '.http_proxy.off', '.http_noproxy'].map(f => path.join(os.homedir(), f)).filter(fileExists); if (legacyMatch || credFiles.length > 0) { findings.push({ what: 'bashrc #PROXY_A93JK2 block' + (credFiles.length ? ` + ${credFiles.join(', ')} (contain PLAINTEXT credentials)` : ''), fix: 'remove legacy line' + (credFiles.length ? ' and delete cred files' : '') }); } // apt legacy file with userinfo const aptLegacy = '/etc/apt/apt.conf.d/80proxy'; let aptLegacyHasCreds = false; try { const content = fs.readFileSync(aptLegacy, 'utf8'); aptLegacyHasCreds = /:\/\/[^/\s]+:[^@\s]+@/.test(content); // presence only } catch {} if (aptLegacyHasCreds) findings.push({ what: `${aptLegacy} (contains embedded credentials)`, fix: 'delete (80proxy-bridge supersedes)' }); // snap proxy with userinfo const snapGet = run('snap', ['get', 'system', 'proxy.http', 'proxy.https'], { readOnly: true }); if (snapGet.status === 0 && /:\/\/[^/\s]+:[^@\s]+@/.test(snapGet.stdout)) { findings.push({ what: 'snap system proxy.* (contain embedded credentials)', fix: 'point at bridge URL without creds (needs sudo)' }); } // systemd service drop-ins with userinfo for (const svc of ['docker', 'snapd', 'ollama']) { const f = `/etc/systemd/system/${svc}.service.d/http-proxy.conf`; try { const content = fs.readFileSync(f, 'utf8'); if (/:\/\/[^/\s]+:[^@\s]+@/.test(content)) { findings.push({ what: `${f} (contains embedded credentials)`, fix: `rewrite to bridge URL (needs sudo)` }); } } catch {} } if (findings.length === 0) { report(S, 'OK', 'no legacy credential-embedding artifacts found'); return true; } console.log('\nLegacy artifacts found:'); findings.forEach((f, i) => console.log(` ${i + 1}. ${f.what}\n fix: ${f.fix}`)); const proceed = await askConfirm('Migrate/remove these now?', true); if (!proceed) { report(S, 'SKIPPED', 'user deferred legacy migration'); return true; } let changed = false; if (legacyMatch) { if (!FLAGS.dryRun) { const cleaned = bashrc.split('\n').filter((line, idx, arr) => { if (line.includes('#PROXY_A93JK2')) return false; return true; }).join('\n'); fs.writeFileSync(BASHRC, cleaned); } changed = true; } if (credFiles.length > 0) { const del = await askConfirm(`Delete plaintext-credential file(s) ${credFiles.join(', ')}?`, true); if (del && !FLAGS.dryRun) credFiles.forEach(f => fs.rmSync(f, { force: true })); changed = changed || del; } if (aptLegacyHasCreds) { if (!FLAGS.dryRun) run('sudo', ['rm', '-f', aptLegacy]); changed = true; } if (findings.some(f => f.what.startsWith('snap system'))) { if (!FLAGS.dryRun) { run('sudo', ['snap', 'set', 'system', `proxy.http=${BRIDGE_URL}`, `proxy.https=${BRIDGE_URL}`]); } changed = true; } for (const svc of ['docker', 'snapd', 'ollama']) { const f = `/etc/systemd/system/${svc}.service.d/http-proxy.conf`; try { const content = fs.readFileSync(f, 'utf8'); if (/:\/\/[^/\s]+:[^@\s]+@/.test(content)) { if (!FLAGS.dryRun) { const clean = `[Service]\nEnvironment="HTTP_PROXY=${BRIDGE_URL}"\nEnvironment="HTTPS_PROXY=${BRIDGE_URL}"\nEnvironment="NO_PROXY=localhost,127.0.0.1,::1"\n`; fs.writeFileSync(`/tmp/proxy-bridge-dropin.tmp`, clean); run('sudo', ['cp', `/tmp/proxy-bridge-dropin.tmp`, f]); fs.rmSync(`/tmp/proxy-bridge-dropin.tmp`, { force: true }); } changed = true; } } catch {} } report(S, changed ? 'CHANGED' : 'OK', changed ? `migrated ${findings.length} artifact(s)` : 'no changes needed'); return true; } // --------------------------------------------------------------------------- // Section: systemd user service // --------------------------------------------------------------------------- function sectionUnit() { const S = 'systemd service'; if (FLAGS.noSystemd) { report(S, 'SKIPPED', '--no-systemd'); return true; } const unitPath = path.join(SYSTEMD_USER_DIR, 'proxy-bridge.service'); const nodeBin = process.execPath; const want = `[Unit]\nDescription=Local Proxy Bridge (Keyring Auth)\nAfter=network.target\n\n[Service]\nType=simple\nExecStart=${nodeBin} ${BRIDGE_DIR}/bridge.js\nRestart=on-failure\nRestartSec=5\n\n[Install]\nWantedBy=default.target\n`; let current = null; try { current = fs.readFileSync(unitPath, 'utf8'); } catch {} if (current === want) { report(S, 'OK', 'unit file up to date'); return true; } if (!FLAGS.dryRun) { fs.mkdirSync(SYSTEMD_USER_DIR, { recursive: true }); fs.writeFileSync(unitPath, want); run('systemctl', ['--user', 'daemon-reload']); run('systemctl', ['--user', 'enable', 'proxy-bridge.service']); } report(S, 'CHANGED', 'unit file written + enabled (restart happens in verify section with your consent)'); return true; } // --------------------------------------------------------------------------- // Section: system-wide proxy point-at-bridge (idempotent) // --------------------------------------------------------------------------- async function sectionSystemWide() { const S = 'System-wide proxy'; let changed = false; // bashrc bridge block (marker-guarded) let bashrc = ''; try { bashrc = fs.readFileSync(BASHRC, 'utf8'); } catch {} const marker = '#PROXY_BRIDGE_CONFIG'; const block = `\n# Local Proxy Bridge Configuration ${marker}\nexport http_proxy="${BRIDGE_URL}"\nexport https_proxy="${BRIDGE_URL}"\nexport HTTP_PROXY="${BRIDGE_URL}"\nexport HTTPS_PROXY="${BRIDGE_URL}"\nexport NO_PROXY="localhost,127.0.0.1,::1"\nexport no_proxy="localhost,127.0.0.1,::1"\n`; if (!bashrc.includes(marker)) { changed = true; if (!FLAGS.dryRun) fs.writeFileSync(BASHRC, bashrc.replace(/\n*$/, '\n') + block); } // apt conf (needs sudo; content-compare via sudo cat is awkward — check existence + no-userinfo suffices for OK) let aptOk = false; try { const c = fs.readFileSync(APT_CONF, 'utf8'); aptOk = c.includes('127.0.0.1:8888') && !/:\/\/[^/\s]+:[^@\s]+@/.test(c); } catch {} if (!aptOk) { const doIt = FLAGS.nonInteractive ? true : await askConfirm(`Write ${APT_CONF} with bridge URL (sudo)?`, true); if (doIt) { changed = true; if (!FLAGS.dryRun) { const tmpW = `/tmp/proxy-bridge-apt.tmp`; fs.writeFileSync(tmpW, `Acquire::http::Proxy "${BRIDGE_URL}";\nAcquire::https::Proxy "${BRIDGE_URL}";\n`); run('sudo', ['cp', tmpW, APT_CONF]); fs.rmSync(tmpW, { force: true }); } } } // service drop-ins (docker/snapd/ollama) — bridge URL only for (const svc of ['docker', 'snapd', 'ollama']) { const f = `/etc/systemd/system/${svc}.service.d/http-proxy.conf`; let ok = false; try { const c = fs.readFileSync(f, 'utf8'); ok = c.includes('127.0.0.1:8888') && !/:\/\/[^/\s]+:[^@\s]+@/.test(c); } catch {} if (!ok) { const svcExists = run('systemctl', ['list-unit-files', `${svc}.service`], { readOnly: true }); if (svcExists.status !== 0 || !svcExists.stdout.includes(svc)) continue; // service not installed const doIt = FLAGS.nonInteractive ? true : await askConfirm(`Point ${svc} at the bridge (sudo + restart ${svc})?`, true); if (doIt) { changed = true; if (!FLAGS.dryRun) { const tmpW = `/tmp/proxy-bridge-dropin.tmp`; fs.writeFileSync(tmpW, `[Service]\nEnvironment="HTTP_PROXY=${BRIDGE_URL}"\nEnvironment="HTTPS_PROXY=${BRIDGE_URL}"\nEnvironment="NO_PROXY=localhost,127.0.0.1,::1"\n`); run('sudo', ['mkdir', '-p', path.dirname(f)]); run('sudo', ['cp', tmpW, f]); fs.rmSync(tmpW, { force: true }); run('sudo', ['systemctl', 'daemon-reload']); run('sudo', ['systemctl', 'restart', svc]); } } } } report(S, changed ? 'CHANGED' : 'OK', changed ? 'system-wide proxy config updated' : 'bashrc/apt/services already point at the bridge'); return true; } // --------------------------------------------------------------------------- // Section: verify (service restart happens here, with consent) // --------------------------------------------------------------------------- async function sectionVerify() { const S = 'Verify'; if (FLAGS.noSystemd) { report(S, 'SKIPPED', '--no-systemd (no restart/checks)'); return true; } const active = run('systemctl', ['--user', 'is-active', 'proxy-bridge'], { readOnly: true }).stdout.trim(); if (active !== 'active') { const doIt = FLAGS.nonInteractive ? true : await askConfirm('Start/restart proxy-bridge now? (drops bridged connectivity briefly)', true); if (doIt && !FLAGS.dryRun) { run('systemctl', ['--user', 'restart', 'proxy-bridge']); await new Promise(r => setTimeout(r, 1500)); } } const state = run('systemctl', ['--user', 'is-active', 'proxy-bridge'], { readOnly: true }).stdout.trim(); if (state !== 'active') { report(S, 'FAILED', `service state: ${state || 'unknown'}`); return false; } // ports listening const ss = run('ss', ['-tln'], { readOnly: true }); const ports = [8888, 8889, 8890, 8891]; const listening = ports.filter(p => ss.stdout.includes(`127.0.0.1:${p}`)); const allCore = listening.length === ports.length; report(S, allCore ? 'OK' : 'FAILED', `service active; core ports listening: ${listening.join(',') || 'none'}`); return allCore; } // --------------------------------------------------------------------------- // Main // --------------------------------------------------------------------------- async function main() { console.log(`=== Proxy Bridge installer ${FLAGS.dryRun ? '(DRY RUN) ' : ''}===`); console.log(`target: ${BRIDGE_DIR}`); const missingInfo = []; let credsFailed = false; try { if (!sectionPreflight()) throw new Error('PREFLIGHT_FAILED'); if (!sectionDeploy()) throw new Error('DEPLOY_FAILED'); const config = await sectionConfig(); if (config === null) throw new Error('CONFIG_FAILED'); credsFailed = !(await sectionCredentials(config)); // Do not abort on credential failure: remaining sections show/prep what // else needs doing, and the summary still exits non-zero. if (!await sectionLegacy()) throw new Error('LEGACY_FAILED'); if (!sectionUnit()) throw new Error('UNIT_FAILED'); if (!await sectionSystemWide()) throw new Error('SYSWIDE_FAILED'); if (!FLAGS.dryRun && !credsFailed) await sectionVerify(); else report('Verify', 'SKIPPED', FLAGS.dryRun ? 'dry run' : 'credentials not verified; skipping restart'); } catch (e) { if (String(e.message).startsWith('MISSING_INFO:')) { missingInfo.push(e.message.split(':')[1]); } else if (!['PREFLIGHT_FAILED', 'DEPLOY_FAILED', 'CONFIG_FAILED', 'CREDS_FAILED', 'LEGACY_FAILED', 'UNIT_FAILED', 'SYSWIDE_FAILED'].includes(e.message)) { throw e; } } finally { if (rl) rl.close(); } const failed = sectionResults.filter(r => r.status === 'FAILED'); console.log('\n=== Summary ==='); sectionResults.forEach(r => console.log(` ${r.status.padEnd(8)} ${r.section}`)); if (missingInfo.length) console.log(`\nMissing information for --non-interactive: ${missingInfo.join(', ')}`); if (FLAGS.dryRun) console.log('\nDry run: no changes were made.'); process.exit(failed.length > 0 || missingInfo.length > 0 ? 1 : 0); } main().catch(e => { console.error('installer error:', e.message); process.exit(1); });