@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
@@ -0,0 +1,601 @@
1
+ /**
2
+ * GitHub API Module
3
+ *
4
+ * Provides functions for interacting with GitHub's REST API.
5
+ * Used for repository creation and availability checking.
6
+ */
7
+
8
+ // ============================================================================
9
+ // Types
10
+ // ============================================================================
11
+
12
+ /**
13
+ * GitHub user information
14
+ */
15
+ export interface GitHubUser {
16
+ login: string;
17
+ id: number;
18
+ avatar_url: string;
19
+ html_url: string;
20
+ name?: string;
21
+ }
22
+
23
+ /**
24
+ * Repository information returned after creation
25
+ */
26
+ export interface CreatedRepository {
27
+ /** Repository name */
28
+ name: string;
29
+ /** Full name (owner/repo) */
30
+ fullName: string;
31
+ /** HTML URL (https://github.com/owner/repo) */
32
+ htmlUrl: string;
33
+ /** SSH URL (git@github.com:owner/repo.git) */
34
+ sshUrl: string;
35
+ /** HTTPS clone URL */
36
+ cloneUrl: string;
37
+ }
38
+
39
+ /**
40
+ * Result of repository availability check
41
+ */
42
+ export interface RepoAvailabilityResult {
43
+ /** Whether the name is available */
44
+ available: boolean;
45
+ /** If not available, why */
46
+ reason?: "exists" | "invalid" | "error";
47
+ /** Error message if any */
48
+ message?: string;
49
+ }
50
+
51
+ /**
52
+ * GitHub Pages deployment status
53
+ * Feature 21: Used to check if a deployed site is live
54
+ */
55
+ export interface PagesStatus {
56
+ /** Deployment status: built, building, errored, or unknown */
57
+ status: "built" | "building" | "errored" | "unknown";
58
+ /** The GitHub Pages URL for this repository */
59
+ url: string;
60
+ /** The commit SHA this build corresponds to */
61
+ commit?: string;
62
+ /** Error message from the build, if any */
63
+ error?: string;
64
+ }
65
+
66
+ /**
67
+ * GitHub Pages configuration (custom domain + HTTPS state)
68
+ * Used by the idempotent configure_domain hook to check current state.
69
+ */
70
+ export interface PagesConfig {
71
+ /** Currently configured custom domain (CNAME), or null if none */
72
+ cname: string | null;
73
+ /** Whether HTTPS is enforced for the custom domain */
74
+ https_enforced: boolean;
75
+ }
76
+
77
+ // ============================================================================
78
+ // API Constants
79
+ // ============================================================================
80
+
81
+ export const GITHUB_API_BASE = "https://api.github.com";
82
+ export const GITHUB_API_HEADERS = {
83
+ Accept: "application/vnd.github.v3+json",
84
+ "User-Agent": "moss-GitHub-Deployer",
85
+ };
86
+
87
+ // ============================================================================
88
+ // API Functions
89
+ // ============================================================================
90
+
91
+ /**
92
+ * Get the authenticated user's information
93
+ *
94
+ * @param token - GitHub OAuth access token
95
+ * @returns User information
96
+ * @throws Error if request fails or token is invalid
97
+ */
98
+ export async function getAuthenticatedUser(token: string): Promise<GitHubUser> {
99
+ const response = await fetch(`${GITHUB_API_BASE}/user`, {
100
+ headers: {
101
+ ...GITHUB_API_HEADERS,
102
+ Authorization: `Bearer ${token}`,
103
+ },
104
+ });
105
+
106
+ if (!response.ok) {
107
+ if (response.status === 401) {
108
+ throw new Error("Invalid or expired token");
109
+ }
110
+ throw new Error(`Failed to get user: ${response.status}`);
111
+ }
112
+
113
+ return response.json();
114
+ }
115
+
116
+ /**
117
+ * Check if a repository name is available for the authenticated user
118
+ *
119
+ * @param name - Repository name to check
120
+ * @param token - GitHub OAuth access token
121
+ * @returns Availability result
122
+ */
123
+ export async function checkRepoNameAvailable(
124
+ name: string,
125
+ token: string
126
+ ): Promise<RepoAvailabilityResult> {
127
+ // Validate name format first
128
+ if (!isValidRepoName(name)) {
129
+ return {
130
+ available: false,
131
+ reason: "invalid",
132
+ message: "Repository name can only contain letters, numbers, hyphens, underscores, and periods",
133
+ };
134
+ }
135
+
136
+ try {
137
+ // Get the authenticated user to check their repos
138
+ const user = await getAuthenticatedUser(token);
139
+
140
+ // Check if repo exists
141
+ const response = await fetch(`${GITHUB_API_BASE}/repos/${user.login}/${name}`, {
142
+ headers: {
143
+ ...GITHUB_API_HEADERS,
144
+ Authorization: `Bearer ${token}`,
145
+ },
146
+ });
147
+
148
+ if (response.status === 404) {
149
+ // Repo doesn't exist - name is available
150
+ return { available: true };
151
+ }
152
+
153
+ if (response.ok) {
154
+ // Repo exists
155
+ return {
156
+ available: false,
157
+ reason: "exists",
158
+ message: `Repository '${name}' already exists`,
159
+ };
160
+ }
161
+
162
+ // Other error
163
+ return {
164
+ available: false,
165
+ reason: "error",
166
+ message: `Failed to check availability: ${response.status}`,
167
+ };
168
+ } catch (error) {
169
+ return {
170
+ available: false,
171
+ reason: "error",
172
+ message: error instanceof Error ? error.message : "Unknown error",
173
+ };
174
+ }
175
+ }
176
+
177
+ /**
178
+ * Create a new public repository for the authenticated user
179
+ *
180
+ * @param name - Repository name
181
+ * @param token - GitHub OAuth access token
182
+ * @param description - Optional repository description
183
+ * @returns Created repository information
184
+ * @throws Error if creation fails
185
+ */
186
+ export async function createRepository(
187
+ name: string,
188
+ token: string,
189
+ description?: string
190
+ ): Promise<CreatedRepository> {
191
+ console.log(`Creating repository: ${name}`);
192
+
193
+ const response = await fetch(`${GITHUB_API_BASE}/user/repos`, {
194
+ method: "POST",
195
+ headers: {
196
+ ...GITHUB_API_HEADERS,
197
+ Authorization: `Bearer ${token}`,
198
+ "Content-Type": "application/json",
199
+ },
200
+ body: JSON.stringify({
201
+ name,
202
+ description: description ?? "Created with moss",
203
+ private: false, // Always public for GitHub Pages
204
+ auto_init: false, // Force-push overwrites any initial commit; avoid useless "Initial commit"
205
+ }),
206
+ });
207
+
208
+ if (!response.ok) {
209
+ const error = await response.json().catch(() => ({}));
210
+ const message = error.message || `Failed to create repository: ${response.status}`;
211
+ throw new Error(message);
212
+ }
213
+
214
+ const repo = await response.json();
215
+
216
+ console.log(`Repository created: ${repo.html_url}`);
217
+
218
+ return {
219
+ name: repo.name,
220
+ fullName: repo.full_name,
221
+ htmlUrl: repo.html_url,
222
+ sshUrl: repo.ssh_url,
223
+ cloneUrl: repo.clone_url,
224
+ };
225
+ }
226
+
227
+ /**
228
+ * Get the SSH URL for an existing repository
229
+ *
230
+ * @param owner - Repository owner (username or org)
231
+ * @param repo - Repository name
232
+ * @param token - GitHub OAuth access token
233
+ * @returns The SSH URL (e.g., git@github.com:owner/repo.git)
234
+ * @throws Error if repo is not found or request fails
235
+ */
236
+ export async function getRepoSshUrl(
237
+ owner: string,
238
+ repo: string,
239
+ token: string
240
+ ): Promise<string> {
241
+ const response = await fetch(`${GITHUB_API_BASE}/repos/${owner}/${repo}`, {
242
+ headers: { ...GITHUB_API_HEADERS, Authorization: `Bearer ${token}` },
243
+ });
244
+ if (!response.ok) throw new Error(`Repo not found: ${owner}/${repo}`);
245
+ const data = await response.json();
246
+ return data.ssh_url;
247
+ }
248
+
249
+ /**
250
+ * Check if a repository exists for a given owner
251
+ *
252
+ * Feature 20: Used to check if {username}.github.io already exists
253
+ * before auto-creating it.
254
+ *
255
+ * @param owner - Repository owner (username or org)
256
+ * @param name - Repository name
257
+ * @param token - GitHub OAuth access token
258
+ * @returns true if repo exists, false otherwise (including errors)
259
+ */
260
+ export async function checkRepoExists(
261
+ owner: string,
262
+ name: string,
263
+ token: string
264
+ ): Promise<boolean> {
265
+ try {
266
+ const response = await fetch(`${GITHUB_API_BASE}/repos/${owner}/${name}`, {
267
+ headers: {
268
+ ...GITHUB_API_HEADERS,
269
+ Authorization: `Bearer ${token}`,
270
+ },
271
+ });
272
+
273
+ return response.ok;
274
+ } catch {
275
+ // Network errors or other failures - treat as "doesn't exist"
276
+ return false;
277
+ }
278
+ }
279
+
280
+ /**
281
+ * Check GitHub Pages deployment status
282
+ *
283
+ * Feature 21: Used to verify if a deployed site is live.
284
+ * Uses GET /repos/{owner}/{repo}/pages/builds/latest
285
+ *
286
+ * @see https://docs.github.com/en/rest/pages/pages
287
+ *
288
+ * @param owner - Repository owner (username or org)
289
+ * @param repo - Repository name
290
+ * @param token - GitHub OAuth access token
291
+ * @returns Pages status with deployment state and URL
292
+ */
293
+ export async function checkPagesStatus(
294
+ owner: string,
295
+ repo: string,
296
+ token: string
297
+ ): Promise<PagesStatus> {
298
+ try {
299
+ const response = await fetch(
300
+ `${GITHUB_API_BASE}/repos/${owner}/${repo}/pages/builds/latest`,
301
+ {
302
+ headers: {
303
+ ...GITHUB_API_HEADERS,
304
+ Authorization: `Bearer ${token}`,
305
+ },
306
+ }
307
+ );
308
+
309
+ if (!response.ok) {
310
+ return { status: "unknown", url: "" };
311
+ }
312
+
313
+ const data = await response.json();
314
+
315
+ // Generate the GitHub Pages URL
316
+ // Root repo ({username}.github.io) → https://{username}.github.io/
317
+ // Project repo → https://{username}.github.io/{repo}
318
+ const isRootRepo = repo === `${owner}.github.io`;
319
+ const url = isRootRepo
320
+ ? `https://${owner}.github.io/`
321
+ : `https://${owner}.github.io/${repo}`;
322
+
323
+ // Map API status to our status type
324
+ const status = data.status as "built" | "building" | "errored" | undefined;
325
+
326
+ return {
327
+ status: status || "unknown",
328
+ url,
329
+ commit: data.commit || undefined,
330
+ error: data.error?.message || undefined,
331
+ };
332
+ } catch {
333
+ return { status: "unknown", url: "" };
334
+ }
335
+ }
336
+
337
+ /**
338
+ * Explicitly request a GitHub Pages build.
339
+ *
340
+ * Force-pushed orphan commits sometimes don't trigger automatic builds.
341
+ * This endpoint forces GitHub to rebuild from the current gh-pages branch.
342
+ *
343
+ * @see https://docs.github.com/en/rest/pages/pages#request-a-github-pages-build
344
+ */
345
+ export async function requestPagesBuild(
346
+ owner: string,
347
+ repo: string,
348
+ token: string,
349
+ ): Promise<boolean> {
350
+ try {
351
+ const response = await fetch(
352
+ `${GITHUB_API_BASE}/repos/${owner}/${repo}/pages/builds`,
353
+ {
354
+ method: "POST",
355
+ headers: {
356
+ ...GITHUB_API_HEADERS,
357
+ Authorization: `Bearer ${token}`,
358
+ },
359
+ }
360
+ );
361
+ return response.ok;
362
+ } catch {
363
+ return false;
364
+ }
365
+ }
366
+
367
+ /**
368
+ * Get current GitHub Pages configuration (custom domain + HTTPS state)
369
+ *
370
+ * Used by the idempotent configure_domain hook to check what's already
371
+ * configured before making changes.
372
+ *
373
+ * @see https://docs.github.com/en/rest/pages/pages#get-a-github-pages-site
374
+ *
375
+ * @param owner - Repository owner (username or org)
376
+ * @param repo - Repository name
377
+ * @param token - GitHub OAuth access token
378
+ * @returns Pages config, or null if Pages is not enabled (404)
379
+ */
380
+ export async function getPages(
381
+ owner: string,
382
+ repo: string,
383
+ token: string,
384
+ ): Promise<PagesConfig | null> {
385
+ const url = `${GITHUB_API_BASE}/repos/${owner}/${repo}/pages`;
386
+ const response = await fetch(url, {
387
+ headers: { ...GITHUB_API_HEADERS, Authorization: `Bearer ${token}` },
388
+ });
389
+ if (!response.ok) return null;
390
+ const data = await response.json();
391
+ return { cname: data.cname || null, https_enforced: !!data.https_enforced };
392
+ }
393
+
394
+ /**
395
+ * Enforce HTTPS on a GitHub Pages custom domain
396
+ *
397
+ * This requires the SSL certificate to be provisioned by Let's Encrypt,
398
+ * which happens automatically after DNS propagation. If the cert isn't
399
+ * ready yet, GitHub returns an error and this function returns false.
400
+ *
401
+ * @see https://docs.github.com/en/rest/pages/pages#update-information-about-a-github-pages-site
402
+ *
403
+ * @param owner - Repository owner (username or org)
404
+ * @param repo - Repository name
405
+ * @param token - GitHub OAuth access token
406
+ * @returns true if HTTPS was successfully enforced, false if not yet possible
407
+ */
408
+ export async function enforceHttps(
409
+ owner: string,
410
+ repo: string,
411
+ token: string,
412
+ ): Promise<boolean> {
413
+ const url = `${GITHUB_API_BASE}/repos/${owner}/${repo}/pages`;
414
+ const response = await fetch(url, {
415
+ method: "PUT",
416
+ headers: {
417
+ ...GITHUB_API_HEADERS,
418
+ Authorization: `Bearer ${token}`,
419
+ "Content-Type": "application/json",
420
+ },
421
+ body: JSON.stringify({ https_enforced: true }),
422
+ });
423
+ return response.ok;
424
+ }
425
+
426
+ /**
427
+ * Set a custom domain (CNAME) for GitHub Pages
428
+ *
429
+ * Uses the GitHub Pages API to configure a custom domain for the repository.
430
+ * This is the API equivalent of setting the "Custom domain" field in the
431
+ * repository's Pages settings.
432
+ *
433
+ * @see https://docs.github.com/en/rest/pages/pages#update-information-about-a-github-pages-site
434
+ *
435
+ * @param owner - GitHub username or organization
436
+ * @param repo - Repository name
437
+ * @param token - GitHub OAuth access token
438
+ * @param domain - Custom domain to configure (e.g., "example.com")
439
+ * @returns true if the domain was set successfully
440
+ */
441
+ export async function setCustomDomain(
442
+ owner: string,
443
+ repo: string,
444
+ token: string,
445
+ domain: string,
446
+ ): Promise<boolean> {
447
+ const url = `${GITHUB_API_BASE}/repos/${owner}/${repo}/pages`;
448
+ const headers = {
449
+ ...GITHUB_API_HEADERS,
450
+ Authorization: `Bearer ${token}`,
451
+ "Content-Type": "application/json",
452
+ };
453
+
454
+ // Try with HTTPS enforcement first
455
+ const response = await fetch(url, {
456
+ method: "PUT",
457
+ headers,
458
+ body: JSON.stringify({ cname: domain, https_enforced: true }),
459
+ });
460
+
461
+ if (response.ok) return true;
462
+
463
+ // GitHub rejects https_enforced if DNS hasn't propagated yet (422),
464
+ // or if the SSL certificate doesn't exist yet (404).
465
+ // Retry without it — HTTPS can be enabled later in GitHub settings
466
+ // once DNS propagates and the certificate is provisioned.
467
+ if (response.status === 422 || response.status === 404) {
468
+ const retryResponse = await fetch(url, {
469
+ method: "PUT",
470
+ headers,
471
+ body: JSON.stringify({ cname: domain }),
472
+ });
473
+ if (retryResponse.ok) return true;
474
+
475
+ // GitHub returns 404 when the SSL certificate doesn't exist yet.
476
+ // The CNAME is typically set despite the 404 response — GitHub
477
+ // processes the CNAME before checking the cert. Return true and
478
+ // let getPages() verify the actual state on the next call.
479
+ if (retryResponse.status === 404) return true;
480
+
481
+ const body = await retryResponse.text();
482
+ throw new Error(
483
+ `GitHub Pages API error (${retryResponse.status}): ${body}`
484
+ );
485
+ }
486
+
487
+ const body = await response.text();
488
+ throw new Error(
489
+ `GitHub Pages API error (${response.status}): ${body}`
490
+ );
491
+ }
492
+
493
+ // ============================================================================
494
+ // Pages Source Configuration
495
+ // ============================================================================
496
+
497
+ /**
498
+ * Result of ensuring GitHub Pages source is configured correctly
499
+ */
500
+ export interface EnsurePagesResult {
501
+ /** Whether Pages is now serving from the desired branch */
502
+ configured: boolean;
503
+ /** Whether Pages was newly created (vs. already existed) */
504
+ wasCreated: boolean;
505
+ }
506
+
507
+ /**
508
+ * Ensure GitHub Pages serves from the specified branch.
509
+ *
510
+ * - If Pages is not enabled (404): POST to create it
511
+ * - If Pages exists but on the wrong branch: PUT to update it
512
+ * - If Pages is already on the correct branch: no-op
513
+ *
514
+ * Non-fatal: returns { configured: false } on errors instead of throwing.
515
+ *
516
+ * @param owner - Repository owner
517
+ * @param repo - Repository name
518
+ * @param token - GitHub access token
519
+ * @param branch - Desired source branch (e.g. "gh-pages")
520
+ * @returns Result indicating whether Pages was configured
521
+ */
522
+ export async function ensurePagesSource(
523
+ owner: string,
524
+ repo: string,
525
+ token: string,
526
+ branch: string,
527
+ ): Promise<EnsurePagesResult> {
528
+ const url = `${GITHUB_API_BASE}/repos/${owner}/${repo}/pages`;
529
+ const headers = {
530
+ ...GITHUB_API_HEADERS,
531
+ Authorization: `Bearer ${token}`,
532
+ "Content-Type": "application/json",
533
+ };
534
+ const sourceBody = JSON.stringify({ source: { branch, path: "/" } });
535
+
536
+ try {
537
+ // Check current Pages config
538
+ const getResp = await fetch(url, { headers });
539
+
540
+ if (getResp.status === 404) {
541
+ // Pages not enabled — create it
542
+ const postResp = await fetch(url, { method: "POST", headers, body: sourceBody });
543
+ if (postResp.ok) {
544
+ return { configured: true, wasCreated: true };
545
+ }
546
+ return { configured: false, wasCreated: false };
547
+ }
548
+
549
+ if (getResp.ok) {
550
+ const data = await getResp.json();
551
+ if (data.source?.branch === branch) {
552
+ // Already correct
553
+ return { configured: true, wasCreated: false };
554
+ }
555
+ // Wrong branch — update it
556
+ const putResp = await fetch(url, { method: "PUT", headers, body: sourceBody });
557
+ if (putResp.ok) {
558
+ return { configured: true, wasCreated: false };
559
+ }
560
+ return { configured: false, wasCreated: false };
561
+ }
562
+
563
+ return { configured: false, wasCreated: false };
564
+ } catch {
565
+ return { configured: false, wasCreated: false };
566
+ }
567
+ }
568
+
569
+ // ============================================================================
570
+ // Validation Helpers
571
+ // ============================================================================
572
+
573
+ /**
574
+ * Check if a repository name is valid
575
+ *
576
+ * GitHub repo names can contain:
577
+ * - Letters (a-z, A-Z)
578
+ * - Numbers (0-9)
579
+ * - Hyphens (-)
580
+ * - Underscores (_)
581
+ * - Periods (.)
582
+ *
583
+ * Cannot start with a period or be empty.
584
+ */
585
+ export function isValidRepoName(name: string): boolean {
586
+ if (!name || name.length === 0) {
587
+ return false;
588
+ }
589
+
590
+ if (name.startsWith(".")) {
591
+ return false;
592
+ }
593
+
594
+ // GitHub has a max length of 100 characters
595
+ if (name.length > 100) {
596
+ return false;
597
+ }
598
+
599
+ // Only allowed characters
600
+ return /^[a-zA-Z0-9._-]+$/.test(name);
601
+ }