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) <noreply@anthropic.com>
109 lines
3.4 KiB
TypeScript
109 lines
3.4 KiB
TypeScript
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<string, string>;
|
|
|
|
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<Uint8Array> {
|
|
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<void> {
|
|
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<string, unknown> = { 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<void> {
|
|
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<string[]> {
|
|
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);
|
|
}
|
|
}
|