feat(web,ui): per-provider git forge API base URL and detection URLs

Configure a default API base URL per git provider (github/gitlab/gitea)
in settings.json gitProviders, with GitHub Enterprise support (Octokit
baseUrl + device-flow web origin derived from the API base), and replace
the client-side custom-domains list with server-persisted detection URL
chips (SSH/HTTPS forms normalized to hosts). The configured API base host
auto-counts as a detection host. Settings round-trip through the existing
/api/config/settings sanitizer; the UI store hydrates from server settings
with a one-time localStorage migration.
This commit is contained in:
2026-08-17 09:55:11 +00:00
parent a87b3fd228
commit 0fc857959e
55 changed files with 1685 additions and 272 deletions
@@ -0,0 +1,48 @@
# Git Providers Configuration Module
## Purpose
- This module owns the per-provider git hosting configuration (`gitProviders` in the user settings file): API base URLs and provider-detection hostnames for GitHub, GitLab, and Gitea.
- It is the single source of truth for the effective provider defaults consumed by `packages/web/server/lib/{github,gitlab,gitea}` and is validated end-to-end through the settings GET/PUT routes (the `gitProviders` key round-trips via `sanitizeSettingsUpdate` in `packages/web/server/lib/opencode/settings-helpers.js`).
## Entrypoints
- `packages/web/server/lib/git-providers/config.js`: the single module file, exporting the helpers directly.
## Public exports
- `GIT_PROVIDER_DEFAULTS`: `{ github: 'https://api.github.com', gitlab: 'https://gitlab.com', gitea: null }`. Built-in defaults are **not persisted**; they are applied at read time by getters.
- `normalizeBaseUrl(raw)`: normalize an API base URL (add `https://` when the scheme is missing, strip trailing slashes, preserve subpaths like `/gitlab`), `null` for empty/unparseable input.
- `normalizeDetectionHost(raw)`: extract the bare lowercase hostname from any git remote/URL form (`https://`, `ssh://`, scp-like `git@host:path`, IPv6); mirrors `packages/ui/src/lib/gitHost.ts` `parseGitHost`.
- `sanitizeGitProviders(payload)`: validate/normalize the `gitProviders` shape — only `github|gitlab|gitea` keys survive; `apiBaseUrl` via `normalizeBaseUrl`, `detectUrls` deduped bare hostnames; empty/absent values dropped; returns `undefined` when nothing valid remains.
- `readGitProvidersConfig()`: read the `gitProviders` section from `settings.json` (`OPENCHAMBER_DATA_DIR` env override, else `~/.config/openchamber`); never throws, returns `{}` on missing/invalid data.
- `getProviderApiBaseUrl(provider)`: configured value -> `GIT_PROVIDER_DEFAULTS[provider]` -> `null` (gitea).
- `githubWebOriginFromApiBase(apiBase)`: GitHub web origin from an API base — `https://api.github.com` -> `https://github.com`; Enterprise `https://host/api[/v3]` -> `https://host` (trailing `/api`/`/api/v3` stripped, subpath prefixes kept); otherwise the URL origin; never throws, falls back to `https://github.com`.
## Settings shape
`~/.config/openchamber/settings.json`:
```json
"gitProviders": {
"github": { "apiBaseUrl": "https://github.example.com/api/v3", "detectUrls": ["github.example.com"] },
"gitlab": { "apiBaseUrl": "https://gitlab.example.com", "detectUrls": [] },
"gitea": { "apiBaseUrl": "", "detectUrls": ["gitea.example.com"] }
}
```
- `apiBaseUrl`: API base URL; the per-account baseUrl (gitlab/gitea accounts) still wins when set; this is the default/fallback plus connect-form prefill.
- `detectUrls`: SSH/HTTPS URLs normalized to bare hostnames for provider autodetection (client-side; the server only persists/validates them).
- The whole `gitProviders` key is omitted when empty.
## Consumers
- `packages/web/server/lib/github/octokit.js`, `device-flow.js`, `routes.js`, `repo/index.js`, `pr-status.js`, `repo/fork-detection.js`: GitHub Enterprise support (Octokit `baseUrl`, device-flow web origin, remote parsing, fallback URLs).
- `packages/web/server/lib/gitlab/auth.js`, `client.js`, `routes.js`: effective default base URL.
- `packages/web/server/lib/gitea/auth.js`, `routes.js`: connect-form default / status `defaultBaseUrl`.
- `packages/web/server/lib/opencode/settings-helpers.js`: `gitProviders` persistence whitelist.
## Notes for contributors
- Readers must never throw: `readGitProvidersConfig` and `githubWebOriginFromApiBase` fail closed.
- No new dependencies.
@@ -0,0 +1,203 @@
import fs from 'fs';
import os from 'os';
import path from 'path';
const OPENCHAMBER_DATA_DIR = process.env.OPENCHAMBER_DATA_DIR
? path.resolve(process.env.OPENCHAMBER_DATA_DIR)
: path.join(os.homedir(), '.config', 'openchamber');
const SETTINGS_FILE = path.join(OPENCHAMBER_DATA_DIR, 'settings.json');
// Built-in defaults are applied at read time by getters; they are never
// persisted (sanitizeGitProviders only stores user-provided overrides).
const GIT_PROVIDER_KEYS = ['github', 'gitlab', 'gitea'];
export const GIT_PROVIDER_DEFAULTS = {
github: 'https://api.github.com',
gitlab: 'https://gitlab.com',
gitea: null,
};
/**
* Normalize a user-provided API base URL. Adds `https://` when no scheme is
* present, strips a trailing slash, preserves any subpath (e.g. `/gitlab`),
* and returns null for anything unparseable or empty.
*/
export function normalizeBaseUrl(raw) {
if (typeof raw !== 'string') {
return null;
}
let value = raw.trim();
if (!value) {
return null;
}
if (!/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(value)) {
value = `https://${value}`;
}
let parsed;
try {
parsed = new URL(value);
} catch {
return null;
}
if (!parsed.hostname) {
return null;
}
parsed.hash = '';
parsed.search = '';
parsed.pathname = parsed.pathname.replace(/\/+$/, '');
return parsed.href.replace(/\/+$/, '');
}
const normalizeHost = (host) =>
String(host || '').replace(/^\[|\]$/g, '').toLowerCase().replace(/\.$/, '');
/**
* Extract the bare lowercase hostname from any git remote / URL form:
* `https://host/...`, `ssh://git@host/...`, scp-like `git@host:path`,
* `host:path`, and bracketed or unbracketed IPv6. Returns null for empty or
* unparseable input.
*/
export function normalizeDetectionHost(raw) {
if (typeof raw !== 'string') {
return null;
}
const value = raw.trim();
if (!value) {
return null;
}
// scp-like form: [user@]host:path — never applies once a scheme is present.
if (!value.includes('://')) {
const authority = value.slice(value.lastIndexOf('@') + 1);
// Bracketed IPv6, e.g. `[2001:db8::1]` or `[2001:db8::1]:owner/repo.git`.
if (authority.startsWith('[')) {
const close = authority.indexOf(']');
if (close > 0 && authority.slice(1, close).includes(':')) {
return normalizeHost(authority.slice(1, close));
}
// Malformed brackets fall through to URL parsing, which rejects them.
} else {
const colon = authority.indexOf(':');
if (colon > 0) {
const candidate = authority.slice(0, colon);
// A single-segment pre-colon value without a dot is not a host — the
// guard rejects Windows paths like `C:\foo`. Hosts with a numeric
// port (`localhost:3000`) still resolve via the URL branch.
if (!candidate.includes('/') && candidate.includes('.')) {
return normalizeHost(candidate);
}
}
// Unbracketed IPv6 (e.g. `2001:db8::1`): parse as a bracketed host.
if (authority.includes(':') && !authority.includes('/') && authority.length > 2) {
try {
return normalizeHost(new URL(`ssh://[${authority}]`).hostname);
} catch {
// Not IPv6; fall through to generic URL parsing.
}
}
}
}
try {
const parsed = new URL(value.includes('://') ? value : `ssh://${value}`);
return normalizeHost(parsed.hostname);
} catch {
return null;
}
}
const sanitizeDetectionHosts = (value) => {
if (!Array.isArray(value)) {
return [];
}
const seen = new Set();
const hosts = [];
for (const raw of value) {
const host = normalizeDetectionHost(raw);
if (!host || seen.has(host)) continue;
seen.add(host);
hosts.push(host);
}
return hosts;
};
/**
* Validate and normalize a `gitProviders` settings value. Only the known
* provider keys (github|gitlab|gitea) survive; per provider, `apiBaseUrl` is
* normalized via normalizeBaseUrl and `detectUrls` becomes a deduped array of
* bare hostnames. Empty/absent values are dropped. Returns undefined when
* nothing valid remains, otherwise the normalized partial object.
*/
export function sanitizeGitProviders(payload) {
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
return undefined;
}
const result = {};
for (const provider of GIT_PROVIDER_KEYS) {
const entry = payload[provider];
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue;
const normalized = {};
if (entry.apiBaseUrl !== undefined && entry.apiBaseUrl !== null) {
const baseUrl = normalizeBaseUrl(entry.apiBaseUrl);
if (baseUrl) normalized.apiBaseUrl = baseUrl;
}
if (entry.detectUrls !== undefined && entry.detectUrls !== null) {
const hosts = sanitizeDetectionHosts(entry.detectUrls);
if (hosts.length > 0) normalized.detectUrls = hosts;
}
if (Object.keys(normalized).length > 0) {
result[provider] = normalized;
}
}
return Object.keys(result).length > 0 ? result : undefined;
}
/**
* Read the `gitProviders` section of the user settings file
* (`~/.config/openchamber/settings.json`, overridable via
* OPENCHAMBER_DATA_DIR). Never throws; returns {} on missing/invalid data.
*/
export function readGitProvidersConfig() {
try {
if (fs.existsSync(SETTINGS_FILE)) {
const parsed = JSON.parse(fs.readFileSync(SETTINGS_FILE, 'utf8')) || {};
return sanitizeGitProviders(parsed.gitProviders) ?? {};
}
} catch {
// ignore
}
return {};
}
/**
* Effective API base URL for a provider: the configured settings.json value if
* present, else the built-in default (null for gitea, which has none).
*/
export function getProviderApiBaseUrl(provider) {
return readGitProvidersConfig()[provider]?.apiBaseUrl || GIT_PROVIDER_DEFAULTS[provider] || null;
}
/**
* Derive the GitHub web origin from an API base URL. The public API host
* (`https://api.github.com`) maps to `https://github.com`; an Enterprise API
* base (`https://host/api/v3` or `https://host/api`) maps to `https://host`
* (trailing `/api[/v3]` path segments are stripped, so subpath installs like
* `https://host/ghe/api/v3` keep their prefix). Anything else yields the
* origin of the URL. Never throws; falls back to `https://github.com`.
*/
export function githubWebOriginFromApiBase(apiBase) {
try {
const url = new URL(apiBase);
if (!url.hostname) {
return 'https://github.com';
}
if (url.hostname === 'api.github.com') {
return 'https://github.com';
}
const pathname = url.pathname.replace(/\/+$/, '');
const stripped = pathname.replace(/\/api\/v3$/, '').replace(/\/api$/, '');
return `${url.protocol}//${url.host}${stripped}`;
} catch {
return 'https://github.com';
}
}
@@ -0,0 +1,176 @@
import fs from 'fs';
import os from 'os';
import path from 'path';
import { afterAll, afterEach, describe, expect, test } from 'vitest';
const TEMP_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-git-providers-'));
process.env.OPENCHAMBER_DATA_DIR = TEMP_DATA_DIR;
const {
GIT_PROVIDER_DEFAULTS,
normalizeBaseUrl,
normalizeDetectionHost,
sanitizeGitProviders,
readGitProvidersConfig,
getProviderApiBaseUrl,
githubWebOriginFromApiBase,
} = await import('./config.js');
const SETTINGS_FILE = path.join(TEMP_DATA_DIR, 'settings.json');
afterAll(() => {
fs.rmSync(TEMP_DATA_DIR, { recursive: true, force: true });
});
afterEach(() => {
if (fs.existsSync(SETTINGS_FILE)) {
fs.unlinkSync(SETTINGS_FILE);
}
});
describe('normalizeBaseUrl', () => {
test('adds https scheme when missing', () => {
expect(normalizeBaseUrl('github.example.com')).toBe('https://github.example.com');
expect(normalizeBaseUrl('gitlab.example.com/gitlab')).toBe('https://gitlab.example.com/gitlab');
});
test('strips trailing slashes but preserves subpaths', () => {
expect(normalizeBaseUrl('https://github.example.com/api/v3/')).toBe('https://github.example.com/api/v3');
expect(normalizeBaseUrl('https://gitlab.example.com/')).toBe('https://gitlab.example.com');
expect(normalizeBaseUrl('https://gitlab.example.com/gitlab/')).toBe('https://gitlab.example.com/gitlab');
});
test('keeps an explicit non-https scheme', () => {
expect(normalizeBaseUrl('http://localhost:8080')).toBe('http://localhost:8080');
});
test('returns null for empty or unparseable input', () => {
expect(normalizeBaseUrl('')).toBeNull();
expect(normalizeBaseUrl(' ')).toBeNull();
expect(normalizeBaseUrl('not a url')).toBeNull();
expect(normalizeBaseUrl(null)).toBeNull();
expect(normalizeBaseUrl(undefined)).toBeNull();
expect(normalizeBaseUrl(42)).toBeNull();
});
});
describe('normalizeDetectionHost', () => {
test('extracts the host from https remotes', () => {
expect(normalizeDetectionHost('https://Github.Example.com/owner/repo.git')).toBe('github.example.com');
expect(normalizeDetectionHost('https://github.com/owner/repo')).toBe('github.com');
});
test('extracts the host from scp-like and ssh remotes', () => {
expect(normalizeDetectionHost('git@github.example.com:owner/repo.git')).toBe('github.example.com');
expect(normalizeDetectionHost('ssh://git@github.example.com/owner/repo.git')).toBe('github.example.com');
expect(normalizeDetectionHost('github.example.com:owner/repo.git')).toBe('github.example.com');
});
test('handles ports, user info, and IPv6', () => {
expect(normalizeDetectionHost('https://github.example.com:8443/owner/repo')).toBe('github.example.com');
expect(normalizeDetectionHost('ssh://user@host.example.com/owner/repo')).toBe('host.example.com');
expect(normalizeDetectionHost('[2001:db8::1]:owner/repo.git')).toBe('2001:db8::1');
expect(normalizeDetectionHost('2001:db8::1')).toBe('2001:db8::1');
});
test('returns null for empty or unparseable input', () => {
expect(normalizeDetectionHost('')).toBeNull();
expect(normalizeDetectionHost(null)).toBeNull();
expect(normalizeDetectionHost(42)).toBeNull();
expect(normalizeDetectionHost('C:\\foo')).toBeNull();
});
});
describe('sanitizeGitProviders', () => {
test('normalizes a valid payload', () => {
expect(sanitizeGitProviders({
github: { apiBaseUrl: 'https://github.example.com/api/v3', detectUrls: ['https://github.example.com/owner/repo.git'] },
gitlab: { apiBaseUrl: 'gitlab.example.com', detectUrls: [] },
gitea: { apiBaseUrl: '', detectUrls: ['gitea.example.com'] },
})).toEqual({
github: { apiBaseUrl: 'https://github.example.com/api/v3', detectUrls: ['github.example.com'] },
gitlab: { apiBaseUrl: 'https://gitlab.example.com' },
gitea: { detectUrls: ['gitea.example.com'] },
});
});
test('dedupes and lowercases detectUrls', () => {
expect(sanitizeGitProviders({
github: { detectUrls: ['GitHub.Example.com', 'https://github.example.com/x', 'other.example.com', 'other.example.com'] },
})).toEqual({
github: { detectUrls: ['github.example.com', 'other.example.com'] },
});
});
test('drops malformed or empty entries', () => {
expect(sanitizeGitProviders({ github: { apiBaseUrl: ' ' } })).toBeUndefined();
expect(sanitizeGitProviders({ github: { detectUrls: 'not-an-array' } })).toBeUndefined();
expect(sanitizeGitProviders({ unknown: { apiBaseUrl: 'https://x.example.com' } })).toBeUndefined();
expect(sanitizeGitProviders('not-an-object')).toBeUndefined();
expect(sanitizeGitProviders(null)).toBeUndefined();
expect(sanitizeGitProviders([])).toBeUndefined();
});
test('ignores unknown provider keys', () => {
expect(sanitizeGitProviders({
github: { apiBaseUrl: 'https://github.example.com' },
bitbucket: { apiBaseUrl: 'https://bitbucket.example.com' },
})).toEqual({
github: { apiBaseUrl: 'https://github.example.com' },
});
});
});
describe('readGitProvidersConfig / getProviderApiBaseUrl', () => {
test('returns {} / defaults when no settings file exists', () => {
expect(readGitProvidersConfig()).toEqual({});
expect(getProviderApiBaseUrl('github')).toBe('https://api.github.com');
expect(getProviderApiBaseUrl('gitlab')).toBe('https://gitlab.com');
expect(getProviderApiBaseUrl('gitea')).toBeNull();
});
test('reads the configured values from settings.json', () => {
fs.writeFileSync(SETTINGS_FILE, JSON.stringify({
gitProviders: {
github: { apiBaseUrl: 'https://github.example.com/api/v3' },
gitlab: { apiBaseUrl: 'https://gitlab.example.com' },
},
}));
expect(readGitProvidersConfig()).toEqual({
github: { apiBaseUrl: 'https://github.example.com/api/v3' },
gitlab: { apiBaseUrl: 'https://gitlab.example.com' },
});
expect(getProviderApiBaseUrl('github')).toBe('https://github.example.com/api/v3');
expect(getProviderApiBaseUrl('gitlab')).toBe('https://gitlab.example.com');
expect(getProviderApiBaseUrl('gitea')).toBeNull();
});
test('never throws on a malformed settings file', () => {
fs.writeFileSync(SETTINGS_FILE, '{not-json');
expect(readGitProvidersConfig()).toEqual({});
expect(getProviderApiBaseUrl('github')).toBe(GIT_PROVIDER_DEFAULTS.github);
});
});
describe('githubWebOriginFromApiBase', () => {
test('maps the public api host to github.com', () => {
expect(githubWebOriginFromApiBase('https://api.github.com')).toBe('https://github.com');
});
test('maps enterprise api bases to the host', () => {
expect(githubWebOriginFromApiBase('https://github.example.com/api/v3')).toBe('https://github.example.com');
expect(githubWebOriginFromApiBase('https://github.example.com/api')).toBe('https://github.example.com');
});
test('keeps subpath prefixes and plain origins', () => {
expect(githubWebOriginFromApiBase('https://github.example.com/ghe/api/v3')).toBe('https://github.example.com/ghe');
expect(githubWebOriginFromApiBase('https://github.example.com')).toBe('https://github.example.com');
expect(githubWebOriginFromApiBase('https://github.example.com:8443/api/v3')).toBe('https://github.example.com:8443');
});
test('falls back for invalid input and never throws', () => {
expect(githubWebOriginFromApiBase('')).toBe('https://github.com');
expect(githubWebOriginFromApiBase(null)).toBe('https://github.com');
expect(githubWebOriginFromApiBase('not a url')).toBe('https://github.com');
});
});