Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
226 changes: 226 additions & 0 deletions plugins/spai-name-chkr
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
#!/usr/bin/env bb
;; spai plugin: name-chkr
;;
;; Checks name availability across domains, GitHub, Homebrew, npm, PyPI, crates.io.
;; Takes multiple names as args, checks them all in parallel.
;;
;; Usage: spai name-chkr <name1> [name2] [name3] ...

{:spai/args "<name> [name2] [name3] ..."
:spai/returns "EDN map of availability per name across registries"
:spai/example "spai name-chkr spoqe spindle hedl"}

(require '[clojure.string :as str]
'[clojure.pprint :as pp]
'[babashka.process :as p]
'[babashka.http-client :as http]
'[cheshire.core :as json])

(def args *command-line-args*)

(when (or (empty? args) (contains? #{"--help" "-h"} (first args)))
(println "Usage: spai name-chkr <name> [name2] [name3] ...")
(println)
(println "Checks availability across:")
(println " domains — .com .io .dev .org .app .sh")
(println " github — org/user name")
(println " homebrew — formula/cask")
(println " npm — package")
(println " pypi — package")
(println " crates.io — crate")
(println)
(println "Examples:")
(println " spai name-chkr spoqe")
(println " spai name-chkr hedl spindle gearn")
(System/exit 0))

(def domain-tlds ["com" "io" "dev" "org" "app" "sh"])

(defn check-http
"GET url, return status code. Returns :error on exception."
[url & {:keys [timeout] :or {timeout 8000}}]
(try
(let [resp (http/get url {:throw false
:timeout timeout
:headers {"User-Agent" "spai-name-chkr/1.0"}})]
(:status resp))
(catch Exception e
(let [msg (str e)]
(cond
(str/includes? msg "UnknownHost") :no-dns
(str/includes? msg "ConnectTimeout") :timeout
(str/includes? msg "Timeout") :timeout
(str/includes? msg "Connection refused") :refused
:else :error)))))

(defn check-domain
"Check if a domain resolves via DNS (dig)."
[domain]
(let [{:keys [exit out]} (p/shell {:out :string :err :string :continue true}
"dig" "+short" "+time=3" "+tries=1" domain "A")]
(if (and (zero? exit) (not (str/blank? out)))
:taken
;; Also check AAAA
(let [{:keys [exit out]} (p/shell {:out :string :err :string :continue true}
"dig" "+short" "+time=3" "+tries=1" domain "AAAA")]
(if (and (zero? exit) (not (str/blank? out)))
:taken
:available)))))

(defn check-github
"Search GitHub repos matching name, return top 10 by stars."
[name]
(try
(let [resp (http/get "https://api.github.com/search/repositories"
{:query-params {"q" name "sort" "stars" "order" "desc" "per_page" "10"}
:throw false
:timeout 8000
:headers {"User-Agent" "spai-name-chkr/1.0"
"Accept" "application/vnd.github.v3+json"}})
body (when (= 200 (:status resp))
(cheshire.core/parse-string (:body resp) true))]
(if body
{:total (:total_count body)
:hits (mapv (fn [r]
{:repo (:full_name r)
:stars (:stargazers_count r)
:forks (:forks_count r)
:description (some-> (:description r) (subs 0 (min 80 (count (:description r)))))
:url (:html_url r)})
(:items body))}
{:total 0 :hits [] :note (str "status:" (:status resp))}))
(catch Exception e
{:total 0 :hits [] :error (str e)})))

(defn check-npm
"Search npm for packages matching name, return top hits."
[name]
(try
(let [resp (http/get "https://registry.npmjs.org/-/v1/search"
{:query-params {"text" name "size" "10"}
:throw false
:timeout 8000
:headers {"User-Agent" "spai-name-chkr/1.0"}})
body (when (= 200 (:status resp))
(json/parse-string (:body resp) true))]
(if body
(let [objects (:objects body)]
{:total (get-in body [:total])
:exact (some #(= name (get-in % [:package :name])) objects)
:hits (mapv (fn [o]
(let [pkg (:package o)]
{:name (:name pkg)
:version (:version pkg)
:description (some-> (:description pkg) (subs 0 (min 80 (count (:description pkg)))))
:weekly-dl (get-in o [:score :detail :popularity])}))
objects)})
{:total 0 :hits []}))
(catch Exception e
{:total 0 :hits [] :error (str e)})))

(defn check-pypi
"Search PyPI for packages matching name, return top hits."
[name]
(try
(let [;; Check exact match first
exact-resp (http/get (str "https://pypi.org/pypi/" name "/json")
{:throw false :timeout 8000
:headers {"User-Agent" "spai-name-chkr/1.0"}})
exact (when (= 200 (:status exact-resp))
(let [body (json/parse-string (:body exact-resp) true)
info (:info body)]
{:name (:name info)
:version (:version info)
:description (some-> (:summary info) (subs 0 (min 80 (count (:summary info)))))
:author (:author info)}))
;; Search for similar
search-resp (http/get "https://pypi.org/search/"
{:query-params {"q" name}
:throw false :timeout 8000
:headers {"User-Agent" "spai-name-chkr/1.0"
"Accept" "application/json"}})
;; PyPI search doesn't have a JSON API, use simple API list isn't useful
;; Just use xmlrpc or report exact match + note
]
(if exact
{:exact true :hit exact}
{:exact false}))
(catch Exception e
{:exact false :error (str e)})))

(defn check-crates
"Check crates.io crate existence."
[name]
(let [status (check-http (str "https://crates.io/api/v1/crates/" name))]
(case status
200 :taken
404 :available
:unknown)))

(defn check-homebrew
"Check Homebrew formula/cask existence."
[name]
(let [formula-status (check-http (str "https://formulae.brew.sh/api/formula/" name ".json"))
cask-status (when (not= 200 formula-status)
(check-http (str "https://formulae.brew.sh/api/cask/" name ".json")))]
(cond
(= 200 formula-status) :taken-formula
(= 200 cask-status) :taken-cask
:else :available)))

(defn check-name
"Check a single name across all registries. Returns EDN map."
[name]
(binding [*out* *err*]
(println (str " checking: " name "...")))
(let [lower (str/lower-case name)
;; Fire all checks in parallel
domains (into {} (map (fn [tld]
[(keyword (str tld))
(future (check-domain (str lower "." tld)))])
domain-tlds))
gh-f (future (check-github lower))
npm-f (future (check-npm lower))
pypi-f (future (check-pypi lower))
crate-f (future (check-crates lower))
brew-f (future (check-homebrew lower))]

{:domains (into {} (map (fn [[k v]] [k @v]) domains))
:github @gh-f
:npm @npm-f
:pypi @pypi-f
:crates-io @crate-f
:homebrew @brew-f}))

(defn registry-taken? [v]
(cond
(keyword? v) (not (#{:available :unknown :error :timeout} v))
(map? v) (or (:exact v) (pos? (get v :total 0)))
:else false))

(defn summarize
"Add a quick summary line."
[results]
(let [domain-vals (vals (:domains results))
npm-taken (registry-taken? (:npm results))
pypi-taken (registry-taken? (:pypi results))
crate-taken (registry-taken? (:crates-io results))
brew-taken (registry-taken? (:homebrew results))]
(assoc results :summary
{:domains-available (count (filter #{:available} domain-vals))
:domains-taken (count (filter #{:taken} domain-vals))
:npm (if npm-taken :taken :available)
:pypi (if pypi-taken :taken :available)
:crates-io (if crate-taken :taken :available)
:homebrew (if brew-taken :taken :available)
:github-repos (get-in results [:github :total] 0)})))

;; Main
(binding [*out* *err*]
(println (str "name-chkr: checking " (count args) " name(s)...")))

(let [results (into {}
(map (fn [name]
[(keyword name) (summarize (check-name name))])
args))]
(pp/pprint results))
Loading