@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,685 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Repository Setup Module (Consolidated)
|
|
3
|
+
*
|
|
4
|
+
* Feature 20: Smart Repo Setup
|
|
5
|
+
* - Auto-creates {username}.github.io when available (no UI needed)
|
|
6
|
+
* - Shows UI only when root is already taken
|
|
7
|
+
*
|
|
8
|
+
* This module replaces:
|
|
9
|
+
* - repo-setup-browser.ts
|
|
10
|
+
* - repo-create.ts
|
|
11
|
+
* - repo-dialog.ts
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { openBrowserWithHtml, closeBrowser, onEvent } from "@symbiosis-lab/moss-api";
|
|
15
|
+
import { reportProgress } from "./utils";
|
|
16
|
+
import { getToken, getTokenFromGit, storeToken } from "./token";
|
|
17
|
+
import { getAuthenticatedUser, checkRepoExists, createRepository, getRepoSshUrl } from "./github-api";
|
|
18
|
+
import { promptLogin, validateToken, hasRequiredScopes } from "./auth";
|
|
19
|
+
import { DEPLOY_HEARTBEAT_INTERVAL_MS } from "./constants";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Result from the repo setup flow
|
|
23
|
+
*/
|
|
24
|
+
export interface RepoSetupResult {
|
|
25
|
+
/** Repository name */
|
|
26
|
+
name: string;
|
|
27
|
+
/** SSH URL for git remote */
|
|
28
|
+
sshUrl: string;
|
|
29
|
+
/** Full name (owner/repo) */
|
|
30
|
+
fullName: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Value returned when user makes a deploy choice
|
|
35
|
+
*/
|
|
36
|
+
interface DeployChoice {
|
|
37
|
+
action: "replace-root" | "custom-domain";
|
|
38
|
+
repoName?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Ensure a GitHub repository exists for deployment
|
|
43
|
+
*
|
|
44
|
+
* This function:
|
|
45
|
+
* 1. Ensures user is authenticated with GitHub
|
|
46
|
+
* 2. Checks if {username}.github.io exists
|
|
47
|
+
* 3. If available, auto-creates it (no UI needed)
|
|
48
|
+
* 4. If taken, shows UI for user to enter custom repo name
|
|
49
|
+
* 5. Returns the repo info
|
|
50
|
+
*
|
|
51
|
+
* @returns Repository info, or null if cancelled/failed
|
|
52
|
+
*/
|
|
53
|
+
export async function ensureGitHubRepo(): Promise<RepoSetupResult | null> {
|
|
54
|
+
console.log(" Ensuring GitHub repository...");
|
|
55
|
+
|
|
56
|
+
// Step 1: Ensure authentication
|
|
57
|
+
const token = await ensureAuthenticated();
|
|
58
|
+
if (!token) {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Step 2: Get authenticated user info
|
|
63
|
+
let username: string;
|
|
64
|
+
try {
|
|
65
|
+
const user = await getAuthenticatedUser(token);
|
|
66
|
+
username = user.login;
|
|
67
|
+
console.log(` Authenticated as ${username}`);
|
|
68
|
+
} catch (error) {
|
|
69
|
+
console.error(` Failed to get user info: ${error}`);
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Step 3: Check if {username}.github.io exists
|
|
74
|
+
const rootRepoName = `${username}.github.io`;
|
|
75
|
+
const rootExists = await checkRepoExists(username, rootRepoName, token);
|
|
76
|
+
|
|
77
|
+
// Step 4: Auto-create or show deploy choice UI
|
|
78
|
+
if (!rootExists) {
|
|
79
|
+
// Root is available - auto-create (no UI needed!)
|
|
80
|
+
return await createRootRepo(username, rootRepoName, token);
|
|
81
|
+
} else {
|
|
82
|
+
// Root is taken - show decision UI
|
|
83
|
+
const choice = await showDeployChoiceUI(username, token);
|
|
84
|
+
if (!choice) {
|
|
85
|
+
await closeBrowser();
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (choice.action === "replace-root") {
|
|
90
|
+
const sshUrl = await getRepoSshUrl(username, rootRepoName, token);
|
|
91
|
+
await closeBrowser();
|
|
92
|
+
return { name: rootRepoName, sshUrl, fullName: `${username}/${rootRepoName}` };
|
|
93
|
+
} else {
|
|
94
|
+
const createdRepo = await createRepository(choice.repoName!, token, "Created with moss");
|
|
95
|
+
await closeBrowser();
|
|
96
|
+
return { name: createdRepo.name, sshUrl: createdRepo.sshUrl, fullName: createdRepo.fullName };
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Ensure user is authenticated, trying various sources
|
|
103
|
+
*/
|
|
104
|
+
async function ensureAuthenticated(): Promise<string | null> {
|
|
105
|
+
// Try 1: Cached token
|
|
106
|
+
let token = await getToken();
|
|
107
|
+
if (token) {
|
|
108
|
+
return token;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Try 2: Git credential helper
|
|
112
|
+
console.log(" No cached token, checking git credentials...");
|
|
113
|
+
token = await getTokenFromGit();
|
|
114
|
+
if (token) {
|
|
115
|
+
const validation = await validateToken(token);
|
|
116
|
+
if (validation.valid && hasRequiredScopes(validation.scopes || [])) {
|
|
117
|
+
console.log(` Using token from git credentials (${validation.user?.login})`);
|
|
118
|
+
await storeToken(token);
|
|
119
|
+
return token;
|
|
120
|
+
} else {
|
|
121
|
+
console.log(" Git credential token invalid or missing scopes");
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Try 3: OAuth login
|
|
126
|
+
console.log(" No valid credentials found, prompting login...");
|
|
127
|
+
const loginSuccess = await promptLogin();
|
|
128
|
+
if (!loginSuccess) {
|
|
129
|
+
console.warn(" GitHub login cancelled or failed");
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
token = await getToken();
|
|
134
|
+
if (!token) {
|
|
135
|
+
console.error(" Failed to get token after login");
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return token;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Auto-create the root repo (no UI needed)
|
|
144
|
+
*/
|
|
145
|
+
async function createRootRepo(
|
|
146
|
+
_username: string,
|
|
147
|
+
repoName: string,
|
|
148
|
+
token: string
|
|
149
|
+
): Promise<RepoSetupResult | null> {
|
|
150
|
+
console.log(` Auto-creating ${repoName} (will deploy to root URL)...`);
|
|
151
|
+
|
|
152
|
+
try {
|
|
153
|
+
const createdRepo = await createRepository(repoName, token, "Created with moss");
|
|
154
|
+
console.log(` Repository created: ${createdRepo.htmlUrl}`);
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
name: createdRepo.name,
|
|
158
|
+
sshUrl: createdRepo.sshUrl,
|
|
159
|
+
fullName: createdRepo.fullName,
|
|
160
|
+
};
|
|
161
|
+
} catch (error) {
|
|
162
|
+
console.error(` Failed to create repository: ${error}`);
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Show browser with HTML and wait for form submission with progress heartbeats.
|
|
169
|
+
*
|
|
170
|
+
* Uses the new manual browser control pattern:
|
|
171
|
+
* - openBrowserWithHtml() to display content
|
|
172
|
+
* - onEvent() to listen for custom events
|
|
173
|
+
* - Caller is responsible for calling closeBrowser() when done
|
|
174
|
+
*
|
|
175
|
+
* Sends progress heartbeats every 30 seconds to prevent inactivity timeout.
|
|
176
|
+
*
|
|
177
|
+
* @param html - The HTML content for the form
|
|
178
|
+
* @param eventName - Custom event name to listen for (e.g., "github:repo-created")
|
|
179
|
+
* @param progressMessage - Message to show during heartbeat updates
|
|
180
|
+
* @param timeoutMs - Maximum time to wait (default: 300000ms / 5 minutes)
|
|
181
|
+
* @returns Form result or null if cancelled/timeout/error
|
|
182
|
+
*/
|
|
183
|
+
async function showBrowserWithProgress<T>(
|
|
184
|
+
html: string,
|
|
185
|
+
eventName: string,
|
|
186
|
+
progressMessage: string,
|
|
187
|
+
timeoutMs: number = 300000
|
|
188
|
+
): Promise<T | null> {
|
|
189
|
+
// Start heartbeat interval — must be < progress panel STALE_TIMEOUT_MS (15s)
|
|
190
|
+
const heartbeat = setInterval(async () => {
|
|
191
|
+
await reportProgress("setup", 0, 6, progressMessage);
|
|
192
|
+
}, DEPLOY_HEARTBEAT_INTERVAL_MS);
|
|
193
|
+
|
|
194
|
+
let unlisten: (() => void) | null = null;
|
|
195
|
+
|
|
196
|
+
try {
|
|
197
|
+
// Open browser with HTML
|
|
198
|
+
await openBrowserWithHtml(html);
|
|
199
|
+
|
|
200
|
+
// Wait for form submission or timeout
|
|
201
|
+
return await Promise.race([
|
|
202
|
+
// Wait for event
|
|
203
|
+
new Promise<T>(async (resolve) => {
|
|
204
|
+
unlisten = await onEvent<T>(eventName, (payload) => {
|
|
205
|
+
resolve(payload);
|
|
206
|
+
return payload;
|
|
207
|
+
});
|
|
208
|
+
}),
|
|
209
|
+
// Timeout
|
|
210
|
+
new Promise<T | null>((resolve) => {
|
|
211
|
+
setTimeout(() => resolve(null), timeoutMs);
|
|
212
|
+
}),
|
|
213
|
+
]);
|
|
214
|
+
} catch (error) {
|
|
215
|
+
console.error(` Form display error: ${error}`);
|
|
216
|
+
return null;
|
|
217
|
+
} finally {
|
|
218
|
+
// Always clear interval and unlisten from event
|
|
219
|
+
clearInterval(heartbeat);
|
|
220
|
+
if (unlisten != null) {
|
|
221
|
+
(unlisten as () => void)();
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Show deploy choice UI when root repo is already taken.
|
|
228
|
+
* Presents two options: "Replace it" or "Use a custom domain".
|
|
229
|
+
*/
|
|
230
|
+
async function showDeployChoiceUI(
|
|
231
|
+
username: string,
|
|
232
|
+
token: string
|
|
233
|
+
): Promise<DeployChoice | null> {
|
|
234
|
+
console.log(" Root repo already exists, showing deploy choice UI...");
|
|
235
|
+
|
|
236
|
+
const html = createDeployChoiceHtml(username, token);
|
|
237
|
+
return await showBrowserWithProgress<DeployChoice>(
|
|
238
|
+
html,
|
|
239
|
+
"github:deploy-choice",
|
|
240
|
+
"Setting up GitHub repository...",
|
|
241
|
+
300000
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Generate the HTML for the deploy choice browser UI.
|
|
247
|
+
* Two-card layout: "Replace it" (deploy to existing root) or
|
|
248
|
+
* "Use a custom domain" (create a project repo).
|
|
249
|
+
*/
|
|
250
|
+
function createDeployChoiceHtml(username: string, token: string): string {
|
|
251
|
+
const rootRepoName = `${username}.github.io`;
|
|
252
|
+
|
|
253
|
+
return `<!DOCTYPE html>
|
|
254
|
+
<html>
|
|
255
|
+
<head>
|
|
256
|
+
<meta charset="UTF-8">
|
|
257
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
258
|
+
<title>GitHub Repository Setup</title>
|
|
259
|
+
<style>
|
|
260
|
+
:root {
|
|
261
|
+
--bg: #0d1117;
|
|
262
|
+
--surface: #161b22;
|
|
263
|
+
--surface-hover: #21262d;
|
|
264
|
+
--text: #e6edf3;
|
|
265
|
+
--text-muted: #8b949e;
|
|
266
|
+
--primary: #238636;
|
|
267
|
+
--primary-hover: #2ea043;
|
|
268
|
+
--success: #3fb950;
|
|
269
|
+
--error: #f85149;
|
|
270
|
+
--warning: #d29922;
|
|
271
|
+
--border: #30363d;
|
|
272
|
+
--link: #58a6ff;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
* {
|
|
276
|
+
box-sizing: border-box;
|
|
277
|
+
margin: 0;
|
|
278
|
+
padding: 0;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
body {
|
|
282
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
|
283
|
+
background: var(--bg);
|
|
284
|
+
color: var(--text);
|
|
285
|
+
min-height: 100vh;
|
|
286
|
+
display: flex;
|
|
287
|
+
flex-direction: column;
|
|
288
|
+
align-items: center;
|
|
289
|
+
padding: 40px 24px;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
.container {
|
|
293
|
+
width: 100%;
|
|
294
|
+
max-width: 480px;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
.icon {
|
|
298
|
+
width: 48px;
|
|
299
|
+
height: 48px;
|
|
300
|
+
margin-bottom: 16px;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
h1 {
|
|
304
|
+
font-size: 24px;
|
|
305
|
+
font-weight: 600;
|
|
306
|
+
margin-bottom: 8px;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
.subtitle {
|
|
310
|
+
color: var(--text-muted);
|
|
311
|
+
font-size: 14px;
|
|
312
|
+
margin-bottom: 24px;
|
|
313
|
+
line-height: 1.5;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
.info-box {
|
|
317
|
+
padding: 12px 16px;
|
|
318
|
+
background: rgba(210, 153, 34, 0.1);
|
|
319
|
+
border: 1px solid var(--warning);
|
|
320
|
+
border-radius: 6px;
|
|
321
|
+
font-size: 13px;
|
|
322
|
+
color: var(--warning);
|
|
323
|
+
margin-bottom: 24px;
|
|
324
|
+
line-height: 1.5;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
.info-box code {
|
|
328
|
+
background: rgba(210, 153, 34, 0.2);
|
|
329
|
+
padding: 2px 6px;
|
|
330
|
+
border-radius: 3px;
|
|
331
|
+
font-family: monospace;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
.card {
|
|
335
|
+
background: var(--surface);
|
|
336
|
+
border: 1px solid var(--border);
|
|
337
|
+
border-radius: 8px;
|
|
338
|
+
padding: 20px;
|
|
339
|
+
margin-bottom: 16px;
|
|
340
|
+
transition: border-color 0.15s;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
.card:hover {
|
|
344
|
+
border-color: var(--text-muted);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
.card h2 {
|
|
348
|
+
font-size: 16px;
|
|
349
|
+
font-weight: 600;
|
|
350
|
+
margin-bottom: 6px;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
.card p {
|
|
354
|
+
color: var(--text-muted);
|
|
355
|
+
font-size: 13px;
|
|
356
|
+
line-height: 1.5;
|
|
357
|
+
margin-bottom: 16px;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
.card code {
|
|
361
|
+
background: rgba(88, 166, 255, 0.1);
|
|
362
|
+
padding: 2px 6px;
|
|
363
|
+
border-radius: 3px;
|
|
364
|
+
font-family: monospace;
|
|
365
|
+
color: var(--link);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
.form-group {
|
|
369
|
+
margin-bottom: 16px;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
label {
|
|
373
|
+
display: block;
|
|
374
|
+
font-size: 14px;
|
|
375
|
+
font-weight: 500;
|
|
376
|
+
margin-bottom: 8px;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
.input-wrapper {
|
|
380
|
+
display: flex;
|
|
381
|
+
align-items: center;
|
|
382
|
+
background: var(--bg);
|
|
383
|
+
border: 1px solid var(--border);
|
|
384
|
+
border-radius: 6px;
|
|
385
|
+
overflow: hidden;
|
|
386
|
+
transition: border-color 0.15s;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
.input-wrapper:focus-within {
|
|
390
|
+
border-color: var(--link);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
.input-wrapper.error {
|
|
394
|
+
border-color: var(--error);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
.input-wrapper.success {
|
|
398
|
+
border-color: var(--success);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
.prefix {
|
|
402
|
+
padding: 10px 0 10px 12px;
|
|
403
|
+
color: var(--text-muted);
|
|
404
|
+
font-size: 14px;
|
|
405
|
+
white-space: nowrap;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
input[type="text"] {
|
|
409
|
+
flex: 1;
|
|
410
|
+
padding: 10px 12px 10px 4px;
|
|
411
|
+
font-size: 14px;
|
|
412
|
+
background: transparent;
|
|
413
|
+
border: none;
|
|
414
|
+
color: var(--text);
|
|
415
|
+
outline: none;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
input[type="text"]::placeholder {
|
|
419
|
+
color: var(--text-muted);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
.status {
|
|
423
|
+
display: flex;
|
|
424
|
+
align-items: center;
|
|
425
|
+
gap: 6px;
|
|
426
|
+
margin-top: 8px;
|
|
427
|
+
font-size: 13px;
|
|
428
|
+
min-height: 20px;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
.status.checking {
|
|
432
|
+
color: var(--text-muted);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
.status.available {
|
|
436
|
+
color: var(--success);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
.status.taken,
|
|
440
|
+
.status.invalid {
|
|
441
|
+
color: var(--error);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
.spinner {
|
|
445
|
+
width: 14px;
|
|
446
|
+
height: 14px;
|
|
447
|
+
border: 2px solid var(--border);
|
|
448
|
+
border-top-color: var(--link);
|
|
449
|
+
border-radius: 50%;
|
|
450
|
+
animation: spin 0.8s linear infinite;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
@keyframes spin {
|
|
454
|
+
to { transform: rotate(360deg); }
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
button {
|
|
458
|
+
padding: 10px 20px;
|
|
459
|
+
font-size: 14px;
|
|
460
|
+
font-weight: 500;
|
|
461
|
+
border-radius: 6px;
|
|
462
|
+
border: none;
|
|
463
|
+
cursor: pointer;
|
|
464
|
+
transition: background-color 0.15s;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
button:disabled {
|
|
468
|
+
opacity: 0.5;
|
|
469
|
+
cursor: not-allowed;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
.btn-primary {
|
|
473
|
+
background: var(--primary);
|
|
474
|
+
color: white;
|
|
475
|
+
width: 100%;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
.btn-primary:hover:not(:disabled) {
|
|
479
|
+
background: var(--primary-hover);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
.btn-secondary {
|
|
483
|
+
background: var(--surface-hover);
|
|
484
|
+
color: var(--text);
|
|
485
|
+
border: 1px solid var(--border);
|
|
486
|
+
width: 100%;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
.btn-secondary:hover:not(:disabled) {
|
|
490
|
+
background: var(--border);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
.cancel-row {
|
|
494
|
+
text-align: center;
|
|
495
|
+
margin-top: 16px;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
.cancel-link {
|
|
499
|
+
color: var(--text-muted);
|
|
500
|
+
font-size: 13px;
|
|
501
|
+
cursor: pointer;
|
|
502
|
+
background: none;
|
|
503
|
+
border: none;
|
|
504
|
+
text-decoration: underline;
|
|
505
|
+
padding: 0;
|
|
506
|
+
width: auto;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
.cancel-link:hover {
|
|
510
|
+
color: var(--text);
|
|
511
|
+
}
|
|
512
|
+
</style>
|
|
513
|
+
</head>
|
|
514
|
+
<body>
|
|
515
|
+
<div class="container">
|
|
516
|
+
<svg class="icon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
517
|
+
<path d="M12 2C6.475 2 2 6.475 2 12c0 4.42 2.865 8.17 6.84 9.49.5.09.68-.22.68-.48v-1.69c-2.78.6-3.37-1.34-3.37-1.34-.45-1.16-1.11-1.47-1.11-1.47-.91-.62.07-.61.07-.61 1 .07 1.53 1.03 1.53 1.03.89 1.53 2.34 1.09 2.91.83.09-.65.35-1.09.63-1.34-2.22-.25-4.55-1.11-4.55-4.94 0-1.09.39-1.98 1.03-2.68-.1-.25-.45-1.27.1-2.64 0 0 .84-.27 2.75 1.02.8-.22 1.65-.33 2.5-.33.85 0 1.7.11 2.5.33 1.91-1.29 2.75-1.02 2.75-1.02.55 1.37.2 2.39.1 2.64.64.7 1.03 1.59 1.03 2.68 0 3.84-2.34 4.69-4.57 4.94.36.31.68.92.68 1.85v2.75c0 .27.18.58.69.48C19.14 20.17 22 16.42 22 12c0-5.525-4.475-10-10-10z" fill="#8b949e"/>
|
|
518
|
+
</svg>
|
|
519
|
+
|
|
520
|
+
<h1>Deploy your site</h1>
|
|
521
|
+
<p class="subtitle">
|
|
522
|
+
<code>${rootRepoName}</code> already exists. How would you like to deploy?
|
|
523
|
+
</p>
|
|
524
|
+
|
|
525
|
+
<!-- Card 1: Replace root -->
|
|
526
|
+
<div class="card" id="card-replace">
|
|
527
|
+
<h2>Replace it</h2>
|
|
528
|
+
<p>
|
|
529
|
+
Deploy to your existing <code>${rootRepoName}</code> repository.
|
|
530
|
+
Your site will be at <strong>${username}.github.io/</strong>
|
|
531
|
+
</p>
|
|
532
|
+
<button class="btn-primary" id="replace-btn">Deploy to ${username}.github.io</button>
|
|
533
|
+
</div>
|
|
534
|
+
|
|
535
|
+
<!-- Card 2: Custom domain / project repo -->
|
|
536
|
+
<div class="card" id="card-custom">
|
|
537
|
+
<h2>Use a custom domain</h2>
|
|
538
|
+
<p>
|
|
539
|
+
Create a new repository and set up a custom domain later.
|
|
540
|
+
Your site will be at <strong>${username}.github.io/<em>repo-name</em>/</strong> until the domain is configured.
|
|
541
|
+
</p>
|
|
542
|
+
|
|
543
|
+
<div class="form-group">
|
|
544
|
+
<label for="repo-name">Repository name</label>
|
|
545
|
+
<div class="input-wrapper" id="input-wrapper">
|
|
546
|
+
<span class="prefix">github.com/${username}/</span>
|
|
547
|
+
<input type="text" id="repo-name" placeholder="my-website"
|
|
548
|
+
autocomplete="off" autocorrect="off" spellcheck="false">
|
|
549
|
+
</div>
|
|
550
|
+
<div class="status" id="status"></div>
|
|
551
|
+
</div>
|
|
552
|
+
|
|
553
|
+
<button class="btn-secondary" id="custom-btn" disabled>Create & Deploy</button>
|
|
554
|
+
</div>
|
|
555
|
+
|
|
556
|
+
<div class="cancel-row">
|
|
557
|
+
<button class="cancel-link" id="cancel-btn">Cancel</button>
|
|
558
|
+
</div>
|
|
559
|
+
</div>
|
|
560
|
+
|
|
561
|
+
<script>
|
|
562
|
+
const token = '${token}';
|
|
563
|
+
|
|
564
|
+
const input = document.getElementById('repo-name');
|
|
565
|
+
const inputWrapper = document.getElementById('input-wrapper');
|
|
566
|
+
const status = document.getElementById('status');
|
|
567
|
+
const replaceBtn = document.getElementById('replace-btn');
|
|
568
|
+
const customBtn = document.getElementById('custom-btn');
|
|
569
|
+
const cancelBtn = document.getElementById('cancel-btn');
|
|
570
|
+
|
|
571
|
+
let checkTimeout = null;
|
|
572
|
+
let isAvailable = false;
|
|
573
|
+
let currentName = '';
|
|
574
|
+
|
|
575
|
+
const validNameRegex = /^[a-zA-Z0-9._-]+$/;
|
|
576
|
+
|
|
577
|
+
function setStatus(type, message) {
|
|
578
|
+
status.className = 'status ' + type;
|
|
579
|
+
|
|
580
|
+
if (type === 'checking') {
|
|
581
|
+
status.innerHTML = '<div class="spinner"></div>' + message;
|
|
582
|
+
} else if (type === 'available') {
|
|
583
|
+
status.innerHTML = '<span>✓</span> ' + message;
|
|
584
|
+
} else if (type === 'taken' || type === 'invalid') {
|
|
585
|
+
status.innerHTML = '<span>✗</span> ' + message;
|
|
586
|
+
} else {
|
|
587
|
+
status.innerHTML = message;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
inputWrapper.className = 'input-wrapper ' +
|
|
591
|
+
(type === 'available' ? 'success' :
|
|
592
|
+
(type === 'taken' || type === 'invalid') ? 'error' : '');
|
|
593
|
+
|
|
594
|
+
customBtn.disabled = type !== 'available';
|
|
595
|
+
isAvailable = type === 'available';
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
function validateName(name) {
|
|
599
|
+
if (!name) {
|
|
600
|
+
setStatus('', '');
|
|
601
|
+
return false;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
if (name.startsWith('.')) {
|
|
605
|
+
setStatus('invalid', 'Name cannot start with a period');
|
|
606
|
+
return false;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
if (!validNameRegex.test(name)) {
|
|
610
|
+
setStatus('invalid', 'Only letters, numbers, hyphens, underscores, and periods allowed');
|
|
611
|
+
return false;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
if (name.length > 100) {
|
|
615
|
+
setStatus('invalid', 'Name is too long (max 100 characters)');
|
|
616
|
+
return false;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
return true;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
async function checkAvailability(name) {
|
|
623
|
+
if (!validateName(name)) return;
|
|
624
|
+
|
|
625
|
+
currentName = name;
|
|
626
|
+
setStatus('checking', 'Checking availability...');
|
|
627
|
+
|
|
628
|
+
try {
|
|
629
|
+
const response = await fetch('https://api.github.com/repos/${username}/' + name, {
|
|
630
|
+
headers: {
|
|
631
|
+
'Accept': 'application/vnd.github.v3+json',
|
|
632
|
+
'Authorization': 'Bearer ' + token
|
|
633
|
+
}
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
if (name !== currentName) return;
|
|
637
|
+
|
|
638
|
+
if (response.status === 404) {
|
|
639
|
+
setStatus('available', 'Name is available');
|
|
640
|
+
} else if (response.ok) {
|
|
641
|
+
setStatus('taken', 'Repository already exists');
|
|
642
|
+
} else {
|
|
643
|
+
setStatus('invalid', 'Error checking availability');
|
|
644
|
+
}
|
|
645
|
+
} catch (error) {
|
|
646
|
+
if (name !== currentName) return;
|
|
647
|
+
setStatus('invalid', 'Error checking availability');
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
input.addEventListener('input', (e) => {
|
|
652
|
+
const name = e.target.value.trim();
|
|
653
|
+
|
|
654
|
+
if (checkTimeout) {
|
|
655
|
+
clearTimeout(checkTimeout);
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
if (!validateName(name)) return;
|
|
659
|
+
|
|
660
|
+
checkTimeout = setTimeout(() => {
|
|
661
|
+
checkAvailability(name);
|
|
662
|
+
}, 300);
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
replaceBtn.addEventListener('click', () => {
|
|
666
|
+
replaceBtn.disabled = true;
|
|
667
|
+
replaceBtn.innerHTML = '<span style="display:flex;align-items:center;justify-content:center;gap:8px"><span class="spinner"></span>Connecting...</span>';
|
|
668
|
+
mossApi.emit('github:deploy-choice', { action: 'replace-root' });
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
customBtn.addEventListener('click', () => {
|
|
672
|
+
if (!isAvailable) return;
|
|
673
|
+
const name = input.value.trim();
|
|
674
|
+
customBtn.disabled = true;
|
|
675
|
+
customBtn.innerHTML = '<span style="display:flex;align-items:center;justify-content:center;gap:8px"><span class="spinner"></span>Creating...</span>';
|
|
676
|
+
mossApi.emit('github:deploy-choice', { action: 'custom-domain', repoName: name });
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
cancelBtn.addEventListener('click', () => {
|
|
680
|
+
mossApi.close();
|
|
681
|
+
});
|
|
682
|
+
</script>
|
|
683
|
+
</body>
|
|
684
|
+
</html>`;
|
|
685
|
+
}
|