@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/main.ts ADDED
@@ -0,0 +1,646 @@
1
+ /**
2
+ * GitHub Deployer Plugin
3
+ *
4
+ * Deploys sites to GitHub Pages via git push to the gh-pages branch.
5
+ * On first-time deploy, also pushes source files to the main branch.
6
+ *
7
+ * Authentication:
8
+ * - Uses OAuth Device Flow for browser-based GitHub login
9
+ * - Stores tokens in git credential helper for persistence
10
+ */
11
+
12
+ import type { DeployContext, ConfigureDomainContext, HookResult, DnsTarget, DnsRecord } from "./types";
13
+ import { getTauriCore, fetchUrl } from "@symbiosis-lab/moss-api";
14
+ import { reportProgress, reportError, setCurrentHookName, showToast, closeBrowser } from "./utils";
15
+ import { buildPagesUrl, parseGitHubUrl } from "./git";
16
+ import { verifyRepoExists, getOriginOwnerRepo, deployViaGitPush, type DeployResult } from "./github-deploy";
17
+ import { promptLogin, validateToken, hasRequiredScopes } from "./auth";
18
+ import { ensureGitHubRepo } from "./repo-setup";
19
+ import { checkPagesStatus, requestPagesBuild, setCustomDomain, ensurePagesSource, getPages, enforceHttps } from "./github-api";
20
+ import { getToken, getTokenFromGit, storeToken } from "./token";
21
+ import { DEPLOY_HEARTBEAT_INTERVAL_MS } from "./constants";
22
+
23
+ // ============================================================================
24
+ // GitHub Pages DNS Configuration
25
+ // ============================================================================
26
+
27
+ /**
28
+ * GitHub Pages A record IP addresses
29
+ * These are the official GitHub Pages IPs for apex domain configuration
30
+ * @see https://docs.github.com/en/pages/configuring-a-custom-domain-for-your-github-pages-site/managing-a-custom-domain-for-your-github-pages-site#configuring-an-apex-domain
31
+ */
32
+ const GITHUB_PAGES_IPS = [
33
+ "185.199.108.153",
34
+ "185.199.109.153",
35
+ "185.199.110.153",
36
+ "185.199.111.153",
37
+ ];
38
+
39
+ /**
40
+ * Generate DNS records for GitHub Pages custom domain configuration
41
+ *
42
+ * @param owner - GitHub username (for CNAME target)
43
+ * @returns DnsTarget with A records for apex and CNAME for www
44
+ */
45
+ function generateDnsTarget(owner: string): DnsTarget {
46
+ const records: DnsRecord[] = [
47
+ // A records for apex domain (@)
48
+ ...GITHUB_PAGES_IPS.map(ip => ({
49
+ record_type: "A",
50
+ name: "@",
51
+ value: ip,
52
+ })),
53
+ // CNAME for www subdomain
54
+ {
55
+ record_type: "CNAME",
56
+ name: "www",
57
+ value: `${owner}.github.io`,
58
+ },
59
+ ];
60
+
61
+ return { records };
62
+ }
63
+
64
+ // ============================================================================
65
+ // Pages Status Polling
66
+ // ============================================================================
67
+
68
+ /**
69
+ * Poll GitHub Pages API until site is live (max 60s)
70
+ *
71
+ * Feature 21: Check if deployed site is accessible before returning.
72
+ *
73
+ * @param owner - GitHub username
74
+ * @param repo - Repository name
75
+ * @param token - GitHub OAuth token (optional - if not available, skips status check)
76
+ * @param pagesUrl - The expected GitHub Pages URL
77
+ * @param expectedCommit - Full SHA of the orphan commit pushed to gh-pages.
78
+ * When set, a "built" status with a different commit is treated as stale.
79
+ * @returns Object with isLive status, URL, and optional error message
80
+ */
81
+ async function waitForPagesLive(
82
+ owner: string,
83
+ repo: string,
84
+ token: string | null,
85
+ pagesUrl: string,
86
+ expectedCommit?: string,
87
+ ): Promise<{ isLive: boolean; url: string; error?: string }> {
88
+ // If no token available, skip status check
89
+ if (!token) {
90
+ console.log(" Status check skipped (no token available)");
91
+ return { isLive: false, url: pagesUrl };
92
+ }
93
+
94
+ const maxAttempts = 6; // 6 attempts × 5s = 30s max
95
+ const pollInterval = 5000;
96
+ let buildRequested = false;
97
+
98
+ console.log(" Checking deployment status...");
99
+
100
+ for (let i = 0; i < maxAttempts; i++) {
101
+ // Report progress FIRST to reset the 60-second inactivity timer
102
+ // This must happen BEFORE sleep() to prevent timeout
103
+ await reportProgress("verifying", 9, 10, `Waiting for GitHub Pages... (${i + 1}/${maxAttempts})`);
104
+
105
+ const status = await checkPagesStatus(owner, repo, token);
106
+
107
+ if (status.status === "built") {
108
+ // Guard against stale builds: if we know the expected commit,
109
+ // only consider it live when the build matches our push.
110
+ if (expectedCommit && status.commit && status.commit !== expectedCommit) {
111
+ console.log(` Stale build detected (got ${status.commit}, expected ${expectedCommit})`);
112
+ // Force-pushed orphan commits sometimes don't trigger automatic builds.
113
+ // Request one explicitly — but only once per deploy.
114
+ if (!buildRequested) {
115
+ buildRequested = true;
116
+ console.log(" Requesting rebuild...");
117
+ await requestPagesBuild(owner, repo, token);
118
+ }
119
+ } else {
120
+ console.log(" Site is live!");
121
+ return { isLive: true, url: pagesUrl };
122
+ }
123
+ }
124
+
125
+ if (status.status === "errored") {
126
+ console.log(" Build failed on GitHub");
127
+ return { isLive: false, url: pagesUrl, error: status.error };
128
+ }
129
+
130
+ // Still building or stale — sleep before next check
131
+ if (i < maxAttempts - 1) {
132
+ await new Promise(resolve => setTimeout(resolve, pollInterval));
133
+ }
134
+ }
135
+
136
+ // Timeout - return URL anyway with isLive: false
137
+ console.log(" Status check timed out (site may still be building)");
138
+ return { isLive: false, url: pagesUrl };
139
+ }
140
+
141
+ // ============================================================================
142
+ // HTTP Reachability Check
143
+ // ============================================================================
144
+
145
+ /**
146
+ * Check if a URL is reachable via HTTP GET.
147
+ * Uses Rust-side fetchUrl (ureq) for reliable, CORS-free checks.
148
+ * Retries with interval. Used as final "deployed" verification.
149
+ */
150
+ async function checkSiteReachable(
151
+ url: string,
152
+ maxAttempts = 3,
153
+ intervalMs = 5000,
154
+ ): Promise<boolean> {
155
+ for (let i = 0; i < maxAttempts; i++) {
156
+ await reportProgress("verifying", 9, 10, `Checking ${url}... (${i + 1}/${maxAttempts})`);
157
+ try {
158
+ const result = await fetchUrl(url, { timeoutMs: 10000 });
159
+ if (result.ok) return true;
160
+ } catch {
161
+ // Network error, retry
162
+ }
163
+ if (i < maxAttempts - 1) {
164
+ await new Promise(resolve => setTimeout(resolve, intervalMs));
165
+ }
166
+ }
167
+ return false;
168
+ }
169
+
170
+ // ============================================================================
171
+ // Hook Implementation
172
+ // ============================================================================
173
+
174
+ /**
175
+ * deploy hook - Deploy to GitHub Pages via git push
176
+ *
177
+ * This capability:
178
+ * 0. Validates requirements (git repo, GitHub remote, built site)
179
+ * 1. Ensures authentication (prompts login if needed)
180
+ * 2. Pushes source files to main branch (first-time only, non-fatal)
181
+ * 3. Force-pushes built site to gh-pages via git CLI
182
+ * 4. Verifies deployment is live
183
+ */
184
+ async function deploy(context: DeployContext): Promise<HookResult> {
185
+ setCurrentHookName("deploy");
186
+
187
+ console.log("GitHub Deployer: Starting deployment...");
188
+
189
+ // Pre-flight: resolve git binary (downloads if needed)
190
+ await reportProgress("configuring", 1, 10, "Checking git...");
191
+ let gitPath: string;
192
+ try {
193
+ gitPath = await getTauriCore().invoke<string>("resolve_git_path");
194
+ } catch (e) {
195
+ const msg = `Git is required for deployment. ${e instanceof Error ? e.message : String(e)}\n\nInstall git by running: xcode-select --install`;
196
+ await reportError(msg, "validation", true);
197
+ return { success: false, message: msg };
198
+ }
199
+ console.log(` Using git: ${gitPath}`);
200
+
201
+ try {
202
+ // Phase 0.5: Early validation using context.site_files (Bug 13 fix)
203
+ // The plugin trusts moss to provide site_files - no need to call listFiles()
204
+ if (!context.site_files || context.site_files.length === 0) {
205
+ const msg = "Site directory is empty. Please build your site first.";
206
+ await reportError(msg, "validation", true);
207
+ return { success: false, message: msg };
208
+ }
209
+ console.log(` Site files: ${context.site_files.length} files ready`);
210
+
211
+ // Phase 0: Determine deploy target from git state (single source of truth)
212
+ let owner: string;
213
+ let repoName: string;
214
+ let wasFirstSetup = false;
215
+
216
+ const existing = await getOriginOwnerRepo(gitPath);
217
+
218
+ if (existing) {
219
+ // .git origin already points to a GitHub repo — use it
220
+ owner = existing.owner;
221
+ repoName = existing.repo;
222
+ console.log(` Deploy target: ${owner}/${repoName} (from git origin)`);
223
+ } else {
224
+ // No .git or no GitHub origin — run setup flow
225
+ await reportProgress("setup", 0, 10, "Setting up GitHub repository...");
226
+ const repoInfo = await ensureGitHubRepo();
227
+
228
+ if (!repoInfo) {
229
+ return { success: false, message: "Repository setup cancelled." };
230
+ }
231
+
232
+ const parsed = parseGitHubUrl(repoInfo.sshUrl);
233
+ if (!parsed) {
234
+ throw new Error("Could not parse GitHub URL from setup result: " + repoInfo.sshUrl);
235
+ }
236
+
237
+ owner = parsed.owner;
238
+ repoName = parsed.repo;
239
+ wasFirstSetup = true;
240
+
241
+ console.log(` Repository configured: ${repoInfo.fullName}`);
242
+ await closeBrowser();
243
+ console.log(" Browser closed - continuing deployment in background");
244
+ }
245
+
246
+ // Phase 1: Ensure authentication (mandatory for GitHub API + push)
247
+ await reportProgress("configuring", 3, 10, "Checking authentication...");
248
+ let token = await getToken();
249
+ if (!token) {
250
+ const gitToken = await getTokenFromGit(gitPath);
251
+ if (gitToken) {
252
+ const validation = await validateToken(gitToken);
253
+ if (validation.valid && hasRequiredScopes(validation.scopes || [])) {
254
+ await storeToken(gitToken);
255
+ token = gitToken;
256
+ } else {
257
+ console.log(" Git credential token invalid or lacks required scopes");
258
+ }
259
+ }
260
+ if (!token) {
261
+ await reportProgress("authenticating", 3, 10, "Authentication required...");
262
+ const authResult = await promptLogin();
263
+ if (!authResult) {
264
+ return { success: false, message: "Authentication required for deployment. Please try again." };
265
+ }
266
+ token = await getToken();
267
+ if (!token) {
268
+ return { success: false, message: "Authentication failed. No valid token available." };
269
+ }
270
+ }
271
+ }
272
+
273
+ // Phase 2: Verify repository exists (fail fast with clear error)
274
+ await verifyRepoExists(owner, repoName, token);
275
+
276
+ // Heartbeat safety net: report progress periodically to prevent inactivity timeout
277
+ // and keep the progress panel visible (must be < STALE_TIMEOUT_MS of 15s).
278
+ // Tracks current phase so heartbeat message is informative, not generic
279
+ let deployResult: DeployResult = { commitSha: "", orphanSha: "", treeChanged: false };
280
+ let currentPhase = "Deploying...";
281
+ let currentStep = 5;
282
+ // Once gh-pages push lands, the deploy is logically done from the user's
283
+ // perspective — the slow tail (source backup to main, Pages-API liveness
284
+ // check) shouldn't keep the panel saying "Deployed!" while progress visibly
285
+ // advances. Track whether we've crossed that boundary so the heartbeat
286
+ // emits a phase-appropriate message instead.
287
+ let postDeployPhase = false;
288
+ const heartbeat = setInterval(() => {
289
+ const stage = postDeployPhase ? "verifying" : "deploying";
290
+ reportProgress(stage, currentStep, 10, currentPhase);
291
+ }, DEPLOY_HEARTBEAT_INTERVAL_MS);
292
+
293
+ // Use context.domain from DeployContext (populated by Rust from .moss/config.toml).
294
+ // Fall back to GitHub Pages API safety net if local config is missing
295
+ // (e.g., iCloud sync corruption, manual edit, or config lost).
296
+ let domain = context.domain;
297
+ if (!domain) {
298
+ try {
299
+ const pages = await getPages(owner, repoName, token);
300
+ if (pages?.cname) {
301
+ domain = pages.cname;
302
+ console.log(` Safety net: preserving existing GitHub Pages CNAME: ${domain}`);
303
+ }
304
+ } catch {
305
+ // Non-fatal — deploy proceeds without CNAME
306
+ }
307
+ }
308
+
309
+ try {
310
+ // Single deploy: commits source + .moss/build/site/, pushes to main,
311
+ // then extracts .moss/build/site/ tree as orphan commit → gh-pages
312
+ deployResult = await deployViaGitPush({
313
+ owner,
314
+ repo: repoName,
315
+ token,
316
+ gitPath,
317
+ domain,
318
+ onProgress: (percent, message) => {
319
+ currentPhase = message;
320
+ // The plugin emits percent=100 once when gh-pages push lands
321
+ // ("Deployed!"), then again with a different message for the
322
+ // post-deploy source-backup phase. Once we've seen that boundary,
323
+ // step locks to 10 and the heartbeat reports the "verifying" stage.
324
+ if (percent >= 100) {
325
+ postDeployPhase = true;
326
+ currentStep = 10;
327
+ reportProgress("verifying", currentStep, 10, message);
328
+ } else {
329
+ // Map 0-99% to steps 5-9 of overall 10-step progress
330
+ currentStep = Math.min(5 + Math.floor((percent / 100) * 4), 9);
331
+ reportProgress("deploying", currentStep, 10, message);
332
+ }
333
+ },
334
+ });
335
+ } finally {
336
+ clearInterval(heartbeat);
337
+ }
338
+
339
+ // Ensure GitHub Pages serves from gh-pages (non-fatal)
340
+ try {
341
+ const pagesResult = await ensurePagesSource(owner, repoName, token, "gh-pages");
342
+ if (!pagesResult.configured) {
343
+ console.warn("Failed to configure GitHub Pages source — user may need to enable Pages manually");
344
+ }
345
+ } catch (e) {
346
+ console.warn(` Failed to configure Pages source: ${e instanceof Error ? e.message : String(e)}`);
347
+ }
348
+
349
+ // Generate pages URL (github.io) for API operations and display URL for user-facing messages
350
+ const pagesUrl = buildPagesUrl(owner, repoName);
351
+ const displayUrl = domain ? `https://${domain}` : pagesUrl;
352
+ const { commitSha, orphanSha, treeChanged } = deployResult;
353
+
354
+ // Use treeChanged (gh-pages content) for primary UI, not commitSha (source backup).
355
+ // The gh-pages push always runs; commitSha only reflects source backup to main.
356
+ const deployed = treeChanged;
357
+
358
+ // Log the deployment result with URL immediately
359
+ if (deployed) {
360
+ console.log(` Deployed: ${orphanSha.substring(0, 7)}`);
361
+ console.log(` Site URL: ${displayUrl}`);
362
+ } else {
363
+ console.log(" No changes to deploy");
364
+ console.log(` Site URL: ${displayUrl}`);
365
+ }
366
+
367
+ // Phase 9: Two-stage deployment verification
368
+ // Stage 1: GitHub API build status (uses github.io URL)
369
+ // Stage 2: HTTP reachability check against displayUrl (verifies actual access)
370
+ // Always verify when tree changed — don't gate on commitSha (source backup).
371
+ let isLive = false;
372
+ let liveError: string | undefined;
373
+ if (deployed) {
374
+ const liveStatus = await waitForPagesLive(owner, repoName, token, pagesUrl, orphanSha);
375
+ liveError = liveStatus.error;
376
+
377
+ if (liveStatus.isLive) {
378
+ // API says built — confirm with HTTP reachability against the URL users see
379
+ isLive = await checkSiteReachable(displayUrl);
380
+ } else if (!liveError) {
381
+ // API timed out — try HTTP check as last resort
382
+ isLive = await checkSiteReachable(displayUrl, 2, 3000);
383
+ }
384
+ }
385
+
386
+ // Build DNS target for custom domain configuration
387
+ const dnsTarget = generateDnsTarget(owner);
388
+
389
+ // Build result message based on scenario
390
+ // Zero-config deployment - no manual steps needed
391
+ let message: string;
392
+ let toastMessage: string;
393
+ let toastVariant: "success" | "info" | "warning" | "error";
394
+ let toastActions: Array<{ label: string; url: string }>;
395
+
396
+ if (wasFirstSetup && deployed) {
397
+ // Scenario 1: First-time deployment (gh-pages branch created)
398
+ message =
399
+ `Your site is being deployed to GitHub Pages!\n\n` +
400
+ `Your site will be available at: ${displayUrl}\n\n` +
401
+ `GitHub Pages is automatically enabled for the gh-pages branch.\n` +
402
+ `It may take a few minutes for your site to appear.`;
403
+ toastMessage = "Deploy configured!";
404
+ toastVariant = "success";
405
+ toastActions = [{ label: "View site", url: displayUrl }];
406
+ } else if (deployed && isLive) {
407
+ // Scenario 2a: Subsequent deploy, site is confirmed live
408
+ message =
409
+ `Site deployed to GitHub Pages!\n\n` +
410
+ `Your site: ${displayUrl}\n\n` +
411
+ `Changes have been pushed to gh-pages branch.`;
412
+ toastMessage = "Site is live!";
413
+ toastVariant = "success";
414
+ toastActions = [{ label: "View site", url: displayUrl }];
415
+ } else if (deployed && liveError) {
416
+ // Scenario 2b: Subsequent deploy, build errored on GitHub
417
+ message =
418
+ `Site pushed to GitHub Pages but the build failed.\n\n` +
419
+ `Error: ${liveError}\n\n` +
420
+ `Check GitHub Pages settings for details.`;
421
+ toastMessage = liveError.length > 60 ? liveError.slice(0, 60) + "..." : liveError;
422
+ toastVariant = "warning";
423
+ toastActions = [{ label: "View on GitHub", url: `https://github.com/${owner}/${repoName}/settings/pages` }];
424
+ } else if (deployed) {
425
+ // Scenario 2c: Deployed but not yet reachable (DNS propagation, CDN cache, first deploy)
426
+ message =
427
+ `Site deployed to GitHub Pages!\n\n` +
428
+ `Your site: ${displayUrl}\n\n` +
429
+ `Changes have been pushed. It may take a moment to go live.`;
430
+ toastMessage = "Deployed \u2014 may take a moment to go live";
431
+ toastVariant = "info";
432
+ toastActions = [{ label: "View site", url: displayUrl }];
433
+ } else {
434
+ // Scenario 3: No changes to push
435
+ message =
436
+ `No changes to deploy.\n\n` +
437
+ `Your site: ${displayUrl}\n\n` +
438
+ `Your local site is already up to date.`;
439
+ toastMessage = "No changes to deploy";
440
+ toastVariant = "info";
441
+ toastActions = [{ label: "View site", url: displayUrl }];
442
+ }
443
+
444
+ // Phase 10: Final progress message based on scenario
445
+ const progressMsg = wasFirstSetup
446
+ ? "GitHub Pages configured!"
447
+ : deployed
448
+ ? "Deployed!"
449
+ : "No changes to deploy";
450
+ await reportProgress("complete", 10, 10, progressMsg);
451
+
452
+ const logMsg = wasFirstSetup
453
+ ? "Setup complete"
454
+ : deployed
455
+ ? "Changes pushed"
456
+ : "No changes";
457
+ console.log(`GitHub Deployer: ${logMsg}`);
458
+
459
+ // Show toast with clickable URL (8s duration for clickable link)
460
+ await showToast({
461
+ message: toastMessage,
462
+ variant: toastVariant,
463
+ actions: toastActions,
464
+ duration: 8000,
465
+ });
466
+
467
+ // Return result - runtime handles completion
468
+ return {
469
+ success: true,
470
+ message,
471
+ deployment: {
472
+ method: "github-pages",
473
+ url: displayUrl,
474
+ deployed_at: new Date().toISOString(),
475
+ metadata: {
476
+ repo_url: `https://github.com/${owner}/${repoName}`,
477
+ branch: "gh-pages",
478
+ was_first_setup: String(wasFirstSetup),
479
+ commit_sha: commitSha,
480
+ is_live: String(isLive),
481
+ },
482
+ dns_target: dnsTarget,
483
+ },
484
+ };
485
+ } catch (error) {
486
+ const errorMessage = error instanceof Error ? error.message : String(error);
487
+ await reportError(errorMessage, "deploy", true);
488
+ console.error(`GitHub Deployer: Failed - ${errorMessage}`);
489
+
490
+ // Categorize error for toast display
491
+ const lowerError = errorMessage.toLowerCase();
492
+ let toastMessage: string;
493
+ if (lowerError.includes("timed out") || lowerError.includes("timeout")) {
494
+ toastMessage = "Push may still be running. Check GitHub in a few minutes.";
495
+ } else if (lowerError.includes("authentication") || lowerError.includes("auth") || lowerError.includes("token")) {
496
+ toastMessage = "Authentication failed";
497
+ } else if (lowerError.includes("network") || lowerError.includes("connection")) {
498
+ toastMessage = "Network error";
499
+ } else if (lowerError.includes("not a git repository") || lowerError.includes("no remote")) {
500
+ toastMessage = "Git not configured";
501
+ } else if (errorMessage.length > 50) {
502
+ toastMessage = errorMessage.slice(0, 50) + "...";
503
+ } else {
504
+ toastMessage = errorMessage;
505
+ }
506
+
507
+ // Show error toast (5s duration, no actions needed for errors)
508
+ await showToast({
509
+ message: toastMessage,
510
+ variant: "error",
511
+ duration: 5000,
512
+ });
513
+
514
+ // Return result - runtime handles completion
515
+ return { success: false, message: errorMessage };
516
+ }
517
+ }
518
+
519
+ // ============================================================================
520
+ // configure_domain Hook Implementation
521
+ // ============================================================================
522
+
523
+ /**
524
+ * configure_domain hook - Set custom domain on GitHub Pages via API
525
+ *
526
+ * Called after moss-seta configures DNS records. This hook tells GitHub
527
+ * about the custom domain so GitHub Pages serves content on it.
528
+ *
529
+ * Uses the GitHub Pages API: PUT /repos/{owner}/{repo}/pages { cname: domain }
530
+ *
531
+ * This is NON-FATAL from moss's perspective - DNS is already configured.
532
+ * If this fails, the user can retry or set the domain manually in GitHub settings.
533
+ */
534
+ async function configure_domain(context: ConfigureDomainContext): Promise<HookResult> {
535
+ setCurrentHookName("configure_domain");
536
+
537
+ const { domain } = context;
538
+
539
+ console.log(`GitHub Deployer: Configuring custom domain "${domain}"...`);
540
+
541
+ try {
542
+ // Resolve git binary (may use portable download if system git unavailable)
543
+ let gitPath: string;
544
+ try {
545
+ gitPath = await getTauriCore().invoke<string>("resolve_git_path");
546
+ } catch (e) {
547
+ console.log(` Git resolution failed, falling back to system git: ${e instanceof Error ? e.message : String(e)}`);
548
+ gitPath = "git"; // Fallback — configure_domain is non-fatal
549
+ }
550
+
551
+ // Get deploy target from git origin
552
+ const repoConfig = await getOriginOwnerRepo(gitPath);
553
+ if (!repoConfig) {
554
+ return {
555
+ success: false,
556
+ message: "No GitHub repository configured. Deploy first.",
557
+ };
558
+ }
559
+
560
+ const { owner, repo } = repoConfig;
561
+
562
+ // Get authentication token (should already be stored from deploy)
563
+ let token = await getToken();
564
+ if (!token) {
565
+ // Try git credential helper as fallback
566
+ token = await getTokenFromGit(gitPath);
567
+ if (token) {
568
+ await storeToken(token);
569
+ }
570
+ }
571
+
572
+ if (!token) {
573
+ return {
574
+ success: false,
575
+ message: "No GitHub authentication token available. Please deploy first to authenticate.",
576
+ };
577
+ }
578
+
579
+ // Check current GitHub Pages state (idempotent — safe to call repeatedly)
580
+ const pages = await getPages(owner, repo, token);
581
+
582
+ if (!pages) {
583
+ // Pages not enabled yet — can't configure domain until first deploy
584
+ return {
585
+ success: false,
586
+ message: "GitHub Pages not enabled. Deploy first.",
587
+ };
588
+ }
589
+
590
+ if (!pages.cname || pages.cname.toLowerCase() !== domain.toLowerCase()) {
591
+ // Phase 1: CNAME not set (or wrong) — set it without HTTPS
592
+ console.log(` Setting CNAME to "${domain}" on ${owner}/${repo}...`);
593
+ await setCustomDomain(owner, repo, token, domain);
594
+ console.log(` Custom domain "${domain}" configured on GitHub Pages`);
595
+ return {
596
+ success: true,
597
+ message: `Custom domain "${domain}" set on GitHub Pages. HTTPS will be enforced after certificate provisioning.`,
598
+ };
599
+ }
600
+
601
+ if (!pages.https_enforced) {
602
+ // Phase 2: CNAME is set, but HTTPS not enforced — try to enforce
603
+ console.log(` CNAME already set. Enforcing HTTPS...`);
604
+ const enforced = await enforceHttps(owner, repo, token);
605
+ if (enforced) {
606
+ console.log(` HTTPS enforced for "${domain}"`);
607
+ return { success: true, message: `HTTPS enforced for "${domain}" on GitHub Pages.` };
608
+ } else {
609
+ // Cert not ready yet — will retry on next orchestrator call (self-healing)
610
+ console.log(` HTTPS enforcement not yet available (certificate pending)`);
611
+ return { success: true, message: `CNAME set. HTTPS pending certificate provisioning.` };
612
+ }
613
+ }
614
+
615
+ // Fully configured — idempotent no-op
616
+ console.log(` Domain "${domain}" already fully configured with HTTPS`);
617
+ return { success: true, message: `Domain "${domain}" already configured with HTTPS on GitHub Pages.` };
618
+ } catch (error) {
619
+ const errorMessage = error instanceof Error ? error.message : String(error);
620
+ console.error(`GitHub Deployer: Failed to configure domain - ${errorMessage}`);
621
+
622
+ return {
623
+ success: false,
624
+ message: `Failed to set custom domain on GitHub Pages: ${errorMessage}`,
625
+ };
626
+ }
627
+ }
628
+
629
+ // ============================================================================
630
+ // Plugin Export
631
+ // ============================================================================
632
+
633
+ /**
634
+ * Plugin object exported as global for the moss plugin runtime
635
+ */
636
+ const GithubPlugin = {
637
+ deploy,
638
+ configure_domain,
639
+ };
640
+
641
+ // Register plugin globally for the plugin runtime
642
+ (window as unknown as { GithubPlugin: typeof GithubPlugin }).GithubPlugin = GithubPlugin;
643
+
644
+ // Also export for module usage
645
+ export { deploy, deploy as on_deploy, configure_domain, configure_domain as on_configure_domain };
646
+ export default GithubPlugin;