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 +21 -0
- package/README.md +220 -0
- package/bin/pairshell-cli.js +8 -0
- package/package.json +48 -0
- package/src/cli.js +138 -0
- package/src/config.js +45 -0
- package/src/crypto.js +28 -0
- package/src/format.js +50 -0
- package/src/net.js +43 -0
- package/src/pairing.js +19 -0
- package/src/providers/devtunnel.js +99 -0
- package/src/providers/index.js +24 -0
- package/src/providers/ngrok.js +185 -0
- package/src/providers/tailscale.js +105 -0
- package/src/pty-bridge.js +78 -0
- package/src/revoke.js +55 -0
- package/src/session.js +81 -0
- package/src/setup.js +44 -0
- package/src/start.js +453 -0
- package/src/status.js +32 -0
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)
|
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
|
+
}
|