Spaces:
Sleeping
Sleeping
Download lib/github-sync/backup.ts from Leon4gr45/nodepad-hypotheses: direct link, hf CLI and curl.
- Browser
- Download file 10.5 kB
-
https://huggingface.co/spaces/Leon4gr45/nodepad-hypotheses/resolve/main/lib/github-sync/backup.ts
- Command line
-
hf download hf://spaces/Leon4gr45/nodepad-hypotheses/lib/github-sync/backup.ts
-
curl -L -o backup.ts https://huggingface.co/spaces/Leon4gr45/nodepad-hypotheses/resolve/main/lib/github-sync/backup.ts
10.5 kB
| import { mkdir, rm, writeFile } from "node:fs/promises" | |
| import path from "node:path" | |
| import { execFile } from "node:child_process" | |
| import { promisify } from "node:util" | |
| import type { Workspace } from "../domain/types" | |
| import type { NodepadStore } from "../persistence/store" | |
| import { restoreSerialized, serializeWorkspace, snapshotContainsSecrets, type SerializedWorkspace } from "./serialization" | |
| import { contentHash } from "../domain/id" | |
| const exec = promisify(execFile) | |
| export interface PushResult { commitSha: string; skipped?: boolean; conflict?: boolean; status?: "completed" | "clean" | "sync_conflict" } | |
| export interface BackupRemote { status(): Promise<{ configured: boolean; detail: string }>; pushWorkspace(snapshot: SerializedWorkspace): Promise<PushResult>; listSnapshots(workspaceId: string): Promise<{ ref: string }[]>; restoreWorkspace(workspaceId: string, ref: string): Promise<SerializedWorkspace> } | |
| export class LocalGitBackup implements BackupRemote { | |
| constructor(private repo = process.env.NODEPAD_BACKUP_REPO || path.join(process.cwd(), "data", "backups"), private base = process.env.NODEPAD_GITHUB_PATH || "nodepad-workspaces") {} | |
| async status() { return { configured: true, detail: this.repo } } | |
| async pushWorkspace(snapshot: SerializedWorkspace): Promise<PushResult> { | |
| await mkdir(this.repo, { recursive: true }); try { await exec("git", ["init", "-q"], { cwd: this.repo }) } catch {} | |
| const root = path.join(this.repo, this.base, snapshot.workspaceId); await rm(root, { recursive: true, force: true }) | |
| for (const [name, value] of Object.entries(snapshot.files)) { const file = path.join(root, name); await mkdir(path.dirname(file), { recursive: true }); await writeFile(file, value) } | |
| await exec("git", ["add", "--", this.base], { cwd: this.repo }) | |
| const dirty = await exec("git", ["diff", "--cached", "--quiet"], { cwd: this.repo }).then(() => false).catch(() => true) | |
| if (!dirty) return { commitSha: (await exec("git", ["rev-parse", "HEAD"], { cwd: this.repo })).stdout.trim(), skipped: true, status: "clean" } | |
| await exec("git", ["-c", "user.name=Nodepad", "-c", "user.email=nodepad@localhost", "commit", "-m", `nodepad(${snapshot.workspaceId}): backup`], { cwd: this.repo }) | |
| return { commitSha: (await exec("git", ["rev-parse", "HEAD"], { cwd: this.repo })).stdout.trim(), status: "completed" } | |
| } | |
| async listSnapshots() { try { return (await exec("git", ["log", "--format=%H"], { cwd: this.repo })).stdout.trim().split("\n").filter(Boolean).map(ref => ({ ref })) } catch { return [] } } | |
| async restoreWorkspace(id: string, ref: string): Promise<SerializedWorkspace> { const files = (await exec("git", ["ls-tree", "-r", "--name-only", ref, "--", `${this.base}/${id}`], { cwd: this.repo })).stdout.trim().split("\n").filter(Boolean); const output: Record<string, string> = {}; for (const file of files) output[path.relative(path.join(this.base, id), file)] = (await exec("git", ["show", `${ref}:${file}`], { cwd: this.repo, maxBuffer: 20_000_000 })).stdout; return { workspaceId: id, schemaVersion: 1, files: output } } | |
| } | |
| interface GitHubOptions { token: string; repository: string; branch: string; basePath: string; allowCreateBranch?: boolean; fetchImpl?: typeof fetch } | |
| export class GitHubBackupRemote implements BackupRemote { | |
| private fetchImpl: typeof fetch | |
| constructor(private options: GitHubOptions) { this.fetchImpl = options.fetchImpl ?? fetch } | |
| static fromEnv(fetchImpl?: typeof fetch) { | |
| const token = process.env.NODEPAD_GITHUB_TOKEN, repository = process.env.NODEPAD_GITHUB_REPOSITORY | |
| if (!token || !repository) throw new Error("NODEPAD_GITHUB_TOKEN and NODEPAD_GITHUB_REPOSITORY are required") | |
| return new GitHubBackupRemote({ token, repository, branch: process.env.NODEPAD_GITHUB_BRANCH || "nodepad-backup", basePath: process.env.NODEPAD_GITHUB_PATH || "nodepad-workspaces", allowCreateBranch: process.env.NODEPAD_GITHUB_CREATE_BRANCH !== "false", fetchImpl }) | |
| } | |
| private async request(route: string, init: RequestInit = {}, allow404 = false): Promise<any> { | |
| const response = await this.fetchImpl(`https://api.github.com/repos/${this.options.repository}${route}`, { ...init, headers: { Accept: "application/vnd.github+json", Authorization: `Bearer ${this.options.token}`, "X-GitHub-Api-Version": "2022-11-28", "Content-Type": "application/json", ...init.headers } }) | |
| if (allow404 && response.status === 404) return null | |
| if (!response.ok) { const body = await response.text(); const error = new Error(`GitHub ${response.status}: ${body}`) as Error & { status?: number }; error.status = response.status; throw error } | |
| return response.status === 204 ? null : response.json() | |
| } | |
| async status() { return { configured: Boolean(this.options.token && this.options.repository), detail: `${this.options.repository}#${this.options.branch}` } } | |
| private async branchHead() { const ref = await this.request(`/git/ref/heads/${encodeURIComponent(this.options.branch)}`, {}, true); return ref?.object?.sha as string | undefined } | |
| private async baseHead() { const repository = await this.request(""); const ref = await this.request(`/git/ref/heads/${encodeURIComponent(repository.default_branch)}`); return ref.object.sha as string } | |
| async pushWorkspace(snapshot: SerializedWorkspace): Promise<PushResult> { | |
| if (snapshotContainsSecrets(snapshot)) throw new Error("Snapshot contains secret-like content") | |
| const expectedHead = await this.branchHead(), branchMissing = !expectedHead | |
| if (branchMissing && this.options.allowCreateBranch === false) throw new Error(`Backup branch ${this.options.branch} does not exist`) | |
| const parent = expectedHead ?? await this.baseHead(), parentCommit = await this.request(`/git/commits/${parent}`) | |
| const prefix = `${this.options.basePath}/${snapshot.workspaceId}/` | |
| const entries: Array<{ path: string; mode: string; type: string; sha: string | null }> = await Promise.all(Object.entries(snapshot.files).sort(([a], [b]) => a.localeCompare(b)).map(async ([name, content]) => { | |
| const blob = await this.request("/git/blobs", { method: "POST", body: JSON.stringify({ content, encoding: "utf-8" }) }) | |
| return { path: `${prefix}${name}`, mode: "100644", type: "blob", sha: blob.sha } | |
| })) | |
| const parentTree = await this.request(`/git/trees/${parentCommit.tree.sha}?recursive=1`) | |
| const desired = new Set(entries.map(entry => entry.path)) | |
| for (const previous of parentTree.tree.filter((entry: any) => entry.type === "blob" && entry.path.startsWith(prefix))) { | |
| if (!desired.has(previous.path)) entries.push({ path: previous.path, mode: "100644", type: "blob", sha: null }) | |
| } | |
| const tree = await this.request("/git/trees", { method: "POST", body: JSON.stringify({ base_tree: parentCommit.tree.sha, tree: entries }) }) | |
| if (tree.sha === parentCommit.tree.sha) return { commitSha: parent, skipped: true, status: "clean" } | |
| const commit = await this.request("/git/commits", { method: "POST", body: JSON.stringify({ message: `nodepad(${snapshot.workspaceId}): backup`, tree: tree.sha, parents: [parent] }) }) | |
| const currentHead = await this.branchHead() | |
| if ((!branchMissing && currentHead !== expectedHead) || (branchMissing && currentHead)) return { commitSha: currentHead ?? parent, conflict: true, status: "sync_conflict" } | |
| try { | |
| if (branchMissing) await this.request("/git/refs", { method: "POST", body: JSON.stringify({ ref: `refs/heads/${this.options.branch}`, sha: commit.sha }) }) | |
| else await this.request(`/git/refs/heads/${encodeURIComponent(this.options.branch)}`, { method: "PATCH", body: JSON.stringify({ sha: commit.sha, force: false }) }) | |
| } catch (error) { | |
| if ((error as Error & { status?: number }).status === 409 || (error as Error & { status?: number }).status === 422) return { commitSha: expectedHead ?? parent, conflict: true, status: "sync_conflict" } | |
| throw error | |
| } | |
| return { commitSha: commit.sha, status: "completed" } | |
| } | |
| async listSnapshots(workspaceId: string) { const commits = await this.request(`/commits?sha=${encodeURIComponent(this.options.branch)}&path=${encodeURIComponent(`${this.options.basePath}/${workspaceId}`)}&per_page=100`, {}, true); return (commits ?? []).map((commit: any) => ({ ref: commit.sha })) } | |
| async restoreWorkspace(workspaceId: string, ref: string): Promise<SerializedWorkspace> { | |
| const commit = await this.request(`/git/commits/${encodeURIComponent(ref)}`), tree = await this.request(`/git/trees/${commit.tree.sha}?recursive=1`) | |
| const prefix = `${this.options.basePath}/${workspaceId}/`, files: Record<string, string> = {} | |
| for (const entry of tree.tree.filter((item: any) => item.type === "blob" && item.path.startsWith(prefix))) { const blob = await this.request(`/git/blobs/${entry.sha}`); files[entry.path.slice(prefix.length)] = Buffer.from(blob.content.replace(/\n/g, ""), blob.encoding === "base64" ? "base64" : "utf8").toString("utf8") } | |
| return { workspaceId, schemaVersion: 1, files } | |
| } | |
| } | |
| export const configuredBackupRemote = () => { | |
| if (process.env.NODEPAD_GITHUB_TOKEN && process.env.NODEPAD_GITHUB_REPOSITORY) return GitHubBackupRemote.fromEnv() | |
| if (process.env.NODEPAD_BACKUP_REPO) return new LocalGitBackup() | |
| throw new Error("backup_not_configured") | |
| } | |
| export async function backupWorkspace(workspace: Workspace, remote: BackupRemote = configuredBackupRemote()) { const snapshotHash=contentHash({nodes:workspace.nodes,edges:workspace.edges,evidence:workspace.evidence,hypotheses:workspace.hypotheses,syntheses:workspace.syntheses,researchTasks:workspace.researchTasks,researchResults:workspace.researchResults,brain:workspace.brain});if(workspace.backup?.snapshotHash===snapshotHash)return{commitSha:workspace.backup.lastCommitSha??"unchanged",skipped:true,status:"clean" as const,snapshotHash};const minimum = Number(process.env.NODEPAD_GITHUB_MIN_COMMIT_INTERVAL_SECONDS ?? 600) * 1000; if(workspace.backup?.lastBackupAt&&Date.now()-Date.parse(workspace.backup.lastBackupAt)<minimum)return{commitSha:workspace.backup.lastCommitSha??"debounced",skipped:true,status:"clean" as const,snapshotHash};return{...await remote.pushWorkspace(serializeWorkspace(workspace)),snapshotHash} } | |
| export async function stageRestore(store: NodepadStore, remote: BackupRemote, id: string, ref: string, newId = `${id}-restored-${Date.now()}`) { const snapshot = await remote.restoreWorkspace(id, ref); const staged = restoreSerialized(snapshot, newId); await store.saveWorkspace(staged); return staged } | |