plx-cli 0.0.0-stage → 0.5.0
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/README.md +60 -2
- package/bin/plx.mjs +15 -0
- package/package.json +28 -4
- package/src/cli.mjs +205 -0
- package/src/connect.mjs +325 -0
- package/src/detect.mjs +102 -0
- package/src/doctor-playbook.md +214 -0
- package/src/doctor-prompt.mjs +125 -0
- package/src/doctor.mjs +727 -0
- package/src/install.mjs +85 -0
- package/src/plxd.mjs +92 -0
- package/src/releases.mjs +133 -0
- package/src/run.mjs +141 -0
- package/src/scripts.mjs +304 -0
- package/src/signin.mjs +210 -0
- package/src/tailscale-setup.mjs +194 -0
- package/src/tailscale.mjs +162 -0
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// `plx add` and `plx setup`: install Parallax on a computer and connect it over Tailscale (0056),
|
|
3
|
+
// until the Tailscale-only path goes (0091). Each step prints a line as it starts, then a ✓ or ✗
|
|
4
|
+
// line as it ends.
|
|
5
|
+
|
|
6
|
+
import net from "node:net";
|
|
7
|
+
|
|
8
|
+
import { UNAME_COMMAND } from "./detect.mjs";
|
|
9
|
+
import { findInstaller as findRelease, localOs, product as productOf, readDetails, sshTarget, step, StepFailed } from "./install.mjs";
|
|
10
|
+
import { appName } from "./releases.mjs";
|
|
11
|
+
import { localRunner, sshArgs, sshRunner } from "./run.mjs";
|
|
12
|
+
import { installScript } from "./scripts.mjs";
|
|
13
|
+
import { findDevice, parseStatus, PLXD_PORT, probe, readStatus, TAGGED } from "./tailscale.mjs";
|
|
14
|
+
|
|
15
|
+
/** @typedef {import("./releases.mjs").Channel} Channel */
|
|
16
|
+
/** @typedef {import("./releases.mjs").Os} Os */
|
|
17
|
+
/** @typedef {import("./detect.mjs").Target} Target */
|
|
18
|
+
/** @typedef {import("./run.mjs").Runner} Runner */
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @typedef {object} Options
|
|
22
|
+
* @property {Channel} channel
|
|
23
|
+
* @property {string} [user] The SSH user, else ssh's own default.
|
|
24
|
+
* @property {string} [port] The SSH port.
|
|
25
|
+
* @property {boolean} [headless] Install plxd alone, without the app. Linux always does.
|
|
26
|
+
* @property {boolean} [dryRun] Print the steps and the install script without running ssh or
|
|
27
|
+
* installing anything. Only the release list is fetched.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/** Whether `os` gets plxd alone: with `--headless`, and always on Linux. @param {Os} os @param {Options} options */
|
|
31
|
+
const plxdOnly = (os, options) => os === "linux" || !!options.headless;
|
|
32
|
+
|
|
33
|
+
/** What this installs: the app, or plxd alone. @param {Os} os @param {Options} options */
|
|
34
|
+
const product = (os, options) => productOf(plxdOnly(os, options), options.channel);
|
|
35
|
+
|
|
36
|
+
/** The newest release's asset for `target`. @param {Target} target @param {Options} options */
|
|
37
|
+
const findInstaller = (target, options) => findRelease(target, options.channel, plxdOnly(target.os, options));
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Installs on `target` through `runner`, prints the script in a dry run, then waits for plxd to
|
|
41
|
+
* answer on `ip`.
|
|
42
|
+
* @param {Runner} runner
|
|
43
|
+
* @param {Target} target
|
|
44
|
+
* @param {string} name
|
|
45
|
+
* @param {string | undefined} ip
|
|
46
|
+
* @param {Options} options
|
|
47
|
+
*/
|
|
48
|
+
async function install(runner, target, name, ip, options) {
|
|
49
|
+
const installer = await findInstaller(target, options);
|
|
50
|
+
const script = installScript(target.os, installer, options.channel, plxdOnly(target.os, options));
|
|
51
|
+
const what = plxdOnly(target.os, options) ? "plxd alone, without the app," : `${appName(options.channel)} and plxd`;
|
|
52
|
+
if (options.dryRun) {
|
|
53
|
+
console.log(`→ Installing ${what} on ${name} with this ${target.os === "windows" ? "PowerShell" : "sh"} script (dry run, not run):`);
|
|
54
|
+
for (const line of script.trimEnd().split("\n")) console.log(` ${line}`);
|
|
55
|
+
console.log(`→ Would wait up to 60 s for ${ip ?? name}:${PLXD_PORT} to answer.`);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
await step(`Installing ${what} on ${name}`, async () => {
|
|
59
|
+
const { code } = await runner.script(target.os === "windows" ? "powershell" : "sh", script, false);
|
|
60
|
+
if (code !== 0) throw new Error(`The install failed on ${name} (exit ${code}).`);
|
|
61
|
+
return [`Installed ${product(target.os, options)} ${installer.version} and turned on Parallax Connect`, undefined];
|
|
62
|
+
});
|
|
63
|
+
await step(`Waiting for ${name} to answer on port ${PLXD_PORT}`, async () => {
|
|
64
|
+
if (!ip) throw new Error("Tailscale gave this computer no IPv4 address to check.");
|
|
65
|
+
if (!(await waitForPort(ip, 60_000))) {
|
|
66
|
+
throw new Error(
|
|
67
|
+
`${name} didn't answer on ${ip}:${PLXD_PORT} within 60 s. Check that Tailscale is on there and that no firewall blocks TCP ${PLXD_PORT}.`,
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
return [`${name} is connected to Parallax.`, undefined];
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Whether `ip:7340` accepts a connection before `timeoutMs` passes. @param {string} ip @param {number} timeoutMs */
|
|
75
|
+
async function waitForPort(ip, timeoutMs) {
|
|
76
|
+
const deadline = Date.now() + timeoutMs;
|
|
77
|
+
while (Date.now() < deadline) {
|
|
78
|
+
if (await probe(ip)) return true;
|
|
79
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
80
|
+
}
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Where `add` connects for `query`: the Tailscale IPv4 of the device it names, or `query` itself
|
|
86
|
+
* when it names none but is an IP. Throws a message for people otherwise.
|
|
87
|
+
* @param {string} query
|
|
88
|
+
*/
|
|
89
|
+
async function resolveTarget(query) {
|
|
90
|
+
/** @type {ReturnType<typeof parseStatus> | undefined} */
|
|
91
|
+
let tailnet;
|
|
92
|
+
try {
|
|
93
|
+
tailnet = parseStatus(await readStatus());
|
|
94
|
+
} catch (error) {
|
|
95
|
+
if (!net.isIP(query)) throw error;
|
|
96
|
+
}
|
|
97
|
+
const device = tailnet && findDevice(tailnet.devices, query);
|
|
98
|
+
if (device?.ip) return { name: device.name, ip: device.ip, online: device.online };
|
|
99
|
+
if (tailnet && findDevice([tailnet.self], query)) throw new Error("That's this computer. Run npx plx-cli setup instead.");
|
|
100
|
+
if (tailnet?.self.tagged) throw new Error(TAGGED);
|
|
101
|
+
if (net.isIP(query)) return { name: query, ip: query, online: true };
|
|
102
|
+
throw new Error(`No device named ${query} in your tailnet. Run npx plx-cli devices to see them.`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* `plx add`: installs Parallax on the device `query` names over ssh, turns on Parallax
|
|
107
|
+
* Connect there, and waits for it to answer. Resolves to the exit code.
|
|
108
|
+
* @param {string} query
|
|
109
|
+
* @param {Options} options
|
|
110
|
+
*/
|
|
111
|
+
export async function add(query, options) {
|
|
112
|
+
/** @type {Awaited<ReturnType<typeof resolveTarget>>} */
|
|
113
|
+
let device;
|
|
114
|
+
try {
|
|
115
|
+
device = await resolveTarget(query);
|
|
116
|
+
} catch (error) {
|
|
117
|
+
console.log(`✗ ${error instanceof Error ? error.message : String(error)}`);
|
|
118
|
+
return 1;
|
|
119
|
+
}
|
|
120
|
+
const { name, ip } = device;
|
|
121
|
+
if (!device.online) console.log(`! Tailscale says ${name} is offline. Trying anyway.`);
|
|
122
|
+
const destination = options.user ? `${options.user}@${ip}` : ip;
|
|
123
|
+
|
|
124
|
+
if (options.dryRun) return dryRunAdd(query, name, ip, destination, options);
|
|
125
|
+
|
|
126
|
+
const runner = sshRunner(destination, { port: options.port });
|
|
127
|
+
try {
|
|
128
|
+
const target = await sshTarget(runner, name, destination, "npx plx-cli setup");
|
|
129
|
+
await install(runner, target, name, ip, options);
|
|
130
|
+
return 0;
|
|
131
|
+
} catch (error) {
|
|
132
|
+
if (error instanceof StepFailed) return 1;
|
|
133
|
+
throw error;
|
|
134
|
+
} finally {
|
|
135
|
+
runner.close();
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* A dry run of `add`: prints the ssh command and the install script for the OS Tailscale reports,
|
|
141
|
+
* with the arch Parallax builds most for it, since finding the real one takes ssh.
|
|
142
|
+
* @param {string} query
|
|
143
|
+
* @param {string} name
|
|
144
|
+
* @param {string} ip
|
|
145
|
+
* @param {string} destination
|
|
146
|
+
* @param {Options} options
|
|
147
|
+
*/
|
|
148
|
+
async function dryRunAdd(query, name, ip, destination, options) {
|
|
149
|
+
const reported = await readStatus()
|
|
150
|
+
.then((status) => findDevice(parseStatus(status).devices, query)?.os)
|
|
151
|
+
.catch(() => undefined);
|
|
152
|
+
/** @type {Os} */
|
|
153
|
+
const os = reported === "macOS" ? "macos" : reported === "windows" ? "windows" : "linux";
|
|
154
|
+
/** @type {Target} */
|
|
155
|
+
const target = { os, arch: os === "macos" ? "arm64" : "x64", label: name, version: os };
|
|
156
|
+
const ssh = sshArgs(destination, { port: options.port, controlDir: "<tmp>" }, [UNAME_COMMAND]);
|
|
157
|
+
console.log(`→ Would connect with: ssh ${ssh.map((arg) => (/[\s'"*<>|&;]/.test(arg) ? `'${arg}'` : arg)).join(" ")}`);
|
|
158
|
+
console.log(`→ Assuming ${target.os} ${target.arch} from Tailscale (a real run asks the computer).`);
|
|
159
|
+
try {
|
|
160
|
+
await install(localRunner(), target, name, ip, options);
|
|
161
|
+
return 0;
|
|
162
|
+
} catch (error) {
|
|
163
|
+
if (error instanceof StepFailed) return 1;
|
|
164
|
+
throw error;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* `plx setup`: the same install on this computer, without ssh, for a device that can't
|
|
170
|
+
* take SSH. Resolves to the exit code.
|
|
171
|
+
* @param {Options} options
|
|
172
|
+
*/
|
|
173
|
+
export async function setup(options) {
|
|
174
|
+
const os = localOs();
|
|
175
|
+
const runner = localRunner();
|
|
176
|
+
try {
|
|
177
|
+
if (!os) throw new Error(`Parallax doesn't run on ${process.platform}.`);
|
|
178
|
+
const self = await step("Checking Tailscale on this computer", async () => {
|
|
179
|
+
const { self } = parseStatus(await readStatus());
|
|
180
|
+
if (!self.ip) throw new Error("Tailscale has no IPv4 address for this computer. Is it connected?");
|
|
181
|
+
return [`This computer is ${self.name} (${self.ip}) on your tailnet`, self];
|
|
182
|
+
});
|
|
183
|
+
const target = await step("Finding this computer's OS", async () => {
|
|
184
|
+
const target = await readDetails(runner, os);
|
|
185
|
+
return [`${target.label}: ${target.version} (${target.arch})`, target];
|
|
186
|
+
});
|
|
187
|
+
await install(runner, target, self.name, self.ip, options);
|
|
188
|
+
return 0;
|
|
189
|
+
} catch (error) {
|
|
190
|
+
if (error instanceof StepFailed) return 1;
|
|
191
|
+
console.log(`✗ ${error instanceof Error ? error.message : String(error)}`);
|
|
192
|
+
return 1;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// This user's tailnet devices, from `tailscale status --json` (0056).
|
|
3
|
+
|
|
4
|
+
import { execFile } from "node:child_process";
|
|
5
|
+
import { existsSync } from "node:fs";
|
|
6
|
+
import net from "node:net";
|
|
7
|
+
|
|
8
|
+
/** Why no devices are listed when this node is tagged. */
|
|
9
|
+
export const TAGGED =
|
|
10
|
+
"This computer is tagged in Tailscale, so it belongs to no user and has no devices of yours to list. Untag it to use Parallax Connect.";
|
|
11
|
+
|
|
12
|
+
/** The port plxd listens on, on its Tailscale address, while Parallax Connect is on (0056). */
|
|
13
|
+
export const PLXD_PORT = 7340;
|
|
14
|
+
|
|
15
|
+
/** Where the Tailscale CLI is when it isn't on PATH. */
|
|
16
|
+
const TAILSCALE_PATHS = [
|
|
17
|
+
"/Applications/Tailscale.app/Contents/MacOS/Tailscale",
|
|
18
|
+
"/opt/homebrew/bin/tailscale",
|
|
19
|
+
"/usr/local/bin/tailscale",
|
|
20
|
+
"/usr/bin/tailscale",
|
|
21
|
+
"C:\\Program Files\\Tailscale\\tailscale.exe",
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @typedef {object} Device
|
|
26
|
+
* @property {string} id Tailscale's stable node ID.
|
|
27
|
+
* @property {string} name The device's host name.
|
|
28
|
+
* @property {string} dnsName Its MagicDNS name, without the trailing dot.
|
|
29
|
+
* @property {string} os As Tailscale reports it: `macOS`, `linux`, `windows`, ...
|
|
30
|
+
* @property {string | undefined} ip Its first Tailscale IPv4 address.
|
|
31
|
+
* @property {boolean} online
|
|
32
|
+
* @property {boolean} parallax plxd answers on its port 7340. Probed only while online.
|
|
33
|
+
* @property {boolean} tagged It has Tailscale tags. Only this node can be: tagged peers are left out.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @typedef {object} TailnetNode The fields plx reads from a node in `tailscale status --json`.
|
|
38
|
+
* @property {string} [ID]
|
|
39
|
+
* @property {string} [HostName]
|
|
40
|
+
* @property {string} [DNSName]
|
|
41
|
+
* @property {string} [OS]
|
|
42
|
+
* @property {string[] | null} [TailscaleIPs]
|
|
43
|
+
* @property {boolean} [Online]
|
|
44
|
+
* @property {number} [UserID]
|
|
45
|
+
* @property {string[] | null} [Tags]
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @typedef {object} Status
|
|
50
|
+
* @property {TailnetNode} [Self]
|
|
51
|
+
* @property {Record<string, TailnetNode> | null} [Peer]
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* This node and the peers that belong to its own Tailscale user, from `tailscale status --json`.
|
|
56
|
+
* Nodes shared from another tailnet have another `UserID`, so they're left out. Tagged nodes are
|
|
57
|
+
* left out too: they all share one "tagged-devices" user, so their `UserID` can match. A tagged
|
|
58
|
+
* node has no user of its own, so when this node is tagged, no peer is listed.
|
|
59
|
+
* `parallax` is false here; {@link listDevices} probes it.
|
|
60
|
+
* @param {Status} status
|
|
61
|
+
* @returns {{ self: Device, devices: Device[] }}
|
|
62
|
+
*/
|
|
63
|
+
export function parseStatus(status) {
|
|
64
|
+
const self = status.Self;
|
|
65
|
+
if (!self || self.UserID === undefined) throw new Error("Tailscale isn't logged in on this computer.");
|
|
66
|
+
const devices = Object.values(status.Peer ?? {})
|
|
67
|
+
.filter((peer) => !isTagged(self) && !isTagged(peer) && peer.UserID === self.UserID)
|
|
68
|
+
.map(toDevice)
|
|
69
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
70
|
+
return { self: { ...toDevice(self), online: true }, devices };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** @param {TailnetNode} node */
|
|
74
|
+
const isTagged = (node) => (node.Tags ?? []).length > 0;
|
|
75
|
+
|
|
76
|
+
/** @param {TailnetNode} node @returns {Device} */
|
|
77
|
+
function toDevice(node) {
|
|
78
|
+
return {
|
|
79
|
+
id: node.ID ?? "",
|
|
80
|
+
name: node.HostName ?? "",
|
|
81
|
+
dnsName: (node.DNSName ?? "").replace(/\.$/, ""),
|
|
82
|
+
os: node.OS ?? "",
|
|
83
|
+
ip: (node.TailscaleIPs ?? []).find((ip) => net.isIPv4(ip)),
|
|
84
|
+
online: node.Online === true,
|
|
85
|
+
parallax: false,
|
|
86
|
+
tagged: isTagged(node),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** The Tailscale CLI: `tailscale` on PATH, else the first of the usual install paths that exists. */
|
|
91
|
+
async function findTailscale() {
|
|
92
|
+
const name = process.platform === "win32" ? "tailscale.exe" : "tailscale";
|
|
93
|
+
const ok = await new Promise((resolve) =>
|
|
94
|
+
execFile(name, ["version"], { timeout: 5000 }, (error) => resolve(!error)),
|
|
95
|
+
);
|
|
96
|
+
if (ok) return name;
|
|
97
|
+
const found = TAILSCALE_PATHS.find((path) => existsSync(path));
|
|
98
|
+
if (!found) throw new Error("Tailscale isn't installed on this computer. Get it at https://tailscale.com/download.");
|
|
99
|
+
return found;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Runs `tailscale status --json` and parses its answer. */
|
|
103
|
+
export async function readStatus() {
|
|
104
|
+
const tailscale = await findTailscale();
|
|
105
|
+
/** @type {string} */
|
|
106
|
+
const stdout = await new Promise((resolve, reject) =>
|
|
107
|
+
execFile(tailscale, ["status", "--json"], { maxBuffer: 16 * 1024 * 1024, timeout: 15_000 }, (error, out, err) => {
|
|
108
|
+
// `status` exits 1 while Tailscale is stopped or logged out, but still prints the JSON.
|
|
109
|
+
if (out.trim().startsWith("{")) resolve(out);
|
|
110
|
+
else reject(new Error(`tailscale status failed: ${(err || error?.message || "").trim()}`));
|
|
111
|
+
}),
|
|
112
|
+
);
|
|
113
|
+
return /** @type {Status} */ (JSON.parse(stdout));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Whether `host:port` accepts a TCP connection within `timeoutMs`.
|
|
118
|
+
* @param {string} host
|
|
119
|
+
* @param {number} [port]
|
|
120
|
+
* @param {number} [timeoutMs]
|
|
121
|
+
* @returns {Promise<boolean>}
|
|
122
|
+
*/
|
|
123
|
+
export function probe(host, port = PLXD_PORT, timeoutMs = 1500) {
|
|
124
|
+
return new Promise((resolve) => {
|
|
125
|
+
const socket = net.connect({ host, port });
|
|
126
|
+
const done = (/** @type {boolean} */ answered) => {
|
|
127
|
+
socket.destroy();
|
|
128
|
+
resolve(answered);
|
|
129
|
+
};
|
|
130
|
+
socket.setTimeout(timeoutMs, () => done(false));
|
|
131
|
+
socket.once("connect", () => done(true));
|
|
132
|
+
socket.once("error", () => done(false));
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** This node and its user's other devices, with `parallax` probed on each that's online. */
|
|
137
|
+
export async function listDevices() {
|
|
138
|
+
const { self, devices } = parseStatus(await readStatus());
|
|
139
|
+
await Promise.all(
|
|
140
|
+
[self, ...devices].map(async (device) => {
|
|
141
|
+
if (device.online && device.ip) device.parallax = await probe(device.ip);
|
|
142
|
+
}),
|
|
143
|
+
);
|
|
144
|
+
return { self, devices };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The device `query` names: its host name, MagicDNS name (whole or first label), or a Tailscale
|
|
149
|
+
* IP, case-insensitively. Undefined when none matches.
|
|
150
|
+
* @param {Device[]} devices
|
|
151
|
+
* @param {string} query
|
|
152
|
+
*/
|
|
153
|
+
export function findDevice(devices, query) {
|
|
154
|
+
const want = query.toLowerCase().replace(/\.$/, "");
|
|
155
|
+
return devices.find(
|
|
156
|
+
(device) =>
|
|
157
|
+
device.name.toLowerCase() === want ||
|
|
158
|
+
device.dnsName.toLowerCase() === want ||
|
|
159
|
+
device.dnsName.split(".")[0]?.toLowerCase() === want ||
|
|
160
|
+
device.ip === want,
|
|
161
|
+
);
|
|
162
|
+
}
|