Skip to content
Merged
Show file tree
Hide file tree
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
node_modules/
*.log
.DS_Store
146 changes: 145 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1 +1,145 @@
# cli-tools
# cli-tools

Local command-line tools, in TypeScript, on PATH.

Ported from the bash and JavaScript originals in
[`profullstack/scripts`](https://github.com/profullstack/scripts). The point of
the port is not the language — it is the two things bash was making expensive:

- **Typed, validated responses.** Every `gh` call used to go through `jq -r` into
a string compare. `jq -r '.mergeable'` on a response that never had the field
prints the four characters `null`, which is not `MERGEABLE`, so a perfectly
mergeable PR read as ineligible *for a reason nobody wrote*. Now an
unrecognised field is named in an error instead of silently becoming a string.
- **Tests.** The originals had none. Verifying a change meant running it against
live pull requests, which is a poor place to discover you were wrong.

## Commands

| Command | What it does |
| --- | --- |
| `gh-prs` | List every open PR across the owners you name |
| `gh-prs-merge` | Sweep open PRs and squash-merge the ones genuinely ready |
| `gh-prs-fix-all` | Fix the open threatcrush-scan PRs that are broken because of us |
| `tcfeed` | Find repositories worth scanning, scan them, print a shortlist |
| `domainjson` | whois-style, JSON-first name lookup |

## Install

```bash
pnpm install
pnpm link # symlink bin/*.ts into ~/.local/bin
```

The names already exist in `~/.local/bin` pointing at `~/scripts/bin`, so a
plain run reports them as not-ours and changes nothing. To migrate:

```bash
node scripts/install-links.mjs --dry-run --force # see what would move
node scripts/install-links.mjs --force # take them over
```

`--force` takes over a *symlink*. A real file of the same name is still
refused — clobbering someone's actual binary to install a convenience is not a
trade a script gets to make on its own.

To go back:

```bash
pnpm unlink # remove the ones we own
ln -sf ~/scripts/bin/gh-prs-merge ~/.local/bin/gh-prs-merge # and so on
```

## Files on PATH, not shell functions

These install as executables on PATH rather than shell aliases or functions.

The older tools carry a comment saying this is because the moshcode pit runs
aliases with `zsh -c`, a non-interactive shell that reads neither `~/.zshrc` nor
`~/.zsh_aliases`. **That is no longer true** — `src/aliases.mjs` in current
moshcode runs `$SHELL -ic`, which is interactive and does source them. Verified:

```console
$ zsh -ic 'gh-prs-all --help' # works — the pit's path
$ zsh -c 'gh-prs-all --help' # zsh:1: command not found
```

The reason to stay on PATH is the weaker but still sufficient one: a file works
from every caller — an interactive shell, `zsh -c`, a systemd unit, a CI step —
without anything having been sourced first. A shell alias only works where a
startup file was read.

Nothing should alias *to* these either. A function beats PATH, so a wrapper of
the same name silently shadows the file and the two drift apart.

Pit aliases (`/alias set <name> "<command>"`, stored in
`~/.moshcode/aliases.json`):

```
/alias set prs "gh-prs --orgs profullstack"
/alias set merge "gh-prs-merge --orgs profullstack --apply --fix"
/alias set merge-dry "gh-prs-merge --orgs profullstack"
/alias set fixprs "gh-prs-fix-all"
/alias set feed "tcfeed"
/alias set whoisj "domainjson"
```

## `gh-prs-merge --fix`

A skip is not always a verdict on the PR. Two PRs were once skipped as
`mergeStateStatus=UNSTABLE` purely because a check had not reported yet; nothing
was wrong with either, and both merged unchanged minutes later.

`--fix` repairs a repairable skip **once**, then judges the PR again against the
identical rules. It requires `--apply`, because every repair writes.

| Blocker | Repair |
| --- | --- |
| checks still running | Wait for them to settle, up to `--fix-wait` (default 600s) |
| `mergeStateStatus=BEHIND` | Ask GitHub to merge the base branch in |
| `mergeable=CONFLICTING` | Same request; succeeds when the base merely moved |

What it will not do is as much of the design:

- **A conflict GitHub declines to merge is left alone**, and the message it gave
is printed as `FIXME`. Resolving one means choosing between two authors'
intent, and a batch tool that guesses produces a merge nobody wrote and nobody
reviewed.
- **A check that ran and failed is a result, not an obstacle.** Retrying until it
passes is how a flaky suite becomes a green one that means nothing.
- **No `--admin`.** Branch protections stay enforced.

## Nothing under `bin/` does work at import time

Every entry point guards its side effects with `isMain(import.meta.url)`, and
anything worth testing lives in `src/`.

This is not decorative. A test that imported `bin/gh-prs-fix-all.ts` to reach one
pure function *ran the tool*: the suite went from 60ms to 93 seconds and swept
live pull requests with `--fix` implied. The guard and the `src/` split are both
that lesson.

The `realpath` in `isMain` matters too — these install as symlinks, so
`process.argv[1]` is the link while `import.meta.url` is its target. Comparing
them raw reports "imported" for every installed command, disabling all of them at
once.

## Development

```bash
pnpm test # vitest
pnpm typecheck # tsc --noEmit
```

Tests stub the subprocess layer rather than the network, so `gh` is never
invoked. The suite runs in well under a second; if it starts taking longer,
something is reaching the network that should not be.

## Differences from the originals

Deliberate, and small:

- `gh-prs` prints `No open PRs found.` instead of a bare header row.
- `gh-prs-merge` adds `fixed=` to its summary line.
- `domainjson` output is unchanged in structure; DNS answers arrive in
round-robin order, so array ordering varies between runs of either version.
113 changes: 113 additions & 0 deletions bin/domainjson.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
#!/usr/bin/env -S npx --yes tsx
/**
* domainjson — whois-style, JSON-first name lookup.
*
* One JSON object on stdout:
*
* { "name": ..., "rdap": {...} | "moshpit": {...}, "dns": {...} }
*
* Names ending in a Moshpit TLD (https://pit.moshcode.sh) are served from the
* registry API; everything else goes through the OpenRDAP CLI (`rdap`), whose
* flags are passed through unchanged except the output-format flags — the RDAP
* portion is always JSON. Either way, dig adds records, hosts, reverse, and
* per-nameserver AXFR attempts.
*/

import { isMain } from '../src/is-main.ts';
import {
DEFAULT_REGISTRY,
DEFAULT_TIMEOUT_MS,
dnsSection,
fetchPitTlds,
findRdapBinary,
moshpitSection,
parseDomainArgs,
rdapSection,
} from '../src/domain.ts';

const USAGE = `Usage:
domainjson [openrdap-args...] <name>
domainjson --name example.com
domainjson --registry https://pit.moshcode.sh --timeout 4000 example.hacker

The final non-flag argument is the name to look up. OpenRDAP flags
(-s/--server, -t/--type, -T/--timeout, -k, --bs-url, --cache-dir, -P/-C/-K,
...) are passed through unchanged; output-format flags (--text, --whois,
--raw, --json) are dropped and JSON is always forced.

Options:
--registry URL Moshpit registry base URL (default: ${DEFAULT_REGISTRY})
--timeout MS per-query timeout for HTTP and dig (default: ${DEFAULT_TIMEOUT_MS})
--name NAME the name to look up (alternative to the positional)
-h, --help show this help
`;

/** Errors are JSON too. A tool whose output is parsed should not switch shape. */
function fail(message: string, code = 2): never {
process.stdout.write(`${JSON.stringify({ error: message })}\n`);
process.exit(code);
}

if (isMain(import.meta.url)) {
const parsed = parseDomainArgs(process.argv.slice(2));

if (parsed.help) {
process.stdout.write(USAGE);
process.exit(0);
}

if (parsed.error) {
if (parsed.error === 'no name given') process.stderr.write(USAGE);
fail(parsed.error);
}

const { own, passthrough } = parsed;
const name = own.name!.toLowerCase();
const out: Record<string, unknown> = { name };

let moshpitData: Record<string, unknown> | null = null;
let registryNote: string | null = null;

try {
const tlds = await fetchPitTlds(own.registry, own.timeout);
const ending = name.includes('.') ? (name.split('.').pop() ?? null) : null;
if (ending && tlds.has(ending)) {
moshpitData = await moshpitSection(own.registry, name, own.timeout);
out.moshpit = moshpitData;
}
} catch (error) {
// Registry unreachable: carry on as a plain RDAP+DNS lookup, but say so
// rather than letting the absence of a moshpit section imply the name is
// simply not one.
registryNote = `moshpit registry unavailable: ${
error instanceof Error ? error.message : String(error)
}`;
}

if (!moshpitData) {
const binary = await findRdapBinary();
const rdap = binary
? await rdapSection(binary, passthrough, name, own.timeout)
: {
error:
'openrdap CLI not found (looked for ~/go/bin/rdap, rdap, openrdap on PATH)',
};
if (registryNote) rdap.note = registryNote;
out.rdap = rdap;
}

const dns = await dnsSection(name, own.timeout, moshpitData);
out.dns = dns;

const rdapOk = Boolean(out.rdap) && !(out.rdap as { error?: unknown }).error;
const moshpitOk = Boolean(out.moshpit);
const dnsOk = Object.keys(dns.records).length > 0 || dns.hosts.length > 0;

if (!rdapOk && !moshpitOk && !dnsOk) {
out.error = 'every data source failed (moshpit, rdap, dns)';
process.stdout.write(`${JSON.stringify(out, null, 2)}\n`);
process.exit(1);
}

process.stdout.write(`${JSON.stringify(out, null, 2)}\n`);
}
50 changes: 50 additions & 0 deletions bin/gh-prs-fix-all.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
#!/usr/bin/env -S npx --yes tsx
/**
* gh-prs-fix-all — look at every open threatcrush-scan pull request, and fix
* the ones that are broken because of us.
*
* gh-prs-fix-all # fix ours, report theirs, leave theirs alone
* gh-prs-fix-all --dry-run # change nothing, just say what stands
* gh-prs-fix-all owner/name ... # only these
*
* The name says fix-all and it will not fix all, which is deliberate. Pushing
* the branch to a fork sets off whatever the upstream repo runs on push, so
* their test suite goes red against a commit that only added files under
* .github/. Those failures are reported and never touched. Failures in our own
* workflow that it does not recognise are printed rather than guessed at: a
* speculative commit pushed onto a stranger's review is worse than red,
* because red is at least honest.
*/

import { readFileSync } from 'node:fs';
import { isMain } from '../src/is-main.ts';
import { buildArgs, hasCheckSubcommand } from '../src/fix-all.ts';
import { launch, missingScriptMessage, resolveScript } from '../src/tcfeed-launch.ts';

async function main(argv: readonly string[]): Promise<number> {
const { repo, script, exists } = resolveScript();

if (!exists) {
process.stderr.write(`${missingScriptMessage('gh-prs-fix-all', repo, script)}\n`);
return 1;
}

if (!hasCheckSubcommand(readFileSync(script, 'utf8'))) {
process.stderr.write(
[
`gh-prs-fix-all: ${script} has no \`check\` subcommand.`,
` git -C ${repo} pull # it is on master, this checkout is behind`,
' Without this guard the old script would read `check` as a post',
' count, fall back to 50, and go scan reddit instead.',
'',
].join('\n'),
);
return 1;
}

return launch('gh-prs-fix-all', buildArgs(argv));
}

if (isMain(import.meta.url)) {
process.exitCode = await main(process.argv.slice(2));
}
Loading
Loading