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:
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user