itap-cli-demo
Version:
> A lightweight, AI-powered npm package finder — directly from your terminal.
270 lines (269 loc) • 9.71 kB
JavaScript
import { select } from "@inquirer/prompts";
import figlet from "figlet";
import gradient from "gradient-string";
import fetch from "node-fetch";
import { spawn } from "child_process";
import { getKey, register, rotate } from "./auth.js";
import ora from "ora";
const API_URL = "https://itap.onrender.com";
let key = await getKey();
if (!key)
key = await register();
async function fetchJsonAuthed(path, init = {}, canRetry = true) {
init.headers = {
"Content-Type": "application/json",
...(init.headers || {}),
"x-api-key": key,
};
const res = await fetch(`${API_URL}${path}`, init);
if (res.ok) {
const t = await res.text();
return t ? JSON.parse(t) : null;
}
const raw = await res.text().catch(() => "");
let payload;
try {
payload = JSON.parse(raw);
}
catch { }
const code = payload?.error_code;
if (res.status === 401 && code === "expired" && canRetry) {
const fresh = await rotate(key);
key = fresh;
init.headers["x-api-key"] = fresh;
return fetchJsonAuthed(path, init, false);
}
if ((res.status === 401 || res.status === 429) && code === "exhausted") {
const until = payload?.expires_at
? ` (resets ${new Date(payload.expires_at).toLocaleString()})`
: "";
console.error(`❌ Quota exhausted${until}. Please wait until expiry.`);
process.exit(1);
}
if (res.status === 401 && code === "invalid" && canRetry) {
const fresh = await register();
key = fresh;
init.headers["x-api-key"] = fresh;
return fetchJsonAuthed(path, init, false);
}
console.error(payload?.error || raw || `${res.status} ${res.statusText}`);
process.exit(1);
}
/** ---------- utils ---------- */
const CHUNK_SIZE = 4;
const sortLabels = {
downloads: "downloads",
stars: "stars",
lastUpdated: "last updated",
itapScore: "itap™ score",
};
function makeHyperlink(text, url) {
return `\u001b]8;;${url}\u0007${text}\u001b]8;;\u0007`;
}
function formatNumber(n) {
return Intl.NumberFormat("en-US", { notation: "compact" }).format(n);
}
function formatDate(iso) {
const d = new Date(iso);
if (Number.isNaN(d.getTime()))
return "Unknown";
return new Intl.DateTimeFormat("en-US", { year: "numeric", month: "short", day: "numeric" }).format(d);
}
function sortPackages(by) {
return (a, b) => {
switch (by) {
case "downloads": return b.downloads - a.downloads;
case "stars": return b.stars - a.stars;
case "lastUpdated": return new Date(b.lastUpdated).getTime() - new Date(a.lastUpdated).getTime();
case "itapScore": return b.score - a.score;
}
};
}
function buildChoice(pkg) {
const starsPart = pkg.repo ? `⭐ ${formatNumber(pkg.stars)} ` : "";
const repoPart = pkg.repo ? makeHyperlink("Repo", pkg.repo) : "No repo";
return {
value: pkg.name,
name: `📦 ${pkg.name} — ${pkg.description}\n` +
` 🔗 ${repoPart} ` +
`📈 ${formatNumber(pkg.downloads)} ` +
starsPart +
`🕒 ${formatDate(pkg.lastUpdated)}`,
};
}
/** ---------- API wrappers ---------- */
export async function searchVectorDb(query) {
const data = await fetchJsonAuthed("/search", {
method: "POST",
body: JSON.stringify({ query }),
});
return data.results.map((pkg) => ({
...pkg,
lastUpdated: pkg.lastUpdated || "Unknown",
skips: pkg.skips || 0,
selects: pkg.selects || 0,
score: pkg.score || 0,
}));
}
async function storePackages(packages) {
await fetchJsonAuthed("/store-batch", {
method: "POST",
body: JSON.stringify({ packages }),
});
}
async function updatePackageScores(packages, selectedName) {
await fetchJsonAuthed("/update-scores", {
method: "POST",
body: JSON.stringify({ packages, selectedName }),
});
}
/** ---------- NPM helpers ---------- */
export async function searchNPM(query, numChunk = 0) {
const params = new URLSearchParams({ text: query, size: String(CHUNK_SIZE), from: String(numChunk * CHUNK_SIZE) });
const fullUrl = `https://registry.npmjs.com/-/v1/search?${params}`;
try {
const res = await fetch(fullUrl);
if (!res.ok)
throw new Error(`NPM registry error: ${res.status}`);
const data = (await res.json());
return Promise.all(data.objects.map(async (obj) => {
const repo = obj.package.links?.repository || obj.package.links?.homepage || "";
const stars = repo ? await getGithubStars(repo) : 0;
return {
name: obj.package.name,
version: obj.package.version,
description: obj.package.description || "No description available",
repo,
downloads: obj.downloads?.weekly ?? 0,
lastUpdated: obj.updated || "Unknown",
stars,
score: 0,
};
}));
}
catch (err) {
console.error("Error searching for package:", err.message);
return [];
}
}
async function getGithubStars(repoUrl) {
if (!repoUrl || !repoUrl.includes("github.com"))
return 0;
try {
const m = repoUrl.match(/github\.com\/([^/]+)\/([^/]+)/);
if (!m)
return 0;
const [, owner, repo] = m;
const cleanRepo = repo.replace(/\.git$/, "");
const apiUrl = `https://api.github.com/repos/${owner}/${cleanRepo}`;
const res = await fetch(apiUrl);
if (!res.ok)
throw new Error(`GitHub API error: ${res.status}`);
const json = await res.json();
return json.stargazers_count || 0;
}
catch (e) {
console.error("Error fetching GitHub stars:", e.message);
return 0;
}
}
/** ---------- CLI ops ---------- */
export async function handleQuery(query, sortArg = "stars") {
printBanner();
const results = [];
let numChunk = 0;
let resultsSource = null;
let selected = false;
let sortBy = sortArg;
const spinner = ora("Searching for packages...").start();
try {
const vectorSearchResults = await searchVectorDb(query);
if (vectorSearchResults.length > 0) {
results.push(...vectorSearchResults);
resultsSource = "vector";
}
else {
const npmSearchResults = await searchNPM(query);
if (npmSearchResults.length > 0) {
results.push(...npmSearchResults);
resultsSource = "npm";
}
else {
spinner.fail("No packages found.");
return;
}
}
spinner.succeed(`Found ${results.length} packages`);
}
catch (e) {
spinner.fail(e?.message ?? "Search failed");
throw e;
}
while (!selected) {
results.sort(sortPackages(sortBy));
const selection = (await select({
message: `Select a package (sorted by ${sortLabels[sortBy]}) — page ${numChunk + 1}`,
choices: [
...results.map(buildChoice),
{ value: "sort", name: `🔃 Change sort order (current: ${sortLabels[sortBy]})` },
{ value: "search", name: "🔍 Continue the search!" },
{ value: "exit", name: "❌ Exit" },
],
}).catch((err) => {
if (err.name === "ExitPromptError") {
console.log("goodbye.");
process.exit(0);
}
}));
switch (selection) {
case "exit":
console.log("Exiting the search.");
process.exit(0);
case "search":
numChunk++;
results.length = 0;
results.push(...(await searchNPM(query, numChunk)));
break;
case "sort": {
const newSort = (await select({
message: "Sort by:",
choices: [
{ name: "📥 Most Downloads", value: "downloads" },
{ name: "⭐ Most Stars", value: "stars" },
{ name: "🕓 Recently Updated", value: "lastUpdated" },
{ name: "💡 itap™ Score", value: "itapScore" },
],
}));
sortBy = newSort;
break;
}
default: {
if (resultsSource === "npm")
await storePackages(results);
selected = true;
console.log("Installing package...");
await updatePackageScores(results, selection);
await installPackage(selection);
}
}
}
}
export function installPackage(pkgName) {
return new Promise((resolve, reject) => {
const proc = spawn("npm", ["install", pkgName], { stdio: "inherit", shell: true });
proc.on("close", (code) => (code === 0 ? resolve() : reject(new Error(`npm install exited with code ${code}`))));
proc.on("error", reject);
});
}
export function printBanner() {
const pastel = gradient(["#FFD6EC", "#C5F3FF", "#D9F8C4", "#FFFACD"]);
figlet.text("itap", { font: "Big", horizontalLayout: "default", verticalLayout: "default" }, (err, data) => {
if (err || !data) {
console.error("figlet error:", err);
return;
}
console.clear();
console.log("\n" + pastel(data));
console.log(pastel(" is there a package?\n"));
});
}