Hand-written command-line tools. Everything in bin/ is meant to sit on PATH.
curl -fsSL https://raw.githubusercontent.com/profullstack/scripts/main/install.sh | shThat is the whole thing on a brand-new box: the repo is public, so the clone
needs no key and no account. It also ends the chicken-and-egg this repo used to
have, where provision-ssh-keys — the tool that puts our keys on a machine —
could only be fetched using the key it exists to install.
Cloning by hand works the same way, and --ssh gets you a checkout you can push
from:
git clone https://github.com/profullstack/scripts.git ~/scripts
sh ~/scripts/install.sh # or: sh ~/scripts/install.sh --sshinstall.sh clones (or updates) the checkout, moves it to the newest release
tag, and symlinks every executable in bin/ into ~/.local/bin. Symlinking
rather than copying keeps ~/.local/bin and this repo from drifting apart.
sh install.sh # newest release
sh install.sh --edge # track main instead
sh install.sh --ref v0.2.0 # a specific tag
sh install.sh --ssh # clone over SSH, for a checkout you push from
sh install.sh --dry-run # say what would happen, change nothingscripts-upgrade # move to the newest release
scripts-upgrade --status # where this box is, and what is newest
scripts-upgrade --edge # switch to tracking mainscripts-upgrade is a front for the same install.sh, so a fresh machine and
an existing one run identical code.
Releases are the point of the tag, not a tarball. Nothing downloads an
archive from this repo; install.sh resolves the newest v* tag on the remote
and checks the tree out at it. Cutting a release is therefore exactly:
git tag -a v0.2.0 -m "..." && git push origin v0.2.0Pushing the tag is enough. .github/workflows/release.yml runs the gate and
then creates the release object, so gh release create by hand is only a
fallback for when Actions is unavailable.
The gate runs twice, and that is deliberate. bin/scripts-check is the
rule — it parses every script by shebang, verifies the executable bits, and runs
shellcheck -S error over the shell ones. .githooks/pre-push runs it before
anything leaves the machine, and CI runs that same file on the runner. Neither
is a re-implementation of the other, which is how the two used to be free to
drift; the hook stays because feedback before a push beats feedback after one.
A note on the red X in the history, and why CI came back. This workflow was
deleted on 2026-08-29 and restored when the repo was opened up. It was never
broken YAML: an unpaid balance on the profullstack org suspends Actions
compute, and the job was refused before a runner was ever allocated — a red X
with no logs, which is indistinguishable from a workflow that ran and failed. A
gate that cannot run is worse than no gate, because the X implies something was
checked, so it was removed rather than left there lying.
Be precise about the money, because the obvious assumption is wrong: this repo's CI minutes were never the cost. They are covered by the included allowance at net $0.00. The balance is Actions artifact storage and Code Quality credits accrued on unrelated repositories, so deleting this workflow saved nothing on the bill and was never going to.
What changed is not the balance — it is still owed, and every private repo in the org is still suspended by it. The suspension only applies to private repos. This one is public now, so the single thing standing between this workflow and a runner is gone.
A development checkout is left alone. Because ~/.local/bin/* are symlinks
into the working tree, the command on PATH is whatever the checkout is at, so
tracking main means an unfinished edit is instantly the installed tool. That
is why the default is a tag. The box where this repo is actually developed is
the exception and needs no flag: a tree with uncommitted changes or unpushed
commits is never moved, only re-symlinked.
Lists open pull requests across any number of organizations and personal accounts, newest first, as an aligned table. In a capable terminal the PR number and URL become clickable hyperlinks.
gh-prs --orgs profullstack,moshcoder,h4kr,infernetprotocol
gh-prs --users ralyodio
gh-prs --orgs profullstack --users ralyodio --limit 50The same table for open issues rather than pull requests. gh search issues
excludes pull requests unless asked for them, so the two tools never overlap.
gh-issues --orgs profullstack,moshcoder,h4kr,infernetprotocol
gh-issues --users ralyodio
gh-issues --orgs profullstack --users ralyodio --limit 50--csv writes the result set to ~/gh-issues-YYYY-MM-DD.csv instead of
printing the table, and --csv=FILE picks the path. The CSV is the archival
form of the same query: whole titles rather than 70 columns of one, ISO 8601
CREATED and UPDATED timestamps rather than "3 days ago", and a bare issue
number so a spreadsheet reads it as one.
gh-issues --orgs profullstack,moshcoder,h4kr,infernetprotocol --csv
gh-issues --orgs profullstack --csv=/tmp/issues.csvBare --csv takes no argument on purpose, so --csv --limit 10 cannot swallow
the next flag as a filename.
Moved to profullstack/cli-tools
on 2026-09-13, the same day it landed here, so that the email, the gh-pulse show terminal view, open, text and json are one implementation with one
name on PATH. The snapshots under ~/.local/share/gh-pulse are the same files
either way. curl -fsSL https://raw.githubusercontent.com/profullstack/cli-tools/master/install.sh | sh
installs it.
Moved to profullstack/cli-tools
on 2026-09-24, for the same reason gh-pulse went: two implementations of one
name on PATH drift, and this one had already drifted. The TypeScript copy
carries the fix for GitHub refusing to merge a stacked PR through the GraphQL
mutation, and has tests around it.
One behaviour differs, so the swap is not silent: repairs were on here
unless you passed --no-fix, and are off there unless you pass --fix.
An alias that relied on the old default needs --fix added.
curl -fsSL https://raw.githubusercontent.com/profullstack/cli-tools/master/install.sh | sh
installs it.
Whois-style, JSON-first name lookup. Prints a single JSON object:
{ "name": "...", "rdap": { ... }, "dns": { "records": {}, "hosts": [], "reverse": [], "axfr": [] } }domainjson example.com
domainjson --timeout 8000 test.hacker
domainjson -s https://rdap.nic.cz -t domain example.cz # openrdap args pass throughNames ending in a Moshpit TLD skip RDAP and are
served from the registry API under a moshpit key instead. Everything else
goes through the OpenRDAP CLI (rdap); its flags pass through unchanged,
except the output-format flags (--text, --whois, --raw), which are
dropped — the RDAP section is always JSON. DNS is queried one type at a time
(A, AAAA, CNAME, MX, TXT, NS; never ANY), plus reverse PTR for
every resolved address and an AXFR attempt against each nameserver — a
refused transfer is reported, never fatal. If every data source fails, the
exit status is non-zero and the JSON carries an error key.
| Flag | Effect |
|---|---|
--registry URL |
Moshpit registry base URL, default https://pit.moshcode.sh |
--timeout MS |
per-query timeout for HTTP and dig, default 4000 |
--name NAME |
alternative to the positional name argument |
Authorizes our public keys on a machine, so every box we buy is reachable
without a password. Dry run by default — nothing is written until you pass
--apply. Idempotent, so it doubles as a drift check on hosts we already own.
provision-ssh-keys 152.53.47.37 # report only
provision-ssh-keys --apply 152.53.47.37 # install
provision-ssh-keys --apply --user anthony box # into a non-root accountEvery ~/.ssh/*.pub is installed unless you narrow it with --key. Only .pub
files are accepted; the script refuses anything else, so a private key cannot be
shipped by a slip of the shell.
The first key is a chicken-and-egg and this script will not solve it. A
machine that has never seen one of our keys answers Permission denied (publickey,password), and nothing here handles passwords. Bootstrap one key
through the provider's own provisioning — netcup SCP, cloud-init, a rescue
console — or interactively, once, with ssh-copy-id -i ~/.ssh/id_ed25519.pub root@HOST. After that this script converges the rest and re-converges later.
Host keys. By default an unknown host is refused rather than trusted;
--accept-new waives that. Better, pass --fingerprint with the value read off
the provider's console and the script verifies before it opens a session,
refusing on mismatch:
provision-ssh-keys --apply --fingerprint SHA256:AUyILK… 152.53.47.37A reinstalled machine legitimately presents new host keys, so a mismatch means go re-read the console, not necessarily an attack. It is still a stop.
Options:
| Flag | Effect |
|---|---|
--user USER |
Remote account to install into; default root |
--port N |
SSH port; default 22 |
--key PATH |
Public key to install; repeatable. Default: every ~/.ssh/*.pub |
--fingerprint SHA256:… |
Require the host to present this key; repeatable |
--accept-new |
Trust an unknown host key instead of refusing |
--apply |
Actually write authorized_keys |
Who actually got in over SSH, and from where. Defaults to today.
ssh-logins # today
ssh-logins yesterday # any journalctl --since expression
ssh-logins -7d
ssh-logins --count # one row per user+source
ssh-logins --raw # the original journal linesNo sudo. Membership in the adm group is enough to read the journal, so the
command never stops to authenticate. Scoped to -u ssh because a full day is
~15k lines and grepping all of them to find thirty is most of the runtime.
Counts Accepted only. Failed and half-finished attempts are thousands a day on
a public box and answer a different question.
Moves this box to the newest release of this repo. See Upgrading.
The release gate, runnable. Parses every script by shebang, checks that
everything in bin/ is executable, and runs shellcheck -S error over the
shell ones.
scripts-check # check the checkout this command lives in
scripts-check --quiet # only complain.githooks/pre-push runs it before every push, wired up by install.sh via
git config core.hooksPath .githooks. git push --no-verify bypasses it — and
CI then runs the same command on the runner, which does not.
A missing shellcheck is skipped with a note rather than failing — an absent
linter must not be able to block a release.
Snapshots of the launcher scripts that third-party installers drop into
~/.local/bin. They are kept here for reference and recovery, not for
installation:
| File | Installed by | Launches |
|---|---|---|
coinpay |
https://coinpayportal.com/install.sh |
~/.coinpay/pkg/bin/coinpay.js |
moshcode |
https://moshcoding.com/install.sh |
~/.moshcode/pkg/bin/moshcode.mjs |
moshscript |
https://moshcoding.com/install.sh |
moshcode.mjs run |
logicsrc |
logicsrc CLI | ~/.logicsrc-cli/.../cli/dist/index.js |
Two reasons they live outside bin/ and are deliberately not symlinked:
- They hardcode absolute
/home/anthonypaths, so they are not portable to another machine or user. - Their installers rewrite
~/.local/bin/<name>on every update. Symlinking would let an installer write through into this repository, producing surprise diffs or silently replacing the link.
Re-run the relevant installer to restore or update one; copy from here only if an installer is unavailable and you need the old contents back.
gh (authenticated), jq, and awk. provision-ssh-keys needs only OpenSSH
(ssh, ssh-keyscan, ssh-keygen) locally and bash on the remote host.
domainjson additionally wants node,
dig, and the OpenRDAP CLI (go install github.com/openrdap/rdap/cmd/rdap@latest,
run from ~/go/bin/rdap or on PATH). ssh-logins needs journalctl and
membership in a group that can read the journal (adm on Ubuntu).
install.sh itself needs only POSIX sh and git — deliberately, since it is
the one file that runs before anything else is installed.
One email a day while a newsletter A/B test is running: every issue's sent, opens, clicks and unsubscribes, what moved since the last reading, and the arms pooled across the whole campaign with a two-proportion z test on clicks.
Opens and clicks keep arriving for days after a send, so a single reading at the end cannot tell a real winner from an early lead. The verdict line says when a gap is real, and when it is not it prints the recipients per arm it would take, which is usually the more useful number: a 5,140 list settles a 0.6% against 1.4% gap in one send, and never settles 0.93% against 1.18%.
newsletter-abtest snapshot every issue, mail the digest
newsletter-abtest --dry-run build and print it, record nothing, send nothing
newsletter-abtest --print snapshot and print, no mail
newsletter-abtest --prefix X issues whose id starts with X (default profullstack-)
The month's sends live in ~/.config/newsletter-abtest/plan.json; see
examples/newsletter-abtest-plan.json. Each entry gives the issue id, its
date, the CTA set and one line on what that issue tests, and the digest prints
the myna newsletter blast command for whichever is next. Cron it daily and
delete the line when the campaign is over.
Drafts the next issue of a running newsletter campaign by itself, three days before its date, and mails a test copy. It stops there: a person reads the test copy and says go.
newsletter-prep prepare the next issue due within 3 days
newsletter-prep --days 5 look further ahead
newsletter-prep --id X that issue, whatever its date
newsletter-prep --dry-run gather and draft, create nothing, mail nothing
newsletter-prep --force redo one already drafted
It reads the campaign from ~/.config/newsletter-abtest/plan.json (--plan
for a trial run against a copy), gathers
every blog post and GitHub release since the previous issue, has claude write
the prose from that material and nothing else, then checks every link itself
and drops the bullets whose links are dead. The issue is created in myna with
that issue's A/B design and one test copy goes out.
Give the plan a recurring block and the schedule keeps itself going: each
run marks off whatever myna has actually sent, and tops the plan up so there
is always a future issue on the books. Without that reconciliation the sent
flags never flip, the same issue stays next forever and the schedule stalls
after one send.
"recurring": { "everyDays": 7, "keepAhead": 1, "keepSent": 12,
"idPrefix": "profullstack-", "list": "profullstack-users",
"ctaSet": "winner" }Two things it will not do. It will not put age gated or adult material in a
newsletter to every customer, whatever the feeds say: the first automatic
draft pulled in a 21+ cigar post on its own, which is what EXCLUDE_WORDS is
for. And it will not send to the list. The send reaches thousands of people
and cannot be recalled, so the last step is a person reading the test copy and
running one line, after which the myna daemon sends it at its hour.