diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3c45938 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +*.log +.DS_Store diff --git a/README.md b/README.md index bbdd2e4..22d5962 100644 --- a/README.md +++ b/README.md @@ -1 +1,145 @@ -# cli-tools \ No newline at end of file +# 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 ""`, 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. diff --git a/bin/domainjson.ts b/bin/domainjson.ts new file mode 100755 index 0000000..4992cd3 --- /dev/null +++ b/bin/domainjson.ts @@ -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...] + 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 = { name }; + + let moshpitData: Record | 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`); +} diff --git a/bin/gh-prs-fix-all.ts b/bin/gh-prs-fix-all.ts new file mode 100755 index 0000000..e3e229f --- /dev/null +++ b/bin/gh-prs-fix-all.ts @@ -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 { + 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)); +} diff --git a/bin/gh-prs-merge.ts b/bin/gh-prs-merge.ts new file mode 100755 index 0000000..55c44d6 --- /dev/null +++ b/bin/gh-prs-merge.ts @@ -0,0 +1,122 @@ +#!/usr/bin/env -S npx --yes tsx +/** + * gh-prs-merge — sweep open pull requests and squash-merge the ones that are + * genuinely ready. + * + * Dry run by default. `--apply` merges. `--fix` additionally repairs the + * blockers that are mechanical rather than substantive, then judges the PR + * again against the identical rules. + * + * gh-prs-merge --orgs profullstack + * gh-prs-merge --orgs profullstack --apply + * gh-prs-merge --orgs profullstack --apply --fix + */ + +import { csv, integer, parseArgs, UsageError } from '../src/args.ts'; +import { isMain } from '../src/is-main.ts'; +import { Gh } from '../src/gh.ts'; +import { defaults, render, renderSummary, sweep, type MergeOptions } from '../src/prs-merge.ts'; + +const USAGE = `Usage: + gh-prs-merge --orgs ORG1,ORG2 [--users USER1,USER2] [--limit N] [--apply] + gh-prs-merge --users USER1,USER2 [--limit N] [--apply] + +By default, this performs a dry run. + +Options: + --orgs ORG1,ORG2 Search repositories owned by these organizations + --users USER1,USER2 Search repositories owned by these personal accounts + --limit N Maximum PRs per owner; default 1000 + --apply Actually squash-merge eligible PRs + --allow-no-checks Also merge clean PRs that have no CI checks + --no-ready-drafts Ignore draft PRs instead of marking them ready + --fix Repair mechanical blockers, then re-evaluate + --fix-wait SECONDS How long --fix waits on running checks; default 600 + -h, --help Show this help + +Eligibility: + - PR is open, and not a draft (or was successfully marked ready) + - mergeable is MERGEABLE and mergeStateStatus is CLEAN + - at least one CI check exists, unless --allow-no-checks + - every check is pass or skipping + - the head commit has not changed when the merge is submitted + +Fixing (--fix): + A skip is not always a verdict on the PR. Some are this tool arriving at the + wrong moment. Requires --apply, since every repair writes. + + Repaired: + checks still running waits for them to settle, up to --fix-wait + mergeStateStatus=BEHIND asks GitHub to merge the base branch in + mergeable=CONFLICTING same request; it succeeds when the base merely + moved underneath the branch + + Never repaired: + A conflict GitHub declines to merge is left alone and its message printed. + Resolving one means choosing between two authors' intent. A check that ran + and failed is a result, not an obstacle. +`; + +async function main(argv: string[]): Promise { + const parsed = parseArgs(argv, { + boolean: ['--apply', '--allow-no-checks', '--ready-drafts', '--no-ready-drafts', '--fix', '--no-fix', '-h', '--help'], + string: ['--orgs', '--users', '--limit', '--fix-wait'], + }); + + if (parsed.flags.has('-h') || parsed.flags.has('--help')) { + process.stdout.write(USAGE); + return 0; + } + + const options: MergeOptions = { + orgs: csv(parsed.values, '--orgs'), + users: csv(parsed.values, '--users'), + limit: integer(parsed.values, '--limit', defaults.limit, { min: 1, max: 1000 }), + apply: parsed.flags.has('--apply'), + allowNoChecks: parsed.flags.has('--allow-no-checks'), + readyDrafts: !parsed.flags.has('--no-ready-drafts'), + fix: parsed.flags.has('--fix') && !parsed.flags.has('--no-fix'), + fixWaitMs: + integer(parsed.values, '--fix-wait', defaults.fixWaitMs / 1000, { max: 86_400 }) * 1000, + pollMs: defaults.pollMs, + }; + + if (options.orgs.length === 0 && options.users.length === 0) { + process.stderr.write(USAGE); + throw new UsageError('pass --orgs, --users, or both'); + } + + // A dry run that mutated every repairable PR is the one thing a dry run + // promises not to do. + if (options.fix && !options.apply) { + throw new UsageError('--fix requires --apply'); + } + + const summary = await sweep(options, new Gh(), (line) => { + const text = render(line); + if (line.kind === 'failed' || line.kind === 'fixme' || line.kind === 'warn') { + process.stderr.write(`${text}\n`); + } else { + process.stdout.write(`${text}\n`); + } + }); + + process.stdout.write(`${renderSummary(summary)}\n`); + return summary.failed > 0 ? 1 : 0; +} + +if (isMain(import.meta.url)) { + try { + process.exitCode = await main(process.argv.slice(2)); + } catch (error) { + if (error instanceof UsageError) { + process.stderr.write(`gh-prs-merge: ${error.message}\n`); + process.exitCode = 2; + } else { + process.stderr.write( + `gh-prs-merge: ${error instanceof Error ? error.message : String(error)}\n`, + ); + process.exitCode = 1; + } + } +} diff --git a/bin/gh-prs.ts b/bin/gh-prs.ts new file mode 100755 index 0000000..fde347e --- /dev/null +++ b/bin/gh-prs.ts @@ -0,0 +1,74 @@ +#!/usr/bin/env -S npx --yes tsx +/** + * gh-prs — list every open pull request across the owners you name. + * + * gh-prs --orgs profullstack,moshcoder,h4kr,infernetprotocol + * gh-prs --users ralyodio,devpreshy + * gh-prs --orgs profullstack --users ralyodio + */ + +import { csv, integer, parseArgs, UsageError } from '../src/args.ts'; +import { isMain } from '../src/is-main.ts'; +import { list } from '../src/prs-list.ts'; + +const USAGE = `Usage: + gh-prs --orgs ORG1,ORG2 [--users USER1,USER2] [--limit NUMBER] + gh-prs --users USER1,USER2 [--limit NUMBER] + +Options: + --orgs ORG1,ORG2 Search repositories owned by these organizations + --users USER1,USER2 Search repositories owned by these personal accounts + --limit NUMBER Maximum PRs per owner; default 1000 + --no-links Never emit terminal hyperlinks + -h, --help Show this help + +Examples: + gh-prs --orgs profullstack,moshcoder,h4kr,infernetprotocol + gh-prs --users ralyodio,devpreshy +`; + +async function main(argv: string[]): Promise { + const parsed = parseArgs(argv, { + boolean: ['--no-links', '-h', '--help'], + string: ['--orgs', '--users', '--limit'], + }); + + if (parsed.flags.has('-h') || parsed.flags.has('--help')) { + process.stdout.write(USAGE); + return 0; + } + + const orgs = csv(parsed.values, '--orgs'); + const users = csv(parsed.values, '--users'); + + if (orgs.length === 0 && users.length === 0) { + process.stderr.write(USAGE); + throw new UsageError('pass --orgs, --users, or both'); + } + + const options = { + orgs, + users, + limit: integer(parsed.values, '--limit', 1000, { min: 1, max: 1000 }), + ...(parsed.flags.has('--no-links') ? { hyperlinks: false } : {}), + }; + + process.stdout.write(`${await list(options)}\n`); + return 0; +} + +if (isMain(import.meta.url)) { + try { + process.exitCode = await main(process.argv.slice(2)); + } catch (error) { + if (error instanceof UsageError) { + process.stderr.write(`gh-prs: ${error.message}\n`); + process.exitCode = 2; + } else { + process.stderr.write( + `gh-prs: ${error instanceof Error ? error.message : String(error)}\n`, + ); + process.exitCode = 1; + } + } +} diff --git a/bin/tcfeed.ts b/bin/tcfeed.ts new file mode 100755 index 0000000..9047bcd --- /dev/null +++ b/bin/tcfeed.ts @@ -0,0 +1,30 @@ +#!/usr/bin/env -S npx --yes tsx +/** + * tcfeed — read the newest posts on a subreddit, find the repositories they + * link, scan each one, and print a shortlist worth reading. + * + * tcfeed # the 50 newest posts + * tcfeed 100 # more of them + * tcfeed --forget # look at everything again next time + * tcfeed pr owner/name [--dry-run] # install the scan workflow + * tcfeed check [--fix] # how are the open requests doing + * + * An executable rather than a shell function, and that is the whole point of + * it. The moshcode pit runs its aliases with `zsh -c `, and `zsh -c` + * is a non-interactive shell: it reads neither ~/.zshrc nor ~/.zsh_aliases, so + * a function defined there is simply not there. `/alias tcfeed "tcfeed"` in the + * pit answered `command not found` while the identical word worked when typed + * at a prompt. + * + * Nothing should alias to this either. A function beats PATH, so a wrapper of + * the same name silently shadows this file and the two drift apart. + * + * TCFEED_REPO where threatcrush is checked out + */ + +import { isMain } from '../src/is-main.ts'; +import { launch } from '../src/tcfeed-launch.ts'; + +if (isMain(import.meta.url)) { + process.exitCode = await launch('tcfeed', process.argv.slice(2)); +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..0e0ebd5 --- /dev/null +++ b/package.json @@ -0,0 +1,28 @@ +{ + "name": "@profullstack/cli-tools", + "version": "0.1.0", + "private": true, + "description": "Local command-line tools, in TypeScript, exposed on PATH.", + "type": "module", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/profullstack/cli-tools.git" + }, + "engines": { + "node": ">=20" + }, + "scripts": { + "test": "vitest run", + "test:watch": "vitest", + "typecheck": "tsc -p tsconfig.json --noEmit", + "link": "node scripts/install-links.mjs", + "unlink": "node scripts/install-links.mjs --remove" + }, + "devDependencies": { + "@types/node": "^22.10.2", + "tsx": "^4.19.2", + "typescript": "^5.7.2", + "vitest": "^4.1.10" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..72e1b76 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,1001 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@types/node': + specifier: ^22.10.2 + version: 22.20.1 + tsx: + specifier: ^4.19.2 + version: 4.23.12 + typescript: + specifier: ^5.7.2 + version: 5.9.3 + vitest: + specifier: ^4.1.10 + version: 4.1.10(@types/node@22.20.1)(vite@8.2.1(@types/node@22.20.1)(esbuild@0.28.2)(tsx@4.23.12)) + +packages: + + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@oxc-project/types@0.144.0': + resolution: {integrity: sha512-nuhZIOLuI6TFQ32I/WnUx+SCPY7SdSKwgnFHydAuoS1+Z4BRcaP+RRJmGzl9lw+0OFF7UmaESf7KQRXaNLHypg==} + + '@rolldown/binding-android-arm64@1.2.4': + resolution: {integrity: sha512-jHC2cnyKz5xU2fhECtFl8OZ83cYNt13GZQD+0uMJ/X3o+ijmd56okHhTUwxVSHPx1IRVIJEZ1/1pPzeLCU6XKA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.2.4': + resolution: {integrity: sha512-Dc5mPD8F5F/FS8i01syd7FTF6yB2fVthH/TRkjwJkzUK6EpoxHtqvZQP5Zwq80/5z19TWYHIg1KOHboCgVx/aQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.2.4': + resolution: {integrity: sha512-fpDm4oBo6SqLvWUYCmFhdde3U9KH2fRNNMeAnAPAIwxRL345xutL0EtEUcuoxsoazdJGv/MuDBQHlCDrtbvqOg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.2.4': + resolution: {integrity: sha512-rSJoreDE/HoIzoaib6MTp5jQtCTdMHKIvItAKT/ImS6Y6Ww76oUaeMyp4Vc/fAgd/ehji068IxetHXAnqUwN9A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.2.4': + resolution: {integrity: sha512-/jm8OGHgn7oGaJu3i/qZI9spUGcJ+y/lk43ttQ/iO1tOd9NissG6o97bighBCiL+BKRngmcDuR6ikfwYdJmVuQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.2.4': + resolution: {integrity: sha512-tIP06BeD9EqvECBrPZ+sqdPlYrT+aYaAiu1wYziVx5elRK/ftm33JxVDy2bXGbr6J0CrtirCkR87/X5a2euEng==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.2.4': + resolution: {integrity: sha512-Ql1Q0EQqVThvn9VAVlwNzsUvbSFtCMGjLpRRi4pk5i7NZZ4n5ISiLMjHYtus4VQ2PvkSw24zyaCVsiS+sXPj1w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.2.4': + resolution: {integrity: sha512-GjbjXD4XXfN19D0LZNbmiCBUoDiRACsYHr0yaIbbn8aFsXjHZifcYqu/W5Er5X2X990WjHXFrxarn5chzItorQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.2.4': + resolution: {integrity: sha512-p5WR0NOwaRmJ/B1b6IjEFLLivwEsf3PrdBIhRbhTCQisbo2SvHHpG4ELB/+FgQNnB88LTOF86upmJmbvZdQ2lw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.2.4': + resolution: {integrity: sha512-4/GyVjmhR+Tc6HLJvwc1sOhPqAZtySiSMesOZyX6JQ5XBxoTDEMKQzvo07NIK6nTon/SivlZqvhzvuVBNQhObQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.2.4': + resolution: {integrity: sha512-l9eeLsCNvPpmSXUej0etw/J1eqV0Jj1D5G/xG6YTijmE6dkv6E2QezgWbTfQk63v952DPqrjOCoiqxq7Bw0YUQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.2.4': + resolution: {integrity: sha512-e0F355MSTMm3+UOqtV3L24gFUp2N5m1f8L/7d56deik6va+AXdrt9F8LbzGpeWGWRbZEDq4m8NVnJDeBtf9DZg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-win32-arm64-msvc@1.2.4': + resolution: {integrity: sha512-AWLi0uBRYh6QlE7OKhiz+phZC0qwtij2QZmhmOdsLdFn64m7oMpooE9ICE3lhm9xMb4SpDo2WbHcxX1iFLFtqw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.2.4': + resolution: {integrity: sha512-UwSDJOg3dqCAejWdxclJjCsh3Qq4vLYMDxmyHqo1btz3stK2VqgwNd3mm5tuIwzSlGIQ/1H9Hr+Zn09mrezNqQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/node@22.20.1': + resolution: {integrity: sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==} + + '@vitest/expect@4.1.10': + resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==} + + '@vitest/mocker@4.1.10': + resolution: {integrity: sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.10': + resolution: {integrity: sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==} + + '@vitest/runner@4.1.10': + resolution: {integrity: sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==} + + '@vitest/snapshot@4.1.10': + resolution: {integrity: sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==} + + '@vitest/spy@4.1.10': + resolution: {integrity: sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==} + + '@vitest/utils@4.1.10': + resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + es-module-lexer@2.3.1: + resolution: {integrity: sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==} + + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} + engines: {node: '>=18'} + hasBin: true + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + nanoid@3.3.18: + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + obug@2.1.4: + resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} + engines: {node: '>=12.20.0'} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + + postcss@8.5.26: + resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} + engines: {node: ^10 || ^12 || >=14} + + rolldown@1.2.4: + resolution: {integrity: sha512-rSr7irW0K7QRWzjdJXqZowkcRdDtjRduh43rBltnVKd0VFq839l1lJoDvGJb6gl7+4rTTCrPWu+YfujUL8Ug7w==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@4.2.0: + resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.3.0: + resolution: {integrity: sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.1.1: + resolution: {integrity: sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==} + engines: {node: '>=14.0.0'} + + tsx@4.23.12: + resolution: {integrity: sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==} + engines: {node: '>=18.0.0'} + hasBin: true + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + vite@8.2.1: + resolution: {integrity: sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.4.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.1.10: + resolution: {integrity: sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.10 + '@vitest/browser-preview': 4.1.10 + '@vitest/browser-webdriverio': 4.1.10 + '@vitest/coverage-istanbul': 4.1.10 + '@vitest/coverage-v8': 4.1.10 + '@vitest/ui': 4.1.10 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + +snapshots: + + '@esbuild/aix-ppc64@0.28.2': + optional: true + + '@esbuild/android-arm64@0.28.2': + optional: true + + '@esbuild/android-arm@0.28.2': + optional: true + + '@esbuild/android-x64@0.28.2': + optional: true + + '@esbuild/darwin-arm64@0.28.2': + optional: true + + '@esbuild/darwin-x64@0.28.2': + optional: true + + '@esbuild/freebsd-arm64@0.28.2': + optional: true + + '@esbuild/freebsd-x64@0.28.2': + optional: true + + '@esbuild/linux-arm64@0.28.2': + optional: true + + '@esbuild/linux-arm@0.28.2': + optional: true + + '@esbuild/linux-ia32@0.28.2': + optional: true + + '@esbuild/linux-loong64@0.28.2': + optional: true + + '@esbuild/linux-mips64el@0.28.2': + optional: true + + '@esbuild/linux-ppc64@0.28.2': + optional: true + + '@esbuild/linux-riscv64@0.28.2': + optional: true + + '@esbuild/linux-s390x@0.28.2': + optional: true + + '@esbuild/linux-x64@0.28.2': + optional: true + + '@esbuild/netbsd-arm64@0.28.2': + optional: true + + '@esbuild/netbsd-x64@0.28.2': + optional: true + + '@esbuild/openbsd-arm64@0.28.2': + optional: true + + '@esbuild/openbsd-x64@0.28.2': + optional: true + + '@esbuild/openharmony-arm64@0.28.2': + optional: true + + '@esbuild/sunos-x64@0.28.2': + optional: true + + '@esbuild/win32-arm64@0.28.2': + optional: true + + '@esbuild/win32-ia32@0.28.2': + optional: true + + '@esbuild/win32-x64@0.28.2': + optional: true + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@oxc-project/types@0.144.0': {} + + '@rolldown/binding-android-arm64@1.2.4': + optional: true + + '@rolldown/binding-darwin-arm64@1.2.4': + optional: true + + '@rolldown/binding-darwin-x64@1.2.4': + optional: true + + '@rolldown/binding-freebsd-x64@1.2.4': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.2.4': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.2.4': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.2.4': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.2.4': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.2.4': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.2.4': + optional: true + + '@rolldown/binding-linux-x64-musl@1.2.4': + optional: true + + '@rolldown/binding-openharmony-arm64@1.2.4': + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.2.4': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.2.4': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@standard-schema/spec@1.1.0': {} + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.9': {} + + '@types/node@22.20.1': + dependencies: + undici-types: 6.21.0 + + '@vitest/expect@4.1.10': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + chai: 6.2.2 + tinyrainbow: 3.1.1 + + '@vitest/mocker@4.1.10(vite@8.2.1(@types/node@22.20.1)(esbuild@0.28.2)(tsx@4.23.12))': + dependencies: + '@vitest/spy': 4.1.10 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.2.1(@types/node@22.20.1)(esbuild@0.28.2)(tsx@4.23.12) + + '@vitest/pretty-format@4.1.10': + dependencies: + tinyrainbow: 3.1.1 + + '@vitest/runner@4.1.10': + dependencies: + '@vitest/utils': 4.1.10 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.10': + dependencies: + '@vitest/pretty-format': 4.1.10 + '@vitest/utils': 4.1.10 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.10': {} + + '@vitest/utils@4.1.10': + dependencies: + '@vitest/pretty-format': 4.1.10 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.1 + + assertion-error@2.0.1: {} + + chai@6.2.2: {} + + convert-source-map@2.0.0: {} + + detect-libc@2.1.2: {} + + es-module-lexer@2.3.1: {} + + esbuild@0.28.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + expect-type@1.4.0: {} + + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + + fsevents@2.3.3: + optional: true + + lightningcss-android-arm64@1.33.0: + optional: true + + lightningcss-darwin-arm64@1.33.0: + optional: true + + lightningcss-darwin-x64@1.33.0: + optional: true + + lightningcss-freebsd-x64@1.33.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + + lightningcss-linux-arm64-musl@1.33.0: + optional: true + + lightningcss-linux-x64-gnu@1.33.0: + optional: true + + lightningcss-linux-x64-musl@1.33.0: + optional: true + + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + + lightningcss-win32-x64-msvc@1.33.0: + optional: true + + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + nanoid@3.3.18: {} + + obug@2.1.4: {} + + pathe@2.0.3: {} + + picocolors@1.1.1: {} + + picomatch@4.0.5: {} + + postcss@8.5.26: + dependencies: + nanoid: 3.3.18 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + rolldown@1.2.4: + dependencies: + '@oxc-project/types': 0.144.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.2.4 + '@rolldown/binding-darwin-arm64': 1.2.4 + '@rolldown/binding-darwin-x64': 1.2.4 + '@rolldown/binding-freebsd-x64': 1.2.4 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.4 + '@rolldown/binding-linux-arm64-gnu': 1.2.4 + '@rolldown/binding-linux-arm64-musl': 1.2.4 + '@rolldown/binding-linux-ppc64-gnu': 1.2.4 + '@rolldown/binding-linux-s390x-gnu': 1.2.4 + '@rolldown/binding-linux-x64-gnu': 1.2.4 + '@rolldown/binding-linux-x64-musl': 1.2.4 + '@rolldown/binding-openharmony-arm64': 1.2.4 + '@rolldown/binding-win32-arm64-msvc': 1.2.4 + '@rolldown/binding-win32-x64-msvc': 1.2.4 + + siginfo@2.0.0: {} + + source-map-js@1.2.1: {} + + stackback@0.0.2: {} + + std-env@4.2.0: {} + + tinybench@2.9.0: {} + + tinyexec@1.3.0: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + + tinyrainbow@3.1.1: {} + + tsx@4.23.12: + dependencies: + esbuild: 0.28.2 + optionalDependencies: + fsevents: 2.3.3 + + typescript@5.9.3: {} + + undici-types@6.21.0: {} + + vite@8.2.1(@types/node@22.20.1)(esbuild@0.28.2)(tsx@4.23.12): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.5 + postcss: 8.5.26 + rolldown: 1.2.4 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 22.20.1 + esbuild: 0.28.2 + fsevents: 2.3.3 + tsx: 4.23.12 + + vitest@4.1.10(@types/node@22.20.1)(vite@8.2.1(@types/node@22.20.1)(esbuild@0.28.2)(tsx@4.23.12)): + dependencies: + '@vitest/expect': 4.1.10 + '@vitest/mocker': 4.1.10(vite@8.2.1(@types/node@22.20.1)(esbuild@0.28.2)(tsx@4.23.12)) + '@vitest/pretty-format': 4.1.10 + '@vitest/runner': 4.1.10 + '@vitest/snapshot': 4.1.10 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + es-module-lexer: 2.3.1 + expect-type: 1.4.0 + magic-string: 0.30.21 + obug: 2.1.4 + pathe: 2.0.3 + picomatch: 4.0.5 + std-env: 4.2.0 + tinybench: 2.9.0 + tinyexec: 1.3.0 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.1 + vite: 8.2.1(@types/node@22.20.1)(esbuild@0.28.2)(tsx@4.23.12) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 22.20.1 + transitivePeerDependencies: + - msw + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..ad0ee26 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,9 @@ +# pnpm 11 no longer reads the `pnpm` field in package.json, and an unapproved +# install script fails the install outright rather than merely warning. +# +# esbuild (vitest's bundler) ships prebuilt binaries that its install script +# puts in place. Approved one package at a time rather than blanket-allowed, so +# a new dependency wanting a shell at install time stays a decision someone +# makes on purpose. +allowBuilds: + esbuild: true diff --git a/scripts/install-links.mjs b/scripts/install-links.mjs new file mode 100644 index 0000000..e55375a --- /dev/null +++ b/scripts/install-links.mjs @@ -0,0 +1,134 @@ +#!/usr/bin/env node +/** + * Symlink every bin/*.ts into ~/.local/bin, without the .ts suffix. + * + * Real executables on PATH, deliberately, and not shell aliases or functions. + * The moshcode pit runs its aliases with `zsh -c `, and `zsh -c` is a + * non-interactive shell: it reads neither ~/.zshrc nor ~/.zsh_aliases. A + * function defined there is simply not there, so `/alias tcfeed "tcfeed"` + * answered `command not found` while the identical word worked when typed at a + * prompt. A file on PATH works from an interactive shell, from `zsh -c`, and + * from the pit, because none of them have to have sourced anything first. + * + * node scripts/install-links.mjs # link + * node scripts/install-links.mjs --dry-run # say what it would do + * node scripts/install-links.mjs --force # take over links owned elsewhere + * node scripts/install-links.mjs --remove # unlink the ones we own + * + * These names already exist in ~/.local/bin pointing at ~/scripts/bin, so a + * plain run reports them as not-ours and changes nothing. --force takes over a + * *symlink*; a real file of that name is still refused, because clobbering + * someone's actual binary to install a convenience is not a trade this gets to + * make on its own. + */ + +import { chmodSync, existsSync, lstatSync, mkdirSync, readdirSync, readlinkSync, symlinkSync, unlinkSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(here, '..'); +const binDir = join(repoRoot, 'bin'); +const target = process.env.CLI_TOOLS_PREFIX ?? join(homedir(), '.local', 'bin'); + +const dryRun = process.argv.includes('--dry-run'); +const remove = process.argv.includes('--remove'); +const force = process.argv.includes('--force'); + +const commands = readdirSync(binDir) + .filter((entry) => entry.endsWith('.ts')) + .map((entry) => ({ name: entry.replace(/\.ts$/, ''), source: join(binDir, entry) })) + .sort((a, b) => a.name.localeCompare(b.name)); + +if (commands.length === 0) { + console.error('install-links: no bin/*.ts found'); + process.exit(1); +} + +if (!dryRun) mkdirSync(target, { recursive: true }); + +let changed = 0; +let skipped = 0; + +for (const { name, source } of commands) { + const link = join(target, name); + const existing = existsSync(link) || isBrokenLink(link); + + // Only ever touch a symlink that points into this repository. A real file of + // the same name is someone else's, and clobbering it to install a + // convenience is not a trade this script gets to make. + const ours = existing && isLinkInto(link, binDir); + + if (remove) { + if (!existing) continue; + if (!ours) { + console.warn(`SKIP ${link} — not a link into ${binDir}`); + skipped += 1; + continue; + } + console.log(`${dryRun ? 'WOULD-UNLINK' : 'UNLINK'} ${link}`); + if (!dryRun) unlinkSync(link); + changed += 1; + continue; + } + + if (existing && !ours) { + // A real file is never taken over, --force or not. + if (!force || !isSymlink(link)) { + console.warn( + `SKIP ${link} — ${isSymlink(link) ? 'points elsewhere; use --force' : 'is a real file'}`, + ); + skipped += 1; + continue; + } + console.log(`TAKEOVER ${link} — was -> ${readlinkSync(link)}`); + } + + if (existing && readlinkSync(link) === source) { + continue; + } + + console.log(`${dryRun ? 'WOULD-LINK' : 'LINK'} ${link} -> ${source}`); + if (!dryRun) { + if (existing) unlinkSync(link); + // The shebang only runs if the target is executable. + chmodSync(source, 0o755); + symlinkSync(source, link); + } + changed += 1; +} + +console.log( + `\n${remove ? 'Unlinked' : 'Linked'} ${changed} command(s) in ${target}` + + (skipped ? `, skipped ${skipped}` : ''), +); + +if (!remove && !process.env.PATH?.split(':').includes(target)) { + console.warn(`\nWARN: ${target} is not on PATH. Add it:\n export PATH="${target}:$PATH"`); +} + +function isSymlink(path) { + try { + return lstatSync(path).isSymbolicLink(); + } catch { + return false; + } +} + +function isBrokenLink(path) { + try { + return lstatSync(path).isSymbolicLink(); + } catch { + return false; + } +} + +function isLinkInto(path, directory) { + try { + if (!lstatSync(path).isSymbolicLink()) return false; + return resolve(dirname(path), readlinkSync(path)).startsWith(directory); + } catch { + return false; + } +} diff --git a/src/args.ts b/src/args.ts new file mode 100644 index 0000000..41fc963 --- /dev/null +++ b/src/args.ts @@ -0,0 +1,115 @@ +/** + * Argument parsing shared by every tool here. + * + * The bash originals hand-rolled a `while (($#))` loop per script, each + * supporting `--flag value` and `--flag=value` by writing both cases out. They + * drifted: one validated its numeric input, another accepted `--limit abc` and + * failed later inside an arithmetic expansion with a message naming neither + * the flag nor the value. + */ + +export class UsageError extends Error { + constructor(message: string) { + super(message); + this.name = 'UsageError'; + } +} + +export interface ParsedArgs { + flags: Set; + values: Map; + positional: string[]; +} + +export interface Spec { + /** Flags that take no value. */ + boolean?: readonly string[]; + /** Flags that require a value. */ + string?: readonly string[]; +} + +export function parseArgs(argv: readonly string[], spec: Spec): ParsedArgs { + const booleans = new Set(spec.boolean ?? []); + const strings = new Set(spec.string ?? []); + + const flags = new Set(); + const values = new Map(); + const positional: string[] = []; + + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]!; + + if (argument === '--') { + positional.push(...argv.slice(index + 1)); + break; + } + + if (!argument.startsWith('-')) { + positional.push(argument); + continue; + } + + const equals = argument.indexOf('='); + const name = equals === -1 ? argument : argument.slice(0, equals); + const inline = equals === -1 ? undefined : argument.slice(equals + 1); + + if (booleans.has(name)) { + if (inline !== undefined) { + throw new UsageError(`${name} does not take a value`); + } + flags.add(name); + continue; + } + + if (strings.has(name)) { + if (inline !== undefined) { + values.set(name, inline); + continue; + } + const next = argv[index + 1]; + // A following flag is a missing value, not the value. `--limit --apply` + // used to set limit to the string "--apply". + if (next === undefined || next.startsWith('-')) { + throw new UsageError(`${name} requires a value`); + } + values.set(name, next); + index += 1; + continue; + } + + throw new UsageError(`unknown option: ${name}`); + } + + return { flags, values, positional }; +} + +export function integer( + values: Map, + name: string, + fallback: number, + { min = 0, max = Number.MAX_SAFE_INTEGER }: { min?: number; max?: number } = {}, +): number { + const raw = values.get(name); + if (raw === undefined) return fallback; + + if (!/^\d+$/.test(raw)) { + throw new UsageError(`${name} must be a non-negative integer, got ${JSON.stringify(raw)}`); + } + + const parsed = Number(raw); + if (parsed < min || parsed > max) { + throw new UsageError(`${name} must be between ${min} and ${max}, got ${parsed}`); + } + + return parsed; +} + +/** Split `a,b , c` into `['a','b','c']`, dropping empties. */ +export function csv(values: Map, name: string): string[] { + const raw = values.get(name); + if (!raw) return []; + return raw + .split(',') + .map((item) => item.trim()) + .filter(Boolean); +} diff --git a/src/domain.ts b/src/domain.ts new file mode 100644 index 0000000..7c112a7 --- /dev/null +++ b/src/domain.ts @@ -0,0 +1,362 @@ +import { constants } from 'node:fs'; +import { access } from 'node:fs/promises'; +import net from 'node:net'; +import os from 'node:os'; +import path from 'node:path'; +import { run } from './exec.ts'; + +export const DEFAULT_REGISTRY = 'https://pit.moshcode.sh'; +export const DEFAULT_TIMEOUT_MS = 4000; +export const DIG = '/usr/bin/dig'; +export const RECORD_TYPES = ['A', 'AAAA', 'CNAME', 'MX', 'TXT', 'NS'] as const; + +export type RecordType = (typeof RECORD_TYPES)[number]; + +/** OpenRDAP flags producing non-JSON output; dropped because JSON is forced. */ +export const OUTPUT_FORMAT_FLAGS = new Set([ + '--text', '-w', '--whois', '-r', '--raw', '-j', '--json', +]); + +/** OpenRDAP flags that consume the next argument, so it is not the name. */ +export const VALUE_FLAGS = new Set([ + '-T', '--timeout', + '-s', '--server', + '-t', '--type', + '--cache-dir', '--bs-url', '--bs-ttl', + '-P', '--p12', + '-C', '--cert', + '-K', '--key', +]); + +export interface OwnOptions { + registry: string; + timeout: number; + name: string | null; +} + +export interface ParsedDomainArgs { + own: OwnOptions; + passthrough: string[]; + help?: boolean; + error?: string; +} + +/** + * Split our flags from OpenRDAP's. + * + * Unlike the shared `parseArgs`, this one must pass through flags it does not + * know, because most of them belong to another program. So it cannot reject an + * unknown option, and the price is that it has to know which foreign flags + * consume a value — otherwise `-s https://rdap.example` reads the URL as the + * name to look up. + */ +export function parseDomainArgs(argv: readonly string[]): ParsedDomainArgs { + const own: OwnOptions = { + registry: DEFAULT_REGISTRY, + timeout: DEFAULT_TIMEOUT_MS, + name: null, + }; + const passthrough: string[] = []; + const positionals: string[] = []; + + const bad = (message: string): ParsedDomainArgs => ({ own, passthrough, error: message }); + + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]!; + + if (argument === '-h' || argument === '--help') return { own, passthrough, help: true }; + + if (argument === '--registry' || argument === '--timeout' || argument === '--name') { + const value = argv[index + 1]; + index += 1; + if (value === undefined) return bad(`${argument} requires a value`); + + if (argument === '--registry') own.registry = value.replace(/\/+$/, ''); + else if (argument === '--name') own.name = value; + else { + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed) || parsed <= 0) { + return bad('--timeout must be a positive integer (ms)'); + } + own.timeout = parsed; + } + continue; + } + + if (argument.startsWith('--registry=')) { + own.registry = argument.slice('--registry='.length).replace(/\/+$/, ''); + continue; + } + + if (argument.startsWith('--timeout=')) { + const parsed = Number.parseInt(argument.slice('--timeout='.length), 10); + if (!Number.isFinite(parsed) || parsed <= 0) { + return bad('--timeout must be a positive integer (ms)'); + } + own.timeout = parsed; + continue; + } + + if (argument.startsWith('--name=')) { + own.name = argument.slice('--name='.length); + continue; + } + + const flagName = argument.startsWith('--') ? (argument.split('=')[0] ?? argument) : argument; + + if (OUTPUT_FORMAT_FLAGS.has(flagName)) continue; + + if (argument.startsWith('-')) { + passthrough.push(argument); + if (!argument.includes('=') && VALUE_FLAGS.has(flagName)) { + const value = argv[index + 1]; + index += 1; + if (value === undefined) return bad(`${argument} requires a value`); + passthrough.push(value); + } + continue; + } + + positionals.push(argument); + } + + // The final non-flag argument is the name; earlier positionals go to rdap. + if (!own.name && positionals.length > 0) own.name = positionals.pop() ?? null; + passthrough.push(...positionals); + + if (!own.name) return bad('no name given'); + + return { own, passthrough }; +} + +export async function fetchJson(url: string, timeoutMs: number): Promise> { + const response = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) }); + if (!response.ok) throw new Error(`HTTP ${response.status} from ${url}`); + return (await response.json()) as Record; +} + +export async function dig(args: readonly string[], timeoutMs: number): Promise { + const seconds = Math.max(1, Math.ceil(timeoutMs / 1000)); + const result = await run(DIG, [`+time=${seconds}`, '+tries=1', ...args], { + timeoutMs: timeoutMs + 1000, + }); + if (result.code !== 0 && !result.stdout) { + throw new Error(result.stderr.trim() || `dig exited ${result.code}`); + } + return result.stdout; +} + +const lines = (text: string): string[] => + text.split('\n').map((line) => line.trim()).filter(Boolean); + +export async function digLines( + name: string, + type: string, + server: string | null, + timeoutMs: number, +): Promise { + const args = [...(server ? [`@${server}`, '-p', '5354'] : []), '+short', name, type]; + return lines(await dig(args, timeoutMs)); +} + +export function tcpOpen(port: number, timeoutMs: number): Promise { + return new Promise((resolve) => { + const socket = net.connect({ host: '127.0.0.1', port }); + const done = (open: boolean): void => { + socket.destroy(); + resolve(open); + }; + socket.setTimeout(timeoutMs); + socket.once('connect', () => done(true)); + socket.once('timeout', () => done(false)); + socket.once('error', () => done(false)); + }); +} + +export async function findRdapBinary(): Promise { + const candidates = [path.join(os.homedir(), 'go', 'bin', 'rdap'), 'rdap', 'openrdap']; + + for (const candidate of candidates) { + try { + if (candidate.includes(path.sep)) { + await access(candidate, constants.X_OK); + } else { + const found = await run('which', [candidate]); + if (found.code !== 0) continue; + } + return candidate; + } catch { + // keep looking + } + } + return null; +} + +/** + * Every Moshpit ending, paged. + * + * The registry paginates at 1000. Any failure means "treat as non-moshpit", + * which is why the caller wraps this rather than this swallowing errors: a + * registry outage should downgrade the lookup, not silently claim the name is + * not a Moshpit one. + */ +export async function fetchPitTlds(registry: string, timeoutMs: number): Promise> { + const first = await fetchJson(`${registry}/api/moshpit/tlds?limit=1000&offset=0`, timeoutMs); + const entries: unknown[] = [...((first.tlds as unknown[]) ?? [])]; + const total = typeof first.total === 'number' ? first.total : entries.length; + + const pages: Promise[] = []; + for (let offset = entries.length; offset < total; offset += 1000) { + pages.push( + fetchJson(`${registry}/api/moshpit/tlds?limit=1000&offset=${offset}`, timeoutMs).then( + (page) => { + entries.push(...((page.tlds as unknown[]) ?? [])); + }, + ), + ); + } + await Promise.all(pages); + + return new Set( + entries + .map((entry) => + typeof entry === 'string' ? entry : String((entry as { tld?: unknown })?.tld ?? ''), + ) + .filter(Boolean), + ); +} + +export async function moshpitSection( + registry: string, + name: string, + timeoutMs: number, +): Promise> { + const query = `name=${encodeURIComponent(name)}`; + const [resolved, pins] = await Promise.all([ + fetchJson(`${registry}/api/moshpit/resolve?${query}&records=1`, timeoutMs), + fetchJson(`${registry}/api/moshpit/pins?${query}`, timeoutMs).catch(() => null), + ]); + return { ...resolved, pins: (pins?.pins as unknown[]) ?? [] }; +} + +export async function rdapSection( + binary: string, + passthrough: readonly string[], + name: string, + timeoutMs: number, +): Promise> { + const result = await run(binary, [...passthrough, '--json', name], { + timeoutMs: Math.max(timeoutMs * 4, 30_000), + }); + + if (result.code !== 0) { + return { error: `openrdap failed: ${result.stderr.trim() || `exited ${result.code}`}` }; + } + + try { + return JSON.parse(result.stdout) as Record; + } catch (error) { + return { + error: `openrdap failed: ${error instanceof Error ? error.message : String(error)}`, + }; + } +} + +export interface DnsSection { + records: Partial>; + hosts: string[]; + reverse: { address: string; ptr: string[] }[]; + axfr: { ns: string; transfer: string; lines?: number }[]; +} + +export async function dnsSection( + name: string, + timeoutMs: number, + moshpitData: Record | null, +): Promise { + const section: DnsSection = { records: {}, hosts: [], reverse: [], axfr: [] }; + + // Moshpit names resolve through the local bridge on 127.0.0.1:5354 when it + // is up; otherwise fall back to what the registry API returned. + const bridge = moshpitData ? await tcpOpen(5354, Math.min(timeoutMs, 1500)) : false; + const server = bridge ? '127.0.0.1' : null; + + if (moshpitData && !bridge) { + const records = moshpitData.records as Record | undefined; + if (records && typeof records === 'object') { + for (const type of RECORD_TYPES) { + const values = records[type] ?? records[type.toLowerCase()]; + if (Array.isArray(values) && values.length > 0) { + section.records[type] = values.map(String); + } + } + } + + const hosts = new Set(); + if (typeof moshpitData.target === 'string' && moshpitData.target) { + hosts.add(moshpitData.target); + } + for (const value of section.records.A ?? []) hosts.add(value); + for (const value of section.records.AAAA ?? []) hosts.add(value); + section.hosts = [...hosts]; + } else { + // One query per type, deliberately not ANY: authoritative servers are + // allowed to answer ANY with a minimal subset. + const answers = await Promise.all( + RECORD_TYPES.map(async (type) => { + try { + return [type, await digLines(name, type, server, timeoutMs)] as const; + } catch { + return [type, [] as string[]] as const; + } + }), + ); + + for (const [type, found] of answers) { + if (found.length > 0) section.records[type] = found; + } + + section.hosts = [ + ...new Set([...(section.records.A ?? []), ...(section.records.AAAA ?? [])]), + ]; + } + + section.reverse = await Promise.all( + section.hosts.map(async (address) => { + try { + const out = await dig( + [...(server ? [`@${server}`, '-p', '5354'] : []), '+short', '-x', address], + timeoutMs, + ); + return { address, ptr: lines(out) }; + } catch { + return { address, ptr: [] }; + } + }), + ); + + // AXFR against each authoritative nameserver; refusal is data, not failure. + for (const ns of section.records.NS ?? []) { + const host = ns.replace(/\.$/, ''); + const entry: { ns: string; transfer: string; lines?: number } = { + ns: host, + transfer: 'failed', + }; + try { + const out = await dig([`@${host}`, name, 'AXFR'], timeoutMs); + if (/status:\s*REFUSED/i.test(out)) { + entry.transfer = 'refused'; + } else if (/XFR size:/i.test(out)) { + entry.transfer = 'ok'; + entry.lines = out + .split('\n') + .filter((line) => line.trim() && !line.startsWith(';')).length; + } + } catch { + // stays "failed" + } + section.axfr.push(entry); + } + + return section; +} diff --git a/src/exec.ts b/src/exec.ts new file mode 100644 index 0000000..6c10f2a --- /dev/null +++ b/src/exec.ts @@ -0,0 +1,76 @@ +import { execFile } from 'node:child_process'; + +export interface RunResult { + code: number; + stdout: string; + stderr: string; +} + +export interface RunOptions { + /** Milliseconds before the child is killed. Default 120_000. */ + timeoutMs?: number; + env?: NodeJS.ProcessEnv; + cwd?: string; +} + +/** + * Run a command with an argv array and never a shell. + * + * The bash original interpolated PR URLs and branch names into command lines. + * Nothing there was attacker-controlled in practice, but the shape is the one + * that breaks on a branch called `feat/it's-fine` long before it breaks on + * anything malicious — and it breaks silently, because the shell splits the + * word and `gh` receives two arguments it does not recognise. + * + * A non-zero exit is a *result*, not an exception. `gh pr checks` exits 1 when + * a check is pending while still printing perfectly good JSON, and `gh pr + * update-branch` exits 1 on a conflict, which is a thing the caller wants to + * read rather than a thing that should unwind the run. + */ +export function run( + file: string, + args: readonly string[], + options: RunOptions = {}, +): Promise { + const { timeoutMs = 120_000, env, cwd } = options; + + return new Promise((resolve) => { + execFile( + file, + [...args], + { + timeout: timeoutMs, + // GitHub API responses are comfortably larger than the 1MB default, + // and truncation would surface as a JSON parse error blamed on gh. + maxBuffer: 64 * 1024 * 1024, + encoding: 'utf8', + ...(env ? { env } : {}), + ...(cwd ? { cwd } : {}), + }, + (error, stdout, stderr) => { + let code = 0; + + if (error) { + const withCode = error as NodeJS.ErrnoException & { code?: number | string }; + code = typeof withCode.code === 'number' ? withCode.code : 1; + + // ENOENT means the binary is missing, which is a setup problem and + // not something a caller can interpret from an exit code. + if (withCode.code === 'ENOENT') { + resolve({ + code: 127, + stdout: '', + stderr: `command not found: ${file}`, + }); + return; + } + } + + resolve({ code, stdout: stdout ?? '', stderr: stderr ?? '' }); + }, + ); + }); +} + +export const sleep = (ms: number): Promise => + new Promise((resolve) => setTimeout(resolve, ms)); diff --git a/src/fix-all.ts b/src/fix-all.ts new file mode 100644 index 0000000..381f9a3 --- /dev/null +++ b/src/fix-all.ts @@ -0,0 +1,37 @@ +/** + * The parts of `gh-prs-fix-all` that are decisions rather than side effects. + * + * They live here, and not in `bin/`, for a reason that cost a real test run to + * learn: a `bin/` entry point does its work at import time, so a test that + * imported it to reach one pure function *ran the tool*. The suite went from + * 60ms to 93 seconds and swept live pull requests with `--fix` implied. + * + * Nothing under `bin/` should export anything a test wants. If a function is + * worth testing, it belongs here. + */ + +/** + * Refuse a checkout that predates the `check` subcommand. + * + * tcfeed reads its first argument as a post count and falls back to 50 when it + * is not a number, so an older script answers `check` by fetching reddit and + * scanning ten strangers' repositories — a long, rate-limited, entirely wrong + * thing to do in response to "fix my pull requests". + */ +export function hasCheckSubcommand(source: string): boolean { + return source.includes("argument === 'check'"); +} + +/** + * `check` reports and changes nothing; `--fix` makes it act. + * + * `--dry-run`/`-n` are consumed here rather than passed on, because reporting + * is already what `check` does without `--fix`. + */ +export function buildArgs(argv: readonly string[]): string[] { + const dryRun = argv.some((argument) => argument === '--dry-run' || argument === '-n'); + const passthrough = argv.filter( + (argument) => argument !== '--dry-run' && argument !== '-n', + ); + return ['check', ...passthrough, ...(dryRun ? [] : ['--fix'])]; +} diff --git a/src/format.ts b/src/format.ts new file mode 100644 index 0000000..1d03350 --- /dev/null +++ b/src/format.ts @@ -0,0 +1,107 @@ +/** + * Table and time formatting shared by the listing tools. + * + * These were a jq program piped into an awk program. Both were correct; both + * were also the reason nobody touched the output, because changing a column + * meant editing two languages that agree only by convention about which field + * is which. + */ + +const SECOND = 1; +const MINUTE = 60; +const HOUR = 3600; +const DAY = 86_400; +const WEEK = 604_800; +const MONTH = 2_629_800; +const YEAR = 31_557_600; + +/** `3 hours ago`, matching the jq original's thresholds exactly. */ +export function timeAgo(iso: string, now: Date = new Date()): string { + const then = Date.parse(iso); + if (Number.isNaN(then)) return 'unknown'; + + // Clamped at zero: a clock a second behind the server should read "0 seconds + // ago", never "-1 seconds ago". + const seconds = Math.max(0, (now.getTime() - then) / 1000); + + const scale = (unit: number, name: string): string => { + const count = Math.floor(seconds / unit); + return `${count} ${name}${count === 1 ? '' : 's'} ago`; + }; + + if (seconds < MINUTE) return scale(SECOND, 'second'); + if (seconds < HOUR) return scale(MINUTE, 'minute'); + if (seconds < DAY) return scale(HOUR, 'hour'); + if (seconds < WEEK) return scale(DAY, 'day'); + if (seconds < MONTH) return scale(WEEK, 'week'); + if (seconds < YEAR) return scale(MONTH, 'month'); + return scale(YEAR, 'year'); +} + +/** Collapse the whitespace that would otherwise break a row across lines. */ +export const clean = (value: unknown): string => String(value ?? '').replace(/[\t\r\n]+/g, ' '); + +export function truncate(value: string, length: number): string { + return value.length > length ? `${value.slice(0, length - 3)}...` : value; +} + +// Written as an escape rather than a literal 0x1B byte: the raw character is +// invisible in a diff and does not survive every copy-paste intact. +const ESC = '\u001b'; + +/** An OSC-8 terminal hyperlink. */ +export const hyperlink = (text: string, url: string): string => + `${ESC}]8;;${url}${ESC}\\${text}${ESC}]8;;${ESC}\\`; + +export interface TableOptions { + /** Column indices to wrap in a link to the row's `url`. */ + linkColumns?: readonly number[]; + /** Per-row link target, by row index (header is row 0). */ + urls?: readonly (string | undefined)[]; + hyperlinks?: boolean; + gap?: string; +} + +/** + * Pad columns to the widest cell, then join. + * + * Padding is measured on the *unlinked* text. An OSC-8 escape is zero-width on + * screen but a dozen characters to `String.length`, so padding the decorated + * cell throws every following column out by the length of a URL — which is how + * the awk version stayed correct: it padded first and decorated second. + */ +export function table(rows: readonly (readonly string[])[], options: TableOptions = {}): string { + const { linkColumns = [], urls = [], hyperlinks = false, gap = ' ' } = options; + + const widths: number[] = []; + for (const row of rows) { + row.forEach((cell, column) => { + widths[column] = Math.max(widths[column] ?? 0, cell.length); + }); + } + + return rows + .map((row, rowIndex) => { + const url = urls[rowIndex]; + const last = row.length - 1; + + return row + .map((cell, column) => { + const padded = column < last ? cell.padEnd(widths[column] ?? 0) : cell; + const decorate = + hyperlinks && rowIndex > 0 && url && linkColumns.includes(column); + // Trailing pad sits outside the link so the clickable region is the + // text, not the whitespace after it. + if (!decorate) return padded; + const trimmed = padded.trimEnd(); + return hyperlink(trimmed, url) + ' '.repeat(padded.length - trimmed.length); + }) + .join(gap) + .trimEnd(); + }) + .join('\n'); +} + +/** Terminal hyperlinks only make sense on a real terminal. */ +export const supportsHyperlinks = (stream: { isTTY?: boolean } = process.stdout): boolean => + Boolean(stream.isTTY) && (process.env.TERM ?? 'dumb') !== 'dumb'; diff --git a/src/gh.ts b/src/gh.ts new file mode 100644 index 0000000..4d4ee54 --- /dev/null +++ b/src/gh.ts @@ -0,0 +1,295 @@ +import { run, sleep, type RunResult } from './exec.ts'; + +/** + * A typed front door to the `gh` CLI. + * + * The bash originals piped every response through `jq -r` and compared the + * result to a string. That reads fine and fails badly: `jq -r '.mergeable'` on + * a response that never had the field prints the four characters `null`, which + * is not `MERGEABLE`, so the PR is reported ineligible for a reason nobody + * wrote. The failure is indistinguishable from a genuine verdict. + * + * So responses are parsed once, validated by shape, and any field that is + * missing or unrecognised is *named* in the error rather than silently + * becoming a string. + */ + +export class GhError extends Error { + constructor( + message: string, + readonly result?: RunResult, + ) { + super(message); + this.name = 'GhError'; + } +} + +/** Values GitHub documents for `mergeable`, plus the honest fallback. */ +export const MERGEABLE = ['MERGEABLE', 'CONFLICTING', 'UNKNOWN'] as const; +export type Mergeable = (typeof MERGEABLE)[number]; + +export const MERGE_STATE = [ + 'BEHIND', + 'BLOCKED', + 'CLEAN', + 'DIRTY', + 'DRAFT', + 'HAS_HOOKS', + 'UNKNOWN', + 'UNSTABLE', +] as const; +export type MergeState = (typeof MERGE_STATE)[number]; + +/** Buckets `gh pr checks --json bucket` reports. */ +export const BUCKET = ['pass', 'fail', 'pending', 'skipping', 'cancel'] as const; +export type Bucket = (typeof BUCKET)[number]; + +export interface PullRequest { + url: string; + title: string; + state: string; + isDraft: boolean; + mergeable: Mergeable; + mergeStateStatus: MergeState; + headRefOid: string; +} + +export interface Check { + name: string; + bucket: Bucket; +} + +function fail(field: string, value: unknown, where: string): never { + throw new GhError( + `${where}: unexpected value for ${field}: ${JSON.stringify(value)}`, + ); +} + +function asString(value: unknown, field: string, where: string): string { + if (typeof value !== 'string') fail(field, value, where); + return value; +} + +function asEnum( + allowed: readonly T[], + value: unknown, + field: string, + where: string, +): T { + if (typeof value !== 'string' || !allowed.includes(value as T)) { + fail(field, value, where); + } + return value as T; +} + +export function parsePullRequest(raw: unknown, where = 'gh pr view'): PullRequest { + if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) { + throw new GhError(`${where}: expected an object, got ${JSON.stringify(raw)}`); + } + + const record = raw as Record; + + if (typeof record.isDraft !== 'boolean') fail('isDraft', record.isDraft, where); + + return { + url: asString(record.url, 'url', where), + title: asString(record.title, 'title', where), + state: asString(record.state, 'state', where), + isDraft: record.isDraft, + mergeable: asEnum(MERGEABLE, record.mergeable, 'mergeable', where), + mergeStateStatus: asEnum( + MERGE_STATE, + record.mergeStateStatus, + 'mergeStateStatus', + where, + ), + headRefOid: asString(record.headRefOid, 'headRefOid', where), + }; +} + +export function parseChecks(raw: unknown, where = 'gh pr checks'): Check[] { + if (!Array.isArray(raw)) { + throw new GhError(`${where}: expected an array, got ${JSON.stringify(raw)}`); + } + + return raw.map((entry) => { + const record = entry as Record; + return { + name: asString(record.name, 'name', where), + bucket: asEnum(BUCKET, record.bucket, 'bucket', where), + }; + }); +} + +export interface GhOptions { + /** Swap in a fake for tests. */ + exec?: typeof run; +} + +export class Gh { + private readonly exec: typeof run; + + constructor(options: GhOptions = {}) { + this.exec = options.exec ?? run; + } + + private async call(args: readonly string[]): Promise { + // GH_PAGER=cat so a configured pager cannot block on a TTY that is not + // there. The bash version set this on every call site and missed none by + // luck rather than design. + return this.exec('gh', args, { + env: { ...process.env, GH_PAGER: 'cat', CLICOLOR: '0' }, + }); + } + + private async json( + args: readonly string[], + parse: (raw: unknown) => T, + { allowNonZero = false }: { allowNonZero?: boolean } = {}, + ): Promise { + const result = await this.call(args); + + // Some subcommands exit non-zero *and* print usable JSON — `gh pr checks` + // does exactly that whenever anything is pending or failing. Reading the + // exit code there would discard the answer we asked for. + if (result.code !== 0 && !allowNonZero) { + throw new GhError( + `gh ${args.join(' ')} exited ${result.code}: ${result.stderr.trim()}`, + result, + ); + } + + const text = result.stdout.trim(); + if (!text) { + throw new GhError(`gh ${args.join(' ')} printed no JSON`, result); + } + + let raw: unknown; + try { + raw = JSON.parse(text); + } catch { + throw new GhError( + `gh ${args.join(' ')} printed output that is not JSON: ${text.slice(0, 200)}`, + result, + ); + } + + return parse(raw); + } + + /** + * Read a PR, retrying while GitHub is still computing mergeability. + * + * `UNKNOWN` is not a state a PR rests in; it means "ask again". Treating it + * as a verdict is how a perfectly mergeable PR gets skipped for + * `mergeable=UNKNOWN` a second after it was opened. + */ + async pullRequest( + url: string, + { attempts = 5, awaitReady = false, delayMs = 2000 } = {}, + ): Promise { + let last: unknown; + + for (let attempt = 1; attempt <= attempts; attempt += 1) { + try { + const pr = await this.json( + [ + 'pr', + 'view', + url, + '--json', + 'state,isDraft,mergeable,mergeStateStatus,headRefOid,title,url', + ], + (raw) => parsePullRequest(raw), + ); + + const settled = pr.mergeable !== 'UNKNOWN'; + const ready = !awaitReady || !pr.isDraft; + + if (settled && ready) return pr; + last = pr; + } catch (error) { + last = error; + } + + if (attempt < attempts) await sleep(delayMs); + } + + if (last instanceof Error) throw last; + if (last) return last as PullRequest; + throw new GhError(`could not read ${url}`); + } + + async checks(url: string): Promise { + try { + return await this.json( + ['pr', 'checks', url, '--json', 'bucket,name'], + (raw) => parseChecks(raw), + { allowNonZero: true }, + ); + } catch (error) { + // A PR with no checks at all makes `gh` print nothing rather than `[]`. + // That is "no checks", which the caller already has a rule for, and not + // an error worth aborting a sweep over. + if (error instanceof GhError && /printed no JSON/.test(error.message)) { + return []; + } + throw error; + } + } + + async searchPrs( + qualifier: 'org' | 'user', + owner: string, + { limit, includeDrafts }: { limit: number; includeDrafts: boolean }, + ): Promise<{ url: string; createdAt: string }[]> { + const args = [ + 'search', + 'prs', + `${qualifier}:${owner}`, + '--state=open', + '--archived=false', + '--sort=created', + '--order=asc', + `--limit=${limit}`, + '--json', + 'url,createdAt', + ]; + + if (!includeDrafts) args.push('--draft=false'); + + return this.json(args, (raw) => { + if (!Array.isArray(raw)) { + throw new GhError('gh search prs: expected an array'); + } + return raw.map((entry) => { + const record = entry as Record; + return { + url: asString(record.url, 'url', 'gh search prs'), + createdAt: asString(record.createdAt, 'createdAt', 'gh search prs'), + }; + }); + }); + } + + async ready(url: string): Promise { + return this.call(['pr', 'ready', url]); + } + + async updateBranch(url: string): Promise { + return this.call(['pr', 'update-branch', url]); + } + + /** + * Squash-merge, pinned to the head we judged. + * + * `--match-head-commit` is the whole safety property: between reading the + * checks and submitting the merge, someone can push. Without it the merge + * lands on a commit nothing verified. + * + * Deliberately no `--admin`. Branch protections stay enforced. + */ + async squashMerge(url: string, headSha: string): Promise { + return this.call(['pr', 'merge', url, '--squash', '--match-head-commit', headSha]); + } +} diff --git a/src/is-main.ts b/src/is-main.ts new file mode 100644 index 0000000..56c46c3 --- /dev/null +++ b/src/is-main.ts @@ -0,0 +1,27 @@ +import { realpathSync } from 'node:fs'; +import { pathToFileURL } from 'node:url'; + +/** + * Was this module executed, or merely imported? + * + * Every entry point under `bin/` guards its side effects with this. Without it + * an `import` of the module *runs the tool*, which is not a hypothetical: a + * test that imported `bin/gh-prs-fix-all.ts` to reach one pure function swept + * live pull requests with `--fix` implied and took the suite from 60ms to 93 + * seconds. + * + * The realpath matters. These commands are installed as symlinks in + * `~/.local/bin`, so `process.argv[1]` is the link while `import.meta.url` is + * the file it points at, and comparing them raw says "imported" for every + * installed command — disabling all of them at once. + */ +export function isMain(moduleUrl: string, argv: readonly string[] = process.argv): boolean { + const entry = argv[1]; + if (!entry) return false; + + try { + return pathToFileURL(realpathSync(entry)).href === moduleUrl; + } catch { + return pathToFileURL(entry).href === moduleUrl; + } +} diff --git a/src/prs-list.ts b/src/prs-list.ts new file mode 100644 index 0000000..6932041 --- /dev/null +++ b/src/prs-list.ts @@ -0,0 +1,130 @@ +import { Gh, GhError } from './gh.ts'; +import { clean, supportsHyperlinks, table, timeAgo, truncate } from './format.ts'; +import { run } from './exec.ts'; + +export interface SearchedPr { + url: string; + number: number; + title: string; + createdAt: string; + updatedAt: string; + repository: { nameWithOwner: string }; + author: { login: string } | null; +} + +export interface ListOptions { + orgs: string[]; + users: string[]; + limit: number; + hyperlinks?: boolean; + now?: Date; +} + +const HEADER = [ + 'ORG/USER', + 'REPOSITORY', + 'PR', + 'AUTHOR', + 'TITLE', + 'UPDATED', + 'LINK', +] as const; + +function parse(raw: unknown): SearchedPr[] { + if (!Array.isArray(raw)) throw new GhError('gh search prs: expected an array'); + + return raw.map((entry) => { + const record = entry as Record; + const repository = (record.repository ?? {}) as Record; + const author = record.author as Record | null | undefined; + + return { + url: String(record.url ?? ''), + number: Number(record.number ?? 0), + title: String(record.title ?? ''), + createdAt: String(record.createdAt ?? ''), + updatedAt: String(record.updatedAt ?? ''), + repository: { nameWithOwner: String(repository.nameWithOwner ?? '') }, + author: author && typeof author.login === 'string' ? { login: author.login } : null, + }; + }); +} + +export async function search( + options: ListOptions, + exec: typeof run = run, +): Promise { + const found = new Map(); + const warnings: string[] = []; + + for (const [qualifier, owners] of [ + ['org', options.orgs], + ['user', options.users], + ] as const) { + for (const owner of owners) { + const result = await exec( + 'gh', + [ + 'search', + 'prs', + `${qualifier}:${owner}`, + '--state=open', + '--archived=false', + '--sort=created', + '--order=desc', + `--limit=${options.limit}`, + '--json', + 'repository,number,title,url,author,createdAt,updatedAt', + ], + { env: { ...process.env, GH_PAGER: 'cat' } }, + ); + + if (result.code !== 0) { + warnings.push(`skipped inaccessible or invalid scope ${qualifier}:${owner}`); + continue; + } + + for (const pr of parse(JSON.parse(result.stdout || '[]'))) { + if (!found.has(pr.url)) found.set(pr.url, pr); + } + } + } + + for (const warning of warnings) process.stderr.write(`WARN: ${warning}\n`); + + // Newest first, matching the original's sort_by(.createdAt) | reverse. + return [...found.values()].sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1)); +} + +export function renderTable(prs: readonly SearchedPr[], options: ListOptions = {} as ListOptions): string { + const rows: string[][] = [[...HEADER]]; + const urls: (string | undefined)[] = [undefined]; + + for (const pr of prs) { + rows.push([ + pr.repository.nameWithOwner.split('/')[0] ?? '', + pr.repository.nameWithOwner, + `#${pr.number}`, + pr.author?.login ?? '-', + truncate(clean(pr.title), 70), + timeAgo(pr.updatedAt, options.now ?? new Date()), + pr.url, + ]); + urls.push(pr.url); + } + + return table(rows, { + // The PR number and the URL are both clickable, as before. + linkColumns: [2, 6], + urls, + hyperlinks: options.hyperlinks ?? supportsHyperlinks(), + }); +} + +export async function list(options: ListOptions, exec: typeof run = run): Promise { + const prs = await search(options, exec); + if (prs.length === 0) return 'No open PRs found.'; + return renderTable(prs, options); +} + +export { Gh }; diff --git a/src/prs-merge.ts b/src/prs-merge.ts new file mode 100644 index 0000000..95fa8f4 --- /dev/null +++ b/src/prs-merge.ts @@ -0,0 +1,351 @@ +import { Gh, type Check, type PullRequest } from './gh.ts'; +import { sleep } from './exec.ts'; + +export interface MergeOptions { + orgs: string[]; + users: string[]; + limit: number; + apply: boolean; + allowNoChecks: boolean; + readyDrafts: boolean; + fix: boolean; + fixWaitMs: number; + /** Poll interval while waiting on running checks. Shortened by tests. */ + pollMs: number; +} + +export interface Summary { + ready: number; + readied: number; + fixed: number; + merged: number; + skipped: number; + failed: number; +} + +export type Line = + | { kind: 'mode'; text: string } + | { kind: 'ready'; url: string; title: string; checks: number } + | { kind: 'merged'; url: string } + | { kind: 'readied'; url: string; title: string } + | { kind: 'would-ready'; url: string; title: string } + | { kind: 'fixing'; url: string; text: string } + | { kind: 'waiting'; url: string; pending: number } + | { kind: 'fixme'; url: string; text: string } + | { kind: 'skip'; url: string; reason: string; title: string } + | { kind: 'failed'; url: string; reason: string } + | { kind: 'warn'; text: string }; + +export const defaults = { + limit: 1000, + fixWaitMs: 600_000, + pollMs: 20_000, +} as const; + +const isBad = (check: Check): boolean => + check.bucket !== 'pass' && check.bucket !== 'skipping'; + +/** + * Why this PR cannot be merged, or empty when it can. + * + * One function so `--fix` re-judges with the same rules rather than a copy of + * them. In the bash version this logic was inline in the loop, which is why + * adding a re-check meant duplicating it. + */ +export function reasonNotMergeable( + pr: PullRequest, + checks: Check[], + allowNoChecks: boolean, +): string { + if (pr.state !== 'OPEN') return `state=${pr.state}`; + if (pr.isDraft) return 'draft'; + if (pr.mergeable !== 'MERGEABLE') return `mergeable=${pr.mergeable}`; + if (pr.mergeStateStatus !== 'CLEAN') return `mergeStateStatus=${pr.mergeStateStatus}`; + if (checks.length === 0 && !allowNoChecks) return 'no CI checks found'; + + const bad = checks.filter(isBad); + if (bad.length > 0) { + return `checks not green: ${bad.map((c) => `${c.name}=${c.bucket}`).join(', ')}`; + } + + return ''; +} + +/** A blocker `--fix` is willing to act on. */ +export function isRepairable(pr: PullRequest, checks: Check[]): boolean { + if (checks.some((check) => check.bucket === 'pending')) return true; + return ( + pr.mergeStateStatus === 'BEHIND' || + pr.mergeStateStatus === 'DIRTY' || + pr.mergeable === 'CONFLICTING' + ); +} + +export async function sweep( + options: MergeOptions, + gh: Gh, + emit: (line: Line) => void, +): Promise { + const summary: Summary = { + ready: 0, + readied: 0, + fixed: 0, + merged: 0, + skipped: 0, + failed: 0, + }; + + const found = new Map(); + + for (const [qualifier, owners] of [ + ['org', options.orgs], + ['user', options.users], + ] as const) { + for (const owner of owners) { + try { + const prs = await gh.searchPrs(qualifier, owner, { + limit: options.limit, + includeDrafts: options.readyDrafts, + }); + for (const pr of prs) { + if (!found.has(pr.url)) found.set(pr.url, pr.createdAt); + } + } catch (error) { + emit({ + kind: 'warn', + text: `skipped inaccessible or invalid scope ${qualifier}:${owner} — ${ + error instanceof Error ? error.message : String(error) + }`, + }); + } + } + } + + const urls = [...found.entries()] + .sort((a, b) => (a[1] < b[1] ? -1 : a[1] > b[1] ? 1 : 0)) + .map(([url]) => url); + + if (options.apply) { + emit({ + kind: 'mode', + text: options.readyDrafts + ? 'MODE: APPLY — drafts will be marked ready and eligible PRs squash-merged.' + : 'MODE: APPLY — eligible PRs will be squash-merged.', + }); + if (options.fix) { + emit({ + kind: 'mode', + text: 'MODE: FIX — repairable blockers will be repaired once, then re-judged.', + }); + } + } else { + emit({ kind: 'mode', text: 'MODE: DRY RUN — nothing will be merged. Add --apply to merge.' }); + } + + for (const url of urls) { + let pr: PullRequest; + + try { + pr = await gh.pullRequest(url); + } catch (error) { + emit({ + kind: 'skip', + url, + reason: `could not read PR metadata — ${ + error instanceof Error ? error.message : String(error) + }`, + title: '', + }); + summary.skipped += 1; + continue; + } + + // Take drafts out of draft first, then judge them like any other PR. + if (pr.isDraft && pr.state === 'OPEN' && options.readyDrafts) { + if (!options.apply) { + emit({ kind: 'would-ready', url, title: pr.title }); + summary.skipped += 1; + continue; + } + + const readied = await gh.ready(url); + if (readied.code !== 0) { + emit({ kind: 'failed', url, reason: 'could not mark draft ready' }); + summary.failed += 1; + continue; + } + + emit({ kind: 'readied', url, title: pr.title }); + summary.readied += 1; + + try { + pr = await gh.pullRequest(url, { awaitReady: true }); + } catch (error) { + emit({ + kind: 'skip', + url, + reason: `could not re-read PR metadata after marking ready — ${ + error instanceof Error ? error.message : String(error) + }`, + title: pr.title, + }); + summary.skipped += 1; + continue; + } + } + + let checks = await gh.checks(url); + let reason = reasonNotMergeable(pr, checks, options.allowNoChecks); + + if (reason && options.fix && isRepairable(pr, checks)) { + const repaired = await repair({ url, checks, reason, options, gh, emit }); + + if (repaired) { + summary.fixed += 1; + try { + pr = await gh.pullRequest(url); + checks = await gh.checks(url); + reason = reasonNotMergeable(pr, checks, options.allowNoChecks); + } catch (error) { + emit({ + kind: 'warn', + text: `could not re-read ${url} after repair — ${ + error instanceof Error ? error.message : String(error) + }`, + }); + } + } + } + + if (reason) { + emit({ kind: 'skip', url, reason, title: pr.title }); + summary.skipped += 1; + continue; + } + + emit({ kind: 'ready', url, title: pr.title, checks: checks.length }); + summary.ready += 1; + + if (!options.apply) continue; + + const merged = await gh.squashMerge(url, pr.headRefOid); + if (merged.code === 0) { + emit({ kind: 'merged', url }); + summary.merged += 1; + } else { + emit({ + kind: 'failed', + url, + reason: `GitHub refused the merge: ${merged.stderr.trim() || merged.stdout.trim()}`, + }); + summary.failed += 1; + } + } + + return summary; +} + +/** + * One repair attempt. Returns true when something was done. + * + * Deliberately not a loop: if a repair did not make the PR mergeable, doing it + * again will not either, and a tool that keeps trying turns a sweep into an + * afternoon of API calls. + */ +async function repair(args: { + url: string; + checks: Check[]; + reason: string; + options: MergeOptions; + gh: Gh; + emit: (line: Line) => void; +}): Promise { + const { url, checks, reason, options, gh, emit } = args; + + // Checks still running. The PR is not blocked, it is unfinished — the only + // defect is that we looked too early. This is the common false skip. + if (checks.some((check) => check.bucket === 'pending')) { + emit({ + kind: 'fixing', + url, + text: `checks still running; waiting up to ${Math.round(options.fixWaitMs / 1000)}s`, + }); + await waitForChecks(url, options, gh, emit); + return true; + } + + // Base branch moved. GitHub merges it in without a local checkout, and only + // when the result needs no human judgement. + emit({ kind: 'fixing', url, text: `${reason}; asking GitHub to merge the base branch in` }); + + const updated = await gh.updateBranch(url); + + if (updated.code !== 0) { + // A real conflict. Print what GitHub said and leave it alone: choosing + // between two authors' intent is not a batch operation. + const message = (updated.stderr.trim() || updated.stdout.trim()).replace(/\s+/g, ' '); + emit({ kind: 'fixme', url, text: `GitHub could not merge the base in: ${message}` }); + return false; + } + + // New head, so every check re-runs. Waiting here is what makes the repair + // worth anything — otherwise the re-judge sees a pending suite and skips for + // the very reason we just set in motion. + await waitForChecks(url, options, gh, emit); + return true; +} + +async function waitForChecks( + url: string, + options: MergeOptions, + gh: Gh, + emit: (line: Line) => void, +): Promise { + const deadline = Date.now() + options.fixWaitMs; + + for (;;) { + const checks = await gh.checks(url); + const pending = checks.filter((check) => check.bucket === 'pending').length; + + if (pending === 0) return; + if (Date.now() >= deadline) return; + + emit({ kind: 'waiting', url, pending }); + await sleep(options.pollMs); + } +} + +export function render(line: Line): string { + switch (line.kind) { + case 'mode': + return line.text; + case 'ready': + return `READY ${line.url} — ${line.checks} checks green — ${line.title}`; + case 'merged': + return `MERGED ${line.url}`; + case 'readied': + return `READIED ${line.url} — ${line.title}`; + case 'would-ready': + return `WOULD-READY ${line.url} — draft; would mark ready, then re-check — ${line.title}`; + case 'fixing': + return `FIXING ${line.url} — ${line.text}`; + case 'waiting': + return ` … ${line.pending} check(s) still running on ${line.url}; waiting`; + case 'fixme': + return `FIXME ${line.url} — ${line.text}`; + case 'skip': + return `SKIP ${line.url} — ${line.reason} — ${line.title}`; + case 'failed': + return `FAILED ${line.url} — ${line.reason}`; + case 'warn': + return `WARN: ${line.text}`; + } +} + +export function renderSummary(summary: Summary): string { + return ( + `\nSummary: ready=${summary.ready} readied=${summary.readied} ` + + `fixed=${summary.fixed} merged=${summary.merged} ` + + `skipped=${summary.skipped} failed=${summary.failed}` + ); +} diff --git a/src/tcfeed-launch.ts b/src/tcfeed-launch.ts new file mode 100644 index 0000000..03cb5e9 --- /dev/null +++ b/src/tcfeed-launch.ts @@ -0,0 +1,58 @@ +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { homedir } from 'node:os'; +import { spawn } from 'node:child_process'; + +/** + * Launch `tcfeed.ts`, which lives in the threatcrush checkout rather than here. + * + * It is not vendored because it is 94KB of scanner-adjacent code that changes + * with the scanner, and a copy would drift the moment threatcrush shipped + * anything. What lives here is only the part that has to be on PATH. + * + * Everything configurable is read from the environment by the script itself, + * so TCFEED_MIN_GAP, TCFEED_MAX, TCFEED_PAUSE, TCFEED_SUB, TCFEED_CACHE, TC_BIN + * and the rest keep working exactly as before. + */ + +export const DEFAULT_REPO = join(homedir(), 'src', 'profullstack', 'threatcrush'); + +export function resolveScript(repo = process.env.TCFEED_REPO ?? DEFAULT_REPO): { + repo: string; + script: string; + exists: boolean; +} { + const script = join(repo, 'bin', 'tcfeed.ts'); + return { repo, script, exists: existsSync(script) }; +} + +export function missingScriptMessage(name: string, repo: string, script: string): string { + return [ + `${name}: no script at ${script}`, + ` git -C ${repo} pull # it lives in the threatcrush repo`, + ' TCFEED_REPO=/elsewhere # if the checkout moved', + ].join('\n'); +} + +export function launch(name: string, args: readonly string[]): Promise { + const { repo, script, exists } = resolveScript(); + + if (!exists) { + process.stderr.write(`${missingScriptMessage(name, repo, script)}\n`); + return Promise.resolve(1); + } + + // Run from the repo so npx resolves tsx against its node_modules before + // reaching for the network. The script itself does not care where it is. + return new Promise((resolve) => { + const child = spawn('npx', ['--yes', 'tsx', 'bin/tcfeed.ts', ...args], { + cwd: repo, + stdio: 'inherit', + }); + child.on('error', (error) => { + process.stderr.write(`${name}: could not start tsx — ${error.message}\n`); + resolve(1); + }); + child.on('close', (code) => resolve(code ?? 1)); + }); +} diff --git a/test/format.test.ts b/test/format.test.ts new file mode 100644 index 0000000..e74a131 --- /dev/null +++ b/test/format.test.ts @@ -0,0 +1,221 @@ +import { describe, expect, it } from 'vitest'; +import { clean, hyperlink, table, timeAgo, truncate } from '../src/format.ts'; +import { csv, integer, parseArgs, UsageError } from '../src/args.ts'; +import { buildArgs, hasCheckSubcommand } from '../src/fix-all.ts'; +import { parseDomainArgs } from '../src/domain.ts'; +import { isMain } from '../src/is-main.ts'; + +const NOW = new Date('2026-08-16T12:00:00Z'); +const ago = (seconds: number): string => + timeAgo(new Date(NOW.getTime() - seconds * 1000).toISOString(), NOW); + +describe('timeAgo', () => { + it('matches the jq original at each threshold', () => { + expect(ago(1)).toBe('1 second ago'); + expect(ago(59)).toBe('59 seconds ago'); + expect(ago(60)).toBe('1 minute ago'); + expect(ago(3599)).toBe('59 minutes ago'); + expect(ago(3600)).toBe('1 hour ago'); + expect(ago(86_399)).toBe('23 hours ago'); + expect(ago(86_400)).toBe('1 day ago'); + expect(ago(604_800)).toBe('1 week ago'); + expect(ago(2_629_800)).toBe('1 month ago'); + expect(ago(31_557_600)).toBe('1 year ago'); + }); + + it('never reads negative when the clock is behind the server', () => { + expect(timeAgo(new Date(NOW.getTime() + 5000).toISOString(), NOW)).toBe('0 seconds ago'); + }); + + it('says so rather than printing NaN for an unparseable date', () => { + expect(timeAgo('not a date', NOW)).toBe('unknown'); + }); +}); + +describe('cell formatting', () => { + it('collapses whitespace that would break a row across lines', () => { + expect(clean('a\nb\tc')).toBe('a b c'); + expect(clean(null)).toBe(''); + }); + + it('truncates to exactly the requested width, ellipsis included', () => { + expect(truncate('abcdefghij', 10)).toBe('abcdefghij'); + expect(truncate('abcdefghijk', 10)).toHaveLength(10); + expect(truncate('abcdefghijk', 10)).toBe('abcdefg...'); + }); +}); + +describe('table', () => { + const rows = [ + ['ORG', 'PR'], + ['acme', '#1'], + ['verylongowner', '#22'], + ]; + + it('pads columns to the widest cell', () => { + const out = table(rows).split('\n'); + expect(out[0]).toBe('ORG PR'); + expect(out[1]).toBe('acme #1'); + expect(out[2]).toBe('verylongowner #22'); + }); + + it('leaves plain text alone when hyperlinks are off', () => { + expect(table(rows, { linkColumns: [1], urls: [undefined, 'u1', 'u2'] })).not.toContain( + '', + ); + }); + + /** + * The bug this guards: an OSC-8 escape is zero-width on screen but a dozen + * characters to String.length. Padding the decorated cell throws every + * following column out by the length of a URL. + */ + it('measures padding on the undecorated text', () => { + const linked = table( + [ + ['A', 'B'], + ['x', 'y'], + ['xxxxx', 'y'], + ], + { linkColumns: [0], urls: [undefined, 'https://example.com/1', 'https://example.com/2'], hyperlinks: true }, + ).split('\n'); + + // Strip the escapes and the layout must be identical to the plain version. + const stripped = linked.map((line) => line.replace(/\]8;;[^]*\\/g, '')); + expect(stripped[1]).toBe('x y'); + expect(stripped[2]).toBe('xxxxx y'); + }); + + it('never decorates the header row', () => { + const out = table(rows, { + linkColumns: [0], + urls: ['header-url', 'u1', 'u2'], + hyperlinks: true, + }).split('\n'); + expect(out[0]).not.toContain(''); + }); + + it('builds a well-formed OSC-8 sequence', () => { + expect(hyperlink('text', 'https://example.com')).toBe( + ']8;;https://example.com\\text]8;;\\', + ); + }); +}); + +describe('parseArgs', () => { + const spec = { boolean: ['--apply'], string: ['--limit'] } as const; + + it('accepts both --flag value and --flag=value', () => { + expect(parseArgs(['--limit', '5'], spec).values.get('--limit')).toBe('5'); + expect(parseArgs(['--limit=5'], spec).values.get('--limit')).toBe('5'); + }); + + /** `--limit --apply` used to set limit to the string "--apply". */ + it('treats a following flag as a missing value', () => { + expect(() => parseArgs(['--limit', '--apply'], spec)).toThrow(UsageError); + }); + + it('rejects a value on a boolean flag', () => { + expect(() => parseArgs(['--apply=yes'], spec)).toThrow(/does not take a value/); + }); + + it('rejects an unknown option instead of ignoring it', () => { + expect(() => parseArgs(['--nope'], spec)).toThrow(/unknown option: --nope/); + }); + + it('stops flag parsing at --', () => { + expect(parseArgs(['--', '--limit'], spec).positional).toEqual(['--limit']); + }); + + it('validates integers with the flag name in the message', () => { + const { values } = parseArgs(['--limit', 'abc'], spec); + expect(() => integer(values, '--limit', 1)).toThrow(/--limit must be a non-negative integer/); + }); + + it('bounds integers', () => { + const { values } = parseArgs(['--limit', '5000'], spec); + expect(() => integer(values, '--limit', 1, { min: 1, max: 1000 })).toThrow(/between 1 and 1000/); + }); + + it('splits and trims csv, dropping empties', () => { + const values = new Map([['--orgs', ' a , b ,, c ']]); + expect(csv(values, '--orgs')).toEqual(['a', 'b', 'c']); + expect(csv(new Map(), '--orgs')).toEqual([]); + }); +}); + +describe('gh-prs-fix-all argument shaping', () => { + it('adds --fix by default', () => { + expect(buildArgs([])).toEqual(['check', '--fix']); + }); + + it('omits --fix for a dry run, and does not pass the flag on', () => { + expect(buildArgs(['--dry-run'])).toEqual(['check']); + expect(buildArgs(['-n'])).toEqual(['check']); + }); + + it('passes repositories through', () => { + expect(buildArgs(['owner/name'])).toEqual(['check', 'owner/name', '--fix']); + expect(buildArgs(['owner/name', '-n'])).toEqual(['check', 'owner/name']); + }); + + it('recognises the check subcommand guard', () => { + expect(hasCheckSubcommand("if (argument === 'check') {")).toBe(true); + expect(hasCheckSubcommand('const x = 1;')).toBe(false); + }); +}); + +describe('isMain', () => { + /** + * The regression this exists for: importing `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. Nothing under bin/ may + * do work at import time. + */ + it('is false when the module was imported rather than executed', () => { + expect(isMain('file:///tools/bin/thing.ts', ['node', '/tools/bin/other.ts'])).toBe(false); + }); + + it('is true when the module is the entry point', () => { + const url = new URL(import.meta.url); + expect(isMain(url.href, ['node', url.pathname])).toBe(true); + }); + + it('is false with no entry point at all', () => { + expect(isMain('file:///tools/bin/thing.ts', ['node'])).toBe(false); + }); +}); + +describe('domainjson argument parsing', () => { + it('takes the final non-flag argument as the name', () => { + const parsed = parseDomainArgs(['-s', 'https://rdap.example', 'example.com']); + expect(parsed.own.name).toBe('example.com'); + // The server URL must not be mistaken for the name. + expect(parsed.passthrough).toEqual(['-s', 'https://rdap.example']); + }); + + it('drops output-format flags because JSON is forced', () => { + expect(parseDomainArgs(['--text', '--whois', 'example.com']).passthrough).toEqual([]); + }); + + it('strips trailing slashes from the registry', () => { + expect(parseDomainArgs(['--registry', 'https://x.test///', 'a.com']).own.registry).toBe( + 'https://x.test', + ); + }); + + it('rejects a non-positive timeout', () => { + expect(parseDomainArgs(['--timeout', '0', 'a.com']).error).toMatch(/positive integer/); + expect(parseDomainArgs(['--timeout=abc', 'a.com']).error).toMatch(/positive integer/); + }); + + it('reports a missing name rather than looking up nothing', () => { + expect(parseDomainArgs([]).error).toBe('no name given'); + }); + + it('prefers --name over the positional', () => { + const parsed = parseDomainArgs(['--name', 'chosen.com', 'other.com']); + expect(parsed.own.name).toBe('chosen.com'); + expect(parsed.passthrough).toContain('other.com'); + }); +}); diff --git a/test/prs-merge.test.ts b/test/prs-merge.test.ts new file mode 100644 index 0000000..cbd7c70 --- /dev/null +++ b/test/prs-merge.test.ts @@ -0,0 +1,322 @@ +import { describe, expect, it } from 'vitest'; +import { Gh, parseChecks, parsePullRequest, GhError } from '../src/gh.ts'; +import type { RunResult } from '../src/exec.ts'; +import { + defaults, + isRepairable, + reasonNotMergeable, + render, + sweep, + type Line, + type MergeOptions, +} from '../src/prs-merge.ts'; + +const PR_URL = 'https://github.com/acme/repo/pull/1'; + +interface StubStep { + mergeable?: string; + mergeStateStatus?: string; + isDraft?: boolean; + state?: string; + checks?: { name: string; bucket: string }[]; +} + +/** + * A scripted `gh`. + * + * Each PR read and check read advances through `steps`, so a test can say "the + * first look sees a pending suite, the second sees it green" — which is the + * behaviour --fix exists for and the one a single-shot stub cannot express. + */ +function stubGh(options: { + steps: StubStep[]; + updateBranchFails?: boolean; + mergeFails?: boolean; +}) { + const calls: string[] = []; + let viewIndex = 0; + let checkIndex = 0; + + const step = (index: number): StubStep => + options.steps[Math.min(index, options.steps.length - 1)]!; + + const exec = async (_file: string, args: readonly string[]): Promise => { + calls.push(args.join(' ')); + const ok = (stdout: string): RunResult => ({ code: 0, stdout, stderr: '' }); + + if (args[0] === 'search') { + return ok(JSON.stringify([{ url: PR_URL, createdAt: '2026-01-01T00:00:00Z' }])); + } + + if (args[0] === 'pr' && args[1] === 'view') { + const current = step(viewIndex); + viewIndex += 1; + return ok( + JSON.stringify({ + url: PR_URL, + title: 'stub pr', + state: current.state ?? 'OPEN', + isDraft: current.isDraft ?? false, + mergeable: current.mergeable ?? 'MERGEABLE', + mergeStateStatus: current.mergeStateStatus ?? 'CLEAN', + headRefOid: 'deadbeef', + }), + ); + } + + if (args[0] === 'pr' && args[1] === 'checks') { + const current = step(checkIndex); + checkIndex += 1; + const checks = current.checks ?? [{ name: 'test', bucket: 'pass' }]; + // gh exits non-zero while still printing JSON when anything is pending. + return { + code: checks.some((c) => c.bucket !== 'pass') ? 1 : 0, + stdout: JSON.stringify(checks), + stderr: '', + }; + } + + if (args[0] === 'pr' && args[1] === 'update-branch') { + return options.updateBranchFails + ? { code: 1, stdout: '', stderr: 'X Cannot update PR branch due to conflicts' } + : ok('Updated branch'); + } + + if (args[0] === 'pr' && args[1] === 'merge') { + return options.mergeFails + ? { code: 1, stdout: '', stderr: 'refused' } + : ok('Merged'); + } + + if (args[0] === 'pr' && args[1] === 'ready') return ok(''); + + return ok(''); + }; + + return { gh: new Gh({ exec }), calls }; +} + +function baseOptions(overrides: Partial = {}): MergeOptions { + return { + orgs: ['acme'], + users: [], + limit: 10, + apply: true, + allowNoChecks: false, + readyDrafts: true, + fix: false, + fixWaitMs: 1_000, + pollMs: 1, + ...overrides, + }; +} + +async function runSweep(gh: Gh, options: MergeOptions) { + const lines: Line[] = []; + const summary = await sweep(options, gh, (line) => lines.push(line)); + return { summary, lines, text: lines.map(render).join('\n') }; +} + +describe('eligibility rules', () => { + const pr = { + url: PR_URL, + title: 't', + state: 'OPEN', + isDraft: false, + mergeable: 'MERGEABLE', + mergeStateStatus: 'CLEAN', + headRefOid: 'abc', + } as const; + + it('accepts an open, clean PR with green checks', () => { + expect(reasonNotMergeable(pr, [{ name: 'test', bucket: 'pass' }], false)).toBe(''); + }); + + it('names the specific blocker', () => { + expect(reasonNotMergeable({ ...pr, state: 'CLOSED' }, [], false)).toBe('state=CLOSED'); + expect(reasonNotMergeable({ ...pr, isDraft: true }, [], false)).toBe('draft'); + expect(reasonNotMergeable({ ...pr, mergeable: 'CONFLICTING' }, [], false)).toBe( + 'mergeable=CONFLICTING', + ); + expect(reasonNotMergeable({ ...pr, mergeStateStatus: 'UNSTABLE' }, [], false)).toBe( + 'mergeStateStatus=UNSTABLE', + ); + expect(reasonNotMergeable(pr, [], false)).toBe('no CI checks found'); + expect(reasonNotMergeable(pr, [{ name: 'test', bucket: 'fail' }], false)).toBe( + 'checks not green: test=fail', + ); + }); + + it('treats skipping as acceptable but pending as not', () => { + expect(reasonNotMergeable(pr, [{ name: 'a', bucket: 'skipping' }], false)).toBe(''); + expect(reasonNotMergeable(pr, [{ name: 'a', bucket: 'pending' }], false)).toContain( + 'a=pending', + ); + }); + + it('only calls mechanical blockers repairable', () => { + expect(isRepairable(pr, [{ name: 'a', bucket: 'pending' }])).toBe(true); + expect(isRepairable({ ...pr, mergeStateStatus: 'BEHIND' }, [])).toBe(true); + expect(isRepairable({ ...pr, mergeable: 'CONFLICTING' }, [])).toBe(true); + // A check that ran and failed is a result, not an obstacle. + expect(isRepairable(pr, [{ name: 'a', bucket: 'fail' }])).toBe(false); + }); +}); + +describe('gh response validation', () => { + it('names the offending field rather than yielding a null string', () => { + // `jq -r '.mergeable'` printed the four characters "null" here, which is + // not MERGEABLE, so the PR read as ineligible for a reason nobody wrote. + expect(() => parsePullRequest({ url: 'u', title: 't', state: 'OPEN', isDraft: false, mergeStateStatus: 'CLEAN', headRefOid: 'a' })) + .toThrow(/mergeable/); + }); + + it('rejects a bucket it does not know instead of silently passing it', () => { + expect(() => parseChecks([{ name: 'a', bucket: 'sideways' }])).toThrow(/bucket/); + }); + + it('accepts the documented shapes', () => { + expect(parseChecks([{ name: 'a', bucket: 'pass' }])).toEqual([ + { name: 'a', bucket: 'pass' }, + ]); + }); + + it('reports non-JSON output as such', async () => { + const gh = new Gh({ + exec: async () => ({ code: 0, stdout: 'gh: not logged in', stderr: '' }), + }); + await expect(gh.pullRequest(PR_URL, { attempts: 1 })).rejects.toBeInstanceOf(GhError); + }); +}); + +describe('sweep', () => { + it('merges an eligible PR, pinned to the head it judged', async () => { + const { gh, calls } = stubGh({ steps: [{}] }); + const { summary, text } = await runSweep(gh, baseOptions()); + + expect(summary.merged).toBe(1); + expect(text).toContain('MERGED'); + expect(calls.some((c) => c.includes('--match-head-commit deadbeef'))).toBe(true); + // Protections stay enforced. + expect(calls.some((c) => c.includes('--admin'))).toBe(false); + }); + + it('merges nothing in a dry run', async () => { + const { gh, calls } = stubGh({ steps: [{}] }); + const { summary } = await runSweep(gh, baseOptions({ apply: false })); + + expect(summary.ready).toBe(1); + expect(summary.merged).toBe(0); + expect(calls.some((c) => c.startsWith('pr merge'))).toBe(false); + }); + + it('skips a conflicting PR untouched when --fix is off', async () => { + const { gh, calls } = stubGh({ + steps: [{ mergeable: 'CONFLICTING', mergeStateStatus: 'DIRTY' }], + }); + const { summary, text } = await runSweep(gh, baseOptions()); + + expect(summary.skipped).toBe(1); + expect(text).toContain('mergeable=CONFLICTING'); + expect(calls.some((c) => c.startsWith('pr update-branch'))).toBe(false); + }); + + it('repairs a BEHIND branch and then merges it', async () => { + const { gh, calls } = stubGh({ + steps: [{ mergeStateStatus: 'BEHIND' }, { mergeStateStatus: 'CLEAN' }], + }); + const { summary, text } = await runSweep(gh, baseOptions({ fix: true })); + + expect(text).toContain('FIXING'); + expect(summary.fixed).toBe(1); + expect(summary.merged).toBe(1); + expect(calls.some((c) => c.startsWith('pr update-branch'))).toBe(true); + }); + + it('refuses to guess at a conflict GitHub will not merge', async () => { + const { gh, calls } = stubGh({ + steps: [{ mergeable: 'CONFLICTING', mergeStateStatus: 'DIRTY' }], + updateBranchFails: true, + }); + const { summary, text } = await runSweep(gh, baseOptions({ fix: true })); + + expect(text).toContain('FIXME'); + expect(text).toContain('Cannot update PR branch due to conflicts'); + expect(summary.merged).toBe(0); + expect(summary.skipped).toBe(1); + // Repair did not succeed, so it is not counted as one. + expect(summary.fixed).toBe(0); + expect(calls.some((c) => c.startsWith('pr merge'))).toBe(false); + }); + + it('waits for a running check, then merges once it goes green', async () => { + // The false skip that motivated --fix: nothing was wrong with the PR, the + // sweep simply looked before Socket had reported. + const { gh } = stubGh({ + steps: [ + { mergeStateStatus: 'UNSTABLE', checks: [{ name: 'socket', bucket: 'pending' }] }, + { mergeStateStatus: 'CLEAN', checks: [{ name: 'socket', bucket: 'pass' }] }, + ], + }); + const { summary, text } = await runSweep(gh, baseOptions({ fix: true })); + + expect(text).toContain('checks still running'); + expect(summary.merged).toBe(1); + }); + + it('gives up waiting at --fix-wait rather than hanging', async () => { + const { gh } = stubGh({ + steps: [{ mergeStateStatus: 'UNSTABLE', checks: [{ name: 'x', bucket: 'pending' }] }], + }); + const { summary } = await runSweep(gh, baseOptions({ fix: true, fixWaitMs: 0, pollMs: 1 })); + + expect(summary.merged).toBe(0); + expect(summary.skipped).toBe(1); + }); + + it('counts a refused merge as failed, not skipped', async () => { + const { gh } = stubGh({ steps: [{}], mergeFails: true }); + const { summary } = await runSweep(gh, baseOptions()); + + expect(summary.failed).toBe(1); + expect(summary.merged).toBe(0); + }); + + it('marks a draft ready, then judges it normally', async () => { + const { gh, calls } = stubGh({ steps: [{ isDraft: true }, { isDraft: false }] }); + const { summary } = await runSweep(gh, baseOptions()); + + expect(calls.some((c) => c.startsWith('pr ready'))).toBe(true); + expect(summary.readied).toBe(1); + expect(summary.merged).toBe(1); + }); + + it('reports a draft without touching it in a dry run', async () => { + const { gh, calls } = stubGh({ steps: [{ isDraft: true }] }); + const { summary, text } = await runSweep(gh, baseOptions({ apply: false })); + + expect(text).toContain('WOULD-READY'); + expect(calls.some((c) => c.startsWith('pr ready'))).toBe(false); + expect(summary.skipped).toBe(1); + }); + + it('carries on when one scope is inaccessible', async () => { + const gh = new Gh({ + exec: async (_f, args) => + args[0] === 'search' + ? { code: 1, stdout: '', stderr: 'not found' } + : { code: 0, stdout: '[]', stderr: '' }, + }); + const { summary, text } = await runSweep(gh, baseOptions({ orgs: ['nope'] })); + + expect(text).toContain('skipped inaccessible or invalid scope'); + expect(summary.failed).toBe(0); + }); +}); + +describe('defaults', () => { + it('waits ten minutes and polls every twenty seconds', () => { + expect(defaults.fixWaitMs).toBe(600_000); + expect(defaults.pollMs).toBe(20_000); + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..6cd0210 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2023"], + "module": "ESNext", + "moduleResolution": "bundler", + "types": ["node"], + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "noImplicitOverride": true, + "noFallthroughCasesInSwitch": true, + "verbatimModuleSyntax": true, + "allowImportingTsExtensions": true, + "noEmit": true, + "skipLibCheck": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src", "bin", "test", "scripts"] +}