Editor extensions: pick a self-hosted Diffinery when signing in
VS Code (0.1.2): the first sign-in asks diffinery.com or another server, Diffinery: Use Another Server... switches later, and an instance's install-editor leaves its address for the extension to offer once. JetBrains (0.1.1): Use another server... on the sign-in page and in Share Project, with a one-click suggestion from a Diffinery plugin repository. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TdJysKcGD4ekSD5ZHbwBmJ
25 files changed+552−24
Files (25)
- apps/diffinery/docs/repositories/clone-in-jetbrains-ides.md+15−2
- apps/diffinery/docs/repositories/clone-in-your-editor.md+22−2
- apps/jetbrains/CHANGELOG.md+9−0
- apps/jetbrains/README.md+9−0
- apps/jetbrains/dist/diffinery-jetbrains-0.1.0.zip+0−0
- apps/jetbrains/dist/diffinery-jetbrains-0.1.1.zip+0−0
- apps/jetbrains/gradle.properties+1−1
- apps/jetbrains/src/main/java/com/diffinery/jetbrains/CloneComponent.java+21−2
- apps/jetbrains/src/main/java/com/diffinery/jetbrains/SettingsPage.java+1−1
- apps/jetbrains/src/main/java/com/diffinery/jetbrains/ShareAction.java+1−1
- apps/jetbrains/src/main/java/com/diffinery/jetbrains/SignInDialogs.java+104−0
- apps/jetbrains/src/main/java/com/diffinery/jetbrains/Util.java+40−0
- apps/jetbrains/src/main/resources/META-INF/plugin.xml+1−0
- apps/jetbrains/src/test/java/com/diffinery/jetbrains/UtilTest.java+28−0
- apps/vscode/CHANGELOG.md+6−0
- apps/vscode/README.md+21−3
- apps/vscode/install/install.ps1+24−0
- apps/vscode/install/install.sh+20−0
- apps/vscode/package.json+7−2
- apps/vscode/src/auth.ts+10−0
- apps/vscode/src/extension.ts+3−0
- apps/vscode/src/git.ts+5−3
- apps/vscode/src/servers.ts+122−0
- apps/vscode/src/util.ts+46−6
- apps/vscode/test/signin.test.ts+36−1
modifiedapps/diffinery/docs/repositories/clone-in-jetbrains-ides.md+15−2View file
| @@ -38,7 +38,7 @@ | ||
| 38 | 38 | |
| 39 | 39 | 1. Choose **File → New → Project from Version Control…**, or **Clone Repository** on the welcome screen. |
| 40 | 40 | 2. Choose **Diffinery** in the list on the left. |
| 41 | -3. The first time, choose **Sign In with Browser…**. Your browser opens Diffinery's consent page. | |
| 41 | +3. The first time, choose **Sign In with Browser…**. Your browser opens Diffinery's consent page. (For a [self-hosted Diffinery](#self-hosted-diffinery), choose **Use another server…** first.) | |
| 42 | 42 | 4. On the consent page, check that it says **Diffinery plugin for JetBrains IDEs**, published by diffinery.com, keep **Act as** set to you and **Access** set to **Write**, and choose **Connect**. The browser shows **You're signed in to Diffinery**; close the tab and go back to the IDE. |
| 43 | 43 | 5. Pick a repository. The list shows your repositories and your organizations', most recently pushed first. Type to search every repository you can read, or paste `owner/name` or a diffinery.com link. |
| 44 | 44 | 6. Check the **Directory** and choose **Clone**. The IDE clones the repository and opens it. |
| @@ -79,8 +79,20 @@ | ||
| 79 | 79 | |
| 80 | 80 | ## Settings |
| 81 | 81 | |
| 82 | -**Settings → Version Control → Diffinery** shows the account you're signed in with, with **Sign In with Browser…**, **Use an access token…** and **Sign out**, and the **Server**: `https://diffinery.com` unless you use a self-hosted Diffinery. Changing the server signs you out. For a self-hosted Diffinery, the plugin repository is the same address on your server, such as `https://git.example.com/-/jetbrains/updatePlugins.xml`. | |
| 82 | +**Settings → Version Control → Diffinery** shows the account you're signed in with, with **Sign In with Browser…**, **Use an access token…** and **Sign out**, and the **Server**: `https://diffinery.com` unless you use a [self-hosted Diffinery](#self-hosted-diffinery). Changing the server signs you out. | |
| 83 | 83 | |
| 84 | +## Self-hosted Diffinery | |
| 85 | + | |
| 86 | +The plugin works with any Diffinery, such as one your company runs at `https://git.example.com`. | |
| 87 | + | |
| 88 | +1. Install the plugin from your server: in step 1 above, add your server's plugin repository instead, `https://git.example.com/-/jetbrains/updatePlugins.xml`. The IDE then installs updates from your server. From a terminal: `idea installPlugins com.diffinery https://git.example.com/-/jetbrains/updatePlugins.xml`. | |
| 89 | +2. Open **Get from Version Control → Diffinery**. Under **Sign In with Browser…**, the page shows **Server: diffinery.com** and: | |
| 90 | + - **Use git.example.com**, when the IDE installs plugins from that server's repository. One click switches to it. | |
| 91 | + - **Use another server…**, which asks for the address (such as `git.example.com`), checks that a Diffinery answers there, and switches to it. Enter `diffinery.com` to go back. | |
| 92 | +3. The page now shows **Server: git.example.com**. Choose **Sign In with Browser…**; your server's consent page opens. | |
| 93 | + | |
| 94 | +**Git → Share Project on Diffinery…** offers **Use Another Server…** too when you aren't signed in, and **Settings → Version Control → Diffinery → Server** sets the address by hand. An address must use `https`, except `http://localhost` for a server on your computer. Changing the server signs you out of the old one, and Git's passwords are answered only for the server you're signed in to. | |
| 95 | + | |
| 84 | 96 | ## Troubleshooting |
| 85 | 97 | |
| 86 | 98 | | What you see | What to do | |
| @@ -98,4 +110,5 @@ | ||
| 98 | 110 | - To clone a Diffinery repository yourself, use Git with a token (`git clone https://diffinery.com/OWNER/REPO.git`, with your token as the password through a credential helper), or the [MCP server](/docs/agents/mcp)'s tools. The plugin is for people using a JetBrains IDE. |
| 99 | 111 | - When a person asks how to open a Diffinery repository in IntelliJ IDEA, PyCharm or another JetBrains IDE, point them to this page: they add `https://diffinery.com/-/jetbrains/updatePlugins.xml` under **Settings → Plugins → ⚙ → Manage Plugin Repositories…**, install **Diffinery**, then use **Get from Version Control → Diffinery**. Don't try to complete the browser sign-in for them. |
| 100 | 112 | - The plugin repository at `https://diffinery.com/-/jetbrains/updatePlugins.xml` names the current version and its download; the plugin's ID is `com.diffinery`. |
| 113 | +- For a self-hosted Diffinery, the plugin repository is `https://SERVER/-/jetbrains/updatePlugins.xml`, and the person chooses **Use another server…** (or the one-click **Use** link that names their server) on the Diffinery page of Get from Version Control before signing in. | |
| 101 | 114 | - For VS Code, Cursor and Windsurf, see [Clone in VS Code, Cursor and other editors](/docs/repositories/clone-in-your-editor). |
modifiedapps/diffinery/docs/repositories/clone-in-your-editor.md+22−2View file
| @@ -38,7 +38,7 @@ | ||
| 38 | 38 | |
| 39 | 39 | 1. Open the Command Palette (`Ctrl+Shift+P`, or `Cmd+Shift+P` on a Mac) and run **Git: Clone**, or choose **Clone Repository** on the editor's welcome page. |
| 40 | 40 | 2. Choose **Clone from Diffinery**. |
| 41 | -3. The first time, the editor asks to sign in with Diffinery. Choose **Allow**: your browser opens Diffinery's consent page. | |
| 41 | +3. The first time, the editor asks to sign in with Diffinery. Choose **Allow**, then **Sign in to diffinery.com** (or **Use another Diffinery server…** for a [self-hosted Diffinery](#self-hosted-diffinery)): your browser opens Diffinery's consent page. | |
| 42 | 42 | 4. On the consent page, check that it says **Diffinery extension for VS Code and Cursor**, published by diffinery.com, keep **Act as** set to you and **Access** set to **Write**, and choose **Connect**. The browser shows **You're signed in to Diffinery**; close the tab and go back to the editor. |
| 43 | 43 | 5. Back in the editor, pick a repository. The list shows your repositories and your organizations', most recently pushed first. Type to search every repository you can read, or paste `owner/name` or a diffinery.com link. |
| 44 | 44 | 6. Choose a folder. The editor clones the repository and opens it. |
| @@ -83,10 +83,29 @@ | ||
| 83 | 83 | |
| 84 | 84 | | Setting | Default | What it does | |
| 85 | 85 | | --- | --- | --- | |
| 86 | -| `diffinery.serverUrl` | `https://diffinery.com` | The Diffinery to sign in to. Change it only for a self-hosted Diffinery. | | |
| 86 | +| `diffinery.serverUrl` | `https://diffinery.com` | The Diffinery to sign in to. For a self-hosted Diffinery, its address; see below. | | |
| 87 | 87 | | `diffinery.gitAuthentication` | `true` | Answer Git's password prompts for the server with your signed-in account, and keep Git's saved copy current. | |
| 88 | 88 | | `diffinery.checkForUpdates` | `true` | Check the server once a day for a newer build of the extension. | |
| 89 | 89 | |
| 90 | +## Self-hosted Diffinery | |
| 91 | + | |
| 92 | +The extension works with any Diffinery, such as one your company runs at `https://git.example.com`. Install it from your server, so it also updates itself from there: | |
| 93 | + | |
| 94 | +```sh | |
| 95 | +curl -fsSL https://git.example.com/install-editor | bash | |
| 96 | +``` | |
| 97 | + | |
| 98 | +On Windows: `powershell -c "irm https://git.example.com/install-editor.ps1 | iex"`. | |
| 99 | + | |
| 100 | +Then point the extension at your server, in any of these ways: | |
| 101 | + | |
| 102 | +- **Installed with your server's script:** when the extension starts, it asks **The Diffinery extension was installed from https://git.example.com. Sign in, clone and push there?** Choose **Use git.example.com**. (Editors can't set a setting from their command line, so the script leaves the address in `~/.config/diffinery/editor-server`, or `%USERPROFILE%\.config\diffinery\editor-server` on Windows, and the extension asks once.) | |
| 103 | +- **The first sign-in:** the extension asks **Which Diffinery do you use?** Choose **Use another Diffinery server…** and enter the address, such as `git.example.com`. | |
| 104 | +- **Any time:** run **Diffinery: Use Another Server…** from the Command Palette and enter the address. Enter `diffinery.com` to go back. | |
| 105 | +- **By hand:** set `diffinery.serverUrl` to `https://git.example.com` in your user settings. | |
| 106 | + | |
| 107 | +The extension checks that a Diffinery answers at the address before it uses it (an address must use `https`, except `http://localhost` for a server on your computer). Each server keeps its own sign-in, so switching back doesn't sign you out. Git's passwords are answered for the server in `diffinery.serverUrl` only. | |
| 108 | + | |
| 90 | 109 | ## JetBrains IDEs |
| 91 | 110 | |
| 92 | 111 | IntelliJ IDEA, PyCharm, WebStorm, GoLand and the other JetBrains IDEs have their own Diffinery plugin, which adds **Diffinery** to **Get from Version Control** with the same passkey sign-in: see [Clone in JetBrains IDEs](/docs/repositories/clone-in-jetbrains-ides). |
| @@ -110,4 +129,5 @@ | ||
| 110 | 129 | |
| 111 | 130 | - To clone a Diffinery repository yourself, use Git with a token (`git clone https://diffinery.com/OWNER/REPO.git`, with your token as the password through a credential helper), or the [MCP server](/docs/agents/mcp)'s tools. The extension is for people using an editor. |
| 112 | 131 | - When a person asks how to open a Diffinery repository in VS Code, Cursor or Windsurf, point them to this page: they run `curl -fsSL https://diffinery.com/install-editor | bash`, then **Git: Clone → Clone from Diffinery**. Don't try to complete the browser sign-in for them. |
| 132 | +- For a self-hosted Diffinery, they run the same script from their server (`curl -fsSL https://git.example.com/install-editor | bash`) and choose **Use git.example.com** when the editor asks, or run **Diffinery: Use Another Server…**. Don't edit their `settings.json` for them. | |
| 113 | 133 | - For connecting an editor's AI (Cursor's agent, VS Code's Copilot Chat) to Diffinery's MCP server, see [Other apps](/docs/connect/other-apps). |
modifiedapps/jetbrains/CHANGELOG.md+9−0View file
| @@ -1,5 +1,14 @@ | ||
| 1 | 1 | # Changelog |
| 2 | 2 | |
| 3 | +## 0.1.1 | |
| 4 | + | |
| 5 | +- Works with any Diffinery, not only diffinery.com: **Use another server…** under **Sign In with Browser…** on the | |
| 6 | + Diffinery page of **Get from Version Control** asks for your server's address, checks that a Diffinery answers | |
| 7 | + there, and switches to it. | |
| 8 | +- Installed from your own Diffinery's plugin repository, the sign-in page offers that server in one click | |
| 9 | + (**Use git.example.com**). | |
| 10 | +- **Share Project on Diffinery…** asks which server to sign in to when you aren't signed in yet. | |
| 11 | + | |
| 3 | 12 | ## 0.1.0 |
| 4 | 13 | |
| 5 | 14 | - **Diffinery** in **Get from Version Control**: your repositories and your organizations', search across every |
modifiedapps/jetbrains/README.md+9−0View file
| @@ -9,6 +9,15 @@ | ||
| 9 | 9 | It's plain Java (no Kotlin), against the platform's stable API only: `./gradlew verifyPlugin` runs JetBrains' Plugin |
| 10 | 10 | Verifier against IntelliJ IDEA 2024.3, 2025.2 and the version the plugin builds with, and fails on internal API use. |
| 11 | 11 | |
| 12 | +## Self-hosted Diffinery | |
| 13 | + | |
| 14 | +The server is `server` in the IDE's `diffinery.xml` (`Accounts.setServer`), `https://diffinery.com` by default. People | |
| 15 | +change it from the Diffinery page of **Get from Version Control**: **Use another server…** under the sign-in button | |
| 16 | +(`SignInDialogs.chooseServer`, which checks that `/-/jetbrains/updatePlugins.xml` answers there first), or one click on | |
| 17 | +**Use git.example.com** when the IDE's plugin repositories include a Diffinery's `updatePlugins.xml` | |
| 18 | +(`Util.pluginRepositoryServers`). **Share Project on Diffinery…** offers the same choice before signing in, and | |
| 19 | +**Settings → Version Control → Diffinery → Server** remains. Changing the server signs out of the old one. | |
| 20 | + | |
| 12 | 21 | ## Build |
| 13 | 22 | |
| 14 | 23 | Gradle 9 through the wrapper, with a JDK 21 or later (the IDE's own JetBrains Runtime works): |
deletedapps/jetbrains/dist/diffinery-jetbrains-0.1.0.zip+0−0
Binary file not shown.
addedapps/jetbrains/dist/diffinery-jetbrains-0.1.1.zip+0−0View file
Binary file not shown.
modifiedapps/jetbrains/gradle.properties+1−1View file
| @@ -1,4 +1,4 @@ | ||
| 1 | -pluginVersion=0.1.0 | |
| 1 | +pluginVersion=0.1.1 | |
| 2 | 2 | # The IDE the plugin is built against. -PideaHome=/path/to/idea uses one already on disk instead of downloading it. |
| 3 | 3 | platformVersion=2026.2.3 |
| 4 | 4 | sinceBuild=243 |
modifiedapps/jetbrains/src/main/java/com/diffinery/jetbrains/CloneComponent.java+21−2View file
| @@ -5,7 +5,6 @@ | ||
| 5 | 5 | import com.intellij.openapi.application.ApplicationManager; |
| 6 | 6 | import com.intellij.openapi.application.ModalityState; |
| 7 | 7 | import com.intellij.openapi.fileChooser.FileChooserDescriptorFactory; |
| 8 | -import com.intellij.openapi.options.ShowSettingsUtil; | |
| 9 | 8 | import com.intellij.openapi.project.Project; |
| 10 | 9 | import com.intellij.openapi.ui.TextBrowseFolderListener; |
| 11 | 10 | import com.intellij.openapi.ui.TextFieldWithBrowseButton; |
| @@ -21,6 +20,7 @@ | ||
| 21 | 20 | import com.intellij.ui.ScrollPaneFactory; |
| 22 | 21 | import com.intellij.ui.SearchTextField; |
| 23 | 22 | import com.intellij.ui.SimpleTextAttributes; |
| 23 | +import com.intellij.ui.components.ActionLink; | |
| 24 | 24 | import com.intellij.ui.components.JBLabel; |
| 25 | 25 | import com.intellij.ui.components.JBList; |
| 26 | 26 | import com.intellij.util.ui.JBUI; |
| @@ -29,6 +29,7 @@ | ||
| 29 | 29 | import git4idea.commands.Git; |
| 30 | 30 | import java.awt.BorderLayout; |
| 31 | 31 | import java.awt.CardLayout; |
| 32 | +import java.awt.FlowLayout; | |
| 32 | 33 | import java.awt.GridBagConstraints; |
| 33 | 34 | import java.awt.GridBagLayout; |
| 34 | 35 | import java.awt.event.KeyAdapter; |
| @@ -72,6 +73,8 @@ | ||
| 72 | 73 | private final TextFieldWithBrowseButton directory = new TextFieldWithBrowseButton(); |
| 73 | 74 | private final JBLabel serverLabel = new JBLabel(); |
| 74 | 75 | private final JButton signInButton = new JButton("Sign In with Browser…"); |
| 76 | + private final ActionLink suggestedLink = Plugin.link("", this::useSuggested); | |
| 77 | + private String suggested; | |
| 75 | 78 | |
| 76 | 79 | private List<Repo> mine = List.of(); |
| 77 | 80 | private List<Repo> found = List.of(); |
| @@ -117,8 +120,13 @@ | ||
| 117 | 120 | serverLabel.setForeground(UIUtil.getContextHelpForeground()); |
| 118 | 121 | c.insets = JBUI.insetsTop(16); |
| 119 | 122 | p.add(serverLabel, c); |
| 123 | + // A self-hosted Diffinery is one click away: the one this plugin was installed from, when the IDE's plugin | |
| 124 | + // repositories name it, or any other by its address. | |
| 125 | + JPanel servers = new JPanel(new FlowLayout(FlowLayout.CENTER, JBUI.scale(12), 0)); | |
| 126 | + servers.add(suggestedLink); | |
| 127 | + servers.add(Plugin.link("Use another server…", () -> SignInDialogs.chooseServer(project, this::refresh))); | |
| 120 | 128 | c.insets = JBUI.emptyInsets(); |
| 121 | - p.add(Plugin.link("Change server…", () -> ShowSettingsUtil.getInstance().showSettingsDialog(project, SettingsPage.class)), c); | |
| 129 | + p.add(servers, c); | |
| 122 | 130 | return p; |
| 123 | 131 | } |
| 124 | 132 | |
| @@ -213,6 +221,11 @@ | ||
| 213 | 221 | getDialogStateListener().onListItemChanged(); |
| 214 | 222 | if (a == null) { |
| 215 | 223 | serverLabel.setText("Server: " + Util.host(Accounts.get().server())); |
| 224 | + List<String> others = SignInDialogs.pluginRepositoryServers(); | |
| 225 | + suggested = others.isEmpty() ? null : others.get(0); | |
| 226 | + suggestedLink.setText(suggested == null ? "" : "Use " + Util.host(suggested)); | |
| 227 | + suggestedLink.setToolTipText(suggested == null ? null : "This IDE installs the Diffinery plugin from " + suggested); | |
| 228 | + suggestedLink.setVisible(suggested != null); | |
| 216 | 229 | cards.show(root, SIGNED_OUT); |
| 217 | 230 | getDialogStateListener().onOkActionEnabled(false); |
| 218 | 231 | return; |
| @@ -222,6 +235,12 @@ | ||
| 222 | 235 | load(); |
| 223 | 236 | } |
| 224 | 237 | |
| 238 | + /** Switches to the Diffinery this plugin came from: it served the plugin, so there's nothing to check. */ | |
| 239 | + private void useSuggested() { | |
| 240 | + String s = suggested; | |
| 241 | + if (s != null) ApplicationManager.getApplication().executeOnPooledThread(() -> Accounts.get().setServer(s)); | |
| 242 | + } | |
| 243 | + | |
| 225 | 244 | private void load() { |
| 226 | 245 | int gen = loads.incrementAndGet(); |
| 227 | 246 | mine = List.of(); |
modifiedapps/jetbrains/src/main/java/com/diffinery/jetbrains/SettingsPage.java+1−1View file
| @@ -47,7 +47,7 @@ | ||
| 47 | 47 | buttons.add(signIn); |
| 48 | 48 | buttons.add(useToken); |
| 49 | 49 | buttons.add(signOut); |
| 50 | - JBLabel serverHelp = new JBLabel("Change it only for a self-hosted Diffinery. Changing it signs you out."); | |
| 50 | + JBLabel serverHelp = new JBLabel("https://diffinery.com, or the address of your own Diffinery. Changing it signs you out."); | |
| 51 | 51 | serverHelp.setForeground(UIUtil.getContextHelpForeground()); |
| 52 | 52 | JBLabel gitHelp = new JBLabel("<html>When you're signed in, Git uses this account for the server: clone, pull and push need no password.</html>"); |
| 53 | 53 | gitHelp.setForeground(UIUtil.getContextHelpForeground()); |
modifiedapps/jetbrains/src/main/java/com/diffinery/jetbrains/SignInDialogs.java+104−0View file
| @@ -2,19 +2,123 @@ | ||
| 2 | 2 | |
| 3 | 3 | import com.intellij.openapi.application.ApplicationManager; |
| 4 | 4 | import com.intellij.openapi.application.ModalityState; |
| 5 | +import com.intellij.openapi.diagnostic.Logger; | |
| 5 | 6 | import com.intellij.openapi.progress.ProgressIndicator; |
| 6 | 7 | import com.intellij.openapi.progress.ProgressManager; |
| 7 | 8 | import com.intellij.openapi.progress.Task; |
| 8 | 9 | import com.intellij.openapi.project.Project; |
| 10 | +import com.intellij.openapi.ui.InputValidatorEx; | |
| 9 | 11 | import com.intellij.openapi.ui.Messages; |
| 12 | +import com.intellij.openapi.updateSettings.impl.UpdateSettings; | |
| 13 | +import java.io.IOException; | |
| 14 | +import java.util.List; | |
| 10 | 15 | import java.util.function.Consumer; |
| 11 | 16 | import org.jetbrains.annotations.NotNull; |
| 12 | 17 | import org.jetbrains.annotations.Nullable; |
| 13 | 18 | |
| 14 | 19 | /** Signing in from anywhere in the IDE: with a progress dialog that can be cancelled, and errors shown as sentences. */ |
| 15 | 20 | final class SignInDialogs { |
| 21 | + private static final Logger LOG = Logger.getInstance(SignInDialogs.class); | |
| 22 | + | |
| 16 | 23 | private SignInDialogs() {} |
| 17 | 24 | |
| 25 | + /** | |
| 26 | + * Signing in from a place that doesn't show the server (Share Project): says which Diffinery, with a way to pick | |
| 27 | + * another one first. `done` runs on the UI thread with the account once signed in. | |
| 28 | + */ | |
| 29 | + static void signIn(@Nullable Project project, @Nullable Consumer<Accounts.Account> done) { | |
| 30 | + String host = Util.host(Accounts.get().server()); | |
| 31 | + int choice = Messages.showDialog(project, "Sign in to Diffinery at " + host + " with your passkey in the browser, or use another Diffinery server, such as your own.", "Sign In to Diffinery", new String[] {"Sign In to " + host + "…", "Use Another Server…", Messages.getCancelButton()}, 0, Plugin.ICON); | |
| 32 | + if (choice == 0) signInWithBrowser(project, done); | |
| 33 | + else if (choice == 1) chooseServer(project, () -> signInWithBrowser(project, done)); | |
| 34 | + } | |
| 35 | + | |
| 36 | + /** | |
| 37 | + * The self-hosted Diffinery servers this IDE installs plugins from (Settings → Plugins → Manage Plugin | |
| 38 | + * Repositories), other than the current one. | |
| 39 | + */ | |
| 40 | + static List<String> pluginRepositoryServers() { | |
| 41 | + try { | |
| 42 | + return Util.pluginRepositoryServers(UpdateSettings.getInstance().getStoredPluginHosts(), Accounts.get().server()); | |
| 43 | + } catch (RuntimeException | LinkageError e) { | |
| 44 | + // Only a suggestion: an IDE that keeps its plugin repositories elsewhere just doesn't get one. | |
| 45 | + LOG.info("Couldn't read the IDE's plugin repositories: " + e); | |
| 46 | + return List.of(); | |
| 47 | + } | |
| 48 | + } | |
| 49 | + | |
| 50 | + /** | |
| 51 | + * "Use another server…": asks for a self-hosted Diffinery's address (offering the one this plugin came from), | |
| 52 | + * checks that a Diffinery answers there, and switches to it, which signs out of the current server. `done` runs on | |
| 53 | + * the UI thread once switched; not when cancelled. | |
| 54 | + */ | |
| 55 | + static void chooseServer(@Nullable Project project, @Nullable Runnable done) { | |
| 56 | + String current = Accounts.get().server(); | |
| 57 | + List<String> suggested = pluginRepositoryServers(); | |
| 58 | + String initial = !suggested.isEmpty() ? suggested.get(0) : current.equals(Util.DEFAULT_SERVER) ? "" : current; | |
| 59 | + Accounts.Account signedIn = Accounts.get().account(); | |
| 60 | + String raw = Messages.showInputDialog( | |
| 61 | + project, | |
| 62 | + "The address of your Diffinery, as in your browser's address bar, such as git.example.com. Enter diffinery.com to go back." | |
| 63 | + + (signedIn != null ? "\nChanging the server signs you out of " + Util.host(current) + "." : ""), | |
| 64 | + "Use Another Diffinery Server", | |
| 65 | + Plugin.ICON, | |
| 66 | + initial, | |
| 67 | + new InputValidatorEx() { | |
| 68 | + @Override | |
| 69 | + public String getErrorText(String input) { | |
| 70 | + return Util.serverInputError(input); | |
| 71 | + } | |
| 72 | + | |
| 73 | + @Override | |
| 74 | + public boolean checkInput(String input) { | |
| 75 | + return Util.serverInputError(input) == null; | |
| 76 | + } | |
| 77 | + | |
| 78 | + @Override | |
| 79 | + public boolean canClose(String input) { | |
| 80 | + return checkInput(input); | |
| 81 | + } | |
| 82 | + }); | |
| 83 | + if (raw == null) return; | |
| 84 | + String server = Util.normalizeServer(raw); | |
| 85 | + if (server.equals(current)) { | |
| 86 | + if (done != null) done.run(); | |
| 87 | + return; | |
| 88 | + } | |
| 89 | + ProgressManager.getInstance().run(new Task.Modal(project, "Looking for Diffinery at " + Util.host(server), true) { | |
| 90 | + private String problem; | |
| 91 | + | |
| 92 | + @Override | |
| 93 | + public void run(@NotNull ProgressIndicator indicator) { | |
| 94 | + indicator.setIndeterminate(true); | |
| 95 | + problem = probe(server); | |
| 96 | + } | |
| 97 | + | |
| 98 | + @Override | |
| 99 | + public void onSuccess() { | |
| 100 | + if (problem != null | |
| 101 | + && Messages.showYesNoDialog(project, server + " doesn't look like a Diffinery server: " + problem + "\nCheck the address, and use it anyway only if you're sure.", "Use Another Diffinery Server", "Use Anyway", Messages.getCancelButton(), Messages.getWarningIcon()) | |
| 102 | + != Messages.YES) return; | |
| 103 | + ApplicationManager.getApplication().executeOnPooledThread(() -> { | |
| 104 | + Accounts.get().setServer(server); | |
| 105 | + if (done != null) ApplicationManager.getApplication().invokeLater(done, ModalityState.any()); | |
| 106 | + }); | |
| 107 | + } | |
| 108 | + }); | |
| 109 | + } | |
| 110 | + | |
| 111 | + /** Null when a Diffinery answers at the server (every instance serves this plugin's repository), else what went wrong. */ | |
| 112 | + private static String probe(String server) { | |
| 113 | + try { | |
| 114 | + Api.Response r = Api.send("GET", server + "/-/jetbrains/updatePlugins.xml", null, null, null); | |
| 115 | + if (r.status() != 200) return "its plugin repository answered " + r.status() + "."; | |
| 116 | + return r.body().contains("\"" + Plugin.ID + "\"") ? null : "it doesn't serve the Diffinery plugin."; | |
| 117 | + } catch (IOException e) { | |
| 118 | + return "it couldn't be reached (" + e.getMessage() + ")."; | |
| 119 | + } | |
| 120 | + } | |
| 121 | + | |
| 18 | 122 | /** Opens the browser to sign in; `done` runs on the UI thread with the account once signed in. */ |
| 19 | 123 | static void signInWithBrowser(@Nullable Project project, @Nullable Consumer<Accounts.Account> done) { |
| 20 | 124 | ProgressManager.getInstance().run(new Task.Modal(project, "Sign In to Diffinery", true) { |
modifiedapps/jetbrains/src/main/java/com/diffinery/jetbrains/Util.java+40−0View file
| @@ -7,6 +7,7 @@ | ||
| 7 | 7 | import java.security.MessageDigest; |
| 8 | 8 | import java.security.NoSuchAlgorithmException; |
| 9 | 9 | import java.security.SecureRandom; |
| 10 | +import java.util.ArrayList; | |
| 10 | 11 | import java.util.Base64; |
| 11 | 12 | import java.util.LinkedHashMap; |
| 12 | 13 | import java.util.List; |
| @@ -43,6 +44,45 @@ | ||
| 43 | 44 | return scheme + "://" + u.getRawAuthority() + path; |
| 44 | 45 | } |
| 45 | 46 | |
| 47 | + /** Why what someone typed as a server address can't be used, for the dialog; null when it can. */ | |
| 48 | + static String serverInputError(String raw) { | |
| 49 | + String s = raw == null ? "" : raw.trim(); | |
| 50 | + if (s.isEmpty()) return "Enter your Diffinery's address, such as git.example.com."; | |
| 51 | + if (s.chars().anyMatch(Character::isWhitespace)) return "An address has no spaces."; | |
| 52 | + try { | |
| 53 | + normalizeServer(s); | |
| 54 | + return null; | |
| 55 | + } catch (IllegalArgumentException e) { | |
| 56 | + return e.getMessage(); | |
| 57 | + } | |
| 58 | + } | |
| 59 | + | |
| 60 | + /** The Diffinery that serves a plugin repository (https://git.example.com/-/jetbrains/updatePlugins.xml), or null. */ | |
| 61 | + static String serverOfPluginRepository(String url) { | |
| 62 | + if (url == null) return null; | |
| 63 | + String u = url.trim().replaceAll("[?#].*$", ""); | |
| 64 | + String suffix = "/-/jetbrains/updatePlugins.xml"; | |
| 65 | + if (!u.endsWith(suffix) || !SCHEME.matcher(u).find()) return null; | |
| 66 | + try { | |
| 67 | + return normalizeServer(u.substring(0, u.length() - suffix.length())); | |
| 68 | + } catch (IllegalArgumentException e) { | |
| 69 | + return null; | |
| 70 | + } | |
| 71 | + } | |
| 72 | + | |
| 73 | + /** | |
| 74 | + * The Diffinery servers among the IDE's plugin repositories, other than `current`, in order: someone who installed | |
| 75 | + * this plugin from their own Diffinery has its updatePlugins.xml there, so it's the server they most likely mean. | |
| 76 | + */ | |
| 77 | + static List<String> pluginRepositoryServers(List<String> repositories, String current) { | |
| 78 | + List<String> out = new ArrayList<>(); | |
| 79 | + for (String r : repositories) { | |
| 80 | + String s = serverOfPluginRepository(r); | |
| 81 | + if (s != null && !s.equals(current) && !out.contains(s)) out.add(s); | |
| 82 | + } | |
| 83 | + return out; | |
| 84 | + } | |
| 85 | + | |
| 46 | 86 | /** The OAuth client_id of this plugin on a server: the URL of the metadata document the server publishes about it. */ |
| 47 | 87 | static String clientId(String server) { |
| 48 | 88 | return server + "/oauth/clients/jetbrains.json"; |
modifiedapps/jetbrains/src/main/resources/META-INF/plugin.xml+1−0View file
| @@ -13,6 +13,7 @@ | ||
| 13 | 13 | <li><b>Push and pull without passwords.</b> When Git asks for a password for Diffinery, the plugin answers with |
| 14 | 14 | your signed-in account, and renews its token before it runs out.</li> |
| 15 | 15 | <li><b>Share Project on Diffinery.</b> Publish a local Git repository as a new Diffinery repository.</li> |
| 16 | + <li><b>Any Diffinery.</b> diffinery.com or your own: choose <b>Use another server…</b> under the sign-in button.</li> | |
| 16 | 17 | </ul> |
| 17 | 18 | <p>Guide: <a href="https://diffinery.com/docs/repositories/clone-in-jetbrains-ides">Clone in JetBrains IDEs</a>.</p> |
| 18 | 19 | ]]></description> |
modifiedapps/jetbrains/src/test/java/com/diffinery/jetbrains/UtilTest.java+28−0View file
| @@ -31,6 +31,34 @@ | ||
| 31 | 31 | } |
| 32 | 32 | |
| 33 | 33 | @Test |
| 34 | + public void serverInput() { | |
| 35 | + assertNull(Util.serverInputError("git.example.com")); | |
| 36 | + assertNull(Util.serverInputError(" https://git.example.com/ ")); | |
| 37 | + assertNull(Util.serverInputError("http://localhost:8787")); | |
| 38 | + assertTrue(Util.serverInputError("").startsWith("Enter")); | |
| 39 | + assertTrue(Util.serverInputError("git example").contains("spaces")); | |
| 40 | + assertTrue(Util.serverInputError("http://git.example.com").contains("https")); | |
| 41 | + assertTrue(Util.serverInputError("https://").contains("URL")); | |
| 42 | + } | |
| 43 | + | |
| 44 | + @Test | |
| 45 | + public void pluginRepositoryServers() { | |
| 46 | + assertEquals("https://git.example.com", Util.serverOfPluginRepository("https://git.example.com/-/jetbrains/updatePlugins.xml")); | |
| 47 | + assertEquals("https://git.example.com:8443/forge", Util.serverOfPluginRepository(" https://git.example.com:8443/forge/-/jetbrains/updatePlugins.xml?build=IU-262 ")); | |
| 48 | + assertNull(Util.serverOfPluginRepository("https://plugins.example.com/updatePlugins.xml")); | |
| 49 | + assertNull(Util.serverOfPluginRepository("/-/jetbrains/updatePlugins.xml")); | |
| 50 | + assertNull(Util.serverOfPluginRepository("http://git.example.com/-/jetbrains/updatePlugins.xml")); | |
| 51 | + assertNull(Util.serverOfPluginRepository(null)); | |
| 52 | + List<String> hosts = List.of( | |
| 53 | + "https://diffinery.com/-/jetbrains/updatePlugins.xml", | |
| 54 | + "https://plugins.example.com/updatePlugins.xml", | |
| 55 | + "https://git.example.com/-/jetbrains/updatePlugins.xml", | |
| 56 | + "https://git.example.com/-/jetbrains/updatePlugins.xml"); | |
| 57 | + assertEquals(List.of("https://git.example.com"), Util.pluginRepositoryServers(hosts, S)); | |
| 58 | + assertEquals(List.of(S), Util.pluginRepositoryServers(hosts, "https://git.example.com")); | |
| 59 | + } | |
| 60 | + | |
| 61 | + @Test | |
| 34 | 62 | public void authorizeUrl() { |
| 35 | 63 | String url = Util.authorizeUrl(S, "http://127.0.0.1:5000/callback", "ch", "st"); |
| 36 | 64 | assertTrue(url.startsWith(S + "/oauth/authorize?")); |
modifiedapps/vscode/CHANGELOG.md+6−0View file
| @@ -1,5 +1,11 @@ | ||
| 1 | 1 | # Changelog |
| 2 | 2 | |
| 3 | +## 0.1.2 | |
| 4 | + | |
| 5 | +- Works with any Diffinery, not only diffinery.com: the first sign-in asks which Diffinery you use, with **Use another Diffinery server…** for a self-hosted one. | |
| 6 | +- New command **Diffinery: Use Another Server…**. Each server keeps its own sign-in. | |
| 7 | +- Installed with a self-hosted Diffinery's `install-editor` script, the extension asks on its next start whether to use that server. | |
| 8 | + | |
| 3 | 9 | ## 0.1.1 |
| 4 | 10 | |
| 5 | 11 | - "Sign In with an Access Token" goes through the editor's own sign-in, so the first clone afterwards no longer asks to allow the extension to sign in. |
modifiedapps/vscode/README.md+21−3View file
| @@ -6,8 +6,9 @@ | ||
| 6 | 6 | - **Clone from Diffinery.** Run **Git: Clone** (or **Clone Repository** on the welcome page) and pick |
| 7 | 7 | **Clone from Diffinery**. You see your repositories and your organizations', most recently pushed first; |
| 8 | 8 | type to search everything you can read, or paste `owner/name`. |
| 9 | -- **Sign in with your passkey.** The first time, the editor opens diffinery.com in your browser, you approve | |
| 10 | - the connection, and you're back in the editor. Diffinery shows up in the Accounts menu. Nothing to copy. | |
| 9 | +- **Sign in with your passkey.** The first time, choose your Diffinery (diffinery.com or your own server), the | |
| 10 | + editor opens it in your browser, you approve the connection, and you're back in the editor. Diffinery shows up | |
| 11 | + in the Accounts menu. Nothing to copy. | |
| 11 | 12 | - **Push and pull without passwords.** Git uses your signed-in account for diffinery.com. The extension renews |
| 12 | 13 | its token before it expires and keeps the copy Git saved in your keychain current. |
| 13 | 14 | - **Publish to Diffinery.** Publishing a local repository that has no remote offers **Publish to Diffinery**, |
| @@ -29,6 +30,22 @@ | ||
| 29 | 30 | The full guide, including JetBrains IDEs and the "Open in VS Code / Cursor" buttons on every repository, is at |
| 30 | 31 | [diffinery.com/docs/repositories/clone-in-your-editor](https://diffinery.com/docs/repositories/clone-in-your-editor). |
| 31 | 32 | |
| 33 | +## Self-hosted Diffinery | |
| 34 | + | |
| 35 | +The extension works with any Diffinery, not only diffinery.com: | |
| 36 | + | |
| 37 | +- **The first time you sign in** (from **Clone from Diffinery**, the Accounts menu or **Diffinery: Sign In**), the | |
| 38 | + extension asks which Diffinery you use. Choose **Use another Diffinery server…** and enter its address, such as | |
| 39 | + `git.example.com`. The extension checks that a Diffinery answers there and saves it in `diffinery.serverUrl`. | |
| 40 | +- **Diffinery: Use Another Server…** changes the server at any time. Each server keeps its own sign-in, so you can | |
| 41 | + switch back without signing in again. | |
| 42 | +- **Installed from your server** with `curl -fsSL https://git.example.com/install-editor | bash` (or its | |
| 43 | + `install-editor.ps1`), the extension asks on its next start whether to use that server: choose | |
| 44 | + **Use git.example.com**. The installer leaves the address in `~/.config/diffinery/editor-server` for this; it | |
| 45 | + doesn't edit the editor's settings. | |
| 46 | + | |
| 47 | +The extension then updates itself from your server too. | |
| 48 | + | |
| 32 | 49 | ## Commands |
| 33 | 50 | |
| 34 | 51 | | Command | What it does | |
| @@ -36,6 +53,7 @@ | ||
| 36 | 53 | | Diffinery: Clone Repository | Opens the Diffinery repository picker directly. | |
| 37 | 54 | | Diffinery: Sign In | Signs in through your browser. | |
| 38 | 55 | | Diffinery: Sign In with an Access Token | Uses a token from Settings → Access tokens instead (agents, browser-based editors). | |
| 56 | +| Diffinery: Use Another Server… | Switches to a self-hosted Diffinery (or back to diffinery.com). | | |
| 39 | 57 | | Diffinery: Sign Out | Signs out and disconnects the editor from your account. | |
| 40 | 58 | | Diffinery: Check for Extension Updates | Installs a newer build from the server, if there is one. | |
| 41 | 59 | |
| @@ -43,6 +61,6 @@ | ||
| 43 | 61 | |
| 44 | 62 | | Setting | Default | | |
| 45 | 63 | |---|---|---| |
| 46 | -| `diffinery.serverUrl` | `https://diffinery.com` | Change only for a self-hosted Diffinery. | | |
| 64 | +| `diffinery.serverUrl` | `https://diffinery.com` | The Diffinery to use: set it to your server's address for a self-hosted Diffinery. | | |
| 47 | 65 | | `diffinery.gitAuthentication` | `true` | Answer Git's password prompts for the server with your signed-in account. | |
| 48 | 66 | | `diffinery.checkForUpdates` | `true` | Check the server once a day for a newer build. | |
modifiedapps/vscode/install/install.ps1+24−0View file
| @@ -27,6 +27,22 @@ | ||
| 27 | 27 | $vsix = Join-Path $tmp "diffinery-$($meta.version).vsix" |
| 28 | 28 | Invoke-WebRequest "$server/-/vscode/diffinery-$($meta.version).vsix" -OutFile $vsix -UseBasicParsing |
| 29 | 29 | if ((Get-FileHash $vsix -Algorithm SHA256).Hash.ToLower() -ne $meta.sha256) { throw "The download didn't match the published SHA-256; nothing was installed." } |
| 30 | + # A self-hosted Diffinery: leave its address where the extension looks when it starts (src/servers.ts), and it asks | |
| 31 | + # whether to use it. Editors' command-line tools can't change a setting, and their settings files are JSON with | |
| 32 | + # comments, so this doesn't edit them. Written before installing, since a running editor starts a new extension at once. | |
| 33 | + $conf = if ($env:XDG_CONFIG_HOME -and [IO.Path]::IsPathRooted($env:XDG_CONFIG_HOME)) { $env:XDG_CONFIG_HOME } else { Join-Path $(if ($env:HOME) { $env:HOME } else { $env:USERPROFILE }) ".config" } | |
| 34 | + $hintFile = Join-Path $conf "diffinery\editor-server" | |
| 35 | + $hint = $false | |
| 36 | + if ($server -ne "https://diffinery.com") { | |
| 37 | + try { | |
| 38 | + New-Item -ItemType Directory -Force (Split-Path $hintFile) | Out-Null | |
| 39 | + [IO.File]::WriteAllText($hintFile, "$server`n") | |
| 40 | + $hint = $true | |
| 41 | + } catch {} | |
| 42 | + } else { | |
| 43 | + Remove-Item -Force $hintFile -ErrorAction SilentlyContinue | |
| 44 | + } | |
| 45 | + | |
| 30 | 46 | $installed = 0 |
| 31 | 47 | foreach ($e in $known) { |
| 32 | 48 | if ($wanted.Count -gt 0 -and $wanted -notcontains $e.Name) { continue } |
| @@ -46,6 +62,14 @@ | ||
| 46 | 62 | exit 1 |
| 47 | 63 | } |
| 48 | 64 | Write-Host "" |
| 65 | + if ($server -ne "https://diffinery.com") { | |
| 66 | + if ($hint) { | |
| 67 | + Write-Host "This Diffinery is $server. When the editor starts, the extension asks to use it: choose Use $($server -replace '^[a-z]+://', '')." | |
| 68 | + Write-Host "If it doesn't ask, run Diffinery: Use Another Server... from the Command Palette and enter $server." | |
| 69 | + } else { | |
| 70 | + Write-Host "This Diffinery is $server`: in the editor, run Diffinery: Use Another Server... from the Command Palette and enter it." | |
| 71 | + } | |
| 72 | + } | |
| 49 | 73 | Write-Host "Next: in the editor, run Git: Clone and pick Clone from Diffinery. ($server/docs/repositories/clone-in-your-editor)" |
| 50 | 74 | } finally { |
| 51 | 75 | Remove-Item -Recurse -Force $tmp -ErrorAction SilentlyContinue |
modifiedapps/vscode/install/install.sh+20−0View file
| @@ -65,6 +65,18 @@ | ||
| 65 | 65 | if command -v sha256sum >/dev/null; then got=$(sha256sum "$vsix" | cut -d' ' -f1); else got=$(shasum -a 256 "$vsix" | cut -d' ' -f1); fi |
| 66 | 66 | [ "$got" = "$sha" ] || die "the download didn't match the published SHA-256; nothing was installed" |
| 67 | 67 | |
| 68 | +# A self-hosted Diffinery: leave its address where the extension looks when it starts (src/servers.ts), and it asks | |
| 69 | +# whether to use it. Editors' command-line tools can't change a setting, and their settings files are JSON with | |
| 70 | +# comments, so this doesn't edit them. Written before installing, since a running editor starts a new extension at once. | |
| 71 | +conf=${XDG_CONFIG_HOME:-} | |
| 72 | +case $conf in /*) ;; *) conf=$HOME/.config ;; esac | |
| 73 | +hint="" | |
| 74 | +if [ "$server" != "https://diffinery.com" ]; then | |
| 75 | + if mkdir -p "$conf/diffinery" 2>/dev/null && printf '%s\n' "$server" >"$conf/diffinery/editor-server" 2>/dev/null; then hint=1; fi | |
| 76 | +else | |
| 77 | + rm -f "$conf/diffinery/editor-server" 2>/dev/null || true | |
| 78 | +fi | |
| 79 | + | |
| 68 | 80 | installed=0 |
| 69 | 81 | while IFS='|' read -r name label app; do |
| 70 | 82 | if [ "$wanted" != " " ] && [[ $wanted != *" $name "* ]]; then continue; fi |
| @@ -86,4 +98,12 @@ | ||
| 86 | 98 | exit 1 |
| 87 | 99 | fi |
| 88 | 100 | say "" |
| 101 | +if [ "$server" != "https://diffinery.com" ]; then | |
| 102 | + if [ -n "$hint" ]; then | |
| 103 | + say "This Diffinery is ${bold}$server${reset}. When the editor starts, the extension asks to use it: choose ${bold}Use ${server#*://}${reset}." | |
| 104 | + say "If it doesn't ask, run ${bold}Diffinery: Use Another Server…${reset} from the Command Palette and enter $server." | |
| 105 | + else | |
| 106 | + say "This Diffinery is ${bold}$server${reset}: in the editor, run ${bold}Diffinery: Use Another Server…${reset} from the Command Palette and enter it." | |
| 107 | + fi | |
| 108 | +fi | |
| 89 | 109 | say "Next: in the editor, run ${bold}Git: Clone${reset} and pick ${bold}Clone from Diffinery${reset}. ${dim}($server/docs/repositories/clone-in-your-editor)${reset}" |
modifiedapps/vscode/package.json+7−2View file
| @@ -2,7 +2,7 @@ | ||
| 2 | 2 | "name": "@diffinery/vscode", |
| 3 | 3 | "displayName": "Diffinery", |
| 4 | 4 | "description": "Clone, publish and push Diffinery repositories from VS Code, Cursor, Windsurf and other editors built on VS Code.", |
| 5 | - "version": "0.1.1", | |
| 5 | + "version": "0.1.2", | |
| 6 | 6 | "publisher": "codician", |
| 7 | 7 | "private": true, |
| 8 | 8 | "license": "Apache-2.0", |
| @@ -86,6 +86,11 @@ | ||
| 86 | 86 | "category": "Diffinery" |
| 87 | 87 | }, |
| 88 | 88 | { |
| 89 | + "command": "diffinery.useServer", | |
| 90 | + "title": "Use Another Server…", | |
| 91 | + "category": "Diffinery" | |
| 92 | + }, | |
| 93 | + { | |
| 89 | 94 | "command": "diffinery.signOut", |
| 90 | 95 | "title": "Sign Out", |
| 91 | 96 | "category": "Diffinery" |
| @@ -103,7 +108,7 @@ | ||
| 103 | 108 | "type": "string", |
| 104 | 109 | "default": "https://diffinery.com", |
| 105 | 110 | "scope": "application", |
| 106 | - "markdownDescription": "The Diffinery server to sign in to. Change it only for a self-hosted Diffinery." | |
| 111 | + "markdownDescription": "The Diffinery server to sign in to: `https://diffinery.com`, or the address of a self-hosted Diffinery. The first sign-in asks which one, and **Diffinery: Use Another Server…** changes it." | |
| 107 | 112 | }, |
| 108 | 113 | "diffinery.gitAuthentication": { |
| 109 | 114 | "type": "boolean", |
modifiedapps/vscode/src/auth.ts+10−0View file
| @@ -2,6 +2,7 @@ | ||
| 2 | 2 | import { randomUUID } from "node:crypto"; |
| 3 | 3 | import * as vscode from "vscode"; |
| 4 | 4 | import { Api } from "./api"; |
| 5 | +import { chooseServer } from "./servers"; | |
| 5 | 6 | import { authorizeUrl, clientId, editorRedirect, normalizeServer, pkce, readCallback } from "./util"; |
| 6 | 7 | |
| 7 | 8 | export const AUTH_ID = "diffinery"; |
| @@ -184,6 +185,10 @@ | ||
| 184 | 185 | } |
| 185 | 186 | |
| 186 | 187 | async createSession(): Promise<vscode.AuthenticationSession> { |
| 188 | + // Every way in (the Accounts menu, the clone picker, Publish, the commands) first asks which Diffinery, until | |
| 189 | + // someone has chosen, so a self-hosted one needs no trip to the settings. It may change diffinery.serverUrl, and | |
| 190 | + // with it this.key(). | |
| 191 | + await chooseServer(this.ctx.globalState); | |
| 187 | 192 | const s = this.withToken ? await this.askForToken() : await this.signInWithBrowser(); |
| 188 | 193 | // Signing in again replaces the old sign-in, and ends its connection on the server. |
| 189 | 194 | const old = await this.load(); |
| @@ -320,6 +325,11 @@ | ||
| 320 | 325 | return { id: randomUUID(), server, login: user.login, accountId: String(user.id), kind: "token", accessToken: token }; |
| 321 | 326 | } |
| 322 | 327 | |
| 328 | + /** Who is signed in to the server in the settings, if anyone. */ | |
| 329 | + async login(): Promise<string | undefined> { | |
| 330 | + return (await this.load())?.login; | |
| 331 | + } | |
| 332 | + | |
| 323 | 333 | async signOut(): Promise<void> { |
| 324 | 334 | const s = await this.load(); |
| 325 | 335 | if (!s) { |
modifiedapps/vscode/src/extension.ts+3−0View file
| @@ -2,6 +2,7 @@ | ||
| 2 | 2 | import { AUTH_ID, DiffineryAuth, SCOPES } from "./auth"; |
| 3 | 3 | import { GitCredentials, Publisher, syncCredentialStore } from "./git"; |
| 4 | 4 | import { RemoteSources } from "./remoteSources"; |
| 5 | +import { offerInstallerServer, switchServer } from "./servers"; | |
| 5 | 6 | import type { GitAPI, GitBaseAPI, GitBaseExtension, GitExtension } from "./typings/git"; |
| 6 | 7 | import { checkForUpdates } from "./updates"; |
| 7 | 8 | |
| @@ -26,6 +27,7 @@ | ||
| 26 | 27 | command("diffinery.signIn", async () => vscode.authentication.getSession(AUTH_ID, SCOPES, { createIfNone: true }), "Signing in to Diffinery failed"); |
| 27 | 28 | command("diffinery.signInWithToken", () => auth.signInWithToken(), "Signing in to Diffinery failed"); |
| 28 | 29 | command("diffinery.signOut", () => auth.signOut(), "Signing out of Diffinery failed"); |
| 30 | + command("diffinery.useServer", () => switchServer(ctx.globalState, () => auth.login()), "Changing the Diffinery server failed"); | |
| 29 | 31 | command("diffinery.checkForUpdates", () => checkForUpdates(ctx, auth.server(), version, log, true), "Checking for updates failed"); |
| 30 | 32 | |
| 31 | 33 | // ---------------------------------------------------------------- the built-in Git extensions |
| @@ -86,6 +88,7 @@ | ||
| 86 | 88 | "Cloning from Diffinery failed", |
| 87 | 89 | ); |
| 88 | 90 | |
| 91 | + void offerInstallerServer(ctx.globalState, () => auth.login(), log).catch(report("Changing the Diffinery server failed")); | |
| 89 | 92 | void checkForUpdates(ctx, auth.server(), version, log, false).catch((e) => log.warn("update check failed", e)); |
| 90 | 93 | } |
| 91 | 94 |
modifiedapps/vscode/src/git.ts+5−3View file
| @@ -10,14 +10,16 @@ | ||
| 10 | 10 | constructor(private readonly auth: DiffineryAuth) {} |
| 11 | 11 | |
| 12 | 12 | async getCredentials(host: vscode.Uri): Promise<Credentials | undefined> { |
| 13 | - let server: URL; | |
| 13 | + let server: string; | |
| 14 | 14 | try { |
| 15 | - server = new URL(this.auth.server()); | |
| 15 | + server = this.auth.server(); | |
| 16 | 16 | } catch { |
| 17 | 17 | return undefined; |
| 18 | 18 | } |
| 19 | - if (host.authority.toLowerCase() !== server.host.toLowerCase()) return undefined; | |
| 19 | + if (host.authority.toLowerCase() !== new URL(server).host.toLowerCase()) return undefined; | |
| 20 | 20 | const session = await vscode.authentication.getSession(AUTH_ID, SCOPES, { createIfNone: true }); |
| 21 | + // Signing in starts by choosing the server: if another one was chosen, its token isn't for this host. | |
| 22 | + if (this.auth.server() !== server) return undefined; | |
| 21 | 23 | return { username: session.account.label, password: session.accessToken }; |
| 22 | 24 | } |
| 23 | 25 | } |
addedapps/vscode/src/servers.ts+122−0View file
| @@ -1,0 +1,122 @@ | ||
| 1 | +import { readFile } from "node:fs/promises"; | |
| 2 | +import { homedir } from "node:os"; | |
| 3 | +import * as vscode from "vscode"; | |
| 4 | +import { DEFAULT_SERVER, installHintPath, normalizeServer, readInstallHint, serverInputError, serverLabel } from "./util"; | |
| 5 | + | |
| 6 | +/** Set (globalState) once someone has chosen a server, so signing in stops asking which one. */ | |
| 7 | +const CHOSEN = "diffinery.serverChosen"; | |
| 8 | +/** The installer's server this editor already asked about (globalState), so it asks once per address. */ | |
| 9 | +const HINT_ANSWERED = "diffinery.installHintAnswered"; | |
| 10 | + | |
| 11 | +/** The server in diffinery.serverUrl, or undefined when the setting isn't a usable address. */ | |
| 12 | +export function configuredServer(): string | undefined { | |
| 13 | + try { | |
| 14 | + return normalizeServer(vscode.workspace.getConfiguration("diffinery").get<string>("serverUrl")); | |
| 15 | + } catch { | |
| 16 | + return undefined; | |
| 17 | + } | |
| 18 | +} | |
| 19 | + | |
| 20 | +/** Saves the server in the user settings; diffinery.com clears the setting, so it follows the default. */ | |
| 21 | +export async function useServer(state: vscode.Memento, server: string): Promise<void> { | |
| 22 | + await vscode.workspace.getConfiguration("diffinery").update("serverUrl", server === DEFAULT_SERVER ? undefined : server, vscode.ConfigurationTarget.Global); | |
| 23 | + await state.update(CHOSEN, true); | |
| 24 | +} | |
| 25 | + | |
| 26 | +/** | |
| 27 | + * Asks for a self-hosted Diffinery's address, and checks that a Diffinery answers there before it's used, so a typo | |
| 28 | + * ends here rather than at an error page in the browser. Undefined when cancelled. | |
| 29 | + */ | |
| 30 | +export async function askForServer(value?: string): Promise<string | undefined> { | |
| 31 | + const raw = await vscode.window.showInputBox({ | |
| 32 | + title: "Use Another Diffinery Server", | |
| 33 | + prompt: "The address of your Diffinery, as in your browser's address bar. The extension signs in, clones and pushes there.", | |
| 34 | + placeHolder: "git.example.com", | |
| 35 | + value, | |
| 36 | + ignoreFocusOut: true, | |
| 37 | + validateInput: serverInputError, | |
| 38 | + }); | |
| 39 | + if (raw === undefined) return undefined; | |
| 40 | + const server = normalizeServer(raw); | |
| 41 | + const found = await vscode.window.withProgress({ location: vscode.ProgressLocation.Notification, title: `Looking for Diffinery at ${serverLabel(server)}…` }, () => isDiffinery(server)); | |
| 42 | + if (found !== true) { | |
| 43 | + const go = await vscode.window.showWarningMessage(`${server} doesn't look like a Diffinery server`, { modal: true, detail: `${found} Check the address, and use it anyway only if you're sure.` }, "Use Anyway"); | |
| 44 | + if (!go) return undefined; | |
| 45 | + } | |
| 46 | + return server; | |
| 47 | +} | |
| 48 | + | |
| 49 | +/** True when the server answers like Diffinery (every instance serves this extension at /-/vscode/), else what went wrong. */ | |
| 50 | +async function isDiffinery(server: string): Promise<true | string> { | |
| 51 | + try { | |
| 52 | + const res = await fetch(`${server}/-/vscode/version.json`, { headers: { accept: "application/json" }, signal: AbortSignal.timeout(15_000) }); | |
| 53 | + if (!res.ok) return `${server}/-/vscode/version.json answered ${res.status}.`; | |
| 54 | + const body = (await res.json()) as { version?: unknown }; | |
| 55 | + return typeof body.version === "string" ? true : `${server}/-/vscode/version.json isn't what Diffinery serves.`; | |
| 56 | + } catch (e) { | |
| 57 | + return `It couldn't be reached (${e instanceof Error ? e.message : String(e)}).`; | |
| 58 | + } | |
| 59 | +} | |
| 60 | + | |
| 61 | +/** | |
| 62 | + * The first sign-in asks which Diffinery: diffinery.com, or a self-hosted one by its address, so nobody has to find | |
| 63 | + * the setting first. Once someone has chosen, or set diffinery.serverUrl themselves, it doesn't ask again | |
| 64 | + * ("Diffinery: Use Another Server…" changes it later). | |
| 65 | + */ | |
| 66 | +export async function chooseServer(state: vscode.Memento): Promise<void> { | |
| 67 | + if (state.get<boolean>(CHOSEN) || vscode.workspace.getConfiguration("diffinery").inspect<string>("serverUrl")?.globalValue !== undefined) return; | |
| 68 | + const items: Array<vscode.QuickPickItem & { server?: string }> = [ | |
| 69 | + { label: `$(diffinery-logo) Sign in to ${serverLabel(DEFAULT_SERVER)}`, server: DEFAULT_SERVER }, | |
| 70 | + { label: "$(server) Use another Diffinery server…", detail: "A self-hosted Diffinery on your own domain" }, | |
| 71 | + ]; | |
| 72 | + const pick = await vscode.window.showQuickPick(items, { title: "Sign in to Diffinery", placeHolder: "Which Diffinery do you use?", ignoreFocusOut: true }); | |
| 73 | + if (!pick) throw new vscode.CancellationError(); | |
| 74 | + const server = pick.server ?? (await askForServer()); | |
| 75 | + if (!server) throw new vscode.CancellationError(); | |
| 76 | + await useServer(state, server); | |
| 77 | +} | |
| 78 | + | |
| 79 | +/** "Diffinery: Use Another Server…": switches the server. Sign-ins on other servers stay, for switching back. */ | |
| 80 | +export async function switchServer(state: vscode.Memento, signedInAs: () => Promise<string | undefined>): Promise<void> { | |
| 81 | + const server = await askForServer(configuredServer()); | |
| 82 | + if (!server) return; | |
| 83 | + await useServer(state, server); | |
| 84 | + await announce(server, await signedInAs()); | |
| 85 | +} | |
| 86 | + | |
| 87 | +async function announce(server: string, login: string | undefined): Promise<void> { | |
| 88 | + if (login) { | |
| 89 | + void vscode.window.showInformationMessage(`Diffinery now uses ${serverLabel(server)}, where you're signed in as ${login}.`); | |
| 90 | + return; | |
| 91 | + } | |
| 92 | + const pick = await vscode.window.showInformationMessage(`Diffinery now uses ${serverLabel(server)}. Sign in to clone and push there.`, "Sign In"); | |
| 93 | + if (pick) void vscode.commands.executeCommand("diffinery.signIn"); | |
| 94 | +} | |
| 95 | + | |
| 96 | +/** | |
| 97 | + * The installer that a self-hosted Diffinery serves leaves its address in a file (util.ts installHintPath), since an | |
| 98 | + * editor's command line can't change a setting. The file only suggests: the person is asked, once per address. | |
| 99 | + */ | |
| 100 | +export async function offerInstallerServer(state: vscode.Memento, signedInAs: () => Promise<string | undefined>, log: vscode.LogOutputChannel): Promise<void> { | |
| 101 | + let server: string | null; | |
| 102 | + try { | |
| 103 | + server = readInstallHint(await readFile(installHintPath(process.env, homedir()), "utf8")); | |
| 104 | + } catch { | |
| 105 | + return; // no file: installed from diffinery.com, or by hand | |
| 106 | + } | |
| 107 | + if (!server || state.get<string>(HINT_ANSWERED) === server) return; | |
| 108 | + const current = configuredServer(); | |
| 109 | + if (server === current) { | |
| 110 | + await state.update(HINT_ANSWERED, server); | |
| 111 | + return; | |
| 112 | + } | |
| 113 | + log.info(`installed from ${server}; asking whether to use it`); | |
| 114 | + const use = `Use ${serverLabel(server)}`; | |
| 115 | + const keep = current ? `Keep ${serverLabel(current)}` : "Not Now"; | |
| 116 | + const pick = await vscode.window.showInformationMessage(`The Diffinery extension was installed from ${server}. Sign in, clone and push there?`, use, keep); | |
| 117 | + if (!pick) return; // closed without answering: ask again next time | |
| 118 | + await state.update(HINT_ANSWERED, server); | |
| 119 | + if (pick !== use) return; | |
| 120 | + await useServer(state, server); | |
| 121 | + await announce(server, await signedInAs()); | |
| 122 | +} |
modifiedapps/vscode/src/util.ts+46−6View file
| @@ -1,25 +1,65 @@ | ||
| 1 | 1 | // Pure helpers, without the vscode module, so the unit tests can run them under Node. |
| 2 | 2 | import { createHash, randomBytes } from "node:crypto"; |
| 3 | +import * as path from "node:path"; | |
| 3 | 4 | |
| 4 | 5 | export const EXTENSION_ID = "codician.diffinery"; |
| 5 | 6 | /** Editors whose URL scheme the Diffinery server accepts as a redirect back to this extension (src/auth/oauth.ts there). */ |
| 6 | 7 | export const EDITOR_SCHEMES = ["vscode", "vscode-insiders", "cursor", "windsurf", "vscodium", "positron", "kiro"]; |
| 7 | 8 | export const DEFAULT_SERVER = "https://diffinery.com"; |
| 8 | 9 | |
| 9 | -/** The server's base URL from the setting: https (or http on this computer), no trailing slash, no path. */ | |
| 10 | -export function normalizeServer(raw: string | undefined): string { | |
| 11 | - const s = (raw ?? "").trim() || DEFAULT_SERVER; | |
| 10 | +function checkServer(s: string): { server: string } | { invalid: "url" | "https" } { | |
| 12 | 11 | let u: URL; |
| 13 | 12 | try { |
| 14 | 13 | u = new URL(s.includes("://") ? s : `https://${s}`); |
| 15 | 14 | } catch { |
| 16 | - throw new Error(`The Diffinery server URL "${s}" isn't a valid URL. Fix diffinery.serverUrl in your settings.`); | |
| 15 | + return { invalid: "url" }; | |
| 17 | 16 | } |
| 18 | 17 | const loopback = /^(localhost|127(\.\d{1,3}){3}|\[::1\])$/.test(u.hostname); |
| 19 | - if (u.protocol !== "https:" && !(u.protocol === "http:" && loopback)) throw new Error(`The Diffinery server URL must use https (diffinery.serverUrl is ${s}).`); | |
| 20 | - return `${u.protocol}//${u.host}${u.pathname.replace(/\/+$/, "")}`; | |
| 18 | + if (u.protocol !== "https:" && !(u.protocol === "http:" && loopback)) return { invalid: "https" }; | |
| 19 | + return { server: `${u.protocol}//${u.host}${u.pathname.replace(/\/+$/, "")}` }; | |
| 21 | 20 | } |
| 22 | 21 | |
| 22 | +/** The server's base URL from the setting: https (or http on this computer), no trailing slash, no path. */ | |
| 23 | +export function normalizeServer(raw: string | undefined): string { | |
| 24 | + const s = (raw ?? "").trim() || DEFAULT_SERVER; | |
| 25 | + const r = checkServer(s); | |
| 26 | + if ("server" in r) return r.server; | |
| 27 | + throw new Error(r.invalid === "url" ? `The Diffinery server URL "${s}" isn't a valid URL. Fix diffinery.serverUrl in your settings.` : `The Diffinery server URL must use https (diffinery.serverUrl is ${s}).`); | |
| 28 | +} | |
| 29 | + | |
| 30 | +/** Why what someone typed as a server address can't be used, for the input box; null when it can. */ | |
| 31 | +export function serverInputError(raw: string): string | null { | |
| 32 | + const s = raw.trim(); | |
| 33 | + if (!s) return "Enter your Diffinery's address, such as git.example.com."; | |
| 34 | + if (/\s/.test(s)) return "An address has no spaces."; | |
| 35 | + const r = checkServer(s); | |
| 36 | + return "server" in r ? null : r.invalid === "url" ? `"${s}" isn't a valid address.` : "The address must use https (http only for a server on this computer)."; | |
| 37 | +} | |
| 38 | + | |
| 39 | +/** A server as people read it: diffinery.com, git.example.com:8443. */ | |
| 40 | +export function serverLabel(server: string): string { | |
| 41 | + const u = new URL(server); | |
| 42 | + return `${u.host}${u.pathname.replace(/\/+$/, "")}`; | |
| 43 | +} | |
| 44 | + | |
| 45 | +/** | |
| 46 | + * Where the editor installer (install/install.sh, install.ps1) leaves the address of the self-hosted Diffinery that | |
| 47 | + * served it, for the extension to offer on its next start: the CLI's config folder (~/.config/diffinery, or under | |
| 48 | + * XDG_CONFIG_HOME), on every system. | |
| 49 | + */ | |
| 50 | +export function installHintPath(env: NodeJS.ProcessEnv, home: string): string { | |
| 51 | + const xdg = env.XDG_CONFIG_HOME; | |
| 52 | + const base = xdg && path.isAbsolute(xdg) ? xdg : path.join(env.HOME || home, ".config"); | |
| 53 | + return path.join(base, "diffinery", "editor-server"); | |
| 54 | +} | |
| 55 | + | |
| 56 | +/** The server named in the installer's hint file, or null when the file holds anything else. */ | |
| 57 | +export function readInstallHint(text: string): string | null { | |
| 58 | + const line = text.split(/\r?\n/, 1)[0]!.trim(); | |
| 59 | + if (!line || line.length > 2000 || serverInputError(line)) return null; | |
| 60 | + return normalizeServer(line); | |
| 61 | +} | |
| 62 | + | |
| 23 | 63 | /** The OAuth client_id of this extension on a server: the URL of the metadata document the server publishes about it. */ |
| 24 | 64 | export const clientId = (server: string) => `${server}/oauth/clients/editor.json`; |
| 25 | 65 |
modifiedapps/vscode/test/signin.test.ts+36−1View file
| @@ -1,6 +1,8 @@ | ||
| 1 | 1 | import { createHash } from "node:crypto"; |
| 2 | 2 | import { describe, expect, it } from "vitest"; |
| 3 | -import { authorizeUrl, clientId, editorRedirect, normalizeServer, pkce, readCallback } from "../src/util"; | |
| 3 | +import { readFileSync } from "node:fs"; | |
| 4 | +import path from "node:path"; | |
| 5 | +import { authorizeUrl, clientId, editorRedirect, installHintPath, normalizeServer, pkce, readCallback, readInstallHint, serverInputError, serverLabel } from "../src/util"; | |
| 4 | 6 | |
| 5 | 7 | const S = "https://diffinery.com"; |
| 6 | 8 | |
| @@ -14,6 +16,39 @@ | ||
| 14 | 16 | expect(() => normalizeServer("https://")).toThrow(/valid URL/); |
| 15 | 17 | }); |
| 16 | 18 | |
| 19 | + it("checks a server address someone types, and shows it by its host", () => { | |
| 20 | + expect(serverInputError("git.example.com")).toBeNull(); | |
| 21 | + expect(serverInputError(" https://git.example.com/ ")).toBeNull(); | |
| 22 | + expect(serverInputError("http://localhost:8787")).toBeNull(); | |
| 23 | + expect(serverInputError("")).toMatch(/Enter/); | |
| 24 | + expect(serverInputError("git example")).toMatch(/spaces/); | |
| 25 | + expect(serverInputError("http://git.example.com")).toMatch(/https/); | |
| 26 | + expect(serverInputError("https://")).toMatch(/valid/); | |
| 27 | + expect(serverLabel(S)).toBe("diffinery.com"); | |
| 28 | + expect(serverLabel("https://git.example.com:8443")).toBe("git.example.com:8443"); | |
| 29 | + }); | |
| 30 | + | |
| 31 | + it("reads the server the installer left, where the installers write it", () => { | |
| 32 | + expect(installHintPath({ HOME: "/home/ada" }, "/ignored")).toBe(path.join("/home/ada", ".config", "diffinery", "editor-server")); | |
| 33 | + expect(installHintPath({ HOME: "/home/ada", XDG_CONFIG_HOME: "/cfg" }, "/ignored")).toBe(path.join("/cfg", "diffinery", "editor-server")); | |
| 34 | + expect(installHintPath({ XDG_CONFIG_HOME: "relative" }, "/home/bo")).toBe(path.join("/home/bo", ".config", "diffinery", "editor-server")); | |
| 35 | + expect(readInstallHint("https://git.example.com/\n")).toBe("https://git.example.com"); | |
| 36 | + expect(readInstallHint("git.example.com\r\n")).toBe("https://git.example.com"); | |
| 37 | + expect(readInstallHint("http://git.example.com\n")).toBeNull(); | |
| 38 | + expect(readInstallHint("")).toBeNull(); | |
| 39 | + expect(readInstallHint("{\"server\": 1}")).toBeNull(); | |
| 40 | + const sh = readFileSync(path.join(__dirname, "../install/install.sh"), "utf8"); | |
| 41 | + const ps1 = readFileSync(path.join(__dirname, "../install/install.ps1"), "utf8"); | |
| 42 | + expect(sh).toContain('"$conf/diffinery/editor-server"'); | |
| 43 | + expect(ps1).toContain('"diffinery\\editor-server"'); | |
| 44 | + }); | |
| 45 | + | |
| 46 | + it("registers every command it contributes", () => { | |
| 47 | + const pkg = JSON.parse(readFileSync(path.join(__dirname, "../package.json"), "utf8")); | |
| 48 | + const src = readFileSync(path.join(__dirname, "../src/extension.ts"), "utf8"); | |
| 49 | + for (const c of pkg.contributes.commands as { command: string }[]) expect(src).toContain(`"${c.command}"`); | |
| 50 | + }); | |
| 51 | + | |
| 17 | 52 | it("returns to the editor by its own scheme when the server knows it", () => { |
| 18 | 53 | expect(editorRedirect("vscode")).toBe("vscode://codician.diffinery/oauth"); |
| 19 | 54 | expect(editorRedirect("cursor")).toBe("cursor://codician.diffinery/oauth"); |