Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ TypeScript, installed as executables on `PATH`.
| [`openmcp`](#openmcp) | The OpenMCP catalog of MCP relays: list, find a tool, call it, register your own |
| [`shorten`](#shorten) | Mint a short link on the pit, and follow it from `/f/<code>` |
| [`sysupdate`](#sysupdate) | Update this box: apt lists, apt packages, snaps |
| [`scorecard`](#scorecard) | One weekly number for the whole fleet: traffic, channels, posts and ads, week over week |
| [`users-dump`](#users-dump) | Every user account across the fleet, as one CSV |
| [`user-export`](#user-export) | Every user across many databases, as one CSV |
| [`email-cleaner`](#email-cleaner) | Clean a mailing list: drop bad, role, disposable, duplicate and unlikely addresses |
Expand Down Expand Up @@ -605,6 +606,38 @@ daily entry:
5 13 * * * $HOME/.local/bin/gh-pulse >>$HOME/.local/share/gh-pulse/cron.log 2>&1
```

### `scorecard`

One weekly read across the fleet, built from the tools that already know the
numbers: `crawlproof dashboard --json` for traffic and ads, `myna dashboard
--json` for what went out. Nothing is recomputed here that one of them can
answer.

```
scorecard # the report, to stdout
scorecard --send # the same, emailed, snapshot kept
scorecard --range 30 # a month instead of a week
scorecard --json # the snapshot
scorecard --no-save # do not write this run into the ledger
```

The line it exists to draw is internal traffic. Most of the biggest referrers
on this fleet are our own properties sending people to each other, which is
real engagement and is not acquisition, so those are split out and the rest is
totalled as `acquired`.

The source column counts events, not visitors, and runs about twelve times the
visitor count. The report says so rather than leaving two different units in
one table to be read as one.

Week over week needs a previous week. Snapshots go in
`~/.local/share/scorecard` and the first run says it has nothing to compare
against instead of printing changes against zero. A weekly entry:

```
30 13 * * 5 export PATH=$HOME/.local/bin:$HOME/.local/share/mise/shims:/usr/local/bin:/usr/bin:/bin; $HOME/.local/bin/scorecard --send >>$HOME/.local/share/scorecard/cron.log 2>&1
```

### `gh-prs-fix-all`

Looks at every open threatcrush-scan pull request and fixes the ones broken
Expand Down
149 changes: 149 additions & 0 deletions bin/scorecard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
#!/usr/bin/env node
/**
* scorecard — one weekly number for the whole fleet.
*
* scorecard the report, to stdout
* scorecard --send the same, emailed, and the snapshot kept
* scorecard --range 30 a month instead of a week
* scorecard --json the snapshot as JSON
* scorecard --no-save do not write this run into the ledger
*
* It shells out to `crawlproof dashboard --json` and `myna dashboard --json`,
* which already know the traffic, the ads and what went out. Nothing is
* recomputed here that one of them can answer.
*
* Week over week needs a previous week, so the first run says so rather than
* printing changes against zero. Snapshots live in ~/.local/share/scorecard.
*
* Mail goes through Resend, like gh-pulse: RESEND_API_KEY from the
* environment, else from ~/.config/logicsrc/shell.env, which is the cron case.
*/
import { UsageError, parseArgs } from '../src/args.ts';
import { isMain } from '../src/is-main.ts';
import { run } from '../src/exec.ts';
import { loadShellEnv, sendResend } from '../src/gh-pulse.ts';
import {
dataDir,
formatHtml,
formatReport,
previousSnapshot,
saveSnapshot,
snapshotFrom,
type Snapshot,
} from '../src/scorecard.ts';

const DEFAULT_FROM = 'Fleet Scorecard <scorecard@profullstack.com>';

const USAGE = `scorecard — one weekly number for the whole fleet

USAGE
scorecard [--range 7] [--send] [--json] [--no-save]

OPTIONS
--range N days to report on (default 7)
--send email the report through Resend
--to a@b.c recipient (default SCORECARD_TO, then anthony@profullstack.com)
--from "N <a@b>" sender on a verified Resend domain
--json print the snapshot instead of the report
--no-save do not write this run into the ledger
`;

/**
* Run a command that prints JSON.
*
* A tool that fails returns null rather than throwing: half a report is worth
* having, and which half is missing is said out loud in the report's notes.
*/
export async function readJson<T>(command: string, args: string[], timeoutMs = 240_000): Promise<T | null> {
const result = await run(command, args, { timeoutMs });
if (result.code !== 0) return null;
try {
return JSON.parse(result.stdout) as T;
} catch {
return null;
}
}

/** crawlproof's range vocabulary, from a number of days. */
export function rangeFor(days: number): string {
if (days >= 28) return '1m';
if (days >= 7) return '1w';
return '1d';
}

async function collect(rangeDays: number): Promise<Snapshot> {
const [crawlproof, myna] = await Promise.all([
readJson<Parameters<typeof snapshotFrom>[0]>('crawlproof', [
'dashboard',
`--range=${rangeFor(rangeDays)}`,
'--json',
'--no-coinpay',
]),
readJson<Parameters<typeof snapshotFrom>[1]>('myna', ['dashboard', '--json'], 90_000),
]);
return snapshotFrom(crawlproof, myna, { rangeDays });
}

async function main(argv: readonly string[]): Promise<number> {
const { flags, values } = parseArgs(argv, {
boolean: ['-h', '--help', '--send', '--json', '--no-save'],
string: ['--range', '--to', '--from'],
});

if (flags.has('-h') || flags.has('--help')) {
process.stdout.write(USAGE);
return 0;
}

const rangeDays = Number(values.get('--range') ?? 7);
if (!Number.isFinite(rangeDays) || rangeDays < 1) {
throw new UsageError('--range wants a number of days, like 7 or 30.');
}

const snapshot = await collect(rangeDays);
const previous = previousSnapshot(snapshot.at);

if (flags.has('--json')) {
process.stdout.write(`${JSON.stringify(snapshot, null, 2)}\n`);
} else {
process.stdout.write(formatReport(snapshot, previous));
}

// Saved after the report is rendered, so this run never reads itself as the
// previous week, and a crash while rendering leaves the ledger untouched.
if (!flags.has('--no-save')) {
const file = saveSnapshot(snapshot, dataDir());
if (!flags.has('--json')) process.stdout.write(`\n kept ${file}\n`);
}

if (flags.has('--send')) {
loadShellEnv();
const key = process.env['RESEND_API_KEY'];
if (!key) throw new Error('RESEND_API_KEY is not set (and not in ~/.config/logicsrc/shell.env)');
const to = values.get('--to') ?? process.env['SCORECARD_TO'] ?? 'anthony@profullstack.com';
const id = await sendResend(
{
to,
from: values.get('--from') ?? DEFAULT_FROM,
subject: `Fleet scorecard, ${rangeDays} days to ${snapshot.at.slice(0, 10)}`,
html: formatHtml(snapshot, previous),
text: formatReport(snapshot, previous),
images: [],
},
key,
fetch,
);
process.stdout.write(` mailed ${to} (${id})\n`);
}

return 0;
}

if (isMain(import.meta.url)) {
main(process.argv.slice(2))
.then((code) => process.exit(code))
.catch((error: Error) => {
console.error(error instanceof UsageError ? error.message : `scorecard: ${error.message}`);
process.exit(1);
});
}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@profullstack/cli-tools",
"version": "0.48.0",
"version": "0.49.0",
"private": true,
"description": "Local command-line tools, in TypeScript, exposed on PATH.",
"type": "module",
Expand Down
1 change: 1 addition & 0 deletions src/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ const SUMMARIES: Record<string, string> = {
'gh-pulse': 'What moved on GitHub, last hour to all time, ranked, with traffic: by email, TUI or JSON',
hqtui: 'Every vital of this box, in the terminal: sockets, HTTP, sessions, services',
porkbun: 'Read and change DNS at Porkbun, and un-park a domain',
scorecard: 'One weekly number for the whole fleet: traffic, channels, posts and ads, week over week',
shorten: 'Mint a short link on the pit, and follow it from /f/<code>',
sysupdate: 'Update this box: apt lists, apt packages, snaps',
'user-export': 'Every user across many databases, as one CSV',
Expand Down
Loading
Loading