Skip to content

Commit 43ea776

Browse files
authored
[Website] Add support for private github repos via git:directory (#2856)
## Motivation for the change, related issues Currently, WordPress Playground can only fetch [git:directory resources](https://wordpress.github.io/wordpress-playground/blueprints/steps/resources/#gitdirectoryreference) from public Github repositories, private ones are not supported. Related: #2182. This PR adds Github OAuth authentication support for private repositories: - Load blueprints that reference private GitHub repositories - Fetch a token via Github OAuth and a user displayed dialog - Store the credentials (in memory or localStorage in dev mode, as previously) **Note:** Because it uses Github OAuth and thus requires a Github user, this is not intended for demoing plugins that reside in private repos, rather for developers to run code in Playground that resides in their own private repositories. <img width="1308" height="656" alt="Screenshot 2025-11-03 at 06 58 57" src="https://github.com/user-attachments/assets/a920020e-b4a7-4b4e-90be-c1ed84e6f778" /> ## Implementation details ### Authentication Flow - Added token storage and authentication helpers to git-sparse-checkout.ts - Git protocol functions now send HTTP Basic Auth headers for Github URLs - Throws GitHubAuthenticationError on 401/403 responses ### UI - New modal prompts users to authenticate when accessing private repos ### Technical Details - Changed OAuth scope from public_repo to repo - Full page reload after OAuth to retry blueprint with new token ## Testing Instructions (or ideally a Blueprint) Test with a private repository: ``` { "steps": [{ "step": "installPlugin", "pluginData": { "resource": "git:directory", "url": "https://github.com/YOUR-USERNAME/YOUR-PRIVATE-REPO", "ref": "HEAD" } }] } ``` **Expected:** Modal prompts for GitHub auth → redirects to GitHub → returns and loads repo successfully without showing modal again. **Verify:** Public repos still work without auth, token persists in dev mode (localStorage), no modal persistence issues.
1 parent 72e4cfa commit 43ea776

File tree

16 files changed

+664
-61
lines changed

16 files changed

+664
-61
lines changed

packages/playground/blueprints/src/lib/v1/compile.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,11 @@ export interface CompileBlueprintV1Options {
8383
* A filesystem to use for the blueprint.
8484
*/
8585
streamBundledFile?: StreamBundledFile;
86+
/**
87+
* Additional headers to pass to git operations.
88+
* A function that returns headers based on the URL being accessed.
89+
*/
90+
gitAdditionalHeadersCallback?: (url: string) => Record<string, string>;
8691
/**
8792
* Additional steps to add to the blueprint.
8893
*/
@@ -142,6 +147,7 @@ function compileBlueprintJson(
142147
onBlueprintValidated = () => {},
143148
corsProxy,
144149
streamBundledFile,
150+
gitAdditionalHeadersCallback,
145151
additionalSteps,
146152
}: CompileBlueprintV1Options = {}
147153
): CompiledBlueprintV1 {
@@ -321,6 +327,7 @@ function compileBlueprintJson(
321327
totalProgressWeight,
322328
corsProxy,
323329
streamBundledFile,
330+
gitAdditionalHeadersCallback,
324331
})
325332
);
326333

@@ -514,6 +521,11 @@ interface CompileStepArgsOptions {
514521
* A filesystem to use for the "blueprint" resource type.
515522
*/
516523
streamBundledFile?: StreamBundledFile;
524+
/**
525+
* Additional headers to pass to git operations.
526+
* A function that returns headers based on the URL being accessed.
527+
*/
528+
gitAdditionalHeadersCallback?: (url: string) => Record<string, string>;
517529
}
518530

