Skip to content

Commit d9da55f

Browse files
nick-papeclaude
andauthored
Support GitHub Enterprise hosts in PublicGitHubRepositorySource (#214)
## Description Fixes #185. `PublicGitHubRepositorySource` accepted any HTTPS URL via the host-agnostic `parseGitHubUrlAndRef()` parser, but internally `_parseGitHubUrl()` only matched `github.com` and `_buildDownloadUrl()` always constructed `codeload.github.com` URLs. GHE URLs were silently accepted at parse time but failed at download time. This PR adds real GHE support: - **Host-agnostic URL parsing**: Renamed `_parseGitHubUrl()` → `_parseRepoUrl()` with a generic regex that returns `{ host, owner, repo }` - **Host-aware download URLs**: `github.com` continues to use `codeload.github.com`; other hosts use the GHE REST API (`https://<host>/api/v3/repos/<owner>/<repo>/zipball/<ref>`) - **Auth token support**: New optional `token` property on `IPublicGitHubRepositorySourceOptions`, sent as `Authorization: token <value>` header - **CLI integration**: `GITHUB_TOKEN` env var is read and passed to all `PublicGitHubRepositorySource` instances ## How was this tested - `rush build` — all 22 projects build successfully - `cd api/spfx-template-api && heft test --clean` — 267 tests pass (14 new snapshots for GHE URL parsing, GHE download URLs, auth header, token constructor tests) - `cd apps/spfx-cli && heft test --clean` — 96 tests pass (5 new snapshots for `GITHUB_TOKEN` passthrough tests) ## Type of change - [x] New feature (non-breaking change that adds functionality) 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 71adea2 commit d9da55f

13 files changed

Lines changed: 530 additions & 73 deletions

File tree

api/spfx-template-api/README.md

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ await writer.writeAsync(templateFs, '/path/to/output');
5858

5959
### `PublicGitHubRepositorySource`
6060

61-
Fetches templates from a public GitHub repository. Pin a specific SPFx version with an optional branch/tag ref.
61+
Fetches templates from a GitHub repository (github.com or GitHub Enterprise). Pin a specific SPFx version with an optional branch/tag ref. An optional `token` enables access to GitHub Enterprise instances or private repositories on github.com.
6262

6363
```typescript
6464
import { Terminal, ConsoleTerminalProvider } from '@rushstack/terminal';
@@ -71,6 +71,13 @@ new PublicGitHubRepositorySource({ repoUrl: 'https://github.com/SharePoint/spfx'
7171

7272
// Specific version
7373
new PublicGitHubRepositorySource({ repoUrl: 'https://github.com/SharePoint/spfx', branch: 'version/1.22', terminal });
74+
75+
// GitHub Enterprise with authentication
76+
new PublicGitHubRepositorySource({
77+
repoUrl: 'https://github.mycompany.com/org/spfx-templates',
78+
terminal,
79+
token: process.env.GITHUB_TOKEN
80+
});
7481
```
7582

7683
### `LocalFileSystemRepositorySource`
@@ -139,7 +146,7 @@ The writer uses these helpers internally. You can also import them directly for
139146
| `SPFxTemplate` | Single template — exposes `name`, `category`, `spfxVersion`, and `renderAsync()` |
140147
| `ITemplateOutputEntry` | A single file entry (text or binary contents) |
141148
| `TemplateOutput` | In-memory file system implementation backed by a `Map`, returned by `renderAsync()` |
142-
| `PublicGitHubRepositorySource` | Loads templates from a public GitHub repo |
149+
| `PublicGitHubRepositorySource` | Loads templates from a GitHub repo (github.com or GHE, with optional auth token) |
143150
| `LocalFileSystemRepositorySource` | Loads templates from the local filesystem |
144151
| `BaseSPFxTemplateRepositorySource` | Base class for building custom template sources |
145152
| `SPFxRepositorySource` | Interface implemented by all source types |

api/spfx-template-api/etc/spfx-template-api.api.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ export interface IPublicGitHubRepositorySourceOptions {
4949
branch?: string;
5050
repoUrl: string;
5151
terminal: ITerminal;
52+
token?: string;
5253
}
5354

5455
// @public (undocumented)

api/spfx-template-api/src/repositories/PublicGitHubRepositorySource.ts

Lines changed: 41 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,8 @@ export async function _createTemplateFromFileMapAsync(
9797
*/
9898
export interface IPublicGitHubRepositorySourceOptions {
9999
/**
100-
* The GitHub repository URL (e.g., https://github.com/owner/repo).
100+
* The GitHub repository URL (e.g., https://github.com/owner/repo or
101+
* https://github.mycompany.com/org/repo for GitHub Enterprise).
101102
*/
102103
repoUrl: string;
103104

@@ -110,11 +111,29 @@ export interface IPublicGitHubRepositorySourceOptions {
110111
* The Terminal instance for logging.
111112
*/
112113
terminal: ITerminal;
114+
115+
/**
116+
* An optional GitHub personal access token for authenticating requests.
117+
* Required for most GitHub Enterprise instances and for private repositories
118+
* on github.com. When provided, it is sent as an `Authorization: token <value>` header.
119+
*/
120+
token?: string;
113121
}
114122

123+
// Matches https://<host>/<owner>/<repo>[.git] — HTTPS only, host-agnostic for GHE support.
124+
const REPO_URL_REGEX: RegExp = /^https:\/\/([^/]+)\/([^/]+)\/([^/]+?)(\.git)?$/;
125+
115126
/**
116127
* @public
117-
* A repository that is hosted on a public GitHub repository.
128+
* A template source backed by a GitHub repository (github.com or GitHub Enterprise).
129+
*
130+
* For `github.com` hosts the archive is fetched from `codeload.github.com`.
131+
* For GitHub Enterprise (GHE) hosts the archive is fetched via the GHE REST API
132+
* (`https://<host>/api/v3/repos/<owner>/<repo>/zipball/<ref>`).
133+
*
134+
* An optional personal access token can be supplied via
135+
* {@link IPublicGitHubRepositorySourceOptions.token} for GHE instances or
136+
* private repositories on github.com.
118137
*
119138
* SECURITY NOTE: This class intentionally fetches from mutable branch references
120139
* (not pinned commit SHAs) to enable template updates independent of CLI releases.
@@ -136,13 +155,15 @@ export class PublicGitHubRepositorySource extends BaseSPFxTemplateRepositorySour
136155
private readonly _repoUrl: string;
137156
private readonly _ref: string;
138157
private readonly _terminal: ITerminal;
158+
private readonly _token: string | undefined;
139159

140160
public constructor(options: IPublicGitHubRepositorySourceOptions) {
141161
super('github');
142-
const { repoUrl, branch, terminal } = options;
162+
const { repoUrl, branch, terminal, token } = options;
143163
this._repoUrl = repoUrl;
144164
this._ref = branch || 'version/latest';
145165
this._terminal = terminal;
166+
this._token = token;
146167
}
147168

148169
/**
@@ -160,23 +181,32 @@ export class PublicGitHubRepositorySource extends BaseSPFxTemplateRepositorySour
160181
}
161182

162183
private _buildDownloadUrl(): string {
163-
const { owner, repo } = this._parseGitHubUrl();
164-
return `https://codeload.github.com/${owner}/${repo}/zip/${this._ref}`;
184+
const { host, owner, repo } = this._parseRepoUrl();
185+
if (host === 'github.com') {
186+
return `https://codeload.github.com/${owner}/${repo}/zip/${this._ref}`;
187+
}
188+
// GitHub Enterprise: use the REST API archive endpoint
189+
return `https://${host}/api/v3/repos/${owner}/${repo}/zipball/${this._ref}`;
165190
}
166191

167-
private _parseGitHubUrl(): { owner: string; repo: string } {
168-
// Parse URLs like: https://github.com/sharepoint/spfx or https://github.com/sharepoint/spfx.git
169-
const match: RegExpMatchArray | null = this._repoUrl.match(/github\.com\/([^\/]+)\/([^\/]+?)(\.git)?$/);
192+
private _parseRepoUrl(): { host: string; owner: string; repo: string } {
193+
// Parse URLs like: https://github.com/owner/repo, https://github.mycompany.com/org/repo,
194+
// or the same with a .git suffix. Only HTTPS is accepted.
195+
const match: RegExpMatchArray | null = this._repoUrl.match(REPO_URL_REGEX);
170196
if (!match) {
171197
throw new Error(`Invalid GitHub repository URL: ${this._repoUrl}`);
172198
}
173199

174-
const [, owner, repo] = match as [string, string, string];
175-
return { owner, repo };
200+
const [, host, owner, repo] = match as [string, string, string, string, string?];
201+
return { host: host.toLowerCase(), owner, repo };
176202
}
177203

178204
private async _downloadAndExtractRepositoryAsync(downloadUrl: string): Promise<Map<string, Buffer>> {
179-
const response: Response = await fetch(downloadUrl);
205+
const fetchInit: RequestInit = {};
206+
if (this._token) {
207+
fetchInit.headers = { Authorization: `token ${this._token}` };
208+
}
209+
const response: Response = await fetch(downloadUrl, fetchInit);
180210
if (!response.ok) {
181211
throw new Error(`Failed to download repository: ${response.status} ${response.statusText}`);
182212
}

0 commit comments

Comments
 (0)