Compare commits
3 Commits
feature/we
...
2612cef1dc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2612cef1dc | ||
|
|
120721bbc6 | ||
|
|
fe5b4659fe |
@@ -18,6 +18,7 @@ This repository contains practical OpenClaw skills and companion integrations. I
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `elevenlabs-stt` | Transcribe local audio files with ElevenLabs Speech-to-Text, with diarization, language hints, event tags, and JSON output. | `skills/elevenlabs-stt` |
|
| `elevenlabs-stt` | Transcribe local audio files with ElevenLabs Speech-to-Text, with diarization, language hints, event tags, and JSON output. | `skills/elevenlabs-stt` |
|
||||||
| `gitea-api` | Interact with Gitea via REST API (repos, issues, PRs, releases, branches, user info). | `skills/gitea-api` |
|
| `gitea-api` | Interact with Gitea via REST API (repos, issues, PRs, releases, branches, user info). | `skills/gitea-api` |
|
||||||
|
| `nordvpn-client` | Install, log in to, connect, disconnect, and verify NordVPN sessions on macOS and Linux. | `skills/nordvpn-client` |
|
||||||
| `portainer` | Manage Portainer stacks via API (list, start/stop/restart, update, prune images). | `skills/portainer` |
|
| `portainer` | Manage Portainer stacks via API (list, start/stop/restart, update, prune images). | `skills/portainer` |
|
||||||
| `searxng` | Search through a local or self-hosted SearXNG instance for web, news, images, and more. | `skills/searxng` |
|
| `searxng` | Search through a local or self-hosted SearXNG instance for web, news, images, and more. | `skills/searxng` |
|
||||||
| `web-automation` | One-shot extraction plus broader browsing/scraping with Playwright-compatible CloakBrowser (auth flows, extraction, bot-protected sites). | `skills/web-automation` |
|
| `web-automation` | One-shot extraction plus broader browsing/scraping with Playwright-compatible CloakBrowser (auth flows, extraction, bot-protected sites). | `skills/web-automation` |
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ This folder contains detailed docs for each skill in this repository.
|
|||||||
|
|
||||||
- [`elevenlabs-stt`](elevenlabs-stt.md) — Local audio transcription through ElevenLabs Speech-to-Text
|
- [`elevenlabs-stt`](elevenlabs-stt.md) — Local audio transcription through ElevenLabs Speech-to-Text
|
||||||
- [`gitea-api`](gitea-api.md) — REST-based Gitea automation (no `tea` CLI required)
|
- [`gitea-api`](gitea-api.md) — REST-based Gitea automation (no `tea` CLI required)
|
||||||
|
- [`nordvpn-client`](nordvpn-client.md) — Cross-platform NordVPN install, login, connect, disconnect, and verification
|
||||||
- [`portainer`](portainer.md) — Portainer stack management (list, lifecycle, updates, image pruning)
|
- [`portainer`](portainer.md) — Portainer stack management (list, lifecycle, updates, image pruning)
|
||||||
- [`searxng`](searxng.md) — Privacy-respecting metasearch via a local or self-hosted SearXNG instance
|
- [`searxng`](searxng.md) — Privacy-respecting metasearch via a local or self-hosted SearXNG instance
|
||||||
- [`web-automation`](web-automation.md) — One-shot extraction plus Playwright-compatible CloakBrowser browser automation and scraping
|
- [`web-automation`](web-automation.md) — One-shot extraction plus Playwright-compatible CloakBrowser browser automation and scraping
|
||||||
|
|||||||
77
docs/nordvpn-client.md
Normal file
77
docs/nordvpn-client.md
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
# nordvpn-client
|
||||||
|
|
||||||
|
Cross-platform NordVPN lifecycle skill for macOS and Linux.
|
||||||
|
|
||||||
|
## What it does
|
||||||
|
|
||||||
|
- Probes whether NordVPN is already installed
|
||||||
|
- Bootstraps NordVPN if missing
|
||||||
|
- Handles login bootstrap
|
||||||
|
- Connects to a country or city target
|
||||||
|
- Disconnects and reports status
|
||||||
|
- Verifies public IP and geolocation after connect
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node skills/nordvpn-client/scripts/nordvpn-client.js status
|
||||||
|
node skills/nordvpn-client/scripts/nordvpn-client.js install
|
||||||
|
node skills/nordvpn-client/scripts/nordvpn-client.js login
|
||||||
|
node skills/nordvpn-client/scripts/nordvpn-client.js verify
|
||||||
|
node skills/nordvpn-client/scripts/nordvpn-client.js verify --country "Italy"
|
||||||
|
node skills/nordvpn-client/scripts/nordvpn-client.js verify --country "Italy" --city "Milan"
|
||||||
|
node skills/nordvpn-client/scripts/nordvpn-client.js connect --country "Italy"
|
||||||
|
node skills/nordvpn-client/scripts/nordvpn-client.js connect --city "Milan"
|
||||||
|
node skills/nordvpn-client/scripts/nordvpn-client.js disconnect
|
||||||
|
```
|
||||||
|
|
||||||
|
## Platform behavior
|
||||||
|
|
||||||
|
### macOS
|
||||||
|
|
||||||
|
- install path: `brew install --cask nordvpn`
|
||||||
|
- probe order:
|
||||||
|
- `nordvpn` CLI if present
|
||||||
|
- `NordVPN.app`
|
||||||
|
- if the installed package exposes a usable CLI, the skill uses it
|
||||||
|
- otherwise it opens the app and returns a clear manual-action-required result for login/connect/disconnect
|
||||||
|
|
||||||
|
### Linux
|
||||||
|
|
||||||
|
- install path follows NordVPN's official installer script
|
||||||
|
- primary control path is the official `nordvpn` CLI
|
||||||
|
- token login is supported through `nordvpn login --token <token>`
|
||||||
|
|
||||||
|
## Credentials
|
||||||
|
|
||||||
|
Supported env vars:
|
||||||
|
|
||||||
|
- `NORDVPN_TOKEN`
|
||||||
|
- `NORDVPN_TOKEN_FILE`
|
||||||
|
- `NORDVPN_USERNAME`
|
||||||
|
- `NORDVPN_PASSWORD`
|
||||||
|
- `NORDVPN_PASSWORD_FILE`
|
||||||
|
|
||||||
|
Do not put secrets in the skill docs or repo.
|
||||||
|
|
||||||
|
## Verification model
|
||||||
|
|
||||||
|
`status`, `verify`, and `connect` emit JSON suitable for agent use:
|
||||||
|
|
||||||
|
- platform
|
||||||
|
- install state
|
||||||
|
- auth state
|
||||||
|
- connection state
|
||||||
|
- requested target
|
||||||
|
- public IP / geolocation lookup
|
||||||
|
|
||||||
|
After `connect`, the intended workflow is:
|
||||||
|
|
||||||
|
1. `nordvpn-client connect ...`
|
||||||
|
2. `nordvpn-client verify ...` if an explicit location check is needed
|
||||||
|
3. run the follow-up task such as `web-automation`
|
||||||
|
|
||||||
|
## Limitations
|
||||||
|
|
||||||
|
- Linux city targeting is attempted through the CLI target string and then validated by public IP/location checks.
|
||||||
|
- macOS app-only fallback cannot guarantee non-interactive control if the app does not expose a CLI.
|
||||||
40
docs/plans/2026-03-11-nordvpn-client-design.md
Normal file
40
docs/plans/2026-03-11-nordvpn-client-design.md
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
# NordVPN Client Skill Design
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
Create a `nordvpn-client` skill that works on macOS and Linux gateway hosts. It should detect whether NordVPN is already installed, bootstrap it if missing, handle login/auth setup, connect to a requested country or city, verify the VPN state and public IP location, disconnect when requested, and then be usable alongside other skills like `web-automation`.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
The skill exposes one logical interface with platform-specific backends. Linux uses the official NordVPN CLI path. macOS probes for a usable CLI first, but falls back to the official app workflow when needed. The skill is responsible only for VPN lifecycle and verification, not for wrapping arbitrary commands inside a VPN session.
|
||||||
|
|
||||||
|
## Interface
|
||||||
|
Single script entrypoint:
|
||||||
|
- `node scripts/nordvpn-client.js install`
|
||||||
|
- `node scripts/nordvpn-client.js login`
|
||||||
|
- `node scripts/nordvpn-client.js connect --country "Italy"`
|
||||||
|
- `node scripts/nordvpn-client.js connect --city "Milan"`
|
||||||
|
- `node scripts/nordvpn-client.js disconnect`
|
||||||
|
- `node scripts/nordvpn-client.js status`
|
||||||
|
|
||||||
|
## Platform Model
|
||||||
|
### Linux
|
||||||
|
- Probe for `nordvpn`
|
||||||
|
- If missing, bootstrap official NordVPN package/CLI
|
||||||
|
- Prefer token-based login for non-interactive auth
|
||||||
|
- Connect/disconnect/status through official CLI
|
||||||
|
|
||||||
|
### macOS
|
||||||
|
- Probe for `nordvpn` CLI if available
|
||||||
|
- Otherwise probe/install the official app
|
||||||
|
- Use CLI when present, otherwise automate the app/login flow
|
||||||
|
- Verify connection using app/CLI state plus external IP/geolocation
|
||||||
|
|
||||||
|
## Auth and Safety
|
||||||
|
- Do not store raw NordVPN secrets in skill docs
|
||||||
|
- Read token/credentials from env vars or a local credential file path
|
||||||
|
- Keep the skill focused on install/login/connect/disconnect/status
|
||||||
|
- After `connect`, verify both local VPN state and external IP/location before the agent proceeds to tasks like `web-automation`
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
- `status` reports platform, install state, auth state, connection state, and public IP/location check
|
||||||
|
- `connect` verifies the requested target as closely as available data allows
|
||||||
|
- Local validation happens first in the OpenClaw workspace, then the proven skill is copied into `stef-openclaw-skills`, documented, committed, and pushed
|
||||||
127
docs/plans/2026-03-11-nordvpn-client.md
Normal file
127
docs/plans/2026-03-11-nordvpn-client.md
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
# NordVPN Client Skill Implementation Plan
|
||||||
|
|
||||||
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||||
|
|
||||||
|
**Goal:** Build a cross-platform `nordvpn-client` skill for macOS and Linux that can install/bootstrap NordVPN, log in, connect to a target country or city, verify the VPN session, disconnect, and report status.
|
||||||
|
|
||||||
|
**Architecture:** Implement one skill with one script entrypoint and platform-specific backends. Linux uses the official NordVPN CLI. macOS uses a CLI path when present and otherwise falls back to the NordVPN app workflow. The skill manages VPN state only, leaving follow-up operations like `web-automation` to separate agent steps.
|
||||||
|
|
||||||
|
**Tech Stack:** Node.js, shell/OS commands, NordVPN CLI/app integration, OpenClaw skills, git
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Create isolated worktree
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: repo git metadata only
|
||||||
|
|
||||||
|
**Step 1: Create worktree**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
git -C /Users/stefano/.openclaw/workspace/projects/stef-openclaw-skills worktree add /Users/stefano/.openclaw/workspace/projects/stef-openclaw-skills/.worktrees/nordvpn-client -b feature/nordvpn-client
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Verify baseline**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
git -C /Users/stefano/.openclaw/workspace/projects/stef-openclaw-skills/.worktrees/nordvpn-client status --short --branch
|
||||||
|
```
|
||||||
|
Expected: clean feature branch
|
||||||
|
|
||||||
|
### Task 2: Create the local skill runtime
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `skills/nordvpn-client/SKILL.md`
|
||||||
|
- Create: `skills/nordvpn-client/scripts/nordvpn-client.js`
|
||||||
|
- Optional Create: helper files under `skills/nordvpn-client/scripts/`
|
||||||
|
|
||||||
|
**Step 1: Write the failing checks**
|
||||||
|
- Missing command/action should fail with clear usage output
|
||||||
|
- Unsupported platform should fail clearly
|
||||||
|
|
||||||
|
**Step 2: Implement platform detection and install probe**
|
||||||
|
- detect `darwin` vs `linux`
|
||||||
|
- detect whether NordVPN CLI/app is already present
|
||||||
|
- expose `status` with install/auth/connect fields
|
||||||
|
|
||||||
|
### Task 3: Implement install and auth bootstrap
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `skills/nordvpn-client/scripts/nordvpn-client.js`
|
||||||
|
|
||||||
|
**Step 1: Linux install/login path**
|
||||||
|
- implement official CLI probe/install path
|
||||||
|
- implement token-based login path
|
||||||
|
|
||||||
|
**Step 2: macOS install/login path**
|
||||||
|
- probe CLI first
|
||||||
|
- if absent, probe/install NordVPN app path
|
||||||
|
- implement login/bootstrap state verification for the app workflow
|
||||||
|
|
||||||
|
**Step 3: Keep secrets external**
|
||||||
|
- env vars or local credential path only
|
||||||
|
- no raw secrets in docs or skill text
|
||||||
|
|
||||||
|
### Task 4: Implement connect/disconnect/status/verification
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `skills/nordvpn-client/scripts/nordvpn-client.js`
|
||||||
|
|
||||||
|
**Step 1: Connect**
|
||||||
|
- support `--country` and `--city`
|
||||||
|
- normalize target handling per platform
|
||||||
|
|
||||||
|
**Step 2: Verify**
|
||||||
|
- report local connection state
|
||||||
|
- run public IP / geolocation verification
|
||||||
|
- fail if connection target cannot be reasonably verified
|
||||||
|
|
||||||
|
**Step 3: Disconnect and status**
|
||||||
|
- implement clean disconnect
|
||||||
|
- ensure `status` emits machine-readable output for agent use
|
||||||
|
|
||||||
|
### Task 5: Validate locally in OpenClaw workspace
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Test: local workspace copy of `nordvpn-client`
|
||||||
|
|
||||||
|
**Step 1: Direct command validation**
|
||||||
|
- usage errors are correct
|
||||||
|
- install probe works on this host
|
||||||
|
- status output is coherent before login/connect
|
||||||
|
|
||||||
|
**Step 2: One real connect flow**
|
||||||
|
- connect to a test country/city if credentials are available
|
||||||
|
- verify local state + external IP/location
|
||||||
|
- disconnect cleanly
|
||||||
|
|
||||||
|
### Task 6: Promote to repo docs and publish
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `README.md`
|
||||||
|
- Modify: `docs/README.md`
|
||||||
|
- Create: `docs/nordvpn-client.md`
|
||||||
|
- Create/Modify: `skills/nordvpn-client/...`
|
||||||
|
|
||||||
|
**Step 1: Document the skill**
|
||||||
|
- install/bootstrap behavior
|
||||||
|
- auth expectations
|
||||||
|
- connect/disconnect/status commands
|
||||||
|
- macOS vs Linux notes
|
||||||
|
|
||||||
|
**Step 2: Commit and push**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
git add skills/nordvpn-client docs README.md
|
||||||
|
git commit -m "feat: add nordvpn client skill"
|
||||||
|
git push -u origin feature/nordvpn-client
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Merge and cleanup**
|
||||||
|
- fast-forward or merge to `main`
|
||||||
|
- push `main`
|
||||||
|
- remove the worktree
|
||||||
|
- delete the feature branch
|
||||||
78
skills/nordvpn-client/SKILL.md
Normal file
78
skills/nordvpn-client/SKILL.md
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
---
|
||||||
|
name: nordvpn-client
|
||||||
|
description: Use when managing NordVPN on macOS or Linux, including install/bootstrap, login, connect, disconnect, status checks, or verifying a VPN location before running another skill.
|
||||||
|
---
|
||||||
|
|
||||||
|
# NordVPN Client
|
||||||
|
|
||||||
|
Cross-platform NordVPN lifecycle management for macOS and Linux hosts.
|
||||||
|
|
||||||
|
## What This Skill Is For
|
||||||
|
|
||||||
|
- Probing whether NordVPN is already installed
|
||||||
|
- Bootstrapping NordVPN if it is missing
|
||||||
|
- Logging in through the Linux CLI or the macOS app/CLI path
|
||||||
|
- Connecting to a country or city before a follow-up action such as `web-automation`
|
||||||
|
- Disconnecting and checking VPN status
|
||||||
|
- Verifying public IP and geolocation after connect
|
||||||
|
|
||||||
|
## Command Surface
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
## Platform Notes
|
||||||
|
|
||||||
|
- Linux:
|
||||||
|
- uses the official `nordvpn` CLI
|
||||||
|
- install path follows NordVPN's Linux installer
|
||||||
|
- token login is supported through `NORDVPN_TOKEN`
|
||||||
|
- macOS:
|
||||||
|
- prefers Homebrew cask install: `brew install --cask nordvpn`
|
||||||
|
- prefers a usable `nordvpn` CLI if the installed package exposes one
|
||||||
|
- otherwise falls back to opening the NordVPN app and guiding the manual login/connect path
|
||||||
|
|
||||||
|
## Credentials
|
||||||
|
|
||||||
|
Do not store secrets in this skill.
|
||||||
|
|
||||||
|
Supported env vars:
|
||||||
|
|
||||||
|
- `NORDVPN_TOKEN`
|
||||||
|
- `NORDVPN_USERNAME`
|
||||||
|
- `NORDVPN_PASSWORD`
|
||||||
|
|
||||||
|
Optional credential file env vars:
|
||||||
|
|
||||||
|
- `NORDVPN_TOKEN_FILE`
|
||||||
|
- `NORDVPN_PASSWORD_FILE`
|
||||||
|
|
||||||
|
## Verification Behavior
|
||||||
|
|
||||||
|
`status`, `verify`, and `connect` report machine-readable JSON including:
|
||||||
|
|
||||||
|
- platform
|
||||||
|
- install state
|
||||||
|
- auth state
|
||||||
|
- connection state
|
||||||
|
- requested target
|
||||||
|
- public IP lookup and geolocation
|
||||||
|
|
||||||
|
Use this skill first, then run the follow-up task under the active VPN session.
|
||||||
|
Use `verify` when you want an explicit post-connect location check without changing VPN state.
|
||||||
|
|
||||||
|
## Known Boundaries
|
||||||
|
|
||||||
|
- Linux country connect is official CLI behavior.
|
||||||
|
- Linux city connect is attempted through the CLI target string and then validated by post-connect IP/location checks.
|
||||||
|
- macOS app-only fallback cannot guarantee non-interactive login/connect if the installed app does not expose a CLI. In that case the skill will open the app and return a clear manual-action-required result.
|
||||||
502
skills/nordvpn-client/scripts/nordvpn-client.js
Normal file
502
skills/nordvpn-client/scripts/nordvpn-client.js
Normal file
@@ -0,0 +1,502 @@
|
|||||||
|
#!/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();
|
||||||
Reference in New Issue
Block a user