Built and signed on GitHub ActionsBuilt and signed on GitHub Actions
Built and signed on GitHub Actions
Enables self-upgrading of a Cliffy CLI which is distributed through GitHub Releases
This package works with DenoIt is unknown whether this package works with Cloudflare Workers, Node.js, Bun, Browsers
JSR Score
100%
Published
4 months ago (0.3.2)
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572import { colors, ensureDirSync, homedir, inflateResponse, Octokit, Provider, semver, Spinner, UpgradeCommand, walkSync, } from "./deps.ts"; import type { OctokitEndpoints } from "./deps.ts"; import type { GithubProviderOptions, UpgradeOptions } from "./deps.ts"; const OLD_VERSION_TAG = ".GHR_OLD."; type AvailableOS = typeof Deno.build.os; type OSAssetMap = { [K in AvailableOS]?: string; }; type ReleaseResponse = OctokitEndpoints["GET /repos/{owner}/{repo}/releases/tags/{tag}"]["response"]; type ReleaseParameters = OctokitEndpoints["GET /repos/{owner}/{repo}/releases/tags/{tag}"][ "parameters" ]; type AssetParameters = OctokitEndpoints["GET /repos/{owner}/{repo}/releases/assets/{asset_id}"][ "parameters" ]; /** * ERROR_CODE_MAP * A map of error codes to human-readable error messages ***/ export const ERROR_CODE_MAP = { 1: "repository must be in the format 'owner/repo'", // Provider options configured incorrectly 2: "Found old version but failed to delete", // old version found but failed to delete 3: "No asset name found for the current OS", // asset name not found in osAssetMap 4: "No asset found for the current OS", // asset not found in release 5: "Network Error: failed to fetch GitHub Release Asset Data", // fetch() failed // 5xxx errors are for fetch() errors 5404: "Failed to fetch GitHub Release Asset Data - Not Found", 5500: "Failed to fetch GitHub Release Asset - Internal Server Error", // 6xxx errors are for octokit.request(data) errors 6404: "Failed to octokit.request GitHub Release Asset Data - Not Found", 6500: "Failed to octokit.request GitHub Release Asset Data - Internal Server Error", // 7xxx errors are for octokit.request(release list) errors 7404: "Failed to octokit.request Release List from GitHub - Not Found", 7500: "Failed to octokit.request Release List from GitHub - Internal Server Error", 8: "Failed to extract archive", // inflateResponse failed 9: "Failed to stash old version", // rename running bin failed 10: "Failed to install new version", // write new bin failed }; /** * GHRError * A simple Error object which includes a code and optional metadata * @param message - A human-readable error message * @param code - A numeric error code * @param metadata - An optional object containing additional error information */ export class GHRError extends Error { code: number; metadata: Record<string, unknown>; constructor(message: string, code: number, metadata = {}) { super(message); this.code = code; this.metadata = metadata; } } type OnCompleteMetadata = { to: string; from?: string; }; type OnCompleteFinalCallback = () => void; interface GithubReleasesProviderOptions extends GithubProviderOptions { destinationDir: string; displaySpinner?: boolean; prerelease?: boolean; untar?: boolean; cleanupOld?: boolean; osAssetMap: OSAssetMap; skipAuth?: boolean; onComplete?: ( metadata: OnCompleteMetadata, cb: OnCompleteFinalCallback, ) => void | never; onError?: (error: GHRError) => void | never; } type GithubReleaseVersions = { versions: string[]; latest: string; }; function latestSemVerFirst(a: string, b: string): number { const aParsed = semver.tryParse(a); const bParsed = semver.tryParse(b); if (aParsed && bParsed) { // compare a and b in descending order return semver.compare(bParsed, aParsed); } else { return 0; // SemVer parsing failed in atleast one value, preserve order } } /** * GithubReleasesProvider * A Cliffy UpgradeProvider for GitHub Releases * @param options - An object containing the following properties: * - repository: A string in the format 'owner/repo' * - destinationDir: A string representing the directory where the release will be installed * - osAssetMap: An object mapping OS names to corresponding assets in GitHub Releases * - skipAuth: An optional boolean to skip authentication (not recommended) * - onError: An optional callback function to handle errors * - onComplete: An optional callback function to handle completion */ export class GithubReleasesProvider extends Provider { name: string = "GithubReleaseProvider"; displaySpinner: boolean = true; prerelease: boolean = false; destinationDir: string; octokit: Octokit; owner: string; repo: string; osAssetMap: OSAssetMap; cleanupOld: boolean = true; skipAuth: boolean = false; onComplete?: ( metadata: OnCompleteMetadata, cb: OnCompleteFinalCallback, ) => void | never; onError?: (error: GHRError) => void | never; constructor(options: GithubReleasesProviderOptions) { super(); const [owner, repo] = options.repository.split("/"); if (!owner || !repo) { const error = new GHRError( "repository must be in the format 'owner/repo'", 1, { repository: options.repository, }, ); this.onError?.(error); throw error; } this.owner = owner; this.repo = repo; this.destinationDir = options.destinationDir.replace("~", homedir()); ensureDirSync(this.destinationDir); this.osAssetMap = options.osAssetMap; if (options.displaySpinner === false) { this.displaySpinner = false; } if (options.prerelease === true) { this.prerelease = true; } this.skipAuth = !!options.skipAuth; const auth = this.skipAuth ? undefined : Deno.env.get("GITHUB_TOKEN") ?? Deno.env.get("GH_TOKEN"); this.octokit = new Octokit({ auth }); if (options.cleanupOld === false) { this.cleanupOld = false; } if (this.cleanupOld) { // triggering this in the provider constructor is somewhat gross // however it's the only way to ensure that the cleanup happens this.cleanOldVersions(); } this.onComplete = options?.onComplete || ((_meta: OnCompleteMetadata, _cb: OnCompleteFinalCallback) => {}); this.onError = options?.onError || ((_error: Error) => {}); } cleanOldVersions() { for (const entry of walkSync(this.destinationDir)) { if (entry.path.includes(OLD_VERSION_TAG)) { try { Deno.removeSync(entry.path); } catch (caught) { if (!(caught instanceof Deno.errors.NotFound)) { const foundButFailedToDelete = new GHRError( "Found old version but failed to delete", 2, { oldfile: entry.path, caught, }, ); this.onError?.(foundButFailedToDelete); throw foundButFailedToDelete; } } } } } getRepositoryUrl(_name: string): string { return `https://github.com/${this.owner}/${this.repo}/releases`; } getRegistryUrl(_name: string, version: string): string { return `https://github.com/${this.owner}/${this.repo}/releases/tag/${version}`; } getReleaseOctokitRequest(version: string): { path: string; opt: ReleaseParameters; } { return { path: `GET /repos/{owner}/{repo}/releases/tags/{tag}`, opt: { owner: this.owner, repo: this.repo, tag: version, }, }; } getOctokitAssetRequest(releaseResponse: ReleaseResponse): { path: string; opt: AssetParameters; } { const assetName = this.osAssetMap[Deno.build.os]; if (!assetName) { throw new GHRError("Failed to find asset name for current OS", 3, { os: Deno.build.os, osAssetMap: this.osAssetMap, }); } const asset = releaseResponse.data.assets.find( (asset: { name: string }) => asset.name === assetName, ); if (!asset) { throw new GHRError("Failed to find asset for current OS", 4, { os: Deno.build.os, assetName, assets: releaseResponse.data.assets, }); } const assetId = asset.id; // this url could be used with fetch() instead of octokit const _assetUrl = `https://api.github.com/repos/${this.owner}/${this.repo}/releases/assets/${assetId}`; return { path: `GET /repos/{owner}/{repo}/releases/assets/{asset_id}`, opt: { owner: this.owner, repo: this.repo, asset_id: assetId }, }; } // Add your custom code here async upgrade(options: UpgradeOptions): Promise<void> { let { name, from, to } = options; const os = Deno.build.os; const spinner = new Spinner({ message: `Upgrading ${colors.cyan(name)} from ${ colors.yellow( from || "?", ) } to version ${colors.cyan(to)}...`, color: "cyan", spinner: [ "▰▱▱▱▱▱▱", "▰▰▱▱▱▱▱", "▰▰▰▱▱▱▱", "▰▰▰▰▱▱▱", "▰▰▰▰▰▱▱", "▰▰▰▰▰▰▱", "▰▰▰▰▰▰▰", "▰▱▱▱▱▱▱", ], interval: 80, }); if (this.displaySpinner) { spinner.start(); } const versions = await this.getVersions(name); if (to === "latest") { to = versions.latest; } let response; // Wraps the Asset Data for inflate_response const stagingDir = Deno.makeTempDirSync(); let errorDetail = {}; if (this.skipAuth) { try { const assetName = this.osAssetMap[os]; if (!assetName) { const error = new GHRError( "Failed to find asset name for current OS", 3, { os, osAssetMap: this.osAssetMap, }, ); this.onError?.(error); throw error; } const url = `https://github.com/${this.owner}/${this.repo}/releases/download/${to}/${assetName}`; response = await fetch(url); errorDetail = { url, }; if (response.status !== 200) { throw new GHRError( "Failed to fetch GitHub Release Asset", parseInt(`5${response.status}`), { ...errorDetail, status: response.status, }, ); } } catch (caught) { const error = new GHRError( "Network Error: Failed to fetch GitHub Release Asset", 5, { ...errorDetail, caught, }, ); this.onError?.(error); throw error; } } else { const req = this.getReleaseOctokitRequest(to); let releaseResponse; // Release Metadata try { releaseResponse = await this.octokit.request(req.path, req.opt); } catch (errorFetching) { const error = new GHRError( "Failed to fetch Release metadata", parseInt(`5${errorFetching.status}`), { ...req, caught: errorFetching, }, ); this.onError?.(error); throw error; } const { path: assetReqPath, opt: assetReqOpt } = this .getOctokitAssetRequest( releaseResponse as ReleaseResponse, // if (releaseResponse.status === 200), this is safe ); let octokitAssetResponse; // Asset Data errorDetail = { assetReqPath, assetReqOpt, }; try { octokitAssetResponse = await this.octokit.request(assetReqPath, { ...assetReqOpt, headers: { Accept: "application/octet-stream", }, request: { responseType: "arraybuffer", }, }); // how costly is creating a Response? response = new Response(octokitAssetResponse.data, { status: octokitAssetResponse.status, }); } catch (errorFetching) { const error = new GHRError( "Failed to fetch GitHub Release Asset Data", parseInt(`6${errorFetching.status}`), { ...errorDetail, caught: errorFetching, }, ); this.onError?.(error); throw error; } } try { await inflateResponse(response, stagingDir, { compressionFormat: "gzip", doUntar: true, }); } catch (caught) { const error = new GHRError( `Failed to extract '${this.osAssetMap[os]}' archive`, 8, { caught, }, ); this.onError?.(error); throw error; } for (const entry of walkSync(stagingDir)) { if (entry.isFile) { const finalPath = entry.path.replace(stagingDir, this.destinationDir); try { // stash the old version Deno.renameSync(finalPath, `${finalPath}${OLD_VERSION_TAG}`); } catch (caught) { if (!(caught instanceof Deno.errors.NotFound)) { const error = new GHRError("Failed to stash old version", 9, { caught, oldfile: finalPath, }); this.onError?.(error); throw error; } } // install the new version try { Deno.renameSync(entry.path, finalPath); } catch (caught) { const error = new GHRError("Failed to install new version", 10, { caught, newfile: entry.path, }); this.onError?.(error); throw error; } if (os !== "windows") { Deno.chmodSync(finalPath, 0o755); } } } this?.onComplete?.({ to, from }, function printSuccessMessage() { spinner.stop(); const fromMsg = from ? ` from version ${colors.yellow(from)}` : ""; console.log( `Successfully upgraded ${ colors.cyan( name, ) }${fromMsg} to version ${colors.green(to)}!\n`, ); }); } async getVersions(_name: string): Promise<GithubReleaseVersions> { const url = `https://api.github.com/repos/${this.owner}/${this.repo}/releases`; let listReleasesResponse; try { listReleasesResponse = await this.octokit.request( "GET /repos/{owner}/{repo}/releases", { owner: this.owner, repo: this.repo, headers: { "X-GitHub-Api-Version": "2022-11-28", }, }, ); } catch (error) { const status = error.status; const getVersionsError = new GHRError( "Failed to octokit.request Release List from GitHub.", parseInt(`7${status}`), { status, caught: error, url, }, ); this.onError?.(getVersionsError); throw getVersionsError; } const versions = listReleasesResponse.data .filter((release) => { // never include draft releases if (release.draft) return false; // only include prereleases if the prerelease option is set to true if (release.prerelease) { if (this.prerelease) return true; // otherwise include all non-prerelease releases return false; } return true; }) .map(({ tag_name }) => tag_name) .sort(latestSemVerFirst); const latest = versions[0]; return { versions, // branches and tags latest, }; } async listVersions( name: string, currentVersion?: string | undefined, ): Promise<void> { const { versions } = await this.getVersions(name); super.printVersions(versions, currentVersion, { indent: 0 }); } } interface GithubReleasesUpgradeOptions { provider: GithubReleasesProvider; } /** * GithubReleasesUpgradeCommand * A Cliffy UpgradeCommand for upgrading software using GitHub Releases * @param options - An object containing the following properties: * - provider: A GithubReleasesProvider instance */ export class GithubReleasesUpgradeCommand extends UpgradeCommand { constructor(options: GithubReleasesUpgradeOptions) { super(options); this.option( "--pre-release, --prerelease", "Include GitHub Releases marked pre-release", () => { // this is strange, but seems to work options.provider.prerelease = true; }, ); } }