Skip to content

Commit 3d04849

Browse files
committed
feat: add GitHub Enterprise Server/Cloud support
Add support for GitHub Enterprise instances via --enterprise-url flag. The enterprise URL is persisted to disk for reuse across sessions. Changes: - Add new url.ts with normalizeDomain, githubBaseUrl, githubApiBaseUrl - Convert GITHUB_BASE_URL and GITHUB_API_BASE_URL to functions - Update all GitHub API calls to use dynamic base URLs - Add enterprise_url persistence in token.ts - Pass enterprise URL to setupGitHubToken Usage: --enterprise-url ghe.example.com PR: ericc-ch#128
1 parent 92ff511 commit 3d04849

File tree

12 files changed

+76
-14
lines changed

12 files changed

+76
-14
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,7 @@ The following command line options are available for the `start` command:
167167
| --show-token | Show GitHub and Copilot tokens on fetch and refresh | false | none |
168168
| --proxy-env | Initialize proxy from environment variables | false | none |
169169
| --api-key | API keys for authentication. Can be specified multiple times | none | none |
170+
| --enterprise-url | GitHub Enterprise host (eg. ghe.example.com) | none | none |
170171

171172
#### Usage examples with --host
172173

src/lib/api-config.ts

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@ import { randomUUID } from "node:crypto"
22

33
import type { State } from "./state"
44

