pairshell-cli 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 PairShell
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,220 @@
1
+ # pairshell-cli
2
+
3
+ Desktop agent CLI for PairShell — pair an Android device over an encrypted tunnel and drive a real terminal session. Works on Windows, macOS, and Linux.
4
+
5
+ ## Install / Run
6
+
7
+ Requires Node.js >= 20. `node-pty` ships prebuilt binaries for Windows and macOS. On Linux, a C++ compiler and Python are needed (standard on any dev machine).
8
+
9
+ **Global install** (run from anywhere):
10
+ ```bash
11
+ npm install -g pairshell-cli
12
+ pairshell-cli setup
13
+ pairshell-cli start
14
+ ```
15
+
16
+ **Direct via npx** (no install needed):
17
+ ```bash
18
+ npx pairshell-cli setup
19
+ npx pairshell-cli start
20
+ ```
21
+
22
+ **Local install** (in a project):
23
+ ```bash
24
+ npm install pairshell-cli
25
+ npx pairshell-cli setup
26
+ npx pairshell-cli start
27
+ ```
28
+
29
+ ## Commands
30
+
31
+ ### `start [path]`
32
+
33
+ Start a WebSocket server in the current directory (or the given path). A pairing QR code is displayed. Scan it with the PairShell app to connect.
34
+
35
+ While running:
36
+ - **Press Enter** — generate a fresh pairing code
37
+ - **Ctrl-C** — stop the server
38
+
39
+ A tunnel is started automatically so the device can connect from anywhere. Use `--no-tunnel` for local-network-only access.
40
+
41
+ ```
42
+ pairshell-cli start
43
+ pairshell-cli start ~/projects/foo
44
+ pairshell-cli start --no-tunnel
45
+ pairshell-cli start --provider ngrok
46
+ pairshell-cli start --port 8080
47
+ ```
48
+
49
+ After pairing, the device gets a persistent session token. Reconnecting the same device skips the pairing step.
50
+
51
+ ### `revoke [device-id]`
52
+
53
+ List all connected devices or revoke a device by ID. Device IDs were issued during initial pairing and are displayed when listing sessions.
54
+
55
+ ```
56
+ pairshell-cli revoke # list active sessions
57
+ pairshell-cli revoke abc123 # revoke the device with ID abc123
58
+ ```
59
+
60
+ The server must be running for this to work. It communicates with the local server directly over HTTP. A revoke removes all session tokens associated with the device, forcing it to re-pair on next connection.
61
+
62
+ ### `setup [provider]`
63
+
64
+ Configure a tunnel provider. Default is `devtunnel`.
65
+
66
+ ```
67
+ pairshell-cli setup # devtunnel (default)
68
+ pairshell-cli setup ngrok --api-key YOUR_KEY
69
+ pairshell-cli setup tailscale
70
+ pairshell-cli setup --force # re-run even if already configured
71
+ ```
72
+
73
+ ### `status`
74
+
75
+ Show current configuration and network status.
76
+
77
+ ```
78
+ pairshell-cli status
79
+ ```
80
+
81
+ ### `guide [provider]`
82
+
83
+ Print setup instructions for a provider.
84
+
85
+ ```
86
+ pairshell-cli guide devtunnel
87
+ pairshell-cli guide ngrok
88
+ pairshell-cli guide tailscale
89
+ ```
90
+
91
+ ## Options
92
+
93
+ | Flag | Description |
94
+ |---|---|
95
+ | `--no-tunnel` | Local-only mode — no outbound tunnel. Use when both ends are on the same LAN (e.g., SSH from a laptop to a desktop on the same WiFi). |
96
+ | `--port <n>` | Override the WebSocket server port |
97
+ | `--provider <name>` | Use a specific tunnel provider |
98
+ | `--list` | List available tunnel providers |
99
+ | `--force` | Re-run setup even if already configured |
100
+ | `-h, --help` | Show help |
101
+ | `-v, --version` | Show version |
102
+
103
+ ## Tunnel Providers
104
+
105
+ ### devtunnel (default)
106
+
107
+ Microsoft devtunnel. Requires the devtunnel CLI and a Microsoft account login.
108
+
109
+ ```
110
+ devtunnel user login
111
+ pairshell-cli setup devtunnel
112
+ pairshell-cli start
113
+ ```
114
+
115
+ The tunnel ID persists across restarts for a stable URL. The CLI can be installed from https://aka.ms/devtunnel/cli.
116
+
117
+ **How it works:** Runs `devtunnel create --allow-anonymous` during setup to create a tunnel ID, then `devtunnel host <id> -p <port> --allow-anonymous` on each start. Outbound-only — no inbound ports needed.
118
+
119
+ ### ngrok
120
+
121
+ Auto-downloads the ngrok binary on first use. Requires a free ngrok account and API key.
122
+
123
+ ```
124
+ pairshell-cli setup ngrok --api-key YOUR_KEY
125
+ pairshell-cli start
126
+ ```
127
+
128
+ Get an API key at https://dashboard.ngrok.com/api-keys.
129
+
130
+ For a custom static domain (paid plan):
131
+ ```json
132
+ { "ngrok": { "apiKey": "YOUR_KEY", "domain": "your-domain.ngrok.io" } }
133
+ ```
134
+
135
+ **How it works:** Downloads ngrok from equinox.io, configures the authtoken, then runs `ngrok http <port>` and parses the JSON log output for the tunnel URL.
136
+
137
+ ### tailscale
138
+
139
+ Uses Tailscale Funnel. Requires Tailscale installed, authenticated, and Funnel enabled in the admin console.
140
+
141
+ ```
142
+ tailscale up
143
+ # enable Funnel at https://login.tailscale.com/admin/machines
144
+ pairshell-cli setup tailscale
145
+ pairshell-cli start
146
+ ```
147
+
148
+ **How it works:** Runs `tailscale funnel on --set-path=/ <port>` to serve the port, then reads `tailscale status --json` to get the machine's DNS name. Traffic stays within your tailnet — no third-party relay.
149
+
150
+ ## How Pairing Works
151
+
152
+ 1. The server generates a random 64-character hex token.
153
+ 2. The token is encoded into a WebSocket URL and displayed as a QR code.
154
+ 3. The device connects, presenting the token.
155
+ 4. The server validates the token (one-time use), then issues a persistent session token.
156
+ 5. On subsequent connections, the device can reconnect directly using the session token without re-pairing.
157
+
158
+ ## File Locations
159
+
160
+ All data lives under `~/.pairshell/`:
161
+
162
+ | File | Purpose |
163
+ |---|---|
164
+ | `~/.pairshell/config.json` | Provider config (tunnel ID, API keys, etc.) |
165
+ | `~/.pairshell/session-tokens.json` | Persistent session tokens for reconnecting devices |
166
+ | `~/.pairshell/bin/` | Auto-downloaded binaries (ngrok) |
167
+
168
+ ## Config File Format
169
+
170
+ `~/.pairshell/config.json` stores provider configuration:
171
+
172
+ ```json
173
+ {
174
+ "provider": "ngrok",
175
+ "ngrok": { "apiKey": "YOUR_KEY" },
176
+ "tailscale": {}
177
+ }
178
+ ```
179
+
180
+ For ngrok with a custom static domain (paid plan):
181
+ ```json
182
+ {
183
+ "provider": "ngrok",
184
+ "ngrok": { "apiKey": "YOUR_KEY", "domain": "your-domain.ngrok.io" }
185
+ }
186
+ ```
187
+
188
+ ## Security
189
+
190
+ - Pairing tokens are 64-character hex strings, single-use, valid until consumed
191
+ - Session tokens persist in `~/.pairshell/session-tokens.json` and allow reconnection without re-pairing
192
+ - Tunnel traffic is end-to-end encrypted by the provider (TLS for devtunnel/ngrok, WireGuard for Tailscale Funnel)
193
+ - No inbound ports opened on your network — tunnels are outbound-only
194
+ - **Session encryption:** all terminal data (keystrokes, PTY output, resize events) is encrypted with tweetnacl secretbox (XSalsa20-Poly1305) before being sent over the WebSocket. The encryption key is derived via SHA-512 from the pairing or session token — every session uses a unique key.
195
+ - **Token visible in handshake:** the pairing and session tokens are sent as query parameters in the WebSocket URL (e.g., `ws://host/?token=...`). The tunnel provider sees this URL during the TLS handshake, before any app-layer encryption applies. This is a small residual exposure — it's the same token a device would need to present, but worth documenting that it exists.
196
+ - **Control frames in the clear:** JSON text messages (resize, ping/pong, auth) are deliberately sent unencrypted. Only binary terminal data carries the encryption overhead, since control messages are low-sensitivity and short-lived. This is a conscious scoping decision, not an oversight.
197
+ - **Session revocation:** running `pairshell-cli revoke <device-id>` on the host deletes all of that device's session tokens, forcing it to re-pair on the next connection.
198
+
199
+ ## Uninstall
200
+
201
+ ```bash
202
+ npm uninstall -g pairshell-cli
203
+ rm -rf ~/.pairshell
204
+ ```
205
+
206
+ ## License
207
+
208
+ MIT
209
+
210
+ ## Testing
211
+
212
+ Two test scripts connect to a running server and verify the bridge:
213
+
214
+ ```bash
215
+ node test/test-bridge.mjs ws://host:port/?token=TOKEN
216
+ node test/test-reject-second.mjs ws://host:port/?token=TOKEN
217
+ ```
218
+
219
+ - `test-bridge.mjs` — sends keystrokes, verifies PTY echo, tests ping/pong and resize
220
+ - `test-reject-second.mjs` — verifies that a second connection with the same one-time token is rejected (code 4001)
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env node
2
+ import { run } from "../src/cli.js";
3
+
4
+ run(process.argv.slice(2)).catch((err) => {
5
+ const message = err && err.message ? err.message : String(err);
6
+ console.error(`pairshell-cli: ${message}`);
7
+ process.exit(1);
8
+ });
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "pairshell-cli",
3
+ "version": "0.0.1",
4
+ "description": "Desktop agent CLI for PairShell — pair an Android device over an encrypted tunnel and drive a real terminal session.",
5
+ "type": "module",
6
+ "bin": {
7
+ "pairshell-cli": "bin/pairshell-cli.js"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "src"
12
+ ],
13
+ "engines": {
14
+ "node": ">=20"
15
+ },
16
+ "scripts": {
17
+ "test": "node test/test-bridge.mjs",
18
+ "start": "node bin/pairshell-cli.js"
19
+ },
20
+ "preferUnplugged": true,
21
+ "license": "MIT",
22
+ "author": "PairShell",
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "git+https://github.com/Pairshell/pairshell-cli.git"
26
+ },
27
+ "bugs": {
28
+ "url": "https://github.com/Pairshell/pairshell-cli/issues"
29
+ },
30
+ "homepage": "https://github.com/Pairshell/pairshell-cli#readme",
31
+ "keywords": [
32
+ "pairshell",
33
+ "terminal",
34
+ "tunnel",
35
+ "remote",
36
+ "ssh",
37
+ "android",
38
+ "devtunnel",
39
+ "ngrok",
40
+ "tailscale"
41
+ ],
42
+ "dependencies": {
43
+ "node-pty": "^1.0.0",
44
+ "qrcode-terminal": "^0.12.0",
45
+ "tweetnacl": "^1.0.3",
46
+ "ws": "^8.18.0"
47
+ }
48
+ }
package/src/cli.js ADDED
@@ -0,0 +1,138 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { fileURLToPath } from "node:url";
3
+ import { dirname, resolve } from "node:path";
4
+ import { startCommand } from "./start.js";
5
+ import { setupCommand } from "./setup.js";
6
+ import { statusCommand } from "./status.js";
7
+ import { revokeCommand } from "./revoke.js";
8
+ import { resolve as resolveProvider, listProviders } from "./providers/index.js";
9
+ import { header, fail, BOLD, RESET } from "./format.js";
10
+
11
+ const PKG_PATH = resolve(dirname(fileURLToPath(import.meta.url)), "..", "package.json");
12
+ const VERSION = JSON.parse(readFileSync(PKG_PATH, "utf8")).version || "0.0.0";
13
+
14
+ const COMMANDS = {
15
+ start: startCommand,
16
+ setup: setupCommand,
17
+ status: statusCommand,
18
+ revoke: revokeCommand,
19
+ };
20
+
21
+ function printHelp() {
22
+ console.log(`\n pairshell-cli v${VERSION} — desktop agent for PairShell
23
+
24
+ USAGE
25
+ pairshell-cli <command> [options]
26
+
27
+ COMMANDS
28
+ start [path] Start a session in the current or given directory
29
+ setup [provider] One-time provider setup (default: devtunnel)
30
+ status Show configuration and network status
31
+ revoke [device] List sessions or revoke a device by ID
32
+ guide [provider] Show setup instructions for a provider
33
+ help Show this help
34
+
35
+ OPTIONS
36
+ --no-tunnel Local-only mode (no outbound tunnel). Use when both
37
+ ends are on the same LAN, e.g., SSHing into your
38
+ desktop from a laptop on the same WiFi.
39
+ --port <n> Override the WebSocket server port
40
+ --provider <name> Use a specific tunnel provider
41
+ --list List available tunnel providers
42
+ --force Re-run setup even if already configured
43
+ -h, --help Show this help
44
+ -v, --version Show version
45
+
46
+ PROVIDERS
47
+ devtunnel Microsoft devtunnel (default, requires CLI + login)
48
+ ngrok ngrok (auto-download binary, API key, free account)
49
+ tailscale Tailscale Funnel (install + enable, no third-party relay)
50
+
51
+ EXAMPLES
52
+ pairshell-cli start
53
+ pairshell-cli start ~/projects/foo
54
+ pairshell-cli start --provider ngrok
55
+ pairshell-cli start --no-tunnel
56
+ pairshell-cli setup
57
+ pairshell-cli setup ngrok --api-key YOUR_KEY
58
+ pairshell-cli status
59
+ pairshell-cli revoke
60
+ pairshell-cli revoke abc123
61
+ `);
62
+ }
63
+
64
+ function parseFlags(args) {
65
+ const flags = { tunnel: true, help: false, version: false, port: 0, force: false, list: false, path: null };
66
+ for (let i = 0; i < args.length; i++) {
67
+ const arg = args[i];
68
+ if (arg === "--no-tunnel") {
69
+ flags.tunnel = false;
70
+ } else if (arg === "-h" || arg === "--help") {
71
+ flags.help = true;
72
+ } else if (arg === "-v" || arg === "--version") {
73
+ flags.version = true;
74
+ } else if (arg === "--port") {
75
+ const value = Number(args[++i]);
76
+ if (Number.isInteger(value) && value > 0) flags.port = value;
77
+ } else if (arg === "--force") {
78
+ flags.force = true;
79
+ } else if (arg === "--list") {
80
+ flags.list = true;
81
+ } else if (arg === "--provider") {
82
+ flags.provider = args[++i];
83
+ } else if (arg === "--api-key") {
84
+ flags.apiKey = args[++i];
85
+ } else if (!arg.startsWith("-") && !flags.path) {
86
+ flags.path = arg;
87
+ }
88
+ }
89
+ return flags;
90
+ }
91
+
92
+ export async function run(argv) {
93
+ const flags = parseFlags(argv.slice(1));
94
+ const cmd = argv[0];
95
+
96
+ if (cmd === "-v" || cmd === "--version") {
97
+ console.log(`pairshell-cli v${VERSION}`);
98
+ return;
99
+ }
100
+
101
+ if (flags.list) {
102
+ console.log(`\n Available providers: ${listProviders().join(", ")}\n`);
103
+ return;
104
+ }
105
+
106
+ if (cmd === "guide") {
107
+ const providerName = flags.provider || argv[1] || "devtunnel";
108
+ try {
109
+ const prov = resolveProvider(providerName);
110
+ header(`Guide: ${prov.label}`);
111
+ prov.printGuide();
112
+ } catch (err) {
113
+ fail(err.message);
114
+ process.exitCode = 1;
115
+ }
116
+ return;
117
+ }
118
+
119
+ if (!cmd || cmd === "help" || flags.help) {
120
+ printHelp();
121
+ return;
122
+ }
123
+
124
+ const handler = COMMANDS[cmd];
125
+ if (!handler) {
126
+ fail(`Unknown command "${cmd}".`);
127
+ console.log(`\n Run ${BOLD}pairshell-cli help${RESET} to see available commands.\n`);
128
+ process.exitCode = 1;
129
+ return;
130
+ }
131
+
132
+ if (flags.version) {
133
+ console.log(`pairshell-cli v${VERSION}`);
134
+ return;
135
+ }
136
+
137
+ await handler(flags);
138
+ }
package/src/config.js ADDED
@@ -0,0 +1,45 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { defaultProvider } from "./providers/index.js";
5
+
6
+ const CONFIG_DIR = path.join(os.homedir(), ".pairshell");
7
+ const CONFIG_PATH = path.join(CONFIG_DIR, "config.json");
8
+
9
+ export function readConfig() {
10
+ try {
11
+ return JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8"));
12
+ } catch {
13
+ return {};
14
+ }
15
+ }
16
+
17
+ export function writeConfig(cfg) {
18
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
19
+ fs.writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2), "utf8");
20
+ try {
21
+ fs.chmodSync(CONFIG_PATH, 0o600);
22
+ } catch {}
23
+ }
24
+
25
+ export function getProvider() {
26
+ const cfg = readConfig();
27
+ return typeof cfg.provider === "string" ? cfg.provider : defaultProvider();
28
+ }
29
+
30
+ export function setProvider(name) {
31
+ const cfg = readConfig();
32
+ cfg.provider = name;
33
+ writeConfig(cfg);
34
+ }
35
+
36
+ export function getProviderConfig(name) {
37
+ return readConfig()[name];
38
+ }
39
+
40
+ export function setProviderConfig(name, providerCfg) {
41
+ const cfg = readConfig();
42
+ cfg.provider = name;
43
+ cfg[name] = providerCfg;
44
+ writeConfig(cfg);
45
+ }
package/src/crypto.js ADDED
@@ -0,0 +1,28 @@
1
+ import nacl from "tweetnacl";
2
+ import { randomBytes } from "node:crypto";
3
+
4
+ const KEY_CONTEXT = "pairshell-session-key-v1";
5
+ const KEY_LENGTH = nacl.secretbox.keyLength;
6
+ const NONCE_LENGTH = nacl.secretbox.nonceLength;
7
+
8
+ export function deriveKey(token) {
9
+ const input = new TextEncoder().encode(token + KEY_CONTEXT);
10
+ const hash = nacl.hash(input);
11
+ return hash.slice(0, KEY_LENGTH);
12
+ }
13
+
14
+ export function encrypt(plaintext, key) {
15
+ const nonce = randomBytes(NONCE_LENGTH);
16
+ const data = typeof plaintext === "string" ? new TextEncoder().encode(plaintext) : plaintext;
17
+ const ciphertext = nacl.secretbox(data, nonce, key);
18
+ return Buffer.concat([nonce, Buffer.from(ciphertext)]);
19
+ }
20
+
21
+ export function decrypt(packet, key) {
22
+ if (packet.length < NONCE_LENGTH + nacl.secretbox.overheadLength) return null;
23
+ const nonce = packet.subarray(0, NONCE_LENGTH);
24
+ const ciphertext = packet.subarray(NONCE_LENGTH);
25
+ const plaintext = nacl.secretbox.open(ciphertext, nonce, key);
26
+ if (!plaintext) return null;
27
+ return Buffer.from(plaintext);
28
+ }
package/src/format.js ADDED
@@ -0,0 +1,50 @@
1
+ const isColor = process.stdout.isTTY && !process.env.NO_COLOR;
2
+
3
+ function esc(code) {
4
+ return isColor ? `\x1b[${code}m` : "";
5
+ }
6
+
7
+ export const RESET = esc(0);
8
+ export const BOLD = esc(1);
9
+ const DIM = esc(2);
10
+ const RED = esc(31);
11
+ const GREEN = esc(32);
12
+ const YELLOW = esc(33);
13
+ const BLUE = esc(34);
14
+ const MAGENTA = esc(35);
15
+ const CYAN = esc(36);
16
+
17
+ export function ok(label) {
18
+ console.log(` ${GREEN}ok${RESET} ${label}`);
19
+ }
20
+
21
+ export function info(label) {
22
+ console.log(` ${BLUE}→${RESET} ${label}`);
23
+ }
24
+
25
+ export function warn(label) {
26
+ console.log(` ${YELLOW}warn${RESET} ${label}`);
27
+ }
28
+
29
+ export function fail(label) {
30
+ console.log(` ${RED}ERROR${RESET} ${label}`);
31
+ }
32
+
33
+ export function header(text) {
34
+ console.log(`\n${BOLD}${CYAN}═══ ${text} ${RESET}${DIM}${"─".repeat(Math.max(0, 60 - text.length - 4))}${RESET}\n`);
35
+ }
36
+
37
+ export function pairingUrl(url) {
38
+ const colored = url.replace(
39
+ /(wss?:\/\/)([^/]+)(\/?.*)/,
40
+ (_, proto, host, rest) => `${CYAN}${proto}${RESET}${BOLD}${host}${RESET}${DIM}${rest}${RESET}`,
41
+ );
42
+ return colored;
43
+ }
44
+
45
+ export function kv(items) {
46
+ const keyLen = Math.max(...items.map(([k]) => k.length));
47
+ for (const [key, val] of items) {
48
+ console.log(` ${BOLD}${key.padEnd(keyLen)}${RESET} ${val}`);
49
+ }
50
+ }
package/src/net.js ADDED
@@ -0,0 +1,43 @@
1
+ import os from "node:os";
2
+
3
+ const SKIP_INTERFACES = [
4
+ "docker", "veth", "br-", "vbox", "vmnet",
5
+ "vEthernet", "hyper-v", "virtualbox",
6
+ "loopback", "lo",
7
+ ];
8
+
9
+ function isVirtual(name) {
10
+ const lower = name.toLowerCase();
11
+ return SKIP_INTERFACES.some((k) => lower.includes(k));
12
+ }
13
+
14
+ export function lanAddress() {
15
+ const interfaces = os.networkInterfaces();
16
+ const candidates = [];
17
+
18
+ for (const name of Object.keys(interfaces)) {
19
+ if (isVirtual(name)) continue;
20
+ for (const iface of interfaces[name] || []) {
21
+ if ((iface.family === "IPv4" || iface.family === 4) && !iface.internal) {
22
+ const addr = iface.address;
23
+ // Skip link-local and VirtualBox default host-only subnet
24
+ if (addr.startsWith("169.254.") || addr.startsWith("192.168.56.")) {
25
+ continue;
26
+ }
27
+ candidates.push({ name, address: addr });
28
+ }
29
+ }
30
+ }
31
+
32
+ if (candidates.length > 0) {
33
+ // Prioritize Wi-Fi/WLAN network interfaces if present
34
+ const wifi = candidates.find((c) => {
35
+ const lower = c.name.toLowerCase();
36
+ return lower.includes("wi-fi") || lower.includes("wifi") || lower.includes("wlan");
37
+ });
38
+ if (wifi) return wifi.address;
39
+ return candidates[0].address;
40
+ }
41
+
42
+ return "127.0.0.1";
43
+ }
package/src/pairing.js ADDED
@@ -0,0 +1,19 @@
1
+ import { randomBytes } from "node:crypto";
2
+
3
+ export class PairingStore {
4
+ constructor() {
5
+ this.tokens = new Set();
6
+ }
7
+
8
+ create() {
9
+ const token = randomBytes(32).toString("hex");
10
+ this.tokens.add(token);
11
+ return token;
12
+ }
13
+
14
+ consume(token) {
15
+ if (!token || !this.tokens.has(token)) return false;
16
+ this.tokens.delete(token);
17
+ return true;
18
+ }
19
+ }