Skip to content

Commit 23eca5e

Browse files
committed
feat: v1.1.0 stable release
Major improvements: - Profile scanning: Now prioritizes pinned repos + gists over starred repos - Package.json parsing: Detects npm dependencies for accurate tech stack - Gist import parsing: Analyzes imports in JS/TS/Python/Go/Rust/Ruby - Robust caching: 3-layer fallback (IndexedDB → localStorage → memory) - Smart cache invalidation: Pattern-based, user-specific, age-based - Stale-while-revalidate: Shows cached data while refreshing in background - Resilient token storage: Multi-layer storage with automatic fallback - Better error recovery: Retry logic, DOM verification, stale cache fallback - Token validation: Test token against GitHub API before saving Breaking: None
1 parent 1fb325b commit 23eca5e

3 files changed

Lines changed: 801 additions & 55 deletions

File tree

utils/api/github.ts

Lines changed: 205 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -3,28 +3,40 @@
33
*
44
* Supports authenticated requests via GitHub token stored in extension storage.
55
* Authenticated requests have a 5,000/hour rate limit vs 60/hour unauthenticated.
6+
*
7+
* Token storage is resilient with fallbacks:
8+
* 1. browser.storage.sync (primary - syncs across devices)
9+
* 2. browser.storage.local (fallback - local only)
10+
* 3. localStorage (fallback - works in content scripts)
11+
* 4. Memory (last resort - session only)
612
*/
713

814
import { CACHE_TTL } from '../cache';
915

16+
// Storage keys
17+
const TOKEN_KEY = 'githubToken';
18+
const TOKEN_LS_KEY = 'gitstack-github-token';
19+
1020
// In-memory cache for repository tree
1121
const treeCache = new Map<string, { paths: string[]; timestamp: number }>();
1222

1323
// Cached token to avoid repeated storage lookups
1424
let cachedToken: string | null = null;
1525
let tokenChecked = false;
26+
let storageAvailable = true;
1627

