Skip to content

Commit

Permalink
feat(gh-bin): add gh-bin (#274)
Browse files Browse the repository at this point in the history
  • Loading branch information
hi-ogawa authored Jan 18, 2025
1 parent 6439b56 commit dbced82
Show file tree
Hide file tree
Showing 9 changed files with 330 additions and 1 deletion.
2 changes: 1 addition & 1 deletion packages/cpgh/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "cpgh",
"version": "0.0.3-pre.0",
"homepage": "https://github.com/hi-ogawa/js-utils/tree/main/packages/cp-gh",
"homepage": "https://github.com/hi-ogawa/js-utils/tree/main/packages/cpgh",
"repository": {
"type": "git",
"url": "https://github.com/hi-ogawa/js-utils",
Expand Down
30 changes: 30 additions & 0 deletions packages/gh-bin/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# gh-bin

Download and install single file executables from GitHub release pages. Inspired by https://github.com/marcosnils/bin.

## usage

<!--
%template-input-start:help%
```txt
$ npx gh-bin --help
{%shell node ./bin/cli.js --help %}
```
%template-input-end:help%
-->

<!-- %template-output-start:help% -->

```txt
$ npx gh-bin --help
[email protected]
Usage:
npx gh-bin https://github.com/<owner>/<repo>
npx gh-bin https://github.com/yt-dlp/yt-dlp
npx gh-bin https://github.com/yt-dlp/yt-dlp/releases/tag/2025.01.15
```

<!-- %template-output-end:help% -->
2 changes: 2 additions & 0 deletions packages/gh-bin/bin/cli.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
#!/usr/bin/env node
import("../dist/cli.js");
33 changes: 33 additions & 0 deletions packages/gh-bin/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
{
"name": "gh-bin",
"version": "0.1.1",
"homepage": "https://github.com/hi-ogawa/js-utils/tree/main/packages/gh-bin",
"repository": {
"type": "git",
"url": "https://github.com/hi-ogawa/js-utils",
"directory": "packages/gh-bin"
},
"license": "MIT",
"type": "module",
"bin": "./bin/cli.js",
"files": ["bin", "dist"],
"scripts": {
"dev": "tsup --watch",
"build": "tsup",
"cli": "tsx ./src/cli.ts",
"test": "vitest",
"docs-update": "inline-template ./README.md && prettier -w ./README.md",
"prepack": "tsup --clean && pnpm docs-update"
},
"dependencies": {
"@clack/prompts": "^0.9.1",
"adm-zip": "^0.5.16",
"nanotar": "^0.1.1"
},
"devDependencies": {
"@types/adm-zip": "^0.5.7"
},
"volta": {
"extends": "../../package.json"
}
}
6 changes: 6 additions & 0 deletions packages/gh-bin/src/cli.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { $ } from "@hiogawa/utils-node";
import { it } from "vitest";

it("basic", async () => {
await $`pnpm cli -h`;
});
189 changes: 189 additions & 0 deletions packages/gh-bin/src/cli.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
import assert from "node:assert";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import process from "node:process";
import { Readable } from "node:stream";
import { name, version } from "../package.json";

const HELP = `\
${name}@${version}
Usage:
npx gh-bin https://github.com/<owner>/<repo>
npx gh-bin https://github.com/yt-dlp/yt-dlp
npx gh-bin https://github.com/yt-dlp/yt-dlp/releases/tag/2025.01.15
`;

const GITHUB_RELEASE_RE = new RegExp(
String.raw`^https://github.com/([^/]+)/([^/]+)(?:/releases/tag/([^/]+))?$`
);

async function main() {
const args = process.argv.slice(2);
if (args.includes("-h") || args.includes("--help") || args.length !== 1) {
console.log(HELP);
return;
}

// https://github.com/bombshell-dev/clack/tree/main/packages/prompts
const prompts = await import("@clack/prompts");

// parse github url
const input = args[0];
const match = input.replace(/\/+$/, "").match(GITHUB_RELEASE_RE);
if (!match) {
console.error(`[ERROR] Invalid input '${input}'\n`);
process.exit(1);
}
let [, owner, repo, tag] = match;

if (!tag) {
// find a latest tag
// https://docs.github.com/en/rest/releases/releases?apiVersion=2022-11-28#get-the-latest-release
const latestRelease = await fetchGhApi(
`https://api.github.com/repos/${owner}/${repo}/releases/latest`
);
tag = latestRelease.tag_name;
console.log(`Found the latest release ${tag}`);
}

// find release assets
// https://docs.github.com/en/rest/releases/assets?apiVersion=2022-11-28#list-release-assets
const release = await fetchGhApi(
`https://api.github.com/repos/${owner}/${repo}/releases/tags/${tag}`
);
const assets = release.assets;
if (!assets || assets.length === 0) {
console.error(`[ERROR] No assets found for release '${tag}'\n`);
process.exit(1);
}

// prompt which files to download from assets
// TODO: reorder by matching arch/os/platform
const selectedAsset = await prompts.select<string>({
message: "Select an asset to download",
options: assets.map((asset: any) => ({
label: asset.name,
value: asset.browser_download_url,
})),
});
if (prompts.isCancel(selectedAsset)) {
return;
}

// download a selected asset
const downloadSpinner = prompts.spinner();
downloadSpinner.start(`Downloading ${selectedAsset}`);
let tmpAssetPath = path.join(
os.tmpdir(),
`gh-bin-asset-${owner}-${repo}${path.extname(selectedAsset)}`
);
try {
const res = await fetch(selectedAsset);
if (!res.ok || !res.body) {
console.error(`[ERROR] Failed to download '${selectedAsset}'\n`);
process.exit(1);
}
await fs.promises.writeFile(
tmpAssetPath,
Readable.fromWeb(res.body as any)
);
} finally {
downloadSpinner.stop(`Downloaded ${selectedAsset}`);
}

let defaultBinName = repo;

// if .zip or .tar.gz, unpack and prompt again which file to use
if (selectedAsset.endsWith(".zip")) {
// https://github.com/cthackers/adm-zip
const { default: AdmZip } = await import("adm-zip");
var zip = new AdmZip(tmpAssetPath);
var zipEntries = zip.getEntries();
const selectedEntry = await prompts.select({
message: "Select an entry to extract from zip",
options: zipEntries.map((entry) => ({
label: entry.name,
value: entry,
})),
});
if (prompts.isCancel(selectedEntry)) {
return;
}
defaultBinName = path.basename(selectedEntry.name);
let tmpZipEntryPath = path.join(
os.tmpdir(),
`gh-bin-asset-${owner}-${repo}-zip-selected-item${path.extname(selectedEntry.name)}`
);
fs.promises.writeFile(tmpZipEntryPath, selectedEntry.getData());
tmpAssetPath = tmpZipEntryPath;
} else if (
selectedAsset.endsWith(".tar.gz") ||
selectedAsset.endsWith(".tar")
) {
// https://github.com/unjs/nanotar
const nanotar = await import("nanotar");
const tarData = await fs.promises.readFile(tmpAssetPath);
const items = selectedAsset.endsWith(".tar.gz")
? await nanotar.parseTarGzip(tarData)
: nanotar.parseTar(tarData);
const selectedItem = await prompts.select({
message: "Select a file to extract from tar",
options: items.map((item) => ({
label: item.name,
value: item,
})),
});
if (prompts.isCancel(selectedItem)) {
return;
}
assert(selectedItem.data);
let tmpZipEntryPath = path.join(
os.tmpdir(),
`gh-bin-asset-${owner}-${repo}-tar-selected-item${path.extname(selectedItem.name)}`
);
defaultBinName = path.basename(selectedItem.name);
fs.promises.writeFile(tmpZipEntryPath, selectedItem.data);
tmpAssetPath = tmpZipEntryPath;
}

// prompt an executable name
const binName = await prompts.text({
message: "Input a name of executable",
initialValue: defaultBinName,
});
if (prompts.isCancel(binName)) {
return;
}

// install executable
const destDir = await findExecutablePathDirectory();
const destPath = path.join(destDir, binName);
await fs.promises.mkdir(destDir, { recursive: true });
await fs.promises.copyFile(tmpAssetPath, destPath);
await fs.promises.chmod(destPath, 0o755);
console.log(`Executable is installed in ${destPath}`);
}

async function fetchGhApi(url: string) {
const res = await fetch(url, {
headers: {
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
},
});
if (!res.ok) {
console.error(`[ERROR] Failed to fetch '${url}'\n`);
process.exit(1);
}
return res.json();
}

// find a directory to install an executable
async function findExecutablePathDirectory() {
// TODO https://github.com/marcosnils/bin/blob/94bbdcac69f74abb8b3d9e5dcc0fcb22d72d6782/pkg/config/config_unix.go#L17-L19
return path.join(os.homedir(), ".local", "bin");
}

main();
7 changes: 7 additions & 0 deletions packages/gh-bin/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"extends": "../../tsconfig.base.json",
"include": ["src"],
"compilerOptions": {
"types": ["@types/node"]
}
}
6 changes: 6 additions & 0 deletions packages/gh-bin/tsup.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { defineConfig } from "tsup";

export default defineConfig({
entry: ["src/cli.ts"],
format: ["esm"],
});
Loading

0 comments on commit dbced82

Please sign in to comment.