Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

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
test-bot authored 1 parent d8f55cbcommit 844812ff12a3662e35afa8349c50860676ba412eBrowse files
52 files changed+1917−106
Files (52)
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 @@
3535 ) {}
3636
3737 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}`}`;
3939 const u = new URL(p);
4040 for (const [k, v] of Object.entries(query ?? {})) if (v !== undefined && v !== null && v !== "") u.searchParams.set(k, String(v));
4141 return u.toString();
@@ -70,6 +70,7 @@
7070 const msg = data && typeof data === "object" && "message" in data ? String((data as { message: unknown }).message) : text.slice(0, 300) || res.statusText;
7171 let hint = "";
7272 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)`;
7374 throw new ApiError(res.status, `${method} ${new URL(url).pathname}: ${res.status} ${msg}${hint}`, data);
7475 }
7576 return { status: res.status, data: data as T, headers: res.headers };
modifiedapps/cli/src/bin.ts+6−0View file
@@ -1,5 +1,11 @@
11 import { main } from "./main.js";
22
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+
39 // EPIPE when piping into `head` and similar should end quietly.
410 process.stdout.on("error", (e: NodeJS.ErrnoException) => {
511 if (e.code === "EPIPE") process.exit(0);
modifiedapps/cli/src/commands/auth.ts+41−12View file
@@ -1,8 +1,9 @@
11 import { Api } from "../api.js";
22 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";
44 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";
67 import { json } from "../output.js";
78
89 interface User {
@@ -13,26 +14,52 @@
1314 html_url?: string;
1415 }
1516
17+const SCOPES = ["read", "write", "admin"] as const;
18+
1619 const login: Command = {
1720 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.`,
2229 options: {
2330 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" },
2536 },
2637 maxArgs: 0,
2738 async run(ctx, o) {
2839 const stored = await readConfig(ctx.io.env);
2940 const cfg = await resolveConfig(ctx.io);
3041 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+ }
3358 const me = await new Api(url, token).get<User>("/user");
3459 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}`);
3663 ctx.err(ctx.c.dim(`Saved to ${file}`));
3764 if (ctx.io.env.DIFFINERY_TOKEN) ctx.err(ctx.c.yellow("note: DIFFINERY_TOKEN is set and overrides the saved token"));
3865 },
@@ -40,14 +67,16 @@
4067
4168 const logout: Command = {
4269 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",
4471 maxArgs: 0,
4572 async run(ctx) {
4673 const stored = await readConfig(ctx.io.env);
4774 if (!stored.token) ctx.out("Not logged in.");
4875 else {
76+ const url = stored.url ? normalizeUrl(stored.url) : DEFAULT_URL;
77+ const revoked = await revokeCliToken(url, stored.token);
4978 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" : ""}.`);
5180 }
5281 if (ctx.io.env.DIFFINERY_TOKEN) ctx.err(ctx.c.yellow("note: DIFFINERY_TOKEN is still set in your environment"));
5382 },
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 @@
99 stderr: { write(s: string): unknown; isTTY?: boolean };
1010 /** Overridable for tests (polling loops). */
1111 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;
1216 }
1317
1418 export function sleep(ms: number, signal?: AbortSignal): Promise<void> {
@@ -25,7 +29,7 @@
2529 }
2630
2731 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] };
2933 }
3034
3135 /** 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 @@
77 import { repoGroup } from "./commands/repo.js";
88 import { runGroup } from "./commands/run.js";
99 import { runnerCommand } from "./commands/runner.js";
10+import { upgradeCommand } from "./commands/upgrade.js";
1011 import { workflowGroup } from "./commands/workflow.js";
1112 import { CliError, processIo, type Io } from "./io.js";
1213
1314 export const registry: Registry = {
1415 groups: [authGroup, repoGroup, prGroup, issueGroup, runGroup, workflowGroup, secretGroup, variableGroup, tokenGroup, agentGroup, migrateGroup],
15- commands: [...authCommands, ...adminCommands, runnerCommand],
16+ commands: [...authCommands, ...adminCommands, runnerCommand, upgradeCommand],
1617 };
1718
1819 /** Runs the CLI and returns its exit code (never calls process.exit). */
modifiedapps/cli/test/helpers.ts+5−0View file
@@ -107,6 +107,9 @@
107107 cwd?: string;
108108 stdin?: string;
109109 stdinTTY?: boolean;
110+ /** Stands in for the browser when the CLI opens one. */
111+ openUrl?: (url: string) => Promise<boolean>;
112+ selfPath?: string;
110113 }
111114
112115 /** An isolated environment: HOME/XDG in a temp dir, git with no global config. */
@@ -140,6 +143,8 @@
140143 stdout: { write: (s: string) => ((stdout += s), true) },
141144 stderr: { write: (s: string) => ((stderr += s), true) },
142145 sleep: () => new Promise((r) => setTimeout(r, 1)),
146+ openUrl: o.openUrl,
147+ selfPath: o.selfPath,
143148 };
144149 const code = await main(argv, io);
145150 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 @@
9797 `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:
9898
9999 ```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
102102 ```
103103
104104 The CLI needs Node.js 22 or later.
modifiedapps/diffinery/docs/accounts/passkeys-and-sign-in.md+1−1View file
@@ -5,7 +5,7 @@
55
66 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.
77
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).
99
1010 ## How passkey sign-in works
1111
modifiedapps/diffinery/docs/accounts/tokens.md+4−4View file
@@ -83,10 +83,10 @@
8383
8484 ### With the CLI
8585
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:
8787
8888 ```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
9090 diffinery login
9191 ```
9292
@@ -210,14 +210,14 @@
210210
211211 ### The CLI
212212
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:
214214
215215 ```sh
216216 export DIFFINERY_TOKEN=dfy_...
217217 diffinery whoami
218218 ```
219219
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).
221221
222222 ### MCP clients
223223
modifiedapps/diffinery/docs/actions/caching-and-artifacts.md+1−1View file
@@ -493,7 +493,7 @@
493493 https://diffinery.com/api/v3/repos/acme/website/actions/artifacts/1187/zip -o site.zip
494494 ```
495495
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:
497497
498498 ```sh
499499 diffinery api GET /repos/acme/website/actions/runs/128/artifacts
modifiedapps/diffinery/docs/actions/overview.md+2−2View file
@@ -72,10 +72,10 @@
7272
7373 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.
7474
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:
7676
7777 ```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
7979 diffinery login
8080 ```
8181
modifiedapps/diffinery/docs/actions/runners.md+1−1View file
@@ -168,7 +168,7 @@
168168 - **The `diffinery` CLI.** Install it with:
169169
170170 ```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
172172 ```
173173
174174 - **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 @@
3737
3838 ### Secrets with the CLI
3939
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:
4141
4242 ```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
4445 ```
4546
4647 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 @@
162162 -d '{"default_workflow_permissions": "write", "can_approve_pull_request_reviews": true}'
163163 ```
164164
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`):
166166
167167 ```sh
168168 diffinery api GET /repos/acme/website/actions/permissions/workflow
modifiedapps/diffinery/docs/admin/site-administration.md+2−2View file
@@ -436,10 +436,10 @@
436436
437437 ## Administration from the CLI
438438
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:
440440
441441 ```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
443443 diffinery login
444444
445445 # Invite someone to the instance (site administrators only)
modifiedapps/diffinery/docs/agents/overview.md+2−2View file
@@ -36,8 +36,8 @@
3636 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:
3737
3838 ```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
4141 ```
4242
4343 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 @@
6161
6262 ### Open an issue with the CLI
6363
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:
6565
6666 ```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
6868 diffinery login
6969 ```
7070
modifiedapps/diffinery/docs/collaboration/pull-requests.md+2−2View file
@@ -64,10 +64,10 @@
6464
6565 ### With the CLI
6666
67-Install the CLI (needs Node.js 22 or later) and log in:
67+Install the CLI and sign in through your browser:
6868
6969 ```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
7171 diffinery login
7272 ```
7373
modifiedapps/diffinery/docs/collaboration/releases.md+1−1View file
@@ -160,7 +160,7 @@
160160
161161 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.
162162
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`):
164164
165165 ```sh
166166 cat > release.json <<'EOF'
modifiedapps/diffinery/docs/migrate-from-github.md+3−3View file
@@ -164,11 +164,11 @@
164164
165165 ## Import with the CLI
166166
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):
168168
169169 ```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
172172 ```
173173
174174 ### diffinery migrate github
modifiedapps/diffinery/docs/nav.json+1−0View file
@@ -16,6 +16,7 @@
1616 "accounts/organizations",
1717 "accounts/permissions",
1818 "accounts/tokens",
19+ "accounts/cli-sign-in",
1920 "accounts/notifications",
2021 "accounts/blocking-and-reporting",
2122 "accounts/profile-and-preferences",
modifiedapps/diffinery/docs/quickstart.md+33−26View file
@@ -1,6 +1,6 @@
11 ---
22 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.
44 ---
55
66 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 @@
1111
1212 - A browser that supports passkeys (current Chrome, Safari, Edge or Firefox).
1313 - Git 2.26 or newer (`git --version`).
14-- Node.js 22 or later, for the `diffinery` CLI (`node --version`).
1514 - 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.
1615
1716 ## 1. Join with your invite and create a passkey
@@ -26,44 +25,52 @@
2625
2726 > **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).
2827
29-## 2. Create a token
28+## 2. Install the CLI and sign in
3029
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:
3231
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+```
3535
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:
4137
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+```
4441
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).
4643
47-## 3. Install the CLI and log in
44+Sign in through your browser and let Git use the same sign-in:
4845
49-Install the `diffinery` command-line client (needs Node.js 22 or later; make sure `~/.local/bin` exists and is on your `PATH`):
50-
5146 ```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
5350 ```
5451
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.
5653
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
6255
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.
6457
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**:
6660
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+
6774 ## 4. Create a repository
6875
6976 Pick one of these.
modifiedapps/diffinery/docs/reference/rest-api.md+3−3View file
@@ -837,11 +837,11 @@
837837
838838 ### The Diffinery CLI
839839
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:
841841
842842 ```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
845845 ```
846846
847847 `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 @@
267267
268268 ### Create a rule with the CLI
269269
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`):
271271
272272 ```sh
273273 cat > rules.json <<'EOF'
modifiedapps/diffinery/docs/repositories/git.md+3−3View file
@@ -48,16 +48,16 @@
4848
4949 ### Let the CLI handle credentials
5050
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:
5252
5353 ```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
5555 ```
5656
5757 Then:
5858
5959 ```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)
6161 diffinery auth setup-git # configure git to ask diffinery for credentials
6262 git clone https://diffinery.com/acme/website.git
6363 ```
modifiedapps/diffinery/docs/repositories/overview.md+3−3View file
@@ -41,13 +41,13 @@
4141
4242 ### With the CLI
4343
44-The CLI needs a token (`diffinery login`). Install it with:
44+Install the CLI and sign in with `diffinery login`:
4545
4646 ```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
4848 ```
4949
50-It needs Node.js 22 or later. Then:
50+Then:
5151
5252 ```sh
5353 # In your own account, private by default
modifiedapps/diffinery/docs/repositories/webhooks.md+1−1View file
@@ -94,7 +94,7 @@
9494
9595 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.
9696
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`):
9898
9999 ```sh
100100 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 @@
11 // Builds the public documentation into src/docs/content.generated.ts:
22 // - every Markdown page under docs/ (with its frontmatter), in the order of docs/nav.json
33 // - 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
56 // The REST API and MCP tool references are generated at runtime from the live
67 // router and tool list (src/docs/reference.ts), so none of the references can drift.
78 //
@@ -17,6 +18,7 @@
1718 const app = path.resolve(here, "..");
1819 const docsDir = path.join(app, "docs");
1920 const cliSrc = path.resolve(app, "../cli/src");
21+const cliInstall = path.resolve(app, "../cli/install");
2022 const out = path.join(app, "src/docs/content.generated.ts");
2123
2224 /** Pages produced in code rather than from a Markdown file. */
@@ -102,23 +104,19 @@
102104 "",
103105 "## Install",
104106 "",
105- "The CLI is a single JavaScript file and needs Node.js 22 or later.",
106- "",
107107 "```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",
112110 "```",
113111 "",
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).",
115113 "",
116114 "## Configuration",
117115 "",
118116 "| Setting | Where | Notes |",
119117 "| --- | --- | --- |",
120118 "| 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. |",
122120 "| Config file | `~/.config/diffinery/config.json` (`$XDG_CONFIG_HOME/diffinery/config.json` when set) | Written by `diffinery login`. |",
123121 "",
124122 "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 @@
219217 export const DOCS_PAGES: Record<string, DocPage> = ${JSON.stringify(ordered)};
220218 export const CLI_VERSION = ${JSON.stringify(cli.VERSION)};
221219 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"))};
222222 `;
223223 await mkdir(path.dirname(out), { recursive: true });
224224 await writeFile(out, src);
modifiedapps/diffinery/src/app.ts+5−0View file
@@ -37,6 +37,8 @@
3737 import { modelProxy } from "./agentic/proxy.js";
3838 import type { MailJob } from "./mail/types.js";
3939 import { expireExports } from "./accounts/export.js";
40+import { cliLogin } from "./http/cli-login.js";
41+import { CliLogins } from "./auth/cli-login.js";
4042
4143 export const app = new Hono<{ Bindings: Env; Variables: AppVars }>();
4244
@@ -92,6 +94,7 @@
9294 app.get("/healthz", (c) => c.json({ ok: true }));
9395
9496 app.route("/", avatars);
97+app.route("/", cliLogin);
9598 app.route("/", wellKnown);
9699 app.route("/", securityTxtRoutes);
97100 app.route("/", git);
@@ -250,6 +253,8 @@
250253 mailCron(env, new Directory(env), at).catch((e) => console.error("mail cron failed", e)),
251254 // Audit log entries past their 2 years, once a day.
252255 (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)),
253258 // Account exports past their 7 days (accounts/export.ts).
254259 expireExports(env).catch((e) => console.error("export expiry failed", e)),
255260 // 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 @@
7575 if (!agent && !IMPORT_GIT_PATH.test(path)) throw new HttpError(403, "this token can only be used by its GitHub import");
7676 return { type: "account", account: v.account, via: "token", scopes: v.scopes, repoIds: v.repoIds, importId: v.importId, tokenId: v.id };
7777 }
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 };
7979 }
8080 // Job and sandbox tokens, and self-hosted runners' own tokens (which only claim jobs: runnerAuth refuses them).
8181 if (cred.startsWith("djt_") || cred.startsWith("dst_") || cred.startsWith("drn_")) {
modifiedapps/diffinery/src/docs/pages.ts+1−1View file
@@ -108,7 +108,7 @@
108108 `- All pages in one file: ${origin}/llms-full.txt`,
109109 `- The REST API as OpenAPI 3.1: ${origin}/docs/api/openapi.json`,
110110 `- 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)`,
112112 "",
113113 ];
114114 for (const s of DOCS_NAV) {
modifiedapps/diffinery/src/docs/web.tsx+10−2View file
@@ -3,7 +3,8 @@
33 * The public docs at /docs, readable without signing in, plus the formats AI
44 * agents read: /docs/<page>.md (or any page with `Accept: text/markdown`),
55 * /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`.
78 */
89 import { raw } from "hono/html";
910 import type { AppContext } from "../auth/principal.js";
@@ -11,7 +12,7 @@
1112 import { fenceHighlighter } from "../web/highlight.js";
1213 import { ICONS } from "../web/assets.js";
1314 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";
1516 import { allPages, getPage, headings, llmsFullTxt, llmsTxt, markdownUrl, nav, pageMarkdown, pageUrl, searchIndex, SLUGS, type Page } from "./pages.js";
1617 import { openApiDocument } from "./reference.js";
1718
@@ -191,6 +192,13 @@
191192 }),
192193 );
193194
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+
194202 web.get("/docs", (c) => {
195203 const p = getPage("index", origin(c))!;
196204 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 @@
446446
447447 // ---------------------------------------------------------------- tokens
448448
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 }> {
451451 const days = Math.min(Math.max(input.expiresInDays ?? 90, 1), 366);
452452 const token = `dfy_${randomToken(30)}`;
453453 const id = newId();
454454 const expiresAt = new Date(Date.now() + days * 86400_000).toISOString();
455455 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)
458458 .run();
459459 // An import's own push token is server-managed and short-lived: no "new token" email.
460460 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));
461461 return { token, id, expiresAt };
462462 }
463463
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 }>> {
465465 // Import push tokens (import_id set) are server-managed and hidden from the personal token list.
466466 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 }));
468468 }
469469
470470 async revokeToken(accountId: string, id: string): Promise<void> {
modifiedapps/diffinery/src/lib/rate-limit.ts+4−3View file
@@ -8,8 +8,9 @@
88 * the limits stop floods and scripted abuse rather than meter use exactly. A binding that isn't
99 * configured (local development) or that fails lets the request through.
1010 *
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.
1314 * - RL_ANON: everything else from someone who isn't signed in, per IP address.
1415 * - RL_ACCOUNT: everything from a signed-in person, agent or personal token, per account.
1516 * - RL_CREATE: creating things other people see or that cost storage (repositories,
@@ -32,7 +33,7 @@
3233
3334 /** Paths that never count: health checks, avatars, and the Actions runtime and runners (their own credentials). */
3435 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$)/;
3637 const CREATE: Array<[method: string, path: RegExp]> = [
3738 ["POST", /^\/api\/v3\/(?:user|orgs\/[^/]+)\/repos$/],
3839 ["POST", /^\/api\/v3\/(?:user\/orgs|admin\/organizations)$/],
modifiedapps/diffinery/src/lib/util.ts+1−1View file
@@ -79,7 +79,7 @@
7979 }
8080
8181 /** 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"]);
8383
8484 /**
8585 * 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 @@
10501050 <>
10511051 <h1>Personal access tokens</h1>
10521052 <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>
10531056 {created ? <TokenOnce c={c} token={created.token} title="Your new token" /> : null}
10541057 <div class="box">
10551058 <div class="head">
@@ -1071,7 +1074,7 @@
10711074 {tokens.map((k) => (
10721075 <tr>
10731076 <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}
10751078 <div class="faint mono small">{k.prefix}…</div>
10761079 </td>
10771080 <td>{k.scopes.join(", ")}</td>
modifiedapps/diffinery/src/web/assets.ts+2−0View file
@@ -294,6 +294,8 @@
294294 .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)}
295295 .btn.block{display:flex;width:100%;justify-content:center}
296296 .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}
297299 .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}
298300 .authfoot{margin-top:20px;padding-top:14px;border-top:1px solid var(--border)}.authfoot p{margin:0 0 6px}
299301 .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 @@
1313 import { registerSecurityReports } from "./security-reports.js";
1414 import { registerStatus } from "./status.js";
1515 import { registerAuth } from "./auth.js";
16+import { registerCliLogin } from "./cli-login.js";
1617 import { registerActions } from "./actions.js";
1718 import { registerCode } from "./code.js";
1819 import { registerCollab } from "./collab.js";
@@ -47,6 +48,7 @@
4748 // registered before the /:owner/:repo patterns they would otherwise match.
4849 registerKumoAssets(web);
4950 registerAuth(web);
51+registerCliLogin(web);
5052 registerPackages(web);
5153 registerMailWeb(web);
5254 registerSecurityReports(web);
modifiedapps/diffinery/src/web/ui.tsx+5−3View file
@@ -300,6 +300,8 @@
300300 refresh?: number;
301301 /** Load Cloudflare Turnstile (the sign-up page): its script and iframe come from challenges.cloudflare.com. */
302302 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;
303305 }
304306
305307 export const WATCH_OPTIONS = [
@@ -374,7 +376,7 @@
374376 );
375377 };
376378
377-function csp(nonce: string, c: AppContext, turnstile?: boolean): string {
379+function csp(nonce: string, c: AppContext, turnstile?: boolean, formAction?: string): string {
378380 const host = new URL(c.req.url).host;
379381 return [
380382 "default-src 'self'",
@@ -387,7 +389,7 @@
387389 "font-src 'self'",
388390 "object-src 'none'",
389391 "base-uri 'none'",
390- "form-action 'self'",
392+ `form-action 'self'${formAction ? ` ${formAction}` : ""}`,
391393 "frame-ancestors 'none'",
392394 ].join("; ");
393395 }
@@ -422,7 +424,7 @@
422424 const p = c.get("principal");
423425 const me = p?.type === "account" ? p.account : null;
424426 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));
426428 c.header("x-frame-options", "DENY");
427429 c.header("x-content-type-options", "nosniff");
428430 c.header("referrer-policy", "same-origin");
modifiedapps/diffinery/test/e2e/passkeys.test.mjs+74−1View file
@@ -13,7 +13,7 @@
1313 // passkey, and the sign-up pages answering 404 while it is off.
1414 import { spawn, execFileSync } from "node:child_process";
1515 import { createHash, createHmac, randomBytes } from "node:crypto";
16-import { mkdtempSync, rmSync } from "node:fs";
16+import { mkdtempSync, readFileSync, rmSync } from "node:fs";
1717 import { tmpdir } from "node:os";
1818 import { join, dirname, resolve } from "node:path";
1919 import { fileURLToPath } from "node:url";
@@ -34,6 +34,8 @@
3434 /** boss is also signed in on another browser (say, a lost laptop), and has an access token. */
3535 const BOSS_OTHER = "boss-other-browser-0123456789abcdef";
3636 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";
3739 const hash = (s) => createHash("sha256").update(s).digest("hex");
3840 const MASTER_KEY = randomBytes(32).toString("base64");
3941 /** The proof only the invitation email carries (src/auth/invite-proof.ts). */
@@ -108,6 +110,8 @@
108110 `INSERT INTO signin_links (id, token_hash, account_id, created_by, created_at, expires_at) VALUES ('link1', '${hash(ADMIN_LINK)}', 'boss1', 'boss1', '${at}', '${later}')`,
109111 `INSERT INTO sessions (id, account_id, method, created_at, last_seen_at, expires_at) VALUES ('${hash(BOSS_OTHER)}', 'boss1', 'passkey', '${at}', '${at}', '${later}')`,
110112 `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}')`,
111115 ].join("; "),
112116 );
113117 dev = spawn(
@@ -364,6 +368,75 @@
364368 }
365369 });
366370
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+
367440 test("open sign-up: off by default; on, an emailed code, then a passkey", async () => {
368441 // ------------------------------------------------------------ off (this file's Worker has no SIGNUP)
369442 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(&#39;|')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(&#39;|')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 @@
211211 expect(await (await get("/-/cli/diffinery.mjs")).text()).toMatch(/^#!\/usr\/bin\/env node\n/);
212212 });
213213
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+
214229 it("returns 404 for unknown pages", async () => {
215230 expect((await get("/docs/no-such-page")).status).toBe(404);
216231 expect((await get("/docs/no-such-page.md")).status).toBe(404);