225 lines
7.5 KiB
JavaScript
225 lines
7.5 KiB
JavaScript
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: 'https://codeberg.org',
|
|
};
|
|
|
|
// Built-in detection hostnames: remotes on these hosts are recognized as the
|
|
// provider even when the user configures nothing. Configured detectUrls extend
|
|
// them — the built-ins always apply (mirrors the client-side detection in
|
|
// packages/ui/src/lib/gitProvider.ts).
|
|
export const GIT_PROVIDER_DEFAULT_DETECT_URLS = {
|
|
github: ['github.com'],
|
|
gitlab: ['gitlab.com'],
|
|
gitea: ['codeberg.org'],
|
|
};
|
|
|
|
/**
|
|
* 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.
|
|
*/
|
|
export function getProviderApiBaseUrl(provider) {
|
|
return readGitProvidersConfig()[provider]?.apiBaseUrl || GIT_PROVIDER_DEFAULTS[provider] || null;
|
|
}
|
|
|
|
/**
|
|
* Effective detection hostnames for a provider: the built-in default hosts
|
|
* plus any user-configured detectUrls, deduped. The built-ins always apply so
|
|
* a default host (e.g. github.com) keeps classifying remotes even when custom
|
|
* enterprise hosts are configured.
|
|
*/
|
|
export function getProviderDetectUrls(provider) {
|
|
const configured = readGitProvidersConfig()[provider]?.detectUrls ?? [];
|
|
return [...new Set([...(GIT_PROVIDER_DEFAULT_DETECT_URLS[provider] ?? []), ...configured])];
|
|
}
|
|
|
|
/**
|
|
* 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';
|
|
}
|
|
}
|