One-line CLI install, and CLI sign-in through the browser (#185)
* CLI sign-in through the browser: server side (WIP) * CLI: browser and device sign-in, upgrade, install scripts (WIP) * Docs and served install scripts for the one-line CLI install and browser sign-in (WIP) * CLI sign-in: end-to-end test, wording
- CI / Typecheck and bundlesuccess
- CI / Unit tests (1/2)success
- CI / Unit tests (2/2)success
- Deploy / Deploy to Cloudflaresuccess
- End-to-end / End-to-end (agent reviews, tasks, Today)success
- End-to-end / End-to-end (git, PRs, Actions)success
- End-to-end / End-to-end (large pushes)success
- End-to-end / End-to-end (passkeys, registry, email)success
- End-to-end / End-to-end (real histories)success
- End-to-end / End-to-end (security)success
- Live test / Containers, registry, sandboxes, webhooks, schedules, LFS and pull requestssuccess
52 files changed+1917−106
Files (52)
- apps/cli/install/install.ps1+108−0
- apps/cli/install/install.sh+137−0
- apps/cli/src/api.ts+2−1
- apps/cli/src/bin.ts+6−0
- apps/cli/src/commands/auth.ts+41−12
- apps/cli/src/commands/upgrade.ts+43−0
- apps/cli/src/io.ts+5−1
- apps/cli/src/login.ts+179−0
- apps/cli/src/main.ts+2−1
- apps/cli/test/helpers.ts+5−0
- apps/cli/test/login.test.ts+217−0
- apps/diffinery/docs/accounts/cli-sign-in.md+119−0
- apps/diffinery/docs/accounts/invitations.md+2−2
- apps/diffinery/docs/accounts/passkeys-and-sign-in.md+1−1
- apps/diffinery/docs/accounts/tokens.md+4−4
- apps/diffinery/docs/actions/caching-and-artifacts.md+1−1
- apps/diffinery/docs/actions/overview.md+2−2
- apps/diffinery/docs/actions/runners.md+1−1
- apps/diffinery/docs/actions/secrets-and-variables.md+3−2
- apps/diffinery/docs/actions/workflow-permissions.md+1−1
- apps/diffinery/docs/admin/site-administration.md+2−2
- apps/diffinery/docs/agents/overview.md+2−2
- apps/diffinery/docs/collaboration/issues.md+2−2
- apps/diffinery/docs/collaboration/pull-requests.md+2−2
- apps/diffinery/docs/collaboration/releases.md+1−1
- apps/diffinery/docs/migrate-from-github.md+3−3
- apps/diffinery/docs/nav.json+1−0
- apps/diffinery/docs/quickstart.md+33−26
- apps/diffinery/docs/reference/rest-api.md+3−3
- apps/diffinery/docs/repositories/branch-protection.md+1−1
- apps/diffinery/docs/repositories/git.md+3−3
- apps/diffinery/docs/repositories/overview.md+3−3
- apps/diffinery/docs/repositories/webhooks.md+1−1
- apps/diffinery/migrations/0034_cli_logins.sql+29−0
- apps/diffinery/scripts/build-docs.mjs+9−9
- apps/diffinery/src/app.ts+5−0
- apps/diffinery/src/auth/cli-login.ts+237−0
- apps/diffinery/src/auth/principal.ts+1−1
- apps/diffinery/src/docs/pages.ts+1−1
- apps/diffinery/src/docs/web.tsx+10−2
- apps/diffinery/src/http/cli-login.ts+69−0
- apps/diffinery/src/lib/directory.ts+6−6
- apps/diffinery/src/lib/rate-limit.ts+4−3
- apps/diffinery/src/lib/util.ts+1−1
- apps/diffinery/src/web/account.tsx+4−1
- apps/diffinery/src/web/assets.ts+2−0
- apps/diffinery/src/web/cli-login.tsx+242−0
- apps/diffinery/src/web/routes.tsx+2−0
- apps/diffinery/src/web/ui.tsx+5−3
- apps/diffinery/test/e2e/passkeys.test.mjs+74−1
- apps/diffinery/test/unit/cli-login.test.ts+265−0
- apps/diffinery/test/unit/docs.test.ts+15−0
addedapps/cli/install/install.ps1+108−0View file
| @@ -1,0 +1,108 @@ | ||
| 1 | +# Installs the Diffinery CLI on Windows: | |
| 2 | +# | |
| 3 | +# powershell -c "irm https://diffinery.com/install.ps1 | iex" | |
| 4 | +# | |
| 5 | +# The CLI goes in %USERPROFILE%\.diffinery (set DIFFINERY_INSTALL to change that), and its bin | |
| 6 | +# folder is added to your user PATH (DIFFINERY_NO_MODIFY_PATH=1 skips that). It runs on Node.js 22 | |
| 7 | +# or later: the one on your PATH when it's new enough, otherwise a private copy of the Node.js LTS | |
| 8 | +# from nodejs.org, checked against its published SHA-256, in .diffinery\node. | |
| 9 | +# Run it again, or `diffinery upgrade`, to update. Served by Diffinery from apps/cli/install/. | |
| 10 | +# `irm | iex` runs this in your session: the block keeps its settings and variables out of it. | |
| 11 | +& { | |
| 12 | + $ErrorActionPreference = 'Stop' | |
| 13 | + # Invoke-WebRequest is many times faster without its progress bar. | |
| 14 | + $ProgressPreference = 'SilentlyContinue' | |
| 15 | + [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 | |
| 16 | + | |
| 17 | + $Server = if ($env:DIFFINERY_URL) { $env:DIFFINERY_URL } else { '__DIFFINERY_URL__' } | |
| 18 | + if ($Server.StartsWith('__')) { $Server = 'https://diffinery.com' } | |
| 19 | + $Server = $Server.TrimEnd('/') | |
| 20 | + $Root = if ($env:DIFFINERY_INSTALL) { $env:DIFFINERY_INSTALL } else { Join-Path $HOME '.diffinery' } | |
| 21 | + $Bin = Join-Path $Root 'bin' | |
| 22 | + $Lib = Join-Path $Root 'lib' | |
| 23 | + $Private = Join-Path $Root 'node\node.exe' | |
| 24 | + $NodeLine = 24 | |
| 25 | + | |
| 26 | + function Test-Node([string] $Exe) { | |
| 27 | + try { | |
| 28 | + & $Exe -e "process.exit(Number(process.versions.node.split('.')[0]) >= 22 ? 0 : 1)" *> $null | |
| 29 | + return $LASTEXITCODE -eq 0 | |
| 30 | + } catch { | |
| 31 | + return $false | |
| 32 | + } | |
| 33 | + } | |
| 34 | + | |
| 35 | + # 1. Node.js for the CLI to run on. | |
| 36 | + $OwnNode = $false | |
| 37 | + $SystemNode = Get-Command node -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 | |
| 38 | + if ((Test-Path $Private) -and (Test-Node $Private)) { | |
| 39 | + $OwnNode = $true | |
| 40 | + } elseif (-not ($SystemNode -and (Test-Node $SystemNode.Source))) { | |
| 41 | + $Arch = if ($env:PROCESSOR_ARCHITECTURE -eq 'ARM64' -or $env:PROCESSOR_ARCHITEW6432 -eq 'ARM64') { 'arm64' } else { 'x64' } | |
| 42 | + Write-Host "Installing Node.js $NodeLine LTS for the CLI, from nodejs.org..." | |
| 43 | + $Dist = "https://nodejs.org/dist/latest-v$NodeLine.x" | |
| 44 | + $Sums = [string] (Invoke-WebRequest -UseBasicParsing "$Dist/SHASUMS256.txt").Content | |
| 45 | + $Line = $Sums -split "`n" | Where-Object { $_ -match "^[0-9a-f]{64} node-v[0-9.]+-win-$Arch\.zip$" } | Select-Object -First 1 | |
| 46 | + if (-not $Line) { throw "nodejs.org has no Node.js build for win-$Arch" } | |
| 47 | + $Want, $File = $Line.Trim() -split '\s+' | |
| 48 | + $Tmp = Join-Path ([IO.Path]::GetTempPath()) ("diffinery-" + [guid]::NewGuid().ToString('n')) | |
| 49 | + New-Item -ItemType Directory -Path $Tmp | Out-Null | |
| 50 | + try { | |
| 51 | + $Zip = Join-Path $Tmp $File | |
| 52 | + Invoke-WebRequest -UseBasicParsing "$Dist/$File" -OutFile $Zip | |
| 53 | + if ((Get-FileHash -Algorithm SHA256 $Zip).Hash.ToLower() -ne $Want) { throw "$File doesn't match its published SHA-256, so it wasn't installed" } | |
| 54 | + Expand-Archive -Path $Zip -DestinationPath $Tmp | |
| 55 | + $Dest = Join-Path $Root 'node' | |
| 56 | + New-Item -ItemType Directory -Force -Path $Root | Out-Null | |
| 57 | + if (Test-Path $Dest) { Remove-Item -Recurse -Force $Dest } | |
| 58 | + Move-Item (Join-Path $Tmp ($File -replace '\.zip$', '')) $Dest | |
| 59 | + } finally { | |
| 60 | + Remove-Item -Recurse -Force $Tmp -ErrorAction SilentlyContinue | |
| 61 | + } | |
| 62 | + $OwnNode = $true | |
| 63 | + } | |
| 64 | + | |
| 65 | + # 2. The CLI itself: one JavaScript file. | |
| 66 | + New-Item -ItemType Directory -Force -Path $Bin, $Lib | Out-Null | |
| 67 | + $Cli = Join-Path $Lib 'diffinery.mjs' | |
| 68 | + $Download = "$Cli.download" | |
| 69 | + Invoke-WebRequest -UseBasicParsing "$Server/-/cli/diffinery.mjs" -OutFile $Download | |
| 70 | + if (-not ((Get-Content $Download -TotalCount 1) -like '#!/usr/bin/env node*')) { | |
| 71 | + Remove-Item -Force $Download | |
| 72 | + throw "$Server/-/cli/diffinery.mjs isn't the CLI" | |
| 73 | + } | |
| 74 | + Move-Item -Force $Download $Cli | |
| 75 | + | |
| 76 | + # 3. The diffinery command: the private Node.js when there is one, else the node on PATH. | |
| 77 | + $Cmd = @( | |
| 78 | + '@echo off', | |
| 79 | + "rem The Diffinery CLI, installed from $Server/install.ps1. Update it with: diffinery upgrade", | |
| 80 | + 'setlocal', | |
| 81 | + 'set "DIFFINERY_NODE=node"', | |
| 82 | + 'if exist "%~dp0..\node\node.exe" set "DIFFINERY_NODE=%~dp0..\node\node.exe"', | |
| 83 | + '"%DIFFINERY_NODE%" "%~dp0..\lib\diffinery.mjs" %*' | |
| 84 | + ) -join "`r`n" | |
| 85 | + Set-Content -Path (Join-Path $Bin 'diffinery.cmd') -Value $Cmd -Encoding ASCII | |
| 86 | + $Version = & (Join-Path $Bin 'diffinery.cmd') --version | |
| 87 | + if ($LASTEXITCODE -ne 0) { throw "the CLI was installed in $Root but doesn't start; run $Bin\diffinery.cmd --version to see why" } | |
| 88 | + | |
| 89 | + # 4. PATH, for new terminals and this one. | |
| 90 | + $Note = $null | |
| 91 | + $UserPath = [Environment]::GetEnvironmentVariable('Path', 'User') | |
| 92 | + if (-not (($UserPath -split ';') -contains $Bin)) { | |
| 93 | + if ($env:DIFFINERY_NO_MODIFY_PATH) { | |
| 94 | + $Note = "Add $Bin to your PATH to run diffinery." | |
| 95 | + } else { | |
| 96 | + [Environment]::SetEnvironmentVariable('Path', ((@($UserPath, $Bin) | Where-Object { $_ }) -join ';'), 'User') | |
| 97 | + $Note = "Added $Bin to your user PATH. Open a new terminal to use it everywhere." | |
| 98 | + } | |
| 99 | + } | |
| 100 | + if (-not (($env:Path -split ';') -contains $Bin)) { $env:Path = "$Bin;$env:Path" } | |
| 101 | + | |
| 102 | + Write-Host "$([char]0x2713) $Version installed to $Bin\diffinery.cmd$(if ($OwnNode) { ' (with its own Node.js)' })" -ForegroundColor Green | |
| 103 | + if ($Note) { Write-Host $Note } | |
| 104 | + $Login = if ($Server -eq 'https://diffinery.com') { 'diffinery login' } else { "diffinery login --url $Server" } | |
| 105 | + Write-Host "" | |
| 106 | + Write-Host "Next, sign in through your browser:" | |
| 107 | + Write-Host " $Login" | |
| 108 | +} |
addedapps/cli/install/install.sh+137−0View file
| @@ -1,0 +1,137 @@ | ||
| 1 | +#!/usr/bin/env bash | |
| 2 | +# Installs the Diffinery CLI on Linux and macOS: | |
| 3 | +# | |
| 4 | +# curl -fsSL https://diffinery.com/install | bash | |
| 5 | +# | |
| 6 | +# The CLI goes in ~/.diffinery (set DIFFINERY_INSTALL to change that), and ~/.diffinery/bin is | |
| 7 | +# added to PATH in your shell's startup file (DIFFINERY_NO_MODIFY_PATH=1 skips that). It runs on | |
| 8 | +# Node.js 22 or later: the one on your PATH when it's new enough, otherwise a private copy of the | |
| 9 | +# Node.js LTS from nodejs.org, checked against its published SHA-256, in ~/.diffinery/node. | |
| 10 | +# Run it again, or `diffinery upgrade`, to update. Served by Diffinery from apps/cli/install/. | |
| 11 | +set -euo pipefail | |
| 12 | + | |
| 13 | +server=${DIFFINERY_URL:-__DIFFINERY_URL__} | |
| 14 | +case $server in __*) server=https://diffinery.com ;; esac | |
| 15 | +server=${server%/} | |
| 16 | +root=${DIFFINERY_INSTALL:-$HOME/.diffinery} | |
| 17 | +bin=$root/bin | |
| 18 | +node_line=24 | |
| 19 | + | |
| 20 | +if [ -t 2 ] && [ -z "${NO_COLOR:-}" ]; then | |
| 21 | + bold=$'\033[1m' dim=$'\033[2m' green=$'\033[32m' red=$'\033[31m' reset=$'\033[0m' | |
| 22 | +else | |
| 23 | + bold='' dim='' green='' red='' reset='' | |
| 24 | +fi | |
| 25 | +say() { printf '%s\n' "$*" >&2; } | |
| 26 | +die() { | |
| 27 | + say "${red}error${reset}: $*" | |
| 28 | + exit 1 | |
| 29 | +} | |
| 30 | +tilde() { case $1 in "$HOME"/*) printf '~%s' "${1#"$HOME"}" ;; *) printf '%s' "$1" ;; esac; } | |
| 31 | + | |
| 32 | +command -v curl >/dev/null || die "curl is required" | |
| 33 | +case $(uname -s) in | |
| 34 | + Linux) os=linux ;; | |
| 35 | + Darwin) os=darwin ;; | |
| 36 | + MINGW* | MSYS* | CYGWIN*) die "on Windows, run this in PowerShell instead: irm $server/install.ps1 | iex" ;; | |
| 37 | + *) die "$(uname -s) isn't supported by this script; install Node.js 22 or later and download $server/-/cli/diffinery.mjs" ;; | |
| 38 | +esac | |
| 39 | +case $(uname -m) in | |
| 40 | + x86_64 | amd64) arch=x64 ;; | |
| 41 | + arm64 | aarch64) arch=arm64 ;; | |
| 42 | + *) arch='' ;; | |
| 43 | +esac | |
| 44 | +# A shell under Rosetta on Apple silicon still gets the arm64 Node.js. | |
| 45 | +if [ "$os" = darwin ] && [ "$arch" = x64 ] && [ "$(sysctl -n sysctl.proc_translated 2>/dev/null || true)" = 1 ]; then arch=arm64; fi | |
| 46 | + | |
| 47 | +tmp=$(mktemp -d) | |
| 48 | +trap 'rm -rf "$tmp"' EXIT | |
| 49 | + | |
| 50 | +node_ok() { "$1" -e 'process.exit(Number(process.versions.node.split(".")[0]) >= 22 ? 0 : 1)' >/dev/null 2>&1; } | |
| 51 | + | |
| 52 | +# 1. Node.js for the CLI to run on. | |
| 53 | +private=$root/node/bin/node | |
| 54 | +if [ -x "$private" ] && node_ok "$private"; then | |
| 55 | + node=$private | |
| 56 | +elif command -v node >/dev/null && node_ok node; then | |
| 57 | + node='' | |
| 58 | +else | |
| 59 | + [ -n "$arch" ] || die "the CLI needs Node.js 22 or later, and nodejs.org has no build for $(uname -m); install Node.js and run this again" | |
| 60 | + if [ "$os" = linux ] && ldd --version 2>&1 | grep -qi musl; then die "the CLI needs Node.js 22 or later; on Alpine and other musl systems install it with your package manager (apk add nodejs) and run this again"; fi | |
| 61 | + say "Installing Node.js $node_line LTS for the CLI, from nodejs.org…" | |
| 62 | + dist=https://nodejs.org/dist/latest-v$node_line.x | |
| 63 | + sums=$(curl -fsSL "$dist/SHASUMS256.txt") || die "couldn't reach nodejs.org" | |
| 64 | + line=$(printf '%s\n' "$sums" | grep -E " node-v[0-9.]+-$os-$arch\.tar\.gz$" | head -n 1 || true) | |
| 65 | + [ -n "$line" ] || die "nodejs.org has no Node.js build for $os-$arch" | |
| 66 | + want=${line%% *} | |
| 67 | + file=${line##* } | |
| 68 | + curl -fsSL "$dist/$file" -o "$tmp/node.tar.gz" || die "couldn't download $file" | |
| 69 | + if command -v sha256sum >/dev/null; then got=$(sha256sum "$tmp/node.tar.gz"); else got=$(shasum -a 256 "$tmp/node.tar.gz"); fi | |
| 70 | + [ "${got%% *}" = "$want" ] || die "$file doesn't match its published SHA-256, so it wasn't installed" | |
| 71 | + mkdir -p "$tmp/node" "$root" | |
| 72 | + tar -xzf "$tmp/node.tar.gz" -C "$tmp/node" --strip-components 1 | |
| 73 | + rm -rf "$root/node" | |
| 74 | + mv "$tmp/node" "$root/node" | |
| 75 | + node=$private | |
| 76 | +fi | |
| 77 | + | |
| 78 | +# 2. The CLI itself: one JavaScript file. | |
| 79 | +curl -fsSL "$server/-/cli/diffinery.mjs" -o "$tmp/diffinery.mjs" || die "couldn't download the CLI from $server" | |
| 80 | +head -n 1 "$tmp/diffinery.mjs" | grep -q '^#!/usr/bin/env node' || die "$server/-/cli/diffinery.mjs isn't the CLI" | |
| 81 | +mkdir -p "$root/lib" "$bin" | |
| 82 | +chmod 755 "$tmp/diffinery.mjs" | |
| 83 | +mv -f "$tmp/diffinery.mjs" "$root/lib/diffinery.mjs" | |
| 84 | + | |
| 85 | +# 3. The diffinery command: the private Node.js when there is one, else the node on PATH. | |
| 86 | +q=$(printf '%q' "$root") | |
| 87 | +cat >"$tmp/diffinery" <<EOF | |
| 88 | +#!/bin/sh | |
| 89 | +# The Diffinery CLI, installed from $server/install. Update it with: diffinery upgrade | |
| 90 | +root=$q | |
| 91 | +node=\$root/node/bin/node | |
| 92 | +[ -x "\$node" ] || node=node | |
| 93 | +exec "\$node" "\$root/lib/diffinery.mjs" "\$@" | |
| 94 | +EOF | |
| 95 | +chmod 755 "$tmp/diffinery" | |
| 96 | +mv -f "$tmp/diffinery" "$bin/diffinery" | |
| 97 | +version=$("$bin/diffinery" --version) || die "the CLI was installed in $(tilde "$root") but doesn't start; run $bin/diffinery --version to see why" | |
| 98 | + | |
| 99 | +# 4. PATH. | |
| 100 | +note='' | |
| 101 | +case ":$PATH:" in | |
| 102 | + *":$bin:"*) ;; | |
| 103 | + *) | |
| 104 | + if [ -z "${DIFFINERY_NO_MODIFY_PATH:-}" ]; then | |
| 105 | + line="export PATH=\"$bin:\$PATH\"" | |
| 106 | + case $(basename "${SHELL:-sh}") in | |
| 107 | + zsh) rc=${ZDOTDIR:-$HOME}/.zshrc ;; | |
| 108 | + bash) if [ "$os" = darwin ]; then rc=$HOME/.bash_profile; else rc=$HOME/.bashrc; fi ;; | |
| 109 | + fish) | |
| 110 | + rc=$HOME/.config/fish/conf.d/diffinery.fish | |
| 111 | + line="fish_add_path \"$bin\"" | |
| 112 | + ;; | |
| 113 | + *) rc=$HOME/.profile ;; | |
| 114 | + esac | |
| 115 | + if grep -qsF "$bin" "$rc"; then | |
| 116 | + did="$(tilde "$rc") already adds $(tilde "$bin") to PATH." | |
| 117 | + else | |
| 118 | + mkdir -p "$(dirname "$rc")" | |
| 119 | + printf '\n# Diffinery CLI\n%s\n' "$line" >>"$rc" | |
| 120 | + did="Added $(tilde "$bin") to PATH in $(tilde "$rc")." | |
| 121 | + fi | |
| 122 | + note="$did Open a new terminal, or run: ${bold}export PATH=\"$bin:\$PATH\"${reset}" | |
| 123 | + else | |
| 124 | + note="Add $(tilde "$bin") to your PATH to run diffinery." | |
| 125 | + fi | |
| 126 | + ;; | |
| 127 | +esac | |
| 128 | + | |
| 129 | +say "${green}✓${reset} ${version} installed to $(tilde "$bin/diffinery")${node:+ ${dim}(with its own Node.js)${reset}}" | |
| 130 | +[ -z "$note" ] || say "$note" | |
| 131 | +other=$(command -v diffinery 2>/dev/null || true) | |
| 132 | +if [ -n "$other" ] && [ "$other" != "$bin/diffinery" ]; then say "${dim}Another diffinery at $(tilde "$other") comes first on your PATH; remove it to use this one.${reset}"; fi | |
| 133 | +login="diffinery login" | |
| 134 | +[ "$server" = https://diffinery.com ] || login="diffinery login --url $server" | |
| 135 | +say "" | |
| 136 | +say "Next, sign in through your browser:" | |
| 137 | +say " ${bold}$login${reset}" |
modifiedapps/cli/src/api.ts+2−1View file
| @@ -35,7 +35,7 @@ | ||
| 35 | 35 | ) {} |
| 36 | 36 | |
| 37 | 37 | url(path: string, query?: RequestOptions["query"]): string { |
| 38 | - const p = /^https?:\/\//.test(path) ? path : `${this.baseUrl}${path.startsWith("/api/") || path === "/mcp" ? "" : "/api/v3"}${path.startsWith("/") ? path : `/${path}`}`; | |
| 38 | + const p = /^https?:\/\//.test(path) ? path : `${this.baseUrl}${path.startsWith("/api/") || path.startsWith("/-/") || path === "/mcp" ? "" : "/api/v3"}${path.startsWith("/") ? path : `/${path}`}`; | |
| 39 | 39 | const u = new URL(p); |
| 40 | 40 | for (const [k, v] of Object.entries(query ?? {})) if (v !== undefined && v !== null && v !== "") u.searchParams.set(k, String(v)); |
| 41 | 41 | return u.toString(); |
| @@ -70,6 +70,7 @@ | ||
| 70 | 70 | const msg = data && typeof data === "object" && "message" in data ? String((data as { message: unknown }).message) : text.slice(0, 300) || res.statusText; |
| 71 | 71 | let hint = ""; |
| 72 | 72 | if (res.status === 401) hint = this.token ? " (token invalid or expired; run `diffinery login`)" : " (not logged in; run `diffinery login`)"; |
| 73 | + else if (res.status === 403 && /needs the (write|admin) scope/.test(msg)) hint = ` (run \`diffinery login --scope ${/admin/.test(msg) ? "admin" : "write"}\` to sign in with it)`; | |
| 73 | 74 | throw new ApiError(res.status, `${method} ${new URL(url).pathname}: ${res.status} ${msg}${hint}`, data); |
| 74 | 75 | } |
| 75 | 76 | return { status: res.status, data: data as T, headers: res.headers }; |
modifiedapps/cli/src/bin.ts+6−0View file
| @@ -1,5 +1,11 @@ | ||
| 1 | 1 | import { main } from "./main.js"; |
| 2 | 2 | |
| 3 | +// The bundle targets Node.js 22; on an older one, say so instead of failing somewhere obscure. | |
| 4 | +if (Number(process.versions.node.split(".")[0]) < 22) { | |
| 5 | + process.stderr.write(`error: diffinery needs Node.js 22 or later (this is ${process.version}). Reinstall with: curl -fsSL https://diffinery.com/install | bash\n`); | |
| 6 | + process.exit(1); | |
| 7 | +} | |
| 8 | + | |
| 3 | 9 | // EPIPE when piping into `head` and similar should end quietly. |
| 4 | 10 | process.stdout.on("error", (e: NodeJS.ErrnoException) => { |
| 5 | 11 | if (e.code === "EPIPE") process.exit(0); |
modifiedapps/cli/src/commands/auth.ts+41−12View file
| @@ -1,8 +1,9 @@ | ||
| 1 | 1 | import { Api } from "../api.js"; |
| 2 | 2 | import { str, type Command, type Group } from "../cli.js"; |
| 3 | -import { normalizeUrl, readConfig, resolveConfig, writeConfig } from "../config.js"; | |
| 3 | +import { DEFAULT_URL, normalizeUrl, readConfig, resolveConfig, writeConfig } from "../config.js"; | |
| 4 | 4 | import { gitCapture } from "../git.js"; |
| 5 | -import { CliError, readAll, valueFromFlagOrStdin } from "../io.js"; | |
| 5 | +import { CliError, readAll } from "../io.js"; | |
| 6 | +import { browserLikely, browserLogin, deviceLogin, revokeCliToken, type Scope } from "../login.js"; | |
| 6 | 7 | import { json } from "../output.js"; |
| 7 | 8 | |
| 8 | 9 | interface User { |
| @@ -13,26 +14,52 @@ | ||
| 13 | 14 | html_url?: string; |
| 14 | 15 | } |
| 15 | 16 | |
| 17 | +const SCOPES = ["read", "write", "admin"] as const; | |
| 18 | + | |
| 16 | 19 | const login: Command = { |
| 17 | 20 | name: "login", |
| 18 | - summary: "Log in with a personal or agent token", | |
| 19 | - description: `Stores the server URL and token in the config file (mode 0600). | |
| 20 | -The token is read from --token, from stdin when piped, or prompted for. | |
| 21 | -Create a token in the web UI (Settings → Access tokens).`, | |
| 21 | + summary: "Sign in through your browser (or with a token)", | |
| 22 | + description: `Opens your browser on the server's authorize page; approve the sign-in there and the CLI | |
| 23 | +gets its own token, valid for 90 days and listed under Settings → Access tokens. | |
| 24 | + | |
| 25 | +On a machine without a browser (over SSH, in a container), --device prints a short code to | |
| 26 | +enter at <server>/login/device from any browser; the CLI picks this by itself when it sees no | |
| 27 | +display. For scripts and CI, pass a token with --token, or pipe one in with --with-token | |
| 28 | +(piping into a plain \`diffinery login\` still works). The config file is written with mode 0600.`, | |
| 22 | 29 | options: { |
| 23 | 30 | url: { type: "string", desc: "Server URL (default: DIFFINERY_URL, the saved URL, or https://diffinery.com)", arg: "URL" }, |
| 24 | - token: { type: "string", desc: "Token (dfy_...)", arg: "TOKEN" }, | |
| 31 | + scope: { type: "string", desc: "Access to ask for: read, write (default) or admin. You can change it on the authorize page", arg: "SCOPE" }, | |
| 32 | + web: { type: "boolean", desc: "Sign in through the browser on this computer (the default in a terminal)" }, | |
| 33 | + device: { type: "boolean", desc: "Sign in with a code you enter in a browser on any device" }, | |
| 34 | + token: { type: "string", desc: "Use this token (dfy_...) instead of signing in through the browser", arg: "TOKEN" }, | |
| 35 | + "with-token": { type: "boolean", desc: "Read a token from stdin" }, | |
| 25 | 36 | }, |
| 26 | 37 | maxArgs: 0, |
| 27 | 38 | async run(ctx, o) { |
| 28 | 39 | const stored = await readConfig(ctx.io.env); |
| 29 | 40 | const cfg = await resolveConfig(ctx.io); |
| 30 | 41 | const url = str(o, "url") ? normalizeUrl(str(o, "url")!) : cfg.url; |
| 31 | - const token = (await valueFromFlagOrStdin(ctx.io, str(o, "token"), `Paste a token for ${url}: `)).trim(); | |
| 32 | - if (!token) throw new CliError("no token given"); | |
| 42 | + const scope = (str(o, "scope") ?? "write") as Scope; | |
| 43 | + if (!SCOPES.includes(scope)) throw new CliError(`--scope must be one of ${SCOPES.join(", ")}`, 2); | |
| 44 | + const modes = (["web", "device", "with-token"] as const).filter((k) => o[k] === true); | |
| 45 | + if (modes.length + (str(o, "token") !== undefined ? 1 : 0) > 1) throw new CliError("use only one of --web, --device, --token and --with-token", 2); | |
| 46 | + | |
| 47 | + let token: string; | |
| 48 | + let note = ""; | |
| 49 | + if (str(o, "token") !== undefined || o["with-token"] || (!modes.length && !ctx.io.stdin.isTTY)) { | |
| 50 | + token = (str(o, "token") ?? (await readAll(ctx.io.stdin)).replace(/\r?\n$/, "")).trim(); | |
| 51 | + if (!token) throw new CliError(o["with-token"] || str(o, "token") !== undefined ? "no token given" : "no token on stdin; run `diffinery login` in a terminal to sign in through your browser, or pass --token"); | |
| 52 | + } else { | |
| 53 | + const device = o.device === true || (o.web !== true && !browserLikely(ctx.io)); | |
| 54 | + const grant = device ? await deviceLogin(ctx, url, scope) : await browserLogin(ctx, url, scope); | |
| 55 | + token = grant.access_token; | |
| 56 | + note = ` with ${grant.scope} access until ${new Date(grant.expires_at).toLocaleDateString("en-US", { dateStyle: "medium" })}`; | |
| 57 | + } | |
| 33 | 58 | const me = await new Api(url, token).get<User>("/user"); |
| 34 | 59 | const file = await writeConfig(ctx.io.env, { ...stored, url, token }); |
| 35 | - ctx.out(`${ctx.c.green("✓")} Logged in to ${url} as ${ctx.c.bold(me.login)}${me.type === "Bot" ? " (agent)" : ""}`); | |
| 60 | + // The token this one replaces: revoke it if a CLI sign-in made it (never a token someone pasted, which may be used elsewhere). | |
| 61 | + if (stored.token && stored.token !== token) await revokeCliToken(stored.url ? normalizeUrl(stored.url) : url, stored.token); | |
| 62 | + ctx.out(`${ctx.c.green("✓")} Logged in to ${url} as ${ctx.c.bold(me.login)}${me.type === "Bot" ? " (agent)" : ""}${note}`); | |
| 36 | 63 | ctx.err(ctx.c.dim(`Saved to ${file}`)); |
| 37 | 64 | if (ctx.io.env.DIFFINERY_TOKEN) ctx.err(ctx.c.yellow("note: DIFFINERY_TOKEN is set and overrides the saved token")); |
| 38 | 65 | }, |
| @@ -40,14 +67,16 @@ | ||
| 40 | 67 | |
| 41 | 68 | const logout: Command = { |
| 42 | 69 | name: "logout", |
| 43 | - summary: "Remove the saved token", | |
| 70 | + summary: "Sign out: forget the saved token, and revoke it if a browser sign-in created it", | |
| 44 | 71 | maxArgs: 0, |
| 45 | 72 | async run(ctx) { |
| 46 | 73 | const stored = await readConfig(ctx.io.env); |
| 47 | 74 | if (!stored.token) ctx.out("Not logged in."); |
| 48 | 75 | else { |
| 76 | + const url = stored.url ? normalizeUrl(stored.url) : DEFAULT_URL; | |
| 77 | + const revoked = await revokeCliToken(url, stored.token); | |
| 49 | 78 | await writeConfig(ctx.io.env, { url: stored.url }); |
| 50 | - ctx.out(`Logged out of ${stored.url ?? "Diffinery"}.`); | |
| 79 | + ctx.out(`Logged out of ${url}${revoked ? " and revoked the CLI's token" : ""}.`); | |
| 51 | 80 | } |
| 52 | 81 | if (ctx.io.env.DIFFINERY_TOKEN) ctx.err(ctx.c.yellow("note: DIFFINERY_TOKEN is still set in your environment")); |
| 53 | 82 | }, |
addedapps/cli/src/commands/upgrade.ts+43−0View file
| @@ -1,0 +1,43 @@ | ||
| 1 | +import { chmod, readFile, realpath, rename, rm, writeFile } from "node:fs/promises"; | |
| 2 | +import path from "node:path"; | |
| 3 | +import { Api } from "../api.js"; | |
| 4 | +import type { Command } from "../cli.js"; | |
| 5 | +import { CliError } from "../io.js"; | |
| 6 | +import { VERSION } from "../version.js"; | |
| 7 | + | |
| 8 | +const SHEBANG = "#!/usr/bin/env node\n"; | |
| 9 | + | |
| 10 | +/** Replaces the file this CLI runs from with the server's current build (served at /-/cli/diffinery.mjs). */ | |
| 11 | +export const upgradeCommand: Command = { | |
| 12 | + name: "upgrade", | |
| 13 | + summary: "Update the CLI to the server's latest build", | |
| 14 | + description: `Downloads <server>/-/cli/diffinery.mjs and replaces the file this CLI runs from, when it changed. | |
| 15 | +The install script (curl -fsSL https://diffinery.com/install | bash) does the same and sets up PATH.`, | |
| 16 | + maxArgs: 0, | |
| 17 | + async run(ctx) { | |
| 18 | + const cfg = await ctx.config(); | |
| 19 | + if (!ctx.io.selfPath) throw new CliError("can't tell which file this CLI runs from; reinstall with curl -fsSL https://diffinery.com/install | bash"); | |
| 20 | + const self = await realpath(ctx.io.selfPath); | |
| 21 | + const current = await readFile(self, "utf8").catch(() => ""); | |
| 22 | + if (!current.startsWith(SHEBANG)) throw new CliError(`${self} isn't an installed diffinery CLI (running from source?); reinstall with curl -fsSL ${cfg.url}/install | bash`); | |
| 23 | + const res = await new Api(cfg.url, null).raw<string>("GET", "/-/cli/diffinery.mjs", { text: true, headers: { accept: "text/javascript" } }); | |
| 24 | + const next = res.data; | |
| 25 | + if (!next.startsWith(SHEBANG) || next.length < 10_000) throw new CliError(`${cfg.url}/-/cli/diffinery.mjs doesn't look like the CLI; nothing changed`); | |
| 26 | + const version = res.headers.get("x-diffinery-cli-version") ?? VERSION; | |
| 27 | + if (next === current) { | |
| 28 | + ctx.out(`diffinery ${VERSION} is up to date.`); | |
| 29 | + return; | |
| 30 | + } | |
| 31 | + // Write beside it and rename over it, so a failed download never leaves half a file. | |
| 32 | + const tmp = path.join(path.dirname(self), `.diffinery-upgrade-${process.pid}.mjs`); | |
| 33 | + try { | |
| 34 | + await writeFile(tmp, next, { mode: 0o755 }); | |
| 35 | + await chmod(tmp, 0o755); | |
| 36 | + await rename(tmp, self); | |
| 37 | + } catch (e) { | |
| 38 | + await rm(tmp, { force: true }); | |
| 39 | + throw new CliError(`couldn't replace ${self}: ${(e as Error).message}`); | |
| 40 | + } | |
| 41 | + ctx.out(`${ctx.c.green("✓")} Updated diffinery ${VERSION === version ? VERSION : `${VERSION} → ${version}`} (${self})`); | |
| 42 | + }, | |
| 43 | +}; |
modifiedapps/cli/src/io.ts+5−1View file
| @@ -9,6 +9,10 @@ | ||
| 9 | 9 | stderr: { write(s: string): unknown; isTTY?: boolean }; |
| 10 | 10 | /** Overridable for tests (polling loops). */ |
| 11 | 11 | sleep(ms: number, signal?: AbortSignal): Promise<void>; |
| 12 | + /** Opens a URL in the browser (tests stand in for the browser); the default runs the system's opener. */ | |
| 13 | + openUrl?(url: string): Promise<boolean>; | |
| 14 | + /** The file this CLI runs from, which `diffinery upgrade` replaces. */ | |
| 15 | + selfPath?: string; | |
| 12 | 16 | } |
| 13 | 17 | |
| 14 | 18 | export function sleep(ms: number, signal?: AbortSignal): Promise<void> { |
| @@ -25,7 +29,7 @@ | ||
| 25 | 29 | } |
| 26 | 30 | |
| 27 | 31 | export function processIo(): Io { |
| 28 | - return { env: process.env, cwd: process.cwd(), stdin: process.stdin, stdout: process.stdout, stderr: process.stderr, sleep }; | |
| 32 | + return { env: process.env, cwd: process.cwd(), stdin: process.stdin, stdout: process.stdout, stderr: process.stderr, sleep, selfPath: process.argv[1] }; | |
| 29 | 33 | } |
| 30 | 34 | |
| 31 | 35 | /** A user-facing failure: printed as `error: message`, exits with `code`. */ |
addedapps/cli/src/login.ts+179−0View file
| @@ -1,0 +1,179 @@ | ||
| 1 | +import { spawn } from "node:child_process"; | |
| 2 | +import { createHash, randomBytes } from "node:crypto"; | |
| 3 | +import { createServer, type Server } from "node:http"; | |
| 4 | +import type { AddressInfo } from "node:net"; | |
| 5 | +import { arch, hostname, platform } from "node:os"; | |
| 6 | +import { Api } from "./api.js"; | |
| 7 | +import type { Ctx } from "./cli.js"; | |
| 8 | +import { CliError, type Io } from "./io.js"; | |
| 9 | +import { VERSION } from "./version.js"; | |
| 10 | + | |
| 11 | +/** | |
| 12 | + * Signing in through the browser, so nobody copies a token by hand (the server side is | |
| 13 | + * apps/diffinery/src/auth/cli-login.ts): | |
| 14 | + * | |
| 15 | + * - browserLogin: listen on 127.0.0.1, open /login/cli with a PKCE challenge, and wait for the | |
| 16 | + * browser to come back to /callback with a one-time code; trade it, with the verifier only this | |
| 17 | + * process knows, for a token. | |
| 18 | + * - deviceLogin: for machines without a browser. Print a short code to type on /login/device in | |
| 19 | + * any browser, and poll until the person approves. | |
| 20 | + */ | |
| 21 | + | |
| 22 | +export type Scope = "read" | "write" | "admin"; | |
| 23 | + | |
| 24 | +export interface TokenGrant { | |
| 25 | + access_token: string; | |
| 26 | + scope: Scope; | |
| 27 | + expires_at: string; | |
| 28 | + login: string; | |
| 29 | +} | |
| 30 | + | |
| 31 | +interface TokenError { | |
| 32 | + error: string; | |
| 33 | + error_description?: string; | |
| 34 | +} | |
| 35 | + | |
| 36 | +/** How long to wait for the person before giving up. */ | |
| 37 | +const BROWSER_TIMEOUT_MS = 10 * 60_000; | |
| 38 | + | |
| 39 | +const b64url = (b: Buffer) => b.toString("base64url"); | |
| 40 | + | |
| 41 | +/** What the authorize page shows about this computer, and what the token is named after. */ | |
| 42 | +export function clientInfo(): { host: string; os: string; version: string } { | |
| 43 | + return { host: hostname().replace(/\.local$/, "") || "unknown host", os: `${platform()}-${arch()}`, version: VERSION }; | |
| 44 | +} | |
| 45 | + | |
| 46 | +/** | |
| 47 | + * Opens `url` in the default browser: `BROWSER` when set (`BROWSER=none` opens nothing), else | |
| 48 | + * open (macOS), rundll32 (Windows, which avoids cmd's handling of `&`) or xdg-open. True when a | |
| 49 | + * browser command started; the caller prints the URL either way. | |
| 50 | + */ | |
| 51 | +export function openBrowser(io: Io, url: string): Promise<boolean> { | |
| 52 | + if (io.openUrl) return io.openUrl(url); | |
| 53 | + const pref = io.env.BROWSER?.trim(); | |
| 54 | + if (pref === "none") return Promise.resolve(false); | |
| 55 | + const [cmd, args] = pref ? [pref, [url]] : process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["rundll32", ["url.dll,FileProtocolHandler", url]] : ["xdg-open", [url]]; | |
| 56 | + return new Promise((resolve) => { | |
| 57 | + try { | |
| 58 | + const child = spawn(cmd, args, { stdio: "ignore", detached: true }); | |
| 59 | + child.once("error", () => resolve(false)); | |
| 60 | + child.once("spawn", () => { | |
| 61 | + child.unref(); | |
| 62 | + resolve(true); | |
| 63 | + }); | |
| 64 | + } catch { | |
| 65 | + resolve(false); | |
| 66 | + } | |
| 67 | + }); | |
| 68 | +} | |
| 69 | + | |
| 70 | +/** Whether a browser on this machine can reach a server on this machine's 127.0.0.1. */ | |
| 71 | +export function browserLikely(io: Io): boolean { | |
| 72 | + if (io.openUrl) return true; | |
| 73 | + if (io.env.BROWSER?.trim() && io.env.BROWSER.trim() !== "none") return true; | |
| 74 | + // Over SSH the browser runs on another computer, where 127.0.0.1 is not this one. | |
| 75 | + if (io.env.SSH_CONNECTION || io.env.SSH_TTY) return false; | |
| 76 | + if (process.platform === "linux" || process.platform === "freebsd" || process.platform === "openbsd") return !!(io.env.DISPLAY || io.env.WAYLAND_DISPLAY); | |
| 77 | + return true; | |
| 78 | +} | |
| 79 | + | |
| 80 | +async function exchange(url: string, body: Record<string, string>): Promise<TokenGrant | TokenError> { | |
| 81 | + const res = await new Api(url, null).raw<TokenGrant | TokenError>("POST", "/-/cli/token", { body, allow: [400] }); | |
| 82 | + return res.data; | |
| 83 | +} | |
| 84 | + | |
| 85 | +const isGrant = (v: TokenGrant | TokenError): v is TokenGrant => typeof (v as TokenGrant).access_token === "string"; | |
| 86 | + | |
| 87 | +const page = (title: string, text: string) => | |
| 88 | + `<!doctype html><meta charset="utf-8"><title>${title}</title><body style="font:16px system-ui,sans-serif;max-width:32rem;margin:15vh auto;padding:0 1rem"><h1 style="font-size:1.4rem">${title}</h1><p>${text}</p></body>`; | |
| 89 | + | |
| 90 | +export async function browserLogin(ctx: Ctx, url: string, scope: Scope): Promise<TokenGrant> { | |
| 91 | + const verifier = b64url(randomBytes(32)); | |
| 92 | + const challenge = b64url(createHash("sha256").update(verifier).digest()); | |
| 93 | + const state = b64url(randomBytes(24)); | |
| 94 | + let settle!: { ok: (g: TokenGrant) => void; fail: (e: Error) => void }; | |
| 95 | + const done = new Promise<TokenGrant>((ok, fail) => (settle = { ok, fail })); | |
| 96 | + done.catch(() => {}); // awaited below; this only keeps an early failure from counting as unhandled | |
| 97 | + let busy = false; | |
| 98 | + const server: Server = createServer(async (req, res) => { | |
| 99 | + const u = new URL(req.url ?? "/", "http://127.0.0.1"); | |
| 100 | + if (req.method !== "GET" || u.pathname !== "/callback") { | |
| 101 | + res.writeHead(404, { "content-type": "text/plain" }).end("not found"); | |
| 102 | + return; | |
| 103 | + } | |
| 104 | + // Only the browser this process sent out carries its state; anything else is ignored. | |
| 105 | + if (u.searchParams.get("state") !== state || busy) { | |
| 106 | + res.writeHead(400, { "content-type": "text/html; charset=utf-8" }).end(page("Not this sign-in", "This link doesn't belong to the <code>diffinery login</code> that is waiting. Run it again if you need to.")); | |
| 107 | + return; | |
| 108 | + } | |
| 109 | + busy = true; | |
| 110 | + const back = (result: string) => res.writeHead(302, { location: `${url}/login/cli/done?result=${result}`, "cache-control": "no-store" }).end(); | |
| 111 | + const error = u.searchParams.get("error"); | |
| 112 | + if (error) { | |
| 113 | + back(error === "access_denied" ? "denied" : "failed"); | |
| 114 | + return settle.fail(new CliError(error === "access_denied" ? "the sign-in was cancelled in the browser" : `the browser returned an error: ${error}`)); | |
| 115 | + } | |
| 116 | + try { | |
| 117 | + const got = await exchange(url, { grant_type: "authorization_code", code: u.searchParams.get("code") ?? "", code_verifier: verifier }); | |
| 118 | + if (!isGrant(got)) throw new CliError(`sign-in failed: ${got.error_description ?? got.error}`); | |
| 119 | + back("ok"); | |
| 120 | + settle.ok(got); | |
| 121 | + } catch (e) { | |
| 122 | + back("failed"); | |
| 123 | + settle.fail(e instanceof Error ? e : new Error(String(e))); | |
| 124 | + } | |
| 125 | + }); | |
| 126 | + await new Promise<void>((ok, fail) => { | |
| 127 | + server.once("error", fail); | |
| 128 | + server.listen(0, "127.0.0.1", () => ok()); | |
| 129 | + }); | |
| 130 | + const port = (server.address() as AddressInfo).port; | |
| 131 | + const client = clientInfo(); | |
| 132 | + const authorize = `${url}/login/cli?${new URLSearchParams({ port: String(port), state, code_challenge: challenge, code_challenge_method: "S256", scope, host: client.host, os: client.os, version: client.version })}`; | |
| 133 | + const timer = setTimeout(() => settle.fail(new CliError("timed out waiting for the browser; run `diffinery login` again")), BROWSER_TIMEOUT_MS); | |
| 134 | + try { | |
| 135 | + const opened = await openBrowser(ctx.io, authorize); | |
| 136 | + ctx.err(opened ? `Opening your browser to sign in to ${ctx.c.bold(new URL(url).host)}…` : `Open this link in a browser on this computer to sign in:`); | |
| 137 | + ctx.err(opened ? ctx.c.dim(`If it didn't open, visit:\n${authorize}`) : authorize); | |
| 138 | + ctx.err(ctx.c.dim("Waiting for you to authorize (Ctrl+C to cancel). No browser here? Run `diffinery login --device`.")); | |
| 139 | + return await done; | |
| 140 | + } finally { | |
| 141 | + clearTimeout(timer); | |
| 142 | + server.closeAllConnections(); | |
| 143 | + server.close(); | |
| 144 | + } | |
| 145 | +} | |
| 146 | + | |
| 147 | +export async function deviceLogin(ctx: Ctx, url: string, scope: Scope): Promise<TokenGrant> { | |
| 148 | + const api = new Api(url, null); | |
| 149 | + const d = await api.post<{ device_code: string; user_code: string; verification_uri: string; expires_in: number; interval: number }>("/-/cli/device", { client: clientInfo(), scope }); | |
| 150 | + ctx.err(`Your one-time code: ${ctx.c.bold(d.user_code)}`); | |
| 151 | + ctx.err(`Open ${ctx.c.bold(d.verification_uri)} in a browser on any device, sign in, and enter the code.`); | |
| 152 | + ctx.err(ctx.c.dim("Waiting for you to approve (Ctrl+C to cancel)…")); | |
| 153 | + let interval = Math.max(1, d.interval) * 1000; | |
| 154 | + const end = Date.now() + d.expires_in * 1000; | |
| 155 | + while (Date.now() < end) { | |
| 156 | + await ctx.io.sleep(interval); | |
| 157 | + const got = await exchange(url, { grant_type: "urn:ietf:params:oauth:grant-type:device_code", device_code: d.device_code }); | |
| 158 | + if (isGrant(got)) return got; | |
| 159 | + if (got.error === "authorization_pending") continue; | |
| 160 | + if (got.error === "slow_down") { | |
| 161 | + interval += 5000; | |
| 162 | + continue; | |
| 163 | + } | |
| 164 | + if (got.error === "access_denied") throw new CliError("the sign-in was cancelled in the browser"); | |
| 165 | + if (got.error === "expired_token") break; | |
| 166 | + throw new CliError(`sign-in failed: ${got.error_description ?? got.error}`); | |
| 167 | + } | |
| 168 | + throw new CliError("the code expired; run `diffinery login --device` again"); | |
| 169 | +} | |
| 170 | + | |
| 171 | +/** `diffinery logout`: a token a CLI sign-in created is revoked with itself. False for any other token, or when the server can't be reached. */ | |
| 172 | +export async function revokeCliToken(url: string, token: string): Promise<boolean> { | |
| 173 | + try { | |
| 174 | + const r = await new Api(url, token).raw<{ revoked?: boolean }>("DELETE", "/-/cli/token", { allow: [401, 403, 404] }); | |
| 175 | + return r.status === 200 && r.data?.revoked === true; | |
| 176 | + } catch { | |
| 177 | + return false; | |
| 178 | + } | |
| 179 | +} |
modifiedapps/cli/src/main.ts+2−1View file
| @@ -7,12 +7,13 @@ | ||
| 7 | 7 | import { repoGroup } from "./commands/repo.js"; |
| 8 | 8 | import { runGroup } from "./commands/run.js"; |
| 9 | 9 | import { runnerCommand } from "./commands/runner.js"; |
| 10 | +import { upgradeCommand } from "./commands/upgrade.js"; | |
| 10 | 11 | import { workflowGroup } from "./commands/workflow.js"; |
| 11 | 12 | import { CliError, processIo, type Io } from "./io.js"; |
| 12 | 13 | |
| 13 | 14 | export const registry: Registry = { |
| 14 | 15 | groups: [authGroup, repoGroup, prGroup, issueGroup, runGroup, workflowGroup, secretGroup, variableGroup, tokenGroup, agentGroup, migrateGroup], |
| 15 | - commands: [...authCommands, ...adminCommands, runnerCommand], | |
| 16 | + commands: [...authCommands, ...adminCommands, runnerCommand, upgradeCommand], | |
| 16 | 17 | }; |
| 17 | 18 | |
| 18 | 19 | /** Runs the CLI and returns its exit code (never calls process.exit). */ |
modifiedapps/cli/test/helpers.ts+5−0View file
| @@ -107,6 +107,9 @@ | ||
| 107 | 107 | cwd?: string; |
| 108 | 108 | stdin?: string; |
| 109 | 109 | stdinTTY?: boolean; |
| 110 | + /** Stands in for the browser when the CLI opens one. */ | |
| 111 | + openUrl?: (url: string) => Promise<boolean>; | |
| 112 | + selfPath?: string; | |
| 110 | 113 | } |
| 111 | 114 | |
| 112 | 115 | /** An isolated environment: HOME/XDG in a temp dir, git with no global config. */ |
| @@ -140,6 +143,8 @@ | ||
| 140 | 143 | stdout: { write: (s: string) => ((stdout += s), true) }, |
| 141 | 144 | stderr: { write: (s: string) => ((stderr += s), true) }, |
| 142 | 145 | sleep: () => new Promise((r) => setTimeout(r, 1)), |
| 146 | + openUrl: o.openUrl, | |
| 147 | + selfPath: o.selfPath, | |
| 143 | 148 | }; |
| 144 | 149 | const code = await main(argv, io); |
| 145 | 150 | return { code, stdout, stderr }; |
addedapps/cli/test/login.test.ts+217−0View file
| @@ -1,0 +1,217 @@ | ||
| 1 | +import { createHash } from "node:crypto"; | |
| 2 | +import { readFileSync, statSync, writeFileSync } from "node:fs"; | |
| 3 | +import path from "node:path"; | |
| 4 | +import { afterEach, beforeEach, describe, expect, it } from "vitest"; | |
| 5 | +import { configPath, writeConfig } from "../src/config.js"; | |
| 6 | +import { fakeServer, isolatedEnv, run, tempDir, type FakeServer } from "./helpers.js"; | |
| 7 | + | |
| 8 | +/** The CLI's side of signing in through the browser; the server's side is tested in apps/diffinery. */ | |
| 9 | +describe("diffinery login in the browser", () => { | |
| 10 | + let srv: FakeServer; | |
| 11 | + beforeEach(async () => { | |
| 12 | + srv = await fakeServer(); | |
| 13 | + srv.on("GET", "/api/v3/user", (r) => (r.headers.authorization === "Bearer dfy_from_browser" ? { body: { login: "alice", type: "User" } } : { status: 401, body: { message: "bad credentials" } })); | |
| 14 | + }); | |
| 15 | + afterEach(() => srv.close()); | |
| 16 | + | |
| 17 | + /** A browser that approves: Diffinery redirects to the CLI's callback with a code and the state it was given. */ | |
| 18 | + const approvingBrowser = (seen: { authorize?: URL; landing?: string }) => async (url: string) => { | |
| 19 | + const u = new URL(url); | |
| 20 | + seen.authorize = u; | |
| 21 | + const callback = `http://127.0.0.1:${u.searchParams.get("port")}/callback?code=the-code&state=${u.searchParams.get("state")}`; | |
| 22 | + // A stray request with the wrong state is ignored, and the real one still works afterwards. | |
| 23 | + expect((await fetch(callback.replace(/state=[^&]+/, "state=forged"), { redirect: "manual" })).status).toBe(400); | |
| 24 | + const res = await fetch(callback, { redirect: "manual" }); | |
| 25 | + seen.landing = res.headers.get("location") ?? ""; | |
| 26 | + return true; | |
| 27 | + }; | |
| 28 | + | |
| 29 | + it("opens the authorize page with a PKCE challenge, trades the code with the verifier, and saves the token", async () => { | |
| 30 | + let verifier = ""; | |
| 31 | + srv.on("POST", "/-/cli/token", (r) => { | |
| 32 | + const b = r.body as Record<string, string>; | |
| 33 | + verifier = b.code_verifier!; | |
| 34 | + return b.grant_type === "authorization_code" && b.code === "the-code" ? { body: { access_token: "dfy_from_browser", token_type: "bearer", scope: "write", expires_at: "2027-01-12T00:00:00Z", login: "alice" } } : { status: 400, body: { error: "invalid_grant" } }; | |
| 35 | + }); | |
| 36 | + const env = isolatedEnv(); | |
| 37 | + const seen: { authorize?: URL; landing?: string } = {}; | |
| 38 | + const r = await run(["login", "--url", srv.url], { env, stdinTTY: true, openUrl: approvingBrowser(seen) }); | |
| 39 | + expect(r.stderr).not.toContain("error"); | |
| 40 | + expect(r.code).toBe(0); | |
| 41 | + const a = seen.authorize!; | |
| 42 | + expect(`${a.origin}${a.pathname}`).toBe(`${srv.url}/login/cli`); | |
| 43 | + expect(a.searchParams.get("code_challenge_method")).toBe("S256"); | |
| 44 | + expect(a.searchParams.get("scope")).toBe("write"); | |
| 45 | + expect(a.searchParams.get("host")).toBeTruthy(); | |
| 46 | + // The verifier never went to the browser; only its hash did. | |
| 47 | + expect(a.searchParams.get("code_challenge")).toBe(createHash("sha256").update(verifier).digest("base64url")); | |
| 48 | + expect(url(a)).not.toContain(verifier); | |
| 49 | + expect(seen.landing).toBe(`${srv.url}/login/cli/done?result=ok`); | |
| 50 | + expect(r.stdout).toContain("Logged in to"); | |
| 51 | + expect(r.stdout).toContain("alice"); | |
| 52 | + expect(r.stdout).toContain("with write access until"); | |
| 53 | + expect(r.stdout).not.toContain("dfy_from_browser"); | |
| 54 | + expect(r.stderr).not.toContain("dfy_from_browser"); | |
| 55 | + expect(JSON.parse(readFileSync(configPath(env), "utf8"))).toEqual({ url: srv.url, token: "dfy_from_browser" }); | |
| 56 | + expect(statSync(configPath(env)).mode & 0o777).toBe(0o600); | |
| 57 | + }); | |
| 58 | + | |
| 59 | + it("reports a cancelled sign-in and saves nothing", async () => { | |
| 60 | + const env = isolatedEnv(); | |
| 61 | + const r = await run(["login", "--url", srv.url, "--web", "--scope", "admin"], { | |
| 62 | + env, | |
| 63 | + openUrl: async (url) => { | |
| 64 | + const u = new URL(url); | |
| 65 | + expect(u.searchParams.get("scope")).toBe("admin"); | |
| 66 | + const res = await fetch(`http://127.0.0.1:${u.searchParams.get("port")}/callback?error=access_denied&state=${u.searchParams.get("state")}`, { redirect: "manual" }); | |
| 67 | + expect(res.headers.get("location")).toBe(`${srv.url}/login/cli/done?result=denied`); | |
| 68 | + return true; | |
| 69 | + }, | |
| 70 | + }); | |
| 71 | + expect(r.code).toBe(1); | |
| 72 | + expect(r.stderr).toContain("cancelled in the browser"); | |
| 73 | + expect(() => readFileSync(configPath(env))).toThrow(); | |
| 74 | + }); | |
| 75 | + | |
| 76 | + it("prints the link when no browser opens", async () => { | |
| 77 | + srv.on("POST", "/-/cli/token", { body: { access_token: "dfy_from_browser", scope: "write", expires_at: "2027-01-12T00:00:00Z", login: "alice" } }); | |
| 78 | + const r = await run(["login", "--url", srv.url, "--web"], { | |
| 79 | + openUrl: async (url) => { | |
| 80 | + const u = new URL(url); | |
| 81 | + // The person copies the link into a browser themselves. | |
| 82 | + setTimeout(() => void fetch(`http://127.0.0.1:${u.searchParams.get("port")}/callback?code=c&state=${u.searchParams.get("state")}`, { redirect: "manual" }), 20); | |
| 83 | + return false; | |
| 84 | + }, | |
| 85 | + }); | |
| 86 | + expect(r.code).toBe(0); | |
| 87 | + expect(r.stderr).toContain("Open this link in a browser on this computer"); | |
| 88 | + expect(r.stderr).toContain(`${srv.url}/login/cli?port=`); | |
| 89 | + }); | |
| 90 | + | |
| 91 | + it("refuses an unknown scope", async () => { | |
| 92 | + const r = await run(["login", "--url", srv.url, "--scope", "root"], { stdinTTY: true }); | |
| 93 | + expect(r.code).toBe(2); | |
| 94 | + }); | |
| 95 | +}); | |
| 96 | + | |
| 97 | +const url = (u: URL) => u.toString(); | |
| 98 | + | |
| 99 | +describe("diffinery login --device", () => { | |
| 100 | + let srv: FakeServer; | |
| 101 | + beforeEach(async () => { | |
| 102 | + srv = await fakeServer(); | |
| 103 | + srv.on("GET", "/api/v3/user", (r) => (r.headers.authorization === "Bearer dfy_from_device" ? { body: { login: "alice", type: "User" } } : { status: 401, body: { message: "bad credentials" } })); | |
| 104 | + srv.on("POST", "/-/cli/device", { body: { device_code: "secret-device-code", user_code: "BCDF-GHJK", verification_uri: `${"https://diffinery.example"}/login/device`, expires_in: 600, interval: 5 } }); | |
| 105 | + }); | |
| 106 | + afterEach(() => srv.close()); | |
| 107 | + | |
| 108 | + it("prints the code and polls until the person approves", async () => { | |
| 109 | + let polls = 0; | |
| 110 | + srv.on("POST", "/-/cli/token", (r) => { | |
| 111 | + expect(r.body).toEqual({ grant_type: "urn:ietf:params:oauth:grant-type:device_code", device_code: "secret-device-code" }); | |
| 112 | + polls++; | |
| 113 | + if (polls === 1) return { status: 400, body: { error: "authorization_pending" } }; | |
| 114 | + if (polls === 2) return { status: 400, body: { error: "slow_down" } }; | |
| 115 | + return { body: { access_token: "dfy_from_device", scope: "read", expires_at: "2027-01-12T00:00:00Z", login: "alice" } }; | |
| 116 | + }); | |
| 117 | + const env = isolatedEnv(); | |
| 118 | + const r = await run(["login", "--url", srv.url, "--device", "--scope", "read"], { env }); | |
| 119 | + expect(r.code).toBe(0); | |
| 120 | + expect(r.stderr).toContain("BCDF-GHJK"); | |
| 121 | + expect(r.stderr).toContain("https://diffinery.example/login/device"); | |
| 122 | + expect(r.stderr).not.toContain("secret-device-code"); | |
| 123 | + expect((srv.find("POST", "/-/cli/device")[0]!.body as { scope: string; client: { host: string } }).scope).toBe("read"); | |
| 124 | + expect(polls).toBe(3); | |
| 125 | + expect(JSON.parse(readFileSync(configPath(env), "utf8")).token).toBe("dfy_from_device"); | |
| 126 | + }); | |
| 127 | + | |
| 128 | + it("is what a terminal without a display gets", async () => { | |
| 129 | + srv.on("POST", "/-/cli/token", { body: { access_token: "dfy_from_device", scope: "write", expires_at: "2027-01-12T00:00:00Z", login: "alice" } }); | |
| 130 | + const r = await run(["login", "--url", srv.url], { env: isolatedEnv({ SSH_CONNECTION: "10.0.0.1 5000 10.0.0.2 22" }), stdinTTY: true }); | |
| 131 | + expect(r.code).toBe(0); | |
| 132 | + expect(r.stderr).toContain("BCDF-GHJK"); | |
| 133 | + }); | |
| 134 | + | |
| 135 | + it("stops when the person cancels", async () => { | |
| 136 | + srv.on("POST", "/-/cli/token", { status: 400, body: { error: "access_denied" } }); | |
| 137 | + const r = await run(["login", "--url", srv.url, "--device"]); | |
| 138 | + expect(r.code).toBe(1); | |
| 139 | + expect(r.stderr).toContain("cancelled in the browser"); | |
| 140 | + }); | |
| 141 | +}); | |
| 142 | + | |
| 143 | +describe("diffinery logout", () => { | |
| 144 | + let srv: FakeServer; | |
| 145 | + beforeEach(async () => { | |
| 146 | + srv = await fakeServer(); | |
| 147 | + }); | |
| 148 | + afterEach(() => srv.close()); | |
| 149 | + | |
| 150 | + it("revokes a CLI sign-in's token on the server, then forgets it", async () => { | |
| 151 | + srv.on("DELETE", "/-/cli/token", { body: { revoked: true } }); | |
| 152 | + const env = isolatedEnv(); | |
| 153 | + await writeConfig(env, { url: srv.url, token: "dfy_cli" }); | |
| 154 | + const r = await run(["logout"], { env }); | |
| 155 | + expect(r.code).toBe(0); | |
| 156 | + expect(r.stdout).toContain("revoked the CLI's token"); | |
| 157 | + expect(srv.find("DELETE", "/-/cli/token")[0]!.headers.authorization).toBe("Bearer dfy_cli"); | |
| 158 | + expect(JSON.parse(readFileSync(configPath(env), "utf8"))).toEqual({ url: srv.url }); | |
| 159 | + }); | |
| 160 | + | |
| 161 | + it("still signs out locally when the server can't revoke it", async () => { | |
| 162 | + srv.on("DELETE", "/-/cli/token", { status: 401, body: { message: "invalid, expired or revoked token" } }); | |
| 163 | + const env = isolatedEnv(); | |
| 164 | + await writeConfig(env, { url: srv.url, token: "dfy_old" }); | |
| 165 | + const r = await run(["logout"], { env }); | |
| 166 | + expect(r.code).toBe(0); | |
| 167 | + expect(r.stdout).toBe(`Logged out of ${srv.url}.\n`); | |
| 168 | + }); | |
| 169 | + | |
| 170 | + it("a new sign-in revokes the CLI token it replaces", async () => { | |
| 171 | + srv.on("DELETE", "/-/cli/token", { body: { revoked: true } }); | |
| 172 | + srv.on("GET", "/api/v3/user", { body: { login: "alice" } }); | |
| 173 | + const env = isolatedEnv(); | |
| 174 | + await writeConfig(env, { url: srv.url, token: "dfy_previous" }); | |
| 175 | + const r = await run(["login", "--url", srv.url, "--token", "dfy_next"], { env }); | |
| 176 | + expect(r.code).toBe(0); | |
| 177 | + expect(srv.find("DELETE", "/-/cli/token").map((q) => q.headers.authorization)).toEqual(["Bearer dfy_previous"]); | |
| 178 | + }); | |
| 179 | +}); | |
| 180 | + | |
| 181 | +describe("diffinery upgrade", () => { | |
| 182 | + let srv: FakeServer; | |
| 183 | + const build = (n: number) => `#!/usr/bin/env node\n// build ${n}\n${"x".repeat(20_000)}\n`; | |
| 184 | + beforeEach(async () => { | |
| 185 | + srv = await fakeServer(); | |
| 186 | + }); | |
| 187 | + afterEach(() => srv.close()); | |
| 188 | + | |
| 189 | + it("replaces the file it runs from when the server has a new build, and says when it's current", async () => { | |
| 190 | + srv.on("GET", "/-/cli/diffinery.mjs", { body: build(2), headers: { "content-type": "text/javascript", "x-diffinery-cli-version": "0.2.0" } }); | |
| 191 | + const self = path.join(tempDir(), "diffinery.mjs"); | |
| 192 | + writeFileSync(self, build(1), { mode: 0o755 }); | |
| 193 | + const env = isolatedEnv({ DIFFINERY_URL: srv.url }); | |
| 194 | + const r = await run(["upgrade"], { env, selfPath: self }); | |
| 195 | + expect(r.stderr).toBe(""); | |
| 196 | + expect(r.code).toBe(0); | |
| 197 | + expect(r.stdout).toContain("Updated diffinery"); | |
| 198 | + expect(readFileSync(self, "utf8")).toBe(build(2)); | |
| 199 | + expect(statSync(self).mode & 0o777).toBe(0o755); | |
| 200 | + const again = await run(["upgrade"], { env, selfPath: self }); | |
| 201 | + expect(again.stdout).toContain("is up to date"); | |
| 202 | + }); | |
| 203 | + | |
| 204 | + it("refuses to replace a source checkout or with something that isn't the CLI", async () => { | |
| 205 | + srv.on("GET", "/-/cli/diffinery.mjs", { body: "<html>maintenance</html>", headers: { "content-type": "text/html" } }); | |
| 206 | + const env = isolatedEnv({ DIFFINERY_URL: srv.url }); | |
| 207 | + const src = path.join(tempDir(), "bin.ts"); | |
| 208 | + writeFileSync(src, "import x from 'y';\n"); | |
| 209 | + expect((await run(["upgrade"], { env, selfPath: src })).stderr).toContain("isn't an installed diffinery CLI"); | |
| 210 | + const self = path.join(tempDir(), "diffinery.mjs"); | |
| 211 | + writeFileSync(self, build(1)); | |
| 212 | + const r = await run(["upgrade"], { env, selfPath: self }); | |
| 213 | + expect(r.code).toBe(1); | |
| 214 | + expect(r.stderr).toContain("doesn't look like the CLI"); | |
| 215 | + expect(readFileSync(self, "utf8")).toBe(build(1)); | |
| 216 | + }); | |
| 217 | +}); |
addedapps/diffinery/docs/accounts/cli-sign-in.md+119−0View file
| @@ -1,0 +1,119 @@ | ||
| 1 | +--- | |
| 2 | +title: Install and sign in to the CLI | |
| 3 | +description: Install the diffinery CLI with one command and sign it in through your browser, or with a code on machines without one. No tokens to copy. | |
| 4 | +--- | |
| 5 | + | |
| 6 | +The `diffinery` command-line client installs with one command and signs in through your browser: you click **Authorize** on diffinery.com and the CLI is signed in, with no token to copy or paste. For every command, see the [CLI reference](/docs/reference/cli). | |
| 7 | + | |
| 8 | +## Install | |
| 9 | + | |
| 10 | +On Linux and macOS: | |
| 11 | + | |
| 12 | +```sh | |
| 13 | +curl -fsSL https://diffinery.com/install | bash | |
| 14 | +``` | |
| 15 | + | |
| 16 | +On Windows, in PowerShell: | |
| 17 | + | |
| 18 | +```powershell | |
| 19 | +powershell -c "irm https://diffinery.com/install.ps1 | iex" | |
| 20 | +``` | |
| 21 | + | |
| 22 | +What the script does: | |
| 23 | + | |
| 24 | +- Puts the CLI in `~/.diffinery` (`%USERPROFILE%\.diffinery` on Windows): the CLI itself in `lib/diffinery.mjs` and the `diffinery` command in `bin/`. | |
| 25 | +- Adds `~/.diffinery/bin` to your `PATH` in your shell's startup file (`~/.zshrc`, `~/.bashrc`, `~/.bash_profile` on macOS, fish's `conf.d`, or `~/.profile`), or to your user `PATH` on Windows. Open a new terminal afterwards, or run the `export PATH=...` line it prints. | |
| 26 | +- Runs the CLI on your Node.js when it's version 22 or later. Otherwise it downloads the current Node.js LTS from `nodejs.org` for the CLI's own use, checks it against the SHA-256 that nodejs.org publishes, and keeps it in `~/.diffinery/node`. Your own Node.js setup isn't touched. On Alpine and other musl-based systems, install Node.js with the package manager (`apk add nodejs`) first. | |
| 27 | +- Prints the version it installed and what to run next. | |
| 28 | + | |
| 29 | +| Variable | Effect | | |
| 30 | +| --- | --- | | |
| 31 | +| `DIFFINERY_INSTALL` | Install somewhere other than `~/.diffinery`. | | |
| 32 | +| `DIFFINERY_NO_MODIFY_PATH=1` | Don't change any startup file or your `PATH`. | | |
| 33 | + | |
| 34 | +The script downloads the CLI from the server it came from, so on your own Diffinery instance use `curl -fsSL https://<your-host>/install | bash`. You can read the script first: open `https://diffinery.com/install` (or `/install.ps1`) in a browser. | |
| 35 | + | |
| 36 | +To update, run `diffinery upgrade`, or run the install command again. To uninstall, delete `~/.diffinery` and the `# Diffinery CLI` lines from your shell's startup file. | |
| 37 | + | |
| 38 | +The CLI is one JavaScript file, so you can also install it by hand next to any Node.js 22 or later: download `https://diffinery.com/-/cli/diffinery.mjs` and run it with `node diffinery.mjs`. | |
| 39 | + | |
| 40 | +## Sign in | |
| 41 | + | |
| 42 | +```sh | |
| 43 | +diffinery login | |
| 44 | +``` | |
| 45 | + | |
| 46 | +1. The CLI opens your browser on **Authorize the Diffinery CLI** at diffinery.com. If you aren't signed in, sign in with your passkey first. If your last sign-in was more than 2 hours ago, Diffinery asks you to confirm it's you with your passkey, as it does before creating any token. | |
| 47 | +2. The page shows the computer asking (its name, operating system and CLI version) and the access it asks for. Check that it's the computer you're on, and pick the **Access**: | |
| 48 | + | |
| 49 | + | Access | Allows | | |
| 50 | + | --- | --- | | |
| 51 | + | **Read** | Clone, and view repositories, issues, pull requests and runs. | | |
| 52 | + | **Write** (default) | Also push, open and merge pull requests, comment, and run workflows (up to the maintain role). | | |
| 53 | + | **Admin** | Everything you can do, including settings, secrets and variables. | | |
| 54 | + | |
| 55 | +3. Click **Authorize**. The browser says "You're signed in", and the terminal prints `✓ Logged in to https://diffinery.com as diffy with write access until Jan 12, 2027`. | |
| 56 | + | |
| 57 | +Click **Cancel** instead and the CLI stops with `error: the sign-in was cancelled in the browser`. | |
| 58 | + | |
| 59 | +To ask for admin access up front (for `diffinery secret set`, for example), run `diffinery login --scope admin`. A command that needs more access than you signed in with says which `diffinery login --scope ...` to run. | |
| 60 | + | |
| 61 | +Then let Git use the same sign-in for `git clone`, `fetch` and `push` to diffinery.com: | |
| 62 | + | |
| 63 | +```sh | |
| 64 | +diffinery auth setup-git | |
| 65 | +``` | |
| 66 | + | |
| 67 | +### On a machine without a browser | |
| 68 | + | |
| 69 | +Over SSH, in a container or on any machine without a display, sign in with a code: | |
| 70 | + | |
| 71 | +```sh | |
| 72 | +diffinery login --device | |
| 73 | +``` | |
| 74 | + | |
| 75 | +```text | |
| 76 | +Your one-time code: WDJB-MJHT | |
| 77 | +Open https://diffinery.com/login/device in a browser on any device, sign in, and enter the code. | |
| 78 | +Waiting for you to approve (Ctrl+C to cancel)… | |
| 79 | +``` | |
| 80 | + | |
| 81 | +Open `https://diffinery.com/login/device` on your laptop or phone, enter the code, check the details (the computer's name, and where and when the code was asked for), and click **Authorize**. The CLI finishes on its own within a few seconds. The code works for 10 minutes. The CLI uses this way by itself when it sees no display (no `DISPLAY` or `WAYLAND_DISPLAY` on Linux, or an SSH session); `--web` forces the browser way. | |
| 82 | + | |
| 83 | +### Scripts and CI | |
| 84 | + | |
| 85 | +For scripts and CI, use a [personal access token](/docs/accounts/tokens) instead: | |
| 86 | + | |
| 87 | +```sh | |
| 88 | +export DIFFINERY_TOKEN=dfy_... # used by every command, nothing saved | |
| 89 | +diffinery login --token dfy_... # or save it in the config file | |
| 90 | +echo "$TOKEN" | diffinery login --with-token | |
| 91 | +``` | |
| 92 | + | |
| 93 | +## Your CLI sign-ins | |
| 94 | + | |
| 95 | +Each sign-in gives the CLI its own token, named after the computer (for example **Diffinery CLI on diffys-laptop**). It's listed under **Settings → Access tokens** with a **CLI sign-in** badge, and Diffinery emails you about it like any new token. | |
| 96 | + | |
| 97 | +- It lasts 90 days. Diffinery emails you before it expires; run `diffinery login` again to get a new one. | |
| 98 | +- `diffinery logout` revokes it on the server and removes it from the computer. Signing in again on the same computer revokes the token it replaces. | |
| 99 | +- Revoke it under **Settings → Access tokens** to sign a lost or old computer out. | |
| 100 | + | |
| 101 | +The CLI saves the server URL and the token in `~/.config/diffinery/config.json` (or under `$XDG_CONFIG_HOME`), readable only by you. `DIFFINERY_TOKEN` and `DIFFINERY_URL` override the saved values. | |
| 102 | + | |
| 103 | +## How it stays safe | |
| 104 | + | |
| 105 | +- **No secret in a link.** The browser sign-in uses a one-time code that Diffinery sends only to the CLI on your own computer (`http://127.0.0.1:<port>`), and it works only together with a secret the CLI never lets out of its process (PKCE, as in OAuth for native apps). A sign-in link someone sends you can't sign their computer in as you: the code would go to your own computer. | |
| 106 | +- **Codes are typed, never linked.** A device code is never part of a URL, so nobody can send you a link that approves their code. Only approve a code that your own terminal shows. If someone asks you to enter a code for them, don't. | |
| 107 | +- **Short-lived and single-use.** Browser codes expire after 2 minutes and device codes after 10, each works once, and Diffinery stores only their hashes. The token is created when the CLI collects it and is never shown in the browser. | |
| 108 | +- **The same rules as tokens.** Approving needs a passkey sign-in from the last 2 hours, sign-ins are in the audit log (`token.create`), and a CLI token is limited by the access you chose like any token. | |
| 109 | + | |
| 110 | +## Troubleshooting | |
| 111 | + | |
| 112 | +| Problem | What to do | | |
| 113 | +| --- | --- | | |
| 114 | +| `command not found: diffinery` after installing | Open a new terminal, or run the `export PATH=...` line the installer printed. | | |
| 115 | +| The browser doesn't open | Copy the link the CLI prints into a browser on the same computer, or use `diffinery login --device`. | | |
| 116 | +| "This sign-in link is incomplete" | The page was opened without the CLI's details. Run `diffinery login` again and use the page it opens. | | |
| 117 | +| "That code isn't valid or has expired" | Check the code in your terminal. Codes last 10 minutes; run `diffinery login --device` again for a new one. | | |
| 118 | +| `error: diffinery needs Node.js 22 or later` | The `node` on your `PATH` is older. Run the install command again: it installs a Node.js for the CLI. | | |
| 119 | +| `403 this token needs the admin scope` | Run `diffinery login --scope admin` and choose **Admin** on the authorize page. | |
modifiedapps/diffinery/docs/accounts/invitations.md+2−2View file
| @@ -97,8 +97,8 @@ | ||
| 97 | 97 | `diffinery invite` invites to an organization, or to the instance when you leave out `--org` (site administrators only). You need to be logged in (`diffinery login`) with a token that has the `admin` scope and isn't restricted to repositories; a `read` or `write` token gets `403 this token needs the admin scope`. If you don't have the CLI yet: |
| 98 | 98 | |
| 99 | 99 | ```sh |
| 100 | -curl -fsSL https://diffinery.com/-/cli/diffinery.mjs -o ~/.local/bin/diffinery && chmod +x ~/.local/bin/diffinery | |
| 101 | -diffinery login | |
| 100 | +curl -fsSL https://diffinery.com/install | bash | |
| 101 | +diffinery login --scope admin | |
| 102 | 102 | ``` |
| 103 | 103 | |
| 104 | 104 | The CLI needs Node.js 22 or later. |
modifiedapps/diffinery/docs/accounts/passkeys-and-sign-in.md+1−1View file
| @@ -5,7 +5,7 @@ | ||
| 5 | 5 | |
| 6 | 6 | People sign in to Diffinery in the browser with a passkey (WebAuthn). There are no passwords. This page covers signing in, joining from an invite or through open sign-up, adding and removing passkeys, managing signed-in browsers, the security emails you get, and how to get back in when you lose a passkey. |
| 7 | 7 | |
| 8 | -Passkeys are only for the web UI. Git, the `diffinery` CLI, scripts and MCP clients use personal access tokens instead (see [Personal access tokens](/docs/accounts/tokens)). For your username, display name, profile picture and light or dark theme, see [Profile and preferences](/docs/accounts/profile-and-preferences). | |
| 8 | +Passkeys are only for the web UI. Git, the `diffinery` CLI, scripts and MCP clients use personal access tokens instead (see [Personal access tokens](/docs/accounts/tokens)); the CLI gets its own when you approve `diffinery login` in the browser (see [Install and sign in to the CLI](/docs/accounts/cli-sign-in)). For your username, display name, profile picture and light or dark theme, see [Profile and preferences](/docs/accounts/profile-and-preferences). | |
| 9 | 9 | |
| 10 | 10 | ## How passkey sign-in works |
| 11 | 11 |
modifiedapps/diffinery/docs/accounts/tokens.md+4−4View file
| @@ -83,10 +83,10 @@ | ||
| 83 | 83 | |
| 84 | 84 | ### With the CLI |
| 85 | 85 | |
| 86 | -Install the CLI (needs Node.js 22 or later) and log in with an existing token: | |
| 86 | +You don't need to create a token for the CLI itself: `diffinery login` signs it in through your browser and creates its own token (see [Install and sign in to the CLI](/docs/accounts/cli-sign-in)). Install it with: | |
| 87 | 87 | |
| 88 | 88 | ```sh |
| 89 | -curl -fsSL https://diffinery.com/-/cli/diffinery.mjs -o ~/.local/bin/diffinery && chmod +x ~/.local/bin/diffinery | |
| 89 | +curl -fsSL https://diffinery.com/install | bash | |
| 90 | 90 | diffinery login |
| 91 | 91 | ``` |
| 92 | 92 | |
| @@ -210,14 +210,14 @@ | ||
| 210 | 210 | |
| 211 | 211 | ### The CLI |
| 212 | 212 | |
| 213 | -`diffinery login` saves the server URL and token in `~/.config/diffinery/config.json` (or under `$XDG_CONFIG_HOME`). The environment variables `DIFFINERY_TOKEN` and `DIFFINERY_URL` override the saved values, which is handy in CI: | |
| 213 | +`diffinery login` signs the CLI in through your browser and saves the server URL and the token it gets in `~/.config/diffinery/config.json` (or under `$XDG_CONFIG_HOME`). Those tokens are listed here with a **CLI sign-in** badge and named after the computer; see [Install and sign in to the CLI](/docs/accounts/cli-sign-in). `diffinery login --token dfy_...` saves a token you created instead. The environment variables `DIFFINERY_TOKEN` and `DIFFINERY_URL` override the saved values, which is handy in CI: | |
| 214 | 214 | |
| 215 | 215 | ```sh |
| 216 | 216 | export DIFFINERY_TOKEN=dfy_... |
| 217 | 217 | diffinery whoami |
| 218 | 218 | ``` |
| 219 | 219 | |
| 220 | -`diffinery logout` removes the saved token. See [CLI reference](/docs/reference/cli). | |
| 220 | +`diffinery logout` removes the saved token, and revokes it when a CLI sign-in created it (a token you pasted may be in use elsewhere, so it's left alone). See [CLI reference](/docs/reference/cli). | |
| 221 | 221 | |
| 222 | 222 | ### MCP clients |
| 223 | 223 |
modifiedapps/diffinery/docs/actions/caching-and-artifacts.md+1−1View file
| @@ -493,7 +493,7 @@ | ||
| 493 | 493 | https://diffinery.com/api/v3/repos/acme/website/actions/artifacts/1187/zip -o site.zip |
| 494 | 494 | ``` |
| 495 | 495 | |
| 496 | -Install the CLI with `curl -fsSL https://diffinery.com/-/cli/diffinery.mjs -o ~/.local/bin/diffinery && chmod +x ~/.local/bin/diffinery` (Node.js 22 or later), then list a run's artifacts: | |
| 496 | +Install the CLI with `curl -fsSL https://diffinery.com/install | bash` and sign in with `diffinery login`, then list a run's artifacts: | |
| 497 | 497 | |
| 498 | 498 | ```sh |
| 499 | 499 | diffinery api GET /repos/acme/website/actions/runs/128/artifacts |
modifiedapps/diffinery/docs/actions/overview.md+2−2View file
| @@ -72,10 +72,10 @@ | ||
| 72 | 72 | |
| 73 | 73 | When the job finishes, the commit shows a check named `CI / test`. On a pull request, the same check appears on the pull request's head commit. |
| 74 | 74 | |
| 75 | -If you don't have the CLI yet, install it (needs Node.js 22 or later) and log in with a token: | |
| 75 | +If you don't have the CLI yet, install it and sign in through your browser: | |
| 76 | 76 | |
| 77 | 77 | ```sh |
| 78 | -curl -fsSL https://diffinery.com/-/cli/diffinery.mjs -o ~/.local/bin/diffinery && chmod +x ~/.local/bin/diffinery | |
| 78 | +curl -fsSL https://diffinery.com/install | bash | |
| 79 | 79 | diffinery login |
| 80 | 80 | ``` |
| 81 | 81 |
modifiedapps/diffinery/docs/actions/runners.md+1−1View file
| @@ -168,7 +168,7 @@ | ||
| 168 | 168 | - **The `diffinery` CLI.** Install it with: |
| 169 | 169 | |
| 170 | 170 | ```sh |
| 171 | - curl -fsSL https://diffinery.com/-/cli/diffinery.mjs -o ~/.local/bin/diffinery && chmod +x ~/.local/bin/diffinery | |
| 171 | + curl -fsSL https://diffinery.com/install | bash | |
| 172 | 172 | ``` |
| 173 | 173 | |
| 174 | 174 | - **The runner agent**, a single Node.js file named `agent.mjs`. Build it from a checkout of the Diffinery source: |
modifiedapps/diffinery/docs/actions/secrets-and-variables.md+3−2View file
| @@ -37,10 +37,11 @@ | ||
| 37 | 37 | |
| 38 | 38 | ### Secrets with the CLI |
| 39 | 39 | |
| 40 | -The CLI needs a token from a person with admin access to the repository. Install it with: | |
| 40 | +The CLI needs to act as a person with admin access to the repository, with the `admin` scope. Install it and sign in: | |
| 41 | 41 | |
| 42 | 42 | ```sh |
| 43 | -curl -fsSL https://diffinery.com/-/cli/diffinery.mjs -o ~/.local/bin/diffinery && chmod +x ~/.local/bin/diffinery | |
| 43 | +curl -fsSL https://diffinery.com/install | bash | |
| 44 | +diffinery login --scope admin | |
| 44 | 45 | ``` |
| 45 | 46 | |
| 46 | 47 | The value comes from `--body`, from stdin when piped, or from a hidden prompt: |
modifiedapps/diffinery/docs/actions/workflow-permissions.md+1−1View file
| @@ -162,7 +162,7 @@ | ||
| 162 | 162 | -d '{"default_workflow_permissions": "write", "can_approve_pull_request_reviews": true}' |
| 163 | 163 | ``` |
| 164 | 164 | |
| 165 | -With the CLI (install it with `curl -fsSL https://diffinery.com/-/cli/diffinery.mjs -o ~/.local/bin/diffinery && chmod +x ~/.local/bin/diffinery`; it needs Node.js 22 or later): | |
| 165 | +With the CLI (install it with `curl -fsSL https://diffinery.com/install | bash`, then `diffinery login`): | |
| 166 | 166 | |
| 167 | 167 | ```sh |
| 168 | 168 | diffinery api GET /repos/acme/website/actions/permissions/workflow |
modifiedapps/diffinery/docs/admin/site-administration.md+2−2View file
| @@ -436,10 +436,10 @@ | ||
| 436 | 436 | |
| 437 | 437 | ## Administration from the CLI |
| 438 | 438 | |
| 439 | -The CLI (needs Node.js 22 or later) covers invites and, through `diffinery api`, any API endpoint: | |
| 439 | +The CLI covers invites and, through `diffinery api`, any API endpoint: | |
| 440 | 440 | |
| 441 | 441 | ```sh |
| 442 | -curl -fsSL https://diffinery.com/-/cli/diffinery.mjs -o ~/.local/bin/diffinery && chmod +x ~/.local/bin/diffinery | |
| 442 | +curl -fsSL https://diffinery.com/install | bash | |
| 443 | 443 | diffinery login |
| 444 | 444 | |
| 445 | 445 | # Invite someone to the instance (site administrators only) |
modifiedapps/diffinery/docs/agents/overview.md+2−2View file
| @@ -36,8 +36,8 @@ | ||
| 36 | 36 | Creating an agent mints its first token, and no token can create a token, so on diffinery.com create agents in the web UI (above). `diffinery agent create` works against a local development server. Listing and disabling agents work from the CLI with a personal token that has the `admin` scope and isn't restricted to repositories: |
| 37 | 37 | |
| 38 | 38 | ```sh |
| 39 | -curl -fsSL https://diffinery.com/-/cli/diffinery.mjs -o ~/.local/bin/diffinery && chmod +x ~/.local/bin/diffinery | |
| 40 | -diffinery login | |
| 39 | +curl -fsSL https://diffinery.com/install | bash | |
| 40 | +diffinery login --scope admin | |
| 41 | 41 | ``` |
| 42 | 42 | |
| 43 | 43 | On a local development server, create the agent like this. The command also creates its first token and prints it once: |
modifiedapps/diffinery/docs/collaboration/issues.md+2−2View file
| @@ -61,10 +61,10 @@ | ||
| 61 | 61 | |
| 62 | 62 | ### Open an issue with the CLI |
| 63 | 63 | |
| 64 | -Install the CLI (needs Node.js 22 or later) and log in with a token: | |
| 64 | +Install the CLI and sign in through your browser: | |
| 65 | 65 | |
| 66 | 66 | ```sh |
| 67 | -curl -fsSL https://diffinery.com/-/cli/diffinery.mjs -o ~/.local/bin/diffinery && chmod +x ~/.local/bin/diffinery | |
| 67 | +curl -fsSL https://diffinery.com/install | bash | |
| 68 | 68 | diffinery login |
| 69 | 69 | ``` |
| 70 | 70 |
modifiedapps/diffinery/docs/collaboration/pull-requests.md+2−2View file
| @@ -64,10 +64,10 @@ | ||
| 64 | 64 | |
| 65 | 65 | ### With the CLI |
| 66 | 66 | |
| 67 | -Install the CLI (needs Node.js 22 or later) and log in: | |
| 67 | +Install the CLI and sign in through your browser: | |
| 68 | 68 | |
| 69 | 69 | ```sh |
| 70 | -curl -fsSL https://diffinery.com/-/cli/diffinery.mjs -o ~/.local/bin/diffinery && chmod +x ~/.local/bin/diffinery | |
| 70 | +curl -fsSL https://diffinery.com/install | bash | |
| 71 | 71 | diffinery login |
| 72 | 72 | ``` |
| 73 | 73 |
modifiedapps/diffinery/docs/collaboration/releases.md+1−1View file
| @@ -160,7 +160,7 @@ | ||
| 160 | 160 | |
| 161 | 161 | Creating a published release whose tag does not exist yet also creates the tag, which sends a `push` event and starts `on: push` workflows that match the tag, exactly as if you had pushed it. |
| 162 | 162 | |
| 163 | -The same request with the CLI (install it with `curl -fsSL https://diffinery.com/-/cli/diffinery.mjs -o ~/.local/bin/diffinery && chmod +x ~/.local/bin/diffinery`, which needs Node.js 22 or later): | |
| 163 | +The same request with the CLI (install it with `curl -fsSL https://diffinery.com/install | bash`, then `diffinery login`): | |
| 164 | 164 | |
| 165 | 165 | ```sh |
| 166 | 166 | cat > release.json <<'EOF' |
modifiedapps/diffinery/docs/migrate-from-github.md+3−3View file
| @@ -164,11 +164,11 @@ | ||
| 164 | 164 | |
| 165 | 165 | ## Import with the CLI |
| 166 | 166 | |
| 167 | -Install the CLI (it needs Node.js 22 or later) and log in with a `write` or `admin` token: | |
| 167 | +Install the CLI and sign in (approve the sign-in in your browser; `--scope admin` if you'll also set secrets): | |
| 168 | 168 | |
| 169 | 169 | ```sh |
| 170 | -curl -fsSL https://diffinery.com/-/cli/diffinery.mjs -o ~/.local/bin/diffinery && chmod +x ~/.local/bin/diffinery | |
| 171 | -diffinery login # paste the dfy_ token | |
| 170 | +curl -fsSL https://diffinery.com/install | bash | |
| 171 | +diffinery login # opens your browser to authorize | |
| 172 | 172 | ``` |
| 173 | 173 | |
| 174 | 174 | ### diffinery migrate github |
modifiedapps/diffinery/docs/quickstart.md+33−26View file
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | --- |
| 2 | 2 | title: Quickstart |
| 3 | -description: Go from an invite to a working Diffinery repository: passkey, token, CLI, first push, workflow run, pull request, release and an AI agent over MCP. | |
| 3 | +description: Go from an invite to a working Diffinery repository: passkey, CLI sign-in, first push, workflow run, pull request, release and an AI agent over MCP. | |
| 4 | 4 | --- |
| 5 | 5 | |
| 6 | 6 | This guide takes you from an invite email to a repository with code, a passing workflow, a merged pull request, a first release and an AI agent connected over MCP. It takes about 15 minutes. Each step shows the web UI and, where it exists, the `diffinery` CLI and the REST API. |
| @@ -11,7 +11,6 @@ | ||
| 11 | 11 | |
| 12 | 12 | - A browser that supports passkeys (current Chrome, Safari, Edge or Firefox). |
| 13 | 13 | - Git 2.26 or newer (`git --version`). |
| 14 | -- Node.js 22 or later, for the `diffinery` CLI (`node --version`). | |
| 15 | 14 | - An invite to Diffinery. Diffinery is invite-only: a site administrator, an organization owner or admin, or a repository admin sends it. On an instance with [open sign-up](/docs/accounts/passkeys-and-sign-in#open-sign-up), create an account at `/signup` instead. |
| 16 | 15 | |
| 17 | 16 | ## 1. Join with your invite and create a passkey |
| @@ -26,44 +25,52 @@ | ||
| 26 | 25 | |
| 27 | 26 | > **Note:** Add a second passkey on another device, or keep the passkey in a password manager that syncs, under **Settings → Passkeys and sessions**. See [Passkeys and sign-in](/docs/accounts/passkeys-and-sign-in). |
| 28 | 27 | |
| 29 | -## 2. Create a token | |
| 28 | +## 2. Install the CLI and sign in | |
| 30 | 29 | |
| 31 | -Git, the CLI, the REST API and MCP clients authenticate with a personal access token, not a passkey. | |
| 30 | +Install the `diffinery` command-line client. On Linux and macOS: | |
| 32 | 31 | |
| 33 | -1. Go to **Settings → Access tokens** (`https://diffinery.com/settings/tokens`). | |
| 34 | -2. Under **Create a token**, enter a **Name** (for example `laptop`), keep **Expires in (days)** at 90 (1 to 366), and pick a **Scope**: | |
| 32 | +```sh | |
| 33 | +curl -fsSL https://diffinery.com/install | bash | |
| 34 | +``` | |
| 35 | 35 | |
| 36 | - | Scope | Allows | | |
| 37 | - | --- | --- | | |
| 38 | - | **Read** | Clone, fetch and view only. Every change is refused (`this token needs the write scope`). | | |
| 39 | - | **Write** (default) | Push, issues, pull requests, releases, runs and creating repositories (up to the maintain role). | | |
| 40 | - | **Admin** | Everything you can do, including repository settings and secrets, and creating organizations, agents, invitations and other tokens. | | |
| 36 | +On Windows, in PowerShell: | |
| 41 | 37 | |
| 42 | - For this guide, keep **Write**. Leave **Restrict to repositories** empty. | |
| 43 | -3. Click **Create token** and copy the token. It starts with `dfy_` and is shown only once. | |
| 38 | +```powershell | |
| 39 | +powershell -c "irm https://diffinery.com/install.ps1 | iex" | |
| 40 | +``` | |
| 44 | 41 | |
| 45 | -Diffinery emails you whenever a token is created on your account. See [Personal access tokens](/docs/accounts/tokens). | |
| 42 | +The script puts the CLI in `~/.diffinery` and adds it to your `PATH` (open a new terminal afterwards). It uses your Node.js 22 or later when you have one, and otherwise installs its own copy. See [Install and sign in to the CLI](/docs/accounts/cli-sign-in). | |
| 46 | 43 | |
| 47 | -## 3. Install the CLI and log in | |
| 44 | +Sign in through your browser and let Git use the same sign-in: | |
| 48 | 45 | |
| 49 | -Install the `diffinery` command-line client (needs Node.js 22 or later; make sure `~/.local/bin` exists and is on your `PATH`): | |
| 50 | - | |
| 51 | 46 | ```sh |
| 52 | -curl -fsSL https://diffinery.com/-/cli/diffinery.mjs -o ~/.local/bin/diffinery && chmod +x ~/.local/bin/diffinery | |
| 47 | +diffinery login # opens diffinery.com; click Authorize | |
| 48 | +diffinery whoami # prints diffy | |
| 49 | +diffinery auth setup-git # git clone/fetch/push to diffinery.com now use it | |
| 53 | 50 | ``` |
| 54 | 51 | |
| 55 | -Log in with the token and let Git use it too: | |
| 52 | +`diffinery login` opens an **Authorize the Diffinery CLI** page showing your computer's name. Check it, keep **Write** access for this guide, and click **Authorize**. The terminal prints `✓ Logged in to https://diffinery.com as diffy`. No token to copy: the CLI gets its own, valid for 90 days and listed under **Settings → Access tokens**, and `diffinery logout` revokes it. On a machine without a browser (over SSH, for example), `diffinery login --device` prints a code to enter at `https://diffinery.com/login/device` from any device. | |
| 56 | 53 | |
| 57 | -```sh | |
| 58 | -diffinery login # paste the dfy_ token when asked | |
| 59 | -diffinery whoami # prints diffy | |
| 60 | -diffinery auth setup-git # git clone/fetch/push to diffinery.com now use the token | |
| 61 | -``` | |
| 54 | +## 3. Tokens for scripts and CI | |
| 62 | 55 | |
| 63 | -`diffinery login` saves the server URL and token to `~/.config/diffinery/config.json` (readable only by you). The `DIFFINERY_TOKEN` and `DIFFINERY_URL` environment variables override it, which is handy in CI. See [CLI reference](/docs/reference/cli). | |
| 56 | +Scripts, CI and plain Git without the CLI authenticate with a personal access token. | |
| 64 | 57 | |
| 65 | -Without the CLI, Git asks for a username and password: enter any username and the token as the password. | |
| 58 | +1. Go to **Settings → Access tokens** (`https://diffinery.com/settings/tokens`). | |
| 59 | +2. Under **Create a token**, enter a **Name** (for example `ci`), keep **Expires in (days)** at 90 (1 to 366), and pick a **Scope**: | |
| 66 | 60 | |
| 61 | + | Scope | Allows | | |
| 62 | + | --- | --- | | |
| 63 | + | **Read** | Clone, fetch and view only. Every change is refused (`this token needs the write scope`). | | |
| 64 | + | **Write** (default) | Push, issues, pull requests, releases, runs and creating repositories (up to the maintain role). | | |
| 65 | + | **Admin** | Everything you can do, including repository settings and secrets, and creating organizations, agents, invitations and other tokens. | | |
| 66 | + | |
| 67 | + Leave **Restrict to repositories** empty, or pick the repositories the script needs. | |
| 68 | +3. Click **Create token** and copy the token. It starts with `dfy_` and is shown only once. | |
| 69 | + | |
| 70 | +Diffinery emails you whenever a token is created on your account, including by a CLI sign-in. See [Personal access tokens](/docs/accounts/tokens). | |
| 71 | + | |
| 72 | +The `DIFFINERY_TOKEN` and `DIFFINERY_URL` environment variables give the CLI a token without signing in, which is handy in CI; `diffinery login --token dfy_...` saves one. Without the CLI, Git asks for a username and password: enter any username and the token as the password. | |
| 73 | + | |
| 67 | 74 | ## 4. Create a repository |
| 68 | 75 | |
| 69 | 76 | Pick one of these. |
modifiedapps/diffinery/docs/reference/rest-api.md+3−3View file
| @@ -837,11 +837,11 @@ | ||
| 837 | 837 | |
| 838 | 838 | ### The Diffinery CLI |
| 839 | 839 | |
| 840 | -The `diffinery` CLI wraps the API with commands for common tasks (`diffinery pr list`, `diffinery issue create`, `diffinery run watch`) and has a raw `api` command for anything else. It needs Node.js 22 or later: | |
| 840 | +The `diffinery` CLI wraps the API with commands for common tasks (`diffinery pr list`, `diffinery issue create`, `diffinery run watch`) and has a raw `api` command for anything else: | |
| 841 | 841 | |
| 842 | 842 | ```sh |
| 843 | -curl -fsSL https://diffinery.com/-/cli/diffinery.mjs -o ~/.local/bin/diffinery && chmod +x ~/.local/bin/diffinery | |
| 844 | -diffinery login --token dfy_... | |
| 843 | +curl -fsSL https://diffinery.com/install | bash | |
| 844 | +diffinery login # in a browser; scripts use --token dfy_... or DIFFINERY_TOKEN | |
| 845 | 845 | ``` |
| 846 | 846 | |
| 847 | 847 | `diffinery api METHOD PATH` sends an authenticated request and prints the JSON response. `PATH` is relative to `/api/v3`. `-f key=value` adds a string field to the JSON body (or to the query string for `GET`), and `--input FILE` (or `-` for stdin) sends a JSON body from a file. It exits with status 1 on any `4xx` or `5xx` response. |
modifiedapps/diffinery/docs/repositories/branch-protection.md+1−1View file
| @@ -267,7 +267,7 @@ | ||
| 267 | 267 | |
| 268 | 268 | ### Create a rule with the CLI |
| 269 | 269 | |
| 270 | -The `diffinery api` command sends an authenticated request (install the CLI with `curl -fsSL https://diffinery.com/-/cli/diffinery.mjs -o ~/.local/bin/diffinery && chmod +x ~/.local/bin/diffinery`, which needs Node.js 22 or later): | |
| 270 | +The `diffinery api` command sends an authenticated request (install the CLI with `curl -fsSL https://diffinery.com/install | bash`, then `diffinery login`): | |
| 271 | 271 | |
| 272 | 272 | ```sh |
| 273 | 273 | cat > rules.json <<'EOF' |
modifiedapps/diffinery/docs/repositories/git.md+3−3View file
| @@ -48,16 +48,16 @@ | ||
| 48 | 48 | |
| 49 | 49 | ### Let the CLI handle credentials |
| 50 | 50 | |
| 51 | -The `diffinery` CLI can act as Git's credential helper for the Diffinery host, so plain `git clone`, `fetch` and `push` use the token you logged in with. Install the CLI (needs Node.js 22 or later): | |
| 51 | +The `diffinery` CLI can act as Git's credential helper for the Diffinery host, so plain `git clone`, `fetch` and `push` use the token you logged in with. Install the CLI: | |
| 52 | 52 | |
| 53 | 53 | ```sh |
| 54 | -curl -fsSL https://diffinery.com/-/cli/diffinery.mjs -o ~/.local/bin/diffinery && chmod +x ~/.local/bin/diffinery | |
| 54 | +curl -fsSL https://diffinery.com/install | bash | |
| 55 | 55 | ``` |
| 56 | 56 | |
| 57 | 57 | Then: |
| 58 | 58 | |
| 59 | 59 | ```sh |
| 60 | -diffinery login # paste a dfy_ token; saved to ~/.config/diffinery/config.json (mode 0600) | |
| 60 | +diffinery login # authorize in your browser; the token is saved to ~/.config/diffinery/config.json (mode 0600) | |
| 61 | 61 | diffinery auth setup-git # configure git to ask diffinery for credentials |
| 62 | 62 | git clone https://diffinery.com/acme/website.git |
| 63 | 63 | ``` |
modifiedapps/diffinery/docs/repositories/overview.md+3−3View file
| @@ -41,13 +41,13 @@ | ||
| 41 | 41 | |
| 42 | 42 | ### With the CLI |
| 43 | 43 | |
| 44 | -The CLI needs a token (`diffinery login`). Install it with: | |
| 44 | +Install the CLI and sign in with `diffinery login`: | |
| 45 | 45 | |
| 46 | 46 | ```sh |
| 47 | -curl -fsSL https://diffinery.com/-/cli/diffinery.mjs -o ~/.local/bin/diffinery && chmod +x ~/.local/bin/diffinery | |
| 47 | +curl -fsSL https://diffinery.com/install | bash | |
| 48 | 48 | ``` |
| 49 | 49 | |
| 50 | -It needs Node.js 22 or later. Then: | |
| 50 | +Then: | |
| 51 | 51 | |
| 52 | 52 | ```sh |
| 53 | 53 | # In your own account, private by default |
modifiedapps/diffinery/docs/repositories/webhooks.md+1−1View file
| @@ -94,7 +94,7 @@ | ||
| 94 | 94 | |
| 95 | 95 | When you send `config.secret`, the response has no `secret` field: store your own copy. Webhook ids are 32-character hexadecimal strings. Other GitHub fields such as `name`, `active` and `config.content_type` are ignored on create. Payloads are always JSON. Event names are not validated: a name that is not in the table above never matches anything. |
| 96 | 96 | |
| 97 | -The same request with the CLI (install it with `curl -fsSL https://diffinery.com/-/cli/diffinery.mjs -o ~/.local/bin/diffinery && chmod +x ~/.local/bin/diffinery`, which needs Node.js 22 or later): | |
| 97 | +The same request with the CLI (install it with `curl -fsSL https://diffinery.com/install | bash`, then `diffinery login`): | |
| 98 | 98 | |
| 99 | 99 | ```sh |
| 100 | 100 | cat > hook.json <<'EOF' |
addedapps/diffinery/migrations/0034_cli_logins.sql+29−0View file
| @@ -1,0 +1,29 @@ | ||
| 1 | +-- Signing the CLI in through the browser (auth/cli-login.ts). A browser sign-in is approved on | |
| 2 | +-- /login/cli and hands the CLI's local callback a one-time code; a device sign-in is a code the | |
| 3 | +-- person types on /login/device while the CLI waits. Either way the CLI gets its token only by | |
| 4 | +-- trading the secret it kept (the PKCE verifier or the device code) at /-/cli/token, once. | |
| 5 | +-- Only hashes of the secrets are stored. Rows are deleted a day after they expire. | |
| 6 | +CREATE TABLE IF NOT EXISTS cli_logins ( | |
| 7 | + id TEXT PRIMARY KEY, | |
| 8 | + flow TEXT NOT NULL CHECK (flow IN ('browser', 'device')), | |
| 9 | + secret_hash TEXT NOT NULL UNIQUE, -- browser: the one-time code; device: the device code | |
| 10 | + user_code TEXT UNIQUE, -- device: what the person types, without the dash | |
| 11 | + code_challenge TEXT, -- browser: the PKCE S256 challenge | |
| 12 | + client TEXT NOT NULL, -- JSON {host, os, version} as the CLI reported them | |
| 13 | + requested_scope TEXT NOT NULL, | |
| 14 | + ip TEXT, -- device: where the CLI asked from | |
| 15 | + location TEXT, | |
| 16 | + status TEXT NOT NULL CHECK (status IN ('pending', 'approved', 'denied', 'used')), | |
| 17 | + account_id TEXT, -- who approved or denied it | |
| 18 | + scope TEXT, -- the scope they chose | |
| 19 | + created_at TEXT NOT NULL, | |
| 20 | + expires_at TEXT NOT NULL, | |
| 21 | + decided_at TEXT, | |
| 22 | + used_at TEXT, | |
| 23 | + token_id TEXT | |
| 24 | +); | |
| 25 | +CREATE INDEX IF NOT EXISTS cli_logins_expires ON cli_logins(expires_at); | |
| 26 | + | |
| 27 | +-- 'cli' marks a token a CLI sign-in created: listed as such in Settings → Access tokens, and | |
| 28 | +-- `diffinery logout` may revoke it with itself. | |
| 29 | +ALTER TABLE tokens ADD COLUMN client TEXT; |
modifiedapps/diffinery/scripts/build-docs.mjs+9−9View file
| @@ -1,7 +1,8 @@ | ||
| 1 | 1 | // Builds the public documentation into src/docs/content.generated.ts: |
| 2 | 2 | // - every Markdown page under docs/ (with its frontmatter), in the order of docs/nav.json |
| 3 | 3 | // - the CLI reference, generated from the CLI's own command registry |
| 4 | -// - the CLI itself as one file, served at /-/cli/diffinery.mjs | |
| 4 | +// - the CLI itself as one file, served at /-/cli/diffinery.mjs, and its install scripts | |
| 5 | +// (apps/cli/install), served at /install and /install.ps1 | |
| 5 | 6 | // The REST API and MCP tool references are generated at runtime from the live |
| 6 | 7 | // router and tool list (src/docs/reference.ts), so none of the references can drift. |
| 7 | 8 | // |
| @@ -17,6 +18,7 @@ | ||
| 17 | 18 | const app = path.resolve(here, ".."); |
| 18 | 19 | const docsDir = path.join(app, "docs"); |
| 19 | 20 | const cliSrc = path.resolve(app, "../cli/src"); |
| 21 | +const cliInstall = path.resolve(app, "../cli/install"); | |
| 20 | 22 | const out = path.join(app, "src/docs/content.generated.ts"); |
| 21 | 23 | |
| 22 | 24 | /** Pages produced in code rather than from a Markdown file. */ |
| @@ -102,23 +104,19 @@ | ||
| 102 | 104 | "", |
| 103 | 105 | "## Install", |
| 104 | 106 | "", |
| 105 | - "The CLI is a single JavaScript file and needs Node.js 22 or later.", | |
| 106 | - "", | |
| 107 | 107 | "```sh", |
| 108 | - "mkdir -p ~/.local/bin", | |
| 109 | - "curl -fsSL https://diffinery.com/-/cli/diffinery.mjs -o ~/.local/bin/diffinery", | |
| 110 | - "chmod +x ~/.local/bin/diffinery", | |
| 111 | - "diffinery --version", | |
| 108 | + "curl -fsSL https://diffinery.com/install | bash # Linux and macOS", | |
| 109 | + "diffinery login # sign in through your browser", | |
| 112 | 110 | "```", |
| 113 | 111 | "", |
| 114 | - "Make sure `~/.local/bin` is on your `PATH`. To update, download the file again.", | |
| 112 | + "On Windows, in PowerShell: `powershell -c \"irm https://diffinery.com/install.ps1 | iex\"`. The CLI runs on Node.js 22 or later; the script installs one for it when yours is older or missing. `diffinery upgrade` updates it. See [Install and sign in to the CLI](/docs/accounts/cli-sign-in).", | |
| 115 | 113 | "", |
| 116 | 114 | "## Configuration", |
| 117 | 115 | "", |
| 118 | 116 | "| Setting | Where | Notes |", |
| 119 | 117 | "| --- | --- | --- |", |
| 120 | 118 | "| Server URL | `DIFFINERY_URL`, else `url` in the config file, else `https://diffinery.com` | Set this for your own Diffinery instance. |", |
| 121 | - "| Token | `DIFFINERY_TOKEN`, else `token` in the config file | `diffinery login` stores a token in the config file. |", | |
| 119 | + "| Token | `DIFFINERY_TOKEN`, else `token` in the config file | `diffinery login` signs in through the browser and stores the CLI's token in the config file. |", | |
| 122 | 120 | "| Config file | `~/.config/diffinery/config.json` (`$XDG_CONFIG_HOME/diffinery/config.json` when set) | Written by `diffinery login`. |", |
| 123 | 121 | "", |
| 124 | 122 | "Commands that work on a repository take `-R OWNER/NAME`. Without it, the CLI uses the `origin` remote of the git repository you are in, when that remote points at the configured server.", |
| @@ -219,6 +217,8 @@ | ||
| 219 | 217 | export const DOCS_PAGES: Record<string, DocPage> = ${JSON.stringify(ordered)}; |
| 220 | 218 | export const CLI_VERSION = ${JSON.stringify(cli.VERSION)}; |
| 221 | 219 | export const CLI_BUNDLE = ${JSON.stringify(cli.bin)}; |
| 220 | +export const CLI_INSTALL_SH = ${JSON.stringify(await readFile(path.join(cliInstall, "install.sh"), "utf8"))}; | |
| 221 | +export const CLI_INSTALL_PS1 = ${JSON.stringify(await readFile(path.join(cliInstall, "install.ps1"), "utf8"))}; | |
| 222 | 222 | `; |
| 223 | 223 | await mkdir(path.dirname(out), { recursive: true }); |
| 224 | 224 | await writeFile(out, src); |
modifiedapps/diffinery/src/app.ts+5−0View file
| @@ -37,6 +37,8 @@ | ||
| 37 | 37 | import { modelProxy } from "./agentic/proxy.js"; |
| 38 | 38 | import type { MailJob } from "./mail/types.js"; |
| 39 | 39 | import { expireExports } from "./accounts/export.js"; |
| 40 | +import { cliLogin } from "./http/cli-login.js"; | |
| 41 | +import { CliLogins } from "./auth/cli-login.js"; | |
| 40 | 42 | |
| 41 | 43 | export const app = new Hono<{ Bindings: Env; Variables: AppVars }>(); |
| 42 | 44 | |
| @@ -92,6 +94,7 @@ | ||
| 92 | 94 | app.get("/healthz", (c) => c.json({ ok: true })); |
| 93 | 95 | |
| 94 | 96 | app.route("/", avatars); |
| 97 | +app.route("/", cliLogin); | |
| 95 | 98 | app.route("/", wellKnown); |
| 96 | 99 | app.route("/", securityTxtRoutes); |
| 97 | 100 | app.route("/", git); |
| @@ -250,6 +253,8 @@ | ||
| 250 | 253 | mailCron(env, new Directory(env), at).catch((e) => console.error("mail cron failed", e)), |
| 251 | 254 | // Audit log entries past their 2 years, once a day. |
| 252 | 255 | (at.getUTCHours() === 3 && at.getUTCMinutes() < 5 ? pruneAuditLog(env.DB, at) : Promise.resolve(0)).catch((e) => console.error("audit log pruning failed", e)), |
| 256 | + // CLI sign-ins a day past their expiry (auth/cli-login.ts). | |
| 257 | + new CliLogins(env).prune().catch((e) => console.error("cli sign-in pruning failed", e)), | |
| 253 | 258 | // Account exports past their 7 days (accounts/export.ts). |
| 254 | 259 | expireExports(env).catch((e) => console.error("export expiry failed", e)), |
| 255 | 260 | // Expire abandoned draft bulk imports after 7 days, and null their stored GitHub token (Low 5). |
addedapps/diffinery/src/auth/cli-login.ts+237−0View file
| @@ -1,0 +1,237 @@ | ||
| 1 | +import type { Env } from "../env.js"; | |
| 2 | +import { Directory, type Account, type TokenScope } from "../lib/directory.js"; | |
| 3 | +import { HttpError, b64url, newId, now, randomToken, sha256 } from "../lib/util.js"; | |
| 4 | +import { TOKEN_SCOPES } from "./principal.js"; | |
| 5 | + | |
| 6 | +/** | |
| 7 | + * Signing the CLI in through the browser, so nobody copies tokens by hand. Two flows, both ending | |
| 8 | + * in a token minted for the CLI and listed in Settings → Access tokens: | |
| 9 | + * | |
| 10 | + * - Browser (`diffinery login`): the CLI listens on 127.0.0.1, opens /login/cli with a PKCE | |
| 11 | + * challenge, and the person approves. Diffinery sends the browser to the CLI's local callback | |
| 12 | + * with a one-time code that only works together with the verifier the CLI kept (RFC 8252). | |
| 13 | + * A link someone else crafted leads nowhere: the code goes to the clicking person's own machine. | |
| 14 | + * - Device (`diffinery login --device`, for machines without a browser): the CLI gets a device | |
| 15 | + * code it keeps and a short user code it shows; the person types the user code on | |
| 16 | + * /login/device in any browser and approves, while the CLI polls (RFC 8628). The code is never | |
| 17 | + * put in a link, so a phishing link can't carry one. | |
| 18 | + * | |
| 19 | + * Approving needs a passkey sign-in from the last 2 hours, like creating any token. Only hashes | |
| 20 | + * of the codes are stored, codes expire within minutes and work once, and the token itself is | |
| 21 | + * minted when the CLI trades its secret, so it is never stored or shown anywhere but the CLI. | |
| 22 | + */ | |
| 23 | + | |
| 24 | +/** How long a device code waits for the person, and how often the CLI may ask. */ | |
| 25 | +export const DEVICE_TTL_S = 600; | |
| 26 | +export const DEVICE_INTERVAL_S = 5; | |
| 27 | +/** How long the browser flow's one-time code waits for the CLI to trade it. */ | |
| 28 | +export const CODE_TTL_S = 120; | |
| 29 | +/** CLI tokens last this long; the expiry reminder email says to run `diffinery login` again. */ | |
| 30 | +export const CLI_TOKEN_DAYS = 90; | |
| 31 | + | |
| 32 | +/** RFC 8628's suggestion: consonants only (no vowels, so no words), without easily confused letters. */ | |
| 33 | +const USER_CODE_ALPHABET = "BCDFGHJKLMNPQRSTVWXZ"; | |
| 34 | +const USER_CODE_LENGTH = 8; | |
| 35 | + | |
| 36 | +export interface CliClient { | |
| 37 | + host: string; | |
| 38 | + os: string; | |
| 39 | + version: string; | |
| 40 | +} | |
| 41 | + | |
| 42 | +export interface CliLoginRow { | |
| 43 | + id: string; | |
| 44 | + flow: "browser" | "device"; | |
| 45 | + user_code: string | null; | |
| 46 | + client: string; | |
| 47 | + requested_scope: string; | |
| 48 | + ip: string | null; | |
| 49 | + location: string | null; | |
| 50 | + status: "pending" | "approved" | "denied" | "used"; | |
| 51 | + account_id: string | null; | |
| 52 | + scope: string | null; | |
| 53 | + created_at: string; | |
| 54 | + expires_at: string; | |
| 55 | + code_challenge: string | null; | |
| 56 | +} | |
| 57 | + | |
| 58 | +/** What the CLI reported about itself, trimmed to something safe to show and to name a token after. */ | |
| 59 | +export function cleanClient(input: { host?: unknown; os?: unknown; version?: unknown }): CliClient { | |
| 60 | + const clean = (v: unknown, max: number) => | |
| 61 | + (typeof v === "string" ? v : "") | |
| 62 | + .replace(/[^\w .,:@()+/-]/g, "") | |
| 63 | + .trim() | |
| 64 | + .slice(0, max); | |
| 65 | + return { host: clean(input.host, 64) || "unknown host", os: clean(input.os, 40), version: clean(input.version, 20) }; | |
| 66 | +} | |
| 67 | + | |
| 68 | +export function parseClient(json: string): CliClient { | |
| 69 | + try { | |
| 70 | + return cleanClient(JSON.parse(json) as Record<string, unknown>); | |
| 71 | + } catch { | |
| 72 | + return cleanClient({}); | |
| 73 | + } | |
| 74 | +} | |
| 75 | + | |
| 76 | +export function scopeOf(v: unknown, fallback: TokenScope = "write"): TokenScope { | |
| 77 | + return TOKEN_SCOPES.includes(v as TokenScope) ? (v as TokenScope) : fallback; | |
| 78 | +} | |
| 79 | + | |
| 80 | +/** The name the token gets, e.g. "Diffinery CLI on alices-mbp". */ | |
| 81 | +export const tokenName = (c: CliClient) => `Diffinery CLI on ${c.host}`.slice(0, 80); | |
| 82 | + | |
| 83 | +/** A user code as typed: any case, with or without the dash or spaces. Null when it can't be one. */ | |
| 84 | +export function normalizeUserCode(input: string): string | null { | |
| 85 | + const s = input.toUpperCase().replace(/[\s-]/g, ""); | |
| 86 | + if (s.length !== USER_CODE_LENGTH) return null; | |
| 87 | + for (const ch of s) if (!USER_CODE_ALPHABET.includes(ch)) return null; | |
| 88 | + return s; | |
| 89 | +} | |
| 90 | + | |
| 91 | +export const formatUserCode = (s: string) => `${s.slice(0, 4)}-${s.slice(4)}`; | |
| 92 | + | |
| 93 | +function newUserCode(): string { | |
| 94 | + // 256 is not a multiple of 20: reject the top values so every letter is equally likely. | |
| 95 | + let out = ""; | |
| 96 | + while (out.length < USER_CODE_LENGTH) { | |
| 97 | + for (const b of crypto.getRandomValues(new Uint8Array(16))) { | |
| 98 | + if (b < 240 && out.length < USER_CODE_LENGTH) out += USER_CODE_ALPHABET[b % 20]; | |
| 99 | + } | |
| 100 | + } | |
| 101 | + return out; | |
| 102 | +} | |
| 103 | + | |
| 104 | +/** The PKCE S256 challenge of a verifier (RFC 7636). */ | |
| 105 | +export async function pkceChallenge(verifier: string): Promise<string> { | |
| 106 | + return b64url(new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier)))); | |
| 107 | +} | |
| 108 | + | |
| 109 | +/** A loopback port the browser may be sent to: unprivileged, so not a system service's. */ | |
| 110 | +export function loopbackPort(v: unknown): number | null { | |
| 111 | + const n = typeof v === "string" && /^\d{4,5}$/.test(v) ? Number(v) : NaN; | |
| 112 | + return n >= 1024 && n <= 65535 ? n : null; | |
| 113 | +} | |
| 114 | + | |
| 115 | +/** A PKCE S256 challenge or an OAuth state: base64url, 43 characters for 32 bytes, at most 128. */ | |
| 116 | +export const isB64urlParam = (v: unknown, min = 16): v is string => typeof v === "string" && v.length >= min && v.length <= 128 && /^[A-Za-z0-9_-]+$/.test(v); | |
| 117 | + | |
| 118 | +/** Errors the token endpoint answers with, as in RFC 8628 and RFC 6749. */ | |
| 119 | +export type TokenError = "authorization_pending" | "slow_down" | "access_denied" | "expired_token" | "invalid_grant" | "invalid_request"; | |
| 120 | + | |
| 121 | +export class CliLoginError extends Error { | |
| 122 | + constructor( | |
| 123 | + readonly error: TokenError, | |
| 124 | + readonly description: string, | |
| 125 | + ) { | |
| 126 | + super(description); | |
| 127 | + } | |
| 128 | +} | |
| 129 | + | |
| 130 | +export class CliLogins { | |
| 131 | + constructor( | |
| 132 | + private env: Env, | |
| 133 | + private dir = new Directory(env), | |
| 134 | + ) {} | |
| 135 | + | |
| 136 | + private get db(): D1Database { | |
| 137 | + return this.env.DB; | |
| 138 | + } | |
| 139 | + | |
| 140 | + /** The CLI starts a device sign-in. Returns the device code (only the CLI ever sees it) and the user code. */ | |
| 141 | + async startDevice(client: CliClient, scope: TokenScope, from: { ip?: string; location?: string }): Promise<{ deviceCode: string; userCode: string; expiresIn: number; interval: number }> { | |
| 142 | + const deviceCode = randomToken(32); | |
| 143 | + for (let attempt = 0; ; attempt++) { | |
| 144 | + const userCode = newUserCode(); | |
| 145 | + try { | |
| 146 | + await this.db | |
| 147 | + .prepare("INSERT INTO cli_logins (id, flow, secret_hash, user_code, client, requested_scope, ip, location, status, created_at, expires_at) VALUES (?, 'device', ?, ?, ?, ?, ?, ?, 'pending', ?, ?)") | |
| 148 | + .bind(newId(), await sha256(deviceCode), userCode, JSON.stringify(client), scope, from.ip ?? null, from.location ?? null, now(), new Date(Date.now() + DEVICE_TTL_S * 1000).toISOString()) | |
| 149 | + .run(); | |
| 150 | + return { deviceCode, userCode, expiresIn: DEVICE_TTL_S, interval: DEVICE_INTERVAL_S }; | |
| 151 | + } catch (e) { | |
| 152 | + // A user code still in the table (they're kept a day): pick another. | |
| 153 | + if (attempt < 3 && /UNIQUE/i.test(String(e))) continue; | |
| 154 | + throw e; | |
| 155 | + } | |
| 156 | + } | |
| 157 | + } | |
| 158 | + | |
| 159 | + /** The pending device sign-in a typed code names, or null (unknown, expired or already decided). */ | |
| 160 | + async pendingDevice(userCode: string): Promise<CliLoginRow | null> { | |
| 161 | + const code = normalizeUserCode(userCode); | |
| 162 | + if (!code) return null; | |
| 163 | + return this.db.prepare("SELECT * FROM cli_logins WHERE user_code = ? AND flow = 'device' AND status = 'pending' AND expires_at > ?").bind(code, now()).first<CliLoginRow>(); | |
| 164 | + } | |
| 165 | + | |
| 166 | + /** The person approves or denies a device sign-in. False when it's no longer pending. */ | |
| 167 | + async decideDevice(id: string, person: Account, approve: boolean, scope: TokenScope): Promise<boolean> { | |
| 168 | + const r = await this.db | |
| 169 | + .prepare("UPDATE cli_logins SET status = ?, account_id = ?, scope = ?, decided_at = ? WHERE id = ? AND flow = 'device' AND status = 'pending' AND expires_at > ?") | |
| 170 | + .bind(approve ? "approved" : "denied", person.id, approve ? scope : null, now(), id, now()) | |
| 171 | + .run(); | |
| 172 | + return (r.meta.changes ?? 0) > 0; | |
| 173 | + } | |
| 174 | + | |
| 175 | + /** The person approved a browser sign-in: a one-time code for the CLI's callback, bound to its PKCE challenge. */ | |
| 176 | + async approveBrowser(person: Account, client: CliClient, scope: TokenScope, challenge: string): Promise<string> { | |
| 177 | + const code = randomToken(32); | |
| 178 | + await this.db | |
| 179 | + .prepare("INSERT INTO cli_logins (id, flow, secret_hash, code_challenge, client, requested_scope, status, account_id, scope, created_at, expires_at, decided_at) VALUES (?, 'browser', ?, ?, ?, ?, 'approved', ?, ?, ?, ?, ?)") | |
| 180 | + .bind(newId(), await sha256(code), challenge, JSON.stringify(client), scope, person.id, scope, now(), new Date(Date.now() + CODE_TTL_S * 1000).toISOString(), now()) | |
| 181 | + .run(); | |
| 182 | + return code; | |
| 183 | + } | |
| 184 | + | |
| 185 | + /** | |
| 186 | + * The CLI trades its secret for a token: the browser flow's code with the PKCE verifier, or the | |
| 187 | + * device code. Each works once; the claim is a single conditional write, so two requests racing | |
| 188 | + * with the same secret can't both get a token. | |
| 189 | + */ | |
| 190 | + async exchange(grant: { type: "code"; code: string; verifier: string } | { type: "device"; deviceCode: string }): Promise<{ token: string; scope: TokenScope; expiresAt: string; login: string; tokenId: string }> { | |
| 191 | + const hash = await sha256(grant.type === "code" ? grant.code : grant.deviceCode); | |
| 192 | + const flow = grant.type === "code" ? "browser" : "device"; | |
| 193 | + const at = now(); | |
| 194 | + const claimed = await this.db | |
| 195 | + .prepare("UPDATE cli_logins SET status = 'used', used_at = ? WHERE secret_hash = ? AND flow = ? AND status = 'approved' AND expires_at > ? RETURNING *") | |
| 196 | + .bind(at, hash, flow, at) | |
| 197 | + .first<CliLoginRow>(); | |
| 198 | + if (!claimed) { | |
| 199 | + const row = await this.db.prepare("SELECT status, expires_at FROM cli_logins WHERE secret_hash = ? AND flow = ?").bind(hash, flow).first<{ status: string; expires_at: string }>(); | |
| 200 | + if (!row) throw new CliLoginError("invalid_grant", "unknown or already used code"); | |
| 201 | + if (row.status === "denied") throw new CliLoginError("access_denied", "the sign-in was denied in the browser"); | |
| 202 | + if (row.status === "used") throw new CliLoginError("invalid_grant", "this code was already used"); | |
| 203 | + if (row.expires_at <= at) throw new CliLoginError("expired_token", "the code expired; run `diffinery login` again"); | |
| 204 | + throw new CliLoginError("authorization_pending", "waiting for you to approve the sign-in in the browser"); | |
| 205 | + } | |
| 206 | + // The verifier is checked after the claim so a wrong guess burns the code rather than allowing retries. | |
| 207 | + if (grant.type === "code" && (!claimed.code_challenge || (await pkceChallenge(grant.verifier)) !== claimed.code_challenge)) { | |
| 208 | + throw new CliLoginError("invalid_grant", "the code verifier doesn't match"); | |
| 209 | + } | |
| 210 | + const person = claimed.account_id ? await this.dir.account(claimed.account_id) : null; | |
| 211 | + if (!person || person.status !== "active" || person.kind !== "user") throw new CliLoginError("access_denied", "the account that approved this sign-in can't sign in"); | |
| 212 | + const scope = scopeOf(claimed.scope); | |
| 213 | + const client = parseClient(claimed.client); | |
| 214 | + const t = await this.dir.createToken(person, { name: tokenName(client), scopes: [scope], expiresInDays: CLI_TOKEN_DAYS, client: "cli" }); | |
| 215 | + await this.db.prepare("UPDATE cli_logins SET token_id = ? WHERE id = ?").bind(t.id, claimed.id).run(); | |
| 216 | + await this.dir.audit({ actorId: person.id, actorLogin: person.login, action: "token.create", target: person.login, detail: { name: tokenName(client), scope, via: `cli ${flow} sign-in` } }); | |
| 217 | + return { token: t.token, scope, expiresAt: t.expiresAt, login: person.login, tokenId: t.id }; | |
| 218 | + } | |
| 219 | + | |
| 220 | + /** `diffinery logout`: a CLI token revokes itself. Other tokens are left alone (they may be used elsewhere). */ | |
| 221 | + async revokeCliToken(accountId: string, tokenId: string): Promise<boolean> { | |
| 222 | + const r = await this.db.prepare("UPDATE tokens SET revoked_at = ? WHERE id = ? AND account_id = ? AND client = 'cli' AND revoked_at IS NULL").bind(now(), tokenId, accountId).run(); | |
| 223 | + return (r.meta.changes ?? 0) > 0; | |
| 224 | + } | |
| 225 | + | |
| 226 | + /** Housekeeping: rows a day past their expiry. */ | |
| 227 | + async prune(): Promise<void> { | |
| 228 | + await this.db.prepare("DELETE FROM cli_logins WHERE expires_at < ?").bind(new Date(Date.now() - 86400_000).toISOString()).run(); | |
| 229 | + } | |
| 230 | +} | |
| 231 | + | |
| 232 | +/** Throws a 422 for a scope that isn't one of read, write and admin. */ | |
| 233 | +export function requireScopeName(v: unknown): TokenScope { | |
| 234 | + if (v === undefined || v === null || v === "") return "write"; | |
| 235 | + if (!TOKEN_SCOPES.includes(v as TokenScope)) throw new HttpError(422, `scope must be one of: ${TOKEN_SCOPES.join(", ")}`); | |
| 236 | + return v as TokenScope; | |
| 237 | +} |
modifiedapps/diffinery/src/auth/principal.ts+1−1View file
| @@ -75,7 +75,7 @@ | ||
| 75 | 75 | if (!agent && !IMPORT_GIT_PATH.test(path)) throw new HttpError(403, "this token can only be used by its GitHub import"); |
| 76 | 76 | return { type: "account", account: v.account, via: "token", scopes: v.scopes, repoIds: v.repoIds, importId: v.importId, tokenId: v.id }; |
| 77 | 77 | } |
| 78 | - return { type: "account", account: v.account, via: "token", scopes: v.scopes, repoIds: v.repoIds }; | |
| 78 | + return { type: "account", account: v.account, via: "token", scopes: v.scopes, repoIds: v.repoIds, tokenId: v.id }; | |
| 79 | 79 | } |
| 80 | 80 | // Job and sandbox tokens, and self-hosted runners' own tokens (which only claim jobs: runnerAuth refuses them). |
| 81 | 81 | if (cred.startsWith("djt_") || cred.startsWith("dst_") || cred.startsWith("drn_")) { |
modifiedapps/diffinery/src/docs/pages.ts+1−1View file
| @@ -108,7 +108,7 @@ | ||
| 108 | 108 | `- All pages in one file: ${origin}/llms-full.txt`, |
| 109 | 109 | `- The REST API as OpenAPI 3.1: ${origin}/docs/api/openapi.json`, |
| 110 | 110 | `- MCP server for agents: ${origin}/mcp (send \`Authorization: Bearer <token>\`)`, |
| 111 | - `- Command-line client: ${origin}/-/cli/diffinery.mjs (Node.js 22 or later)`, | |
| 111 | + `- Command-line client: \`curl -fsSL ${origin}/install | bash\` (Windows: \`irm ${origin}/install.ps1 | iex\`), then \`diffinery login\`; the file itself is ${origin}/-/cli/diffinery.mjs (Node.js 22 or later)`, | |
| 112 | 112 | "", |
| 113 | 113 | ]; |
| 114 | 114 | for (const s of DOCS_NAV) { |
modifiedapps/diffinery/src/docs/web.tsx+10−2View file
| @@ -3,7 +3,8 @@ | ||
| 3 | 3 | * The public docs at /docs, readable without signing in, plus the formats AI |
| 4 | 4 | * agents read: /docs/<page>.md (or any page with `Accept: text/markdown`), |
| 5 | 5 | * /llms.txt, /llms-full.txt, /docs/api/openapi.json and /docs/search.json. |
| 6 | - * Also serves the CLI download at /-/cli/diffinery.mjs. | |
| 6 | + * Also serves the CLI download at /-/cli/diffinery.mjs, and the scripts that install it: | |
| 7 | + * `curl -fsSL https://diffinery.com/install | bash` and `irm https://diffinery.com/install.ps1 | iex`. | |
| 7 | 8 | */ |
| 8 | 9 | import { raw } from "hono/html"; |
| 9 | 10 | import type { AppContext } from "../auth/principal.js"; |
| @@ -11,7 +12,7 @@ | ||
| 11 | 12 | import { fenceHighlighter } from "../web/highlight.js"; |
| 12 | 13 | import { ICONS } from "../web/assets.js"; |
| 13 | 14 | import { render, type WebApp } from "../web/ui.js"; |
| 14 | -import { CLI_BUNDLE, CLI_VERSION } from "./content.generated.js"; | |
| 15 | +import { CLI_BUNDLE, CLI_INSTALL_PS1, CLI_INSTALL_SH, CLI_VERSION } from "./content.generated.js"; | |
| 15 | 16 | import { allPages, getPage, headings, llmsFullTxt, llmsTxt, markdownUrl, nav, pageMarkdown, pageUrl, searchIndex, SLUGS, type Page } from "./pages.js"; |
| 16 | 17 | import { openApiDocument } from "./reference.js"; |
| 17 | 18 | |
| @@ -191,6 +192,13 @@ | ||
| 191 | 192 | }), |
| 192 | 193 | ); |
| 193 | 194 | |
| 195 | + // The install scripts download the CLI from the server that served them (a self-hosted instance installs its own build). | |
| 196 | + const installScript = (c: AppContext, script: string) => | |
| 197 | + c.body(script.replaceAll("__DIFFINERY_URL__", origin(c)), 200, { ...PUBLIC, "content-type": "text/plain; charset=utf-8", "x-diffinery-cli-version": CLI_VERSION }); | |
| 198 | + web.get("/install", (c) => installScript(c, CLI_INSTALL_SH)); | |
| 199 | + web.get("/install.sh", (c) => installScript(c, CLI_INSTALL_SH)); | |
| 200 | + web.get("/install.ps1", (c) => installScript(c, CLI_INSTALL_PS1)); | |
| 201 | + | |
| 194 | 202 | web.get("/docs", (c) => { |
| 195 | 203 | const p = getPage("index", origin(c))!; |
| 196 | 204 | return wantsMarkdown(c) ? markdownResponse(c, pageMarkdown(p, origin(c))) : docsPage(c, p); |
addedapps/diffinery/src/http/cli-login.ts+69−0View file
| @@ -1,0 +1,69 @@ | ||
| 1 | +import { Hono } from "hono"; | |
| 2 | +import type { Env } from "../env.js"; | |
| 3 | +import type { AppVars } from "../auth/principal.js"; | |
| 4 | +import { CliLoginError, CliLogins, cleanClient, formatUserCode, isB64urlParam, requireScopeName } from "../auth/cli-login.js"; | |
| 5 | +import { HttpError } from "../lib/util.js"; | |
| 6 | + | |
| 7 | +/** | |
| 8 | + * What the CLI calls to sign in through the browser (auth/cli-login.ts): start a device sign-in, | |
| 9 | + * trade a code for a token, and revoke its own token on `diffinery logout`. JSON in and out, no | |
| 10 | + * cookies; the pages people approve on are in web/cli-login.tsx. | |
| 11 | + */ | |
| 12 | +export const cliLogin = new Hono<{ Bindings: Env; Variables: AppVars }>(); | |
| 13 | + | |
| 14 | +const NO_STORE = { "cache-control": "no-store", pragma: "no-cache" }; | |
| 15 | + | |
| 16 | +async function jsonBody(req: Request): Promise<Record<string, unknown>> { | |
| 17 | + if (!/^application\/json\b/i.test(req.headers.get("content-type") ?? "")) throw new HttpError(415, "expected JSON"); | |
| 18 | + const len = Number(req.headers.get("content-length") ?? "0"); | |
| 19 | + if (len > 8192) throw new HttpError(413, "request too large"); | |
| 20 | + const b = await req.json().catch(() => null); | |
| 21 | + if (!b || typeof b !== "object" || Array.isArray(b)) throw new HttpError(400, "invalid JSON"); | |
| 22 | + return b as Record<string, unknown>; | |
| 23 | +} | |
| 24 | + | |
| 25 | +cliLogin.post("/-/cli/device", async (c) => { | |
| 26 | + const b = await jsonBody(c.req.raw); | |
| 27 | + const client = cleanClient((b.client ?? {}) as Record<string, unknown>); | |
| 28 | + const scope = requireScopeName(b.scope); | |
| 29 | + const cf = (c.req.raw as { cf?: { city?: string; country?: string } }).cf; | |
| 30 | + const location = [cf?.city, cf?.country].filter(Boolean).join(", ") || undefined; | |
| 31 | + const d = await new CliLogins(c.env, c.get("dir")).startDevice(client, scope, { ip: c.req.header("cf-connecting-ip") ?? undefined, location }); | |
| 32 | + const base = c.env.PUBLIC_URL.replace(/\/$/, ""); | |
| 33 | + return c.json( | |
| 34 | + { | |
| 35 | + device_code: d.deviceCode, | |
| 36 | + user_code: formatUserCode(d.userCode), | |
| 37 | + // No verification_uri_complete: the code is typed, never carried in a link someone could send. | |
| 38 | + verification_uri: `${base}/login/device`, | |
| 39 | + expires_in: d.expiresIn, | |
| 40 | + interval: d.interval, | |
| 41 | + }, | |
| 42 | + 200, | |
| 43 | + NO_STORE, | |
| 44 | + ); | |
| 45 | +}); | |
| 46 | + | |
| 47 | +cliLogin.post("/-/cli/token", async (c) => { | |
| 48 | + const b = await jsonBody(c.req.raw); | |
| 49 | + const logins = new CliLogins(c.env, c.get("dir")); | |
| 50 | + try { | |
| 51 | + let grant: Parameters<CliLogins["exchange"]>[0]; | |
| 52 | + if (b.grant_type === "authorization_code" && typeof b.code === "string" && isB64urlParam(b.code_verifier, 43)) grant = { type: "code", code: b.code, verifier: b.code_verifier }; | |
| 53 | + else if (b.grant_type === "urn:ietf:params:oauth:grant-type:device_code" && typeof b.device_code === "string") grant = { type: "device", deviceCode: b.device_code }; | |
| 54 | + else throw new CliLoginError("invalid_request", "send grant_type authorization_code with code and code_verifier, or the device code grant with device_code"); | |
| 55 | + const t = await logins.exchange(grant); | |
| 56 | + return c.json({ access_token: t.token, token_type: "bearer", scope: t.scope, expires_at: t.expiresAt, login: t.login }, 200, NO_STORE); | |
| 57 | + } catch (e) { | |
| 58 | + if (!(e instanceof CliLoginError)) throw e; | |
| 59 | + return c.json({ error: e.error, error_description: e.description }, 400, NO_STORE); | |
| 60 | + } | |
| 61 | +}); | |
| 62 | + | |
| 63 | +cliLogin.delete("/-/cli/token", async (c) => { | |
| 64 | + const p = c.get("principal"); | |
| 65 | + if (p.type !== "account" || p.via !== "token" || !p.tokenId) throw new HttpError(401, "send the token to revoke as the Authorization header"); | |
| 66 | + const revoked = await new CliLogins(c.env, c.get("dir")).revokeCliToken(p.account.id, p.tokenId); | |
| 67 | + if (revoked) c.executionCtx.waitUntil(c.get("dir").audit({ actorId: p.account.id, actorLogin: p.account.login, action: "token.revoke", target: p.tokenId, detail: { via: "diffinery logout" } })); | |
| 68 | + return c.json({ revoked }, 200, NO_STORE); | |
| 69 | +}); |
modifiedapps/diffinery/src/lib/directory.ts+6−6View file
| @@ -446,25 +446,25 @@ | ||
| 446 | 446 | |
| 447 | 447 | // ---------------------------------------------------------------- tokens |
| 448 | 448 | |
| 449 | - /** `importId`: a GitHub import's short-lived push token (see imports/service.ts). */ | |
| 450 | - async createToken(account: Account, input: { name: string; scopes?: TokenScope[]; repoIds?: string[] | null; expiresInDays?: number; importId?: string }): Promise<{ token: string; id: string; expiresAt: string }> { | |
| 449 | + /** `importId`: a GitHub import's short-lived push token (see imports/service.ts). `client`: "cli" for a CLI sign-in's token (auth/cli-login.ts). */ | |
| 450 | + async createToken(account: Account, input: { name: string; scopes?: TokenScope[]; repoIds?: string[] | null; expiresInDays?: number; importId?: string; client?: "cli" }): Promise<{ token: string; id: string; expiresAt: string }> { | |
| 451 | 451 | const days = Math.min(Math.max(input.expiresInDays ?? 90, 1), 366); |
| 452 | 452 | const token = `dfy_${randomToken(30)}`; |
| 453 | 453 | const id = newId(); |
| 454 | 454 | const expiresAt = new Date(Date.now() + days * 86400_000).toISOString(); |
| 455 | 455 | await this.db |
| 456 | - .prepare("INSERT INTO tokens (id, account_id, name, token_hash, prefix, scopes, repo_ids, expires_at, created_at, import_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)") | |
| 457 | - .bind(id, account.id, input.name || "token", await sha256(token), token.slice(0, 10), JSON.stringify(input.scopes?.length ? input.scopes : ["write"]), input.repoIds ? JSON.stringify(input.repoIds) : null, expiresAt, now(), input.importId ?? null) | |
| 456 | + .prepare("INSERT INTO tokens (id, account_id, name, token_hash, prefix, scopes, repo_ids, expires_at, created_at, import_id, client) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)") | |
| 457 | + .bind(id, account.id, input.name || "token", await sha256(token), token.slice(0, 10), JSON.stringify(input.scopes?.length ? input.scopes : ["write"]), input.repoIds ? JSON.stringify(input.repoIds) : null, expiresAt, now(), input.importId ?? null, input.client ?? null) | |
| 458 | 458 | .run(); |
| 459 | 459 | // An import's own push token is server-managed and short-lived: no "new token" email. |
| 460 | 460 | if (!input.importId) await tokenCreated(this.env, this, account, { name: input.name || "token", scopes: input.scopes?.length ? input.scopes : ["write"] }).catch((e) => console.error("token email failed", e)); |
| 461 | 461 | return { token, id, expiresAt }; |
| 462 | 462 | } |
| 463 | 463 | |
| 464 | - async tokens(accountId: string): Promise<Array<{ id: string; name: string; prefix: string; scopes: string[]; repoIds: string[] | null; expiresAt: string; lastUsedAt: string | null; createdAt: string }>> { | |
| 464 | + async tokens(accountId: string): Promise<Array<{ id: string; name: string; prefix: string; scopes: string[]; repoIds: string[] | null; expiresAt: string; lastUsedAt: string | null; createdAt: string; client: string | null }>> { | |
| 465 | 465 | // Import push tokens (import_id set) are server-managed and hidden from the personal token list. |
| 466 | 466 | const r = await this.db.prepare("SELECT * FROM tokens WHERE account_id = ? AND revoked_at IS NULL AND import_id IS NULL ORDER BY created_at DESC").bind(accountId).all<Record<string, string | null>>(); |
| 467 | - return r.results.map((t) => ({ id: t.id!, name: t.name!, prefix: t.prefix!, scopes: JSON.parse(t.scopes!), repoIds: t.repo_ids ? JSON.parse(t.repo_ids) : null, expiresAt: t.expires_at!, lastUsedAt: t.last_used_at ?? null, createdAt: t.created_at! })); | |
| 467 | + return r.results.map((t) => ({ id: t.id!, name: t.name!, prefix: t.prefix!, scopes: JSON.parse(t.scopes!), repoIds: t.repo_ids ? JSON.parse(t.repo_ids) : null, expiresAt: t.expires_at!, lastUsedAt: t.last_used_at ?? null, createdAt: t.created_at!, client: t.client ?? null })); | |
| 468 | 468 | } |
| 469 | 469 | |
| 470 | 470 | async revokeToken(accountId: string, id: string): Promise<void> { |
modifiedapps/diffinery/src/lib/rate-limit.ts+4−3View file
| @@ -8,8 +8,9 @@ | ||
| 8 | 8 | * the limits stop floods and scripted abuse rather than meter use exactly. A binding that isn't |
| 9 | 9 | * configured (local development) or that fails lets the request through. |
| 10 | 10 | * |
| 11 | - * - RL_AUTH: sign-in, passkey registration, invitation and sign-in links, open sign-up and private | |
| 12 | - * security reports (/security/report), per IP address. | |
| 11 | + * - RL_AUTH: sign-in, passkey registration, invitation and sign-in links, open sign-up, private | |
| 12 | + * security reports (/security/report), and CLI sign-ins (starting one, typing or approving a | |
| 13 | + * code), per IP address. | |
| 13 | 14 | * - RL_ANON: everything else from someone who isn't signed in, per IP address. |
| 14 | 15 | * - RL_ACCOUNT: everything from a signed-in person, agent or personal token, per account. |
| 15 | 16 | * - RL_CREATE: creating things other people see or that cost storage (repositories, |
| @@ -32,7 +33,7 @@ | ||
| 32 | 33 | |
| 33 | 34 | /** Paths that never count: health checks, avatars, and the Actions runtime and runners (their own credentials). */ |
| 34 | 35 | const EXEMPT = /^\/(?:healthz$|api\/avatars\/|twirp\/|_runtime\/|_apis\/|runner\/|-\/model\/)/; |
| 35 | -const AUTH = /^\/(?:auth\/passkey\/|invite\/|signin\/|login(?:$|\/)|signup(?:$|\/)|security\/report$)/; | |
| 36 | +const AUTH = /^\/(?:auth\/passkey\/|invite\/|signin\/|login(?:$|\/)|signup(?:$|\/)|security\/report$|-\/cli\/device$)/; | |
| 36 | 37 | const CREATE: Array<[method: string, path: RegExp]> = [ |
| 37 | 38 | ["POST", /^\/api\/v3\/(?:user|orgs\/[^/]+)\/repos$/], |
| 38 | 39 | ["POST", /^\/api\/v3\/(?:user\/orgs|admin\/organizations)$/], |
modifiedapps/diffinery/src/lib/util.ts+1−1View file
| @@ -79,7 +79,7 @@ | ||
| 79 | 79 | } |
| 80 | 80 | |
| 81 | 81 | /** Top-level paths the app serves; no account may use them as its login. */ |
| 82 | -export const RESERVED_LOGINS = new Set(["api", "mcp", "runner", "settings", "new", "notifications", "invite", "request-access", "healthz", "twirp", "login", "logout", "admin", "explore", "favicon.ico", "robots.txt", "about", "docs", "static", "assets", "www", "git", "diffinery", "auth", "signin", "signup", "security", "status", "v2", "imports", "-", "diffinery-agent-review", "ghost", "github-actions", "diffinery-actions", "noreply", "no-reply", "support", "abuse", "postmaster", "root", "staff"]); | |
| 82 | +export const RESERVED_LOGINS = new Set(["api", "mcp", "runner", "settings", "new", "notifications", "invite", "request-access", "healthz", "twirp", "install", "install.sh", "install.ps1", "cli", "login", "logout", "admin", "explore", "favicon.ico", "robots.txt", "about", "docs", "static", "assets", "www", "git", "diffinery", "auth", "signin", "signup", "security", "status", "v2", "imports", "-", "diffinery-agent-review", "ghost", "github-actions", "diffinery-actions", "noreply", "no-reply", "support", "abuse", "postmaster", "root", "staff"]); | |
| 83 | 83 | |
| 84 | 84 | /** |
| 85 | 85 | * Who a deleted account's issues, pull requests and comments are attributed to, as on GitHub. Not an account: |
modifiedapps/diffinery/src/web/account.tsx+4−1View file
| @@ -1050,6 +1050,9 @@ | ||
| 1050 | 1050 | <> |
| 1051 | 1051 | <h1>Personal access tokens</h1> |
| 1052 | 1052 | <p class="muted">Tokens authenticate Git over HTTPS, the REST API, the CLI and MCP clients. Use your username (or anything) as the Git username and the token as the password.</p> |
| 1053 | + <p class="muted"> | |
| 1054 | + For the CLI you don't need one: run <code>diffinery login</code> and approve the sign-in in your browser. Each CLI sign-in gets its own token, listed here. | |
| 1055 | + </p> | |
| 1053 | 1056 | {created ? <TokenOnce c={c} token={created.token} title="Your new token" /> : null} |
| 1054 | 1057 | <div class="box"> |
| 1055 | 1058 | <div class="head"> |
| @@ -1071,7 +1074,7 @@ | ||
| 1071 | 1074 | {tokens.map((k) => ( |
| 1072 | 1075 | <tr> |
| 1073 | 1076 | <td> |
| 1074 | - <b>{k.name}</b> | |
| 1077 | + <b>{k.name}</b> {k.client === "cli" ? <span class="badge" title="Created by signing in with diffinery login">CLI sign-in</span> : null} | |
| 1075 | 1078 | <div class="faint mono small">{k.prefix}…</div> |
| 1076 | 1079 | </td> |
| 1077 | 1080 | <td>{k.scopes.join(", ")}</td> |
modifiedapps/diffinery/src/web/assets.ts+2−0View file
| @@ -294,6 +294,8 @@ | ||
| 294 | 294 | .navbtn{background:none;border:0;color:var(--fg2);padding:6px 10px;border-radius:6px;font:inherit;cursor:pointer;white-space:nowrap}.navbtn:hover{background:var(--bg-hover);color:var(--fg)} |
| 295 | 295 | .btn.block{display:flex;width:100%;justify-content:center} |
| 296 | 296 | .authcard{max-width:440px;margin:56px auto}.authcard .body{padding:28px}.authcard h1{font-size:24px;margin:12px 0 8px} |
| 297 | +.usercode{font-family:var(--mono);font-size:18px;font-weight:600;letter-spacing:.12em;padding:2px 8px;border:1px solid var(--border);border-radius:var(--r);background:var(--bg2);white-space:nowrap} | |
| 298 | +.usercode-input{font-family:var(--mono);font-size:20px;letter-spacing:.14em;text-transform:uppercase;text-align:center} | |
| 297 | 299 | .authmark{width:44px;height:44px;border-radius:var(--r);display:grid;place-items:center;background:color-mix(in srgb,var(--brand) 14%,transparent);color:var(--brand)}.authmark svg{width:22px;height:22px} |
| 298 | 300 | .authfoot{margin-top:20px;padding-top:14px;border-top:1px solid var(--border)}.authfoot p{margin:0 0 6px} |
| 299 | 301 | .kstack{display:flex;flex-direction:column;align-items:flex-start;gap:6px;margin:0 0 8px} |
addedapps/diffinery/src/web/cli-login.tsx+242−0View file
| @@ -1,0 +1,242 @@ | ||
| 1 | +/** @jsxImportSource hono/jsx */ | |
| 2 | +import { raw } from "hono/html"; | |
| 3 | +import type { Child, FC, PropsWithChildren } from "hono/jsx"; | |
| 4 | +import { requireAccount, requireRecentSignIn, type AppContext } from "../auth/principal.js"; | |
| 5 | +import { CLI_TOKEN_DAYS, CliLogins, cleanClient, formatUserCode, isB64urlParam, loopbackPort, normalizeUserCode, parseClient, scopeOf, type CliClient } from "../auth/cli-login.js"; | |
| 6 | +import type { Account, TokenScope } from "../lib/directory.js"; | |
| 7 | +import { HttpError } from "../lib/util.js"; | |
| 8 | +import { ICONS } from "./assets.js"; | |
| 9 | +import { csrfToken, type FormData } from "./csrf.js"; | |
| 10 | +import { KRadio } from "./kumo.js"; | |
| 11 | +import { Form, ago, post, render, type WebApp } from "./ui.js"; | |
| 12 | + | |
| 13 | +/** | |
| 14 | + * The pages a person approves a CLI sign-in on (auth/cli-login.ts has the flows): | |
| 15 | + * /login/cli `diffinery login` opened this with its loopback port and PKCE challenge | |
| 16 | + * /login/device type the code a `diffinery login --device` printed | |
| 17 | + * /login/cli/done where the CLI sends the browser once it has its token | |
| 18 | + * Both need a passkey sign-in from the last 2 hours, like creating a token in Settings. | |
| 19 | + */ | |
| 20 | + | |
| 21 | +const SCOPE_ITEMS = [ | |
| 22 | + { value: "read", label: "Read", description: "Clone, and view repositories, issues, pull requests and runs." }, | |
| 23 | + { value: "write", label: "Write", description: "Also push, open and merge pull requests, comment, and run workflows (up to maintain)." }, | |
| 24 | + { value: "admin", label: "Admin", description: "Everything you can do, including settings, secrets and variables." }, | |
| 25 | +]; | |
| 26 | + | |
| 27 | +const Card: FC<PropsWithChildren<{ title: string }>> = ({ title, children }) => ( | |
| 28 | + <div class="authcard box"> | |
| 29 | + <div class="body"> | |
| 30 | + <div class="authmark" aria-hidden="true"> | |
| 31 | + {raw(ICONS.code)} | |
| 32 | + </div> | |
| 33 | + <h1>{title}</h1> | |
| 34 | + {children} | |
| 35 | + </div> | |
| 36 | + </div> | |
| 37 | +); | |
| 38 | + | |
| 39 | +const ClientDetails: FC<{ client: CliClient; from?: { ip: string | null; location: string | null; at: string } }> = ({ client, from }) => ( | |
| 40 | + <dl class="kv mb"> | |
| 41 | + <dt>Computer</dt> | |
| 42 | + <dd> | |
| 43 | + <b>{client.host}</b> | |
| 44 | + {client.os ? <span class="muted"> · {client.os}</span> : null} | |
| 45 | + </dd> | |
| 46 | + {client.version ? ( | |
| 47 | + <> | |
| 48 | + <dt>CLI</dt> | |
| 49 | + <dd>diffinery {client.version}</dd> | |
| 50 | + </> | |
| 51 | + ) : null} | |
| 52 | + {from ? ( | |
| 53 | + <> | |
| 54 | + <dt>Requested</dt> | |
| 55 | + <dd> | |
| 56 | + {ago(from.at)} | |
| 57 | + {from.location || from.ip ? <span class="muted"> from {[from.location, from.ip].filter(Boolean).join(" · ")}</span> : null} | |
| 58 | + </dd> | |
| 59 | + </> | |
| 60 | + ) : null} | |
| 61 | + </dl> | |
| 62 | +); | |
| 63 | + | |
| 64 | +/** The approval form both flows share: the details, a scope, and Authorize or Cancel. */ | |
| 65 | +const Approve: FC<{ c: AppContext; me: Account; action: string; client: CliClient; scope: TokenScope; hidden: Record<string, string>; from?: { ip: string | null; location: string | null; at: string }; code?: string }> = (p) => { | |
| 66 | + const t = csrfToken(p.c); | |
| 67 | + return ( | |
| 68 | + <Card title="Authorize the Diffinery CLI"> | |
| 69 | + <p class="muted"> | |
| 70 | + A Diffinery command-line client wants to sign in as <b>{p.me.login}</b>. | |
| 71 | + </p> | |
| 72 | + {p.code ? ( | |
| 73 | + <p> | |
| 74 | + Check that your terminal shows this code: <span class="usercode">{p.code}</span> | |
| 75 | + </p> | |
| 76 | + ) : null} | |
| 77 | + <ClientDetails client={p.client} from={p.from} /> | |
| 78 | + <Form action={p.action} t={t}> | |
| 79 | + {Object.entries(p.hidden).map(([k, v]) => ( | |
| 80 | + <input type="hidden" name={k} value={v} /> | |
| 81 | + ))} | |
| 82 | + <div class="field"> | |
| 83 | + <label>Access</label> | |
| 84 | + <KRadio name="scope" label="Access" value={p.scope} items={SCOPE_ITEMS} /> | |
| 85 | + </div> | |
| 86 | + <p class="muted small"> | |
| 87 | + The CLI gets its own token, valid for {CLI_TOKEN_DAYS} days. It's listed under <a href="/settings/tokens">Settings → Access tokens</a>, where you can revoke it, and <code>diffinery logout</code> revokes it too. | |
| 88 | + </p> | |
| 89 | + <div class="flash warn small">Only authorize a sign-in you started yourself by running <code>diffinery login</code>. Never approve one because someone asked you to.</div> | |
| 90 | + <div class="row"> | |
| 91 | + <button class="btn primary grow" name="decision" value="approve"> | |
| 92 | + Authorize | |
| 93 | + </button> | |
| 94 | + <button class="btn" name="decision" value="deny"> | |
| 95 | + Cancel | |
| 96 | + </button> | |
| 97 | + </div> | |
| 98 | + </Form> | |
| 99 | + </Card> | |
| 100 | + ); | |
| 101 | +}; | |
| 102 | + | |
| 103 | +const Done: FC<{ title: string; children: Child }> = ({ title, children }) => <Card title={title}>{children}</Card>; | |
| 104 | + | |
| 105 | +/** The browser flow's parameters, validated; null when anything is missing or malformed. */ | |
| 106 | +function browserParams(get: (k: string) => string | undefined): { port: number; state: string; challenge: string; client: CliClient; scope: TokenScope } | null { | |
| 107 | + const port = loopbackPort(get("port")); | |
| 108 | + const state = get("state"); | |
| 109 | + const challenge = get("code_challenge"); | |
| 110 | + const method = get("code_challenge_method"); | |
| 111 | + if (!port || !isB64urlParam(state) || !isB64urlParam(challenge, 43) || (method !== undefined && method !== "" && method !== "S256")) return null; | |
| 112 | + return { port, state, challenge, client: cleanClient({ host: get("host"), os: get("os"), version: get("version") }), scope: scopeOf(get("scope")) }; | |
| 113 | +} | |
| 114 | + | |
| 115 | +/** The CLI's callback on this computer. Only 127.0.0.1, so the code never leaves the machine. */ | |
| 116 | +const callback = (port: number, q: Record<string, string>) => `http://127.0.0.1:${port}/callback?${new URLSearchParams(q)}`; | |
| 117 | + | |
| 118 | +function person(c: AppContext, next: string): Account { | |
| 119 | + const me = requireAccount(c); | |
| 120 | + if (me.kind !== "user") throw new HttpError(403, "only people can sign the CLI in; agents use their own tokens"); | |
| 121 | + requireRecentSignIn(c, next); | |
| 122 | + return me; | |
| 123 | +} | |
| 124 | + | |
| 125 | +const badLink = (c: AppContext) => | |
| 126 | + render( | |
| 127 | + c, | |
| 128 | + { title: "Sign-in link incomplete", status: 400 }, | |
| 129 | + <Done title="This sign-in link is incomplete"> | |
| 130 | + <p class="muted"> | |
| 131 | + Run <code>diffinery login</code> again and use the page it opens. | |
| 132 | + </p> | |
| 133 | + </Done>, | |
| 134 | + ); | |
| 135 | + | |
| 136 | +const DeviceForm: FC<{ c: AppContext; error?: string; value?: string }> = ({ c, error, value }) => ( | |
| 137 | + <Card title="Sign in the Diffinery CLI"> | |
| 138 | + <p class="muted"> | |
| 139 | + Enter the code your terminal shows after <code>diffinery login --device</code>. | |
| 140 | + </p> | |
| 141 | + {error ? ( | |
| 142 | + <div class="flash error" role="alert"> | |
| 143 | + {error} | |
| 144 | + </div> | |
| 145 | + ) : null} | |
| 146 | + <Form action="/login/device" t={csrfToken(c)}> | |
| 147 | + <div class="field"> | |
| 148 | + <label for="code">Code</label> | |
| 149 | + <input id="code" name="code" type="text" class="usercode-input" required autocomplete="off" autocapitalize="characters" spellcheck={false} maxlength={12} placeholder="XXXX-XXXX" value={value ?? ""} autofocus /> | |
| 150 | + </div> | |
| 151 | + <button class="btn primary block">Continue</button> | |
| 152 | + </Form> | |
| 153 | + </Card> | |
| 154 | +); | |
| 155 | + | |
| 156 | +export function registerCliLogin(web: WebApp): void { | |
| 157 | + // ---------------------------------------------------------------- browser flow | |
| 158 | + | |
| 159 | + web.get("/login/cli", async (c) => { | |
| 160 | + const q = browserParams((k) => c.req.query(k)); | |
| 161 | + if (!q) return badLink(c); | |
| 162 | + const url = new URL(c.req.url); | |
| 163 | + const me = person(c, url.pathname + url.search); | |
| 164 | + return render( | |
| 165 | + c, | |
| 166 | + // Authorize leads the browser to the CLI's callback on this computer. | |
| 167 | + { title: "Authorize the Diffinery CLI", formAction: `http://127.0.0.1:${q.port}` }, | |
| 168 | + <Approve c={c} me={me} action="/login/cli" client={q.client} scope={q.scope} hidden={{ port: String(q.port), state: q.state, code_challenge: q.challenge, host: q.client.host, os: q.client.os, version: q.client.version }} />, | |
| 169 | + ); | |
| 170 | + }); | |
| 171 | + | |
| 172 | + post(web, "/login/cli", async (c, f: FormData) => { | |
| 173 | + const q = browserParams((k) => f.s(k)); | |
| 174 | + if (!q) return badLink(c); | |
| 175 | + const back = `/login/cli?${new URLSearchParams({ port: String(q.port), state: q.state, code_challenge: q.challenge, code_challenge_method: "S256", scope: q.scope, host: q.client.host, os: q.client.os, version: q.client.version })}`; | |
| 176 | + const me = person(c, back); | |
| 177 | + if (f.s("decision") !== "approve") return c.redirect(callback(q.port, { error: "access_denied", state: q.state }), 303); | |
| 178 | + const code = await new CliLogins(c.env, c.get("dir")).approveBrowser(me, q.client, scopeOf(f.s("scope"), q.scope), q.challenge); | |
| 179 | + return c.redirect(callback(q.port, { code, state: q.state }), 303); | |
| 180 | + }); | |
| 181 | + | |
| 182 | + // Where the CLI sends the browser when it's done; it says which outcome, nothing else. | |
| 183 | + web.get("/login/cli/done", (c) => { | |
| 184 | + const result = c.req.query("result"); | |
| 185 | + if (result === "ok") { | |
| 186 | + return render( | |
| 187 | + c, | |
| 188 | + { title: "CLI signed in" }, | |
| 189 | + <Done title="You're signed in"> | |
| 190 | + <p class="muted">The Diffinery CLI is signed in. You can close this tab and go back to your terminal.</p> | |
| 191 | + </Done>, | |
| 192 | + ); | |
| 193 | + } | |
| 194 | + const denied = result === "denied"; | |
| 195 | + return render( | |
| 196 | + c, | |
| 197 | + { title: denied ? "Sign-in cancelled" : "Sign-in failed" }, | |
| 198 | + <Done title={denied ? "Sign-in cancelled" : "The CLI couldn't finish signing in"}> | |
| 199 | + <p class="muted">{denied ? "Nothing was signed in. You can close this tab." : "Go back to your terminal for the details, then run diffinery login again."}</p> | |
| 200 | + </Done>, | |
| 201 | + ); | |
| 202 | + }); | |
| 203 | + | |
| 204 | + // ---------------------------------------------------------------- device flow | |
| 205 | + | |
| 206 | + web.get("/login/device", async (c) => { | |
| 207 | + person(c, "/login/device"); | |
| 208 | + return render(c, { title: "Sign in the Diffinery CLI" }, <DeviceForm c={c} />); | |
| 209 | + }); | |
| 210 | + | |
| 211 | + post(web, "/login/device", async (c, f) => { | |
| 212 | + const me = person(c, "/login/device"); | |
| 213 | + const typed = f.s("code").trim(); | |
| 214 | + const row = normalizeUserCode(typed) ? await new CliLogins(c.env, c.get("dir")).pendingDevice(typed) : null; | |
| 215 | + if (!row) return render(c, { title: "Sign in the Diffinery CLI", status: 404 }, <DeviceForm c={c} value={typed.slice(0, 12)} error="That code isn't valid or has expired. Check the code in your terminal, or run diffinery login --device again." />); | |
| 216 | + return render( | |
| 217 | + c, | |
| 218 | + { title: "Authorize the Diffinery CLI" }, | |
| 219 | + <Approve c={c} me={me} action="/login/device/decide" client={parseClient(row.client)} scope={scopeOf(row.requested_scope)} hidden={{ id: row.id }} from={{ ip: row.ip, location: row.location, at: row.created_at }} code={formatUserCode(row.user_code ?? "")} />, | |
| 220 | + ); | |
| 221 | + }); | |
| 222 | + | |
| 223 | + post(web, "/login/device/decide", async (c, f) => { | |
| 224 | + const me = person(c, "/login/device"); | |
| 225 | + const approve = f.s("decision") === "approve"; | |
| 226 | + const ok = await new CliLogins(c.env, c.get("dir")).decideDevice(f.s("id"), me, approve, scopeOf(f.s("scope"))); | |
| 227 | + if (!ok) return render(c, { title: "Sign in the Diffinery CLI", status: 410 }, <DeviceForm c={c} error="That sign-in expired or was already answered. Run diffinery login --device again." />); | |
| 228 | + return render( | |
| 229 | + c, | |
| 230 | + { title: approve ? "CLI signed in" : "Sign-in cancelled" }, | |
| 231 | + approve ? ( | |
| 232 | + <Done title="You're signed in"> | |
| 233 | + <p class="muted">The Diffinery CLI will finish signing in within a few seconds. You can close this tab.</p> | |
| 234 | + </Done> | |
| 235 | + ) : ( | |
| 236 | + <Done title="Sign-in cancelled"> | |
| 237 | + <p class="muted">The CLI was not signed in. You can close this tab.</p> | |
| 238 | + </Done> | |
| 239 | + ), | |
| 240 | + ); | |
| 241 | + }); | |
| 242 | +} |
modifiedapps/diffinery/src/web/routes.tsx+2−0View file
| @@ -13,6 +13,7 @@ | ||
| 13 | 13 | import { registerSecurityReports } from "./security-reports.js"; |
| 14 | 14 | import { registerStatus } from "./status.js"; |
| 15 | 15 | import { registerAuth } from "./auth.js"; |
| 16 | +import { registerCliLogin } from "./cli-login.js"; | |
| 16 | 17 | import { registerActions } from "./actions.js"; |
| 17 | 18 | import { registerCode } from "./code.js"; |
| 18 | 19 | import { registerCollab } from "./collab.js"; |
| @@ -47,6 +48,7 @@ | ||
| 47 | 48 | // registered before the /:owner/:repo patterns they would otherwise match. |
| 48 | 49 | registerKumoAssets(web); |
| 49 | 50 | registerAuth(web); |
| 51 | +registerCliLogin(web); | |
| 50 | 52 | registerPackages(web); |
| 51 | 53 | registerMailWeb(web); |
| 52 | 54 | registerSecurityReports(web); |
modifiedapps/diffinery/src/web/ui.tsx+5−3View file
| @@ -300,6 +300,8 @@ | ||
| 300 | 300 | refresh?: number; |
| 301 | 301 | /** Load Cloudflare Turnstile (the sign-up page): its script and iframe come from challenges.cloudflare.com. */ |
| 302 | 302 | turnstile?: boolean; |
| 303 | + /** Another place this page's forms may lead (CSP form-action), e.g. the CLI's loopback callback after approving a sign-in. */ | |
| 304 | + formAction?: string; | |
| 303 | 305 | } |
| 304 | 306 | |
| 305 | 307 | export const WATCH_OPTIONS = [ |
| @@ -374,7 +376,7 @@ | ||
| 374 | 376 | ); |
| 375 | 377 | }; |
| 376 | 378 | |
| 377 | -function csp(nonce: string, c: AppContext, turnstile?: boolean): string { | |
| 379 | +function csp(nonce: string, c: AppContext, turnstile?: boolean, formAction?: string): string { | |
| 378 | 380 | const host = new URL(c.req.url).host; |
| 379 | 381 | return [ |
| 380 | 382 | "default-src 'self'", |
| @@ -387,7 +389,7 @@ | ||
| 387 | 389 | "font-src 'self'", |
| 388 | 390 | "object-src 'none'", |
| 389 | 391 | "base-uri 'none'", |
| 390 | - "form-action 'self'", | |
| 392 | + `form-action 'self'${formAction ? ` ${formAction}` : ""}`, | |
| 391 | 393 | "frame-ancestors 'none'", |
| 392 | 394 | ].join("; "); |
| 393 | 395 | } |
| @@ -422,7 +424,7 @@ | ||
| 422 | 424 | const p = c.get("principal"); |
| 423 | 425 | const me = p?.type === "account" ? p.account : null; |
| 424 | 426 | const active = me?.status === "active"; |
| 425 | - c.header("content-security-policy", csp(nonce, c, opts.turnstile)); | |
| 427 | + c.header("content-security-policy", csp(nonce, c, opts.turnstile, opts.formAction)); | |
| 426 | 428 | c.header("x-frame-options", "DENY"); |
| 427 | 429 | c.header("x-content-type-options", "nosniff"); |
| 428 | 430 | c.header("referrer-policy", "same-origin"); |
modifiedapps/diffinery/test/e2e/passkeys.test.mjs+74−1View file
| @@ -13,7 +13,7 @@ | ||
| 13 | 13 | // passkey, and the sign-up pages answering 404 while it is off. |
| 14 | 14 | import { spawn, execFileSync } from "node:child_process"; |
| 15 | 15 | import { createHash, createHmac, randomBytes } from "node:crypto"; |
| 16 | -import { mkdtempSync, rmSync } from "node:fs"; | |
| 16 | +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; | |
| 17 | 17 | import { tmpdir } from "node:os"; |
| 18 | 18 | import { join, dirname, resolve } from "node:path"; |
| 19 | 19 | import { fileURLToPath } from "node:url"; |
| @@ -34,6 +34,8 @@ | ||
| 34 | 34 | /** boss is also signed in on another browser (say, a lost laptop), and has an access token. */ |
| 35 | 35 | const BOSS_OTHER = "boss-other-browser-0123456789abcdef"; |
| 36 | 36 | const BOSS_TOKEN = "dfy_boss_token_0123456789abcdef"; |
| 37 | +/** clio is signed in on one browser, recently enough to approve CLI sign-ins. */ | |
| 38 | +const CLIO_SESSION = "clio-browser-session-0123456789abcdef"; | |
| 37 | 39 | const hash = (s) => createHash("sha256").update(s).digest("hex"); |
| 38 | 40 | const MASTER_KEY = randomBytes(32).toString("base64"); |
| 39 | 41 | /** The proof only the invitation email carries (src/auth/invite-proof.ts). */ |
| @@ -108,6 +110,8 @@ | ||
| 108 | 110 | `INSERT INTO signin_links (id, token_hash, account_id, created_by, created_at, expires_at) VALUES ('link1', '${hash(ADMIN_LINK)}', 'boss1', 'boss1', '${at}', '${later}')`, |
| 109 | 111 | `INSERT INTO sessions (id, account_id, method, created_at, last_seen_at, expires_at) VALUES ('${hash(BOSS_OTHER)}', 'boss1', 'passkey', '${at}', '${at}', '${later}')`, |
| 110 | 112 | `INSERT INTO tokens (id, account_id, name, token_hash, prefix, scopes, expires_at, created_at) VALUES ('tboss', 'boss1', 'e2e', '${hash(BOSS_TOKEN)}', '${BOSS_TOKEN.slice(0, 10)}', '["admin"]', '${later}', '${at}')`, |
| 113 | + `INSERT INTO accounts (id, login, kind, name, email, status, created_at) VALUES ('clio1', 'clio', 'user', 'Clio', 'clio@example.com', 'active', '${at}')`, | |
| 114 | + `INSERT INTO sessions (id, account_id, method, created_at, last_seen_at, expires_at) VALUES ('${hash(CLIO_SESSION)}', 'clio1', 'passkey', '${at}', '${at}', '${later}')`, | |
| 111 | 115 | ].join("; "), |
| 112 | 116 | ); |
| 113 | 117 | dev = spawn( |
| @@ -364,6 +368,75 @@ | ||
| 364 | 368 | } |
| 365 | 369 | }); |
| 366 | 370 | |
| 371 | +/** Runs the installed CLI; `lines` sees its stderr as it arrives (the sign-in link or code). */ | |
| 372 | +function cli(bin, args, env, lines = () => {}) { | |
| 373 | + const child = spawn(bin, args, { env: { PATH: process.env.PATH, ...env }, stdio: ["ignore", "pipe", "pipe"] }); | |
| 374 | + let out = ""; | |
| 375 | + let err = ""; | |
| 376 | + child.stdout.on("data", (d) => (out += d)); | |
| 377 | + child.stderr.on("data", (d) => { | |
| 378 | + err += d; | |
| 379 | + lines(err); | |
| 380 | + }); | |
| 381 | + return new Promise((resolve) => child.on("close", (code) => resolve({ code, out, err }))); | |
| 382 | +} | |
| 383 | + | |
| 384 | +test("CLI: one-line install, then sign-in in the browser and with a device code", async () => { | |
| 385 | + // ------------------------------------------------------------ install from this server | |
| 386 | + const home = join(work, "cli-home"); | |
| 387 | + execFileSync("mkdir", ["-p", home]); | |
| 388 | + const installed = execFileSync("bash", ["-c", `curl -fsSL ${base}/install | bash`], { env: { PATH: process.env.PATH, HOME: home, SHELL: "/bin/bash" }, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }); | |
| 389 | + assert.equal(installed, ""); | |
| 390 | + const bin = join(home, ".diffinery/bin/diffinery"); | |
| 391 | + assert.match(execFileSync(bin, ["--version"], { encoding: "utf8" }), /^diffinery \d+\.\d+\.\d+/); | |
| 392 | + assert.match(readFileSync(join(home, ".bashrc"), "utf8"), /\.diffinery\/bin/); | |
| 393 | + const env = { HOME: home, XDG_CONFIG_HOME: join(home, ".config"), BROWSER: "none" }; | |
| 394 | + | |
| 395 | + const clio = await person(); | |
| 396 | + // Signed in already (the seeded session): Chrome treats localhost as secure, so a __Host- cookie works over http here. | |
| 397 | + await clio.page.goto(`${base}/login`); | |
| 398 | + await clio.page.evaluate((v) => (document.cookie = `__Host-dfy_session=${v}; path=/; secure; samesite=lax`), CLIO_SESSION); | |
| 399 | + | |
| 400 | + // ------------------------------------------------------------ browser sign-in: the person clicks Authorize | |
| 401 | + let link; | |
| 402 | + const login = cli(bin, ["login", "--url", base, "--web"], env, (err) => (link ??= /(http:\/\/localhost:\d+\/login\/cli\?\S+)/.exec(err)?.[1])); | |
| 403 | + await until("the sign-in link", async () => link); | |
| 404 | + await clio.page.goto(link); | |
| 405 | + assert.match(await text(clio.page), /Authorize the Diffinery CLI/); | |
| 406 | + assert.match(await text(clio.page), /client wants to sign in as clio/); | |
| 407 | + if (process.env.E2E_SCREENSHOTS) await clio.page.screenshot({ path: join(process.env.E2E_SCREENSHOTS, "cli-authorize.png"), fullPage: true }); | |
| 408 | + await Promise.all([clio.page.waitForURL(`${base}/login/cli/done?result=ok`), clio.page.click("button:has-text('Authorize')")]); | |
| 409 | + assert.match(await text(clio.page), /You're signed in/); | |
| 410 | + const done = await login; | |
| 411 | + assert.equal(done.code, 0, done.err); | |
| 412 | + assert.match(done.out, /Logged in to .* as clio with write access/); | |
| 413 | + assert.doesNotMatch(done.out + done.err, /dfy_/); | |
| 414 | + assert.match((await cli(bin, ["whoami"], env)).out, /^clio/); | |
| 415 | + await clio.page.goto(`${base}/settings/tokens`); | |
| 416 | + assert.match(await text(clio.page), /Diffinery CLI on .*CLI sign-in/); | |
| 417 | + | |
| 418 | + // ------------------------------------------------------------ device sign-in: the person types the code | |
| 419 | + const env2 = { ...env, XDG_CONFIG_HOME: join(home, ".config2") }; | |
| 420 | + let code; | |
| 421 | + const device = cli(bin, ["login", "--url", base, "--device", "--scope", "read"], env2, (err) => (code ??= /code: ([A-Z]{4}-[A-Z]{4})/.exec(err)?.[1])); | |
| 422 | + await until("the device code", async () => code); | |
| 423 | + await clio.page.goto(`${base}/login/device`); | |
| 424 | + await clio.page.fill("#code", code.toLowerCase()); | |
| 425 | + await Promise.all([clio.page.waitForLoadState("load"), clio.page.click("button:has-text('Continue')")]); | |
| 426 | + assert.match(await text(clio.page), new RegExp(`your terminal shows this code: ${code}`)); | |
| 427 | + if (process.env.E2E_SCREENSHOTS) await clio.page.screenshot({ path: join(process.env.E2E_SCREENSHOTS, "cli-device.png"), fullPage: true }); | |
| 428 | + await Promise.all([clio.page.waitForLoadState("load"), clio.page.click("button:has-text('Authorize')")]); | |
| 429 | + const viaDevice = await device; | |
| 430 | + assert.equal(viaDevice.code, 0, viaDevice.err); | |
| 431 | + assert.match(viaDevice.out, /as clio with read access/); | |
| 432 | + | |
| 433 | + // ------------------------------------------------------------ logout revokes the CLI's token | |
| 434 | + const token = JSON.parse(readFileSync(join(home, ".config2/diffinery/config.json"), "utf8")).token; | |
| 435 | + const out = await cli(bin, ["logout"], env2); | |
| 436 | + assert.match(out.out, /revoked the CLI's token/); | |
| 437 | + assert.equal((await fetch(`${base}/api/v3/user`, { headers: { authorization: `Bearer ${token}` } })).status, 401); | |
| 438 | +}); | |
| 439 | + | |
| 367 | 440 | test("open sign-up: off by default; on, an emailed code, then a passkey", async () => { |
| 368 | 441 | // ------------------------------------------------------------ off (this file's Worker has no SIGNUP) |
| 369 | 442 | assert.equal((await fetch(`${base}/signup`)).status, 404); |
addedapps/diffinery/test/unit/cli-login.test.ts+265−0View file
| @@ -1,0 +1,265 @@ | ||
| 1 | +/** | |
| 2 | + * Signing the CLI in through the browser (auth/cli-login.ts): the loopback flow with PKCE, the device | |
| 3 | + * code flow, single use, expiry, denial, the recent sign-in rule, and `diffinery logout` revoking only | |
| 4 | + * CLI tokens. Runs the real router over node:sqlite with every migration. | |
| 5 | + */ | |
| 6 | +import { createHash, randomBytes } from "node:crypto"; | |
| 7 | +import { describe, expect, it } from "vitest"; | |
| 8 | +import { app } from "../../src/app.js"; | |
| 9 | +import { web } from "../../src/web/routes.js"; | |
| 10 | +import type { Env } from "../../src/env.js"; | |
| 11 | +import { cleanClient, formatUserCode, normalizeUserCode, pkceChallenge } from "../../src/auth/cli-login.js"; | |
| 12 | +import { bucketsFor } from "../../src/lib/rate-limit.js"; | |
| 13 | +import { testD1 } from "./stubs/d1.js"; | |
| 14 | + | |
| 15 | +app.route("/", web); | |
| 16 | + | |
| 17 | +const ORIGIN = "https://diffinery.example"; | |
| 18 | +const hash = (s: string) => createHash("sha256").update(s).digest("hex"); | |
| 19 | +const CSRF = "csrf-token-0123456789abcdef"; | |
| 20 | + | |
| 21 | +function setup() { | |
| 22 | + const DB = testD1(); | |
| 23 | + const at = new Date().toISOString(); | |
| 24 | + const old = new Date(Date.now() - 5 * 3600_000).toISOString(); | |
| 25 | + const later = new Date(Date.now() + 30 * 86400_000).toISOString(); | |
| 26 | + DB.sqlite.exec(`INSERT INTO accounts (id, login, kind, name, email, status, created_at) VALUES ('u-ana', 'ana', 'user', 'Ana', 'ana@example.com', 'active', '${at}')`); | |
| 27 | + DB.sqlite.exec(`INSERT INTO sessions (id, account_id, method, created_at, last_seen_at, expires_at) VALUES ('${hash("ana-fresh-session-0000000000001")}', 'u-ana', 'passkey', '${at}', '${at}', '${later}')`); | |
| 28 | + DB.sqlite.exec(`INSERT INTO sessions (id, account_id, method, created_at, last_seen_at, expires_at) VALUES ('${hash("ana-stale-session-0000000000002")}', 'u-ana', 'passkey', '${old}', '${at}', '${later}')`); | |
| 29 | + DB.sqlite.exec(`INSERT INTO tokens (id, account_id, name, token_hash, prefix, scopes, expires_at, created_at) VALUES ('t-ci', 'u-ana', 'ci', '${hash("dfy_ana_ci")}', 'dfy_ana_ci', '["write"]', '${later}', '${at}')`); | |
| 30 | + const env = { PUBLIC_URL: ORIGIN, DB, BOOTSTRAP_ADMINS: "" } as unknown as Env; | |
| 31 | + const waits: Promise<unknown>[] = []; | |
| 32 | + const ctx = { waitUntil: (p: Promise<unknown>) => void waits.push(p), passThroughOnException() {}, props: {} } as unknown as ExecutionContext; | |
| 33 | + const fetch = async (path: string, init: RequestInit = {}) => { | |
| 34 | + const res = await app.fetch(new Request(`${ORIGIN}${path}`, { redirect: "manual", ...init }), env, ctx); | |
| 35 | + await Promise.allSettled(waits); | |
| 36 | + return res; | |
| 37 | + }; | |
| 38 | + const browser = (session = "ana-fresh-session-0000000000001") => ({ | |
| 39 | + get: (path: string) => fetch(path, { headers: { cookie: `__Host-dfy_session=${session}; dfy_csrf=${CSRF}` } }), | |
| 40 | + post: (path: string, form: Record<string, string>) => { | |
| 41 | + const body = new URLSearchParams({ _csrf: CSRF, ...form }).toString(); | |
| 42 | + return fetch(path, { method: "POST", headers: { cookie: `__Host-dfy_session=${session}; dfy_csrf=${CSRF}`, "content-type": "application/x-www-form-urlencoded", "content-length": String(Buffer.byteLength(body)), origin: ORIGIN, "sec-fetch-site": "same-origin" }, body }); | |
| 43 | + }, | |
| 44 | + }); | |
| 45 | + const cli = (path: string, body: unknown, method = "POST", token?: string) => fetch(path, { method, headers: { "content-type": "application/json", ...(token ? { authorization: `Bearer ${token}` } : {}) }, body: method === "DELETE" ? undefined : JSON.stringify(body) }); | |
| 46 | + const tokens = () => DB.sqlite.prepare("SELECT id, name, scopes, client, revoked_at FROM tokens WHERE account_id = 'u-ana' ORDER BY created_at").all() as Array<{ id: string; name: string; scopes: string; client: string | null; revoked_at: string | null }>; | |
| 47 | + return { DB, fetch, browser, cli, tokens }; | |
| 48 | +} | |
| 49 | + | |
| 50 | +/** What the CLI does before opening the browser. */ | |
| 51 | +async function pkce() { | |
| 52 | + const verifier = randomBytes(32).toString("base64url"); | |
| 53 | + const state = randomBytes(16).toString("base64url"); | |
| 54 | + return { verifier, state, challenge: await pkceChallenge(verifier) }; | |
| 55 | +} | |
| 56 | + | |
| 57 | +const authorizeUrl = (p: { state: string; challenge: string }, extra: Record<string, string> = {}) => | |
| 58 | + `/login/cli?${new URLSearchParams({ port: "53123", state: p.state, code_challenge: p.challenge, code_challenge_method: "S256", scope: "write", host: "ana-laptop", os: "darwin-arm64", version: "0.1.0", ...extra })}`; | |
| 59 | + | |
| 60 | +describe("CLI sign-in in the browser", () => { | |
| 61 | + it("shows who is asking, and sends the approval to the CLI's callback on 127.0.0.1 only", async () => { | |
| 62 | + const t = setup(); | |
| 63 | + const p = await pkce(); | |
| 64 | + const page = await t.browser().get(authorizeUrl(p)); | |
| 65 | + expect(page.status).toBe(200); | |
| 66 | + const html = await page.text(); | |
| 67 | + expect(html).toContain("Authorize the Diffinery CLI"); | |
| 68 | + expect(html).toContain("ana-laptop"); | |
| 69 | + expect(html).toContain("darwin-arm64"); | |
| 70 | + // The page's forms may lead to the CLI's port, nowhere else. | |
| 71 | + expect(page.headers.get("content-security-policy")).toContain("form-action 'self' http://127.0.0.1:53123;"); | |
| 72 | + expect(page.headers.get("x-frame-options")).toBe("DENY"); | |
| 73 | + | |
| 74 | + const res = await t.browser().post("/login/cli", { port: "53123", state: p.state, code_challenge: p.challenge, host: "ana-laptop", os: "darwin-arm64", version: "0.1.0", scope: "admin", decision: "approve" }); | |
| 75 | + expect(res.status).toBe(303); | |
| 76 | + const to = new URL(res.headers.get("location")!); | |
| 77 | + expect(to.origin).toBe("http://127.0.0.1:53123"); | |
| 78 | + expect(to.pathname).toBe("/callback"); | |
| 79 | + expect(to.searchParams.get("state")).toBe(p.state); | |
| 80 | + const code = to.searchParams.get("code")!; | |
| 81 | + expect(code.length).toBeGreaterThan(40); | |
| 82 | + // Nothing is minted until the CLI trades the code. | |
| 83 | + expect(t.tokens().filter((k) => k.client === "cli")).toHaveLength(0); | |
| 84 | + | |
| 85 | + // The code alone is useless: a wrong verifier burns it. | |
| 86 | + const wrong = await t.cli("/-/cli/token", { grant_type: "authorization_code", code, code_verifier: randomBytes(32).toString("base64url") }); | |
| 87 | + expect(wrong.status).toBe(400); | |
| 88 | + expect((await wrong.json()) as unknown).toMatchObject({ error: "invalid_grant" }); | |
| 89 | + const after = await t.cli("/-/cli/token", { grant_type: "authorization_code", code, code_verifier: p.verifier }); | |
| 90 | + expect(((await after.json()) as { error: string }).error).toBe("invalid_grant"); | |
| 91 | + expect(t.tokens().filter((k) => k.client === "cli")).toHaveLength(0); | |
| 92 | + }); | |
| 93 | + | |
| 94 | + it("trades the code and verifier for a CLI token once, with the scope the person chose", async () => { | |
| 95 | + const t = setup(); | |
| 96 | + const p = await pkce(); | |
| 97 | + const res = await t.browser().post("/login/cli", { port: "53123", state: p.state, code_challenge: p.challenge, host: "ana-laptop", os: "linux-x64", version: "0.1.0", scope: "read", decision: "approve" }); | |
| 98 | + const code = new URL(res.headers.get("location")!).searchParams.get("code")!; | |
| 99 | + const ok = await t.cli("/-/cli/token", { grant_type: "authorization_code", code, code_verifier: p.verifier }); | |
| 100 | + expect(ok.status).toBe(200); | |
| 101 | + expect(ok.headers.get("cache-control")).toBe("no-store"); | |
| 102 | + const body = (await ok.json()) as { access_token: string; scope: string; login: string; expires_at: string }; | |
| 103 | + expect(body.access_token).toMatch(/^dfy_/); | |
| 104 | + expect(body).toMatchObject({ scope: "read", login: "ana" }); | |
| 105 | + expect(Date.parse(body.expires_at) - Date.now()).toBeGreaterThan(89 * 86400_000); | |
| 106 | + const cli = t.tokens().find((k) => k.client === "cli")!; | |
| 107 | + expect(cli).toMatchObject({ name: "Diffinery CLI on ana-laptop", scopes: '["read"]', revoked_at: null }); | |
| 108 | + // The token works, as the person, with its scope. | |
| 109 | + const me = await t.fetch("/api/v3/user", { headers: { authorization: `Bearer ${body.access_token}` } }); | |
| 110 | + expect(((await me.json()) as { login: string }).login).toBe("ana"); | |
| 111 | + // Single use. | |
| 112 | + const again = await t.cli("/-/cli/token", { grant_type: "authorization_code", code, code_verifier: p.verifier }); | |
| 113 | + expect(((await again.json()) as { error: string }).error).toBe("invalid_grant"); | |
| 114 | + // It shows up in Settings → Access tokens as a CLI sign-in. | |
| 115 | + const list = await (await t.browser().get("/settings/tokens")).text(); | |
| 116 | + expect(list).toContain("Diffinery CLI on ana-laptop"); | |
| 117 | + expect(list).toContain("CLI sign-in"); | |
| 118 | + // The sign-in is audited. | |
| 119 | + const audit = t.DB.sqlite.prepare("SELECT detail FROM audit_log WHERE action = 'token.create'").all() as Array<{ detail: string }>; | |
| 120 | + expect(audit.map((a) => a.detail).join()).toContain("cli browser sign-in"); | |
| 121 | + }); | |
| 122 | + | |
| 123 | + it("Cancel tells the CLI it was denied and creates nothing", async () => { | |
| 124 | + const t = setup(); | |
| 125 | + const p = await pkce(); | |
| 126 | + const res = await t.browser().post("/login/cli", { port: "53123", state: p.state, code_challenge: p.challenge, host: "h", decision: "deny" }); | |
| 127 | + const to = new URL(res.headers.get("location")!); | |
| 128 | + expect(to.origin).toBe("http://127.0.0.1:53123"); | |
| 129 | + expect(Object.fromEntries(to.searchParams)).toEqual({ error: "access_denied", state: p.state }); | |
| 130 | + expect(t.DB.sqlite.prepare("SELECT count(*) AS n FROM cli_logins").get()).toEqual({ n: 0 }); | |
| 131 | + }); | |
| 132 | + | |
| 133 | + it("needs a recent passkey sign-in, and sends signed-out people to sign in first", async () => { | |
| 134 | + const t = setup(); | |
| 135 | + const p = await pkce(); | |
| 136 | + const stale = await t.browser("ana-stale-session-0000000000002").get(authorizeUrl(p)); | |
| 137 | + expect(stale.status).toBe(303); | |
| 138 | + const confirm = new URL(stale.headers.get("location")!, ORIGIN); | |
| 139 | + expect(confirm.pathname).toBe("/login"); | |
| 140 | + expect(confirm.searchParams.get("confirm")).toBe("1"); | |
| 141 | + expect(confirm.searchParams.get("next")).toBe(authorizeUrl(p)); | |
| 142 | + const post = await t.browser("ana-stale-session-0000000000002").post("/login/cli", { port: "53123", state: p.state, code_challenge: p.challenge, decision: "approve" }); | |
| 143 | + expect(new URL(post.headers.get("location")!, ORIGIN).pathname).toBe("/login"); | |
| 144 | + expect(t.DB.sqlite.prepare("SELECT count(*) AS n FROM cli_logins").get()).toEqual({ n: 0 }); | |
| 145 | + | |
| 146 | + const anon = await t.fetch(authorizeUrl(p)); | |
| 147 | + expect(anon.status).toBe(302); | |
| 148 | + expect(new URL(anon.headers.get("location")!, ORIGIN).searchParams.get("next")).toBe(authorizeUrl(p)); | |
| 149 | + }); | |
| 150 | + | |
| 151 | + it("refuses links that could send the code anywhere but a local port", async () => { | |
| 152 | + const t = setup(); | |
| 153 | + const p = await pkce(); | |
| 154 | + for (const extra of [{ port: "80" }, { port: "70000" }, { port: "53123@evil.example" }, { code_challenge: "short" }, { code_challenge_method: "plain" }, { state: "" }]) { | |
| 155 | + const res = await t.browser().get(authorizeUrl(p, extra)); | |
| 156 | + expect(res.status, JSON.stringify(extra)).toBe(400); | |
| 157 | + expect(await res.text()).toContain("This sign-in link is incomplete"); | |
| 158 | + } | |
| 159 | + const res = await t.browser().post("/login/cli", { port: "22", state: p.state, code_challenge: p.challenge, decision: "approve" }); | |
| 160 | + expect(res.status).toBe(400); | |
| 161 | + }); | |
| 162 | + | |
| 163 | + it("a token can't approve: the form needs a browser", async () => { | |
| 164 | + const t = setup(); | |
| 165 | + const p = await pkce(); | |
| 166 | + const body = new URLSearchParams({ port: "53123", state: p.state, code_challenge: p.challenge, decision: "approve" }).toString(); | |
| 167 | + const res = await t.fetch("/login/cli", { method: "POST", headers: { authorization: "Bearer dfy_ana_ci", "content-type": "application/x-www-form-urlencoded", "content-length": String(body.length) }, body }); | |
| 168 | + expect(res.status).toBe(403); | |
| 169 | + }); | |
| 170 | +}); | |
| 171 | + | |
| 172 | +describe("CLI sign-in with a device code", () => { | |
| 173 | + const start = async (t: ReturnType<typeof setup>, scope = "write") => { | |
| 174 | + const r = await t.cli("/-/cli/device", { client: { host: "build-box", os: "linux-x64", version: "0.1.0" }, scope }); | |
| 175 | + expect(r.status).toBe(200); | |
| 176 | + return (await r.json()) as { device_code: string; user_code: string; verification_uri: string; expires_in: number; interval: number }; | |
| 177 | + }; | |
| 178 | + const poll = async (t: ReturnType<typeof setup>, device_code: string) => (await t.cli("/-/cli/token", { grant_type: "urn:ietf:params:oauth:grant-type:device_code", device_code })).json() as Promise<Record<string, string>>; | |
| 179 | + | |
| 180 | + it("waits for the person, then hands the CLI its token once", async () => { | |
| 181 | + const t = setup(); | |
| 182 | + const d = await start(t); | |
| 183 | + expect(d.user_code).toMatch(/^[BCDFGHJKLMNPQRSTVWXZ]{4}-[BCDFGHJKLMNPQRSTVWXZ]{4}$/); | |
| 184 | + expect(d.verification_uri).toBe(`${ORIGIN}/login/device`); | |
| 185 | + expect(d).not.toHaveProperty("verification_uri_complete"); | |
| 186 | + expect(d).toMatchObject({ expires_in: 600, interval: 5 }); | |
| 187 | + expect((await poll(t, d.device_code)).error).toBe("authorization_pending"); | |
| 188 | + | |
| 189 | + // Typed in any case, with or without the dash. | |
| 190 | + const page = await t.browser().post("/login/device", { code: d.user_code.replace("-", "").toLowerCase() }); | |
| 191 | + expect(page.status).toBe(200); | |
| 192 | + const html = await page.text(); | |
| 193 | + expect(html).toContain("build-box"); | |
| 194 | + expect(html).toContain(d.user_code); | |
| 195 | + const id = /name="id" value="([^"]+)"/.exec(html)![1]!; | |
| 196 | + const done = await t.browser().post("/login/device/decide", { id, scope: "admin", decision: "approve" }); | |
| 197 | + expect(await done.text()).toMatch(/You('|')re signed in/); | |
| 198 | + | |
| 199 | + const got = await poll(t, d.device_code); | |
| 200 | + expect(got.access_token).toMatch(/^dfy_/); | |
| 201 | + expect(got).toMatchObject({ scope: "admin", login: "ana" }); | |
| 202 | + expect((await poll(t, d.device_code)).error).toBe("invalid_grant"); | |
| 203 | + // Approving again changes nothing. | |
| 204 | + expect(await (await t.browser().post("/login/device/decide", { id, decision: "approve" })).text()).toContain("already answered"); | |
| 205 | + }); | |
| 206 | + | |
| 207 | + it("tells the CLI when the person cancels, and an unknown code finds nothing", async () => { | |
| 208 | + const t = setup(); | |
| 209 | + const d = await start(t, "read"); | |
| 210 | + const html = await (await t.browser().post("/login/device", { code: d.user_code })).text(); | |
| 211 | + const id = /name="id" value="([^"]+)"/.exec(html)![1]!; | |
| 212 | + await t.browser().post("/login/device/decide", { id, decision: "deny" }); | |
| 213 | + expect((await poll(t, d.device_code)).error).toBe("access_denied"); | |
| 214 | + expect((await poll(t, "not-a-device-code")).error).toBe("invalid_grant"); | |
| 215 | + const unknown = await t.browser().post("/login/device", { code: "BCDF-GHJK" }); | |
| 216 | + expect(unknown.status).toBe(404); | |
| 217 | + expect(await unknown.text()).toMatch(/isn('|')t valid or has expired/); | |
| 218 | + }); | |
| 219 | + | |
| 220 | + it("expires", async () => { | |
| 221 | + const t = setup(); | |
| 222 | + const d = await start(t); | |
| 223 | + t.DB.sqlite.exec(`UPDATE cli_logins SET expires_at = '${new Date(Date.now() - 1000).toISOString()}'`); | |
| 224 | + expect((await poll(t, d.device_code)).error).toBe("expired_token"); | |
| 225 | + expect((await t.browser().post("/login/device", { code: d.user_code })).status).toBe(404); | |
| 226 | + }); | |
| 227 | + | |
| 228 | + it("refuses an unknown scope and counts starts against the sign-in rate limit", async () => { | |
| 229 | + const t = setup(); | |
| 230 | + expect((await t.cli("/-/cli/device", { scope: "root" })).status).toBe(422); | |
| 231 | + expect(bucketsFor("POST", "/-/cli/device", { type: "anonymous" }, "203.0.113.1")).toEqual([["RL_AUTH", "ip:203.0.113.1"]]); | |
| 232 | + expect(bucketsFor("POST", "/login/device", { type: "anonymous" }, "203.0.113.1")).toEqual([["RL_AUTH", "ip:203.0.113.1"]]); | |
| 233 | + }); | |
| 234 | +}); | |
| 235 | + | |
| 236 | +describe("diffinery logout", () => { | |
| 237 | + it("revokes a CLI token with itself, and leaves other tokens alone", async () => { | |
| 238 | + const t = setup(); | |
| 239 | + const p = await pkce(); | |
| 240 | + const res = await t.browser().post("/login/cli", { port: "53123", state: p.state, code_challenge: p.challenge, host: "h", decision: "approve" }); | |
| 241 | + const code = new URL(res.headers.get("location")!).searchParams.get("code")!; | |
| 242 | + const { access_token } = (await (await t.cli("/-/cli/token", { grant_type: "authorization_code", code, code_verifier: p.verifier })).json()) as { access_token: string }; | |
| 243 | + expect(await (await t.cli("/-/cli/token", null, "DELETE", "dfy_ana_ci")).json()).toEqual({ revoked: false }); | |
| 244 | + expect(t.tokens().find((k) => k.id === "t-ci")!.revoked_at).toBeNull(); | |
| 245 | + expect(await (await t.cli("/-/cli/token", null, "DELETE", access_token)).json()).toEqual({ revoked: true }); | |
| 246 | + expect((await t.fetch("/api/v3/user", { headers: { authorization: `Bearer ${access_token}` } })).status).toBe(401); | |
| 247 | + expect((await t.cli("/-/cli/token", null, "DELETE")).status).toBe(401); | |
| 248 | + }); | |
| 249 | +}); | |
| 250 | + | |
| 251 | +describe("codes and client details", () => { | |
| 252 | + it("normalizes typed codes", () => { | |
| 253 | + expect(normalizeUserCode("bcdf-ghjk")).toBe("BCDFGHJK"); | |
| 254 | + expect(normalizeUserCode(" BCDF GHJK ")).toBe("BCDFGHJK"); | |
| 255 | + expect(normalizeUserCode("BCDF-GHJA")).toBeNull(); | |
| 256 | + expect(normalizeUserCode("BCDF-GHJ")).toBeNull(); | |
| 257 | + expect(formatUserCode("BCDFGHJK")).toBe("BCDF-GHJK"); | |
| 258 | + }); | |
| 259 | + | |
| 260 | + it("keeps what the CLI says about itself short and plain", () => { | |
| 261 | + expect(cleanClient({ host: "<script>alert(1)</script>", os: 42, version: "0.1.0" })).toEqual({ host: "scriptalert(1)/script", os: "", version: "0.1.0" }); | |
| 262 | + expect(cleanClient({}).host).toBe("unknown host"); | |
| 263 | + expect(cleanClient({ host: "x".repeat(200) }).host).toHaveLength(64); | |
| 264 | + }); | |
| 265 | +}); |
modifiedapps/diffinery/test/unit/docs.test.ts+15−0View file
| @@ -211,6 +211,21 @@ | ||
| 211 | 211 | expect(await (await get("/-/cli/diffinery.mjs")).text()).toMatch(/^#!\/usr\/bin\/env node\n/); |
| 212 | 212 | }); |
| 213 | 213 | |
| 214 | + it("serves the install scripts, pointed at this server", async () => { | |
| 215 | + for (const path of ["/install", "/install.sh"]) { | |
| 216 | + const res = await get(path); | |
| 217 | + expect(res.status, path).toBe(200); | |
| 218 | + expect(res.headers.get("content-type")).toContain("text/plain"); | |
| 219 | + const sh = await res.text(); | |
| 220 | + expect(sh).toMatch(/^#!\/usr\/bin\/env bash\n/); | |
| 221 | + expect(sh).toContain(`server=\${DIFFINERY_URL:-${ORIGIN}}`); | |
| 222 | + expect(sh).not.toContain("__DIFFINERY_URL__"); | |
| 223 | + } | |
| 224 | + const ps1 = await (await get("/install.ps1")).text(); | |
| 225 | + expect(ps1).toContain(`'${ORIGIN}'`); | |
| 226 | + expect(ps1).toContain("/-/cli/diffinery.mjs"); | |
| 227 | + }); | |
| 228 | + | |
| 214 | 229 | it("returns 404 for unknown pages", async () => { |
| 215 | 230 | expect((await get("/docs/no-such-page")).status).toBe(404); |
| 216 | 231 | expect((await get("/docs/no-such-page.md")).status).toBe(404); |