refactor(web/server): consolidate GitHub utilities into single module (#436)
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
# GitHub Module Documentation
|
||||
|
||||
## Purpose
|
||||
This module provides GitHub authentication, OAuth device flow, Octokit client factory, and repository URL parsing utilities for the web server runtime.
|
||||
|
||||
## Entrypoints and structure
|
||||
- `packages/web/server/lib/github/index.js`: public entrypoint imported by `packages/web/server/index.js`.
|
||||
- `packages/web/server/lib/github/auth.js`: auth storage, multi-account support, and client ID/scope configuration.
|
||||
- `packages/web/server/lib/github/device-flow.js`: OAuth device code flow implementation for browserless auth.
|
||||
- `packages/web/server/lib/github/octokit.js`: Octokit client factory backed by current auth.
|
||||
- `packages/web/server/lib/github/repo/index.js`: GitHub remote URL parser and directory-to-repo resolver.
|
||||
|
||||
## Public exports (from index.js)
|
||||
|
||||
### Auth (`auth.js`)
|
||||
- `getGitHubAuth()`: Returns current auth entry (accessToken, user, scope, accountId).
|
||||
- `getGitHubAuthAccounts()`: Returns list of all configured accounts.
|
||||
- `setGitHubAuth({ accessToken, scope, tokenType, user, accountId })`: Stores or updates auth entry.
|
||||
- `activateGitHubAuth(accountId)`: Sets specified account as current.
|
||||
- `clearGitHubAuth()`: Removes current account or deletes storage file if last account.
|
||||
- `getGitHubClientId()`: Resolves client ID from env var, settings.json, or default.
|
||||
- `getGitHubScopes()`: Resolves scopes from env var, settings.json, or default.
|
||||
- `GITHUB_AUTH_FILE`: Storage file path constant.
|
||||
|
||||
### Device flow (`device-flow.js`)
|
||||
- `startDeviceFlow({ clientId, scope })`: Requests device code from GitHub.
|
||||
- `exchangeDeviceCode({ clientId, deviceCode })`: Polls for access token.
|
||||
|
||||
### Octokit (`octokit.js`)
|
||||
- `getOctokitOrNull()`: Returns configured Octokit instance or null if no auth.
|
||||
|
||||
### Repo (`repo/index.js`)
|
||||
- `parseGitHubRemoteUrl(raw)`: Parses SSH/HTTPS URLs into `{ owner, repo, url }`.
|
||||
- `resolveGitHubRepoFromDirectory(directory, remoteName)`: Resolves GitHub repo from git remote.
|
||||
|
||||
## Storage and configuration
|
||||
- Auth storage: `~/.config/openchamber/github-auth.json` (atomic writes, mode 0o600).
|
||||
- Client ID: `OPENCHAMBER_GITHUB_CLIENT_ID` env var → `settings.json` → default.
|
||||
- Scopes: `OPENCHAMBER_GITHUB_SCOPES` env var → `settings.json` → default.
|
||||
|
||||
## Account resolution
|
||||
Account IDs are resolved in priority order: explicit `accountId` → user login → user ID → token prefix.
|
||||
|
||||
## Notes for contributors
|
||||
- All auth operations use atomic file writes for safe multi-instance sharing.
|
||||
- Device flow handles GitHub's `authorization_pending` responses at caller level.
|
||||
- Repo parser supports `git@github.com:`, `ssh://git@github.com/`, and `https://github.com/` URL formats.
|
||||
@@ -0,0 +1,307 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
|
||||
const OPENCHAMBER_DATA_DIR = process.env.OPENCHAMBER_DATA_DIR
|
||||
? path.resolve(process.env.OPENCHAMBER_DATA_DIR)
|
||||
: path.join(os.homedir(), '.config', 'openchamber');
|
||||
|
||||
const STORAGE_DIR = OPENCHAMBER_DATA_DIR;
|
||||
const STORAGE_FILE = path.join(STORAGE_DIR, 'github-auth.json');
|
||||
const SETTINGS_FILE = path.join(OPENCHAMBER_DATA_DIR, 'settings.json');
|
||||
|
||||
const DEFAULT_GITHUB_CLIENT_ID = 'Ov23liNd8TxDcMXtAHHM';
|
||||
const DEFAULT_GITHUB_SCOPES = 'repo read:org workflow read:user user:email';
|
||||
|
||||
function ensureStorageDir() {
|
||||
if (!fs.existsSync(STORAGE_DIR)) {
|
||||
fs.mkdirSync(STORAGE_DIR, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
function readJsonFile() {
|
||||
ensureStorageDir();
|
||||
if (!fs.existsSync(STORAGE_FILE)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const raw = fs.readFileSync(STORAGE_FILE, 'utf8');
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
return null;
|
||||
}
|
||||
return parsed;
|
||||
} catch (error) {
|
||||
console.error('Failed to read GitHub auth file:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeJsonFile(payload) {
|
||||
ensureStorageDir();
|
||||
|
||||
// Atomic write so multiple OpenChamber instances can safely share the same file.
|
||||
const tmpFile = `${STORAGE_FILE}.${process.pid}.${Date.now()}.tmp`;
|
||||
fs.writeFileSync(tmpFile, JSON.stringify(payload, null, 2), 'utf8');
|
||||
try {
|
||||
fs.chmodSync(tmpFile, 0o600);
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
|
||||
fs.renameSync(tmpFile, STORAGE_FILE);
|
||||
try {
|
||||
fs.chmodSync(STORAGE_FILE, 0o600);
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
|
||||
function resolveAccountId({ user, accessToken, accountId }) {
|
||||
if (typeof accountId === 'string' && accountId.trim()) {
|
||||
return accountId.trim();
|
||||
}
|
||||
if (user && typeof user.login === 'string' && user.login.trim()) {
|
||||
return user.login.trim();
|
||||
}
|
||||
if (user && typeof user.id === 'number') {
|
||||
return String(user.id);
|
||||
}
|
||||
if (typeof accessToken === 'string' && accessToken.trim()) {
|
||||
return `token:${accessToken.slice(0, 8)}`;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function normalizeAuthEntry(entry) {
|
||||
if (!entry || typeof entry !== 'object') return null;
|
||||
const accessToken = typeof entry.accessToken === 'string' ? entry.accessToken : '';
|
||||
if (!accessToken) return null;
|
||||
const user = entry.user && typeof entry.user === 'object'
|
||||
? {
|
||||
login: typeof entry.user.login === 'string' ? entry.user.login : null,
|
||||
avatarUrl: typeof entry.user.avatarUrl === 'string' ? entry.user.avatarUrl : null,
|
||||
id: typeof entry.user.id === 'number' ? entry.user.id : null,
|
||||
name: typeof entry.user.name === 'string' ? entry.user.name : null,
|
||||
email: typeof entry.user.email === 'string' ? entry.user.email : null,
|
||||
}
|
||||
: null;
|
||||
|
||||
const accountId = resolveAccountId({
|
||||
user,
|
||||
accessToken,
|
||||
accountId: typeof entry.accountId === 'string' ? entry.accountId : '',
|
||||
});
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
scope: typeof entry.scope === 'string' ? entry.scope : '',
|
||||
tokenType: typeof entry.tokenType === 'string' ? entry.tokenType : 'bearer',
|
||||
createdAt: typeof entry.createdAt === 'number' ? entry.createdAt : null,
|
||||
user,
|
||||
current: Boolean(entry.current),
|
||||
accountId,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeAuthList(raw) {
|
||||
const list = (Array.isArray(raw) ? raw : [raw])
|
||||
.map((entry) => normalizeAuthEntry(entry))
|
||||
.filter(Boolean);
|
||||
|
||||
if (!list.length) {
|
||||
return { list: [], changed: false };
|
||||
}
|
||||
|
||||
let changed = false;
|
||||
let currentFound = false;
|
||||
list.forEach((entry) => {
|
||||
if (entry.current && !currentFound) {
|
||||
currentFound = true;
|
||||
} else if (entry.current && currentFound) {
|
||||
entry.current = false;
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (!currentFound && list[0]) {
|
||||
list[0].current = true;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
list.forEach((entry) => {
|
||||
if (!entry.accountId) {
|
||||
entry.accountId = resolveAccountId(entry);
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
|
||||
return { list, changed };
|
||||
}
|
||||
|
||||
function readAuthList() {
|
||||
const data = readJsonFile();
|
||||
if (!data) {
|
||||
return [];
|
||||
}
|
||||
const { list, changed } = normalizeAuthList(data);
|
||||
if (changed) {
|
||||
writeJsonFile(list);
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
function writeAuthList(list) {
|
||||
writeJsonFile(list);
|
||||
}
|
||||
|
||||
export function getGitHubAuth() {
|
||||
const list = readAuthList();
|
||||
if (!list.length) {
|
||||
return null;
|
||||
}
|
||||
const current = list.find((entry) => entry.current) || list[0];
|
||||
if (!current?.accessToken) {
|
||||
return null;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
export function getGitHubAuthAccounts() {
|
||||
const list = readAuthList();
|
||||
return list
|
||||
.filter((entry) => entry?.user && entry.accountId)
|
||||
.map((entry) => ({
|
||||
id: entry.accountId,
|
||||
user: entry.user,
|
||||
scope: entry.scope || '',
|
||||
current: Boolean(entry.current),
|
||||
}));
|
||||
}
|
||||
|
||||
export function setGitHubAuth({ accessToken, scope, tokenType, user, accountId }) {
|
||||
if (!accessToken || typeof accessToken !== 'string') {
|
||||
throw new Error('accessToken is required');
|
||||
}
|
||||
const normalizedUser = user && typeof user === 'object'
|
||||
? {
|
||||
login: typeof user.login === 'string' ? user.login : undefined,
|
||||
avatarUrl: typeof user.avatarUrl === 'string' ? user.avatarUrl : undefined,
|
||||
id: typeof user.id === 'number' ? user.id : undefined,
|
||||
name: typeof user.name === 'string' ? user.name : undefined,
|
||||
email: typeof user.email === 'string' ? user.email : undefined,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const resolvedAccountId = resolveAccountId({
|
||||
user: normalizedUser,
|
||||
accessToken,
|
||||
accountId,
|
||||
});
|
||||
|
||||
const list = readAuthList();
|
||||
const existingIndex = list.findIndex((entry) => entry.accountId === resolvedAccountId);
|
||||
const nextEntry = {
|
||||
accessToken,
|
||||
scope: typeof scope === 'string' ? scope : '',
|
||||
tokenType: typeof tokenType === 'string' ? tokenType : 'bearer',
|
||||
createdAt: Date.now(),
|
||||
user: normalizedUser || null,
|
||||
current: true,
|
||||
accountId: resolvedAccountId,
|
||||
};
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
list[existingIndex] = nextEntry;
|
||||
} else {
|
||||
list.push(nextEntry);
|
||||
}
|
||||
|
||||
list.forEach((entry, index) => {
|
||||
entry.current = index === (existingIndex >= 0 ? existingIndex : list.length - 1);
|
||||
});
|
||||
writeAuthList(list);
|
||||
return nextEntry;
|
||||
}
|
||||
|
||||
export function activateGitHubAuth(accountId) {
|
||||
if (typeof accountId !== 'string' || !accountId.trim()) {
|
||||
return false;
|
||||
}
|
||||
const list = readAuthList();
|
||||
const index = list.findIndex((entry) => entry.accountId === accountId.trim());
|
||||
if (index === -1) {
|
||||
return false;
|
||||
}
|
||||
list.forEach((entry, idx) => {
|
||||
entry.current = idx === index;
|
||||
});
|
||||
writeAuthList(list);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function clearGitHubAuth() {
|
||||
try {
|
||||
const list = readAuthList();
|
||||
if (!list.length) {
|
||||
return true;
|
||||
}
|
||||
const remaining = list.filter((entry) => !entry.current);
|
||||
if (!remaining.length) {
|
||||
if (fs.existsSync(STORAGE_FILE)) {
|
||||
fs.unlinkSync(STORAGE_FILE);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
remaining.forEach((entry, index) => {
|
||||
entry.current = index === 0;
|
||||
});
|
||||
writeAuthList(remaining);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to clear GitHub auth file:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function getGitHubClientId() {
|
||||
const raw = process.env.OPENCHAMBER_GITHUB_CLIENT_ID;
|
||||
const clientId = typeof raw === 'string' ? raw.trim() : '';
|
||||
if (clientId) return clientId;
|
||||
|
||||
try {
|
||||
if (fs.existsSync(SETTINGS_FILE)) {
|
||||
const parsed = JSON.parse(fs.readFileSync(SETTINGS_FILE, 'utf8'));
|
||||
const stored = typeof parsed?.githubClientId === 'string' ? parsed.githubClientId.trim() : '';
|
||||
if (stored) return stored;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
return DEFAULT_GITHUB_CLIENT_ID;
|
||||
}
|
||||
|
||||
export function getGitHubScopes() {
|
||||
const raw = process.env.OPENCHAMBER_GITHUB_SCOPES;
|
||||
const fromEnv = typeof raw === 'string' ? raw.trim() : '';
|
||||
if (fromEnv) return fromEnv;
|
||||
|
||||
try {
|
||||
if (fs.existsSync(SETTINGS_FILE)) {
|
||||
const parsed = JSON.parse(fs.readFileSync(SETTINGS_FILE, 'utf8'));
|
||||
const stored = typeof parsed?.githubScopes === 'string' ? parsed.githubScopes.trim() : '';
|
||||
if (stored) return stored;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
return DEFAULT_GITHUB_SCOPES;
|
||||
}
|
||||
|
||||
export const GITHUB_AUTH_FILE = STORAGE_FILE;
|
||||
@@ -0,0 +1,50 @@
|
||||
const DEVICE_CODE_URL = 'https://github.com/login/device/code';
|
||||
const ACCESS_TOKEN_URL = 'https://github.com/login/oauth/access_token';
|
||||
const DEVICE_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:device_code';
|
||||
|
||||
const encodeForm = (params) => {
|
||||
const body = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value == null) continue;
|
||||
body.set(key, String(value));
|
||||
}
|
||||
return body.toString();
|
||||
};
|
||||
|
||||
async function postForm(url, params) {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: encodeForm(params),
|
||||
});
|
||||
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
const message = payload?.error_description || payload?.error || response.statusText;
|
||||
const error = new Error(message || 'GitHub request failed');
|
||||
error.status = response.status;
|
||||
error.payload = payload;
|
||||
throw error;
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
export async function startDeviceFlow({ clientId, scope }) {
|
||||
return postForm(DEVICE_CODE_URL, {
|
||||
client_id: clientId,
|
||||
scope,
|
||||
});
|
||||
}
|
||||
|
||||
export async function exchangeDeviceCode({ clientId, deviceCode }) {
|
||||
// GitHub returns 200 with {error: 'authorization_pending'|...} for non-success states.
|
||||
const payload = await postForm(ACCESS_TOKEN_URL, {
|
||||
client_id: clientId,
|
||||
device_code: deviceCode,
|
||||
grant_type: DEVICE_GRANT_TYPE,
|
||||
});
|
||||
return payload;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
export {
|
||||
getGitHubAuth,
|
||||
getGitHubAuthAccounts,
|
||||
setGitHubAuth,
|
||||
activateGitHubAuth,
|
||||
clearGitHubAuth,
|
||||
getGitHubClientId,
|
||||
getGitHubScopes,
|
||||
GITHUB_AUTH_FILE,
|
||||
} from './auth.js';
|
||||
|
||||
export {
|
||||
startDeviceFlow,
|
||||
exchangeDeviceCode,
|
||||
} from './device-flow.js';
|
||||
|
||||
export {
|
||||
getOctokitOrNull,
|
||||
} from './octokit.js';
|
||||
|
||||
export {
|
||||
parseGitHubRemoteUrl,
|
||||
resolveGitHubRepoFromDirectory,
|
||||
} from './repo/index.js';
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Octokit } from '@octokit/rest';
|
||||
import { getGitHubAuth } from './auth.js';
|
||||
|
||||
export function getOctokitOrNull() {
|
||||
const auth = getGitHubAuth();
|
||||
if (!auth?.accessToken) {
|
||||
return null;
|
||||
}
|
||||
return new Octokit({ auth: auth.accessToken });
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { getRemoteUrl } from '../../git/index.js';
|
||||
|
||||
export const parseGitHubRemoteUrl = (raw) => {
|
||||
if (typeof raw !== 'string') {
|
||||
return null;
|
||||
}
|
||||
const value = raw.trim();
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// git@github.com:OWNER/REPO.git
|
||||
if (value.startsWith('git@github.com:')) {
|
||||
const rest = value.slice('git@github.com:'.length);
|
||||
const cleaned = rest.endsWith('.git') ? rest.slice(0, -4) : rest;
|
||||
const [owner, repo] = cleaned.split('/');
|
||||
if (!owner || !repo) return null;
|
||||
return { owner, repo, url: `https://github.com/${owner}/${repo}` };
|
||||
}
|
||||
|
||||
// ssh://git@github.com/OWNER/REPO.git
|
||||
if (value.startsWith('ssh://git@github.com/')) {
|
||||
const rest = value.slice('ssh://git@github.com/'.length);
|
||||
const cleaned = rest.endsWith('.git') ? rest.slice(0, -4) : rest;
|
||||
const [owner, repo] = cleaned.split('/');
|
||||
if (!owner || !repo) return null;
|
||||
return { owner, repo, url: `https://github.com/${owner}/${repo}` };
|
||||
}
|
||||
|
||||
// https://github.com/OWNER/REPO(.git)
|
||||
try {
|
||||
const url = new URL(value);
|
||||
if (url.hostname !== 'github.com') {
|
||||
return null;
|
||||
}
|
||||
const path = url.pathname.replace(/^\/+/, '').replace(/\/+$/, '');
|
||||
const cleaned = path.endsWith('.git') ? path.slice(0, -4) : path;
|
||||
const [owner, repo] = cleaned.split('/');
|
||||
if (!owner || !repo) return null;
|
||||
return { owner, repo, url: `https://github.com/${owner}/${repo}` };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export async function resolveGitHubRepoFromDirectory(directory, remoteName = 'origin') {
|
||||
const remoteUrl = await getRemoteUrl(directory, remoteName).catch(() => null);
|
||||
if (!remoteUrl) {
|
||||
return { repo: null, remoteUrl: null };
|
||||
}
|
||||
return {
|
||||
repo: parseGitHubRemoteUrl(remoteUrl),
|
||||
remoteUrl,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user