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 CHANGED
@@ -1,3 +1,61 @@
1
- # Temporary Holding Version
1
+ # plx
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Parallax's command line. `plx connect` sets a computer up for [Parallax](https://github.com/ryan-stoffel/parallax) Connect with one sign-in: it installs plxd with its login service, registers the computer with Parallax Relay under your Parallax account, and every device signed in to that account can reach it, with no Tailscale, sshd, keys, or open ports ([0091](../../docs/decisions/0091-connect-over-relay.md)).
4
+
5
+ It publishes as `plx-cli`, with a `plx` command, until the `plx` name on npm is Parallax's.
6
+
7
+ ```sh
8
+ npx plx-cli connect
9
+ ```
10
+
11
+ Every download is checked against the SHA-256 its GitHub release reports, and plx refuses one that doesn't match or has none. plxd goes in `~/.parallax-plxd` (`%USERPROFILE%\.parallax-plxd` on Windows), and its login service runs it from there. On Linux, `~/.local/bin/plxd` links to it.
12
+
13
+ ## Commands
14
+
15
+ | Command | What it does |
16
+ | --- | --- |
17
+ | `plx connect` | Signs in, installs plxd here, registers it with the relay, and turns on `relay` and `remote`. Once it's online it shows a 6-digit code: type it on a signed-in device when that device opens this computer, and plx prints the device's name. |
18
+ | `plx connect add <ssh destination>` | The same on another computer over SSH, with the sign-in here. |
19
+ | `plx connect approve` | Approves a device waiting to sign in, once its code matches, or shows a new code to type on a device. |
20
+ | `plx connect status` | Prints the account, host id, relay state, and routes. |
21
+ | `plx connect unlink` | Turns the relay off here and keeps the registration, so the host id and webhook URLs stay. Signed-in devices keep their sessions until removed in Settings. |
22
+ | `plx connect remove` | Signs in again and removes this computer from your account. |
23
+ | `plx doctor` | Checks Parallax on this computer and hands what's left to your coding agent. See [Doctor](#doctor). |
24
+
25
+ Until the release after this one, the Tailscale commands stay: `plx devices [--json]`, `plx add <device>`, and `plx setup` ([0056](../../docs/decisions/0056-parallax-connect.md)).
26
+
27
+ Options:
28
+
29
+ - `--channel stable|nightly`: which release to install (default `stable`).
30
+ - `--no-browser`: sign in on any device and paste the code here. This is automatic over SSH and on Linux without a display.
31
+ - `--user <user>` and `--port <port>`: the SSH user and port for `add`.
32
+ - `--headless`: install plxd alone, without the app, for the Tailscale `add` and `setup`. `connect` always installs plxd alone.
33
+ - `--dry-run`: print the Tailscale steps and install script without installing anything.
34
+ - `--agent claude|codex` and `--model <model>`: the agent `doctor` hands off to, without asking, and its model.
35
+
36
+ ## Sign-in
37
+
38
+ `plx connect` uses the Parallax app's Clerk OAuth application, with PKCE. With a browser on this computer, sign-in returns to a listener on 127.0.0.1. Otherwise plx prints a link: open it on any device, sign in, and paste the code `relay.parallax.codes/connect/code` shows. The code works only with the verifier plx holds. The token goes to plxd once, to register, and nothing keeps it.
39
+
40
+ ## Doctor
41
+
42
+ ```sh
43
+ npx plx-cli doctor
44
+ ```
45
+
46
+ It asks the running plxd for its version, health, and agents, then checks:
47
+
48
+ - plxd is installed, running, answering, and running the binary on disk, not one a self-update replaced.
49
+ - It's the newest release on its channel.
50
+ - The project store is ok and its writes aren't queuing.
51
+ - Each agent Parallax uses is installed and signed in.
52
+ - git is on PATH, the disk has room, and plxd's log has no errors from the last 24 hours.
53
+
54
+ For each failed check it can fix, it asks first: it starts plxd or runs an agent's own sign-in. Then it writes `context.md` (this computer's facts and the results) and `prompt.md` (the [playbook](src/doctor-playbook.md)) to `doctor/<time>/` in the data folder. It offers to open Claude Code or Codex on them, as T3 Code's `t3 triage` does. The agent asks what went wrong, reads the logs and the store, checks Parallax's source and issues, and helps you file an issue with the `via-doctor` template. It posts only after you approve the text. Without either agent, the doctor prints the two files' paths to paste into any agent.
55
+
56
+ ## Requirements
57
+
58
+ - Node 20 or newer on the computer that runs plx, unless the Parallax app runs it.
59
+ - For `add`, SSH on the other computer: Remote Login on macOS, OpenSSH Server on Windows, or sshd on Linux. On macOS and Linux, SSH asks for the password at most once.
60
+ - Parallax builds for macOS on Apple silicon, Windows x64 and arm64, and Linux x64 and arm64.
61
+ - On Linux, plxd brings the bubblewrap and socat Claude Code's sandbox needs. Ubuntu 24.04 and later also need the distro's `bubblewrap` and the AppArmor profile from Claude Code's sandboxing docs. NixOS needs `programs.nix-ld.enable = true;` to run agent CLIs.
package/bin/plx.mjs ADDED
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env node
2
+ // @ts-check
3
+ // plx: Parallax's command line (src/cli.mjs).
4
+
5
+ import { main } from "../src/cli.mjs";
6
+
7
+ main(process.argv.slice(2)).then(
8
+ (code) => {
9
+ process.exitCode = code;
10
+ },
11
+ (error) => {
12
+ console.log(`✗ ${error instanceof Error ? error.message : String(error)}`);
13
+ process.exitCode = 1;
14
+ },
15
+ );
package/package.json CHANGED
@@ -1,6 +1,30 @@
1
1
  {
2
2
  "name": "plx-cli",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.5.0",
4
+ "description": "Parallax's command line: set up a computer for Parallax Connect with one sign-in.",
5
+ "license": "Apache-2.0",
6
+ "type": "module",
7
+ "bin": {
8
+ "plx": "bin/plx.mjs"
9
+ },
10
+ "files": [
11
+ "bin",
12
+ "src"
13
+ ],
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/ryan-stoffel/parallax.git",
17
+ "directory": "packages/plx"
18
+ },
19
+ "engines": {
20
+ "node": ">=20"
21
+ },
22
+ "scripts": {
23
+ "check": "tsc -p .",
24
+ "test": "node --test"
25
+ },
26
+ "devDependencies": {
27
+ "@types/node": "^26.6.5",
28
+ "typescript": "^7.0.2"
29
+ }
30
+ }
package/src/cli.mjs ADDED
@@ -0,0 +1,205 @@
1
+ // @ts-check
2
+ // plx: Parallax's command line. `plx connect` sets a computer up for Parallax Connect over
3
+ // Parallax Relay with one sign-in (0091), `plx doctor` checks Parallax on this computer, and
4
+ // `devices`, `add`, and `setup` keep the Tailscale path (0056) for the release that migrates it.
5
+
6
+ import { createInterface } from "node:readline/promises";
7
+ import { parseArgs } from "node:util";
8
+
9
+ import * as connect from "./connect.mjs";
10
+ import { doctor } from "./doctor.mjs";
11
+ import { readDetails } from "./install.mjs";
12
+ import { attach, VERSION } from "./plxd.mjs";
13
+ import { canOpenBrowser, clerkOf, codePage, openBrowser, signIn } from "./signin.mjs";
14
+ import { add, setup } from "./tailscale-setup.mjs";
15
+ import { listDevices, TAGGED } from "./tailscale.mjs";
16
+
17
+ const HELP = `plx: Parallax's command line.
18
+
19
+ Usage:
20
+ plx connect Sign in, install plxd here, and connect this computer to
21
+ your Parallax account
22
+ plx connect add <ssh destination> The same on another computer over SSH, signing in here
23
+ plx connect approve Approve a device waiting to sign in, or show a new code
24
+ for one to type
25
+ plx connect status Show the account, host id, relay state, and routes
26
+ plx connect unlink Turn Parallax Relay off here, keeping the registration
27
+ plx connect remove Sign in again and remove this computer from your account
28
+ plx doctor Check Parallax on this computer, fix what it can, and hand
29
+ the rest to claude or codex to look into and file an issue
30
+
31
+ Tailscale (until the release after this one):
32
+ plx devices [--json] List your devices on your tailnet
33
+ plx add <device> Install Parallax on a device over SSH and connect it
34
+ plx setup Install Parallax on this computer and connect it
35
+
36
+ Options:
37
+ --channel stable|nightly Which release to install (default: stable)
38
+ --no-browser Sign in on any device and paste the code here, rather than in a
39
+ browser on this computer. Automatic over SSH and without a display
40
+ --user <user> The SSH user for add (default: ssh's own)
41
+ --port <port> The SSH port for add
42
+ --headless Install plxd alone, without the app, for the Tailscale add and setup
43
+ (Linux always gets plxd alone; connect always installs plxd alone)
44
+ --dry-run Print the Tailscale steps and install script without installing
45
+ --json Print devices as JSON
46
+ --agent claude|codex The agent doctor hands off to, without asking
47
+ --model <model> The model doctor's agent uses (default: the agent's own)
48
+ -h, --help Show this help
49
+ -v, --version Show plx's version`;
50
+
51
+ const CONNECT_COMMANDS = new Set(["add", "approve", "status", "unlink", "remove"]);
52
+
53
+ /** Asks for a line on the terminal. @param {string} question */
54
+ async function prompt(question) {
55
+ const lines = createInterface({ input: process.stdin, output: process.stdout });
56
+ try {
57
+ return await lines.question(question);
58
+ } finally {
59
+ lines.close();
60
+ }
61
+ }
62
+
63
+ /**
64
+ * What `plx connect` reaches: Clerk through the browser or a pasted code, plxd over `plxd
65
+ * attach`, and the terminal.
66
+ * @param {boolean} noBrowser
67
+ * @returns {import("./connect.mjs").Deps}
68
+ */
69
+ const realDeps = (noBrowser) => ({
70
+ signIn: () =>
71
+ signIn({
72
+ clerk: clerkOf(process.env),
73
+ browser: canOpenBrowser(process.env, process.platform, noBrowser),
74
+ codePage: codePage(process.env),
75
+ open: openBrowser,
76
+ prompt,
77
+ }),
78
+ details: readDetails,
79
+ install: connect.install,
80
+ plxd: (runner, os) => attach(runner.attach(os)),
81
+ prompt,
82
+ routes: () => connect.localRoutes(),
83
+ // Unref'd: a wait for a setup code to lapse never keeps plx running after it's done.
84
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms).unref()),
85
+ });
86
+
87
+ /**
88
+ * Runs the command line and resolves to the exit code.
89
+ * @param {string[]} argv
90
+ * @param {(noBrowser: boolean) => import("./connect.mjs").Deps} [deps] What `connect` reaches;
91
+ * tests pass fakes.
92
+ */
93
+ export async function main(argv, deps = realDeps) {
94
+ /** @type {ReturnType<typeof parse>} */
95
+ let args;
96
+ try {
97
+ args = parse(argv);
98
+ } catch (error) {
99
+ return usage(error instanceof Error ? error.message : String(error));
100
+ }
101
+ const { values, positionals } = args;
102
+ if (values.help) return print(HELP);
103
+ if (values.version) return print(VERSION);
104
+ const channel = values.channel ?? "stable";
105
+ if (channel !== "stable" && channel !== "nightly") return usage(`--channel is stable or nightly, not ${channel}.`);
106
+ if (values.port !== undefined && !/^\d+$/.test(values.port)) return usage(`--port is a number, not ${values.port}.`);
107
+ /** @type {import("./tailscale-setup.mjs").Options} */
108
+ const options = { channel, user: values.user, port: values.port, headless: values.headless, dryRun: values["dry-run"] };
109
+
110
+ const [command, ...rest] = positionals;
111
+ switch (command) {
112
+ case undefined:
113
+ return print(HELP);
114
+ case "connect": {
115
+ const [sub, ...more] = rest;
116
+ if (sub !== undefined && !CONNECT_COMMANDS.has(sub)) return usage(`Unknown connect command: ${sub}`);
117
+ if (sub !== "add" && more.length > 0) return usage(`connect${sub ? ` ${sub}` : ""} takes no arguments.`);
118
+ const [destination] = more;
119
+ if (sub === "add" && (more.length !== 1 || !destination)) return usage("connect add takes one ssh destination.");
120
+ // ssh would read a destination starting with - as an option.
121
+ if (sub === "add" && !/^[^\s-]\S*$/.test(destination ?? "")) return usage(`${destination} isn't an ssh destination.`);
122
+ const connectDeps = deps(values["no-browser"] === true);
123
+ switch (sub) {
124
+ case undefined:
125
+ return connect.connect(options, connectDeps);
126
+ case "add":
127
+ return connect.add(destination ?? "", options, connectDeps);
128
+ case "approve":
129
+ return connect.approve(connectDeps);
130
+ case "status":
131
+ return connect.status(connectDeps);
132
+ case "unlink":
133
+ return connect.unlink(connectDeps);
134
+ default:
135
+ return connect.remove(connectDeps);
136
+ }
137
+ }
138
+ case "devices":
139
+ return devices(values.json === true);
140
+ case "add":
141
+ if (rest.length !== 1 || !rest[0]) return usage("add takes one device.");
142
+ return add(rest[0], options);
143
+ case "setup":
144
+ return setup(options);
145
+ case "doctor":
146
+ if (values.agent !== undefined && values.agent !== "claude" && values.agent !== "codex") return usage(`--agent is claude or codex, not ${values.agent}.`);
147
+ return doctor({ agent: values.agent, model: values.model });
148
+ default:
149
+ return usage(`Unknown command: ${command}`);
150
+ }
151
+ }
152
+
153
+ /** @param {string[]} argv */
154
+ function parse(argv) {
155
+ return parseArgs({
156
+ args: argv,
157
+ allowPositionals: true,
158
+ options: {
159
+ channel: { type: "string" },
160
+ "no-browser": { type: "boolean" },
161
+ user: { type: "string" },
162
+ port: { type: "string" },
163
+ headless: { type: "boolean" },
164
+ "dry-run": { type: "boolean" },
165
+ json: { type: "boolean" },
166
+ agent: { type: "string" },
167
+ model: { type: "string" },
168
+ help: { type: "boolean", short: "h" },
169
+ version: { type: "boolean", short: "v" },
170
+ },
171
+ });
172
+ }
173
+
174
+ /** @param {string} text */
175
+ function print(text) {
176
+ console.log(text);
177
+ return 0;
178
+ }
179
+
180
+ /** @param {string} message */
181
+ function usage(message) {
182
+ console.error(`${message}\n\n${HELP}`);
183
+ return 2;
184
+ }
185
+
186
+ /** `plx devices`: a table, or `{ self, devices }` as JSON. @param {boolean} json */
187
+ async function devices(json) {
188
+ const tailnet = await listDevices();
189
+ if (json) return print(JSON.stringify(tailnet, null, 2));
190
+ const rows = [
191
+ ["NAME", "DNS NAME", "OS", "IP", "ONLINE", "PARALLAX"],
192
+ ...[{ ...tailnet.self, name: `${tailnet.self.name} (this computer)` }, ...tailnet.devices].map((d) => [
193
+ d.name,
194
+ d.dnsName,
195
+ d.os,
196
+ d.ip ?? "-",
197
+ d.online ? "yes" : "no",
198
+ d.parallax ? "yes" : "no",
199
+ ]),
200
+ ];
201
+ const widths = rows[0]?.map((_, column) => Math.max(...rows.map((row) => row[column]?.length ?? 0))) ?? [];
202
+ for (const row of rows) console.log(row.map((cell, column) => cell.padEnd(widths[column] ?? 0)).join(" ").trimEnd());
203
+ if (tailnet.self.tagged) console.log(`\n${TAGGED}`);
204
+ return 0;
205
+ }
@@ -0,0 +1,325 @@
1
+ // @ts-check
2
+ // `plx connect` and its subcommands (0091): set up a computer for Parallax Connect over Parallax
3
+ // Relay with one sign-in, and manage it after. Everything plxd does here goes over `plxd attach`,
4
+ // its trusted local connection, or over ssh for `connect add`.
5
+
6
+ import https from "node:https";
7
+
8
+ import { findInstaller, localOs, sshTarget, step, StepFailed } from "./install.mjs";
9
+ import { localRunner, sshRunner } from "./run.mjs";
10
+ import { installScript, REMOTE_PORT } from "./scripts.mjs";
11
+
12
+ /** @typedef {import("./plxd.mjs").Plxd} Plxd */
13
+ /** @typedef {import("./releases.mjs").Channel} Channel */
14
+ /** @typedef {import("./run.mjs").Runner} Runner */
15
+ /** @typedef {import("./releases.mjs").Os} Os */
16
+ /** @typedef {import("./detect.mjs").Target} Target */
17
+
18
+ /**
19
+ * What the commands reach outside this process, so tests can fake each.
20
+ * @typedef {object} Deps
21
+ * @property {() => Promise<string>} signIn Signs in and resolves to a Clerk access token.
22
+ * @property {(runner: Runner, os: Os) => Promise<Target>} details Finds the OS details of the
23
+ * computer `runner` reaches.
24
+ * @property {(runner: Runner, target: Target, name: string, channel: Channel) => Promise<void>} install
25
+ * Installs plxd with its login service on `target` through `runner`.
26
+ * @property {(runner: Runner, os: Os) => Promise<Plxd>} plxd Opens `plxd attach` through
27
+ * `runner` on an `os` computer.
28
+ * @property {(question: string) => Promise<string>} prompt Asks the user for a line.
29
+ * @property {() => Promise<string[]>} routes This computer's routes, from its listener.
30
+ * @property {(ms: number) => Promise<void>} sleep
31
+ */
32
+
33
+ /**
34
+ * @typedef {object} Options
35
+ * @property {Channel} channel
36
+ * @property {string} [user] The SSH user for `add`.
37
+ * @property {string} [port] The SSH port for `add`.
38
+ */
39
+
40
+ /** How long setup waits for plxd to reach the relay and listen. */
41
+ const ONLINE_TIMEOUT_MS = 60_000;
42
+
43
+ /**
44
+ * Runs `body` with a step failure's ✗ already printed as exit 1, and any other error printed.
45
+ * @param {() => Promise<number | void>} body
46
+ * @returns {Promise<number>}
47
+ */
48
+ async function exitCode(body) {
49
+ try {
50
+ return (await body()) ?? 0;
51
+ } catch (error) {
52
+ if (!(error instanceof StepFailed)) console.log(`✗ ${error instanceof Error ? error.message : String(error)}`);
53
+ return 1;
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Runs `body` with plxd open through `runner` on an `os` computer, this one by default, closing
59
+ * it after.
60
+ * @template T
61
+ * @param {Deps} deps
62
+ * @param {(plxd: Plxd) => Promise<T>} body
63
+ * @param {Runner} [runner]
64
+ * @param {Os} [os]
65
+ */
66
+ async function withPlxd(deps, body, runner = localRunner(), os = localOs() ?? "linux") {
67
+ const plxd = await deps.plxd(runner, os);
68
+ try {
69
+ return await body(plxd);
70
+ } finally {
71
+ plxd.close();
72
+ }
73
+ }
74
+
75
+ /**
76
+ * `plx connect`: signs in, installs plxd with its login service here, and links it. Resolves to
77
+ * the exit code.
78
+ * @param {Options} options
79
+ * @param {Deps} deps
80
+ */
81
+ export function connect(options, deps) {
82
+ const runner = localRunner();
83
+ return exitCode(async () => {
84
+ const os = localOs();
85
+ if (!os) throw new Error(`Parallax doesn't run on ${process.platform}.`);
86
+ const token = await step("Signing in to Parallax", async () => [`Signed in to Parallax Connect`, await deps.signIn()]);
87
+ const target = await step("Finding this computer's OS", async () => {
88
+ const target = await deps.details(runner, os);
89
+ return [`${target.label}: ${target.version} (${target.arch})`, target];
90
+ });
91
+ await deps.install(runner, target, "this computer", options.channel);
92
+ await withPlxd(
93
+ deps,
94
+ async (plxd) => {
95
+ await link(plxd, token, deps);
96
+ await approveNewDevice(plxd, deps);
97
+ },
98
+ runner,
99
+ os,
100
+ );
101
+ });
102
+ }
103
+
104
+ /**
105
+ * `plx connect add <ssh destination>`: the same setup on another computer over ssh, with the
106
+ * sign-in shown here. The only path that uses ssh, and only once.
107
+ * @param {string} destination `[user@]host`, as ssh takes it.
108
+ * @param {Options} options
109
+ * @param {Deps} deps
110
+ */
111
+ export function add(destination, options, deps) {
112
+ const target = options.user ? `${options.user}@${destination}` : destination;
113
+ const runner = sshRunner(target, { port: options.port });
114
+ return exitCode(async () => {
115
+ const token = await step("Signing in to Parallax", async () => [`Signed in to Parallax Connect`, await deps.signIn()]);
116
+ const details = await sshTarget(runner, destination, target, "npx plx-cli connect");
117
+ await deps.install(runner, details, destination, options.channel);
118
+ await withPlxd(
119
+ deps,
120
+ async (plxd) => {
121
+ await link(plxd, token, deps);
122
+ await approveNewDevice(plxd, deps);
123
+ },
124
+ runner,
125
+ details.os,
126
+ );
127
+ }).finally(() => runner.close());
128
+ }
129
+
130
+ /**
131
+ * Installs plxd alone, with its login service, through `runner` (0072).
132
+ * @param {Runner} runner
133
+ * @param {Target} target
134
+ * @param {string} name
135
+ * @param {Channel} channel
136
+ */
137
+ export async function install(runner, target, name, channel) {
138
+ const installer = await findInstaller(target, channel, true);
139
+ await step(`Installing plxd on ${name}`, async () => {
140
+ const script = installScript(target.os, installer, channel, true, false);
141
+ const { code } = await runner.script(target.os === "windows" ? "powershell" : "sh", script, false);
142
+ if (code !== 0) throw new Error(`The install failed on ${name} (exit ${code}).`);
143
+ return [`Installed plxd ${installer.version} with its login service`, undefined];
144
+ });
145
+ }
146
+
147
+ /**
148
+ * Registers the host with the relay under the token's account, turns on `relay` and `remote`,
149
+ * and waits until plxd is connected to the relay and listening. Prints its name as the relay
150
+ * lists it.
151
+ * @param {Plxd} plxd
152
+ * @param {string} token
153
+ * @param {Deps} deps
154
+ */
155
+ async function link(plxd, token, deps) {
156
+ await step("Registering this computer with Parallax Relay", async () => {
157
+ await plxd.call("relay/register", { token });
158
+ await plxd.call("host/settings/set", { relay: true, remote: true });
159
+ return ["Registered, with Reach from anywhere on", undefined];
160
+ });
161
+ await step("Waiting for it to come online", async () => {
162
+ for (let waited = 0; ; waited += 1000) {
163
+ const status = await plxd.call("remote/sessions");
164
+ if (status.relay?.connected && status.listening) break;
165
+ if (waited >= ONLINE_TIMEOUT_MS) {
166
+ const why = status.relay?.problem ?? status.problem ?? "it didn't connect in time";
167
+ throw new Error(`It isn't online: ${why}`);
168
+ }
169
+ await deps.sleep(1000);
170
+ }
171
+ const listed = await plxd.call("relay/hosts", { token }).catch(() => undefined);
172
+ const self = listed?.hosts?.find((/** @type {{ thisComputer: boolean }} */ host) => host.thisComputer);
173
+ return [`${self?.name ?? "This computer"} is online on Parallax Connect`, undefined];
174
+ });
175
+ }
176
+
177
+ /**
178
+ * Shows a setup code for a signed-in device to type, a new one each time one lapses, and waits
179
+ * until a device pairs with it. Prints the device's name.
180
+ * @param {Plxd} plxd
181
+ * @param {Deps} deps
182
+ */
183
+ async function approveNewDevice(plxd, deps) {
184
+ console.log("\nOn a device signed in to Parallax, open this computer and type its code there.");
185
+ for (;;) {
186
+ const { code, expiresAt } = await plxd.call("access/setupCode");
187
+ console.log(`\n ${code}\n\nWaiting for a device (Ctrl-C to stop; plx connect approve shows a new code later).`);
188
+ let state = await plxd.call("access/get");
189
+ const lapses = deps.sleep(Math.max(0, Date.parse(expiresAt) - Date.now())).then(() => undefined);
190
+ while (!state.setupApproved) {
191
+ const changed = await Promise.race([plxd.call("access/get", { after: state.version }), lapses]);
192
+ if (!changed) break;
193
+ state = changed;
194
+ }
195
+ if (state.setupApproved) {
196
+ console.log(`✓ Approved ${state.setupApproved}. It can reach this computer now, and so can your other devices.`);
197
+ return;
198
+ }
199
+ console.log("The code lapsed. Here's a new one.");
200
+ }
201
+ }
202
+
203
+ /**
204
+ * `plx connect approve`: approves a new device's waiting request after its code shows, with
205
+ * Enter, or denies it with `n`. With none waiting, shows a code to type on a device, as setup
206
+ * ends.
207
+ * @param {Deps} deps
208
+ */
209
+ export function approve(deps) {
210
+ return exitCode(() =>
211
+ withPlxd(deps, async (plxd) => {
212
+ let state = await plxd.call("access/get");
213
+ if (!state.pending) return approveNewDevice(plxd, deps);
214
+ if (!state.pending.code) console.log(`${state.pending.name} asked to sign in. Waiting for it to show its code…`);
215
+ while (state.pending && !state.pending.code) state = await plxd.call("access/get", { after: state.version });
216
+ const pending = state.pending;
217
+ if (!pending) throw new Error("The request went away before it showed a code.");
218
+ console.log(`${pending.name} asks to sign in to this computer, with the code ${pending.code}.`);
219
+ const answer = (await deps.prompt("Approve it if that device shows the same code. Enter approves, n denies: ")).trim().toLowerCase();
220
+ if (answer === "n" || answer === "no") {
221
+ await plxd.call("access/deny", { request: pending.id });
222
+ console.log(`✓ Denied ${pending.name}.`);
223
+ } else {
224
+ await plxd.call("access/approve", { request: pending.id });
225
+ console.log(`✓ Approved ${pending.name}.`);
226
+ }
227
+ }),
228
+ );
229
+ }
230
+
231
+ /**
232
+ * `plx connect unlink`: turns `relay` off and keeps the registration, so the host id and webhook
233
+ * URLs stay. New account sign-ins stop; sessions already issued stay until revoked in Settings.
234
+ * @param {Deps} deps
235
+ */
236
+ export function unlink(deps) {
237
+ return exitCode(() =>
238
+ withPlxd(deps, async (plxd) => {
239
+ await plxd.call("host/settings/set", { relay: false });
240
+ console.log("✓ This computer is off Parallax Relay. It stays registered, so plx connect brings it back with the same id.");
241
+ console.log(" Devices already signed in keep their sessions until you remove them in Settings.");
242
+ }),
243
+ );
244
+ }
245
+
246
+ /**
247
+ * `plx connect remove`: signs in again, since nothing kept the token, removes this host from the
248
+ * account on the relay, and turns `relay` off so plxd stops reconnecting.
249
+ * @param {Deps} deps
250
+ */
251
+ export function remove(deps) {
252
+ return exitCode(() =>
253
+ withPlxd(deps, async (plxd) => {
254
+ const token = await step("Signing in to Parallax", async () => [`Signed in`, await deps.signIn()]);
255
+ await step("Removing this computer from your account", async () => {
256
+ const { hostId } = await plxd.call("relay/id");
257
+ await plxd.call("relay/remove", { token, hostId });
258
+ await plxd.call("host/settings/set", { relay: false });
259
+ return [`Removed ${hostId} from Parallax Relay`, undefined];
260
+ });
261
+ }),
262
+ );
263
+ }
264
+
265
+ /**
266
+ * `plx connect status`: the account, host id, relay state, and routes.
267
+ * @param {Deps} deps
268
+ */
269
+ export function status(deps) {
270
+ return exitCode(() =>
271
+ withPlxd(deps, async (plxd) => {
272
+ const [{ hostId }, access, settings, sessions] = await Promise.all([
273
+ plxd.call("relay/id"),
274
+ plxd.call("access/get"),
275
+ plxd.call("host/settings/get"),
276
+ plxd.call("remote/sessions"),
277
+ ]);
278
+ const relay = !settings.relay
279
+ ? "off"
280
+ : sessions.relay?.connected
281
+ ? "on, connected"
282
+ : `on, not connected${sessions.relay?.problem ? `: ${sessions.relay.problem}` : ""}`;
283
+ const remote = !settings.remote ? "off" : sessions.listening ? "on, listening" : `on, not listening${sessions.problem ? `: ${sessions.problem}` : ""}`;
284
+ const routes = sessions.listening ? await deps.routes().catch(() => []) : [];
285
+ const rows = [
286
+ ["Account", access.account ?? "not signed in (run plx connect)"],
287
+ ["Host id", hostId],
288
+ ["Relay", relay],
289
+ ["Reach from anywhere", remote],
290
+ ["Routes", routes.length ? routes.join(", ") : "none"],
291
+ ["Approved devices", String(access.approvedClients.length)],
292
+ ];
293
+ const width = Math.max(...rows.map(([label]) => label?.length ?? 0));
294
+ for (const [label, value] of rows) console.log(`${(label ?? "").padEnd(width)} ${value}`);
295
+ if (access.pending) console.log(`\n${access.pending.name} is waiting for approval. Run plx connect approve.`);
296
+ }),
297
+ );
298
+ }
299
+
300
+ /**
301
+ * This computer's routes, best first, as its remote listener's public descriptor reports them
302
+ * (0065). The listener's certificate is self-signed and this only reads the routes, so it isn't
303
+ * checked.
304
+ * @param {number} [port]
305
+ * @returns {Promise<string[]>}
306
+ */
307
+ export function localRoutes(port = REMOTE_PORT) {
308
+ return new Promise((resolve, reject) => {
309
+ const req = https.get({ host: "127.0.0.1", port, path: "/.well-known/parallax", rejectUnauthorized: false, timeout: 5000 }, (res) => {
310
+ let body = "";
311
+ res.setEncoding("utf8");
312
+ res.on("data", (chunk) => (body += chunk));
313
+ res.on("end", () => {
314
+ try {
315
+ const routes = JSON.parse(body).routes;
316
+ resolve(Array.isArray(routes) ? routes.filter((route) => typeof route === "string") : []);
317
+ } catch (error) {
318
+ reject(error);
319
+ }
320
+ });
321
+ });
322
+ req.once("timeout", () => req.destroy(new Error("The listener didn't answer.")));
323
+ req.once("error", reject);
324
+ });
325
+ }