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.
@@ -0,0 +1,85 @@
1
+ // @ts-check
2
+ // What the Tailscale commands and `plx connect` share: steps that print a line as they start and
3
+ // a ✓ or ✗ line as they end, and finding a computer's OS and the release to install there.
4
+
5
+ import { parseDetails, parseUname, POSIX_DETAILS, UNAME_COMMAND, WINDOWS_DETAILS } from "./detect.mjs";
6
+ import { appName, fetchReleases, pickAsset, pickRelease, withSha256 } from "./releases.mjs";
7
+
8
+ /** @typedef {import("./releases.mjs").Channel} Channel */
9
+ /** @typedef {import("./releases.mjs").Os} Os */
10
+ /** @typedef {import("./detect.mjs").Target} Target */
11
+ /** @typedef {import("./run.mjs").Runner} Runner */
12
+
13
+ /** A step failed. Its ✗ line is already printed. */
14
+ export class StepFailed extends Error {}
15
+
16
+ /**
17
+ * Prints `title`, runs `run`, then prints its ✓ line, or ✗ with the error's message.
18
+ * @template T
19
+ * @param {string} title
20
+ * @param {() => Promise<[string, T]>} run Resolves to the ✓ line's text and the step's value.
21
+ * @returns {Promise<T>}
22
+ */
23
+ export async function step(title, run) {
24
+ console.log(`→ ${title}`);
25
+ try {
26
+ const [done, value] = await run();
27
+ console.log(`✓ ${done}`);
28
+ return value;
29
+ } catch (error) {
30
+ console.log(`✗ ${error instanceof Error ? error.message : String(error)}`);
31
+ throw new StepFailed();
32
+ }
33
+ }
34
+
35
+ /** This computer's OS as releases name it, or undefined where Parallax doesn't run. @returns {Os | undefined} */
36
+ export const localOs = () =>
37
+ process.platform === "darwin" ? "macos" : process.platform === "linux" ? "linux" : process.platform === "win32" ? "windows" : undefined;
38
+
39
+ /** The target's OS and details, with the runner's shell. @param {Runner} runner @param {Os} os */
40
+ export async function readDetails(runner, os) {
41
+ const windows = os === "windows";
42
+ const { code, stdout } = await runner.script(windows ? "powershell" : "sh", windows ? WINDOWS_DETAILS : POSIX_DETAILS, true);
43
+ if (code !== 0) throw new Error(`Couldn't read the computer's details (exit ${code}).`);
44
+ return parseDetails(os, stdout);
45
+ }
46
+
47
+ /**
48
+ * Connects to `destination` over ssh, then finds its OS and details, as two steps.
49
+ * @param {Runner} runner The ssh runner for `destination`.
50
+ * @param {string} name What to call the computer.
51
+ * @param {string} destination
52
+ * @param {string} instead The command to run there when ssh can't connect.
53
+ */
54
+ export async function sshTarget(runner, name, destination, instead) {
55
+ const os = await step(`Connecting to ${name} (${destination}) over SSH`, async () => {
56
+ const { code, stdout } = await runner.command(UNAME_COMMAND);
57
+ if (code === 255) {
58
+ throw new Error(
59
+ `SSH couldn't connect to ${destination}. Turn on Remote Login (macOS), OpenSSH Server (Windows), or sshd (Linux) there, or run ${instead} on it.`,
60
+ );
61
+ }
62
+ return [`Connected to ${name}`, parseUname(stdout, code)];
63
+ });
64
+ return step(`Finding ${name}'s OS`, async () => {
65
+ const target = await readDetails(runner, os);
66
+ return [`${target.label}: ${target.version} (${target.arch})`, target];
67
+ });
68
+ }
69
+
70
+ /** The product a plxd-only install, or else the app, is called. @param {boolean} plxdOnly @param {Channel} channel */
71
+ export const product = (plxdOnly, channel) => (plxdOnly ? "plxd" : appName(channel));
72
+
73
+ /**
74
+ * The newest release's asset for `target`, with the SHA-256 the script checks it against.
75
+ * @param {Target} target
76
+ * @param {Channel} channel
77
+ * @param {boolean} plxdOnly
78
+ */
79
+ export function findInstaller(target, channel, plxdOnly) {
80
+ return step(`Finding the newest ${channel} release`, async () => {
81
+ const found = pickAsset(pickRelease(await fetchReleases(), channel), target.os, target.arch, plxdOnly);
82
+ const installer = await withSha256(found);
83
+ return [`${product(plxdOnly, channel)} ${installer.version}: ${installer.name}`, installer];
84
+ });
85
+ }
package/src/plxd.mjs ADDED
@@ -0,0 +1,92 @@
1
+ // @ts-check
2
+ // A JSON-RPC connection to the running plxd over `plxd attach`'s stdio, which is plxd's trusted
3
+ // local connection, as `doctor` and the app use it. Requests may overlap, so `access/get` can wait
4
+ // for a change while another request goes out. plxd closes a connection that sends nothing for
5
+ // 90 s, so an open one sends `host/health` every 30 s, as the editor does, to outlast a long wait.
6
+
7
+ import { readFileSync } from "node:fs";
8
+
9
+ /** plx's version, for `initialize`. */
10
+ export const VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
11
+
12
+ /**
13
+ * @typedef {object} Plxd
14
+ * @property {(method: string, params?: object) => Promise<any>} call Sends a request and resolves
15
+ * to its result, or rejects with plxd's error message.
16
+ * @property {() => void} close Ends `plxd attach`.
17
+ */
18
+
19
+ /** How often an open connection sends `host/health`, well under plxd's 90 s idle limit. */
20
+ const KEEPALIVE_MS = 30_000;
21
+
22
+ /**
23
+ * Speaks JSON-RPC with `child`, a `plxd attach`, and sends `initialize` first. Resolves once plxd
24
+ * answered it.
25
+ * @param {import("node:child_process").ChildProcess} child
26
+ * @param {{ keepaliveMs?: number }} [options] `keepaliveMs` is shorter in tests.
27
+ * @returns {Promise<Plxd>}
28
+ */
29
+ export async function attach(child, { keepaliveMs = KEEPALIVE_MS } = {}) {
30
+ /** @type {Map<number, { resolve: (value: any) => void, reject: (error: Error) => void }>} */
31
+ const waiting = new Map();
32
+ let next = 1;
33
+ /** @type {Error | undefined} */
34
+ let ended;
35
+ /** @type {NodeJS.Timeout | undefined} */
36
+ let keepalive;
37
+ const end = (/** @type {Error} */ error) => {
38
+ clearInterval(keepalive);
39
+ ended ??= error;
40
+ for (const { reject } of waiting.values()) reject(ended);
41
+ waiting.clear();
42
+ };
43
+ child.once("error", (error) => end(new Error(`Couldn't start plxd: ${error.message}`)));
44
+ child.once("exit", () => end(new Error("plxd stopped answering.")));
45
+ child.stdin?.on("error", () => {});
46
+
47
+ let pending = "";
48
+ child.stdout?.setEncoding("utf8");
49
+ child.stdout?.on("data", (/** @type {string} */ chunk) => {
50
+ const lines = (pending + chunk).split("\n");
51
+ pending = lines.pop() ?? "";
52
+ for (const line of lines) {
53
+ /** @type {{ id?: unknown, result?: unknown, error?: { message?: string } }} */
54
+ let message;
55
+ try {
56
+ message = JSON.parse(line);
57
+ } catch {
58
+ continue;
59
+ }
60
+ const request = typeof message.id === "number" ? waiting.get(message.id) : undefined;
61
+ if (!request) continue;
62
+ waiting.delete(/** @type {number} */ (message.id));
63
+ if (message.error) request.reject(new Error(message.error.message ?? "plxd refused the request."));
64
+ else request.resolve(message.result);
65
+ }
66
+ });
67
+
68
+ /** @type {Plxd["call"]} */
69
+ const call = (method, params = {}) =>
70
+ new Promise((resolve, reject) => {
71
+ if (ended) return reject(ended);
72
+ const id = next++;
73
+ waiting.set(id, { resolve, reject });
74
+ child.stdin?.write(`${JSON.stringify({ jsonrpc: "2.0", id, method, params })}\n`);
75
+ });
76
+
77
+ try {
78
+ await call("initialize", { protocol: { min: 1, max: 1 }, client: { name: "plx", version: VERSION }, capabilities: {} });
79
+ } catch (error) {
80
+ child.kill();
81
+ throw error;
82
+ }
83
+ if (!ended) keepalive = setInterval(() => call("host/health").catch(() => {}), keepaliveMs).unref();
84
+ return {
85
+ call,
86
+ close() {
87
+ clearInterval(keepalive);
88
+ child.stdin?.end();
89
+ child.kill();
90
+ },
91
+ };
92
+ }
@@ -0,0 +1,133 @@
1
+ // @ts-check
2
+ // Which Parallax installer to download, from the repo's GitHub Releases (0028, 0029, 0030).
3
+
4
+ /** @typedef {"stable" | "nightly"} Channel */
5
+ /** @typedef {"macos" | "linux" | "windows"} Os */
6
+ /** @typedef {"arm64" | "x64"} Arch */
7
+
8
+ /**
9
+ * @typedef {object} Release The fields plx reads from GitHub's release list.
10
+ * @property {string} tag_name
11
+ * @property {boolean} prerelease
12
+ * @property {boolean} [draft]
13
+ * @property {Asset[]} assets
14
+ */
15
+
16
+ /**
17
+ * @typedef {object} Asset
18
+ * @property {string} name
19
+ * @property {string} browser_download_url
20
+ * @property {string | null} [digest] GitHub's `sha256:<hex>` of the file, on assets uploaded since
21
+ * mid-2025.
22
+ */
23
+
24
+ /**
25
+ * @typedef {object} Installer What an install script downloads.
26
+ * @property {string} version The release's version: its tag without the `v`.
27
+ * @property {string} name The asset's file name.
28
+ * @property {string} url Where to download it.
29
+ * @property {string} sha256 Its SHA-256 as the release reports it, in hex. The script refuses a
30
+ * file that doesn't match.
31
+ */
32
+
33
+ /**
34
+ * @typedef {Omit<Installer, "sha256"> & { sha256?: string, sha256Url?: string }} Found
35
+ * An asset {@link pickAsset} chose: with its SHA-256 from GitHub's digest, or where its
36
+ * `.sha256` asset is, or neither. {@link withSha256} makes it an {@link Installer}.
37
+ */
38
+
39
+ const RELEASES_URL = "https://api.github.com/repos/ryan-stoffel/parallax/releases?per_page=30";
40
+
41
+ const OS_NAMES = { macos: "macOS", linux: "Linux", windows: "Windows" };
42
+
43
+ /** The app's name for a channel: a nightly build is "Parallax (Nightly)" (PLX-286). @param {Channel} channel */
44
+ export const appName = (channel) => (channel === "nightly" ? "Parallax (Nightly)" : "Parallax");
45
+
46
+ /** The release list, newest first, from GitHub's API. */
47
+ export async function fetchReleases() {
48
+ const response = await fetch(RELEASES_URL, {
49
+ headers: { accept: "application/vnd.github+json", "user-agent": "plx-cli" },
50
+ signal: AbortSignal.timeout(20_000),
51
+ });
52
+ if (!response.ok) throw new Error(`GitHub answered ${response.status} ${response.statusText} for the release list.`);
53
+ return /** @type {Release[]} */ (await response.json());
54
+ }
55
+
56
+ /**
57
+ * The newest release of `channel` in `releases` (GitHub lists newest first): for nightly, a
58
+ * prerelease tagged `*-nightly`; for stable, any release that isn't a prerelease. Throws a message
59
+ * for people when there's none.
60
+ * @param {Release[]} releases
61
+ * @param {Channel} channel
62
+ */
63
+ export function pickRelease(releases, channel) {
64
+ const release = releases.find(
65
+ (r) => !r.draft && (channel === "nightly" ? r.prerelease && r.tag_name.endsWith("-nightly") : !r.prerelease),
66
+ );
67
+ if (release) return release;
68
+ if (channel === "stable") throw new Error("Parallax has no stable release yet. Run again with --channel nightly.");
69
+ throw new Error("Parallax has no nightly release.");
70
+ }
71
+
72
+ /**
73
+ * The asset in `release` for `os` and `arch`: the dmg on macOS (arm64 only) and the NSIS exe on
74
+ * Windows, or with `headless`, plxd by itself (`parallax-plxd-<v>-mac-arm64`,
75
+ * `parallax-plxd-<v>-win-<arch>.exe`). Linux always gets plxd by itself (PLX-642's asset), which
76
+ * runs where the AppImage's runtime can't, such as NixOS. Throws a message for people when the
77
+ * release has none.
78
+ * @param {Release} release
79
+ * @param {Os} os
80
+ * @param {Arch} arch
81
+ * @param {boolean} [headless]
82
+ * @returns {Found}
83
+ */
84
+ export function pickAsset(release, os, arch, headless = false) {
85
+ const version = release.tag_name.replace(/^v/, "");
86
+ if (os === "macos" && arch !== "arm64") throw new Error("Parallax has no build for Intel Macs, only Apple silicon.");
87
+ const plxdOnly = os === "linux" || headless;
88
+ const tag = { macos: "mac", windows: "win", linux: "linux" }[os];
89
+ const name = plxdOnly
90
+ ? `parallax-plxd-${version}-${tag}-${arch}${os === "windows" ? ".exe" : ""}`
91
+ : `parallax-${version}-${tag}-${arch}.${os === "macos" ? "dmg" : "exe"}`;
92
+ const asset = release.assets.find((a) => a.name === name);
93
+ if (!asset) {
94
+ const kind = release.prerelease ? "nightly" : "release";
95
+ const what = plxdOnly && os !== "linux" ? "plxd" : "build";
96
+ throw new Error(`The newest ${kind}, ${release.tag_name}, has no ${OS_NAMES[os]} ${arch} ${what}.`);
97
+ }
98
+ const digest = /^sha256:([0-9a-f]{64})$/i.exec(asset.digest ?? "")?.[1]?.toLowerCase();
99
+ const sums = release.assets.find((a) => a.name === `${name}.sha256`);
100
+ return {
101
+ version,
102
+ name,
103
+ url: asset.browser_download_url,
104
+ ...(digest ? { sha256: digest } : sums && { sha256Url: sums.browser_download_url }),
105
+ };
106
+ }
107
+
108
+ /**
109
+ * `found` with its SHA-256: GitHub's digest, else the hash its `.sha256` asset lists for it
110
+ * (`<hex> <name>`, as `shasum -a 256` writes). Throws a message for people when there's neither,
111
+ * so nothing is installed unchecked.
112
+ * @param {Found} found
113
+ * @param {(url: string) => Promise<string>} [fetchText]
114
+ * @returns {Promise<Installer>}
115
+ */
116
+ export async function withSha256({ sha256Url, ...found }, fetchText = fetchUrl) {
117
+ if (found.sha256) return { ...found, sha256: found.sha256 };
118
+ const listed = sha256Url
119
+ ? (await fetchText(sha256Url))
120
+ .split(/\r?\n/)
121
+ .map((line) => /^([0-9a-f]{64}) [ *]?(.+)$/i.exec(line.trim()))
122
+ .find((m) => m?.[2] === found.name)?.[1]
123
+ : undefined;
124
+ if (!listed) throw new Error(`The release has no SHA-256 for ${found.name}, so plx won't install it.`);
125
+ return { ...found, sha256: listed.toLowerCase() };
126
+ }
127
+
128
+ /** The text at `url`. @param {string} url */
129
+ async function fetchUrl(url) {
130
+ const response = await fetch(url, { headers: { "user-agent": "plx-cli" }, signal: AbortSignal.timeout(20_000) });
131
+ if (!response.ok) throw new Error(`GitHub answered ${response.status} ${response.statusText} for ${url}.`);
132
+ return response.text();
133
+ }
package/src/run.mjs ADDED
@@ -0,0 +1,141 @@
1
+ // @ts-check
2
+ // Running a script, or `plxd attach`, on the target: over the system ssh for `add` and
3
+ // `connect add`, or here for `setup` and `connect`.
4
+
5
+ import { spawn } from "node:child_process";
6
+ import { mkdtempSync, rmSync } from "node:fs";
7
+ import os from "node:os";
8
+ import path from "node:path";
9
+
10
+ import { POWERSHELL_ARGS } from "./scripts.mjs";
11
+
12
+ /** @typedef {"sh" | "powershell"} Shell */
13
+ /** @typedef {{ code: number, stdout: string }} Result */
14
+
15
+ /**
16
+ * @typedef {object} Runner
17
+ * @property {(command: string) => Promise<Result>} command Runs `command` in the target's own
18
+ * shell, keeping stdout. Only SSH targets have one to run it in.
19
+ * @property {(shell: Shell, script: string, capture: boolean) => Promise<Result>} script Runs
20
+ * `script` with `sh -s` or PowerShell, from stdin. With `capture`, keeps stdout; without, prints
21
+ * stdout and stderr indented as they arrive.
22
+ * @property {(os: import("./releases.mjs").Os) => import("node:child_process").ChildProcess} attach
23
+ * Starts `plxd attach` from `~/.parallax-plxd` there, where the install put it (0072), with
24
+ * stdin and stdout piped for plxd's JSON-RPC.
25
+ * @property {() => void} close Ends what the runner keeps open.
26
+ */
27
+
28
+ /** Where the install puts plxd on this computer (0072). */
29
+ export const localPlxd = () =>
30
+ path.join(os.homedir(), ".parallax-plxd", process.platform === "win32" ? "plxd.exe" : "plxd");
31
+
32
+ /**
33
+ * The remote command that starts `plxd attach` from `~/.parallax-plxd` on an `os` computer: in
34
+ * sh, or on Windows in cmd, OpenSSH's default shell there, which expands `%USERPROFILE%`.
35
+ * @param {import("./releases.mjs").Os} os
36
+ */
37
+ export const remoteAttach = (os) =>
38
+ os === "windows" ? `cmd /c "%USERPROFILE%\\.parallax-plxd\\plxd.exe" attach` : `"$HOME/.parallax-plxd/plxd" attach`;
39
+
40
+ /**
41
+ * ssh's arguments to run `command` on `destination`. Host keys are accepted the first time,
42
+ * since the tailnet already authenticated the node (0056). With `controlDir`, the calls share
43
+ * one connection, so a password is asked once. Not BatchMode: the app runs this in a terminal,
44
+ * where ssh can ask for a password.
45
+ * @param {string} destination `[user@]host`
46
+ * @param {{ port?: string, controlDir?: string }} options
47
+ * @param {string[]} command
48
+ */
49
+ export function sshArgs(destination, options, command) {
50
+ const args = ["-o", "StrictHostKeyChecking=accept-new", "-o", "ConnectTimeout=10"];
51
+ if (options.controlDir) {
52
+ args.push("-o", "ControlMaster=auto", "-o", `ControlPath=${options.controlDir}/%C`, "-o", "ControlPersist=120");
53
+ }
54
+ if (options.port) args.push("-p", options.port);
55
+ return [...args, "--", destination, ...command];
56
+ }
57
+
58
+ /** The remote command that reads a script of `shell` from stdin. @param {Shell} shell */
59
+ const remoteReader = (shell) => (shell === "sh" ? ["sh -s"] : [`powershell ${POWERSHELL_ARGS.join(" ")}`]);
60
+
61
+ /**
62
+ * A runner for `destination` over ssh. Windows' OpenSSH client has no ControlMaster, so there
63
+ * each call connects on its own. The control socket's folder is under /tmp, since the path of a
64
+ * Unix socket is limited to about 104 bytes and macOS' tmpdir is long.
65
+ * @param {string} destination
66
+ * @param {{ port?: string }} options
67
+ * @returns {Runner}
68
+ */
69
+ export function sshRunner(destination, options) {
70
+ const controlDir = process.platform === "win32" ? undefined : mkdtempSync(path.join("/tmp", "plxc-"));
71
+ const ssh = process.platform === "win32" ? "ssh.exe" : "ssh";
72
+ const args = (/** @type {string[]} */ command) => sshArgs(destination, { ...options, controlDir }, command);
73
+ return {
74
+ command: (command) => exec(ssh, args([command]), { capture: true }),
75
+ script: (shell, script, capture) => exec(ssh, args(remoteReader(shell)), { input: script, capture }),
76
+ attach: (os) => spawn(ssh, args([remoteAttach(os)]), { stdio: ["pipe", "pipe", "ignore"] }),
77
+ close() {
78
+ if (!controlDir) return;
79
+ spawn(ssh, ["-O", "exit", ...args([])], { stdio: "ignore" }).once("close", () =>
80
+ rmSync(controlDir, { recursive: true, force: true }),
81
+ );
82
+ },
83
+ };
84
+ }
85
+
86
+ /** A runner for this computer. @returns {Runner} */
87
+ export function localRunner() {
88
+ return {
89
+ command: () => Promise.reject(new Error("no shell command runs locally")),
90
+ script: (shell, script, capture) =>
91
+ shell === "sh"
92
+ ? exec("sh", ["-s"], { input: script, capture })
93
+ : exec("powershell.exe", POWERSHELL_ARGS, { input: script, capture }),
94
+ attach: () => spawn(localPlxd(), ["attach"], { stdio: ["pipe", "pipe", "ignore"] }),
95
+ close() {},
96
+ };
97
+ }
98
+
99
+ /**
100
+ * Runs `file` and resolves to its exit code (255 when it couldn't start, as ssh reports a failed
101
+ * connection) and, with `capture`, its stdout. stderr always reaches the terminal: the first ssh
102
+ * call may become a background ControlMaster that keeps stderr open, so it is never piped then.
103
+ * @param {string} file
104
+ * @param {string[]} args
105
+ * @param {{ input?: string, capture: boolean }} options
106
+ * @returns {Promise<Result>}
107
+ */
108
+ function exec(file, args, { input, capture }) {
109
+ return new Promise((resolve) => {
110
+ const child = spawn(file, args, {
111
+ stdio: [input === undefined ? "inherit" : "pipe", "pipe", capture ? "inherit" : "pipe"],
112
+ });
113
+ let stdout = "";
114
+ if (capture) child.stdout?.on("data", (chunk) => (stdout += chunk));
115
+ else {
116
+ indent(child.stdout);
117
+ indent(child.stderr);
118
+ }
119
+ child.once("error", (error) => {
120
+ console.log(` ${file}: ${error.message}`);
121
+ resolve({ code: 255, stdout });
122
+ });
123
+ child.once("close", (code) => resolve({ code: code ?? 1, stdout }));
124
+ if (input !== undefined) child.stdin?.end(input);
125
+ });
126
+ }
127
+
128
+ /** Prints what `stream` sends, a line at a time, indented under the step it belongs to. @param {import("node:stream").Readable | null} stream */
129
+ function indent(stream) {
130
+ if (!stream) return;
131
+ let pending = "";
132
+ stream.setEncoding("utf8");
133
+ stream.on("data", (/** @type {string} */ chunk) => {
134
+ const lines = (pending + chunk).split("\n");
135
+ pending = lines.pop() ?? "";
136
+ for (const line of lines) console.log(` ${line.replace(/\r$/, "")}`);
137
+ });
138
+ stream.on("end", () => {
139
+ if (pending) console.log(` ${pending.replace(/\r$/, "")}`);
140
+ });
141
+ }