@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/auth.ts
ADDED
|
@@ -0,0 +1,588 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub OAuth Device Flow Authentication Module
|
|
3
|
+
*
|
|
4
|
+
* Implements the OAuth 2.0 Device Authorization Grant (RFC 8628)
|
|
5
|
+
* for GitHub authentication. This flow is ideal for CLI/desktop apps
|
|
6
|
+
* that can't easily handle redirect callbacks.
|
|
7
|
+
*
|
|
8
|
+
* Flow:
|
|
9
|
+
* 1. Request device code from GitHub
|
|
10
|
+
* 2. Open browser for user to enter code
|
|
11
|
+
* 3. Poll for access token
|
|
12
|
+
* 4. Store token in git credential helper
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { openSystemBrowser, httpPost, openBrowserWithHtml, closeBrowser, onEvent } from "@symbiosis-lab/moss-api";
|
|
16
|
+
import { sleep, reportProgress } from "./utils";
|
|
17
|
+
import { storeToken, getToken, clearToken, getTokenFromGit } from "./token";
|
|
18
|
+
import type {
|
|
19
|
+
DeviceCodeResponse,
|
|
20
|
+
TokenResponse,
|
|
21
|
+
GitHubUser,
|
|
22
|
+
AuthState,
|
|
23
|
+
} from "./types";
|
|
24
|
+
|
|
25
|
+
// ============================================================================
|
|
26
|
+
// Configuration
|
|
27
|
+
// ============================================================================
|
|
28
|
+
|
|
29
|
+
/** GitHub OAuth App Client ID for moss */
|
|
30
|
+
const CLIENT_ID = "Ov23li8HTgRH8nuO16oK";
|
|
31
|
+
|
|
32
|
+
/** Required OAuth scopes for GitHub Pages deployment
|
|
33
|
+
* Note: We only need "repo" scope for gh-pages deployment since we push directly
|
|
34
|
+
* to the gh-pages branch. The "workflow" scope is NOT needed because we don't
|
|
35
|
+
* use GitHub Actions for deployment. (Bug 23 fix)
|
|
36
|
+
*/
|
|
37
|
+
const REQUIRED_SCOPES = ["repo"];
|
|
38
|
+
|
|
39
|
+
/** GitHub API endpoints */
|
|
40
|
+
const GITHUB_DEVICE_CODE_URL = "https://github.com/login/device/code";
|
|
41
|
+
const GITHUB_TOKEN_URL = "https://github.com/login/oauth/access_token";
|
|
42
|
+
const GITHUB_API_USER_URL = "https://api.github.com/user";
|
|
43
|
+
|
|
44
|
+
/** Maximum time to wait for user authorization (5 minutes) */
|
|
45
|
+
const MAX_POLL_TIME_MS = 300000;
|
|
46
|
+
|
|
47
|
+
// ============================================================================
|
|
48
|
+
// Device Flow Implementation
|
|
49
|
+
// ============================================================================
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Request a device code from GitHub
|
|
53
|
+
*
|
|
54
|
+
* Uses httpPost to bypass CORS restrictions in Tauri WebView.
|
|
55
|
+
*/
|
|
56
|
+
export async function requestDeviceCode(): Promise<DeviceCodeResponse> {
|
|
57
|
+
console.log(" Requesting device code from GitHub...");
|
|
58
|
+
|
|
59
|
+
const response = await httpPost(
|
|
60
|
+
GITHUB_DEVICE_CODE_URL,
|
|
61
|
+
{
|
|
62
|
+
client_id: CLIENT_ID,
|
|
63
|
+
scope: REQUIRED_SCOPES.join(" "),
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
headers: {
|
|
67
|
+
Accept: "application/json",
|
|
68
|
+
Origin: "https://github.com",
|
|
69
|
+
},
|
|
70
|
+
}
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
if (!response.ok) {
|
|
74
|
+
throw new Error(`Failed to request device code: ${response.status} ${response.text()}`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const data = JSON.parse(response.text());
|
|
78
|
+
|
|
79
|
+
if (data.error) {
|
|
80
|
+
throw new Error(`GitHub error: ${data.error_description || data.error}`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
console.log(` Device code received. User code: ${data.user_code}`);
|
|
84
|
+
|
|
85
|
+
return data as DeviceCodeResponse;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Poll GitHub for access token
|
|
90
|
+
*
|
|
91
|
+
* Uses httpPost to bypass CORS restrictions in Tauri WebView.
|
|
92
|
+
*
|
|
93
|
+
* Returns the token response, which may contain:
|
|
94
|
+
* - access_token: Success!
|
|
95
|
+
* - error: "authorization_pending" - Keep polling
|
|
96
|
+
* - error: "slow_down" - Increase interval
|
|
97
|
+
* - error: "expired_token" - Device code expired
|
|
98
|
+
* - error: "access_denied" - User denied authorization
|
|
99
|
+
*/
|
|
100
|
+
export async function pollForToken(
|
|
101
|
+
deviceCode: string,
|
|
102
|
+
_interval: number
|
|
103
|
+
): Promise<TokenResponse> {
|
|
104
|
+
const response = await httpPost(
|
|
105
|
+
GITHUB_TOKEN_URL,
|
|
106
|
+
{
|
|
107
|
+
client_id: CLIENT_ID,
|
|
108
|
+
device_code: deviceCode,
|
|
109
|
+
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
headers: {
|
|
113
|
+
Accept: "application/json",
|
|
114
|
+
Origin: "https://github.com",
|
|
115
|
+
},
|
|
116
|
+
}
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
if (!response.ok) {
|
|
120
|
+
throw new Error(`Failed to poll for token: ${response.status} ${response.text()}`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return JSON.parse(response.text()) as TokenResponse;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Validate an access token by calling the GitHub API
|
|
128
|
+
*/
|
|
129
|
+
export async function validateToken(token: string): Promise<{
|
|
130
|
+
valid: boolean;
|
|
131
|
+
user?: GitHubUser;
|
|
132
|
+
scopes?: string[];
|
|
133
|
+
}> {
|
|
134
|
+
try {
|
|
135
|
+
const response = await fetch(GITHUB_API_USER_URL, {
|
|
136
|
+
headers: {
|
|
137
|
+
Authorization: `Bearer ${token}`,
|
|
138
|
+
Accept: "application/vnd.github.v3+json",
|
|
139
|
+
"User-Agent": "moss-GitHub-Deployer",
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
if (!response.ok) {
|
|
144
|
+
return { valid: false };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const user = (await response.json()) as GitHubUser;
|
|
148
|
+
|
|
149
|
+
// Get scopes from response headers
|
|
150
|
+
const scopeHeader = response.headers.get("X-OAuth-Scopes") || "";
|
|
151
|
+
const scopes = scopeHeader.split(",").map((s) => s.trim()).filter(Boolean);
|
|
152
|
+
|
|
153
|
+
return { valid: true, user, scopes };
|
|
154
|
+
} catch {
|
|
155
|
+
return { valid: false };
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Check if we have required scopes
|
|
161
|
+
*/
|
|
162
|
+
export function hasRequiredScopes(scopes: string[]): boolean {
|
|
163
|
+
return REQUIRED_SCOPES.every((required) => scopes.includes(required));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ============================================================================
|
|
167
|
+
// High-Level Authentication Functions
|
|
168
|
+
// ============================================================================
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Check if user is authenticated with valid GitHub credentials
|
|
172
|
+
*
|
|
173
|
+
* Checks in order:
|
|
174
|
+
* 1. Plugin cookies (fastest - cached from previous auth)
|
|
175
|
+
* 2. Git credential helper (system-stored tokens)
|
|
176
|
+
*
|
|
177
|
+
* Note: Plugin identity and project path are auto-detected from runtime context.
|
|
178
|
+
*/
|
|
179
|
+
export async function checkAuthentication(): Promise<AuthState> {
|
|
180
|
+
console.log(" Checking GitHub authentication...");
|
|
181
|
+
|
|
182
|
+
// 1. Try to get token from plugin cookies (fastest)
|
|
183
|
+
let token = await getToken();
|
|
184
|
+
|
|
185
|
+
if (token) {
|
|
186
|
+
// Validate the cached token
|
|
187
|
+
const validation = await validateToken(token);
|
|
188
|
+
|
|
189
|
+
if (validation.valid && hasRequiredScopes(validation.scopes || [])) {
|
|
190
|
+
console.log(` Authenticated as ${validation.user?.login} (from plugin cookies)`);
|
|
191
|
+
return {
|
|
192
|
+
isAuthenticated: true,
|
|
193
|
+
username: validation.user?.login,
|
|
194
|
+
scopes: validation.scopes,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Token is invalid - clear it and try git credentials
|
|
199
|
+
console.log(" Cached token invalid, clearing...");
|
|
200
|
+
await clearToken();
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// 2. Try git credential helper (Bug 8 fix)
|
|
204
|
+
console.log(" Checking git credential helper...");
|
|
205
|
+
token = await getTokenFromGit();
|
|
206
|
+
|
|
207
|
+
if (token) {
|
|
208
|
+
const validation = await validateToken(token);
|
|
209
|
+
|
|
210
|
+
if (validation.valid && hasRequiredScopes(validation.scopes || [])) {
|
|
211
|
+
// Store in plugin cookies for faster future access
|
|
212
|
+
await storeToken(token);
|
|
213
|
+
console.log(` Authenticated as ${validation.user?.login} (from git credentials)`);
|
|
214
|
+
return {
|
|
215
|
+
isAuthenticated: true,
|
|
216
|
+
username: validation.user?.login,
|
|
217
|
+
scopes: validation.scopes,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
console.log(" Git credential token lacks required scopes or is invalid");
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
console.log(" No valid credentials found");
|
|
225
|
+
return { isAuthenticated: false };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Run the full OAuth Device Flow to authenticate the user
|
|
230
|
+
*
|
|
231
|
+
* This will:
|
|
232
|
+
* 1. Request a device code
|
|
233
|
+
* 2. Show auth UI panel with the user code
|
|
234
|
+
* 3. Open the system browser with pre-filled verification URL
|
|
235
|
+
* 4. Poll for the access token (cancellable via auth UI)
|
|
236
|
+
* 5. Store the token in plugin cookies
|
|
237
|
+
* 6. Close the auth UI panel
|
|
238
|
+
*/
|
|
239
|
+
export async function promptLogin(): Promise<boolean> {
|
|
240
|
+
try {
|
|
241
|
+
// Step 1: Request device code
|
|
242
|
+
await reportProgress("authentication", 0, 4, "Requesting authorization...");
|
|
243
|
+
const deviceCodeResponse = await requestDeviceCode();
|
|
244
|
+
|
|
245
|
+
const userCode = deviceCodeResponse.user_code;
|
|
246
|
+
const browserUrl = deviceCodeResponse.verification_uri_complete
|
|
247
|
+
?? deviceCodeResponse.verification_uri;
|
|
248
|
+
|
|
249
|
+
// Step 2: Show auth UI panel with the user code
|
|
250
|
+
await reportProgress("authentication", 1, 4, `Enter code: ${userCode}`);
|
|
251
|
+
await openBrowserWithHtml(createAuthUiHtml(userCode));
|
|
252
|
+
|
|
253
|
+
// Step 3: Open system browser with pre-filled URL
|
|
254
|
+
console.log(` Opening system browser for GitHub authorization...`);
|
|
255
|
+
console.log(` Enter code: ${userCode}`);
|
|
256
|
+
await openSystemBrowser(browserUrl);
|
|
257
|
+
|
|
258
|
+
// Step 4: Listen for cancel from auth UI
|
|
259
|
+
let cancelled = false;
|
|
260
|
+
const unlisten = await onEvent<object>("github:auth-cancel", () => {
|
|
261
|
+
cancelled = true;
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
try {
|
|
265
|
+
// Step 5: Poll for token
|
|
266
|
+
await reportProgress("authentication", 2, 4, "Waiting for authorization...");
|
|
267
|
+
const token = await waitForToken(
|
|
268
|
+
deviceCodeResponse.device_code,
|
|
269
|
+
deviceCodeResponse.interval,
|
|
270
|
+
deviceCodeResponse.expires_in * 1000,
|
|
271
|
+
() => cancelled
|
|
272
|
+
);
|
|
273
|
+
|
|
274
|
+
if (!token) {
|
|
275
|
+
console.warn(" Authorization timed out or was denied");
|
|
276
|
+
if (!cancelled) {
|
|
277
|
+
await emitAuthState("error", "Authorization timed out or was denied");
|
|
278
|
+
await closeBrowser().catch(() => {});
|
|
279
|
+
}
|
|
280
|
+
return false;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Step 6: Success — notify auth UI and store token
|
|
284
|
+
await emitAuthState("success");
|
|
285
|
+
await reportProgress("authentication", 3, 4, "Storing credentials...");
|
|
286
|
+
|
|
287
|
+
const stored = await storeToken(token);
|
|
288
|
+
if (!stored) {
|
|
289
|
+
console.warn(" Failed to store token");
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
await reportProgress("authentication", 4, 4, "Authenticated");
|
|
293
|
+
console.log(" Successfully authenticated with GitHub");
|
|
294
|
+
|
|
295
|
+
// Close auth UI panel
|
|
296
|
+
await closeBrowser();
|
|
297
|
+
|
|
298
|
+
return true;
|
|
299
|
+
} finally {
|
|
300
|
+
unlisten();
|
|
301
|
+
}
|
|
302
|
+
} catch (error) {
|
|
303
|
+
console.error(` Authentication failed: ${error}`);
|
|
304
|
+
await emitAuthState("error", String(error));
|
|
305
|
+
return false;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// ============================================================================
|
|
310
|
+
// Auth UI
|
|
311
|
+
// ============================================================================
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Emit a state transition event to the auth UI panel
|
|
315
|
+
*/
|
|
316
|
+
async function emitAuthState(phase: "success" | "error", error?: string): Promise<void> {
|
|
317
|
+
try {
|
|
318
|
+
const w = window as unknown as {
|
|
319
|
+
__TAURI__?: { event?: { emit: (name: string, payload: unknown) => Promise<void> } }
|
|
320
|
+
};
|
|
321
|
+
await w.__TAURI__?.event?.emit("github:auth-state", { phase, error });
|
|
322
|
+
} catch {
|
|
323
|
+
// Non-fatal — auth UI panel may already be closed
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Generate HTML for the auth UI panel displayed during device flow
|
|
329
|
+
*/
|
|
330
|
+
export function createAuthUiHtml(userCode: string): string {
|
|
331
|
+
return `<!DOCTYPE html>
|
|
332
|
+
<html>
|
|
333
|
+
<head>
|
|
334
|
+
<meta charset="UTF-8">
|
|
335
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
336
|
+
<title>Authorize moss on GitHub</title>
|
|
337
|
+
<style>
|
|
338
|
+
:root {
|
|
339
|
+
--bg: #0d1117;
|
|
340
|
+
--surface: #161b22;
|
|
341
|
+
--surface-hover: #21262d;
|
|
342
|
+
--text: #e6edf3;
|
|
343
|
+
--text-muted: #8b949e;
|
|
344
|
+
--success: #3fb950;
|
|
345
|
+
--error: #f85149;
|
|
346
|
+
--border: #30363d;
|
|
347
|
+
--link: #58a6ff;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
351
|
+
|
|
352
|
+
body {
|
|
353
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
|
354
|
+
background: var(--bg);
|
|
355
|
+
color: var(--text);
|
|
356
|
+
min-height: 100vh;
|
|
357
|
+
display: flex;
|
|
358
|
+
flex-direction: column;
|
|
359
|
+
align-items: center;
|
|
360
|
+
padding: 40px 24px;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
.container { width: 100%; max-width: 400px; text-align: center; }
|
|
364
|
+
|
|
365
|
+
.icon { width: 48px; height: 48px; margin-bottom: 16px; }
|
|
366
|
+
|
|
367
|
+
h1 { font-size: 22px; font-weight: 600; margin-bottom: 8px; }
|
|
368
|
+
|
|
369
|
+
.subtitle {
|
|
370
|
+
color: var(--text-muted);
|
|
371
|
+
font-size: 14px;
|
|
372
|
+
margin-bottom: 24px;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
.code-display {
|
|
376
|
+
font-family: 'SF Mono', SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace;
|
|
377
|
+
font-size: 32px;
|
|
378
|
+
letter-spacing: 0.15em;
|
|
379
|
+
font-weight: 700;
|
|
380
|
+
color: var(--text);
|
|
381
|
+
padding: 20px 24px;
|
|
382
|
+
background: var(--surface);
|
|
383
|
+
border-radius: 8px;
|
|
384
|
+
border: 1px solid var(--border);
|
|
385
|
+
margin-bottom: 16px;
|
|
386
|
+
user-select: all;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
.copy-area { margin-bottom: 32px; }
|
|
390
|
+
|
|
391
|
+
.btn-copy {
|
|
392
|
+
padding: 8px 16px;
|
|
393
|
+
font-size: 13px;
|
|
394
|
+
font-weight: 500;
|
|
395
|
+
border-radius: 6px;
|
|
396
|
+
border: 1px solid var(--border);
|
|
397
|
+
background: var(--surface);
|
|
398
|
+
color: var(--text);
|
|
399
|
+
cursor: pointer;
|
|
400
|
+
transition: background-color 0.15s;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
.btn-copy:hover { background: var(--surface-hover); }
|
|
404
|
+
.btn-copy.copied { color: var(--success); border-color: var(--success); }
|
|
405
|
+
|
|
406
|
+
.status-area {
|
|
407
|
+
display: flex;
|
|
408
|
+
align-items: center;
|
|
409
|
+
justify-content: center;
|
|
410
|
+
gap: 8px;
|
|
411
|
+
margin-bottom: 24px;
|
|
412
|
+
min-height: 24px;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
.spinner {
|
|
416
|
+
width: 16px;
|
|
417
|
+
height: 16px;
|
|
418
|
+
border: 2px solid var(--border);
|
|
419
|
+
border-top-color: var(--link);
|
|
420
|
+
border-radius: 50%;
|
|
421
|
+
animation: spin 0.8s linear infinite;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
@keyframes spin { to { transform: rotate(360deg); } }
|
|
425
|
+
|
|
426
|
+
#status-text { color: var(--text-muted); font-size: 14px; }
|
|
427
|
+
#status-text.success { color: var(--success); }
|
|
428
|
+
#status-text.error { color: var(--error); }
|
|
429
|
+
|
|
430
|
+
.btn-cancel {
|
|
431
|
+
padding: 10px 20px;
|
|
432
|
+
font-size: 14px;
|
|
433
|
+
font-weight: 500;
|
|
434
|
+
border-radius: 6px;
|
|
435
|
+
border: 1px solid var(--border);
|
|
436
|
+
background: var(--surface);
|
|
437
|
+
color: var(--text);
|
|
438
|
+
cursor: pointer;
|
|
439
|
+
transition: background-color 0.15s;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
.btn-cancel:hover { background: var(--surface-hover); }
|
|
443
|
+
|
|
444
|
+
.hidden { display: none; }
|
|
445
|
+
</style>
|
|
446
|
+
</head>
|
|
447
|
+
<body>
|
|
448
|
+
<div class="container">
|
|
449
|
+
<svg class="icon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
450
|
+
<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"/>
|
|
451
|
+
</svg>
|
|
452
|
+
|
|
453
|
+
<h1>Authorize moss on GitHub</h1>
|
|
454
|
+
<p class="subtitle">Enter this code in your browser</p>
|
|
455
|
+
|
|
456
|
+
<div class="code-display" id="user-code">${userCode}</div>
|
|
457
|
+
|
|
458
|
+
<div class="copy-area">
|
|
459
|
+
<button class="btn-copy" id="copy-btn">Copy code</button>
|
|
460
|
+
</div>
|
|
461
|
+
|
|
462
|
+
<div class="status-area">
|
|
463
|
+
<div class="spinner" id="spinner"></div>
|
|
464
|
+
<span id="status-text">Waiting for authorization...</span>
|
|
465
|
+
</div>
|
|
466
|
+
|
|
467
|
+
<button class="btn-cancel" id="cancel-btn">Cancel</button>
|
|
468
|
+
</div>
|
|
469
|
+
|
|
470
|
+
<script>
|
|
471
|
+
const copyBtn = document.getElementById('copy-btn');
|
|
472
|
+
const cancelBtn = document.getElementById('cancel-btn');
|
|
473
|
+
const spinner = document.getElementById('spinner');
|
|
474
|
+
const statusText = document.getElementById('status-text');
|
|
475
|
+
const userCode = ${JSON.stringify(userCode)};
|
|
476
|
+
|
|
477
|
+
copyBtn.addEventListener('click', async () => {
|
|
478
|
+
try {
|
|
479
|
+
await navigator.clipboard.writeText(userCode);
|
|
480
|
+
} catch {
|
|
481
|
+
const ta = document.createElement('textarea');
|
|
482
|
+
ta.value = userCode;
|
|
483
|
+
ta.style.position = 'fixed';
|
|
484
|
+
ta.style.opacity = '0';
|
|
485
|
+
document.body.appendChild(ta);
|
|
486
|
+
ta.select();
|
|
487
|
+
document.execCommand('copy');
|
|
488
|
+
document.body.removeChild(ta);
|
|
489
|
+
}
|
|
490
|
+
copyBtn.textContent = 'Copied!';
|
|
491
|
+
copyBtn.classList.add('copied');
|
|
492
|
+
setTimeout(() => {
|
|
493
|
+
copyBtn.textContent = 'Copy code';
|
|
494
|
+
copyBtn.classList.remove('copied');
|
|
495
|
+
}, 2000);
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
cancelBtn.addEventListener('click', () => {
|
|
499
|
+
mossApi.emit('github:auth-cancel', {});
|
|
500
|
+
mossApi.close();
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
const { event } = window.__TAURI__;
|
|
504
|
+
event.listen('github:auth-state', (e) => {
|
|
505
|
+
const { phase, error } = e.payload;
|
|
506
|
+
if (phase === 'success') {
|
|
507
|
+
spinner.classList.add('hidden');
|
|
508
|
+
statusText.textContent = 'Authenticated!';
|
|
509
|
+
statusText.className = 'success';
|
|
510
|
+
cancelBtn.classList.add('hidden');
|
|
511
|
+
} else if (phase === 'error') {
|
|
512
|
+
spinner.classList.add('hidden');
|
|
513
|
+
statusText.textContent = error || 'Authorization failed';
|
|
514
|
+
statusText.className = 'error';
|
|
515
|
+
}
|
|
516
|
+
});
|
|
517
|
+
</script>
|
|
518
|
+
</body>
|
|
519
|
+
</html>`;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Poll for access token until authorization is complete, cancelled, or timeout
|
|
524
|
+
*/
|
|
525
|
+
async function waitForToken(
|
|
526
|
+
deviceCode: string,
|
|
527
|
+
initialInterval: number,
|
|
528
|
+
maxWaitMs: number,
|
|
529
|
+
isCancelled: () => boolean = () => false
|
|
530
|
+
): Promise<string | null> {
|
|
531
|
+
const startTime = Date.now();
|
|
532
|
+
let interval = initialInterval;
|
|
533
|
+
|
|
534
|
+
while (Date.now() - startTime < Math.min(maxWaitMs, MAX_POLL_TIME_MS)) {
|
|
535
|
+
if (isCancelled()) return null;
|
|
536
|
+
|
|
537
|
+
// Wait for the specified interval
|
|
538
|
+
await sleep(interval * 1000);
|
|
539
|
+
|
|
540
|
+
if (isCancelled()) return null;
|
|
541
|
+
|
|
542
|
+
try {
|
|
543
|
+
const response = await pollForToken(deviceCode, interval);
|
|
544
|
+
|
|
545
|
+
if (response.access_token) {
|
|
546
|
+
return response.access_token;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
if (response.error === "authorization_pending") {
|
|
550
|
+
// User hasn't authorized yet, keep polling
|
|
551
|
+
continue;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
if (response.error === "slow_down") {
|
|
555
|
+
// GitHub wants us to slow down
|
|
556
|
+
interval += 5;
|
|
557
|
+
console.log(` Slowing down, new interval: ${interval}s`);
|
|
558
|
+
continue;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
if (response.error === "expired_token") {
|
|
562
|
+
console.warn(" Device code expired");
|
|
563
|
+
return null;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
if (response.error === "access_denied") {
|
|
567
|
+
console.warn(" User denied authorization");
|
|
568
|
+
return null;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// Unknown error
|
|
572
|
+
console.error(` Unexpected error: ${response.error}`);
|
|
573
|
+
return null;
|
|
574
|
+
} catch (error) {
|
|
575
|
+
console.error(` Poll error: ${error}`);
|
|
576
|
+
// Continue polling on network errors
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
console.warn(" Authorization timeout");
|
|
581
|
+
return null;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// ============================================================================
|
|
585
|
+
// Exports for Testing
|
|
586
|
+
// ============================================================================
|
|
587
|
+
|
|
588
|
+
export { CLIENT_ID, REQUIRED_SCOPES };
|
package/src/constants.ts
ADDED
package/src/git.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git utility functions for the GitHub Pages Publisher Plugin
|
|
3
|
+
*
|
|
4
|
+
* Pure functions for URL parsing.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Extract GitHub owner and repo from remote URL
|
|
9
|
+
*/
|
|
10
|
+
export function parseGitHubUrl(remoteUrl: string): { owner: string; repo: string } | null {
|
|
11
|
+
// Parse HTTPS URLs: https://github.com/user/repo.git
|
|
12
|
+
// Allows dots in repo name (e.g., username.github.io) but not slashes
|
|
13
|
+
const httpsMatch = remoteUrl.match(/^https:\/\/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?$/);
|
|
14
|
+
if (httpsMatch) {
|
|
15
|
+
return { owner: httpsMatch[1], repo: httpsMatch[2] };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Parse SSH URLs: git@github.com:user/repo.git
|
|
19
|
+
// Allows dots in repo name (e.g., username.github.io) but not slashes
|
|
20
|
+
const sshMatch = remoteUrl.match(/^git@github\.com:([^/]+)\/([^/]+?)(?:\.git)?$/);
|
|
21
|
+
if (sshMatch) {
|
|
22
|
+
return { owner: sshMatch[1], repo: sshMatch[2] };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Check if a repo is the root GitHub Pages repo for the given owner.
|
|
30
|
+
* Root repos follow the pattern {owner}.github.io (case-insensitive).
|
|
31
|
+
*/
|
|
32
|
+
export function isRootRepo(owner: string, repo: string): boolean {
|
|
33
|
+
return repo.toLowerCase() === `${owner.toLowerCase()}.github.io`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Build GitHub Pages URL from owner and repo name.
|
|
38
|
+
* User/org site repos (e.g., "username.github.io") serve at root.
|
|
39
|
+
*/
|
|
40
|
+
export function buildPagesUrl(owner: string, repo: string): string {
|
|
41
|
+
if (repo.toLowerCase() === `${owner.toLowerCase()}.github.io`) {
|
|
42
|
+
return `https://${owner}.github.io`;
|
|
43
|
+
}
|
|
44
|
+
return `https://${owner}.github.io/${repo}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Extract GitHub Pages URL from remote URL
|
|
49
|
+
*/
|
|
50
|
+
export function extractGitHubPagesUrl(remoteUrl: string): string {
|
|
51
|
+
const parsed = parseGitHubUrl(remoteUrl);
|
|
52
|
+
if (!parsed) {
|
|
53
|
+
throw new Error("Could not parse GitHub URL from remote");
|
|
54
|
+
}
|
|
55
|
+
// User/org site repos (e.g., "username.github.io") serve at root
|
|
56
|
+
if (parsed.repo.toLowerCase() === `${parsed.owner.toLowerCase()}.github.io`) {
|
|
57
|
+
return `https://${parsed.owner}.github.io`;
|
|
58
|
+
}
|
|
59
|
+
return `https://${parsed.owner}.github.io/${parsed.repo}`;
|
|
60
|
+
}
|