UNPKG

@indiekit/store-github

Version:

GitHub content store adaptor for Indiekit

338 lines (296 loc) 9.13 kB
import { Buffer } from "node:buffer"; import path from "node:path"; import process from "node:process"; import { IndiekitError } from "@indiekit/error"; import makeDebug from "debug"; const debug = makeDebug(`indiekit-store:github`); const defaults = { baseUrl: "https://api.github.com", branch: "main", token: process.env.GITHUB_TOKEN, }; const crudErrorMessage = ({ error, operation, filePath, branch, repo }) => { const summary = `Could not ${operation} file ${filePath} in repo ${repo}, branch ${branch}`; const details = [ `Original error message: ${error.message}`, `Ensure the GitHub token is not expired and has the necessary permissions`, `You can check your tokens here: https://github.com/settings/tokens`, ]; if (operation !== "create") { details.push(`Ensure the file exists`); } return `${summary}. ${details.join(". ")}`; }; export default class GithubStore { name = "GitHub store"; /** * @param {object} [options] - Plug-in options * @param {string} [options.user] - Username * @param {string} [options.repo] - Repository * @param {string} [options.branch] - Branch * @param {string} [options.token] - Personal access token */ constructor(options = {}) { this.options = { ...defaults, ...options }; } get environment() { return ["GITHUB_TOKEN"]; } get info() { const { repo, user } = this.options; return { name: `${user}/${repo} on GitHub`, uid: `https://github.com/${user}/${repo}`, }; } get prompts() { return [ { type: "text", name: "user", message: "What is your GitHub username?", }, { type: "text", name: "repo", message: "Which repository is your publication stored on?", }, { type: "text", name: "branch", message: "Which branch are you publishing from?", initial: defaults.branch, }, ]; } /** * @access private * @param {string} filePath - Request path * @param {string} [method] - Request method * @param {object} [body] - Request body * @returns {Promise<Response>} GitHub client interface */ async #client(filePath, method = "GET", body) { const { baseUrl, user, repo, token } = this.options; const apiPath = path.join(`repos/${user}/${repo}/contents`, filePath); const url = new URL(apiPath, baseUrl); try { const response = await fetch(url.href, { method, headers: { accept: "application/vnd.github+json", authorization: `token ${token}`, }, body: JSON.stringify(body), }); if (!response.ok) { throw new Error(response.statusText); } return response; } catch (error) { throw new IndiekitError(error.message, { cause: error.cause, plugin: this.name, status: error.status, }); } } /** * Check if file exists * @param {string} filePath - Path to file * @returns {Promise<boolean>} File exists * @see {@link https://docs.github.com/en/rest/repos/contents#get-repository-content} */ async fileExists(filePath) { const { branch, repo } = this.options; try { debug(`Try reading file ${filePath} in repo ${repo}, branch ${branch}`); await this.#client(`${filePath}?ref=${branch}`); return true; } catch { return false; } } /** * Create file * @param {string} filePath - Path to file * @param {string} content - File content * @param {object} options - Options * @param {string} options.message - Commit message * @returns {Promise<string>} Created file URL * @see {@link https://docs.github.com/en/rest/repos/contents#create-or-update-file-contents} */ async createFile(filePath, content, { message }) { const { branch, repo } = this.options; let createResponse; try { const fileExists = await this.fileExists(filePath); if (fileExists) { return; } debug(`Try creating file ${filePath} in repo ${repo}, branch ${branch}`); createResponse = await this.#client(filePath, "PUT", { branch, content: Buffer.from(content).toString("base64"), message, }); debug(`Creating file ${filePath}`); const file = await createResponse.json(); return file.content.html_url; } catch (error) { const message = crudErrorMessage({ error, operation: "create", filePath, repo, branch, }); debug(message); throw new Error(message); } } /** * Read file * @param {string} filePath - Path to file * @returns {Promise<string>} File content * @see {@link https://docs.github.com/en/rest/repos/contents#get-repository-content} */ async readFile(filePath) { const { branch, repo } = this.options; let readResponse; try { debug(`Try reading file ${filePath} in repo ${repo}, branch ${branch}`); readResponse = await this.#client(`${filePath}?ref=${branch}`); } catch (error) { const message = crudErrorMessage({ error, operation: "read", filePath, repo, branch, }); debug(message); throw new Error(message); } const { content } = await readResponse.json(); return Buffer.from(content, "base64").toString("utf8"); } /** * Update file * @param {string} filePath - Path to file * @param {string} content - File content * @param {object} options - Options * @param {string} options.message - Commit message * @param {string} [options.newPath] - New path to file * @returns {Promise<string>} Updated file URL * @see {@link https://docs.github.com/en/rest/repos/contents#create-or-update-file-contents} */ async updateFile(filePath, content, { message, newPath }) { const { branch, repo } = this.options; let readResponse; try { debug(`Try reading file ${filePath} in repo ${repo}, branch ${branch}`); readResponse = await this.#client(`${filePath}?ref=${branch}`); } catch (error) { const message = crudErrorMessage({ error, operation: "read", filePath, repo, branch, }); debug(message); throw new Error(message); } const { sha } = await readResponse.json(); const updateFilePath = newPath || filePath; let updateResponse; try { debug(`Try updating file ${filePath} in repo ${repo}, branch ${branch}`); updateResponse = await this.#client(updateFilePath, "PUT", { branch, content: Buffer.from(content).toString("base64"), message, sha: sha || false, }); debug(`Updated file ${filePath}`); } catch (error) { const message = crudErrorMessage({ error, operation: "update", filePath, repo, branch, }); debug(message); throw new Error(message); } const file = await updateResponse.json(); if (newPath) { debug(`Try deleting file ${filePath} in repo ${repo}, branch ${branch}`); await this.deleteFile(filePath, { message }); debug(`Deleted file ${filePath}`); } return file.content.html_url; } /** * Delete file * @param {string} filePath - Path to file * @param {object} options - Options * @param {string} options.message - Commit message * @returns {Promise<boolean>} File deleted * @see {@link https://docs.github.com/en/rest/repos/contents#delete-a-file} */ async deleteFile(filePath, { message }) { const repo = this.options.repo; const branch = this.options.branch; let readResponse; try { debug(`Try reading file ${filePath} in repo ${repo}, branch ${branch}`); readResponse = await this.#client(`${filePath}?ref=${branch}`); } catch (error) { const message = crudErrorMessage({ error, operation: "read", filePath, repo, branch, }); debug(message); throw new Error(message); } const { sha } = await readResponse.json(); try { debug(`Try deleting file ${filePath} in repo ${repo}, branch ${branch}`); await this.#client(filePath, "DELETE", { branch, message, sha, }); debug(`Deleted file ${filePath}`); } catch (error) { const message = crudErrorMessage({ error, operation: "delete", filePath, repo, branch, }); debug(message); throw new Error(message); } return true; } init(Indiekit) { const required_configs = ["baseUrl", "branch", "repo", "token", "user"]; for (const required of required_configs) { if (!this.options[required]) { const message = `Could not initialize ${this.name}: ${required} not set. See https://www.npmjs.com/package/@indiekit/store-github for details.`; debug(message); console.error(message); throw new Error(message); } } Indiekit.addStore(this); } }