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
package/src/scripts.mjs
ADDED
|
@@ -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
|
+
}
|