519531
/**
@@ -532,6 +544,7 @@ function compileStep<S extends StepDefinition>(
532544
totalProgressWeight,
533545
corsProxy,
534546
streamBundledFile,
547+
gitAdditionalHeadersCallback,
535548
}: CompileStepArgsOptions
536549
): { run: CompiledV1Step; step: S; resources: Array<Resource<any>> } {
537550
const stepProgress = rootProgressTracker.stage(
@@ -546,6 +559,7 @@ function compileStep<S extends StepDefinition>(
546559
semaphore,
547560
corsProxy,
548561
streamBundledFile,
562+
gitAdditionalHeadersCallback,
549563
});
550564
}
551565
args[key] = value;

packages/playground/blueprints/src/lib/v1/resources.spec.ts

Lines changed: 90 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import {
33
GitDirectoryResource,
44
BundledResource,
55
} from './resources';
6-
import { expect, describe, it, vi, beforeEach } from 'vitest';
6+
import { expect, describe, it, vi, beforeEach, afterEach } from 'vitest';
77
import { StreamedFile } from '@php-wasm/stream-compression';
88
import { mkdtemp, rm, writeFile, mkdir } from 'fs/promises';
99
import { tmpdir } from 'os';
@@ -232,6 +232,95 @@ describe('GitDirectoryResource', () => {
232232
expect(name).toBe('https-github.com-WordPress-link-manager-trunk');
233233
});
234234
});
235+
236+
describe('CORS handling', () => {
237+
let originalFetch: typeof global.fetch;
238+
239+
beforeEach(() => {
240+
originalFetch = global.fetch;
241+
});
242+
243+
afterEach(() => {
244+
global.fetch = originalFetch;
245+
});
246+
247+
it('should unwrap CORS URL in GitAuthenticationError', async () => {
248+
global.fetch = vi.fn().mockResolvedValue({
249+
ok: false,
250+
status: 401,
251+
statusText: 'Unauthorized',
252+
});
253+
254+
const githubUrl = 'https://github.com/user/private-repo';
255+
const resource = new GitDirectoryResource(
256+
{
257+
resource: 'git:directory',
258+
url: githubUrl,
259+
ref: 'main',
260+
},
261+
undefined,
262+
{
263+
corsProxy: 'https://cors-proxy.com/',
264+
}
265+
);
266+
267+
await expect(resource.resolve()).rejects.toMatchObject({
268+
name: 'GitAuthenticationError',
269+
repoUrl: githubUrl,
270+
status: 401,
271+
});
272+
});
273+
274+
it('should preserve GitHub URL in GitAuthenticationError without CORS proxy', async () => {
275+
global.fetch = vi.fn().mockResolvedValue({
276+
ok: false,
277+
status: 401,
278+
statusText: 'Unauthorized',
279+
});
280+
281+
const githubUrl = 'https://github.com/user/private-repo';
282+
const resource = new GitDirectoryResource({
283+
resource: 'git:directory',
284+
url: githubUrl,
285+
ref: 'main',
286+
});
287+
288+
await expect(resource.resolve()).rejects.toMatchObject({
289+
name: 'GitAuthenticationError',
290+
repoUrl: githubUrl,
291+
status: 401,
292+
});
293+
});
294+
295+
it('should call gitAdditionalHeadersCallback without CORS proxy', async () => {
296+
const githubUrl = 'https://github.com/user/private-repo';
297+
const headerCallback = vi.fn().mockReturnValue({
298+
Authorization: 'Bearer test-token',
299+
});
300+
301+
const resource = new GitDirectoryResource(
302+
{
303+
resource: 'git:directory',
304+
url: githubUrl,
305+
ref: 'main',
306+
},
307+
undefined,
308+
{
309+
additionalHeaders: headerCallback,
310+
}
311+
);
312+
313+
// Call resolve - it will fail but that's okay, we just want to verify the callback
314+
try {
315+
await resource.resolve();
316+
} catch {
317+
// Expected to fail - we're not mocking the entire git resolution
318+
}
319+
320+
// Verify the callback was called with the GitHub URL (not CORS-wrapped)
321+
expect(headerCallback).toHaveBeenCalledWith(githubUrl);
322+
});
323+
});
235324
});
236325

237326
describe('BlueprintResource', () => {

packages/playground/blueprints/src/lib/v1/resources.ts

Lines changed: 77 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type { FileTree, UniversalPHP } from '@php-wasm/universal';
77
import type { Semaphore } from '@php-wasm/util';
88
import { randomFilename } from '@php-wasm/util';
99
import {
10+
GitAuthenticationError,
1011
listDescendantFiles,
1112
listGitFiles,
1213
resolveCommitHash,
@@ -157,12 +158,16 @@ export abstract class Resource<T extends File | Directory> {
157158
progress,
158159
corsProxy,
159160
streamBundledFile,
161+
gitAdditionalHeadersCallback,
160162
}: {
161163
/** Optional semaphore to limit concurrent downloads */
162164
semaphore?: Semaphore;
163165
progress?: ProgressTracker;
164166
corsProxy?: string;
165167
streamBundledFile?: StreamBundledFile;
168+
gitAdditionalHeadersCallback?: (
169+
url: string
170+
) => Record<string, string>;
166171
}
167172
): Resource<File | Directory> {
168173
let resource: Resource<File | Directory>;
@@ -185,6 +190,7 @@ export abstract class Resource<T extends File | Directory> {
185190
case 'git:directory':
186191
resource = new GitDirectoryResource(ref, progress, {
187192
corsProxy,
193+
additionalHeaders: gitAdditionalHeadersCallback,
188194
});
189195
break;
190196
case 'literal:directory':
@@ -556,12 +562,18 @@ export class UrlResource extends FetchResource {
556562
*/
557563
export class GitDirectoryResource extends Resource<Directory> {
558564
private reference: GitDirectoryReference;
559-
private options?: { corsProxy?: string };
565+
private options?: {
566+
corsProxy?: string;
567+
additionalHeaders?: (url: string) => Record<string, string>;
568+
};
560569

561570
constructor(
562571
reference: GitDirectoryReference,
563572
_progress?: ProgressTracker,
564-
options?: { corsProxy?: string }
573+
options?: {
574+
corsProxy?: string;
575+
additionalHeaders?: (url: string) => Record<string, string>;
576+
}
565577
) {
566578
super();
567579
this.reference = reference;
@@ -570,51 +582,77 @@ export class GitDirectoryResource extends Resource<Directory> {
570582
}
571583

572584
async resolve() {
585+
const additionalHeaders =
586+
this.options?.additionalHeaders?.(this.reference.url) ?? {};
587+
573588
const repoUrl = this.options?.corsProxy
574589
? `${this.options.corsProxy}${this.reference.url}`
575590
: this.reference.url;
576591

577-
const commitHash = await resolveCommitHash(repoUrl, {
578-
value: this.reference.ref,
579-
type: this.reference.refType ?? 'infer',
580-
});
581-
const allFiles = await listGitFiles(repoUrl, commitHash);
582-
583-
const requestedPath = (this.reference.path ?? '').replace(/^\/+/, '');
584-
const filesToClone = listDescendantFiles(allFiles, requestedPath);
585-
const checkout = await sparseCheckout(
586-
repoUrl,
587-
commitHash,
588-
filesToClone,
589-
{
590-
withObjects: this.reference['.git'],
591-
}
592-
);
593-
let files = checkout.files;
592+
try {
593+
const commitHash = await resolveCommitHash(
594+
repoUrl,
595+
{
596+
value: this.reference.ref,
597+
type: this.reference.refType ?? 'infer',
598+
},
599+
additionalHeaders
600+
);
601+
const allFiles = await listGitFiles(
602+
repoUrl,
603+
commitHash,
604+
additionalHeaders
605+
);
594606

595-
// Remove the path prefix from the cloned file names.
596-
files = mapKeys(files, (name) =>
597-
name.substring(requestedPath.length).replace(/^\/+/, '')
598-
);
599-
if (this.reference['.git']) {
600-
const gitFiles = await createDotGitDirectory({
601-
repoUrl: this.reference.url,
607+
const requestedPath = (this.reference.path ?? '').replace(
608+
/^\/+/,
609+
''
610+
);
611+
const filesToClone = listDescendantFiles(allFiles, requestedPath);
612+
const checkout = await sparseCheckout(
613+
repoUrl,
602614
commitHash,
603-
ref: this.reference.ref,
604-
refType: this.reference.refType,
605-
objects: checkout.objects ?? [],
606-
fileOids: checkout.fileOids ?? {},
607-
pathPrefix: requestedPath,
608-
});
609-
files = {
610-
...gitFiles,
611-
...files,
615+
filesToClone,
616+
{
617+
withObjects: this.reference['.git'],
618+
additionalHeaders,
619+
}
620+
);
621+
let files = checkout.files;
622+
623+
// Remove the path prefix from the cloned file names.
624+
files = mapKeys(files, (name) =>
625+
name.substring(requestedPath.length).replace(/^\/+/, '')
626+
);
627+
if (this.reference['.git']) {
628+
const gitFiles = await createDotGitDirectory({
629+
repoUrl: this.reference.url,
630+
commitHash,
631+
ref: this.reference.ref,
632+
refType: this.reference.refType,
633+
objects: checkout.objects ?? [],
634+
fileOids: checkout.fileOids ?? {},
635+
pathPrefix: requestedPath,
636+
});
637+
files = {
638+
...gitFiles,
639+
...files,
640+
};
641+
}
642+
return {
643+
name: this.filename,
644+
files,
612645
};
646+
} catch (error) {
647+
if (error instanceof GitAuthenticationError) {
648+
// Unwrap and re-throw with the original URL (without CORS proxy)
649+
throw new GitAuthenticationError(
650+
this.reference.url,
651+
error.status
652+
);
653+
}
654+
throw error;
613655
}
614-
return {
615-
name: this.filename,
616-
files,
617-
};
618656
}
619657

620658
/**

packages/playground/client/src/blueprints-v1-handler.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export class BlueprintsV1Handler {
2121
onBlueprintValidated,
2222
onBlueprintStepCompleted,
2323
corsProxy,
24+
gitAdditionalHeadersCallback,
2425
mounts,
2526
sapiName,
2627
scope,
@@ -72,6 +73,7 @@ export class BlueprintsV1Handler {
7273
onStepCompleted: onBlueprintStepCompleted,
7374
onBlueprintValidated,
7475
corsProxy,
76+
gitAdditionalHeadersCallback,
7577
});
7678
await runBlueprintV1Steps(compiled, playground);
7779
}

packages/playground/client/src/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,11 @@ export interface StartPlaygroundOptions {
8585
* your Blueprint to replace all cross-origin URLs with the proxy URL.
8686
*/
8787
corsProxy?: string;
88+
/**
89+
* Additional headers to pass to git operations.
90+
* A function that returns headers based on the URL being accessed.
91+
*/
92+
gitAdditionalHeadersCallback?: (url: string) => Record<string, string>;
8893
/**
8994
* The version of the SQLite driver to use.
9095
* Defaults to the latest development version.

0 commit comments

Comments
 (0)