From 7cf7960aff3ee139c6a524435e980a7fced6d271 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sun, 12 Apr 2026 09:42:02 -0400 Subject: [PATCH] feat: add git API layer with Gitea and GitHub implementations GitHost interface for reading/writing vault files via REST API. Gitea and GitHub implementations handle base64 content encoding, SHA-based updates, and directory listing. Co-Authored-By: Claude Opus 4.6 (1M context) --- extension/src/service-worker/git-host.ts | 54 +++++++++++ extension/src/service-worker/gitea.ts | 114 +++++++++++++++++++++++ extension/src/service-worker/github.ts | 108 +++++++++++++++++++++ 3 files changed, 276 insertions(+) create mode 100644 extension/src/service-worker/git-host.ts create mode 100644 extension/src/service-worker/gitea.ts create mode 100644 extension/src/service-worker/github.ts diff --git a/extension/src/service-worker/git-host.ts b/extension/src/service-worker/git-host.ts new file mode 100644 index 0000000..e856d82 --- /dev/null +++ b/extension/src/service-worker/git-host.ts @@ -0,0 +1,54 @@ +/// Abstract interface for reading/writing vault files on a git host. +/// +/// Both Gitea and GitHub expose a "repo contents" REST API that lets us +/// read, write, and delete individual files without cloning the repo. +/// This interface captures just the operations the vault needs. + +export interface GitHost { + /// Read a single file from the repo, returning its raw bytes. + readFile(path: string): Promise; + + /// Create or update a file in the repo with a commit message. + writeFile(path: string, content: Uint8Array, message: string): Promise; + + /// Delete a file from the repo with a commit message. + deleteFile(path: string, message: string): Promise; + + /// List file names in a directory (non-recursive). + listDir(path: string): Promise; +} + +/// Convert a Uint8Array to a base64 string (works in service worker context). +export function uint8ArrayToBase64(bytes: Uint8Array): string { + let binary = ''; + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary); +} + +/// Convert a base64 string to a Uint8Array. +export function base64ToUint8Array(base64: string): Uint8Array { + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; +} + +/// Factory function that returns the appropriate GitHost implementation. +import { GiteaHost } from './gitea'; +import { GitHubHost } from './github'; + +export function createGitHost( + hostType: 'gitea' | 'github', + hostUrl: string, + repoPath: string, + apiToken: string, +): GitHost { + if (hostType === 'gitea') { + return new GiteaHost(hostUrl, repoPath, apiToken); + } + return new GitHubHost(repoPath, apiToken); +} diff --git a/extension/src/service-worker/gitea.ts b/extension/src/service-worker/gitea.ts new file mode 100644 index 0000000..4f9582d --- /dev/null +++ b/extension/src/service-worker/gitea.ts @@ -0,0 +1,114 @@ +import type { GitHost } from './git-host'; +import { uint8ArrayToBase64, base64ToUint8Array } from './git-host'; + +/// Gitea Contents API implementation. +/// +/// Endpoints: +/// GET {hostUrl}/api/v1/repos/{repoPath}/contents/{path} +/// POST {hostUrl}/api/v1/repos/{repoPath}/contents/{path} (create) +/// PUT {hostUrl}/api/v1/repos/{repoPath}/contents/{path} (update) +/// DELETE {hostUrl}/api/v1/repos/{repoPath}/contents/{path} +/// +/// Auth: `token {apiToken}` header. +/// Content is base64-encoded in both request and response bodies. +/// Updates and deletes require the current file SHA. + +export class GiteaHost implements GitHost { + private baseUrl: string; + private headers: Record; + + constructor(hostUrl: string, repoPath: string, apiToken: string) { + // Remove trailing slash from hostUrl + const host = hostUrl.replace(/\/+$/, ''); + this.baseUrl = `${host}/api/v1/repos/${repoPath}/contents`; + this.headers = { + 'Authorization': `token ${apiToken}`, + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }; + } + + async readFile(path: string): Promise { + const resp = await fetch(`${this.baseUrl}/${path}`, { + headers: this.headers, + }); + if (!resp.ok) { + throw new Error(`Gitea readFile ${path}: ${resp.status} ${resp.statusText}`); + } + const json = await resp.json(); + // Gitea returns base64 content with possible newlines + const clean = (json.content as string).replace(/\n/g, ''); + return base64ToUint8Array(clean); + } + + async writeFile(path: string, content: Uint8Array, message: string): Promise { + const b64 = uint8ArrayToBase64(content); + + // Try to get the current SHA for an update; if 404 it's a create. + let sha: string | null = null; + try { + const existing = await fetch(`${this.baseUrl}/${path}`, { + headers: this.headers, + }); + if (existing.ok) { + const json = await existing.json(); + sha = json.sha as string; + } + } catch { + // File does not exist — will create. + } + + const body: Record = { content: b64, message }; + if (sha) { + body.sha = sha; + } + + const method = sha ? 'PUT' : 'POST'; + const resp = await fetch(`${this.baseUrl}/${path}`, { + method, + headers: this.headers, + body: JSON.stringify(body), + }); + + if (!resp.ok) { + const text = await resp.text(); + throw new Error(`Gitea writeFile ${path}: ${resp.status} ${text}`); + } + } + + async deleteFile(path: string, message: string): Promise { + // Need the current SHA to delete. + const existing = await fetch(`${this.baseUrl}/${path}`, { + headers: this.headers, + }); + if (!existing.ok) { + throw new Error(`Gitea deleteFile ${path}: file not found (${existing.status})`); + } + const json = await existing.json(); + const sha = json.sha as string; + + const resp = await fetch(`${this.baseUrl}/${path}`, { + method: 'DELETE', + headers: this.headers, + body: JSON.stringify({ message, sha }), + }); + + if (!resp.ok) { + const text = await resp.text(); + throw new Error(`Gitea deleteFile ${path}: ${resp.status} ${text}`); + } + } + + async listDir(path: string): Promise { + const resp = await fetch(`${this.baseUrl}/${path}`, { + headers: this.headers, + }); + if (!resp.ok) { + if (resp.status === 404) return []; + throw new Error(`Gitea listDir ${path}: ${resp.status} ${resp.statusText}`); + } + const json = await resp.json(); + if (!Array.isArray(json)) return []; + return json.map((item: { name: string }) => item.name); + } +} diff --git a/extension/src/service-worker/github.ts b/extension/src/service-worker/github.ts new file mode 100644 index 0000000..7dcee89 --- /dev/null +++ b/extension/src/service-worker/github.ts @@ -0,0 +1,108 @@ +import type { GitHost } from './git-host'; +import { uint8ArrayToBase64, base64ToUint8Array } from './git-host'; + +/// GitHub Contents API implementation. +/// +/// Endpoints: +/// GET https://api.github.com/repos/{repoPath}/contents/{path} +/// PUT https://api.github.com/repos/{repoPath}/contents/{path} (create/update) +/// DELETE https://api.github.com/repos/{repoPath}/contents/{path} +/// +/// Auth: `Bearer {apiToken}` header. +/// Content is base64-encoded. Updates and deletes require the current file SHA. + +export class GitHubHost implements GitHost { + private baseUrl: string; + private headers: Record; + + constructor(repoPath: string, apiToken: string) { + this.baseUrl = `https://api.github.com/repos/${repoPath}/contents`; + this.headers = { + 'Authorization': `Bearer ${apiToken}`, + 'Content-Type': 'application/json', + 'Accept': 'application/vnd.github.v3+json', + 'X-GitHub-Api-Version': '2022-11-28', + }; + } + + async readFile(path: string): Promise { + const resp = await fetch(`${this.baseUrl}/${path}`, { + headers: this.headers, + }); + if (!resp.ok) { + throw new Error(`GitHub readFile ${path}: ${resp.status} ${resp.statusText}`); + } + const json = await resp.json(); + const clean = (json.content as string).replace(/\n/g, ''); + return base64ToUint8Array(clean); + } + + async writeFile(path: string, content: Uint8Array, message: string): Promise { + const b64 = uint8ArrayToBase64(content); + + // Try to get the current SHA for an update; if 404 it's a create. + let sha: string | null = null; + try { + const existing = await fetch(`${this.baseUrl}/${path}`, { + headers: this.headers, + }); + if (existing.ok) { + const json = await existing.json(); + sha = json.sha as string; + } + } catch { + // File does not exist — will create. + } + + const body: Record = { content: b64, message }; + if (sha) { + body.sha = sha; + } + + const resp = await fetch(`${this.baseUrl}/${path}`, { + method: 'PUT', + headers: this.headers, + body: JSON.stringify(body), + }); + + if (!resp.ok) { + const text = await resp.text(); + throw new Error(`GitHub writeFile ${path}: ${resp.status} ${text}`); + } + } + + async deleteFile(path: string, message: string): Promise { + const existing = await fetch(`${this.baseUrl}/${path}`, { + headers: this.headers, + }); + if (!existing.ok) { + throw new Error(`GitHub deleteFile ${path}: file not found (${existing.status})`); + } + const json = await existing.json(); + const sha = json.sha as string; + + const resp = await fetch(`${this.baseUrl}/${path}`, { + method: 'DELETE', + headers: this.headers, + body: JSON.stringify({ message, sha }), + }); + + if (!resp.ok) { + const text = await resp.text(); + throw new Error(`GitHub deleteFile ${path}: ${resp.status} ${text}`); + } + } + + async listDir(path: string): Promise { + const resp = await fetch(`${this.baseUrl}/${path}`, { + headers: this.headers, + }); + if (!resp.ok) { + if (resp.status === 404) return []; + throw new Error(`GitHub listDir ${path}: ${resp.status} ${resp.statusText}`); + } + const json = await resp.json(); + if (!Array.isArray(json)) return []; + return json.map((item: { name: string }) => item.name); + } +}