alacritty-theme-switch
Version:
CLI utility for switching Alacritty color themes
350 lines (349 loc) • 17.4 kB
JavaScript
var __classPrivateFieldSet = (this && this.__classPrivateFieldSet) || function (receiver, state, value, kind, f) {
if (kind === "m") throw new TypeError("Private method is not writable");
if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a setter");
if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot write private member to an object whose class did not declare it");
return (kind === "a" ? f.call(receiver, value) : f ? f.value = value : state.set(receiver, value)), value;
};
var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (receiver, state, kind, f) {
if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a getter");
if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it");
return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);
};
var _GitHubClient_instances, _GitHubClient_owner, _GitHubClient_repo, _GitHubClient_ref, _GitHubClient_apiBaseUrl, _GitHubClient_rawBaseUrl, _GitHubClient_token, _GitHubClient_downloadLicense, _GitHubClient_tryDownloadLicenseFilesSequentially, _GitHubClient_tryDownloadLicenseFile, _GitHubClient_fetchJson, _GitHubClient_fetchRawContent;
import * as dntShim from "../../../_dnt.shims.js";
import { basename, join } from "../../../deps/jsr.io/@std/path/1.1.4/mod.js";
import { err, errAsync, fromPromise, fromSafePromise, ok, okAsync, Result, } from "neverthrow";
import pMap from "p-map";
import { safeEnsureDir, safeWriteFile, } from "../../utils/fs-utils.js";
import { safeParseTomlContent } from "../../utils/toml-utils.js";
import { Theme } from "../theme.js";
import { FileDownloadError, GitHubApiError, InvalidRepositoryUrlError, NoLicenseFileFoundError, } from "./errors.js";
/**
* GitHub client for fetching Alacritty themes from a remote repository.
*
* This client provides methods to list and download TOML theme files from a GitHub repository.
* It uses GitHub's REST API v3 for listing files, but downloads content directly from
* raw.githubusercontent.com to avoid API rate limits.
*
* API rate limits (only applies to listing):
* - Without authentication: 60 requests per hour
* - With authentication: 5000 requests per hour
*
* Raw content downloads are not subject to API rate limits.
*
* Note: This class is not exported directly. Use the `createGitHubClient` factory function
* to create instances with proper error handling.
*
* @internal
*/
class GitHubClient {
/**
* Creates a new GitHub client for the specified repository.
*
* This constructor assumes that the owner and repo values are well-formed.
* Use the `createGitHubClient` factory function for URL parsing and validation.
*
* @param owner - GitHub repository owner
* @param repo - GitHub repository name
* @param ref - Git reference (branch, tag, or commit SHA) to use for downloads (default: "master")
* @param token - Optional GitHub personal access token for authentication
*
* @internal
*/
constructor(owner, repo, ref = "master", token) {
_GitHubClient_instances.add(this);
_GitHubClient_owner.set(this, void 0);
_GitHubClient_repo.set(this, void 0);
_GitHubClient_ref.set(this, void 0);
_GitHubClient_apiBaseUrl.set(this, "https://api.github.com");
_GitHubClient_rawBaseUrl.set(this, "https://raw.githubusercontent.com");
_GitHubClient_token.set(this, void 0);
__classPrivateFieldSet(this, _GitHubClient_owner, owner, "f");
__classPrivateFieldSet(this, _GitHubClient_repo, repo, "f");
__classPrivateFieldSet(this, _GitHubClient_ref, ref, "f");
__classPrivateFieldSet(this, _GitHubClient_token, token, "f");
}
/**
* Lists all TOML theme files in the repository.
*
* This method fetches the repository tree recursively and filters for files
* with a .toml extension. The returned themes have their path set to the
* remote path in the repository.
*
* @returns A ResultAsync containing an array of themes or an error
*/
listThemes() {
const url = `${__classPrivateFieldGet(this, _GitHubClient_apiBaseUrl, "f")}/repos/${__classPrivateFieldGet(this, _GitHubClient_owner, "f")}/${__classPrivateFieldGet(this, _GitHubClient_repo, "f")}/git/trees/HEAD?recursive=1`;
return __classPrivateFieldGet(this, _GitHubClient_instances, "m", _GitHubClient_fetchJson).call(this, url)
.map((response) => {
// Filter for TOML files only
const tomlFiles = response.tree.filter((item) => item.type === "blob" && item.path.endsWith(".toml"));
// Convert to Theme instances
// Note: brightness defaults to "dark" since we haven't downloaded the files yet
return tomlFiles.map((file) => new Theme(file.path, {}, null));
});
}
/**
* Downloads a single theme file from the repository to the specified output directory.
*
* This method fetches the file content directly from raw.githubusercontent.com,
* which is not subject to GitHub API rate limits. The file will be saved with
* its original filename in the output directory. If the output directory doesn't
* exist, it will be created.
*
* @param remotePath - Path to the theme file in the repository (e.g., "themes/monokai_pro.toml")
* @param outputPath - Local directory path where the theme should be saved
* @returns A ResultAsync containing the downloaded theme with local path or an error
*
* @example
* const client = new GitHubClient("https://github.com/alacritty/alacritty-theme");
* const result = await client.downloadTheme(
* "themes/monokai_pro.toml",
* "./my-themes"
* )
*
* if (result.isOk()) {
* console.log(`Downloaded theme to: ${result.data.path}`);
* } else {
* console.error("Download failed:", result.error);
* }
*/
downloadTheme(remotePath, outputPath) {
// Construct raw content URL
const repoUrl = `${__classPrivateFieldGet(this, _GitHubClient_rawBaseUrl, "f")}/${__classPrivateFieldGet(this, _GitHubClient_owner, "f")}/${__classPrivateFieldGet(this, _GitHubClient_repo, "f")}/`;
const url = new URL(`${repoUrl}/refs/heads/${__classPrivateFieldGet(this, _GitHubClient_ref, "f")}/${remotePath}`);
return safeEnsureDir(outputPath)
.andThen(() => __classPrivateFieldGet(this, _GitHubClient_instances, "m", _GitHubClient_fetchRawContent).call(this, url.toString()))
.andThen((content) => {
const filename = basename(remotePath);
const localPath = join(outputPath, filename);
return safeParseTomlContent(content)
.asyncAndThen((themeContent) => {
return safeWriteFile(localPath, content).map(() => {
return new Theme(localPath, themeContent, null);
});
});
});
}
/**
* Downloads all TOML theme files from the repository to the specified output directory.
*
* This method downloads each theme file sequentially from raw.githubusercontent.com, which is not
* subject to API rate limits. It also downloads the repository's LICENSE file to preserve
* proper attribution, naming it uniquely to avoid conflicts when downloading from multiple
* repositories.
*
* If the output directory doesn't exist, it will be created.
*
* @param themes - Array of themes to download (typically obtained from listThemes())
* @param outputPath - Local directory path where themes should be saved
* @param onProgress - Optional callback to report download progress
* @returns A ResultAsync containing an array of downloaded themes with local paths or an error
*
* @example
* ```typescript
* const client = new GitHubClient("https://github.com/alacritty/alacritty-theme");
* const themesResult = await client.listThemes()
*
* if (themesResult.isOk()) {
* const result = await client.downloadAllThemes(themesResult.data, "./my-themes")
*
* if (result.isOk()) {
* console.log(`Downloaded ${result.data.length} themes`);
* result.data.forEach(theme => {
* console.log(` - ${theme.label}`);
* });
* } else {
* console.error("Download failed:", result.error);
* }
* }
* ```
*
* @example With progress callback
* ```typescript
* const client = new GitHubClient("https://github.com/alacritty/alacritty-theme");
* const themesResult = await client.listThemes()
*
* if (themesResult.isOk()) {
* const result = await client.downloadAllThemes(
* themesResult.data,
* "./my-themes",
* (current, total) => {
* console.log(`Progress: ${current}/${total}`);
* }
* )
* }
* ```
*/
downloadAllThemes(themes, outputPath, onProgress) {
const downloadResultsPromises = pMap(themes, async (theme) => {
const downloaded = await this.downloadTheme(theme.path, outputPath);
onProgress?.(themes.indexOf(theme) + 1, themes.length);
return downloaded;
}, { concurrency: 10 });
const downloadResult = fromSafePromise(downloadResultsPromises)
.andThen((results) => Result.combine(results))
.andThen((themes) => {
// Try to download LICENSE, but don't fail if it doesn't exist
return __classPrivateFieldGet(this, _GitHubClient_instances, "m", _GitHubClient_downloadLicense).call(this, outputPath)
.orElse((error) => {
// Silently succeed if no LICENSE file is found
if (error._tag === "NoLicenseFileFoundError") {
return okAsync(undefined);
}
return errAsync(error);
})
.map(() => themes); // Return themes regardless of LICENSE download result
});
return downloadResult;
}
}
_GitHubClient_owner = new WeakMap(), _GitHubClient_repo = new WeakMap(), _GitHubClient_ref = new WeakMap(), _GitHubClient_apiBaseUrl = new WeakMap(), _GitHubClient_rawBaseUrl = new WeakMap(), _GitHubClient_token = new WeakMap(), _GitHubClient_instances = new WeakSet(), _GitHubClient_downloadLicense = function _GitHubClient_downloadLicense(outputPath) {
// Common license file naming conventions, in order of preference
const licenseFilenames = [
"LICENSE",
"LICENSE.md",
"LICENSE.txt",
"license",
"license.md",
"license.txt",
];
return safeEnsureDir(outputPath)
.map(() => __classPrivateFieldGet(this, _GitHubClient_instances, "m", _GitHubClient_tryDownloadLicenseFilesSequentially).call(this, licenseFilenames, outputPath))
.andThen((result) => result);
}, _GitHubClient_tryDownloadLicenseFilesSequentially =
/**
* Helper method to try downloading license files sequentially using a loop.
*
* This method iterates through each filename in the array. If a download succeeds,
* it returns Ok. If a 404 is encountered, it tries the next filename. Any other
* error is propagated immediately.
*
* @param filenames - Array of license filenames to try
* @param outputPath - Local directory path where the LICENSE should be saved
* @returns A Promise<Result> that resolves when a LICENSE is downloaded or all attempts fail
*/
async function _GitHubClient_tryDownloadLicenseFilesSequentially(filenames, outputPath) {
for (const filename of filenames) {
const result = await __classPrivateFieldGet(this, _GitHubClient_instances, "m", _GitHubClient_tryDownloadLicenseFile).call(this, filename, outputPath);
if (result.isOk()) {
return result;
}
}
return err(new NoLicenseFileFoundError());
}, _GitHubClient_tryDownloadLicenseFile = function _GitHubClient_tryDownloadLicenseFile(remoteFilename, outputPath) {
const repoUrl = `${__classPrivateFieldGet(this, _GitHubClient_rawBaseUrl, "f")}/${__classPrivateFieldGet(this, _GitHubClient_owner, "f")}/${__classPrivateFieldGet(this, _GitHubClient_repo, "f")}/`;
const url = new URL(`${repoUrl}/refs/heads/${__classPrivateFieldGet(this, _GitHubClient_ref, "f")}/${remoteFilename}`);
return __classPrivateFieldGet(this, _GitHubClient_instances, "m", _GitHubClient_fetchRawContent).call(this, url.toString()).andThen((content) => {
// Create a unique filename based on repository owner and name
const uniqueFilename = `LICENSE-${__classPrivateFieldGet(this, _GitHubClient_owner, "f")}-${__classPrivateFieldGet(this, _GitHubClient_repo, "f")}`;
const localPath = join(outputPath, uniqueFilename);
// Write LICENSE file to disk
return safeWriteFile(localPath, content);
});
}, _GitHubClient_fetchJson = function _GitHubClient_fetchJson(url) {
const headers = {
"Accept": "application/vnd.github.v3+json",
"User-Agent": "alacritty-theme-switch",
};
// Add authentication token if available
if (__classPrivateFieldGet(this, _GitHubClient_token, "f")) {
headers["Authorization"] = `Bearer ${__classPrivateFieldGet(this, _GitHubClient_token, "f")}`;
}
return safeFetch(url, (error) => new GitHubApiError(url, { cause: error }))
.map((response) => response.json()); // TODO: Add parsing/validation!
}, _GitHubClient_fetchRawContent = function _GitHubClient_fetchRawContent(url) {
return safeFetch(url, (error) => new FileDownloadError(url, { cause: error })).map((response) => response.text());
};
/**
* Fetches a URL and returns the response as a ResultAsync.
*
* @param url - URL to fetch
* @param errorMapper - Function to map fetch errors to custom errors
* @returns A ResultAsync containing the response or an error
*/
function safeFetch(url, errorMapper) {
return fromPromise(fetch(url), errorMapper).andThen((response) => {
if (!response.ok) {
const cause = new Error(`HTTP ${response.status}: ${response.statusText}`);
return errAsync(errorMapper(cause));
}
return okAsync(response);
});
}
/**
* Parses a GitHub repository URL to extract owner and repository name.
*
* Supports the following URL formats:
* - https://github.com/owner/repo
* - https://github.com/owner/repo.git
* - git@github.com:owner/repo.git
*
* @param url - GitHub repository URL
* @returns Repository information or null if URL is invalid
*/
function parseRepositoryUrl(url) {
// Handle HTTPS URLs
const httpsMatch = url.match(/^https?:\/\/github\.com\/([^/]+)\/([^/]+?)(\.git)?$/);
if (httpsMatch) {
return {
owner: httpsMatch[1],
repo: httpsMatch[2],
};
}
// Handle SSH URLs
const sshMatch = url.match(/^git@github\.com:([^/]+)\/(.+?)(\.git)?$/);
if (sshMatch) {
return {
owner: sshMatch[1],
repo: sshMatch[2],
};
}
return null;
}
/**
* Creates a new GitHub client for the specified repository.
*
* This factory function parses the repository URL and returns a Result containing
* either a GitHubClient instance or an error if the URL format is invalid.
*
* The client will automatically use a GitHub token from the GITHUB_TOKEN environment
* variable if available, which increases the API rate limit from 60 to 5000 requests/hour.
* Note that only the listing operation uses the API; downloads fetch directly from
* raw.githubusercontent.com and are not subject to rate limits.
*
* @param repositoryUrl - GitHub repository URL in one of the following formats:
* - https://github.com/owner/repo
* - https://github.com/owner/repo.git
* - git@github.com:owner/repo.git
* @param ref - Git reference (branch, tag, or commit SHA) to use for downloads (default: "master")
* @param token - Optional GitHub personal access token. If not provided, will check GITHUB_TOKEN env var
*
* @returns A Result containing the GitHubClient instance or an InvalidRepositoryUrlError
*
* @example
* import { createGitHubClient } from "./github/client.ts";
*
* const clientResult = createGitHubClient("https://github.com/alacritty/alacritty-theme");
*
* if (clientResult.isOk()) {
* const client = clientResult.data;
*
* // List all themes in the repository
* const themesResult = await client.listThemes()
* if (themesResult.isOk()) {
* console.log(`Found ${themesResult.data.length} themes`);
* }
* } else {
* console.error("Invalid repository URL:", clientResult.error);
* }
*/
export function createGitHubClient(repositoryUrl, ref = "master", token) {
const repoInfo = parseRepositoryUrl(repositoryUrl);
if (!repoInfo) {
return err(new InvalidRepositoryUrlError(repositoryUrl));
}
// Use provided token or fall back to GITHUB_TOKEN environment variable
const authToken = token ?? dntShim.Deno.env.get("GITHUB_TOKEN");
return ok(new GitHubClient(repoInfo.owner, repoInfo.repo, ref, authToken));
}