1728
// Listen for storage changes (when popup saves/removes token)
1829
try {
1930
browser.storage.onChanged.addListener((changes, areaName) => {
20-
if (areaName === 'sync' && changes.githubToken) {
21-
cachedToken = (changes.githubToken.newValue as string) ?? null;
31+
if ((areaName === 'sync' || areaName === 'local') && changes[TOKEN_KEY]) {
32+
cachedToken = (changes[TOKEN_KEY].newValue as string) ?? null;
2233
tokenChecked = true;
2334
console.log('[GitStack] Token updated from storage:', cachedToken ? 'set' : 'removed');
2435
}
2536
});
2637
} catch (e) {
2738
// Storage listener not available (e.g., in non-extension context)
39+
storageAvailable = false;
2840
}
2941

3042
export interface RepoInfo {
@@ -37,27 +49,75 @@ export interface RepoInfo {
3749
}
3850

3951
/**
40-
* Get GitHub token from extension storage
52+
* Try to get token from extension storage with retry
4153
*/
42-
async function getGitHubToken(): Promise<string | null> {
43-
if (tokenChecked) return cachedToken;
54+
async function tryExtensionStorage(retries = 2): Promise<string | null> {
55+
for (let i = 0; i < retries; i++) {
56+
try {
57+
// Try sync storage first (syncs across devices)
58+
const syncResult = await browser.storage.sync.get(TOKEN_KEY) as { [key: string]: string };
59+
if (syncResult[TOKEN_KEY]) {
60+
return syncResult[TOKEN_KEY];
61+
}
62+
63+
// Try local storage
64+
const localResult = await browser.storage.local.get(TOKEN_KEY) as { [key: string]: string };
65+
if (localResult[TOKEN_KEY]) {
66+
return localResult[TOKEN_KEY];
67+
}
68+
69+
return null;
70+
} catch (e) {
71+
if (i < retries - 1) {
72+
await new Promise(r => setTimeout(r, 100 * (i + 1)));
73+
}
74+
}
75+
}
76+
return null;
77+
}
4478

79+
/**
80+
* Try to get token from localStorage (works in content scripts)
81+
*/
82+
function tryLocalStorage(): string | null {
4583
try {
46-
// Try to get token from extension storage
47-
const result = await browser.storage.sync.get('githubToken') as { githubToken?: string };
48-
cachedToken = result.githubToken ?? null;
49-
tokenChecked = true;
84+
const token = localStorage.getItem(TOKEN_LS_KEY);
85+
return token || null;
86+
} catch {
87+
return null;
88+
}
89+
}
5090

51-
if (cachedToken) {
52-
console.log('[GitStack] Using authenticated GitHub API requests');
91+
/**
92+
* Get GitHub token from storage with fallback chain
93+
*/
94+
async function getGitHubToken(): Promise<string | null> {
95+
// Return cached if we've already checked
96+
if (tokenChecked && cachedToken) return cachedToken;
97+
98+
// 1. Try extension storage (with retry)
99+
if (storageAvailable) {
100+
const extToken = await tryExtensionStorage();
101+
if (extToken) {
102+
cachedToken = extToken;
103+
tokenChecked = true;
104+
console.log('[GitStack] Using authenticated GitHub API requests (from extension storage)');
105+
return cachedToken;
53106
}
107+
}
54108

55-
return cachedToken;
56-
} catch (e) {
57-
console.warn('[GitStack] Could not access extension storage:', e);
109+
// 2. Try localStorage fallback
110+
const lsToken = tryLocalStorage();
111+
if (lsToken) {
112+
cachedToken = lsToken;
58113
tokenChecked = true;
59-
return null;
114+
console.log('[GitStack] Using authenticated GitHub API requests (from localStorage fallback)');
115+
return cachedToken;
60116
}
117+
118+
// 3. No token found
119+
tokenChecked = true;
120+
return null;
61121
}
62122

63123
/**
@@ -236,21 +296,93 @@ export async function fetchRawFile(
236296
}
237297

238298
/**
239-
* Set GitHub token (called from popup settings)
299+
* Set GitHub token with fallback storage layers
300+
* Returns status: 'success' | 'partial' | 'memory_only' | 'failed'
240301
*/
241-
export async function setGitHubToken(token: string | null): Promise<void> {
242-
try {
243-
if (token) {
244-
await browser.storage.sync.set({ githubToken: token });
245-
cachedToken = token;
246-
} else {
247-
await browser.storage.sync.remove('githubToken');
248-
cachedToken = null;
302+
export async function setGitHubToken(token: string | null): Promise<{
303+
success: boolean;
304+
status: 'success' | 'partial' | 'memory_only' | 'failed';
305+
message: string;
306+
}> {
307+
let syncSuccess = false;
308+
let localSuccess = false;
309+
let lsSuccess = false;
310+
311+
// Always update memory cache first
312+
cachedToken = token;
313+
tokenChecked = true;
314+
315+
if (token) {
316+
// Try sync storage (best - syncs across devices)
317+
try {
318+
await browser.storage.sync.set({ [TOKEN_KEY]: token });
319+
syncSuccess = true;
320+
} catch (e) {
321+
console.warn('[GitStack] Sync storage failed:', e);
249322
}
250-
tokenChecked = true;
251-
console.log('[GitStack] GitHub token updated');
252-
} catch (e) {
253-
console.warn('[GitStack] Could not save token:', e);
323+
324+
// Try local storage (extension-only fallback)
325+
try {
326+
await browser.storage.local.set({ [TOKEN_KEY]: token });
327+
localSuccess = true;
328+
} catch (e) {
329+
console.warn('[GitStack] Local storage failed:', e);
330+
}
331+
332+
// Try localStorage (works in content scripts)
333+
try {
334+
localStorage.setItem(TOKEN_LS_KEY, token);
335+
lsSuccess = true;
336+
} catch (e) {
337+
console.warn('[GitStack] localStorage failed:', e);
338+
}
339+
} else {
340+
// Remove token from all storage layers
341+
try {
342+
await browser.storage.sync.remove(TOKEN_KEY);
343+
syncSuccess = true;
344+
} catch { }
345+
346+
try {
347+
await browser.storage.local.remove(TOKEN_KEY);
348+
localSuccess = true;
349+
} catch { }
350+
351+
try {
352+
localStorage.removeItem(TOKEN_LS_KEY);
353+
lsSuccess = true;
354+
} catch { }
355+
}
356+
357+
// Determine status
358+
if (syncSuccess || localSuccess) {
359+
console.log('[GitStack] Token saved to extension storage');
360+
return {
361+
success: true,
362+
status: 'success',
363+
message: 'Token saved successfully'
364+
};
365+
} else if (lsSuccess) {
366+
console.log('[GitStack] Token saved to localStorage only (extension storage unavailable)');
367+
return {
368+
success: true,
369+
status: 'partial',
370+
message: 'Token saved (may not sync across devices)'
371+
};
372+
} else if (token) {
373+
// Only memory worked
374+
console.log('[GitStack] Token stored in memory only (all storage failed)');
375+
return {
376+
success: true,
377+
status: 'memory_only',
378+
message: 'Token active for this session only'
379+
};
380+
} else {
381+
return {
382+
success: true,
383+
status: 'success',
384+
message: 'Token removed'
385+
};
254386
}
255387
}
256388

@@ -261,3 +393,48 @@ export async function hasGitHubToken(): Promise<boolean> {
261393
const token = await getGitHubToken();
262394
return token !== null && token.length > 0;
263395
}
396+
397+
/**
398+
* Validate token by making a test API call
399+
*/
400+
export async function validateGitHubToken(token: string): Promise<{
401+
valid: boolean;
402+
scopes: string[];
403+
rateLimit: number;
404+
error?: string;
405+
}> {
406+
try {
407+
const res = await fetch('https://api.github.com/user', {
408+
headers: {
409+
'Authorization': `Bearer ${token}`,
410+
'Accept': 'application/vnd.github+json',
411+
}
412+
});
413+
414+
if (res.ok) {
415+
const scopes = res.headers.get('X-OAuth-Scopes')?.split(', ') || [];
416+
const rateLimit = parseInt(res.headers.get('X-RateLimit-Limit') || '5000', 10);
417+
return { valid: true, scopes, rateLimit };
418+
} else if (res.status === 401) {
419+
return { valid: false, scopes: [], rateLimit: 0, error: 'Invalid token' };
420+
} else {
421+
return { valid: false, scopes: [], rateLimit: 0, error: `API error: ${res.status}` };
422+
}
423+
} catch (e) {
424+
return { valid: false, scopes: [], rateLimit: 0, error: 'Network error' };
425+
}
426+
}
427+
428+
/**
429+
* Clear token from all storage layers
430+
*/
431+
export async function clearGitHubToken(): Promise<void> {
432+
cachedToken = null;
433+
tokenChecked = true;
434+
435+
try { await browser.storage.sync.remove(TOKEN_KEY); } catch { }
436+
try { await browser.storage.local.remove(TOKEN_KEY); } catch { }
437+
try { localStorage.removeItem(TOKEN_LS_KEY); } catch { }
438+
439+
console.log('[GitStack] Token cleared from all storage');
440+
}

0 commit comments

Comments
 (0)