|
| 1 | +#!/usr/bin/env bb |
| 2 | +;; spai plugin: name-chkr |
| 3 | +;; |
| 4 | +;; Checks name availability across domains, GitHub, Homebrew, npm, PyPI, crates.io. |
| 5 | +;; Takes multiple names as args, checks them all in parallel. |
| 6 | +;; |
| 7 | +;; Usage: spai name-chkr <name1> [name2] [name3] ... |
| 8 | + |
| 9 | +{:spai/args "<name> [name2] [name3] ..." |
| 10 | + :spai/returns "EDN map of availability per name across registries" |
| 11 | + :spai/example "spai name-chkr spoqe spindle hedl"} |
| 12 | + |
| 13 | +(require '[clojure.string :as str] |
| 14 | + '[clojure.pprint :as pp] |
| 15 | + '[babashka.process :as p] |
| 16 | + '[babashka.http-client :as http] |
| 17 | + '[cheshire.core :as json]) |
| 18 | + |
| 19 | +(def args *command-line-args*) |
| 20 | + |
| 21 | +(when (or (empty? args) (contains? #{"--help" "-h"} (first args))) |
| 22 | + (println "Usage: spai name-chkr <name> [name2] [name3] ...") |
| 23 | + (println) |
| 24 | + (println "Checks availability across:") |
| 25 | + (println " domains — .com .io .dev .org .app .sh") |
| 26 | + (println " github — org/user name") |
| 27 | + (println " homebrew — formula/cask") |
| 28 | + (println " npm — package") |
| 29 | + (println " pypi — package") |
| 30 | + (println " crates.io — crate") |
| 31 | + (println) |
| 32 | + (println "Examples:") |
| 33 | + (println " spai name-chkr spoqe") |
| 34 | + (println " spai name-chkr hedl spindle gearn") |
| 35 | + (System/exit 0)) |
| 36 | + |
| 37 | +(def domain-tlds ["com" "io" "dev" "org" "app" "sh"]) |
| 38 | + |
| 39 | +(defn check-http |
| 40 | + "GET url, return status code. Returns :error on exception." |
| 41 | + [url & {:keys [timeout] :or {timeout 8000}}] |
| 42 | + (try |
| 43 | + (let [resp (http/get url {:throw false |
| 44 | + :timeout timeout |
| 45 | + :headers {"User-Agent" "spai-name-chkr/1.0"}})] |
| 46 | + (:status resp)) |
| 47 | + (catch Exception e |
| 48 | + (let [msg (str e)] |
| 49 | + (cond |
| 50 | + (str/includes? msg "UnknownHost") :no-dns |
| 51 | + (str/includes? msg "ConnectTimeout") :timeout |
| 52 | + (str/includes? msg "Timeout") :timeout |
| 53 | + (str/includes? msg "Connection refused") :refused |
| 54 | + :else :error))))) |
| 55 | + |
| 56 | +(defn check-domain |
| 57 | + "Check if a domain resolves via DNS (dig)." |
| 58 | + [domain] |
| 59 | + (let [{:keys [exit out]} (p/shell {:out :string :err :string :continue true} |
| 60 | + "dig" "+short" "+time=3" "+tries=1" domain "A")] |
| 61 | + (if (and (zero? exit) (not (str/blank? out))) |
| 62 | + :taken |
| 63 | + ;; Also check AAAA |
| 64 | + (let [{:keys [exit out]} (p/shell {:out :string :err :string :continue true} |
| 65 | + "dig" "+short" "+time=3" "+tries=1" domain "AAAA")] |
| 66 | + (if (and (zero? exit) (not (str/blank? out))) |
| 67 | + :taken |
| 68 | + :available))))) |
| 69 | + |
| 70 | +(defn check-github |
| 71 | + "Search GitHub repos matching name, return top 10 by stars." |
| 72 | + [name] |
| 73 | + (try |
| 74 | + (let [resp (http/get "https://api.github.com/search/repositories" |
| 75 | + {:query-params {"q" name "sort" "stars" "order" "desc" "per_page" "10"} |
| 76 | + :throw false |
| 77 | + :timeout 8000 |
| 78 | + :headers {"User-Agent" "spai-name-chkr/1.0" |
| 79 | + "Accept" "application/vnd.github.v3+json"}}) |
| 80 | + body (when (= 200 (:status resp)) |
| 81 | + (cheshire.core/parse-string (:body resp) true))] |
| 82 | + (if body |
| 83 | + {:total (:total_count body) |
| 84 | + :hits (mapv (fn [r] |
| 85 | + {:repo (:full_name r) |
| 86 | + :stars (:stargazers_count r) |
| 87 | + :forks (:forks_count r) |
| 88 | + :description (some-> (:description r) (subs 0 (min 80 (count (:description r))))) |
| 89 | + :url (:html_url r)}) |
| 90 | + (:items body))} |
| 91 | + {:total 0 :hits [] :note (str "status:" (:status resp))})) |
| 92 | + (catch Exception e |
| 93 | + {:total 0 :hits [] :error (str e)}))) |
| 94 | + |
| 95 | +(defn check-npm |
| 96 | + "Search npm for packages matching name, return top hits." |
| 97 | + [name] |
| 98 | + (try |
| 99 | + (let [resp (http/get "https://registry.npmjs.org/-/v1/search" |
| 100 | + {:query-params {"text" name "size" "10"} |
| 101 | + :throw false |
| 102 | + :timeout 8000 |
| 103 | + :headers {"User-Agent" "spai-name-chkr/1.0"}}) |
| 104 | + body (when (= 200 (:status resp)) |
| 105 | + (json/parse-string (:body resp) true))] |
| 106 | + (if body |
| 107 | + (let [objects (:objects body)] |
| 108 | + {:total (get-in body [:total]) |
| 109 | + :exact (some #(= name (get-in % [:package :name])) objects) |
| 110 | + :hits (mapv (fn [o] |
| 111 | + (let [pkg (:package o)] |
| 112 | + {:name (:name pkg) |
| 113 | + :version (:version pkg) |
| 114 | + :description (some-> (:description pkg) (subs 0 (min 80 (count (:description pkg))))) |
| 115 | + :weekly-dl (get-in o [:score :detail :popularity])})) |
| 116 | + objects)}) |
| 117 | + {:total 0 :hits []})) |
| 118 | + (catch Exception e |
| 119 | + {:total 0 :hits [] :error (str e)}))) |
| 120 | + |
| 121 | +(defn check-pypi |
| 122 | + "Search PyPI for packages matching name, return top hits." |
| 123 | + [name] |
| 124 | + (try |
| 125 | + (let [;; Check exact match first |
| 126 | + exact-resp (http/get (str "https://pypi.org/pypi/" name "/json") |
| 127 | + {:throw false :timeout 8000 |
| 128 | + :headers {"User-Agent" "spai-name-chkr/1.0"}}) |
| 129 | + exact (when (= 200 (:status exact-resp)) |
| 130 | + (let [body (json/parse-string (:body exact-resp) true) |
| 131 | + info (:info body)] |
| 132 | + {:name (:name info) |
| 133 | + :version (:version info) |
| 134 | + :description (some-> (:summary info) (subs 0 (min 80 (count (:summary info))))) |
| 135 | + :author (:author info)})) |
| 136 | + ;; Search for similar |
| 137 | + search-resp (http/get "https://pypi.org/search/" |
| 138 | + {:query-params {"q" name} |
| 139 | + :throw false :timeout 8000 |
| 140 | + :headers {"User-Agent" "spai-name-chkr/1.0" |
| 141 | + "Accept" "application/json"}}) |
| 142 | + ;; PyPI search doesn't have a JSON API, use simple API list isn't useful |
| 143 | + ;; Just use xmlrpc or report exact match + note |
| 144 | + ] |
| 145 | + (if exact |
| 146 | + {:exact true :hit exact} |
| 147 | + {:exact false})) |
| 148 | + (catch Exception e |
| 149 | + {:exact false :error (str e)}))) |
| 150 | + |
| 151 | +(defn check-crates |
| 152 | + "Check crates.io crate existence." |
| 153 | + [name] |
| 154 | + (let [status (check-http (str "https://crates.io/api/v1/crates/" name))] |
| 155 | + (case status |
| 156 | + 200 :taken |
| 157 | + 404 :available |
| 158 | + :unknown))) |
| 159 | + |
| 160 | +(defn check-homebrew |
| 161 | + "Check Homebrew formula/cask existence." |
| 162 | + [name] |
| 163 | + (let [formula-status (check-http (str "https://formulae.brew.sh/api/formula/" name ".json")) |
| 164 | + cask-status (when (not= 200 formula-status) |
| 165 | + (check-http (str "https://formulae.brew.sh/api/cask/" name ".json")))] |
| 166 | + (cond |
| 167 | + (= 200 formula-status) :taken-formula |
| 168 | + (= 200 cask-status) :taken-cask |
| 169 | + :else :available))) |
| 170 | + |
| 171 | +(defn check-name |
| 172 | + "Check a single name across all registries. Returns EDN map." |
| 173 | + [name] |
| 174 | + (binding [*out* *err*] |
| 175 | + (println (str " checking: " name "..."))) |
| 176 | + (let [lower (str/lower-case name) |
| 177 | + ;; Fire all checks in parallel |
| 178 | + domains (into {} (map (fn [tld] |
| 179 | + [(keyword (str tld)) |
| 180 | + (future (check-domain (str lower "." tld)))]) |
| 181 | + domain-tlds)) |
| 182 | + gh-f (future (check-github lower)) |
| 183 | + npm-f (future (check-npm lower)) |
| 184 | + pypi-f (future (check-pypi lower)) |
| 185 | + crate-f (future (check-crates lower)) |
| 186 | + brew-f (future (check-homebrew lower))] |
| 187 | + |
| 188 | + {:domains (into {} (map (fn [[k v]] [k @v]) domains)) |
| 189 | + :github @gh-f |
| 190 | + :npm @npm-f |
| 191 | + :pypi @pypi-f |
| 192 | + :crates-io @crate-f |
| 193 | + :homebrew @brew-f})) |
| 194 | + |
| 195 | +(defn registry-taken? [v] |
| 196 | + (cond |
| 197 | + (keyword? v) (not (#{:available :unknown :error :timeout} v)) |
| 198 | + (map? v) (or (:exact v) (pos? (get v :total 0))) |
| 199 | + :else false)) |
| 200 | + |
| 201 | +(defn summarize |
| 202 | + "Add a quick summary line." |
| 203 | + [results] |
| 204 | + (let [domain-vals (vals (:domains results)) |
| 205 | + npm-taken (registry-taken? (:npm results)) |
| 206 | + pypi-taken (registry-taken? (:pypi results)) |
| 207 | + crate-taken (registry-taken? (:crates-io results)) |
| 208 | + brew-taken (registry-taken? (:homebrew results))] |
| 209 | + (assoc results :summary |
| 210 | + {:domains-available (count (filter #{:available} domain-vals)) |
| 211 | + :domains-taken (count (filter #{:taken} domain-vals)) |
| 212 | + :npm (if npm-taken :taken :available) |
| 213 | + :pypi (if pypi-taken :taken :available) |
| 214 | + :crates-io (if crate-taken :taken :available) |
| 215 | + :homebrew (if brew-taken :taken :available) |
| 216 | + :github-repos (get-in results [:github :total] 0)}))) |
| 217 | + |
| 218 | +;; Main |
| 219 | +(binding [*out* *err*] |
| 220 | + (println (str "name-chkr: checking " (count args) " name(s)..."))) |
| 221 | + |
| 222 | +(let [results (into {} |
| 223 | + (map (fn [name] |
| 224 | + [(keyword name) (summarize (check-name name))]) |
| 225 | + args))] |
| 226 | + (pp/pprint results)) |
0 commit comments