@symbiosis-lab/moss-plugin-github 1.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +18 -0
  3. package/assets/icon.svg +3 -0
  4. package/assets/manifest.json +19 -0
  5. package/e2e/deploy-api.test.ts +1129 -0
  6. package/e2e/moss-cli.test.ts +478 -0
  7. package/features/auth/device-flow.feature +41 -0
  8. package/features/deploy/validation.feature +50 -0
  9. package/features/steps/auth.steps.ts +285 -0
  10. package/features/steps/deploy.steps.ts +354 -0
  11. package/package.json +51 -0
  12. package/src/__tests__/auth-flow.integration.test.ts +738 -0
  13. package/src/__tests__/auth.test.ts +147 -0
  14. package/src/__tests__/configure-domain.test.ts +263 -0
  15. package/src/__tests__/deploy.integration.test.ts +798 -0
  16. package/src/__tests__/git.test.ts +190 -0
  17. package/src/__tests__/github-api.test.ts +761 -0
  18. package/src/__tests__/github-deploy.test.ts +2411 -0
  19. package/src/__tests__/progress-timeout.test.ts +209 -0
  20. package/src/__tests__/repo-setup-progress.test.ts +367 -0
  21. package/src/__tests__/repo-setup.test.ts +370 -0
  22. package/src/__tests__/token.test.ts +152 -0
  23. package/src/__tests__/utils.test.ts +129 -0
  24. package/src/__tests__/workflow.test.ts +146 -0
  25. package/src/auth.ts +588 -0
  26. package/src/constants.ts +7 -0
  27. package/src/git.ts +60 -0
  28. package/src/github-api.ts +601 -0
  29. package/src/github-deploy.ts +593 -0
  30. package/src/main.ts +646 -0
  31. package/src/repo-setup.ts +685 -0
  32. package/src/token.ts +202 -0
  33. package/src/types.ts +91 -0
  34. package/src/utils.ts +108 -0
  35. package/src/workflow.ts +79 -0
  36. package/test-helpers/mock-github-api.ts +217 -0
  37. package/tsconfig.json +20 -0
  38. package/vitest.config.ts +50 -0
