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.
@@ -0,0 +1,99 @@
1
+ import { spawn } from "node:child_process";
2
+
3
+ const TUNNEL_URL_RE = /https:\/\/[a-z0-9-]+\.devtunnels\.ms(?::\d+)?/i;
4
+ const TUNNEL_ID_RE = /Tunnel ID[:\s]+\s*([^\r\n]+)/i;
5
+ const TUNNEL_TIMEOUT_MS = 30000;
6
+
7
+ async function execDevtunnel(args) {
8
+ return new Promise((resolve, reject) => {
9
+ const child = spawn("devtunnel", args, {
10
+ stdio: ["ignore", "pipe", "pipe"],
11
+ });
12
+ let stdout = "";
13
+ let stderr = "";
14
+ child.stdout.on("data", (c) => { stdout += c; });
15
+ child.stderr.on("data", (c) => { stderr += c; });
16
+ child.on("error", (err) => reject(new Error(`Cannot start devtunnel: ${err.message}`)));
17
+ child.on("exit", (code) => {
18
+ if (code === 0) resolve(stdout + stderr);
19
+ else reject(new Error(`devtunnel exited code ${code}: ${(stderr || stdout).slice(0, 200)}`));
20
+ });
21
+ });
22
+ }
23
+
24
+ export const provider = {
25
+ name: "devtunnel",
26
+ label: "Microsoft devtunnel",
27
+
28
+ checkSetup(config) {
29
+ const id = config?.devtunnel?.tunnelId;
30
+ if (id) return { ok: true, message: `Tunnel ID: ${id}` };
31
+ return { ok: false, message: "No tunnel configured. Run 'pairshell-cli setup devtunnel'" };
32
+ },
33
+
34
+ async setup(config) {
35
+ const output = await execDevtunnel(["create", "--allow-anonymous"]);
36
+ const match = output.match(TUNNEL_ID_RE);
37
+ if (!match) throw new Error("Could not parse tunnel ID from devtunnel output");
38
+ const id = match[1].trim().split(/\s+/)[0];
39
+ return { ...config, provider: "devtunnel", devtunnel: { ...config?.devtunnel, tunnelId: id } };
40
+ },
41
+
42
+ startTunnel(port, config) {
43
+ const tunnelId = config?.devtunnel?.tunnelId;
44
+ if (!tunnelId) throw new Error("No devtunnel ID configured. Run setup first.");
45
+
46
+ return new Promise((resolve, reject) => {
47
+ const child = spawn("devtunnel", ["host", tunnelId, "-p", String(port), "--allow-anonymous"], {
48
+ stdio: ["ignore", "pipe", "pipe"],
49
+ });
50
+
51
+ let buffer = "";
52
+ let settled = false;
53
+
54
+ const fail = (msg) => {
55
+ if (settled) return;
56
+ settled = true;
57
+ try { child.kill("SIGTERM"); } catch {}
58
+ reject(new Error(msg));
59
+ };
60
+
61
+ child.on("error", (err) => fail(`Could not start devtunnel CLI: ${err.message}. Install from https://aka.ms/devtunnel/cli`));
62
+ child.on("exit", (code, signal) => {
63
+ if (!settled) fail(`devtunnel exited before URL (code ${code}, signal ${signal})`);
64
+ });
65
+
66
+ const feed = (chunk) => {
67
+ buffer += chunk.toString();
68
+ const m = buffer.match(TUNNEL_URL_RE);
69
+ if (m && !settled) {
70
+ settled = true;
71
+ resolve({ url: m[0], child });
72
+ }
73
+ };
74
+ child.stdout.on("data", feed);
75
+ child.stderr.on("data", feed);
76
+
77
+ setTimeout(() => fail("Timed out waiting for devtunnel URL"), TUNNEL_TIMEOUT_MS);
78
+ });
79
+ },
80
+
81
+ printGuide() {
82
+ console.log(`
83
+ ${this.label} Setup Guide
84
+ ───────────────────────
85
+
86
+ 1. Install the devtunnel CLI:
87
+ https://aka.ms/devtunnel/cli
88
+
89
+ 2. Log in with a Microsoft account:
90
+ devtunnel user login
91
+
92
+ 3. Run setup:
93
+ pairshell-cli setup devtunnel
94
+
95
+ The tunnel ID persists in ~/.pairshell/config.json.
96
+ Every start reuses the same tunnel ID for a stable URL.
97
+ `);
98
+ },
99
+ };
@@ -0,0 +1,24 @@
1
+ import { provider as devtunnel } from "./devtunnel.js";
2
+ import { provider as ngrok } from "./ngrok.js";
3
+ import { provider as tailscale } from "./tailscale.js";
4
+
5
+ const REGISTRY = { devtunnel, ngrok, tailscale };
6
+ const DEFAULT = "devtunnel";
7
+
8
+ export function resolve(name) {
9
+ const key = (name || DEFAULT).toLowerCase();
10
+ const prov = REGISTRY[key];
11
+ if (!prov) {
12
+ const available = Object.keys(REGISTRY).join(", ");
13
+ throw new Error(`Unknown provider "${name}". Available: ${available}`);
14
+ }
15
+ return prov;
16
+ }
17
+
18
+ export function listProviders() {
19
+ return Object.keys(REGISTRY);
20
+ }
21
+
22
+ export function defaultProvider() {
23
+ return DEFAULT;
24
+ }
@@ -0,0 +1,185 @@
1
+ import { spawn } from "node:child_process";
2
+ import { access, mkdir, writeFile } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { get } from "node:https";
6
+ import { createWriteStream } from "node:fs";
7
+
8
+ const BIN_DIR = join(homedir(), ".pairshell", "bin");
9
+ const NGROK_PATH = join(BIN_DIR, process.platform === "win32" ? "ngrok.exe" : "ngrok");
10
+ const TUNNEL_TIMEOUT_MS = 30000;
11
+
12
+ function platformTag() {
13
+ switch (process.platform) {
14
+ case "win32": return "windows";
15
+ case "darwin": return "darwin";
16
+ default: return "linux";
17
+ }
18
+ }
19
+
20
+ function archTag() {
21
+ return process.arch === "arm64" ? "arm64" : "amd64";
22
+ }
23
+
24
+ function ngrokDownloadUrl() {
25
+ const tag = platformTag();
26
+ const arch = archTag();
27
+ const ext = tag === "windows" ? "zip" : "tgz";
28
+ const version = "3";
29
+ return `https://bin.equinox.io/c/bNyj1mQVY4c/ngrok-v${version}-${tag}-${arch}.${ext}`;
30
+ }
31
+
32
+ async function downloadNgrok() {
33
+ const url = ngrokDownloadUrl();
34
+ const tmp = join(BIN_DIR, `ngrok-dl.${process.platform === "win32" ? "zip" : "tgz"}`);
35
+
36
+ await mkdir(BIN_DIR, { recursive: true });
37
+ console.log(` Downloading ngrok from ${url} ...`);
38
+
39
+ await new Promise((resolve, reject) => {
40
+ const file = createWriteStream(tmp);
41
+ get(url, (res) => {
42
+ if (res.statusCode !== 200) {
43
+ reject(new Error(`Download failed (HTTP ${res.statusCode})`));
44
+ return;
45
+ }
46
+ res.pipe(file);
47
+ file.on("finish", () => { file.close(); resolve(); });
48
+ }).on("error", reject);
49
+ });
50
+
51
+ if (tmp.endsWith(".zip")) {
52
+ await new Promise((resolve, reject) => {
53
+ const child = spawn("powershell", [
54
+ "-NoProfile", "-Command",
55
+ `Expand-Archive -Path "${tmp}" -DestinationPath "${BIN_DIR}" -Force`,
56
+ ], { stdio: "inherit" });
57
+ child.on("exit", (code) => code === 0 ? resolve() : reject(new Error("Extraction failed")));
58
+ });
59
+ } else {
60
+ await new Promise((resolve, reject) => {
61
+ const child = spawn("tar", ["-xzf", tmp, "-C", BIN_DIR], { stdio: "inherit" });
62
+ child.on("exit", (code) => code === 0 ? resolve() : reject(new Error("Extraction failed")));
63
+ });
64
+ }
65
+
66
+ try { await access(tmp); } catch {}
67
+ }
68
+
69
+ async function ngrokAuthtoken(config) {
70
+ const apiKey = config?.ngrok?.apiKey;
71
+ if (!apiKey) throw new Error("No ngrok API key configured. Run setup.");
72
+
73
+ return new Promise((resolve, reject) => {
74
+ const child = spawn(NGROK_PATH, ["config", "add-authtoken", apiKey], {
75
+ stdio: ["ignore", "pipe", "pipe"],
76
+ });
77
+ let out = "";
78
+ child.stdout.on("data", (c) => { out += c; });
79
+ child.stderr.on("data", (c) => { out += c; });
80
+ child.on("exit", (code) => {
81
+ if (code === 0) resolve(out);
82
+ else reject(new Error(`ngrok authtoken failed: ${out.slice(0, 200)}`));
83
+ });
84
+ });
85
+ }
86
+
87
+ export const provider = {
88
+ name: "ngrok",
89
+ label: "ngrok",
90
+
91
+ checkSetup(config) {
92
+ const cfg = config?.ngrok;
93
+ if (!cfg?.apiKey) return { ok: false, message: "No API key configured. Run 'pairshell-cli setup ngrok'" };
94
+ return { ok: true, message: `API key: ${cfg.apiKey.slice(0, 8)}...` };
95
+ },
96
+
97
+ async setup(config) {
98
+ console.log(" ngrok setup requires a free ngrok account and API key.");
99
+ console.log(" Get your API key at https://dashboard.ngrok.com/api-keys\n");
100
+
101
+ const apiKey = config?.ngrok?.apiKey;
102
+ if (apiKey) {
103
+ console.log(` Using existing API key: ${apiKey.slice(0, 8)}...`);
104
+ return { ...config, provider: "ngrok", ngrok: { ...config?.ngrok, apiKey } };
105
+ }
106
+
107
+ throw new Error(
108
+ "No API key found. Set it with:\n\n" +
109
+ " pairshell-cli setup ngrok --api-key YOUR_KEY\n\n" +
110
+ "Or manually edit ~/.pairshell/config.json and add:\n" +
111
+ ' { "provider": "ngrok", "ngrok": { "apiKey": "YOUR_KEY" } }'
112
+ );
113
+ },
114
+
115
+ async startTunnel(port, config) {
116
+ try { await access(NGROK_PATH); } catch { await downloadNgrok(); }
117
+ await ngrokAuthtoken(config);
118
+
119
+ const domain = config?.ngrok?.domain;
120
+
121
+ return new Promise((resolve, reject) => {
122
+ const args = ["http", String(port), "--log=stdout", "--log-format=json"];
123
+ if (domain) args.push("--domain", domain);
124
+
125
+ const child = spawn(NGROK_PATH, args, { stdio: ["ignore", "pipe", "pipe"] });
126
+ let settled = false;
127
+
128
+ const fail = (msg) => {
129
+ if (settled) return;
130
+ settled = true;
131
+ try { child.kill("SIGTERM"); } catch {}
132
+ reject(new Error(msg));
133
+ };
134
+
135
+ child.on("error", (err) => fail(`Could not start ngrok: ${err.message}`));
136
+ child.on("exit", (code, signal) => {
137
+ if (!settled) fail(`ngrok exited (code ${code}, signal ${signal})`);
138
+ });
139
+
140
+ const feed = (chunk) => {
141
+ if (settled) return;
142
+ for (const line of chunk.toString().split("\n")) {
143
+ try {
144
+ const entry = JSON.parse(line);
145
+ if (entry.msg === "started tunnel") {
146
+ const url = entry.url;
147
+ if (url && !settled) {
148
+ settled = true;
149
+ resolve({ url, child });
150
+ }
151
+ }
152
+ } catch {}
153
+ }
154
+ };
155
+ child.stdout.on("data", feed);
156
+ child.stderr.on("data", feed);
157
+
158
+ setTimeout(() => fail("Timed out waiting for ngrok tunnel URL"), TUNNEL_TIMEOUT_MS);
159
+ });
160
+ },
161
+
162
+ printGuide() {
163
+ console.log(`
164
+ ${this.label} Setup Guide
165
+ ─────────────────
166
+
167
+ 1. Sign up for a free ngrok account:
168
+ https://dashboard.ngrok.com/signup
169
+
170
+ 2. Get your API key:
171
+ https://dashboard.ngrok.com/api-keys
172
+
173
+ 3. Run setup with your API key:
174
+ pairshell-cli setup ngrok --api-key YOUR_KEY
175
+
176
+ Or edit ~/.pairshell/config.json:
177
+ { "provider": "ngrok", "ngrok": { "apiKey": "YOUR_KEY" } }
178
+
179
+ For a custom static domain (paid plan), add:
180
+ { "ngrok": { "apiKey": "YOUR_KEY", "domain": "your-domain.ngrok.io" } }
181
+
182
+ The ngrok binary auto-downloads on first use.
183
+ `);
184
+ },
185
+ };
@@ -0,0 +1,105 @@
1
+ import { spawn } from "node:child_process";
2
+ import { access } from "node:fs/promises";
3
+
4
+ const TAILSCALE_CMD = process.platform === "win32" ? "tailscale" : "tailscale";
5
+ const TUNNEL_TIMEOUT_MS = 30000;
6
+
7
+ async function getTailscaleStatus() {
8
+ return new Promise((resolve) => {
9
+ const child = spawn(TAILSCALE_CMD, ["status", "--json"], {
10
+ stdio: ["ignore", "pipe", "pipe"],
11
+ });
12
+ let out = "";
13
+ child.stdout.on("data", (c) => { out += c; });
14
+ child.on("close", () => {
15
+ try { resolve(JSON.parse(out)); } catch { resolve(null); }
16
+ });
17
+ child.on("error", () => resolve(null));
18
+ });
19
+ }
20
+
21
+ export const provider = {
22
+ name: "tailscale",
23
+ label: "Tailscale Funnel",
24
+
25
+ checkSetup(config) {
26
+ const enabled = config?.tailscale?.funnelEnabled;
27
+ if (enabled) return { ok: true, message: "Funnel is enabled" };
28
+ return { ok: false, message: "Funnel not configured. Run 'pairshell-cli setup tailscale'" };
29
+ },
30
+
31
+ async setup(config) {
32
+ const child = spawn(TAILSCALE_CMD, ["up"], {
33
+ stdio: "inherit",
34
+ shell: process.platform === "win32",
35
+ });
36
+
37
+ await new Promise((resolve, reject) => {
38
+ child.on("close", (code) => {
39
+ if (code === 0) resolve();
40
+ else reject(new Error(`tailscale up failed (code ${code})`));
41
+ });
42
+ child.on("error", (err) => reject(new Error(`Cannot start tailscale: ${err.message}`)));
43
+ });
44
+
45
+ return { ...config, provider: "tailscale", tailscale: { ...config?.tailscale, funnelEnabled: true } };
46
+ },
47
+
48
+ async startTunnel(port, config) {
49
+ const serveArgs = ["funnel", "on", `--set-path=/`, String(port)];
50
+ await new Promise((resolve, reject) => {
51
+ const child = spawn(TAILSCALE_CMD, serveArgs, { stdio: ["ignore", "pipe", "pipe"] });
52
+ let out = "";
53
+ child.stdout.on("data", (c) => { out += c; });
54
+ child.stderr.on("data", (c) => { out += c; });
55
+ child.on("error", (err) => reject(new Error(`Cannot run tailscale funnel: ${err.message}`)));
56
+ child.on("exit", (code) => {
57
+ if (code === 0) resolve();
58
+ else reject(new Error(`tailscale funnel on failed: ${out.slice(0, 200)}`));
59
+ });
60
+ });
61
+
62
+ const url = await new Promise((resolve, reject) => {
63
+ const child = spawn(TAILSCALE_CMD, ["status", "--json"], {
64
+ stdio: ["ignore", "pipe", "pipe"],
65
+ });
66
+ let out = "";
67
+ child.stdout.on("data", (c) => { out += c; });
68
+ child.on("error", (err) => reject(new Error(`Cannot get tailscale status: ${err.message}`)));
69
+ child.on("exit", () => {
70
+ try {
71
+ const parsed = JSON.parse(out);
72
+ const dnsName = parsed?.Self?.DNSName?.replace(/\.$/, "") || "";
73
+ if (!dnsName) throw new Error("no DNSName");
74
+ resolve(`https://${dnsName}`);
75
+ } catch {
76
+ reject(new Error("Could not parse tailscale funnel URL"));
77
+ }
78
+ });
79
+ });
80
+
81
+ return { url, child: null };
82
+ },
83
+
84
+ printGuide() {
85
+ console.log(`
86
+ ${this.label} Setup Guide
87
+ ────────────────────────
88
+
89
+ 1. Install Tailscale on this machine:
90
+ https://tailscale.com/download
91
+
92
+ 2. Authenticate and connect to your tailnet:
93
+ tailscale up
94
+
95
+ 3. Enable Funnel in the Tailscale admin console:
96
+ https://login.tailscale.com/admin/machines
97
+
98
+ 4. Run setup:
99
+ pairshell-cli setup tailscale
100
+
101
+ The serve config persists across reboots. Traffic stays
102
+ within your tailnet; no third-party relay.
103
+ `);
104
+ },
105
+ };
@@ -0,0 +1,78 @@
1
+ import pty from "node-pty";
2
+ import { encrypt, decrypt } from "./crypto.js";
3
+
4
+ export function detectShell() {
5
+ if (process.platform === "win32") {
6
+ return process.env.COMSPEC || "cmd.exe";
7
+ }
8
+ return process.env.SHELL || "/bin/sh";
9
+ }
10
+
11
+ export function bridgeSession(ws, opts = {}) {
12
+ const shell = detectShell();
13
+ const cols = opts.cols && opts.cols > 0 ? opts.cols : 80;
14
+ const rows = opts.rows && opts.rows > 0 ? opts.rows : 24;
15
+ const sharedKey = opts.sharedKey || null;
16
+
17
+ const term = pty.spawn(shell, [], {
18
+ name: "xterm-256color",
19
+ cols,
20
+ rows,
21
+ cwd: process.cwd(),
22
+ });
23
+
24
+ let closed = false;
25
+ const cleanup = () => {
26
+ if (closed) return;
27
+ closed = true;
28
+ try { term.kill(); } catch {}
29
+ try { ws.close(); } catch {}
30
+ };
31
+
32
+ term.onData((data) => {
33
+ if (ws.readyState === ws.OPEN) {
34
+ if (sharedKey) {
35
+ ws.send(encrypt(data, sharedKey));
36
+ } else {
37
+ ws.send(Buffer.from(data));
38
+ }
39
+ }
40
+ });
41
+
42
+ term.onExit(() => cleanup());
43
+
44
+ const MAX_BINARY_FRAME = 1024 * 1024;
45
+
46
+ ws.on("message", (message) => {
47
+ if (Buffer.isBuffer(message)) {
48
+ if (message.length > MAX_BINARY_FRAME) return;
49
+ if (sharedKey) {
50
+ const plain = decrypt(message, sharedKey);
51
+ if (!plain) return;
52
+ term.write(plain);
53
+ } else {
54
+ term.write(message);
55
+ }
56
+ return;
57
+ }
58
+
59
+ let control;
60
+ try {
61
+ control = JSON.parse(message);
62
+ } catch {
63
+ return;
64
+ }
65
+ if (!control || typeof control.type !== "string") return;
66
+
67
+ if (control.type === "resize" && Number.isInteger(control.cols) && Number.isInteger(control.rows)) {
68
+ try { term.resize(control.cols, control.rows); } catch {}
69
+ } else if (control.type === "ping") {
70
+ try { ws.send(JSON.stringify({ type: "pong" })); } catch {}
71
+ }
72
+ });
73
+
74
+ ws.on("close", cleanup);
75
+ ws.on("error", cleanup);
76
+
77
+ return term;
78
+ }
package/src/revoke.js ADDED
@@ -0,0 +1,55 @@
1
+ import { readFileSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+
5
+ const ACTIVE_FILE = join(homedir(), ".pairshell", "active.json");
6
+
7
+ function readActive() {
8
+ try {
9
+ return JSON.parse(readFileSync(ACTIVE_FILE, "utf8"));
10
+ } catch {
11
+ return null;
12
+ }
13
+ }
14
+
15
+ export async function revokeCommand(opts) {
16
+ const active = readActive();
17
+ const port = opts.port || (active ? active.port : null);
18
+ if (!port) {
19
+ console.log("Cannot determine server port. Is a session running?\n");
20
+ console.log(" Start one with: pairshell-cli start");
21
+ console.log(" Or specify: pairshell-cli revoke --port <number>\n");
22
+ return;
23
+ }
24
+
25
+ const base = `http://127.0.0.1:${port}`;
26
+
27
+ const deviceId = opts.path;
28
+ if (deviceId) {
29
+ const res = await fetch(`${base}/revoke`, {
30
+ method: "POST",
31
+ headers: { "Content-Type": "application/json" },
32
+ body: JSON.stringify({ deviceId }),
33
+ });
34
+ const data = await res.json();
35
+ if (data.revoked) {
36
+ console.log(`Revoked device: ${data.deviceId}`);
37
+ } else {
38
+ console.log(`Device not found: ${data.deviceId}`);
39
+ }
40
+ return;
41
+ }
42
+
43
+ console.log(`\n Active sessions:\n`);
44
+ const res = await fetch(`${base}/sessions`);
45
+ const sessions = await res.json();
46
+ if (sessions.length === 0) {
47
+ console.log(" (no active sessions)\n");
48
+ return;
49
+ }
50
+ sessions.forEach((s, i) => {
51
+ const age = Math.floor((Date.now() - s.createdAt) / 1000);
52
+ console.log(` [${i}] deviceId: ${s.deviceId} (created ${age}s ago)`);
53
+ });
54
+ console.log(`\n Revoke a device: pairshell-cli revoke <device-id>\n`);
55
+ }
package/src/session.js ADDED
@@ -0,0 +1,81 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ const SESSION_FILE = join(homedir(), ".pairshell", "session-tokens.json");
7
+
8
+ export class SessionStore {
9
+ constructor() {
10
+ this.sessions = new Map();
11
+ this._dirty = false;
12
+ this._load();
13
+ }
14
+
15
+ _load() {
16
+ try {
17
+ const data = JSON.parse(readFileSync(SESSION_FILE, "utf8"));
18
+ if (data && typeof data === "object") {
19
+ for (const [token, meta] of Object.entries(data)) {
20
+ if (token && meta?.deviceId) {
21
+ this.sessions.set(token, meta);
22
+ }
23
+ }
24
+ }
25
+ } catch {}
26
+ }
27
+
28
+ _save() {
29
+ if (!this._dirty) return;
30
+ const obj = Object.fromEntries(this.sessions.entries());
31
+ mkdirSync(join(homedir(), ".pairshell"), { recursive: true });
32
+ writeFileSync(SESSION_FILE, JSON.stringify(obj, null, 2), "utf8");
33
+ this._dirty = false;
34
+ }
35
+
36
+ create(deviceId) {
37
+ const token = randomBytes(32).toString("hex");
38
+ this.sessions.set(token, { deviceId, createdAt: Date.now() });
39
+ this._dirty = true;
40
+ this._save();
41
+ return token;
42
+ }
43
+
44
+ validate(token) {
45
+ if (!token) return null;
46
+ const meta = this.sessions.get(token);
47
+ return meta || null;
48
+ }
49
+
50
+ remove(token) {
51
+ if (this.sessions.delete(token)) {
52
+ this._dirty = true;
53
+ this._save();
54
+ }
55
+ }
56
+
57
+ list() {
58
+ const seen = new Map();
59
+ for (const [token, meta] of this.sessions) {
60
+ if (!seen.has(meta.deviceId)) {
61
+ seen.set(meta.deviceId, { ...meta, token });
62
+ }
63
+ }
64
+ return [...seen.values()];
65
+ }
66
+
67
+ revoke(deviceId) {
68
+ let removed = false;
69
+ for (const [token, meta] of this.sessions) {
70
+ if (meta.deviceId === deviceId) {
71
+ this.sessions.delete(token);
72
+ removed = true;
73
+ }
74
+ }
75
+ if (removed) {
76
+ this._dirty = true;
77
+ this._save();
78
+ }
79
+ return removed;
80
+ }
81
+ }
package/src/setup.js ADDED
@@ -0,0 +1,44 @@
1
+ import { readConfig, setProviderConfig, getProvider } from "./config.js";
2
+ import { resolve } from "./providers/index.js";
3
+ import { ok, info, warn, fail, header, kv, BOLD, RESET } from "./format.js";
4
+
5
+ export async function setupCommand(opts) {
6
+ const providerName = opts.provider || getProvider();
7
+ const prov = resolve(providerName);
8
+
9
+ header(`PairShell Setup: ${prov.label}`);
10
+
11
+ const config = readConfig();
12
+ const existing = config[providerName];
13
+
14
+ if (existing && !opts.force) {
15
+ warn(`${prov.label} is already configured.`);
16
+ kv([[`${prov.name} config`, JSON.stringify(existing)]]);
17
+ console.log(`\n Run ${BOLD}pairshell-cli start${RESET} to begin.\n`);
18
+ return;
19
+ }
20
+
21
+ info(`Setting up ${prov.label} ...`);
22
+
23
+ let newConfig;
24
+ try {
25
+ newConfig = await prov.setup(config);
26
+ } catch (err) {
27
+ fail(`Setup failed: ${err.message || err}`);
28
+ console.log();
29
+ prov.printGuide();
30
+ process.exit(1);
31
+ }
32
+
33
+ setProviderConfig(providerName, newConfig[providerName]);
34
+ ok(`${prov.label} configured and saved`);
35
+
36
+ header("Configuration");
37
+ kv([
38
+ ["Provider", prov.label],
39
+ [`${prov.name} config`, JSON.stringify(newConfig[providerName])],
40
+ ["Config file", "~/.pairshell/config.json"],
41
+ ]);
42
+
43
+ console.log(`\n Run ${BOLD}pairshell-cli start${RESET} to begin.\n`);
44
+ }