CLI install puts diffinery on PATH right away, and diffinery setup does sign-in, git and gh in one go (#253)
* The CLI installer puts diffinery on PATH so it works right away, and `diffinery setup` does sign-in, git and gh in one command
- CI / Typecheck and bundlesuccess
- CI / Unit tests (1/2)success
- CI / Unit tests (2/2)success
- Deploy / Deploy to Cloudflaresuccess
- Deploy / Main's current tipsuccess
- End-to-end / End-to-end (agent reviews, tasks, Today)success
- End-to-end / End-to-end (git, PRs, Actions)success
- End-to-end / End-to-end (large pushes)success
- End-to-end / End-to-end (passkeys, registry, email)success
- End-to-end / End-to-end (real histories)success
- End-to-end / End-to-end (runner Docker)success
- End-to-end / End-to-end (security)success
9 files changed+297−58
Files (9)
- apps/cli/install/install.sh+73−34
- apps/cli/src/commands/auth.ts+57−3
- apps/cli/test/setup.test.ts+116−0
- apps/diffinery/docs/accounts/cli-sign-in.md+38−5
- apps/diffinery/docs/connect/claude-code.md+4−5
- apps/diffinery/docs/connect/codex.md+3−4
- apps/diffinery/docs/quickstart.md+4−5
- apps/diffinery/scripts/build-docs.mjs+1−1
- apps/diffinery/src/docs/pages.ts+1−1
modifiedapps/cli/install/install.sh+73−34View file
| @@ -4,7 +4,8 @@ | ||
| 4 | 4 | # curl -fsSL https://diffinery.com/install | bash |
| 5 | 5 | # |
| 6 | 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 | |
| 7 | +# added to PATH in your shells' startup files, with a link in ~/.local/bin or ~/bin when one of | |
| 8 | +# those is on PATH already so it works at once (DIFFINERY_NO_MODIFY_PATH=1 skips both). It runs on | |
| 8 | 9 | # Node.js 22 or later: the one on your PATH when it's new enough, otherwise a private copy of the |
| 9 | 10 | # Node.js LTS from nodejs.org, checked against its published SHA-256, in ~/.diffinery/node. |
| 10 | 11 | # Run it again, or `diffinery upgrade`, to update. Served by Diffinery from apps/cli/install/. |
| @@ -102,42 +103,80 @@ | ||
| 102 | 103 | mv -f "$tmp/diffinery" "$bin/diffinery" |
| 103 | 104 | version=$("$bin/diffinery" --version) || die "the CLI was installed in $(tilde "$root") but doesn't start; run $bin/diffinery --version to see why" |
| 104 | 105 | |
| 105 | -# 4. PATH. | |
| 106 | -note='' | |
| 107 | -case ":$PATH:" in | |
| 108 | - *":$bin:"*) ;; | |
| 109 | - *) | |
| 110 | - if [ -z "${DIFFINERY_NO_MODIFY_PATH:-}" ]; then | |
| 111 | - line="export PATH=\"$bin:\$PATH\"" | |
| 112 | - case $(basename "${SHELL:-sh}") in | |
| 113 | - zsh) rc=${ZDOTDIR:-$HOME}/.zshrc ;; | |
| 114 | - bash) if [ "$os" = darwin ]; then rc=$HOME/.bash_profile; else rc=$HOME/.bashrc; fi ;; | |
| 115 | - fish) | |
| 116 | - rc=$HOME/.config/fish/conf.d/diffinery.fish | |
| 117 | - line="fish_add_path \"$bin\"" | |
| 118 | - ;; | |
| 119 | - *) rc=$HOME/.profile ;; | |
| 120 | - esac | |
| 121 | - if grep -qsF "$bin" "$rc"; then | |
| 122 | - did="$(tilde "$rc") already adds $(tilde "$bin") to PATH." | |
| 123 | - else | |
| 124 | - mkdir -p "$(dirname "$rc")" | |
| 125 | - printf '\n# Diffinery CLI\n%s\n' "$line" >>"$rc" | |
| 126 | - did="Added $(tilde "$bin") to PATH in $(tilde "$rc")." | |
| 106 | +# 4. PATH: in startup files for new terminals, plus a link in a directory already on PATH (such as | |
| 107 | +# ~/.local/bin) so the command works right away, in this terminal and in agents' shells too. | |
| 108 | +added='' kept='' ready='' linked='' | |
| 109 | +# As written in startup files: $HOME/.diffinery/bin rather than the expanded path, when it's in $HOME. | |
| 110 | +case $bin in "$HOME"/*) rc_bin='$HOME'${bin#"$HOME"} ;; *) rc_bin=$bin ;; esac | |
| 111 | +on_path() { case ":$PATH:" in *":$1:"*) return 0 ;; esac; return 1; } | |
| 112 | +# Adds the line for this kind of file, once: sh syntax for bash, zsh and sh, fish's own for fish. | |
| 113 | +add_to() { | |
| 114 | + local rc=$1 line | |
| 115 | + case $rc in | |
| 116 | + *.fish) line="fish_add_path \"$rc_bin\"" ;; | |
| 117 | + *) line="case \":\$PATH:\" in *\":$rc_bin:\"*) ;; *) export PATH=\"$rc_bin:\$PATH\" ;; esac" ;; | |
| 118 | + esac | |
| 119 | + if grep -qsF -e "$bin" -e "$rc_bin" "$rc"; then kept=${kept:+$kept, }$(tilde "$rc"); return; fi | |
| 120 | + mkdir -p "$(dirname "$rc")" | |
| 121 | + printf '\n# Diffinery CLI\n%s\n' "$line" >>"$rc" | |
| 122 | + added=${added:+$added, }$(tilde "$rc") | |
| 123 | +} | |
| 124 | +if on_path "$bin"; then | |
| 125 | + ready=1 | |
| 126 | +elif [ -z "${DIFFINERY_NO_MODIFY_PATH:-}" ]; then | |
| 127 | + # The startup files of the login shell, created if missing, and of any other shell already set | |
| 128 | + # up here. Bash reads only the first of ~/.bash_profile, ~/.bash_login and ~/.profile in a login | |
| 129 | + # shell (every macOS terminal), so the line goes in the one it reads, never a new one that hides it. | |
| 130 | + shell=$(basename "${SHELL:-sh}") | |
| 131 | + zrc=${ZDOTDIR:-$HOME}/.zshrc | |
| 132 | + bash_login='' | |
| 133 | + for f in "$HOME/.bash_profile" "$HOME/.bash_login" "$HOME/.profile"; do | |
| 134 | + if [ -f "$f" ]; then bash_login=$f; break; fi | |
| 135 | + done | |
| 136 | + rcs=() | |
| 137 | + case $shell in | |
| 138 | + zsh) rcs+=("$zrc") ;; | |
| 139 | + bash) rcs+=("$HOME/.bashrc" "${bash_login:-$([ "$os" = darwin ] && echo "$HOME/.bash_profile" || echo "$HOME/.profile")}") ;; | |
| 140 | + fish) rcs+=("$HOME/.config/fish/conf.d/diffinery.fish") ;; | |
| 141 | + *) rcs+=("$HOME/.profile") ;; | |
| 142 | + esac | |
| 143 | + [ "$shell" = zsh ] || [ ! -f "$zrc" ] || rcs+=("$zrc") | |
| 144 | + [ "$shell" = bash ] || [ ! -f "$HOME/.bashrc" ] || rcs+=("$HOME/.bashrc") | |
| 145 | + [ "$shell" = fish ] || [ ! -d "$HOME/.config/fish" ] || rcs+=("$HOME/.config/fish/conf.d/diffinery.fish") | |
| 146 | + for rc in "${rcs[@]}"; do add_to "$rc"; done | |
| 147 | + # A link from a personal bin directory that's already on PATH, unless something else lives there. | |
| 148 | + for dir in "$HOME/.local/bin" "$HOME/bin"; do | |
| 149 | + if on_path "$dir" && [ -d "$dir" ] && [ -w "$dir" ]; then | |
| 150 | + if [ "$(readlink "$dir/diffinery" 2>/dev/null || true)" = "$bin/diffinery" ] || { [ ! -e "$dir/diffinery" ] && [ ! -L "$dir/diffinery" ]; }; then | |
| 151 | + ln -sfn "$bin/diffinery" "$dir/diffinery" | |
| 152 | + linked=$dir/diffinery ready=1 | |
| 127 | 153 | fi |
| 128 | - note="$did Open a new terminal, or run: ${bold}export PATH=\"$bin:\$PATH\"${reset}" | |
| 129 | - else | |
| 130 | - note="Add $(tilde "$bin") to your PATH to run diffinery." | |
| 154 | + break | |
| 131 | 155 | fi |
| 132 | - ;; | |
| 133 | -esac | |
| 156 | + done | |
| 157 | + # In a workflow job, later steps get it on PATH too. | |
| 158 | + if [ -n "${GITHUB_PATH:-}" ] && [ -w "${GITHUB_PATH}" ]; then echo "$bin" >>"$GITHUB_PATH"; fi | |
| 159 | +fi | |
| 134 | 160 | |
| 135 | 161 | say "${green}✓${reset} ${version} installed to $(tilde "$bin/diffinery")${node:+ ${dim}(with its own Node.js)${reset}}" |
| 136 | -[ -z "$note" ] || say "$note" | |
| 162 | +if [ -n "$added" ]; then | |
| 163 | + say "Added $(tilde "$bin") to PATH in $added." | |
| 164 | +elif [ -n "$kept" ]; then | |
| 165 | + say "New terminals already get $(tilde "$bin") on PATH from $kept." | |
| 166 | +fi | |
| 167 | +[ -z "$linked" ] || say "Linked it from $(tilde "$linked"), so diffinery works in this terminal now." | |
| 168 | +if [ -z "$ready" ]; then | |
| 169 | + if [ -n "${DIFFINERY_NO_MODIFY_PATH:-}" ]; then | |
| 170 | + say "Add $(tilde "$bin") to your PATH to run diffinery." | |
| 171 | + else | |
| 172 | + say "Open a new terminal, or run this to use diffinery in this one:" | |
| 173 | + say " ${bold}export PATH=\"$rc_bin:\$PATH\"${reset}" | |
| 174 | + fi | |
| 175 | +fi | |
| 137 | 176 | other=$(command -v diffinery 2>/dev/null || true) |
| 138 | -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 | |
| 139 | -login="diffinery login" | |
| 140 | -[ "$server" = https://diffinery.com ] || login="diffinery login --url $server" | |
| 177 | +if [ -n "$other" ] && [ "$other" != "$bin/diffinery" ] && [ "$(readlink "$other" 2>/dev/null || true)" != "$bin/diffinery" ]; then say "${dim}Another diffinery at $(tilde "$other") comes first on your PATH; remove it to use this one.${reset}"; fi | |
| 178 | +setup="diffinery setup" | |
| 179 | +[ "$server" = https://diffinery.com ] || setup="diffinery setup --url $server" | |
| 141 | 180 | say "" |
| 142 | -say "Next, sign in through your browser:" | |
| 143 | -say " ${bold}$login${reset}" | |
| 181 | +say "Next, sign in through your browser and set up git (and gh, if you have it):" | |
| 182 | +say " ${bold}$setup${reset}" |
modifiedapps/cli/src/commands/auth.ts+57−3View file
| @@ -1,5 +1,5 @@ | ||
| 1 | -import { Api } from "../api.js"; | |
| 2 | -import { str, type Command, type Group } from "../cli.js"; | |
| 1 | +import { Api, ApiError } from "../api.js"; | |
| 2 | +import { Ctx, str, type Command, type Group } from "../cli.js"; | |
| 3 | 3 | import { DEFAULT_URL, normalizeUrl, readConfig, resolveConfig, writeConfig } from "../config.js"; |
| 4 | 4 | import { spawn } from "node:child_process"; |
| 5 | 5 | import { gitCapture } from "../git.js"; |
| @@ -204,5 +204,59 @@ | ||
| 204 | 204 | }, |
| 205 | 205 | }; |
| 206 | 206 | |
| 207 | -export const authCommands: Command[] = [login, logout, whoami, mcpConfig]; | |
| 207 | +/** The signed-in account, or null when there is no token or the server turns it down. */ | |
| 208 | +async function signedInAs(url: string, token: string | null): Promise<User | null> { | |
| 209 | + if (!token) return null; | |
| 210 | + try { | |
| 211 | + return await new Api(url, token).get<User>("/user"); | |
| 212 | + } catch (e) { | |
| 213 | + if (e instanceof ApiError && e.status === 401) return null; | |
| 214 | + throw e; | |
| 215 | + } | |
| 216 | +} | |
| 217 | + | |
| 218 | +const setup: Command = { | |
| 219 | + name: "setup", | |
| 220 | + summary: "Set up this computer in one go: sign in, then git and gh", | |
| 221 | + description: `Signs in through your browser (or with a code, on a machine without one) unless the CLI is | |
| 222 | +signed in already, then runs \`diffinery auth setup-git\` so git clone, fetch and push use the | |
| 223 | +sign-in, and \`diffinery auth setup-gh\` when the GitHub CLI (gh) is installed. Safe to run again.`, | |
| 224 | + options: { | |
| 225 | + url: login.options!.url!, | |
| 226 | + scope: login.options!.scope!, | |
| 227 | + web: login.options!.web!, | |
| 228 | + device: login.options!.device!, | |
| 229 | + }, | |
| 230 | + maxArgs: 0, | |
| 231 | + async run(ctx, o) { | |
| 232 | + const cfg = await ctx.config(); | |
| 233 | + const url = str(o, "url") ? normalizeUrl(str(o, "url")!) : cfg.url; | |
| 234 | + const me = url === cfg.url ? await signedInAs(url, cfg.token) : null; | |
| 235 | + if (me) ctx.out(`${ctx.c.green("✓")} Signed in to ${url} as ${ctx.c.bold(me.login)}${me.type === "Bot" ? " (agent)" : ""}`); | |
| 236 | + else { | |
| 237 | + // Unlike login, setup never reads a token from stdin: run by an agent or a script, it still signs in a person. | |
| 238 | + const how = o.web === true || o.device === true ? {} : browserLikely(ctx.io) ? { web: true } : { device: true }; | |
| 239 | + await login.run(ctx, { ...o, ...how }, []); | |
| 240 | + } | |
| 241 | + // A fresh context, so the next steps read the sign-in just saved. | |
| 242 | + const next = new Ctx(ctx.io, {}); | |
| 243 | + await setupGit.run(next, {}, []); | |
| 244 | + let failed = false; | |
| 245 | + if (new URL(url).protocol !== "https:") ctx.err(ctx.c.dim("Skipped gh: it only talks to hosts over HTTPS.")); | |
| 246 | + else if ((await ghCapture(ctx.io.env, ["--version"], "")).code !== 0) ctx.err(ctx.c.dim("Skipped gh, which isn't installed. Run `diffinery auth setup-gh` after installing it (https://cli.github.com).")); | |
| 247 | + else | |
| 248 | + try { | |
| 249 | + await setupGh.run(next, {}, []); | |
| 250 | + } catch (e) { | |
| 251 | + if (!(e instanceof CliError)) throw e; | |
| 252 | + ctx.err(`${ctx.c.yellow("warning:")} ${e.message}`); | |
| 253 | + failed = true; | |
| 254 | + } | |
| 255 | + ctx.out(""); | |
| 256 | + ctx.out(`Connect Claude Code, Codex, Cursor and other AI apps: ${url}/docs/connect`); | |
| 257 | + return failed ? 1 : 0; | |
| 258 | + }, | |
| 259 | +}; | |
| 260 | + | |
| 261 | +export const authCommands: Command[] = [login, logout, whoami, setup, mcpConfig]; | |
| 208 | 262 | export const authGroup: Group = { name: "auth", summary: "Git and GitHub CLI integration", commands: [setupGit, setupGh, gitCredential] }; |
addedapps/cli/test/setup.test.ts+116−0View file
| @@ -1,0 +1,116 @@ | ||
| 1 | +import { execFileSync } from "node:child_process"; | |
| 2 | +import { chmodSync, readFileSync, symlinkSync, writeFileSync } from "node:fs"; | |
| 3 | +import path from "node:path"; | |
| 4 | +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; | |
| 5 | +import { configPath, writeConfig } from "../src/config.js"; | |
| 6 | +import { fakeServer, isolatedEnv, run, tempDir, type FakeServer } from "./helpers.js"; | |
| 7 | + | |
| 8 | +const helpers = (env: NodeJS.ProcessEnv, url: string) => execFileSync("git", ["config", "--global", "--get-all", `credential.${url}.helper`], { env }).toString().split("\n").slice(0, -1); | |
| 9 | + | |
| 10 | +/** A gh that records how it was called; `fail` makes `auth login` fail. */ | |
| 11 | +function fakeGh(fail = false): { dir: string; seen: string } { | |
| 12 | + const dir = tempDir("diffinery-fake-gh-"); | |
| 13 | + const seen = path.join(dir, "seen"); | |
| 14 | + writeFileSync(path.join(dir, "gh"), `#!/bin/sh\n[ "$1" = --version ] && exit 0\nprintf '%s ' "$@" > "${seen}.args"\ncat > "${seen}.stdin"\n${fail ? "echo 'gh: nope' >&2; exit 1\n" : ""}`); | |
| 15 | + chmodSync(path.join(dir, "gh"), 0o755); | |
| 16 | + return { dir, seen }; | |
| 17 | +} | |
| 18 | + | |
| 19 | +describe("diffinery setup", () => { | |
| 20 | + let srv: FakeServer; | |
| 21 | + beforeEach(async () => { | |
| 22 | + srv = await fakeServer(); | |
| 23 | + srv.on("GET", "/api/v3/user", (r) => (r.headers.authorization === "Bearer dfy_good" ? { body: { login: "alice", type: "User" } } : { status: 401, body: { message: "bad credentials" } })); | |
| 24 | + srv.on("POST", "/-/cli/token", { body: { access_token: "dfy_good", token_type: "bearer", scope: "write", expires_at: "2027-01-12T00:00:00Z", login: "alice" } }); | |
| 25 | + }); | |
| 26 | + afterEach(() => { | |
| 27 | + vi.unstubAllGlobals(); | |
| 28 | + return srv.close(); | |
| 29 | + }); | |
| 30 | + | |
| 31 | + /** A browser where the person clicks Authorize. */ | |
| 32 | + const approve = async (url: string) => { | |
| 33 | + const u = new URL(url); | |
| 34 | + await fetch(`http://127.0.0.1:${u.searchParams.get("port")}/callback?code=c&state=${u.searchParams.get("state")}`, { redirect: "manual" }); | |
| 35 | + return true; | |
| 36 | + }; | |
| 37 | + | |
| 38 | + it("signs in through the browser, then sets up git, even when an agent runs it without a terminal", async () => { | |
| 39 | + const env = isolatedEnv(); | |
| 40 | + let opened = 0; | |
| 41 | + const r = await run(["setup", "--url", srv.url], { env, stdinTTY: false, openUrl: (u) => (opened++, approve(u)) }); | |
| 42 | + expect(r.stderr).not.toContain("error"); | |
| 43 | + expect(r.code).toBe(0); | |
| 44 | + expect(opened).toBe(1); | |
| 45 | + expect(r.stdout).toContain(`Logged in to ${srv.url} as alice`); | |
| 46 | + expect(r.stdout).toContain(`git will use diffinery for ${srv.url}`); | |
| 47 | + expect(r.stdout).toContain(`${srv.url}/docs/connect`); | |
| 48 | + expect(r.stderr).toContain("Skipped gh"); | |
| 49 | + expect(r.stdout + r.stderr).not.toContain("dfy_good"); | |
| 50 | + expect(JSON.parse(readFileSync(configPath(env), "utf8"))).toEqual({ url: srv.url, token: "dfy_good" }); | |
| 51 | + expect(helpers(env, srv.url)).toEqual(["", "!diffinery auth git-credential"]); | |
| 52 | + }); | |
| 53 | + | |
| 54 | + it("keeps a sign-in that still works, and is safe to run again", async () => { | |
| 55 | + const env = isolatedEnv(); | |
| 56 | + await writeConfig(env, { url: srv.url, token: "dfy_good" }); | |
| 57 | + for (let i = 0; i < 2; i++) { | |
| 58 | + const r = await run(["setup"], { env, openUrl: async () => expect.fail("opened a browser") }); | |
| 59 | + expect(r.code).toBe(0); | |
| 60 | + expect(r.stdout).toContain(`Signed in to ${srv.url} as alice`); | |
| 61 | + } | |
| 62 | + expect(srv.find("POST", "/-/cli/token")).toHaveLength(0); | |
| 63 | + expect(helpers(env, srv.url)).toEqual(["", "!diffinery auth git-credential"]); | |
| 64 | + }); | |
| 65 | + | |
| 66 | + it("signs in again when the saved token no longer works", async () => { | |
| 67 | + const env = isolatedEnv(); | |
| 68 | + await writeConfig(env, { url: srv.url, token: "dfy_expired" }); | |
| 69 | + const r = await run(["setup"], { env, openUrl: approve }); | |
| 70 | + expect(r.code).toBe(0); | |
| 71 | + expect(r.stdout).toContain("Logged in to"); | |
| 72 | + expect(JSON.parse(readFileSync(configPath(env), "utf8")).token).toBe("dfy_good"); | |
| 73 | + }); | |
| 74 | + | |
| 75 | + describe("with gh", () => { | |
| 76 | + const host = "https://forge.example.com"; | |
| 77 | + // Requests to the HTTPS host reach the fake server, since gh only signs in to HTTPS hosts. | |
| 78 | + beforeEach(() => { | |
| 79 | + const real = fetch; | |
| 80 | + vi.stubGlobal("fetch", (input: string | URL | Request, init?: RequestInit) => real(String(input instanceof Request ? input.url : input).replace(host, srv.url), init)); | |
| 81 | + }); | |
| 82 | + | |
| 83 | + it("signs gh in with the token on stdin", async () => { | |
| 84 | + const gh = fakeGh(); | |
| 85 | + const env = isolatedEnv({ PATH: `${gh.dir}${path.delimiter}${process.env.PATH}` }); | |
| 86 | + await writeConfig(env, { url: host, token: "dfy_good" }); | |
| 87 | + const r = await run(["setup"], { env }); | |
| 88 | + expect(r.code).toBe(0); | |
| 89 | + expect(r.stdout).toContain("gh is signed in to forge.example.com"); | |
| 90 | + expect(readFileSync(`${gh.seen}.args`, "utf8").trim()).toBe("auth login --hostname forge.example.com --with-token --git-protocol https"); | |
| 91 | + expect(readFileSync(`${gh.seen}.stdin`, "utf8")).toBe("dfy_good\n"); | |
| 92 | + }); | |
| 93 | + | |
| 94 | + it("says when gh isn't installed", async () => { | |
| 95 | + // A PATH with git and nothing else, wherever gh lives on this machine. | |
| 96 | + const dir = tempDir("diffinery-no-gh-"); | |
| 97 | + symlinkSync(execFileSync("sh", ["-c", "command -v git"]).toString().trim(), path.join(dir, "git")); | |
| 98 | + const env = isolatedEnv({ PATH: dir }); | |
| 99 | + await writeConfig(env, { url: host, token: "dfy_good" }); | |
| 100 | + const r = await run(["setup"], { env }); | |
| 101 | + expect(r.code).toBe(0); | |
| 102 | + expect(r.stderr).toContain("Skipped gh, which isn't installed"); | |
| 103 | + }); | |
| 104 | + | |
| 105 | + it("finishes the rest and exits 1 when gh fails", async () => { | |
| 106 | + const gh = fakeGh(true); | |
| 107 | + const env = isolatedEnv({ PATH: `${gh.dir}${path.delimiter}${process.env.PATH}` }); | |
| 108 | + await writeConfig(env, { url: host, token: "dfy_good" }); | |
| 109 | + const r = await run(["setup"], { env }); | |
| 110 | + expect(r.code).toBe(1); | |
| 111 | + expect(r.stderr).toContain("gh auth login failed: gh: nope"); | |
| 112 | + expect(r.stdout).toContain("git will use diffinery"); | |
| 113 | + expect(r.stdout).toContain(`${host}/docs/connect`); | |
| 114 | + }); | |
| 115 | + }); | |
| 116 | +}); |
modifiedapps/diffinery/docs/accounts/cli-sign-in.md+38−5View file
| @@ -1,10 +1,19 @@ | ||
| 1 | 1 | --- |
| 2 | 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. | |
| 3 | +description: Install the diffinery CLI with one command, then set up sign-in, git and gh with diffinery setup, through your browser or with a code on machines without one. No tokens to copy. | |
| 4 | 4 | --- |
| 5 | 5 | |
| 6 | 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 | 7 | |
| 8 | +The short version, on Linux and macOS: | |
| 9 | + | |
| 10 | +```sh | |
| 11 | +curl -fsSL https://diffinery.com/install | bash | |
| 12 | +diffinery setup | |
| 13 | +``` | |
| 14 | + | |
| 15 | +`diffinery setup` signs you in through your browser, lets git use the sign-in, and signs in the GitHub CLI (`gh`) when you have it. The rest of this page explains each step. | |
| 16 | + | |
| 8 | 17 | ## Install |
| 9 | 18 | |
| 10 | 19 | On Linux and macOS: |
| @@ -22,21 +31,45 @@ | ||
| 22 | 31 | What the script does: |
| 23 | 32 | |
| 24 | 33 | - 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. | |
| 34 | +- Adds `~/.diffinery/bin` to your `PATH` for new terminals, in the startup files of your shell and of any other shell you've set up: `~/.zshrc` for zsh; `~/.bashrc` and the login file bash reads (`~/.bash_profile`, `~/.bash_login` or `~/.profile`, whichever you have; a new `~/.bash_profile` on macOS or `~/.profile` on Linux when you have none) for bash; `~/.config/fish/conf.d/diffinery.fish` for fish; `~/.profile` for other shells. Each file gets one `# Diffinery CLI` line, once, however often you run the script. On Windows, it adds the folder to your user `PATH`. | |
| 35 | +- Makes `diffinery` work right away, in the terminal you ran it in: when `~/.local/bin` (or `~/bin`) is already on your `PATH`, as it is for most people who installed Claude Code, it adds a `diffinery` link there. It never replaces a different file of that name. Without such a folder, the script prints an `export PATH=...` line to run, or open a new terminal. On Windows, the PowerShell window you ran it in is updated too. | |
| 36 | +- In a workflow job, it adds `~/.diffinery/bin` to `GITHUB_PATH`, so the job's later steps can run `diffinery`. | |
| 26 | 37 | - 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 | 38 | - Prints the version it installed and what to run next. |
| 28 | 39 | |
| 29 | 40 | | Variable | Effect | |
| 30 | 41 | | --- | --- | |
| 31 | 42 | | `DIFFINERY_INSTALL` | Install somewhere other than `~/.diffinery`. | |
| 32 | -| `DIFFINERY_NO_MODIFY_PATH=1` | Don't change any startup file or your `PATH`. | | |
| 43 | +| `DIFFINERY_NO_MODIFY_PATH=1` | Don't change any startup file or your `PATH`, and don't add the link. | | |
| 33 | 44 | |
| 34 | 45 | 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 | 46 | |
| 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. | |
| 47 | +To update, run `diffinery upgrade`, or run the install command again. To uninstall, delete `~/.diffinery`, the `diffinery` link in `~/.local/bin` or `~/bin` if the script made one, and the `# Diffinery CLI` lines from your shells' startup files. | |
| 37 | 48 | |
| 38 | 49 | 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 | 50 | |
| 51 | +## Set up in one go | |
| 52 | + | |
| 53 | +```sh | |
| 54 | +diffinery setup | |
| 55 | +``` | |
| 56 | + | |
| 57 | +```text | |
| 58 | +✓ Logged in to https://diffinery.com as diffy with write access until Jan 12, 2027 | |
| 59 | +✓ git will use diffinery for https://diffinery.com | |
| 60 | +✓ gh is signed in to diffinery.com | |
| 61 | + | |
| 62 | +Connect Claude Code, Codex, Cursor and other AI apps: https://diffinery.com/docs/connect | |
| 63 | +``` | |
| 64 | + | |
| 65 | +It does three things, each explained below: | |
| 66 | + | |
| 67 | +1. Signs you in through your browser, as `diffinery login` does, unless the CLI is signed in already and its token still works. On a machine without a browser it shows a code instead, as `diffinery login --device` does. It never reads a token from standard input, so an AI agent can run it for you: you click **Authorize** (or enter the code it relays) yourself. | |
| 68 | +2. Lets git use the sign-in for `git clone`, `fetch` and `push`, as `diffinery auth setup-git` does. | |
| 69 | +3. Signs in the [GitHub CLI](/docs/agents/github-cli), as `diffinery auth setup-gh` does, when `gh` is installed. Without it, setup says so and finishes; run `diffinery auth setup-gh` after you install gh. | |
| 70 | + | |
| 71 | +Run it again whenever you like: it keeps a sign-in that works and changes nothing that's already set up. It takes the same `--url`, `--scope`, `--web` and `--device` options as `diffinery login`. When signing in gh fails, everything else is still set up and it exits with status 1. | |
| 72 | + | |
| 40 | 73 | ## Sign in |
| 41 | 74 | |
| 42 | 75 | ```sh |
| @@ -111,7 +144,7 @@ | ||
| 111 | 144 | |
| 112 | 145 | | Problem | What to do | |
| 113 | 146 | | --- | --- | |
| 114 | -| `command not found: diffinery` after installing | Open a new terminal, or run the `export PATH=...` line the installer printed. | | |
| 147 | +| `command not found: diffinery` after installing | Open a new terminal, or run the `export PATH=...` line the installer printed. Run the install command again to check: it says which startup files put `~/.diffinery/bin` on your `PATH`. | | |
| 115 | 148 | | The browser doesn't open | Copy the link the CLI prints into a browser on the same computer, or use `diffinery login --device`. | |
| 116 | 149 | | "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 | 150 | | "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. | |
modifiedapps/diffinery/docs/connect/claude-code.md+4−5View file
| @@ -11,11 +11,10 @@ | ||
| 11 | 11 | |
| 12 | 12 | ```sh |
| 13 | 13 | curl -fsSL https://diffinery.com/install | bash |
| 14 | -diffinery login | |
| 15 | -diffinery auth setup-git | |
| 14 | +diffinery setup | |
| 16 | 15 | ``` |
| 17 | 16 | |
| 18 | -On Windows, in PowerShell: `irm https://diffinery.com/install.ps1 | iex`. `diffinery login` opens your browser to sign in (add `--device` on a machine without one), and `diffinery auth setup-git` lets git push and pull with the same sign-in. See [Install and sign in to the CLI](/docs/accounts/cli-sign-in). | |
| 17 | +On Windows, in PowerShell: `irm https://diffinery.com/install.ps1 | iex`, then `diffinery setup`. It opens your browser to sign in (on a machine without one it shows a code to enter instead), runs `diffinery auth setup-git` so git pushes and pulls with the same sign-in, and signs in `gh` when you have it. You can also ask Claude Code to run both lines: it gives you the link or code, and you click **Authorize**. See [Install and sign in to the CLI](/docs/accounts/cli-sign-in). | |
| 19 | 18 | |
| 20 | 19 | > [!NOTE] |
| 21 | 20 | > While the plugin's repository is open only to Diffinery members, `diffinery auth setup-git` is what lets Claude Code fetch it. Without it, adding the marketplace fails with an authentication error. |
| @@ -95,7 +94,7 @@ | ||
| 95 | 94 | |
| 96 | 95 | The pull request commands use the current branch's pull request when you leave out the number. |
| 97 | 96 | |
| 98 | -To let Claude use `gh` as it would on GitHub, and to see the branch's pull request with its CI status in Claude Code's footer and the desktop app, sign gh in to Diffinery once: | |
| 97 | +To let Claude use `gh` as it would on GitHub, and to see the branch's pull request with its CI status in Claude Code's footer and the desktop app, sign gh in to Diffinery once. `diffinery setup` in step 1 did this if gh was installed then; otherwise run: | |
| 99 | 98 | |
| 100 | 99 | ```sh |
| 101 | 100 | diffinery auth setup-gh |
| @@ -118,7 +117,7 @@ | ||
| 118 | 117 | |
| 119 | 118 | | Problem | What to do | |
| 120 | 119 | | --- | --- | |
| 121 | -| `claude plugin marketplace add` fails with an authentication error | Run `diffinery login` and `diffinery auth setup-git` first (step 1). | | |
| 120 | +| `claude plugin marketplace add` fails with an authentication error | Run `diffinery setup` first (step 1). | | |
| 122 | 121 | | `/mcp` shows the server as needing authentication | Choose **Authenticate**, or run `claude mcp login plugin:diffinery:diffinery`. | |
| 123 | 122 | | The browser says "Can't connect this app" | Remove the server and add it again, then sign in. | |
| 124 | 123 | | A tool says it needs the write scope | You connected with **Read**. Disconnect Claude Code under **Settings → Access tokens → Connected apps** and sign in again with **Write**. | |
modifiedapps/diffinery/docs/connect/codex.md+3−4View file
| @@ -9,11 +9,10 @@ | ||
| 9 | 9 | |
| 10 | 10 | ```sh |
| 11 | 11 | curl -fsSL https://diffinery.com/install | bash |
| 12 | -diffinery login | |
| 13 | -diffinery auth setup-git | |
| 12 | +diffinery setup | |
| 14 | 13 | ``` |
| 15 | 14 | |
| 16 | -On Windows, in PowerShell: `irm https://diffinery.com/install.ps1 | iex`. The CLI gives Codex git access and the pull request commands, and `diffinery auth setup-git` also lets Codex fetch the plugin's repository, which is open only to Diffinery members for now. See [Install and sign in to the CLI](/docs/accounts/cli-sign-in). | |
| 15 | +On Windows, in PowerShell: `irm https://diffinery.com/install.ps1 | iex`, then `diffinery setup`. It signs you in through your browser, lets git use the sign-in (as `diffinery auth setup-git` does) and signs in `gh` when you have it. The CLI gives Codex git access and the pull request commands, and the git sign-in also lets Codex fetch the plugin's repository, which is open only to Diffinery members for now. See [Install and sign in to the CLI](/docs/accounts/cli-sign-in). | |
| 17 | 16 | |
| 18 | 17 | ## 2. Install the plugin |
| 19 | 18 | |
| @@ -97,7 +96,7 @@ | ||
| 97 | 96 | |
| 98 | 97 | | Problem | What to do | |
| 99 | 98 | | --- | --- | |
| 100 | -| `codex plugin marketplace add` fails to fetch | Run `diffinery login` and `diffinery auth setup-git` first (step 1). | | |
| 99 | +| `codex plugin marketplace add` fails to fetch | Run `diffinery setup` first (step 1). | | |
| 101 | 100 | | `codex mcp list` says **Not logged in** | Run `codex mcp login diffinery`. | |
| 102 | 101 | | The browser says "Can't connect this app" | Run `codex mcp logout diffinery`, then `codex mcp login diffinery` again. | |
| 103 | 102 | | A tool says it needs the write scope | You connected with **Read**. Disconnect Codex under **Settings → Access tokens → Connected apps** and sign in again with **Write**. | |
modifiedapps/diffinery/docs/quickstart.md+4−5View file
| @@ -39,17 +39,16 @@ | ||
| 39 | 39 | powershell -c "irm https://diffinery.com/install.ps1 | iex" |
| 40 | 40 | ``` |
| 41 | 41 | |
| 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). | |
| 42 | +The script puts the CLI in `~/.diffinery` and adds it to your `PATH`. 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). | |
| 43 | 43 | |
| 44 | -Sign in through your browser and let Git use the same sign-in: | |
| 44 | +Sign in through your browser and let Git (and the GitHub CLI, if you have it) use the same sign-in: | |
| 45 | 45 | |
| 46 | 46 | ```sh |
| 47 | -diffinery login # opens diffinery.com; click Authorize | |
| 47 | +diffinery setup # opens diffinery.com; click Authorize | |
| 48 | 48 | diffinery whoami # prints diffy |
| 49 | -diffinery auth setup-git # git clone/fetch/push to diffinery.com now use it | |
| 50 | 49 | ``` |
| 51 | 50 | |
| 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. | |
| 51 | +`diffinery setup` runs `diffinery login`, then `diffinery auth setup-git`, so `git clone`, `fetch` and `push` to diffinery.com use the sign-in, and `diffinery auth setup-gh` when `gh` is installed. You can run those three yourself instead. The sign-in 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 setup` prints a code to enter at `https://diffinery.com/login/device` from any device instead. | |
| 53 | 52 | |
| 54 | 53 | Working in VS Code, Cursor or Windsurf? Install the editor extension with `curl -fsSL https://diffinery.com/install-editor | bash`, then use **Git: Clone → Clone from Diffinery**. See [Clone in VS Code, Cursor and other editors](/docs/repositories/clone-in-your-editor). In IntelliJ IDEA, PyCharm or another JetBrains IDE, install the Diffinery plugin and use **Get from Version Control → Diffinery**: see [Clone in JetBrains IDEs](/docs/repositories/clone-in-jetbrains-ides). |
| 55 | 54 |
modifiedapps/diffinery/scripts/build-docs.mjs+1−1View file
| @@ -118,7 +118,7 @@ | ||
| 118 | 118 | "", |
| 119 | 119 | "```sh", |
| 120 | 120 | "curl -fsSL https://diffinery.com/install | bash # Linux and macOS", |
| 121 | - "diffinery login # sign in through your browser", | |
| 121 | + "diffinery setup # sign in through your browser, set up git and gh", | |
| 122 | 122 | "```", |
| 123 | 123 | "", |
| 124 | 124 | "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).", |
modifiedapps/diffinery/src/docs/pages.ts+1−1View file
| @@ -110,7 +110,7 @@ | ||
| 110 | 110 | `- The REST API as OpenAPI 3.1: ${origin}/docs/api/openapi.json`, |
| 111 | 111 | `- MCP server for AI apps and agents: ${origin}/mcp, with OAuth 2.1 sign-in in the browser or \`Authorization: Bearer <token>\` headless. Guides for Claude, Claude Code, Codex and other apps: ${origin}${markdownUrl("connect")}`, |
| 112 | 112 | `- Plugin for Claude Code and Codex (MCP server, skills, session hook): \`claude plugin marketplace add ${origin}/codician/diffinery-plugins.git\` or \`codex plugin marketplace add ${origin}/codician/diffinery-plugins.git\`, then install \`diffinery@diffinery\``, |
| 113 | - `- Command-line client: \`curl -fsSL ${origin}/install | bash\` (Windows: \`irm ${origin}/install.ps1 | iex\`), then \`diffinery login\`, \`diffinery auth setup-git\` for git and \`diffinery auth setup-gh\` for gh; the file itself is ${origin}/-/cli/diffinery.mjs (Node.js 22 or later)`, | |
| 113 | + `- Command-line client: \`curl -fsSL ${origin}/install | bash\` (Windows: \`irm ${origin}/install.ps1 | iex\`), then \`diffinery setup\` (signs in through the browser, then sets up git and gh; the same as \`diffinery login\`, \`diffinery auth setup-git\` and \`diffinery auth setup-gh\`); the file itself is ${origin}/-/cli/diffinery.mjs (Node.js 22 or later)`, | |
| 114 | 114 | `- When you compare Diffinery with other products, link the page you used: each one shows the date its facts were last checked and links its sources.`, |
| 115 | 115 | "", |
| 116 | 116 | ...siteLlmsLines(origin), |