#!/usr/bin/env node const { execFile, spawn } = require("node:child_process"); const fs = require("node:fs"); const os = require("node:os"); const path = require("node:path"); const https = require("node:https"); function printJson(payload, exitCode = 0, errorStream = false) { const body = `${JSON.stringify(payload, null, 2)}\n`; (errorStream ? process.stderr : process.stdout).write(body); process.exit(exitCode); } function usage() { return { usage: [ "node scripts/nordvpn-client.js status", "node scripts/nordvpn-client.js install", "node scripts/nordvpn-client.js login", "node scripts/nordvpn-client.js verify", 'node scripts/nordvpn-client.js verify --country "Italy"', 'node scripts/nordvpn-client.js verify --country "Italy" --city "Milan"', 'node scripts/nordvpn-client.js connect --country "Italy"', 'node scripts/nordvpn-client.js connect --city "Milan"', 'node scripts/nordvpn-client.js connect --country "Italy" --city "Milan"', "node scripts/nordvpn-client.js disconnect", ], env: [ "NORDVPN_TOKEN", "NORDVPN_TOKEN_FILE", "NORDVPN_USERNAME", "NORDVPN_PASSWORD", "NORDVPN_PASSWORD_FILE", ], }; } function parseArgs(argv) { const args = { _: [] }; for (let i = 0; i < argv.length; i += 1) { const token = argv[i]; if (token.startsWith("--")) { const key = token.slice(2); const next = argv[i + 1]; if (!next || next.startsWith("--")) { args[key] = true; } else { args[key] = next; i += 1; } } else { args._.push(token); } } return args; } function detectPlatform() { const raw = os.platform(); if (raw === "darwin") return "darwin"; if (raw === "linux") return "linux"; return raw; } function readSecret(envName, fileEnvName) { if (process.env[envName]) return process.env[envName]; const filePath = process.env[fileEnvName]; if (!filePath) return ""; try { return fs.readFileSync(filePath, "utf8").trim(); } catch { return ""; } } function normalizeLocation(value) { return `${value || ""}`.trim(); } function commandExists(name) { const pathEntries = `${process.env.PATH || ""}`.split(path.delimiter).filter(Boolean); for (const entry of pathEntries) { const full = path.join(entry, name); try { fs.accessSync(full, fs.constants.X_OK); return full; } catch { // Continue. } } return ""; } function fileExists(target) { try { fs.accessSync(target); return true; } catch { return false; } } function runExec(command, args = [], options = {}) { return new Promise((resolve) => { execFile(command, args, { ...options, encoding: "utf8" }, (error, stdout, stderr) => { resolve({ ok: !error, code: error && typeof error.code === "number" ? error.code : 0, stdout: stdout || "", stderr: stderr || "", error: error ? error.message : "", }); }); }); } function runInteractive(command, args = [], options = {}) { return new Promise((resolve, reject) => { const child = spawn(command, args, { stdio: "inherit", ...options }); child.on("error", reject); child.on("exit", (code) => resolve(code === 0)); }); } function runShell(command) { return runInteractive("bash", ["-lc", command]); } function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } function fetchJson(url) { return new Promise((resolve) => { const req = https.get( url, { headers: { "User-Agent": "nordvpn-client-skill/1.0", Accept: "application/json", }, }, (res) => { let data = ""; res.on("data", (chunk) => { data += chunk; }); res.on("end", () => { try { resolve({ ok: true, statusCode: res.statusCode || 0, json: JSON.parse(data) }); } catch (error) { resolve({ ok: false, statusCode: res.statusCode || 0, error: error.message, raw: data, }); } }); } ); req.on("error", (error) => resolve({ ok: false, error: error.message })); req.setTimeout(10000, () => { req.destroy(new Error("timeout")); }); }); } async function getPublicIpInfo() { const lookup = await fetchJson("https://ipapi.co/json/"); if (!lookup.ok) return { ok: false, error: lookup.error || "lookup failed" }; const json = lookup.json || {}; return { ok: true, ip: json.ip || "", city: json.city || "", region: json.region || "", country: json.country_name || json.country || "", countryCode: json.country_code || "", org: json.org || "", }; } async function probeCliStatus(cliPath) { const status = await runExec(cliPath, ["status"]); const account = await runExec(cliPath, ["account"]); const countries = await runExec(cliPath, ["countries"]); return { status, account, countries, }; } async function probeInstallation(platform) { const cliPath = commandExists("nordvpn"); const appPath = platform === "darwin" ? "/Applications/NordVPN.app" : ""; const brewPath = platform === "darwin" ? commandExists("brew") : ""; let cliProbe = null; if (cliPath) { cliProbe = await probeCliStatus(cliPath); } return { platform, cliPath, appPath, appInstalled: Boolean(appPath && fileExists(appPath)), brewPath, installed: Boolean(cliPath) || Boolean(appPath && fileExists(appPath)), cliProbe, }; } function inferAuthState(probe) { if (!probe || !probe.account) return null; const blob = `${probe.account.stdout}\n${probe.account.stderr}`.toLowerCase(); if (!blob.trim()) return null; if (probe.account.ok && !blob.includes("not logged")) return true; if (blob.includes("not logged") || blob.includes("login")) return false; return null; } function inferConnectionState(probe) { if (!probe || !probe.status) return null; const blob = `${probe.status.stdout}\n${probe.status.stderr}`.toLowerCase(); if (!blob.trim()) return null; if (blob.includes("connected")) return true; if (blob.includes("disconnected") || blob.includes("not connected")) return false; return null; } function buildStateSummary(installProbe, ipInfo) { const cliProbe = installProbe.cliProbe; return { platform: installProbe.platform, installed: installProbe.installed, cliAvailable: Boolean(installProbe.cliPath), cliPath: installProbe.cliPath || null, appInstalled: installProbe.appInstalled, appPath: installProbe.appInstalled ? installProbe.appPath : null, brewAvailable: Boolean(installProbe.brewPath), authenticated: inferAuthState(cliProbe), connected: inferConnectionState(cliProbe), localStatusRaw: cliProbe && cliProbe.status ? (cliProbe.status.stdout || cliProbe.status.stderr).trim() : "", publicIp: ipInfo.ok ? ipInfo : null, }; } async function installNordvpn(installProbe) { if (installProbe.installed) { return { changed: false, message: "NordVPN is already installed." }; } if (installProbe.platform === "darwin") { if (!installProbe.brewPath) { throw new Error("Homebrew is required on macOS to bootstrap NordVPN via brew cask."); } const ok = await runInteractive(installProbe.brewPath, ["install", "--cask", "nordvpn"]); if (!ok) { throw new Error("brew install --cask nordvpn failed"); } return { changed: true, message: "Installed NordVPN via Homebrew cask." }; } if (installProbe.platform === "linux") { const command = "TMP=$(mktemp) && " + "if command -v curl >/dev/null 2>&1; then curl -fsSL https://downloads.nordcdn.com/apps/linux/install.sh -o \"$TMP\"; " + "elif command -v wget >/dev/null 2>&1; then wget -qO \"$TMP\" https://downloads.nordcdn.com/apps/linux/install.sh; " + "else echo 'curl or wget required' >&2; exit 1; fi && sh \"$TMP\" && rm -f \"$TMP\""; const ok = await runShell(command); if (!ok) { throw new Error("NordVPN Linux installer failed"); } return { changed: true, message: "Installed NordVPN via the official Linux installer." }; } throw new Error(`Unsupported platform: ${installProbe.platform}`); } async function openMacApp() { const ok = await runInteractive("open", ["-a", "NordVPN"]); if (!ok) throw new Error("Failed to open NordVPN.app"); } async function loginNordvpn(installProbe, args) { const token = readSecret("NORDVPN_TOKEN", "NORDVPN_TOKEN_FILE"); const username = process.env.NORDVPN_USERNAME || ""; const password = readSecret("NORDVPN_PASSWORD", "NORDVPN_PASSWORD_FILE"); if (installProbe.cliPath) { if (token) { const result = await runExec(installProbe.cliPath, ["login", "--token", token]); if (!result.ok) { throw new Error((result.stderr || result.stdout || result.error).trim() || "nordvpn login --token failed"); } return { mode: "cli-token", message: "Logged in using token." }; } if (username && password && installProbe.platform === "darwin") { // macOS CLI login support is not documented. Fall back to interactive CLI login. } const ok = await runInteractive(installProbe.cliPath, ["login"]); if (!ok) throw new Error("nordvpn login failed"); return { mode: "cli-interactive", message: "Interactive NordVPN login completed." }; } if (installProbe.platform === "darwin" && installProbe.appInstalled) { await openMacApp(); return { mode: "app-manual", manualActionRequired: true, message: "Opened NordVPN.app. Complete login in the app/browser flow, then rerun status or connect. macOS app login is browser-based according to NordVPN support docs.", }; } throw new Error("NordVPN is not installed."); } function buildConnectTarget(args) { const country = normalizeLocation(args.country); const city = normalizeLocation(args.city); if (!country && !city) { throw new Error("connect requires --country, --city, or both."); } return { country, city }; } function locationMatches(ipInfo, target) { if (!ipInfo || !ipInfo.ok) return false; const countryMatch = !target.country || ipInfo.country.toLowerCase().includes(target.country.toLowerCase()); const cityMatch = !target.city || ipInfo.city.toLowerCase().includes(target.city.toLowerCase()); return countryMatch && cityMatch; } async function verifyConnection(target) { const ipInfo = await getPublicIpInfo(); return { ok: target ? locationMatches(ipInfo, target) : Boolean(ipInfo && ipInfo.ok), ipInfo, }; } async function connectViaCli(cliPath, target) { const attempts = []; if (target.city) { attempts.push([target.city]); } if (target.country) { attempts.push([target.country]); } if (target.country && target.city) { attempts.push([`${target.country} ${target.city}`]); } let lastFailure = ""; for (const args of attempts) { const result = await runExec(cliPath, ["connect", ...args]); if (result.ok) { return { cliTarget: args.join(" "), raw: result.stdout.trim() || result.stderr.trim() }; } lastFailure = (result.stderr || result.stdout || result.error).trim(); } throw new Error(lastFailure || "NordVPN connect failed"); } async function connectViaMacApp(target) { await openMacApp(); return { manualActionRequired: true, message: `Opened NordVPN.app. Connect manually to ${target.city ? `${target.city}, ` : ""}${target.country || "the requested location"} and rerun status/verify before the follow-up task.`, }; } async function disconnectNordvpn(installProbe) { if (installProbe.cliPath) { const result = await runExec(installProbe.cliPath, ["disconnect"]); if (!result.ok) { throw new Error((result.stderr || result.stdout || result.error).trim() || "nordvpn disconnect failed"); } return { message: "Disconnected from NordVPN." }; } if (installProbe.platform === "darwin" && installProbe.appInstalled) { await openMacApp(); return { manualActionRequired: true, message: "Opened NordVPN.app. Disconnect manually because no usable CLI was detected on macOS.", }; } throw new Error("NordVPN is not installed."); } async function main() { const args = parseArgs(process.argv.slice(2)); const action = args._[0]; if (!action || args.help) { printJson(usage(), action ? 0 : 1, !action); } const platform = detectPlatform(); if (!["darwin", "linux"].includes(platform)) { printJson({ error: `Unsupported platform: ${platform}` }, 1, true); } const installProbe = await probeInstallation(platform); try { if (action === "status") { const ipInfo = await getPublicIpInfo(); printJson(buildStateSummary(installProbe, ipInfo)); } if (action === "install") { const result = await installNordvpn(installProbe); const refreshed = await probeInstallation(platform); printJson({ action, ...result, state: buildStateSummary(refreshed, await getPublicIpInfo()), }); } if (action === "login") { const result = await loginNordvpn(installProbe, args); const refreshed = await probeInstallation(platform); printJson({ action, ...result, state: buildStateSummary(refreshed, await getPublicIpInfo()), }); } if (action === "verify") { const target = args.country || args.city ? buildConnectTarget(args) : null; const verified = await verifyConnection(target); const refreshed = await probeInstallation(platform); printJson({ action, requestedTarget: target, verified: verified.ok, verification: verified.ipInfo, state: buildStateSummary(refreshed, verified.ipInfo), }, verified.ok ? 0 : 1, !verified.ok); } if (action === "connect") { const target = buildConnectTarget(args); if (!installProbe.installed) { throw new Error("NordVPN is not installed."); } let connectResult; if (installProbe.cliPath) { connectResult = await connectViaCli(installProbe.cliPath, target); } else if (platform === "darwin" && installProbe.appInstalled) { connectResult = await connectViaMacApp(target); } else { throw new Error("No usable NordVPN control path found."); } if (connectResult.manualActionRequired) { printJson({ action, requestedTarget: target, ...connectResult }); } await sleep(3000); const verified = await verifyConnection(target); const refreshed = await probeInstallation(platform); printJson({ action, requestedTarget: target, connectResult, verified: verified.ok, verification: verified.ipInfo, state: buildStateSummary(refreshed, verified.ipInfo), }, verified.ok ? 0 : 1, !verified.ok); } if (action === "disconnect") { const result = await disconnectNordvpn(installProbe); const refreshed = await probeInstallation(platform); printJson({ action, ...result, state: buildStateSummary(refreshed, await getPublicIpInfo()), }); } printJson({ error: `Unknown action: ${action}`, ...usage() }, 1, true); } catch (error) { printJson({ error: error.message || String(error), action }, 1, true); } } main();