@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
|
@@ -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
|
+
}
|