Diffinery

codician/diffinerypublic

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

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
Claude authored signed1 parent 5c3daf4commit 5d4a2f969613ca1a7bc17e535f26db56fe51e97aBrowse files
25 files changed+552−24
Files (25)
modifiedapps/diffinery/docs/repositories/clone-in-jetbrains-ides.md+15−2View file
@@ -38,7 +38,7 @@
3838
3939 1. Choose **File → New → Project from Version Control…**, or **Clone Repository** on the welcome screen.
4040 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.)
4242 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.
4343 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.
4444 6. Check the **Directory** and choose **Clone**. The IDE clones the repository and opens it.
@@ -79,8 +79,20 @@
7979
8080 ## Settings
8181
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.
8383
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+
8496 ## Troubleshooting
8597
8698 | What you see | What to do |
@@ -98,4 +110,5 @@
98110 - 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.
99111 - 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.
100112 - 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.
101114 - 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 @@
3838
3939 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.
4040 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.
4242 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.
4343 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.
4444 6. Choose a folder. The editor clones the repository and opens it.
@@ -83,10 +83,29 @@
8383
8484 | Setting | Default | What it does |
8585 | --- | --- | --- |
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. |
8787 | `diffinery.gitAuthentication` | `true` | Answer Git's password prompts for the server with your signed-in account, and keep Git's saved copy current. |
8888 | `diffinery.checkForUpdates` | `true` | Check the server once a day for a newer build of the extension. |
8989
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+
90109 ## JetBrains IDEs
91110
92111 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 @@
110129
111130 - 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.
112131 - 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.
113133 - 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 @@
11 # Changelog
22
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+
312 ## 0.1.0
413
514 - **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 @@
99 It's plain Java (no Kotlin), against the platform's stable API only: `./gradlew verifyPlugin` runs JetBrains' Plugin
1010 Verifier against IntelliJ IDEA 2024.3, 2025.2 and the version the plugin builds with, and fails on internal API use.
1111
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+
1221 ## Build
1322
1423 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
22 # The IDE the plugin is built against. -PideaHome=/path/to/idea uses one already on disk instead of downloading it.
33 platformVersion=2026.2.3
44 sinceBuild=243
modifiedapps/jetbrains/src/main/java/com/diffinery/jetbrains/CloneComponent.java+21−2View file
@@ -5,7 +5,6 @@
55 import com.intellij.openapi.application.ApplicationManager;
66 import com.intellij.openapi.application.ModalityState;
77 import com.intellij.openapi.fileChooser.FileChooserDescriptorFactory;
8-import com.intellij.openapi.options.ShowSettingsUtil;
98 import com.intellij.openapi.project.Project;
109 import com.intellij.openapi.ui.TextBrowseFolderListener;
1110 import com.intellij.openapi.ui.TextFieldWithBrowseButton;
@@ -21,6 +20,7 @@
2120 import com.intellij.ui.ScrollPaneFactory;
2221 import com.intellij.ui.SearchTextField;
2322 import com.intellij.ui.SimpleTextAttributes;
23+import com.intellij.ui.components.ActionLink;
2424 import com.intellij.ui.components.JBLabel;
2525 import com.intellij.ui.components.JBList;
2626 import com.intellij.util.ui.JBUI;
@@ -29,6 +29,7 @@
2929 import git4idea.commands.Git;
3030 import java.awt.BorderLayout;
3131 import java.awt.CardLayout;
32+import java.awt.FlowLayout;
3233 import java.awt.GridBagConstraints;
3334 import java.awt.GridBagLayout;
3435 import java.awt.event.KeyAdapter;
@@ -72,6 +73,8 @@
7273 private final TextFieldWithBrowseButton directory = new TextFieldWithBrowseButton();
7374 private final JBLabel serverLabel = new JBLabel();
7475 private final JButton signInButton = new JButton("Sign In with Browser…");
76+ private final ActionLink suggestedLink = Plugin.link("", this::useSuggested);
77+ private String suggested;
7578
7679 private List<Repo> mine = List.of();
7780 private List<Repo> found = List.of();
@@ -117,8 +120,13 @@
117120 serverLabel.setForeground(UIUtil.getContextHelpForeground());
118121 c.insets = JBUI.insetsTop(16);
119122 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)));
120128 c.insets = JBUI.emptyInsets();
121- p.add(Plugin.link("Change server…", () -> ShowSettingsUtil.getInstance().showSettingsDialog(project, SettingsPage.class)), c);
129+ p.add(servers, c);
122130 return p;
123131 }
124132
@@ -213,6 +221,11 @@
213221 getDialogStateListener().onListItemChanged();
214222 if (a == null) {
215223 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);
216229 cards.show(root, SIGNED_OUT);
217230 getDialogStateListener().onOkActionEnabled(false);
218231 return;
@@ -222,6 +235,12 @@
222235 load();
223236 }
224237
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+
225244 private void load() {
226245 int gen = loads.incrementAndGet();
227246 mine = List.of();
modifiedapps/jetbrains/src/main/java/com/diffinery/jetbrains/SettingsPage.java+1−1View file
@@ -47,7 +47,7 @@
4747 buttons.add(signIn);
4848 buttons.add(useToken);
4949 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.");
5151 serverHelp.setForeground(UIUtil.getContextHelpForeground());
5252 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>");
5353 gitHelp.setForeground(UIUtil.getContextHelpForeground());
modifiedapps/jetbrains/src/main/java/com/diffinery/jetbrains/ShareAction.java+1−1View file
@@ -62,7 +62,7 @@
6262 Messages.showInfoMessage(project, "Commit your files on a branch first, then share the project.", "Share Project on Diffinery");
6363 return;
6464 }
65- if (Accounts.get().account() == null) SignInDialogs.signInWithBrowser(project, a -> chooseAndShare(project, repo));
65+ if (Accounts.get().account() == null) SignInDialogs.signIn(project, a -> chooseAndShare(project, repo));
6666 else chooseAndShare(project, repo);
6767 }
6868
modifiedapps/jetbrains/src/main/java/com/diffinery/jetbrains/SignInDialogs.java+104−0View file
@@ -2,19 +2,123 @@
22
33 import com.intellij.openapi.application.ApplicationManager;
44 import com.intellij.openapi.application.ModalityState;
5+import com.intellij.openapi.diagnostic.Logger;
56 import com.intellij.openapi.progress.ProgressIndicator;
67 import com.intellij.openapi.progress.ProgressManager;
78 import com.intellij.openapi.progress.Task;
89 import com.intellij.openapi.project.Project;
10+import com.intellij.openapi.ui.InputValidatorEx;
911 import com.intellij.openapi.ui.Messages;
12+import com.intellij.openapi.updateSettings.impl.UpdateSettings;
13+import java.io.IOException;
14+import java.util.List;
1015 import java.util.function.Consumer;
1116 import org.jetbrains.annotations.NotNull;
1217 import org.jetbrains.annotations.Nullable;
1318
1419 /** Signing in from anywhere in the IDE: with a progress dialog that can be cancelled, and errors shown as sentences. */
1520 final class SignInDialogs {
21+ private static final Logger LOG = Logger.getInstance(SignInDialogs.class);
22+
1623 private SignInDialogs() {}
1724
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+
18122 /** Opens the browser to sign in; `done` runs on the UI thread with the account once signed in. */
19123 static void signInWithBrowser(@Nullable Project project, @Nullable Consumer<Accounts.Account> done) {
20124 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 @@
77 import java.security.MessageDigest;
88 import java.security.NoSuchAlgorithmException;
99 import java.security.SecureRandom;
10+import java.util.ArrayList;
1011 import java.util.Base64;
1112 import java.util.LinkedHashMap;
1213 import java.util.List;
@@ -43,6 +44,45 @@
4344 return scheme + "://" + u.getRawAuthority() + path;
4445 }
4546
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+
4686 /** The OAuth client_id of this plugin on a server: the URL of the metadata document the server publishes about it. */
4787 static String clientId(String server) {
4888 return server + "/oauth/clients/jetbrains.json";
modifiedapps/jetbrains/src/main/resources/META-INF/plugin.xml+1−0View file
@@ -13,6 +13,7 @@
1313 <li><b>Push and pull without passwords.</b> When Git asks for a password for Diffinery, the plugin answers with
1414 your signed-in account, and renews its token before it runs out.</li>
1515 <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>
1617 </ul>
1718 <p>Guide: <a href="https://diffinery.com/docs/repositories/clone-in-jetbrains-ides">Clone in JetBrains IDEs</a>.</p>
1819 ]]></description>
modifiedapps/jetbrains/src/test/java/com/diffinery/jetbrains/UtilTest.java+28−0View file
@@ -31,6 +31,34 @@
3131 }
3232
3333 @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
3462 public void authorizeUrl() {
3563 String url = Util.authorizeUrl(S, "http://127.0.0.1:5000/callback", "ch", "st");
3664 assertTrue(url.startsWith(S + "/oauth/authorize?"));
modifiedapps/vscode/CHANGELOG.md+6−0View file
@@ -1,5 +1,11 @@
11 # Changelog
22
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+
39 ## 0.1.1
410
511 - "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 @@
66 - **Clone from Diffinery.** Run **Git: Clone** (or **Clone Repository** on the welcome page) and pick
77 **Clone from Diffinery**. You see your repositories and your organizations', most recently pushed first;
88 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.
1112 - **Push and pull without passwords.** Git uses your signed-in account for diffinery.com. The extension renews
1213 its token before it expires and keeps the copy Git saved in your keychain current.
1314 - **Publish to Diffinery.** Publishing a local repository that has no remote offers **Publish to Diffinery**,
@@ -29,6 +30,22 @@
2930 The full guide, including JetBrains IDEs and the "Open in VS Code / Cursor" buttons on every repository, is at
3031 [diffinery.com/docs/repositories/clone-in-your-editor](https://diffinery.com/docs/repositories/clone-in-your-editor).
3132
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+
3249 ## Commands
3350
3451 | Command | What it does |
@@ -36,6 +53,7 @@
3653 | Diffinery: Clone Repository | Opens the Diffinery repository picker directly. |
3754 | Diffinery: Sign In | Signs in through your browser. |
3855 | 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). |
3957 | Diffinery: Sign Out | Signs out and disconnects the editor from your account. |
4058 | Diffinery: Check for Extension Updates | Installs a newer build from the server, if there is one. |
4159
@@ -43,6 +61,6 @@
4361
4462 | Setting | Default | |
4563 |---|---|---|
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. |
4765 | `diffinery.gitAuthentication` | `true` | Answer Git's password prompts for the server with your signed-in account. |
4866 | `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 @@
2727 $vsix = Join-Path $tmp "diffinery-$($meta.version).vsix"
2828 Invoke-WebRequest "$server/-/vscode/diffinery-$($meta.version).vsix" -OutFile $vsix -UseBasicParsing
2929 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+
3046 $installed = 0
3147 foreach ($e in $known) {
3248 if ($wanted.Count -gt 0 -and $wanted -notcontains $e.Name) { continue }
@@ -46,6 +62,14 @@
4662 exit 1
4763 }
4864 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+ }
4973 Write-Host "Next: in the editor, run Git: Clone and pick Clone from Diffinery. ($server/docs/repositories/clone-in-your-editor)"
5074 } finally {
5175 Remove-Item -Recurse -Force $tmp -ErrorAction SilentlyContinue
modifiedapps/vscode/install/install.sh+20−0View file
@@ -65,6 +65,18 @@
6565 if command -v sha256sum >/dev/null; then got=$(sha256sum "$vsix" | cut -d' ' -f1); else got=$(shasum -a 256 "$vsix" | cut -d' ' -f1); fi
6666 [ "$got" = "$sha" ] || die "the download didn't match the published SHA-256; nothing was installed"
6767
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+
6880 installed=0
6981 while IFS='|' read -r name label app; do
7082 if [ "$wanted" != " " ] && [[ $wanted != *" $name "* ]]; then continue; fi
@@ -86,4 +98,12 @@
8698 exit 1
8799 fi
88100 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
89109 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 @@
22 "name": "@diffinery/vscode",
33 "displayName": "Diffinery",
44 "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",
66 "publisher": "codician",
77 "private": true,
88 "license": "Apache-2.0",
@@ -86,6 +86,11 @@
8686 "category": "Diffinery"
8787 },
8888 {
89+ "command": "diffinery.useServer",
90+ "title": "Use Another Server…",
91+ "category": "Diffinery"
92+ },
93+ {
8994 "command": "diffinery.signOut",
9095 "title": "Sign Out",
9196 "category": "Diffinery"
@@ -103,7 +108,7 @@
103108 "type": "string",
104109 "default": "https://diffinery.com",
105110 "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."
107112 },
108113 "diffinery.gitAuthentication": {
109114 "type": "boolean",
modifiedapps/vscode/src/auth.ts+10−0View file
@@ -2,6 +2,7 @@
22 import { randomUUID } from "node:crypto";
33 import * as vscode from "vscode";
44 import { Api } from "./api";
5+import { chooseServer } from "./servers";
56 import { authorizeUrl, clientId, editorRedirect, normalizeServer, pkce, readCallback } from "./util";
67
78 export const AUTH_ID = "diffinery";
@@ -184,6 +185,10 @@
184185 }
185186
186187 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);
187192 const s = this.withToken ? await this.askForToken() : await this.signInWithBrowser();
188193 // Signing in again replaces the old sign-in, and ends its connection on the server.
189194 const old = await this.load();
@@ -320,6 +325,11 @@
320325 return { id: randomUUID(), server, login: user.login, accountId: String(user.id), kind: "token", accessToken: token };
321326 }
322327
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+
323333 async signOut(): Promise<void> {
324334 const s = await this.load();
325335 if (!s) {
modifiedapps/vscode/src/extension.ts+3−0View file
@@ -2,6 +2,7 @@
22 import { AUTH_ID, DiffineryAuth, SCOPES } from "./auth";
33 import { GitCredentials, Publisher, syncCredentialStore } from "./git";
44 import { RemoteSources } from "./remoteSources";
5+import { offerInstallerServer, switchServer } from "./servers";
56 import type { GitAPI, GitBaseAPI, GitBaseExtension, GitExtension } from "./typings/git";
67 import { checkForUpdates } from "./updates";
78
@@ -26,6 +27,7 @@
2627 command("diffinery.signIn", async () => vscode.authentication.getSession(AUTH_ID, SCOPES, { createIfNone: true }), "Signing in to Diffinery failed");
2728 command("diffinery.signInWithToken", () => auth.signInWithToken(), "Signing in to Diffinery failed");
2829 command("diffinery.signOut", () => auth.signOut(), "Signing out of Diffinery failed");
30+ command("diffinery.useServer", () => switchServer(ctx.globalState, () => auth.login()), "Changing the Diffinery server failed");
2931 command("diffinery.checkForUpdates", () => checkForUpdates(ctx, auth.server(), version, log, true), "Checking for updates failed");
3032
3133 // ---------------------------------------------------------------- the built-in Git extensions
@@ -86,6 +88,7 @@
8688 "Cloning from Diffinery failed",
8789 );
8890
91+ void offerInstallerServer(ctx.globalState, () => auth.login(), log).catch(report("Changing the Diffinery server failed"));
8992 void checkForUpdates(ctx, auth.server(), version, log, false).catch((e) => log.warn("update check failed", e));
9093 }
9194
modifiedapps/vscode/src/git.ts+5−3View file
@@ -10,14 +10,16 @@
1010 constructor(private readonly auth: DiffineryAuth) {}
1111
1212 async getCredentials(host: vscode.Uri): Promise<Credentials | undefined> {
13- let server: URL;
13+ let server: string;
1414 try {
15- server = new URL(this.auth.server());
15+ server = this.auth.server();
1616 } catch {
1717 return undefined;
1818 }
19- if (host.authority.toLowerCase() !== server.host.toLowerCase()) return undefined;
19+ if (host.authority.toLowerCase() !== new URL(server).host.toLowerCase()) return undefined;
2020 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;
2123 return { username: session.account.label, password: session.accessToken };
2224 }
2325 }
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 @@
11 // Pure helpers, without the vscode module, so the unit tests can run them under Node.
22 import { createHash, randomBytes } from "node:crypto";
3+import * as path from "node:path";
34
45 export const EXTENSION_ID = "codician.diffinery";
56 /** Editors whose URL scheme the Diffinery server accepts as a redirect back to this extension (src/auth/oauth.ts there). */
67 export const EDITOR_SCHEMES = ["vscode", "vscode-insiders", "cursor", "windsurf", "vscodium", "positron", "kiro"];
78 export const DEFAULT_SERVER = "https://diffinery.com";
89
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" } {
1211 let u: URL;
1312 try {
1413 u = new URL(s.includes("://") ? s : `https://${s}`);
1514 } 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" };
1716 }
1817 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(/\/+$/, "")}` };
2120 }
2221
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+
2363 /** The OAuth client_id of this extension on a server: the URL of the metadata document the server publishes about it. */
2464 export const clientId = (server: string) => `${server}/oauth/clients/editor.json`;
2565
modifiedapps/vscode/test/signin.test.ts+36−1View file
@@ -1,6 +1,8 @@
11 import { createHash } from "node:crypto";
22 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";
46
57 const S = "https://diffinery.com";
68
@@ -14,6 +16,39 @@
1416 expect(() => normalizeServer("https://")).toThrow(/valid URL/);
1517 });
1618
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+
1752 it("returns to the editor by its own scheme when the server knows it", () => {
1853 expect(editorRedirect("vscode")).toBe("vscode://codician.diffinery/oauth");
1954 expect(editorRedirect("cursor")).toBe("cursor://codician.diffinery/oauth");