Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
774 lines
20 KiB
TypeScript
774 lines
20 KiB
TypeScript
/**
|
|
* GitHub OAuth Adapter - Device Flow Implementation
|
|
*
|
|
* Uses Device Authorization Grant (RFC 8628) which doesn't require a client secret.
|
|
* Perfect for desktop apps where the secret cannot be securely stored.
|
|
*
|
|
* Flow:
|
|
* 1. Request device code from GitHub
|
|
* 2. User opens browser and enters the code
|
|
* 3. Poll for access token until user completes auth
|
|
* 4. Use Gist API for sync file storage
|
|
*/
|
|
|
|
import {
|
|
SYNC_CONSTANTS,
|
|
type OAuthTokens,
|
|
type ProviderAccount,
|
|
type SyncedFile,
|
|
type GitHubDeviceCodeResponse,
|
|
} from '../../../domain/sync';
|
|
import { netcattyBridge } from '../netcattyBridge';
|
|
|
|
// ============================================================================
|
|
// Types
|
|
// ============================================================================
|
|
|
|
export interface GitHubUser {
|
|
id: number;
|
|
login: string;
|
|
name: string | null;
|
|
email: string | null;
|
|
avatar_url: string;
|
|
}
|
|
|
|
export interface GitHubGistFile {
|
|
filename: string;
|
|
/** Embedded body from the Gist API. Truncated at ~1 MB when `truncated` is true. */
|
|
content?: string;
|
|
truncated?: boolean;
|
|
raw_url?: string;
|
|
/** File size in UTF-8 bytes (GitHub Gist API). */
|
|
size?: number;
|
|
}
|
|
|
|
export interface GitHubGist {
|
|
id: string;
|
|
description: string;
|
|
files: Record<string, GitHubGistFile>;
|
|
created_at: string;
|
|
updated_at: string;
|
|
history?: Array<{
|
|
version: string;
|
|
committed_at: string;
|
|
}>;
|
|
}
|
|
|
|
export interface DeviceFlowState {
|
|
deviceCode: string;
|
|
userCode: string;
|
|
verificationUri: string;
|
|
expiresAt: number;
|
|
interval: number;
|
|
authAttemptId?: number;
|
|
}
|
|
|
|
const createGitHubPollId = (): string => {
|
|
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
|
return crypto.randomUUID();
|
|
}
|
|
return `github-poll-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
};
|
|
|
|
const createGitHubCancelError = (): Error => {
|
|
const error = new Error('GitHub auth cancelled');
|
|
error.name = 'AbortError';
|
|
return error;
|
|
};
|
|
|
|
const throwIfAborted = (signal?: AbortSignal): void => {
|
|
if (signal?.aborted) {
|
|
throw createGitHubCancelError();
|
|
}
|
|
};
|
|
|
|
const delayWithSignal = (ms: number, signal?: AbortSignal): Promise<void> => {
|
|
if (!signal) {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
return new Promise((resolve, reject) => {
|
|
if (signal.aborted) {
|
|
reject(createGitHubCancelError());
|
|
return;
|
|
}
|
|
|
|
const timer = setTimeout(() => {
|
|
signal.removeEventListener('abort', onAbort);
|
|
resolve();
|
|
}, ms);
|
|
|
|
const onAbort = () => {
|
|
clearTimeout(timer);
|
|
signal.removeEventListener('abort', onAbort);
|
|
reject(createGitHubCancelError());
|
|
};
|
|
|
|
signal.addEventListener('abort', onAbort, { once: true });
|
|
});
|
|
};
|
|
|
|
// ============================================================================
|
|
// Device Flow Authentication
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Start GitHub Device Flow authentication
|
|
* Returns codes for user to enter in browser
|
|
*/
|
|
export const startDeviceFlow = async (): Promise<DeviceFlowState> => {
|
|
console.log('[GitHub] Starting device flow...');
|
|
console.log('[GitHub] Client ID:', SYNC_CONSTANTS.GITHUB_CLIENT_ID);
|
|
|
|
const bridge = netcattyBridge.get();
|
|
if (bridge?.githubStartDeviceFlow) {
|
|
return bridge.githubStartDeviceFlow({
|
|
clientId: SYNC_CONSTANTS.GITHUB_CLIENT_ID,
|
|
scope: 'gist read:user',
|
|
});
|
|
}
|
|
|
|
let response: Response;
|
|
try {
|
|
response = await fetch(SYNC_CONSTANTS.GITHUB_DEVICE_CODE_URL, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Accept': 'application/json',
|
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
},
|
|
body: new URLSearchParams({
|
|
client_id: SYNC_CONSTANTS.GITHUB_CLIENT_ID,
|
|
scope: 'gist read:user',
|
|
}).toString(),
|
|
});
|
|
} catch (fetchError) {
|
|
console.error('[GitHub] Network error:', fetchError);
|
|
throw new Error(`Network error: ${fetchError instanceof Error ? fetchError.message : 'Failed to fetch'}`);
|
|
}
|
|
|
|
console.log('[GitHub] Response status:', response.status);
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text();
|
|
console.error('[GitHub] Error response:', errorText);
|
|
throw new Error(`GitHub device flow failed: ${response.status} - ${errorText}`);
|
|
}
|
|
|
|
const data: GitHubDeviceCodeResponse = await response.json();
|
|
console.log('[GitHub] Device flow started, user code:', data.user_code);
|
|
|
|
return {
|
|
deviceCode: data.device_code,
|
|
userCode: data.user_code,
|
|
verificationUri: data.verification_uri,
|
|
expiresAt: Date.now() + data.expires_in * 1000,
|
|
interval: data.interval,
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Poll for access token after user authorizes
|
|
*/
|
|
export const pollForToken = async (
|
|
deviceCode: string,
|
|
interval: number,
|
|
expiresAt: number,
|
|
onPending?: () => void,
|
|
signal?: AbortSignal
|
|
): Promise<OAuthTokens | null> => {
|
|
const pollInterval = Math.max(interval, 5) * 1000; // Minimum 5 seconds
|
|
const bridge = netcattyBridge.get();
|
|
|
|
while (Date.now() < expiresAt) {
|
|
await delayWithSignal(pollInterval, signal);
|
|
throwIfAborted(signal);
|
|
const pollId = createGitHubPollId();
|
|
const cancelPoll = () => {
|
|
void bridge?.githubCancelDeviceFlowPoll?.(pollId);
|
|
};
|
|
|
|
if (signal) {
|
|
signal.addEventListener('abort', cancelPoll, { once: true });
|
|
}
|
|
|
|
try {
|
|
let data;
|
|
try {
|
|
data = bridge?.githubPollDeviceFlowToken
|
|
? await bridge.githubPollDeviceFlowToken({
|
|
clientId: SYNC_CONSTANTS.GITHUB_CLIENT_ID,
|
|
deviceCode,
|
|
pollId,
|
|
})
|
|
: await (async () => {
|
|
const response = await fetch(SYNC_CONSTANTS.GITHUB_ACCESS_TOKEN_URL, {
|
|
method: 'POST',
|
|
signal,
|
|
headers: {
|
|
'Accept': 'application/json',
|
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
},
|
|
body: new URLSearchParams({
|
|
client_id: SYNC_CONSTANTS.GITHUB_CLIENT_ID,
|
|
device_code: deviceCode,
|
|
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
|
|
}).toString(),
|
|
});
|
|
return response.json();
|
|
})();
|
|
} catch (error) {
|
|
if (
|
|
signal?.aborted ||
|
|
(error instanceof Error &&
|
|
(error.name === 'AbortError' || error.message.toLowerCase().includes('abort')))
|
|
) {
|
|
throw createGitHubCancelError();
|
|
}
|
|
throw error;
|
|
}
|
|
|
|
throwIfAborted(signal);
|
|
|
|
if (data.access_token) {
|
|
return {
|
|
accessToken: data.access_token,
|
|
tokenType: data.token_type || 'bearer',
|
|
scope: data.scope,
|
|
};
|
|
}
|
|
|
|
if (data.error === 'authorization_pending') {
|
|
onPending?.();
|
|
continue;
|
|
}
|
|
|
|
if (data.error === 'slow_down') {
|
|
// Increase interval as requested
|
|
await delayWithSignal(5000, signal);
|
|
continue;
|
|
}
|
|
|
|
if (data.error === 'expired_token') {
|
|
throw new Error('Device code expired. Please try again.');
|
|
}
|
|
|
|
if (data.error === 'access_denied') {
|
|
throw new Error('User denied authorization.');
|
|
}
|
|
|
|
if (data.error) {
|
|
throw new Error(`GitHub auth error: ${data.error_description || data.error}`);
|
|
}
|
|
} finally {
|
|
if (signal) {
|
|
signal.removeEventListener('abort', cancelPoll);
|
|
}
|
|
}
|
|
}
|
|
|
|
throw new Error('Device code expired. Please try again.');
|
|
};
|
|
|
|
// ============================================================================
|
|
// User Info
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Get authenticated user info
|
|
*/
|
|
export const getUserInfo = async (
|
|
accessToken: string,
|
|
signal?: AbortSignal
|
|
): Promise<ProviderAccount> => {
|
|
const response = await fetch(`${SYNC_CONSTANTS.GITHUB_API_BASE}/user`, {
|
|
signal,
|
|
headers: {
|
|
'Authorization': `Bearer ${accessToken}`,
|
|
'Accept': 'application/vnd.github.v3+json',
|
|
},
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to get user info: ${response.statusText}`);
|
|
}
|
|
|
|
const user: GitHubUser = await response.json();
|
|
|
|
return {
|
|
id: String(user.id),
|
|
email: user.email || undefined,
|
|
name: user.name || user.login,
|
|
avatarUrl: user.avatar_url,
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Validate access token is still valid
|
|
*/
|
|
export const validateToken = async (accessToken: string): Promise<boolean> => {
|
|
try {
|
|
const response = await fetch(`${SYNC_CONSTANTS.GITHUB_API_BASE}/user`, {
|
|
headers: {
|
|
'Authorization': `Bearer ${accessToken}`,
|
|
'Accept': 'application/vnd.github.v3+json',
|
|
},
|
|
});
|
|
return response.ok;
|
|
} catch {
|
|
return false;
|
|
}
|
|
};
|
|
|
|
// ============================================================================
|
|
// Gist Operations
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Find existing Netcatty sync gist
|
|
*/
|
|
export const findSyncGist = async (
|
|
accessToken: string,
|
|
signal?: AbortSignal
|
|
): Promise<string | null> => {
|
|
// List user's gists and find ours
|
|
const response = await fetch(`${SYNC_CONSTANTS.GITHUB_API_BASE}/gists?per_page=100`, {
|
|
signal,
|
|
headers: {
|
|
'Authorization': `Bearer ${accessToken}`,
|
|
'Accept': 'application/vnd.github.v3+json',
|
|
},
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to list gists: ${response.statusText}`);
|
|
}
|
|
|
|
const gists: GitHubGist[] = await response.json();
|
|
|
|
const syncGist = gists.find(g =>
|
|
g.description === SYNC_CONSTANTS.GIST_DESCRIPTION &&
|
|
g.files[SYNC_CONSTANTS.SYNC_FILE_NAME]
|
|
);
|
|
|
|
return syncGist?.id || null;
|
|
};
|
|
|
|
/**
|
|
* Create a new sync gist
|
|
*/
|
|
export const createSyncGist = async (
|
|
accessToken: string,
|
|
syncedFile: SyncedFile
|
|
): Promise<string> => {
|
|
const response = await fetch(`${SYNC_CONSTANTS.GITHUB_API_BASE}/gists`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Authorization': `Bearer ${accessToken}`,
|
|
'Accept': 'application/vnd.github.v3+json',
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
description: SYNC_CONSTANTS.GIST_DESCRIPTION,
|
|
public: false,
|
|
files: {
|
|
[SYNC_CONSTANTS.SYNC_FILE_NAME]: {
|
|
content: JSON.stringify(syncedFile, null, 2),
|
|
},
|
|
},
|
|
}),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to create gist: ${response.statusText}`);
|
|
}
|
|
|
|
const gist: GitHubGist = await response.json();
|
|
return gist.id;
|
|
};
|
|
|
|
/**
|
|
* Update existing sync gist
|
|
*/
|
|
export const updateSyncGist = async (
|
|
accessToken: string,
|
|
gistId: string,
|
|
syncedFile: SyncedFile
|
|
): Promise<void> => {
|
|
const response = await fetch(`${SYNC_CONSTANTS.GITHUB_API_BASE}/gists/${gistId}`, {
|
|
method: 'PATCH',
|
|
headers: {
|
|
'Authorization': `Bearer ${accessToken}`,
|
|
'Accept': 'application/vnd.github.v3+json',
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
files: {
|
|
[SYNC_CONSTANTS.SYNC_FILE_NAME]: {
|
|
content: JSON.stringify(syncedFile, null, 2),
|
|
},
|
|
},
|
|
}),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to update gist: ${response.statusText}`);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* GitHub's Gist REST API truncates each file's embedded `content` around 1 MB
|
|
* and sets `truncated: true`. Full bodies must be fetched from `raw_url`
|
|
* (issue #2643: JSON.parse on truncated content → "Unterminated string").
|
|
*/
|
|
const fetchGistRawContent = async (
|
|
accessToken: string,
|
|
rawUrl: string,
|
|
): Promise<string> => {
|
|
const bridge = netcattyBridge.get();
|
|
if (bridge?.githubDownloadGistRawContent) {
|
|
return bridge.githubDownloadGistRawContent({ accessToken, rawUrl });
|
|
}
|
|
|
|
const response = await fetch(rawUrl, {
|
|
headers: {
|
|
Authorization: `Bearer ${accessToken}`,
|
|
Accept: 'application/vnd.github.raw',
|
|
},
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to download full gist content: ${response.statusText}`);
|
|
}
|
|
|
|
return response.text();
|
|
};
|
|
|
|
const utf8ByteLength = (value: string): number =>
|
|
new TextEncoder().encode(value).byteLength;
|
|
|
|
const gistFileNeedsRawFetch = (file: GitHubGistFile): boolean => {
|
|
if (file.truncated) return true;
|
|
// GitHub `size` is UTF-8 bytes; JS string `.length` is UTF-16 code units.
|
|
if (
|
|
typeof file.size === 'number' &&
|
|
typeof file.content === 'string' &&
|
|
file.size > utf8ByteLength(file.content)
|
|
) {
|
|
return true;
|
|
}
|
|
return false;
|
|
};
|
|
|
|
const parseSyncedFileFromGistFile = async (
|
|
accessToken: string,
|
|
file: GitHubGistFile | undefined,
|
|
): Promise<SyncedFile | null> => {
|
|
if (!file) return null;
|
|
|
|
const tryParse = (raw: string): SyncedFile => JSON.parse(raw) as SyncedFile;
|
|
|
|
if (gistFileNeedsRawFetch(file)) {
|
|
if (!file.raw_url) {
|
|
throw new Error(
|
|
'GitHub Gist sync file is truncated and no raw URL is available. ' +
|
|
'The vault may exceed GitHub Gist size limits.',
|
|
);
|
|
}
|
|
return tryParse(await fetchGistRawContent(accessToken, file.raw_url));
|
|
}
|
|
|
|
if (!file.content) return null;
|
|
|
|
try {
|
|
return tryParse(file.content);
|
|
} catch (error) {
|
|
// Defensive path for incomplete embedded JSON without truncated=true (#2643).
|
|
if (error instanceof SyntaxError && file.raw_url) {
|
|
return tryParse(await fetchGistRawContent(accessToken, file.raw_url));
|
|
}
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Download sync file from gist
|
|
*/
|
|
export const downloadSyncGist = async (
|
|
accessToken: string,
|
|
gistId: string
|
|
): Promise<SyncedFile | null> => {
|
|
const response = await fetch(`${SYNC_CONSTANTS.GITHUB_API_BASE}/gists/${gistId}`, {
|
|
headers: {
|
|
'Authorization': `Bearer ${accessToken}`,
|
|
'Accept': 'application/vnd.github.v3+json',
|
|
},
|
|
});
|
|
|
|
if (!response.ok) {
|
|
if (response.status === 404) {
|
|
return null;
|
|
}
|
|
throw new Error(`Failed to download gist: ${response.statusText}`);
|
|
}
|
|
|
|
const gist: GitHubGist = await response.json();
|
|
return parseSyncedFileFromGistFile(accessToken, gist.files[SYNC_CONSTANTS.SYNC_FILE_NAME]);
|
|
};
|
|
|
|
/**
|
|
* Delete sync gist
|
|
*/
|
|
export const deleteSyncGist = async (
|
|
accessToken: string,
|
|
gistId: string
|
|
): Promise<void> => {
|
|
const response = await fetch(`${SYNC_CONSTANTS.GITHUB_API_BASE}/gists/${gistId}`, {
|
|
method: 'DELETE',
|
|
headers: {
|
|
'Authorization': `Bearer ${accessToken}`,
|
|
'Accept': 'application/vnd.github.v3+json',
|
|
},
|
|
});
|
|
|
|
if (!response.ok && response.status !== 404) {
|
|
throw new Error(`Failed to delete gist: ${response.statusText}`);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Get gist revision history
|
|
*/
|
|
export const getGistHistory = async (
|
|
accessToken: string,
|
|
gistId: string
|
|
): Promise<Array<{ version: string; date: Date }>> => {
|
|
const response = await fetch(`${SYNC_CONSTANTS.GITHUB_API_BASE}/gists/${gistId}`, {
|
|
headers: {
|
|
'Authorization': `Bearer ${accessToken}`,
|
|
'Accept': 'application/vnd.github.v3+json',
|
|
},
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to get gist history: ${response.statusText}`);
|
|
}
|
|
|
|
const gist: GitHubGist = await response.json();
|
|
|
|
return (gist.history || []).map(h => ({
|
|
version: h.version,
|
|
date: new Date(h.committed_at),
|
|
}));
|
|
};
|
|
|
|
/**
|
|
* Download a specific historical revision of the sync gist.
|
|
* Uses `GET /gists/{gist_id}/{sha}` which returns the gist at that point
|
|
* in time. Returns the raw SyncedFile (still encrypted) or null if the
|
|
* revision does not contain the sync file.
|
|
*/
|
|
export const downloadGistRevision = async (
|
|
accessToken: string,
|
|
gistId: string,
|
|
sha: string,
|
|
): Promise<SyncedFile | null> => {
|
|
const response = await fetch(
|
|
`${SYNC_CONSTANTS.GITHUB_API_BASE}/gists/${gistId}/${sha}`,
|
|
{
|
|
headers: {
|
|
'Authorization': `Bearer ${accessToken}`,
|
|
'Accept': 'application/vnd.github.v3+json',
|
|
},
|
|
},
|
|
);
|
|
|
|
if (!response.ok) {
|
|
if (response.status === 404) return null;
|
|
throw new Error(`Failed to download gist revision: ${response.statusText}`);
|
|
}
|
|
|
|
const gist: GitHubGist = await response.json();
|
|
return parseSyncedFileFromGistFile(accessToken, gist.files[SYNC_CONSTANTS.SYNC_FILE_NAME]);
|
|
};
|
|
|
|
// ============================================================================
|
|
// GitHub Adapter Class
|
|
// ============================================================================
|
|
|
|
export class GitHubAdapter {
|
|
private accessToken: string | null = null;
|
|
private gistId: string | null = null;
|
|
private account: ProviderAccount | null = null;
|
|
|
|
constructor(tokens?: OAuthTokens, gistId?: string) {
|
|
if (tokens) {
|
|
this.accessToken = tokens.accessToken;
|
|
}
|
|
this.gistId = gistId || null;
|
|
}
|
|
|
|
get isAuthenticated(): boolean {
|
|
return !!this.accessToken;
|
|
}
|
|
|
|
get accountInfo(): ProviderAccount | null {
|
|
return this.account;
|
|
}
|
|
|
|
get resourceId(): string | null {
|
|
return this.gistId;
|
|
}
|
|
|
|
/**
|
|
* Start Device Flow authentication
|
|
*/
|
|
async startAuth(): Promise<DeviceFlowState> {
|
|
return startDeviceFlow();
|
|
}
|
|
|
|
/**
|
|
* Complete authentication by polling for token
|
|
*/
|
|
async completeAuth(
|
|
deviceCode: string,
|
|
interval: number,
|
|
expiresAt: number,
|
|
onPending?: () => void,
|
|
signal?: AbortSignal
|
|
): Promise<OAuthTokens> {
|
|
const tokens = await pollForToken(deviceCode, interval, expiresAt, onPending, signal);
|
|
if (!tokens) {
|
|
throw new Error('Failed to obtain access token');
|
|
}
|
|
|
|
throwIfAborted(signal);
|
|
this.accessToken = tokens.accessToken;
|
|
this.account = await getUserInfo(tokens.accessToken, signal);
|
|
throwIfAborted(signal);
|
|
|
|
return tokens;
|
|
}
|
|
|
|
/**
|
|
* Set tokens from storage
|
|
*/
|
|
async setTokens(tokens: OAuthTokens): Promise<void> {
|
|
this.accessToken = tokens.accessToken;
|
|
|
|
if (await validateToken(tokens.accessToken)) {
|
|
this.account = await getUserInfo(tokens.accessToken);
|
|
} else {
|
|
throw new Error('Token is invalid or expired');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Sign out
|
|
*/
|
|
signOut(): void {
|
|
this.accessToken = null;
|
|
this.gistId = null;
|
|
this.account = null;
|
|
}
|
|
|
|
/**
|
|
* Initialize or find sync gist
|
|
*/
|
|
async initializeSync(signal?: AbortSignal): Promise<string | null> {
|
|
if (!this.accessToken) {
|
|
throw new Error('Not authenticated');
|
|
}
|
|
|
|
this.gistId = await findSyncGist(this.accessToken, signal);
|
|
return this.gistId;
|
|
}
|
|
|
|
/**
|
|
* Upload sync file
|
|
*/
|
|
async upload(syncedFile: SyncedFile): Promise<string> {
|
|
if (!this.accessToken) {
|
|
throw new Error('Not authenticated');
|
|
}
|
|
|
|
if (this.gistId) {
|
|
await updateSyncGist(this.accessToken, this.gistId, syncedFile);
|
|
return this.gistId;
|
|
} else {
|
|
this.gistId = await createSyncGist(this.accessToken, syncedFile);
|
|
return this.gistId;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Download sync file
|
|
*/
|
|
async download(): Promise<SyncedFile | null> {
|
|
if (!this.accessToken) {
|
|
throw new Error('Not authenticated');
|
|
}
|
|
|
|
if (!this.gistId) {
|
|
this.gistId = await findSyncGist(this.accessToken);
|
|
}
|
|
|
|
if (!this.gistId) {
|
|
return null;
|
|
}
|
|
|
|
return downloadSyncGist(this.accessToken, this.gistId);
|
|
}
|
|
|
|
/**
|
|
* Delete sync data
|
|
*/
|
|
async deleteSync(): Promise<void> {
|
|
if (!this.accessToken || !this.gistId) {
|
|
return;
|
|
}
|
|
|
|
await deleteSyncGist(this.accessToken, this.gistId);
|
|
this.gistId = null;
|
|
}
|
|
|
|
/**
|
|
* Get revision history for the sync gist. Lazily discovers the gist
|
|
* ID if it hasn't been resolved yet (same pattern as `download()`).
|
|
*/
|
|
async getHistory(): Promise<Array<{ version: string; date: Date }>> {
|
|
if (!this.accessToken) return [];
|
|
if (!this.gistId) {
|
|
this.gistId = await findSyncGist(this.accessToken);
|
|
}
|
|
if (!this.gistId) return [];
|
|
return getGistHistory(this.accessToken, this.gistId);
|
|
}
|
|
|
|
/**
|
|
* Download a specific historical revision of the sync gist (still
|
|
* encrypted — the caller must decrypt it). Lazily discovers the
|
|
* gist ID if needed.
|
|
*/
|
|
async downloadRevision(sha: string): Promise<SyncedFile | null> {
|
|
if (!this.accessToken) return null;
|
|
if (!this.gistId) {
|
|
this.gistId = await findSyncGist(this.accessToken);
|
|
}
|
|
if (!this.gistId) return null;
|
|
return downloadGistRevision(this.accessToken, this.gistId, sha);
|
|
}
|
|
|
|
/**
|
|
* Get tokens for storage
|
|
*/
|
|
getTokens(): OAuthTokens | null {
|
|
if (!this.accessToken) return null;
|
|
return {
|
|
accessToken: this.accessToken,
|
|
tokenType: 'bearer',
|
|
};
|
|
}
|
|
}
|
|
|
|
export default GitHubAdapter;
|