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,304 @@
1
+ // @ts-check
2
+ // The install script for each OS (0056). Each installs the app, or plxd alone (`--headless`, and
3
+ // always on Linux), checking every download against the release's SHA-256. Every one puts plxd at
4
+ // ~/.parallax-plxd/plxd (plxd.exe on Windows) with plxd.version, as host/update and the app's SSH
5
+ // install do (0072), turns Tailscale Connect on for the Tailscale commands, installs plxd's login
6
+ // service from there, and installs plx-cli when npm is there. macOS and Linux run theirs with `sh -s`, Windows with
7
+ // PowerShell. They print plain ASCII lines, since Windows PowerShell's output encoding varies, and
8
+ // fail with a non-zero exit.
9
+
10
+ import { PLXD_PORT } from "./tailscale.mjs";
11
+ import { appName } from "./releases.mjs";
12
+
13
+ /** The port of plxd's remote listener (0065), which an `account:` host's LAN route reaches. */
14
+ export const REMOTE_PORT = 7341;
15
+
16
+ /** @typedef {import("./releases.mjs").Channel} Channel */
17
+ /** @typedef {import("./releases.mjs").Installer} Installer */
18
+ /** @typedef {import("./releases.mjs").Os} Os */
19
+
20
+ /** `value` as one word for a POSIX shell. @param {string} value */
21
+ export const shQuote = (value) => `'${value.replaceAll("'", `'\\''`)}'`;
22
+
23
+ /** `value` as a PowerShell string literal. @param {string} value */
24
+ export const psQuote = (value) => `'${value.replaceAll("'", "''")}'`;
25
+
26
+ /**
27
+ * The install script for `os`: the app and plxd, or with `headless`, plxd alone. Linux always
28
+ * gets plxd alone. With `tailnet`, for the Tailscale commands (0056), it turns `connect` on;
29
+ * `plx connect` turns on `relay` and `remote` itself, over plxd's protocol (0091).
30
+ * @param {Os} os
31
+ * @param {Installer} installer
32
+ * @param {Channel} channel
33
+ * @param {boolean} [headless]
34
+ * @param {boolean} [tailnet]
35
+ */
36
+ export function installScript(os, installer, channel, headless = false, tailnet = true) {
37
+ if (os === "macos") return macScript(installer, channel, headless, tailnet);
38
+ if (os === "linux") return linuxScript(installer, tailnet);
39
+ return windowsScript(installer, channel, headless, tailnet);
40
+ }
41
+
42
+ /**
43
+ * What every POSIX script starts with: the asset, its SHA-256, and its version; plxd's place,
44
+ * `$plxd` in `$dir`; and `download`, which saves the asset to `$1` and checks it, removing it and
45
+ * stopping the script when its SHA-256 isn't the release's.
46
+ * @param {Installer} installer
47
+ */
48
+ const posixHead = (installer) => `set -eu
49
+ url=${shQuote(installer.url)}
50
+ sha256=${shQuote(installer.sha256)}
51
+ version=${shQuote(installer.version)}
52
+ dir="$HOME/.parallax-plxd"
53
+ plxd="$dir/plxd"
54
+ mkdir -p "$dir"
55
+ download() {
56
+ echo "Downloading $url"
57
+ if command -v curl >/dev/null 2>&1; then
58
+ curl -fsSL --retry 3 -o "$1" "$url"
59
+ else
60
+ wget -q -O "$1" "$url"
61
+ fi
62
+ if command -v sha256sum >/dev/null 2>&1; then
63
+ actual=$(sha256sum "$1" | cut -d' ' -f1)
64
+ else
65
+ actual=$(shasum -a 256 "$1" | cut -d' ' -f1)
66
+ fi
67
+ if [ "$actual" != "$sha256" ]; then
68
+ rm -f "$1"
69
+ echo "error: the download's SHA-256 is $actual, not $sha256 as the release says, so it wasn't installed." >&2
70
+ exit 1
71
+ fi
72
+ echo "Checked its SHA-256"
73
+ }`;
74
+
75
+ /**
76
+ * Puts `$dir/.plxd.new` in place as `$plxd`, with its version in `plxd.version` beside it, where
77
+ * plxd reads it (0030); without it, plxd reports its build's placeholder, 0.1.0. A new file, not
78
+ * the old one overwritten, so a running plxd keeps its own.
79
+ */
80
+ const posixPlace = `chmod 755 "$dir/.plxd.new"
81
+ printf '%s\\n' "$version" > "$dir/plxd.version"
82
+ mv -f "$dir/.plxd.new" "$plxd"
83
+ echo "Installed plxd $version in $dir"`;
84
+
85
+ /**
86
+ * macOS: mounts the dmg and copies the app into /Applications, or ~/Applications when that isn't
87
+ * writable, then copies its plxd, from its Resources folder, to `~/.parallax-plxd`. An app of the
88
+ * same name already in either is replaced only when its version differs. With `headless`, it
89
+ * downloads plxd alone, the signed copy from the app (package-app).
90
+ * @param {Installer} installer
91
+ * @param {Channel} channel
92
+ * @param {boolean} [headless]
93
+ * @param {boolean} [tailnet]
94
+ */
95
+ export function macScript(installer, channel, headless = false, tailnet = true) {
96
+ if (headless) return `${posixHead(installer)}\ndownload "$dir/.plxd.new"\n${posixPlace}\n${posixTail(tailnet)}`;
97
+ return `${posixHead(installer)}
98
+ bundle=${shQuote(`${appName(channel)}.app`)}
99
+ tmp=$(mktemp -d /tmp/plx.XXXXXX)
100
+ trap 'hdiutil detach "$tmp/mnt" -quiet >/dev/null 2>&1 || true; rm -rf "$tmp"' EXIT
101
+ app=
102
+ for each in /Applications "$HOME/Applications"; do
103
+ if [ -d "$each/$bundle" ]; then app="$each/$bundle"; break; fi
104
+ done
105
+ current=
106
+ if [ -n "$app" ]; then
107
+ current=$(defaults read "$app/Contents/Info.plist" CFBundleShortVersionString 2>/dev/null || true)
108
+ fi
109
+ if [ -n "$app" ] && [ "$current" = "$version" ]; then
110
+ echo "$bundle $version is already in \${app%/*}"
111
+ else
112
+ download "$tmp/parallax.dmg"
113
+ mkdir "$tmp/mnt"
114
+ hdiutil attach -nobrowse -readonly -noautoopen -mountpoint "$tmp/mnt" "$tmp/parallax.dmg" >/dev/null </dev/null
115
+ if [ -z "$app" ]; then
116
+ if [ -w /Applications ]; then app="/Applications/$bundle"; else mkdir -p "$HOME/Applications"; app="$HOME/Applications/$bundle"; fi
117
+ fi
118
+ rm -rf "$app"
119
+ ditto "$tmp/mnt/$bundle" "$app"
120
+ hdiutil detach "$tmp/mnt" -quiet
121
+ echo "Installed $bundle $version in \${app%/*}"
122
+ fi
123
+ cp "$app/Contents/Resources/plxd" "$dir/.plxd.new"
124
+ ${posixPlace}
125
+ ${posixTail(tailnet)}`;
126
+ }
127
+
128
+ /**
129
+ * Linux: downloads the release's static plxd, so no AppImage runtime has to run (NixOS won't run
130
+ * one), and links `~/.local/bin/plxd` to it, where earlier installs put plxd, so `plxd` stays on
131
+ * PATH and their service runs this one until `service install --replace` moves it over. That plxd
132
+ * carries the bubblewrap and socat Claude Code's worker sandbox needs (PLX-895). On NixOS, says
133
+ * when nix-ld, which agent CLIs' generic Linux binaries need, is off. Then turns on lingering, so
134
+ * plxd's user service runs with nobody logged in.
135
+ * @param {Installer} installer
136
+ * @param {boolean} [tailnet]
137
+ */
138
+ export function linuxScript(installer, tailnet = true) {
139
+ return `${posixHead(installer)}
140
+ download "$dir/.plxd.new"
141
+ ${posixPlace}
142
+ mkdir -p "$HOME/.local/bin"
143
+ rm -f "$HOME/.local/bin/plxd.version"
144
+ ln -sf "$plxd" "$HOME/.local/bin/plxd"
145
+ echo "Linked ~/.local/bin/plxd to it"
146
+ if [ -e /etc/NIXOS ] && [ -z "\${NIX_LD:-}" ] && [ ! -e /run/current-system/sw/share/nix-ld/lib/ld.so ]; then
147
+ echo "warning: agent CLIs such as Claude Code ship generic Linux binaries, which NixOS runs only with nix-ld. Add programs.nix-ld.enable = true; to configuration.nix, then run sudo nixos-rebuild switch."
148
+ fi
149
+ if ! loginctl enable-linger "$(id -un)" >/dev/null 2>&1; then
150
+ echo "warning: loginctl enable-linger failed, so plxd runs only while you're logged in."
151
+ fi
152
+ ${posixTail(tailnet)}`;
153
+ }
154
+
155
+ /**
156
+ * What every POSIX script ends with, once `$plxd` is in place: with `tailnet`, Tailscale Connect
157
+ * on, then plxd's
158
+ * login service installed from `$plxd` and started, replacing any service or `serve` running
159
+ * another plxd. On a Mac nobody is logged in to, there's no GUI session for launchd to start the
160
+ * LaunchAgent in, but its file is written, so it starts at the next login. Any other failure is
161
+ * printed with a warning. Without the service running, plxd starts now with `attach`, and the
162
+ * install fails when that does too. Then plx-cli from npm when npm is there.
163
+ * @param {boolean} tailnet
164
+ */
165
+ const posixTail = (tailnet) => `${tailnet ? `echo "Turning on Parallax Connect"\n"$plxd" connect on\n` : ""}echo "Installing plxd's login service"
166
+ if ! out=$("$plxd" service install --replace </dev/null 2>&1); then
167
+ if [ "$(uname -s)" = Darwin ] && ! launchctl print "gui/$(id -u)" >/dev/null 2>&1 &&
168
+ "$plxd" service status </dev/null 2>/dev/null | grep -qx 'installed: true'; then
169
+ echo "Nobody is logged in to this Mac, so plxd's login service starts at the next login. Starting plxd now."
170
+ else
171
+ printf '%s\\n' "$out"
172
+ echo "warning: plxd's login service didn't install, so plxd won't start at login. Starting it now."
173
+ fi
174
+ "$plxd" attach </dev/null >/dev/null
175
+ fi
176
+ npm=$(command -v npm 2>/dev/null || true)
177
+ if [ -z "$npm" ]; then
178
+ case \${SHELL:-} in
179
+ */bash|*/zsh) npm=$("$SHELL" -lc 'command -v npm' </dev/null 2>/dev/null | tail -n 1 || true) ;;
180
+ esac
181
+ fi
182
+ case $npm in /*) ;; *) npm= ;; esac
183
+ if [ -z "$npm" ]; then
184
+ echo "npm isn't installed, so plx-cli wasn't installed here."
185
+ elif PATH="$(dirname "$npm"):$PATH" "$npm" install -g --prefix "$HOME/.local" plx-cli </dev/null >/dev/null; then
186
+ echo "Installed plx-cli in ~/.local"
187
+ else
188
+ echo "warning: npm couldn't install plx-cli here."
189
+ fi
190
+ `;
191
+
192
+ /**
193
+ * Windows: runs the NSIS installer silently (a per-user install under %LOCALAPPDATA%\Programs) and
194
+ * copies plxd.exe from the folder holding `<app name>.exe`, or with `headless`, downloads plxd.exe
195
+ * alone, to %USERPROFILE%\.parallax-plxd. A running plxd.exe can't be overwritten, but it can be
196
+ * renamed, so the old one moves aside to plxd.exe.old. Then it allows that plxd through Windows
197
+ * Firewall on TCP 7340, or without `tailnet` on the remote listener's 7341, warning when that needs an administrator, and installs plxd's logon task
198
+ * (0023) with `service install --replace`. A plxd from before PLX-576 has no `service`, so then
199
+ * plxd is started with `attach`, which runs `serve` outside the SSH session's job. It waits for
200
+ * `attach` alone with `WaitForExit`: `Start-Process -Wait` also waits for its descendants, so the
201
+ * `serve` it leaves running would hang it. Reading `Handle` first keeps `ExitCode`, which is
202
+ * otherwise lost when the process exits before PowerShell opens it. Windows PowerShell can turn a
203
+ * native command's stderr into errors, which `Stop` would throw, so `service install` runs under
204
+ * `Continue`.
205
+ * @param {Installer} installer
206
+ * @param {Channel} channel
207
+ * @param {boolean} [headless]
208
+ * @param {boolean} [tailnet]
209
+ */
210
+ export function windowsScript(installer, channel, headless = false, tailnet = true) {
211
+ const name = appName(channel);
212
+ const port = tailnet ? PLXD_PORT : REMOTE_PORT;
213
+ const get = headless
214
+ ? `Save-Checked "$plxd.new"`
215
+ : `$installer = Join-Path $env:TEMP ${psQuote(installer.name)}
216
+ Save-Checked $installer
217
+ "Running the installer"
218
+ $run = Start-Process -FilePath $installer -ArgumentList '/S' -Wait -PassThru
219
+ Remove-Item -Force $installer
220
+ if ($run.ExitCode -ne 0) { throw "The installer exited with $($run.ExitCode)." }
221
+ $app = Get-ChildItem -Directory (Join-Path $env:LOCALAPPDATA 'Programs') |
222
+ Where-Object { (Test-Path -LiteralPath (Join-Path $_.FullName "$name.exe")) -and (Test-Path -LiteralPath (Join-Path $_.FullName 'resources\\plxd.exe')) } |
223
+ Select-Object -First 1
224
+ if (-not $app) { throw "Couldn't find $name.exe and plxd.exe under $env:LOCALAPPDATA\\Programs." }
225
+ "Installed $name in $($app.FullName)"
226
+ Copy-Item -Force -LiteralPath (Join-Path $app.FullName 'resources\\plxd.exe') -Destination "$plxd.new"`;
227
+ const again = headless ? "" : " Opening Parallax starts it again.";
228
+ return `$ErrorActionPreference = 'Stop'
229
+ $ProgressPreference = 'SilentlyContinue'
230
+ [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
231
+ $url = ${psQuote(installer.url)}
232
+ $sha256 = ${psQuote(installer.sha256)}
233
+ $version = ${psQuote(installer.version)}
234
+ $name = ${psQuote(name)}
235
+ $dir = Join-Path $env:USERPROFILE '.parallax-plxd'
236
+ $plxd = Join-Path $dir 'plxd.exe'
237
+ New-Item -ItemType Directory -Force -Path $dir | Out-Null
238
+ function Save-Checked($file) {
239
+ "Downloading $url"
240
+ Invoke-WebRequest -Uri $url -OutFile $file -UseBasicParsing
241
+ $actual = (Get-FileHash -Algorithm SHA256 -LiteralPath $file).Hash.ToLower()
242
+ if ($actual -ne $sha256) {
243
+ Remove-Item -Force -LiteralPath $file
244
+ throw "The download's SHA-256 is $actual, not $sha256 as the release says, so it wasn't installed."
245
+ }
246
+ 'Checked its SHA-256'
247
+ }
248
+ ${get}
249
+ if (Test-Path -LiteralPath $plxd) {
250
+ Remove-Item -Force -LiteralPath "$plxd.old" -ErrorAction SilentlyContinue
251
+ Move-Item -Force -LiteralPath $plxd -Destination "$plxd.old"
252
+ }
253
+ Move-Item -LiteralPath "$plxd.new" -Destination $plxd
254
+ [IO.File]::WriteAllText((Join-Path $dir 'plxd.version'), "$version\`n")
255
+ "Installed plxd $version in $dir"
256
+ $rule = "$name plxd"
257
+ try {
258
+ Remove-NetFirewallRule -DisplayName $rule -ErrorAction SilentlyContinue
259
+ New-NetFirewallRule -DisplayName $rule -Direction Inbound -Action Allow -Protocol TCP -LocalPort ${port} -Program $plxd -ErrorAction Stop | Out-Null
260
+ "Allowed plxd through Windows Firewall on TCP ${port}"
261
+ } catch {
262
+ "warning: Windows Firewall refused the rule for plxd ($($_.Exception.Message)). Allow $plxd on TCP ${port} as an administrator."
263
+ }
264
+ ${tailnet ? `"Turning on Parallax Connect"\n& $plxd connect on\nif ($LASTEXITCODE -ne 0) { throw 'plxd connect on failed.' }\n` : ""}"Installing plxd's logon task"
265
+ $ErrorActionPreference = 'Continue'
266
+ & $plxd service install --replace *> $null
267
+ $service = $LASTEXITCODE
268
+ $ErrorActionPreference = 'Stop'
269
+ if ($service -ne 0) {
270
+ "Starting plxd"
271
+ $in = New-TemporaryFile
272
+ $out = New-TemporaryFile
273
+ $attach = Start-Process -FilePath $plxd -ArgumentList 'attach' -RedirectStandardInput $in.FullName -RedirectStandardOutput $out.FullName -NoNewWindow -PassThru
274
+ $null = $attach.Handle
275
+ $attach.WaitForExit()
276
+ Remove-Item -Force $in.FullName, $out.FullName
277
+ if ($attach.ExitCode -ne 0) { throw "plxd attach exited with $($attach.ExitCode)." }
278
+ "warning: Windows has no plxd login service yet, so plxd runs until you sign out.${again}"
279
+ }
280
+ $npm = Get-Command npm.cmd -ErrorAction SilentlyContinue
281
+ if (-not $npm) {
282
+ "npm isn't installed, so plx-cli wasn't installed here."
283
+ } else {
284
+ & $npm.Source install -g plx-cli | Out-Null
285
+ if ($LASTEXITCODE -eq 0) { 'Installed plx-cli' } else { "warning: npm couldn't install plx-cli here." }
286
+ }
287
+ `;
288
+ }
289
+
290
+ /**
291
+ * The command a shell runs for PowerShell to read a script from stdin and run it. The script
292
+ * goes on stdin, not the command line, which cmd.exe limits to 8191 characters; the reader is
293
+ * `-EncodedCommand`, so no shell's quoting can change it. It prints an error as one plain line,
294
+ * where PowerShell would print CLIXML to a redirected stderr, and exits 1.
295
+ */
296
+ export const POWERSHELL_ARGS = [
297
+ "-NoProfile",
298
+ "-NonInteractive",
299
+ "-EncodedCommand",
300
+ Buffer.from(
301
+ "$s = [Console]::In.ReadToEnd(); try { & ([scriptblock]::Create($s)) } catch { [Console]::Error.WriteLine('error: ' + $_.Exception.Message); exit 1 }",
302
+ "utf16le",
303
+ ).toString("base64"),
304
+ ];
package/src/signin.mjs ADDED
@@ -0,0 +1,210 @@
1
+ // @ts-check
2
+ // Signing in to the Parallax account (0067, 0091) with the desktop app's Clerk OAuth application:
3
+ // authorization code with PKCE. With a browser here, Clerk redirects to a loopback listener (RFC
4
+ // 8252). Without one, the user signs in on any device, the relay's /connect/code page shows the
5
+ // code, and the user pastes it here. Either way only this process holds the PKCE verifier, so the
6
+ // code is useless anywhere else. The access token is handed to plxd once and never saved.
7
+
8
+ import { spawn } from "node:child_process";
9
+ import { createHash, randomBytes } from "node:crypto";
10
+ import { createServer } from "node:http";
11
+
12
+ // The Clerk instance and OAuth application the desktop app signs in with (account.ts). Both are
13
+ // public. PLX_CLERK_PUBLISHABLE_KEY and PLX_CLERK_CLIENT_ID point at another instance, as there.
14
+ const PUBLISHABLE_KEY = "pk_test_YWRqdXN0ZWQtbWFzdG9kb24tODcwNS5jbGVyay5hY2NvdW50cy5kZXYk";
15
+ const CLIENT_ID = "SXDueyOLOJ2v4Vmq";
16
+ const SCOPES = "profile email";
17
+
18
+ /** Parallax Relay, whose /connect/code page is the pasted sign-in's redirect URI. */
19
+ const RELAY_URL = "https://relay.parallax.codes";
20
+
21
+ /**
22
+ * @typedef {object} Clerk
23
+ * @property {string} api The Frontend API, such as `https://clerk.parallax.codes`.
24
+ * @property {string} clientId The OAuth application's client id.
25
+ */
26
+
27
+ /** The Frontend API a publishable key names: `pk_test_` or `pk_live_`, then base64 of `<host>$`. @param {string} key */
28
+ export function frontendApi(key) {
29
+ const encoded = key.replace(/^pk_(test|live)_/, "");
30
+ return `https://${Buffer.from(encoded, "base64").toString().replace(/\$$/, "")}`;
31
+ }
32
+
33
+ /** The Clerk application to sign in with, from `env`. @param {NodeJS.ProcessEnv} env @returns {Clerk} */
34
+ export const clerkOf = (env) => ({
35
+ api: frontendApi(env["PLX_CLERK_PUBLISHABLE_KEY"] || PUBLISHABLE_KEY),
36
+ clientId: env["PLX_CLERK_CLIENT_ID"] || CLIENT_ID,
37
+ });
38
+
39
+ /** Where the pasted sign-in's redirect lands: the relay's page that shows the code. @param {NodeJS.ProcessEnv} env */
40
+ export const codePage = (env) => `${(env["PLX_RELAY_URL"] || RELAY_URL).replace(/\/+$/, "")}/connect/code`;
41
+
42
+ /**
43
+ * Whether sign-in can open a browser here: not with `--no-browser`, not over SSH, and on Linux
44
+ * not without a display.
45
+ * @param {NodeJS.ProcessEnv} env
46
+ * @param {NodeJS.Platform} platform
47
+ * @param {boolean} noBrowser
48
+ */
49
+ export function canOpenBrowser(env, platform, noBrowser) {
50
+ if (noBrowser || env["SSH_CONNECTION"]) return false;
51
+ if (platform === "darwin" || platform === "win32") return true;
52
+ return !!(env["DISPLAY"] || env["WAYLAND_DISPLAY"]);
53
+ }
54
+
55
+ /**
56
+ * Clerk's authorize URL for this sign-in.
57
+ * @param {Clerk} clerk
58
+ * @param {{ redirectUri: string, state: string, verifier: string }} request
59
+ */
60
+ export function authorizeUrl(clerk, { redirectUri, state, verifier }) {
61
+ const url = new URL(`${clerk.api}/oauth/authorize`);
62
+ url.search = new URLSearchParams({
63
+ response_type: "code",
64
+ client_id: clerk.clientId,
65
+ redirect_uri: redirectUri,
66
+ scope: SCOPES,
67
+ state,
68
+ code_challenge: createHash("sha256").update(verifier).digest("base64url"),
69
+ code_challenge_method: "S256",
70
+ }).toString();
71
+ return url.href;
72
+ }
73
+
74
+ /**
75
+ * Trades an authorization code for an access token at Clerk's token endpoint, as the public
76
+ * client, with the PKCE verifier.
77
+ * @param {Clerk} clerk
78
+ * @param {{ code: string, verifier: string, redirectUri: string }} grant
79
+ * @returns {Promise<string>}
80
+ */
81
+ export async function redeem(clerk, { code, verifier, redirectUri }) {
82
+ const res = await fetch(`${clerk.api}/oauth/token`, {
83
+ method: "POST",
84
+ body: new URLSearchParams({
85
+ client_id: clerk.clientId,
86
+ grant_type: "authorization_code",
87
+ code,
88
+ code_verifier: verifier,
89
+ redirect_uri: redirectUri,
90
+ }),
91
+ signal: AbortSignal.timeout(15_000),
92
+ });
93
+ const body = /** @type {Record<string, unknown>} */ (await res.json().catch(() => ({})));
94
+ if (res.ok && typeof body["access_token"] === "string") return body["access_token"];
95
+ const why = [body["error_description"], body["error"]].find((v) => typeof v === "string");
96
+ throw new Error(`Clerk didn't accept the code: ${why ?? `it answered ${res.status}`}.`);
97
+ }
98
+
99
+ /**
100
+ * @typedef {object} SignInOptions
101
+ * @property {Clerk} clerk
102
+ * @property {boolean} browser Whether to open a browser here, from `canOpenBrowser`.
103
+ * @property {string} codePage The pasted sign-in's redirect URI.
104
+ * @property {(url: string) => Promise<boolean>} open Opens `url` in a browser here.
105
+ * @property {(question: string) => Promise<string>} prompt Asks the user for a line.
106
+ * @property {number} [timeoutMs] How long the loopback sign-in waits.
107
+ */
108
+
109
+ /**
110
+ * Signs in and resolves to the access token, or rejects with why not, for people.
111
+ * @param {SignInOptions} options
112
+ * @returns {Promise<string>}
113
+ */
114
+ export function signIn(options) {
115
+ return options.browser ? signInHere(options) : signInElsewhere(options);
116
+ }
117
+
118
+ /**
119
+ * The paste flow: prints the link, reads the code the relay's page shows, and redeems it. A code
120
+ * Clerk refuses can be pasted again, up to 3 times, since a mistyped one doesn't use up the real
121
+ * one.
122
+ * @param {SignInOptions} options
123
+ */
124
+ async function signInElsewhere({ clerk, codePage, prompt }) {
125
+ const verifier = randomBytes(32).toString("base64url");
126
+ const url = authorizeUrl(clerk, { redirectUri: codePage, state: randomBytes(16).toString("base64url"), verifier });
127
+ console.log(`Open this link in a browser on any device, sign in, and allow this computer:\n\n ${url}\n`);
128
+ for (let tries = 1; ; tries++) {
129
+ const code = (await prompt("Paste the code it shows: ")).trim();
130
+ if (!code) throw new Error("No code was pasted.");
131
+ try {
132
+ return await redeem(clerk, { code, verifier, redirectUri: codePage });
133
+ } catch (error) {
134
+ if (tries === 3) throw error;
135
+ console.log(`✗ ${error instanceof Error ? error.message : String(error)} Try again.`);
136
+ }
137
+ }
138
+ }
139
+
140
+ /**
141
+ * The loopback flow: waits on 127.0.0.1, on a port the OS picks, for Clerk to send the browser
142
+ * back to `/callback` with `state`, then redeems its code here. Any other request, such as a page
143
+ * guessing the port, gets a 403 and the wait goes on.
144
+ * @param {SignInOptions} options
145
+ * @returns {Promise<string>}
146
+ */
147
+ async function signInHere({ clerk, open, timeoutMs = 10 * 60_000 }) {
148
+ const verifier = randomBytes(32).toString("base64url");
149
+ const state = randomBytes(16).toString("base64url");
150
+ /** @type {(result: { token?: string, error?: string }) => void} */
151
+ let settle = () => {};
152
+ const done = new Promise((resolve) => (settle = resolve));
153
+ let answered = false;
154
+ const server = createServer((req, res) => {
155
+ // Only this address: a DNS-rebound name pointing here gets nothing.
156
+ if (req.headers.host !== host) return void res.writeHead(403).end();
157
+ const url = new URL(req.url ?? "/", `http://${host}`);
158
+ if (req.method !== "GET" || url.pathname !== "/callback") return void res.writeHead(404).end();
159
+ if (answered || url.searchParams.get("state") !== state) return void res.writeHead(403).end("Start signing in from plx connect.");
160
+ answered = true;
161
+ const code = url.searchParams.get("code");
162
+ /** @type {Promise<{ token?: string, error?: string }>} */
163
+ const redeemed = code
164
+ ? redeem(clerk, { code, verifier, redirectUri }).then((token) => ({ token }), (error) => ({ error: String(error.message) }))
165
+ : Promise.resolve({ error: url.searchParams.get("error_description") ?? url.searchParams.get("error") ?? "Clerk sent no code." });
166
+ void redeemed.then((result) => {
167
+ const text = result.error ? `Couldn't sign in: ${result.error}` : "Signed in to Parallax. You can close this tab and go back to the terminal.";
168
+ res.writeHead(200, { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store", connection: "close" }).end(text);
169
+ settle(result);
170
+ });
171
+ });
172
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve(undefined)));
173
+ const address = /** @type {import("node:net").AddressInfo} */ (server.address());
174
+ const host = `127.0.0.1:${address.port}`;
175
+ const redirectUri = `http://${host}/callback`;
176
+ const timer = setTimeout(() => settle({ error: "Sign-in timed out. Run it again." }), timeoutMs);
177
+ try {
178
+ const url = authorizeUrl(clerk, { redirectUri, state, verifier });
179
+ const opened = await open(url);
180
+ console.log(`${opened ? "Opened your browser to sign in. If it didn't open, go" : "Go"} to:\n\n ${url}\n`);
181
+ const result = /** @type {{ token?: string, error?: string }} */ (await done);
182
+ if (!result.token) throw new Error(result.error);
183
+ return result.token;
184
+ } finally {
185
+ clearTimeout(timer);
186
+ server.close();
187
+ }
188
+ }
189
+
190
+ /**
191
+ * Opens `url` in the default browser and resolves to whether the opener started.
192
+ * @param {string} url
193
+ * @returns {Promise<boolean>}
194
+ */
195
+ export function openBrowser(url) {
196
+ const [file, args] =
197
+ process.platform === "darwin"
198
+ ? ["open", [url]]
199
+ : process.platform === "win32"
200
+ ? ["rundll32", ["url.dll,FileProtocolHandler", url]]
201
+ : ["xdg-open", [url]];
202
+ return new Promise((resolve) => {
203
+ const child = spawn(file, args, { stdio: "ignore", detached: true });
204
+ child.once("error", () => resolve(false));
205
+ child.once("spawn", () => {
206
+ child.unref();
207
+ resolve(true);
208
+ });
209
+ });
210
+ }