5+
import { state } from "./state"
6+
import { githubApiBaseUrl, githubBaseUrl } from "./url"
7+
58
export const standardHeaders = () => ({
69
"content-type": "application/json",
710
accept: "application/json",
@@ -13,10 +16,15 @@ const USER_AGENT = `GitHubCopilotChat/${COPILOT_VERSION}`
1316

1417
const API_VERSION = "2025-04-01"
1518

16-
export const copilotBaseUrl = (state: State) =>
17-
state.accountType === "individual" ?
18-
"https://api.githubcopilot.com"
19-
: `https://api.${state.accountType}.githubcopilot.com`
19+
export const copilotBaseUrl = (st: State) => {
20+
if (st.enterpriseUrl) {
21+
return `https://copilot-api.${st.enterpriseUrl}`
22+
}
23+
24+
return st.accountType === "individual"
25+
? "https://api.githubcopilot.com"
26+
: `https://api.${st.accountType}.githubcopilot.com`
27+
}
2028
export const copilotHeaders = (state: State, vision: boolean = false) => {
2129
const headers: Record<string, string> = {
2230
Authorization: `Bearer ${state.copilotToken}`,
@@ -36,7 +44,7 @@ export const copilotHeaders = (state: State, vision: boolean = false) => {
3644
return headers
3745
}
3846

39-
export const GITHUB_API_BASE_URL = "https://api.github.com"
47+
export const GITHUB_API_BASE_URL = () => githubApiBaseUrl(state.enterpriseUrl)
4048
export const githubHeaders = (state: State) => ({
4149
...standardHeaders(),
4250
authorization: `token ${state.githubToken}`,
@@ -47,6 +55,6 @@ export const githubHeaders = (state: State) => ({
4755
"x-vscode-user-agent-library-version": "electron-fetch",
4856
})
4957

50-
export const GITHUB_BASE_URL = "https://github.com"
58+
export const GITHUB_BASE_URL = () => githubBaseUrl(state.enterpriseUrl)
5159
export const GITHUB_CLIENT_ID = "Iv1.b507a08c87ecfe98"
5260
export const GITHUB_APP_SCOPES = ["read:user"].join(" ")

src/lib/paths.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,18 @@ import path from "node:path"
55
const APP_DIR = path.join(os.homedir(), ".local", "share", "copilot-api")
66

77
const GITHUB_TOKEN_PATH = path.join(APP_DIR, "github_token")
8+
const ENTERPRISE_URL_PATH = path.join(APP_DIR, "enterprise_url")
89

910
export const PATHS = {
1011
APP_DIR,
1112
GITHUB_TOKEN_PATH,
13+
ENTERPRISE_URL_PATH,
1214
}
1315

1416
export async function ensurePaths(): Promise<void> {
1517
await fs.mkdir(PATHS.APP_DIR, { recursive: true })
1618
await ensureFile(PATHS.GITHUB_TOKEN_PATH)
19+
await ensureFile(PATHS.ENTERPRISE_URL_PATH)
1720
}
1821

1922
async function ensureFile(filePath: string): Promise<void> {

src/lib/state.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { ModelsResponse } from "~/services/copilot/get-models"
33
export interface State {
44
githubToken?: string
55
copilotToken?: string
6+
enterpriseUrl?: string
67

78
accountType: string
89
models?: ModelsResponse
@@ -25,4 +26,5 @@ export const state: State = {
2526
manualApprove: false,
2627
rateLimitWait: false,
2728
showToken: false,
29+
enterpriseUrl: undefined,
2830
}

src/lib/token.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,19 @@ const readGithubToken = () => fs.readFile(PATHS.GITHUB_TOKEN_PATH, "utf8")
1515
const writeGithubToken = (token: string) =>
1616
fs.writeFile(PATHS.GITHUB_TOKEN_PATH, token)
1717

18+
const readEnterpriseUrl = async () => {
19+
try {
20+
const txt = await fs.readFile(PATHS.ENTERPRISE_URL_PATH, "utf8")
21+
const trimmed = txt.trim()
22+
return trimmed || undefined
23+
} catch {
24+
return undefined
25+
}
26+
}
27+
28+
const writeEnterpriseUrl = (url?: string) =>
29+
fs.writeFile(PATHS.ENTERPRISE_URL_PATH, url || "")
30+
1831
export const setupCopilotToken = async () => {
1932
const { token, refresh_in } = await getCopilotToken()
2033
state.copilotToken = token
@@ -44,13 +57,16 @@ export const setupCopilotToken = async () => {
4457

4558
interface SetupGitHubTokenOptions {
4659
force?: boolean
60+
enterpriseUrl?: string
4761
}
4862

4963
export async function setupGitHubToken(
5064
options?: SetupGitHubTokenOptions,
5165
): Promise<void> {
5266
try {
5367
const githubToken = await readGithubToken()
68+
const persistedEnterprise = await readEnterpriseUrl()
69+
if (persistedEnterprise) state.enterpriseUrl = persistedEnterprise
5470

5571
if (githubToken && !options?.force) {
5672
state.githubToken = githubToken
@@ -63,6 +79,10 @@ export async function setupGitHubToken(
6379
}
6480

6581
consola.info("Not logged in, getting new access token")
82+
83+
const enterpriseFromOptions = options?.enterpriseUrl
84+
if (enterpriseFromOptions) state.enterpriseUrl = enterpriseFromOptions
85+
6686
const response = await getDeviceCode()
6787
consola.debug("Device code response:", response)
6888

@@ -72,6 +92,7 @@ export async function setupGitHubToken(
7292

7393
const token = await pollAccessToken(response)
7494
await writeGithubToken(token)
95+
await writeEnterpriseUrl(state.enterpriseUrl)
7596
state.githubToken = token
7697

7798
if (state.showToken) {

src/lib/url.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
export function normalizeDomain(url: string | undefined): string | undefined {
2+
if (!url) return undefined
3+
return url.replace(/^https?:\/\//, "").replace(/\/+$/, "")
4+
}
5+
6+
export function githubBaseUrl(enterprise?: string): string {
7+
if (!enterprise) return "https://github.com"
8+
const domain = normalizeDomain(enterprise)
9+
return `https://${domain}`
10+
}
11+
12+
export function githubApiBaseUrl(enterprise?: string): string {
13+
if (!enterprise) return "https://api.github.com"
14+
const domain = normalizeDomain(enterprise)
15+
return `https://api.${domain}`
16+
}

src/services/github/get-copilot-token.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { state } from "~/lib/state"
44

55
export const getCopilotToken = async () => {
66
const response = await fetch(
7-
`${GITHUB_API_BASE_URL}/copilot_internal/v2/token`,
7+
`${GITHUB_API_BASE_URL()}/copilot_internal/v2/token`,
88
{
99
headers: githubHeaders(state),
1010
},

src/services/github/get-copilot-usage.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,12 @@ import { HTTPError } from "~/lib/error"
33
import { state } from "~/lib/state"
44

55
export const getCopilotUsage = async (): Promise<CopilotUsageResponse> => {
6-
const response = await fetch(`${GITHUB_API_BASE_URL}/copilot_internal/user`, {
7-
headers: githubHeaders(state),
8-
})
6+
const response = await fetch(
7+
`${GITHUB_API_BASE_URL()}/copilot_internal/user`,
8+
{
9+
headers: githubHeaders(state),
10+
},
11+
)
912

1013
if (!response.ok) {
1114
throw new HTTPError("Failed to get Copilot usage", response)

src/services/github/get-device-code.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {
77
import { HTTPError } from "~/lib/error"
88

99
export async function getDeviceCode(): Promise<DeviceCodeResponse> {
10-
const response = await fetch(`${GITHUB_BASE_URL}/login/device/code`, {
10+
const response = await fetch(`${GITHUB_BASE_URL()}/login/device/code`, {
1111
method: "POST",
1212
headers: standardHeaders(),
1313
body: JSON.stringify({

src/services/github/get-user.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { HTTPError } from "~/lib/error"
33
import { state } from "~/lib/state"
44

55
export async function getGitHubUser() {
6-
const response = await fetch(`${GITHUB_API_BASE_URL}/user`, {
6+
const response = await fetch(`${GITHUB_API_BASE_URL()}/user`, {
77
headers: {
88
authorization: `token ${state.githubToken}`,
99
...standardHeaders(),

0 commit comments

Comments
 (0)