package/src/auth.ts ADDED
@@ -0,0 +1,588 @@
1
+ /**
2
+ * GitHub OAuth Device Flow Authentication Module
3
+ *
4
+ * Implements the OAuth 2.0 Device Authorization Grant (RFC 8628)
5
+ * for GitHub authentication. This flow is ideal for CLI/desktop apps
6
+ * that can't easily handle redirect callbacks.
7
+ *
8
+ * Flow:
9
+ * 1. Request device code from GitHub
10
+ * 2. Open browser for user to enter code
11
+ * 3. Poll for access token
12
+ * 4. Store token in git credential helper
13
+ */
14
+
15
+ import { openSystemBrowser, httpPost, openBrowserWithHtml, closeBrowser, onEvent } from "@symbiosis-lab/moss-api";
16
+ import { sleep, reportProgress } from "./utils";
17
+ import { storeToken, getToken, clearToken, getTokenFromGit } from "./token";
18
+ import type {
19
+ DeviceCodeResponse,
20
+ TokenResponse,
21
+ GitHubUser,
22
+ AuthState,
23
+ } from "./types";
24
+
25
+ // ============================================================================
26
+ // Configuration
27
+ // ============================================================================
28
+
29
+ /** GitHub OAuth App Client ID for moss */
30
+ const CLIENT_ID = "Ov23li8HTgRH8nuO16oK";
31
+
32
+ /** Required OAuth scopes for GitHub Pages deployment
33
+ * Note: We only need "repo" scope for gh-pages deployment since we push directly
34
+ * to the gh-pages branch. The "workflow" scope is NOT needed because we don't
35
+ * use GitHub Actions for deployment. (Bug 23 fix)
36
+ */
37
+ const REQUIRED_SCOPES = ["repo"];
38
+
39
+ /** GitHub API endpoints */
40
+ const GITHUB_DEVICE_CODE_URL = "https://github.com/login/device/code";
41
+ const GITHUB_TOKEN_URL = "https://github.com/login/oauth/access_token";
42
+ const GITHUB_API_USER_URL = "https://api.github.com/user";
43
+
44
+ /** Maximum time to wait for user authorization (5 minutes) */
45
+ const MAX_POLL_TIME_MS = 300000;
46
+
47
+ // ============================================================================
48
+ // Device Flow Implementation
49
+ // ============================================================================
50
+
51
+ /**
52
+ * Request a device code from GitHub
53
+ *
54
+ * Uses httpPost to bypass CORS restrictions in Tauri WebView.
55
+ */
56
+ export async function requestDeviceCode(): Promise<DeviceCodeResponse> {
57
+ console.log(" Requesting device code from GitHub...");
58
+
59
+ const response = await httpPost(
60
+ GITHUB_DEVICE_CODE_URL,
61
+ {
62
+ client_id: CLIENT_ID,
63
+ scope: REQUIRED_SCOPES.join(" "),
64
+ },
65
+ {
66
+ headers: {
67
+ Accept: "application/json",
68
+ Origin: "https://github.com",
69
+ },
70
+ }
71
+ );
72
+
73
+ if (!response.ok) {
74
+ throw new Error(`Failed to request device code: ${response.status} ${response.text()}`);
75
+ }
76
+
77
+ const data = JSON.parse(response.text());
78
+
79
+ if (data.error) {
80
+ throw new Error(`GitHub error: ${data.error_description || data.error}`);
81
+ }
82
+
83
+ console.log(` Device code received. User code: ${data.user_code}`);
84
+
85
+ return data as DeviceCodeResponse;
86
+ }
87
+
88
+ /**
89
+ * Poll GitHub for access token
90
+ *
91
+ * Uses httpPost to bypass CORS restrictions in Tauri WebView.
92
+ *
93
+ * Returns the token response, which may contain:
94
+ * - access_token: Success!
95
+ * - error: "authorization_pending" - Keep polling
96
+ * - error: "slow_down" - Increase interval
97
+ * - error: "expired_token" - Device code expired
98
+ * - error: "access_denied" - User denied authorization
99
+ */
100
+ export async function pollForToken(
101
+ deviceCode: string,
102
+ _interval: number
103
+ ): Promise<TokenResponse> {
104
+ const response = await httpPost(
105
+ GITHUB_TOKEN_URL,
106
+ {
107
+ client_id: CLIENT_ID,
108
+ device_code: deviceCode,
109
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code",
110
+ },
111
+ {
112
+ headers: {
113
+ Accept: "application/json",
114
+ Origin: "https://github.com",
115
+ },
116
+ }
117
+ );
118
+
119
+ if (!response.ok) {
120
+ throw new Error(`Failed to poll for token: ${response.status} ${response.text()}`);
121
+ }
122
+
123
+ return JSON.parse(response.text()) as TokenResponse;
124
+ }
125
+
126
+ /**
127
+ * Validate an access token by calling the GitHub API
128
+ */
129
+ export async function validateToken(token: string): Promise<{
130
+ valid: boolean;
131
+ user?: GitHubUser;
132
+ scopes?: string[];
133
+ }> {
134
+ try {
135
+ const response = await fetch(GITHUB_API_USER_URL, {
136
+ headers: {
137
+ Authorization: `Bearer ${token}`,
138
+ Accept: "application/vnd.github.v3+json",
139
+ "User-Agent": "moss-GitHub-Deployer",
140
+ },
141
+ });
142
+
143
+ if (!response.ok) {
144
+ return { valid: false };
145
+ }
146
+
147
+ const user = (await response.json()) as GitHubUser;
148
+
149
+ // Get scopes from response headers
150
+ const scopeHeader = response.headers.get("X-OAuth-Scopes") || "";
151
+ const scopes = scopeHeader.split(",").map((s) => s.trim()).filter(Boolean);
152
+
153
+ return { valid: true, user, scopes };
154
+ } catch {
155
+ return { valid: false };
156
+ }
157
+ }
158
+
159
+ /**
160
+ * Check if we have required scopes
161
+ */
162
+ export function hasRequiredScopes(scopes: string[]): boolean {
163
+ return REQUIRED_SCOPES.every((required) => scopes.includes(required));
164
+ }
165
+
166
+ // ============================================================================
167
+ // High-Level Authentication Functions
168
+ // ============================================================================
169
+
170
+ /**
171
+ * Check if user is authenticated with valid GitHub credentials
172
+ *
173
+ * Checks in order:
174
+ * 1. Plugin cookies (fastest - cached from previous auth)
175
+ * 2. Git credential helper (system-stored tokens)
176
+ *
177
+ * Note: Plugin identity and project path are auto-detected from runtime context.
178
+ */
179
+ export async function checkAuthentication(): Promise<AuthState> {
180
+ console.log(" Checking GitHub authentication...");
181
+
182
+ // 1. Try to get token from plugin cookies (fastest)
183
+ let token = await getToken();
184
+
185
+ if (token) {
186
+ // Validate the cached token
187
+ const validation = await validateToken(token);
188
+
189
+ if (validation.valid && hasRequiredScopes(validation.scopes || [])) {
190
+ console.log(` Authenticated as ${validation.user?.login} (from plugin cookies)`);
191
+ return {
192
+ isAuthenticated: true,
193
+ username: validation.user?.login,
194
+ scopes: validation.scopes,
195
+ };
196
+ }
197
+
198
+ // Token is invalid - clear it and try git credentials
199
+ console.log(" Cached token invalid, clearing...");
200
+ await clearToken();
201
+ }
202
+
203
+ // 2. Try git credential helper (Bug 8 fix)
204
+ console.log(" Checking git credential helper...");
205
+ token = await getTokenFromGit();
206
+
207
+ if (token) {
208
+ const validation = await validateToken(token);
209
+
210
+ if (validation.valid && hasRequiredScopes(validation.scopes || [])) {
211
+ // Store in plugin cookies for faster future access
212
+ await storeToken(token);
213
+ console.log(` Authenticated as ${validation.user?.login} (from git credentials)`);
214
+ return {
215
+ isAuthenticated: true,
216
+ username: validation.user?.login,
217
+ scopes: validation.scopes,
218
+ };
219
+ }
220
+
221
+ console.log(" Git credential token lacks required scopes or is invalid");
222
+ }
223
+
224
+ console.log(" No valid credentials found");
225
+ return { isAuthenticated: false };
226
+ }
227
+
228
+ /**
229
+ * Run the full OAuth Device Flow to authenticate the user
230
+ *
231
+ * This will:
232
+ * 1. Request a device code
233
+ * 2. Show auth UI panel with the user code
234
+ * 3. Open the system browser with pre-filled verification URL
235
+ * 4. Poll for the access token (cancellable via auth UI)
236
+ * 5. Store the token in plugin cookies
237
+ * 6. Close the auth UI panel
238
+ */
239
+ export async function promptLogin(): Promise<boolean> {
240
+ try {
241
+ // Step 1: Request device code
242
+ await reportProgress("authentication", 0, 4, "Requesting authorization...");
243
+ const deviceCodeResponse = await requestDeviceCode();
244
+
245
+ const userCode = deviceCodeResponse.user_code;
246
+ const browserUrl = deviceCodeResponse.verification_uri_complete
247
+ ?? deviceCodeResponse.verification_uri;
248
+
249
+ // Step 2: Show auth UI panel with the user code
250
+ await reportProgress("authentication", 1, 4, `Enter code: ${userCode}`);
251
+ await openBrowserWithHtml(createAuthUiHtml(userCode));
252
+
253
+ // Step 3: Open system browser with pre-filled URL
254
+ console.log(` Opening system browser for GitHub authorization...`);
255
+ console.log(` Enter code: ${userCode}`);
256
+ await openSystemBrowser(browserUrl);
257
+
258
+ // Step 4: Listen for cancel from auth UI
259
+ let cancelled = false;
260
+ const unlisten = await onEvent<object>("github:auth-cancel", () => {
261
+ cancelled = true;
262
+ });
263
+
264
+ try {
265
+ // Step 5: Poll for token
266
+ await reportProgress("authentication", 2, 4, "Waiting for authorization...");
267
+ const token = await waitForToken(
268
+ deviceCodeResponse.device_code,
269
+ deviceCodeResponse.interval,
270
+ deviceCodeResponse.expires_in * 1000,
271
+ () => cancelled
272
+ );
273
+
274
+ if (!token) {
275
+ console.warn(" Authorization timed out or was denied");
276
+ if (!cancelled) {
277
+ await emitAuthState("error", "Authorization timed out or was denied");
278
+ await closeBrowser().catch(() => {});
279
+ }
280
+ return false;
281
+ }
282
+
283
+ // Step 6: Success — notify auth UI and store token
284
+ await emitAuthState("success");
285
+ await reportProgress("authentication", 3, 4, "Storing credentials...");
286
+
287
+ const stored = await storeToken(token);
288
+ if (!stored) {
289
+ console.warn(" Failed to store token");
290
+ }
291
+
292
+ await reportProgress("authentication", 4, 4, "Authenticated");
293
+ console.log(" Successfully authenticated with GitHub");
294
+
295
+ // Close auth UI panel
296
+ await closeBrowser();
297
+
298
+ return true;
299
+ } finally {
300
+ unlisten();
301
+ }
302
+ } catch (error) {
303
+ console.error(` Authentication failed: ${error}`);
304
+ await emitAuthState("error", String(error));
305
+ return false;
306
+ }
307
+ }
308
+
309
+ // ============================================================================
310
+ // Auth UI
311
+ // ============================================================================
312
+
313
+ /**
314
+ * Emit a state transition event to the auth UI panel
315
+ */
316
+ async function emitAuthState(phase: "success" | "error", error?: string): Promise<void> {
317
+ try {
318
+ const w = window as unknown as {
319
+ __TAURI__?: { event?: { emit: (name: string, payload: unknown) => Promise<void> } }
320
+ };
321
+ await w.__TAURI__?.event?.emit("github:auth-state", { phase, error });
322
+ } catch {
323
+ // Non-fatal — auth UI panel may already be closed
324
+ }
325
+ }
326
+
327
+ /**
328
+ * Generate HTML for the auth UI panel displayed during device flow
329
+ */
330
+ export function createAuthUiHtml(userCode: string): string {
331
+ return `<!DOCTYPE html>
332
+ <html>
333
+ <head>
334
+ <meta charset="UTF-8">
335
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
336
+ <title>Authorize moss on GitHub</title>
337
+ <style>
338
+ :root {
339
+ --bg: #0d1117;
340
+ --surface: #161b22;
341
+ --surface-hover: #21262d;
342
+ --text: #e6edf3;
343
+ --text-muted: #8b949e;
344
+ --success: #3fb950;
345
+ --error: #f85149;
346
+ --border: #30363d;
347
+ --link: #58a6ff;
348
+ }
349
+
350
+ * { box-sizing: border-box; margin: 0; padding: 0; }
351
+
352
+ body {
353
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
354
+ background: var(--bg);
355
+ color: var(--text);
356
+ min-height: 100vh;
357
+ display: flex;
358
+ flex-direction: column;
359
+ align-items: center;
360
+ padding: 40px 24px;
361
+ }
362
+
363
+ .container { width: 100%; max-width: 400px; text-align: center; }
364
+
365
+ .icon { width: 48px; height: 48px; margin-bottom: 16px; }
366
+
367
+ h1 { font-size: 22px; font-weight: 600; margin-bottom: 8px; }
368
+
369
+ .subtitle {
370
+ color: var(--text-muted);
371
+ font-size: 14px;
372
+ margin-bottom: 24px;
373
+ }
374
+
375
+ .code-display {
376
+ font-family: 'SF Mono', SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace;
377
+ font-size: 32px;
378
+ letter-spacing: 0.15em;
379
+ font-weight: 700;
380
+ color: var(--text);
381
+ padding: 20px 24px;
382
+ background: var(--surface);
383
+ border-radius: 8px;
384
+ border: 1px solid var(--border);
385
+ margin-bottom: 16px;
386
+ user-select: all;
387
+ }
388
+
389
+ .copy-area { margin-bottom: 32px; }
390
+
391
+ .btn-copy {
392
+ padding: 8px 16px;
393
+ font-size: 13px;
394
+ font-weight: 500;
395
+ border-radius: 6px;
396
+ border: 1px solid var(--border);
397
+ background: var(--surface);
398
+ color: var(--text);
399
+ cursor: pointer;
400
+ transition: background-color 0.15s;
401
+ }
402
+
403
+ .btn-copy:hover { background: var(--surface-hover); }
404
+ .btn-copy.copied { color: var(--success); border-color: var(--success); }
405
+
406
+ .status-area {
407
+ display: flex;
408
+ align-items: center;
409
+ justify-content: center;
410
+ gap: 8px;
411
+ margin-bottom: 24px;
412
+ min-height: 24px;
413
+ }
414
+
415
+ .spinner {
416
+ width: 16px;
417
+ height: 16px;
418
+ border: 2px solid var(--border);
419
+ border-top-color: var(--link);
420
+ border-radius: 50%;
421
+ animation: spin 0.8s linear infinite;
422
+ }
423
+
424
+ @keyframes spin { to { transform: rotate(360deg); } }
425
+
426
+ #status-text { color: var(--text-muted); font-size: 14px; }
427
+ #status-text.success { color: var(--success); }
428
+ #status-text.error { color: var(--error); }
429
+
430
+ .btn-cancel {
431
+ padding: 10px 20px;
432
+ font-size: 14px;
433
+ font-weight: 500;
434
+ border-radius: 6px;
435
+ border: 1px solid var(--border);
436
+ background: var(--surface);
437
+ color: var(--text);
438
+ cursor: pointer;
439
+ transition: background-color 0.15s;
440
+ }
441
+
442
+ .btn-cancel:hover { background: var(--surface-hover); }
443
+
444
+ .hidden { display: none; }
445
+ </style>
446
+ </head>
447
+ <body>
448
+ <div class="container">
449
+ <svg class="icon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
450
+ <path d="M12 2C6.475 2 2 6.475 2 12c0 4.42 2.865 8.17 6.84 9.49.5.09.68-.22.68-.48v-1.69c-2.78.6-3.37-1.34-3.37-1.34-.45-1.16-1.11-1.47-1.11-1.47-.91-.62.07-.61.07-.61 1 .07 1.53 1.03 1.53 1.03.89 1.53 2.34 1.09 2.91.83.09-.65.35-1.09.63-1.34-2.22-.25-4.55-1.11-4.55-4.94 0-1.09.39-1.98 1.03-2.68-.1-.25-.45-1.27.1-2.64 0 0 .84-.27 2.75 1.02.8-.22 1.65-.33 2.5-.33.85 0 1.7.11 2.5.33 1.91-1.29 2.75-1.02 2.75-1.02.55 1.37.2 2.39.1 2.64.64.7 1.03 1.59 1.03 2.68 0 3.84-2.34 4.69-4.57 4.94.36.31.68.92.68 1.85v2.75c0 .27.18.58.69.48C19.14 20.17 22 16.42 22 12c0-5.525-4.475-10-10-10z" fill="#8b949e"/>
451
+ </svg>
452
+
453
+ <h1>Authorize moss on GitHub</h1>
454
+ <p class="subtitle">Enter this code in your browser</p>
455
+
456
+ <div class="code-display" id="user-code">${userCode}</div>
457
+
458
+ <div class="copy-area">
459
+ <button class="btn-copy" id="copy-btn">Copy code</button>
460
+ </div>
461
+
462
+ <div class="status-area">
463
+ <div class="spinner" id="spinner"></div>
464
+ <span id="status-text">Waiting for authorization...</span>
465
+ </div>
466
+
467
+ <button class="btn-cancel" id="cancel-btn">Cancel</button>
468
+ </div>
469
+
470
+ <script>
471
+ const copyBtn = document.getElementById('copy-btn');
472
+ const cancelBtn = document.getElementById('cancel-btn');
473
+ const spinner = document.getElementById('spinner');
474
+ const statusText = document.getElementById('status-text');
475
+ const userCode = ${JSON.stringify(userCode)};
476
+
477
+ copyBtn.addEventListener('click', async () => {
478
+ try {
479
+ await navigator.clipboard.writeText(userCode);
480
+ } catch {
481
+ const ta = document.createElement('textarea');
482
+ ta.value = userCode;
483
+ ta.style.position = 'fixed';
484
+ ta.style.opacity = '0';
485
+ document.body.appendChild(ta);
486
+ ta.select();
487
+ document.execCommand('copy');
488
+ document.body.removeChild(ta);
489
+ }
490
+ copyBtn.textContent = 'Copied!';
491
+ copyBtn.classList.add('copied');
492
+ setTimeout(() => {
493
+ copyBtn.textContent = 'Copy code';
494
+ copyBtn.classList.remove('copied');
495
+ }, 2000);
496
+ });
497
+
498
+ cancelBtn.addEventListener('click', () => {
499
+ mossApi.emit('github:auth-cancel', {});
500
+ mossApi.close();
501
+ });
502
+
503
+ const { event } = window.__TAURI__;
504
+ event.listen('github:auth-state', (e) => {
505
+ const { phase, error } = e.payload;
506
+ if (phase === 'success') {
507
+ spinner.classList.add('hidden');
508
+ statusText.textContent = 'Authenticated!';
509
+ statusText.className = 'success';
510
+ cancelBtn.classList.add('hidden');
511
+ } else if (phase === 'error') {
512
+ spinner.classList.add('hidden');
513
+ statusText.textContent = error || 'Authorization failed';
514
+ statusText.className = 'error';
515
+ }
516
+ });
517
+ </script>
518
+ </body>
519
+ </html>`;
520
+ }
521
+
522
+ /**
523
+ * Poll for access token until authorization is complete, cancelled, or timeout
524
+ */
525
+ async function waitForToken(
526
+ deviceCode: string,
527
+ initialInterval: number,
528
+ maxWaitMs: number,
529
+ isCancelled: () => boolean = () => false
530
+ ): Promise<string | null> {
531
+ const startTime = Date.now();
532
+ let interval = initialInterval;
533
+
534
+ while (Date.now() - startTime < Math.min(maxWaitMs, MAX_POLL_TIME_MS)) {
535
+ if (isCancelled()) return null;
536
+
537
+ // Wait for the specified interval
538
+ await sleep(interval * 1000);
539
+
540
+ if (isCancelled()) return null;
541
+
542
+ try {
543
+ const response = await pollForToken(deviceCode, interval);
544
+
545
+ if (response.access_token) {
546
+ return response.access_token;
547
+ }
548
+
549
+ if (response.error === "authorization_pending") {
550
+ // User hasn't authorized yet, keep polling
551
+ continue;
552
+ }
553
+
554
+ if (response.error === "slow_down") {
555
+ // GitHub wants us to slow down
556
+ interval += 5;
557
+ console.log(` Slowing down, new interval: ${interval}s`);
558
+ continue;
559
+ }
560
+
561
+ if (response.error === "expired_token") {
562
+ console.warn(" Device code expired");
563
+ return null;
564
+ }
565
+
566
+ if (response.error === "access_denied") {
567
+ console.warn(" User denied authorization");
568
+ return null;
569
+ }
570
+
571
+ // Unknown error
572
+ console.error(` Unexpected error: ${response.error}`);
573
+ return null;
574
+ } catch (error) {
575
+ console.error(` Poll error: ${error}`);
576
+ // Continue polling on network errors
577
+ }
578
+ }
579
+
580
+ console.warn(" Authorization timeout");
581
+ return null;
582
+ }
583
+
584
+ // ============================================================================
585
+ // Exports for Testing
586
+ // ============================================================================
587
+
588
+ export { CLIENT_ID, REQUIRED_SCOPES };
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Deploy heartbeat interval in milliseconds.
3
+ *
4
+ * Must be shorter than the progress panel's STALE_TIMEOUT_MS (15s)
5
+ * so the progress bar stays visible during long git push operations.
6
+ */
7
+ export const DEPLOY_HEARTBEAT_INTERVAL_MS = 10_000;
package/src/git.ts ADDED
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Git utility functions for the GitHub Pages Publisher Plugin
3
+ *
4
+ * Pure functions for URL parsing.
5
+ */
6
+
7
+ /**
8
+ * Extract GitHub owner and repo from remote URL
9
+ */
10
+ export function parseGitHubUrl(remoteUrl: string): { owner: string; repo: string } | null {
11
+ // Parse HTTPS URLs: https://github.com/user/repo.git
12
+ // Allows dots in repo name (e.g., username.github.io) but not slashes
13
+ const httpsMatch = remoteUrl.match(/^https:\/\/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?$/);
14
+ if (httpsMatch) {
15
+ return { owner: httpsMatch[1], repo: httpsMatch[2] };
16
+ }
17
+
18
+ // Parse SSH URLs: git@github.com:user/repo.git
19
+ // Allows dots in repo name (e.g., username.github.io) but not slashes
20
+ const sshMatch = remoteUrl.match(/^git@github\.com:([^/]+)\/([^/]+?)(?:\.git)?$/);
21
+ if (sshMatch) {
22
+ return { owner: sshMatch[1], repo: sshMatch[2] };
23
+ }
24
+
25
+ return null;
26
+ }
27
+
28
+ /**
29
+ * Check if a repo is the root GitHub Pages repo for the given owner.
30
+ * Root repos follow the pattern {owner}.github.io (case-insensitive).
31
+ */
32
+ export function isRootRepo(owner: string, repo: string): boolean {
33
+ return repo.toLowerCase() === `${owner.toLowerCase()}.github.io`;
34
+ }
35
+
36
+ /**
37
+ * Build GitHub Pages URL from owner and repo name.
38
+ * User/org site repos (e.g., "username.github.io") serve at root.
39
+ */
40
+ export function buildPagesUrl(owner: string, repo: string): string {
41
+ if (repo.toLowerCase() === `${owner.toLowerCase()}.github.io`) {
42
+ return `https://${owner}.github.io`;
43
+ }
44
+ return `https://${owner}.github.io/${repo}`;
45
+ }
46
+
47
+ /**
48
+ * Extract GitHub Pages URL from remote URL
49
+ */
50
+ export function extractGitHubPagesUrl(remoteUrl: string): string {
51
+ const parsed = parseGitHubUrl(remoteUrl);
52
+ if (!parsed) {
53
+ throw new Error("Could not parse GitHub URL from remote");
54
+ }
55
+ // User/org site repos (e.g., "username.github.io") serve at root
56
+ if (parsed.repo.toLowerCase() === `${parsed.owner.toLowerCase()}.github.io`) {
57
+ return `https://${parsed.owner}.github.io`;
58
+ }
59
+ return `https://${parsed.owner}.github.io/${parsed.repo}`;
60
+ }