@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.
- package/CHANGELOG.md +30 -0
- package/README.md +18 -0
- package/assets/icon.svg +3 -0
- package/assets/manifest.json +19 -0
- package/e2e/deploy-api.test.ts +1129 -0
- package/e2e/moss-cli.test.ts +478 -0
- package/features/auth/device-flow.feature +41 -0
- package/features/deploy/validation.feature +50 -0
- package/features/steps/auth.steps.ts +285 -0
- package/features/steps/deploy.steps.ts +354 -0
- package/package.json +51 -0
- package/src/__tests__/auth-flow.integration.test.ts +738 -0
- package/src/__tests__/auth.test.ts +147 -0
- package/src/__tests__/configure-domain.test.ts +263 -0
- package/src/__tests__/deploy.integration.test.ts +798 -0
- package/src/__tests__/git.test.ts +190 -0
- package/src/__tests__/github-api.test.ts +761 -0
- package/src/__tests__/github-deploy.test.ts +2411 -0
- package/src/__tests__/progress-timeout.test.ts +209 -0
- package/src/__tests__/repo-setup-progress.test.ts +367 -0
- package/src/__tests__/repo-setup.test.ts +370 -0
- package/src/__tests__/token.test.ts +152 -0
- package/src/__tests__/utils.test.ts +129 -0
- package/src/__tests__/workflow.test.ts +146 -0
- package/src/auth.ts +588 -0
- package/src/constants.ts +7 -0
- package/src/git.ts +60 -0
- package/src/github-api.ts +601 -0
- package/src/github-deploy.ts +593 -0
- package/src/main.ts +646 -0
- package/src/repo-setup.ts +685 -0
- package/src/token.ts +202 -0
- package/src/types.ts +91 -0
- package/src/utils.ts +108 -0
- package/src/workflow.ts +79 -0
- package/test-helpers/mock-github-api.ts +217 -0
- package/tsconfig.json +20 -0
- 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;
|