Skip to content

Commit 759dfc7

Browse files
ralyodioclaude
andauthored
Publish the read side as @profullstack/crawlproof, and make crawlproof a command (#241)
* Make `crawlproof` a command, not a cd and an npm incantation The CLI had no bin and was not on PATH, so every command in its own help and in this PR's description was a lie unless you first cd'd into the checkout and typed `npm run cli --`. `crawlproof dashboard` now works from anywhere. bin/crawlproof.mjs resolves the repo from its own location and runs tsx against cli/index.ts, preferring the checkout's tsx and falling back to npx so a fresh clone still starts. It relays the child's signal rather than inventing an exit code, so a TUI killed with ctrl+c does not look like a clean exit. The token now also falls back to the `token` field of ~/.crawlproof.json, the same shape and the same reasoning as ~/.coinpay.json: using the CLI should not be conditional on remembering to export a secret first. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HvWJ4336pxTFRdRbvsTQeD * Publish the read side as @profullstack/crawlproof A dashboard you can only run by cd'ing into a Next.js checkout is not installed anywhere. packages/cli publishes the two commands a box actually wants — `dashboard` and `stats` — so cli-tools can vendor it the way it vendors hqtui, and any machine can `npm i -g @profullstack/crawlproof`. Only those two, deliberately. Both are token-authed and pure HTTP, which is what makes them publishable: `audit` needs the audit engines and their model SDKs, and `sweep` needs a cron secret. Those stay in the repo. The package is a **build artifact**, not a second copy. lib/dashboard/* and cli/dashboard.ts stay where the suite already covers them and where the in-repo CLI already imports them; build.mjs bundles the same files with esbuild and leaves hqtui and the CoinPay SDK external, since both have their own release cadence. There is one implementation and it cannot drift. The stats printer moves to lib/dashboard/stats-text.ts and returns a string rather than writing to stdout, because two CLIs print it now and a printer that owns the process can be neither shared nor tested. Verified from the bundle against production: `crawlproof stats crawlproof.com` reads the token out of ~/.crawlproof.json with nothing exported, and the dashboard collects all 52 sites with zero source errors. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HvWJ4336pxTFRdRbvsTQeD --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent d270d24 commit 759dfc7

11 files changed

Lines changed: 618 additions & 26 deletions

File tree

‎bin/crawlproof.mjs‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
#!/usr/bin/env node
2+
// The `crawlproof` entry point.
3+
//
4+
// The CLI is TypeScript inside a Next.js app rather than a built package, so
5+
// running it means running tsx against cli/index.ts. Everything here is about
6+
// finding the repo and the right tsx no matter where the caller stood, because
7+
// the alternative — telling people to cd into a checkout and type
8+
// `npm run cli --` — is a setup step, and those do not survive contact.
9+
10+
import { spawn } from "node:child_process";
11+
import { existsSync } from "node:fs";
12+
import { dirname, join, resolve } from "node:path";
13+
import { fileURLToPath } from "node:url";
14+
15+
const here = dirname(fileURLToPath(import.meta.url));
16+
const repo = resolve(here, "..");
17+
const entry = join(repo, "cli", "index.ts");
18+
19+
if (!existsSync(entry)) {
20+
console.error(`crawlproof: cannot find ${entry}`);
21+
console.error("The launcher must sit in bin/ inside the crawlproof.com checkout.");
22+
process.exit(1);
23+
}
24+
25+
// Prefer the checkout's own tsx; fall back to npx so a fresh clone still runs.
26+
const localTsx = join(repo, "node_modules", ".bin", "tsx");
27+
const [cmd, prefix] = existsSync(localTsx) ? [localTsx, []] : ["npx", ["--yes", "tsx"]];
28+
29+
const child = spawn(cmd, [...prefix, entry, ...process.argv.slice(2)], {
30+
cwd: repo,
31+
stdio: "inherit",
32+
env: process.env,
33+
});
34+
35+
child.on("error", (err) => {
36+
console.error(`crawlproof: could not start tsx (${err.message})`);
37+
console.error(`Run \`npm install\` in ${repo}.`);
38+
process.exit(1);
39+
});
40+
41+
// Relay the child's fate rather than inventing one: a TUI killed by ctrl+c
42+
// should not look like a clean exit to whatever called this.
43+
child.on("exit", (code, signal) => {
44+
if (signal) {
45+
process.kill(process.pid, signal);
46+
return;
47+
}
48+
process.exit(code ?? 0);
49+
});

‎cli/index.ts‎

Lines changed: 22 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -185,9 +185,25 @@ async function cmdSweep(args: Args): Promise<number> {
185185
// Both talk to /api/ads/v1/* with a CrawlProof API token (Social → API
186186
// tokens), the same token the MCP server and the myna plugin use.
187187

188+
/**
189+
* The API token, from the flag, the environment, or ~/.crawlproof.json.
190+
*
191+
* The config file is last and exists so that using the CLI is not conditional
192+
* on remembering to export a secret first. Same shape and same reasoning as
193+
* ~/.coinpay.json, which `coinpayAuth` below reads.
194+
*/
188195
function apiToken(args: Args): string | null {
189-
const token = (args.flags.token as string | undefined) ?? process.env.CRAWLPROOF_TOKEN ?? null;
190-
return token && token.trim() ? token.trim() : null;
196+
const direct = (args.flags.token as string | undefined) ?? process.env.CRAWLPROOF_TOKEN;
197+
if (direct && direct.trim()) return direct.trim();
198+
199+
try {
200+
const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
201+
const file = process.env.CRAWLPROOF_CONFIG ?? `${home}/.crawlproof.json`;
202+
const token = (JSON.parse(readFileSync(file, "utf8")) as { token?: string }).token;
203+
return token && token.trim() ? token.trim() : null;
204+
} catch {
205+
return null;
206+
}
191207
}
192208

193209
function apiBase(args: Args): string {
@@ -364,29 +380,8 @@ async function cmdStats(args: Args): Promise<number> {
364380
return 0;
365381
}
366382

367-
const project = json.project as { name?: string; url?: string } | undefined;
368-
const totals = json.totals as { visitors?: number; pageviews?: number } | undefined;
369-
const list = (key: string) => (Array.isArray(json[key]) ? (json[key] as { label: string; value: number }[]) : []);
370-
371-
process.stdout.write(`${project?.name ?? "project"} ${range} ${who}\n`);
372-
process.stdout.write(`${totals?.visitors ?? 0} visitors, ${totals?.pageviews ?? 0} pageviews\n`);
373-
374-
const section = (title: string, items: { label: string; value: number }[]) => {
375-
if (!items.length) return;
376-
process.stdout.write(`\n${title}\n`);
377-
const width = Math.min(46, Math.max(...items.map((i) => i.label.length)));
378-
for (const item of items.slice(0, 10)) {
379-
process.stdout.write(` ${item.label.slice(0, width).padEnd(width)} ${item.value}\n`);
380-
}
381-
};
382-
section("Sources", list("sources"));
383-
section("Referrers", list("referrers"));
384-
section("Pages", list("pages"));
385-
386-
// Nothing at all is a real answer, and the likeliest cause is worth naming.
387-
if (!(totals?.pageviews ?? 0) && !list("sources").length) {
388-
process.stdout.write(`\nNothing in this window. Check the tag is on the page, or widen --range.\n`);
389-
}
383+
const { renderStats } = await import("../lib/dashboard/stats-text");
384+
process.stdout.write(renderStats(json as Parameters<typeof renderStats>[0], { range, who }));
390385
return 0;
391386
}
392387

@@ -593,7 +588,8 @@ ENV
593588
CRAWLPROOF_SITE_URL Override the API base URL for 'report', 'sweep', 'track', 'ads' and 'slots'.
594589
CRAWLPROOF_PROJECT Default project UUID for 'track'.
595590
CRAWLPROOF_TOKEN API token (crp_…) for 'ads', 'slots', 'stats' and
596-
'dashboard'; --token overrides.
591+
'dashboard'; --token overrides. Falls back to the
592+
'token' field of ~/.crawlproof.json.
597593
COINPAY_SESSION_TOKEN CoinPay merchant JWT for the money half of
598594
'dashboard'. Defaults to jwtToken in ~/.coinpay.json,
599595
which 'coinpay auth login' writes.

‎lib/dashboard/stats-text.ts‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
// Rendering a /api/tracker/v1/stats answer as plain text.
2+
//
3+
// Pure and string-returning rather than writing to stdout, because two CLIs
4+
// print it now — the in-repo one and the published @profullstack/crawlproof —
5+
// and a printer that owns the process is a printer that cannot be shared or
6+
// tested.
7+
8+
export type StatsItem = { label: string; value: number };
9+
10+
export type StatsAnswerish = {
11+
project?: { name?: string; url?: string } | null;
12+
totals?: { visitors?: number; pageviews?: number } | null;
13+
sources?: StatsItem[] | null;
14+
referrers?: StatsItem[] | null;
15+
pages?: StatsItem[] | null;
16+
};
17+
18+
const list = (v: StatsItem[] | null | undefined): StatsItem[] => (Array.isArray(v) ? v : []);
19+
20+
export function renderStats(
21+
answer: StatsAnswerish,
22+
{ range, who, rows = 10 }: { range: string; who: string; rows?: number },
23+
): string {
24+
const totals = answer.totals ?? {};
25+
const out: string[] = [
26+
`${answer.project?.name ?? "project"} ${range} ${who}`,
27+
`${totals.visitors ?? 0} visitors, ${totals.pageviews ?? 0} pageviews`,
28+
];
29+
30+
const section = (title: string, items: StatsItem[]) => {
31+
if (!items.length) return;
32+
out.push("", title);
33+
const width = Math.min(46, Math.max(...items.map((i) => i.label.length)));
34+
for (const item of items.slice(0, rows)) {
35+
out.push(` ${item.label.slice(0, width).padEnd(width)} ${item.value}`);
36+
}
37+
};
38+
section("Sources", list(answer.sources));
39+
section("Referrers", list(answer.referrers));
40+
section("Pages", list(answer.pages));
41+
42+
// Nothing at all is a real answer, and the likeliest cause is worth naming.
43+
if (!(totals.pageviews ?? 0) && !list(answer.sources).length) {
44+
out.push("", "Nothing in this window. Check the tag is on the page, or widen --range.");
45+
}
46+
return `${out.join("\n")}\n`;
47+
}

‎package.json‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
{
22
"name": "crawlproof",
33
"version": "0.3.0",
4+
"bin": {
5+
"crawlproof": "./bin/crawlproof.mjs"
6+
},
47
"private": true,
58
"license": "AGPL-3.0-only",
69
"type": "module",

‎packages/cli/.gitignore‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
dist/
2+
node_modules/

‎packages/cli/README.md‎

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
# @profullstack/crawlproof
2+
3+
What the fleet costs and what it returns, in a terminal.
4+
5+
```
6+
npm install -g @profullstack/crawlproof
7+
crawlproof dashboard
8+
```
9+
10+
Five live screens over three feeds that are not otherwise in the same place:
11+
CrawlProof's tracker for who arrived, its ad network for what was delivered,
12+
and CoinPay for what the bank actually did.
13+
14+
| Screen | Answers |
15+
| --- | --- |
16+
| ROI | Monthly burn against revenue, cost per reader, break-even |
17+
| Traffic | Every site on the account, ranked, with its share of the cost |
18+
| Ads | Delivery as advertiser and as publisher, and ad-driven arrivals |
19+
| Money | Earnings, bank position, invoices, income vs spending by month |
20+
| Spend | Who we pay, largest first, and burn by category |
21+
22+
`1`–`5` or Tab switches screens, `w` cycles the window, `b` cycles humans /
23+
all / bots, `r` refreshes, `?` explains the arithmetic, `q` quits.
24+
25+
## Two rules the numbers keep
26+
27+
**Self-deal is not revenue.** Where an account advertises on its own slots, ad
28+
spend and ad earnings are one dollar moving between two pockets. They are shown
29+
under *Internal* and counted as neither cost nor revenue.
30+
31+
**Personal money is not business cost.** A bank feed carries groceries next to
32+
servers, so cost is the business scope only — joined from each transaction's
33+
account to that account's scope.
34+
35+
Everything is normalised to a monthly rate and then prorated onto the traffic
36+
window, because burn is a rate: the traffic side can be asked for an hour while
37+
the bank side only answers in weeks.
38+
39+
The dashboard also reports what it cannot know. A site that did not answer is
40+
missing rather than zero. A vendor list built from one page of a longer ledger
41+
says so. A fleet whose visits run far above its pageviews — a "visitor" is any
42+
non-crawler hit, which on a site with a machine-readable endpoint runs orders of
43+
magnitude above pages anyone read — says that next to the number, and offers the
44+
per-pageview figure instead.
45+
46+
## Commands
47+
48+
```
49+
crawlproof dashboard [--range=1h|4h|1d|1w|1m] [--who=humans|bots|all]
50+
[--interval=60] [--sites=a.com,b.com] [--concurrency=8]
51+
[--no-coinpay] [--json]
52+
crawlproof stats [site] [--range=1d] [--who=humans] [--json]
53+
```
54+
55+
`--json` prints the same snapshot the screens render, for a script or a box
56+
with no terminal. Aliases for `dashboard`: `roi`, `tui`.
57+
58+
## Auth
59+
60+
| | |
61+
| --- | --- |
62+
| `CRAWLPROOF_TOKEN` | API token (`crp_…`) from Social → API tokens. Also read from the `token` field of `~/.crawlproof.json`. `--token` wins. |
63+
| `CRAWLPROOF_SITE_URL` | API base, default `https://crawlproof.com`. |
64+
| `COINPAY_SESSION_TOKEN` | CoinPay merchant JWT for the money screens. Defaults to `jwtToken` in `~/.coinpay.json`, which `coinpay auth login` writes. |
65+
66+
Without a CoinPay session the traffic and ads screens still work and the money
67+
panels say what is missing, rather than showing zero.
68+
69+
Needs Node 22.6 or newer, and a terminal for the dashboard. `--json` needs
70+
neither.
71+
72+
## License
73+
74+
MIT

‎packages/cli/build.mjs‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
// Bundle the publishable CLI out of the app's own source.
2+
//
3+
// The point of bundling rather than moving files is that lib/dashboard/* and
4+
// cli/dashboard.ts stay where the test suite already covers them and where the
5+
// in-repo CLI already imports them. The package is a build artifact of the
6+
// same code, so there is one implementation and it cannot drift.
7+
//
8+
// hqtui and the CoinPay SDK stay external: they are real dependencies with
9+
// their own release cadence, declared in package.json and installed alongside.
10+
11+
import { build } from "esbuild";
12+
import { chmod, readFile } from "node:fs/promises";
13+
import { dirname, join } from "node:path";
14+
import { fileURLToPath } from "node:url";
15+
16+
const here = dirname(fileURLToPath(import.meta.url));
17+
const out = join(here, "dist", "cli.mjs");
18+
19+
const pkg = JSON.parse(await readFile(join(here, "package.json"), "utf8"));
20+
21+
await build({
22+
entryPoints: [join(here, "src", "bin.ts")],
23+
outfile: out,
24+
bundle: true,
25+
platform: "node",
26+
format: "esm",
27+
target: "node22",
28+
// Anything with its own version belongs in package.json, not inlined here.
29+
external: [...Object.keys(pkg.dependencies ?? {}), "node:*"],
30+
banner: { js: "#!/usr/bin/env node" },
31+
legalComments: "none",
32+
logLevel: "info",
33+
});
34+
35+
await chmod(out, 0o755);
36+
console.log(`built ${out}`);

‎packages/cli/package.json‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
{
2+
"name": "@profullstack/crawlproof",
3+
"version": "0.1.0",
4+
"description": "What the fleet costs and what it returns: a live terminal dashboard over CrawlProof traffic, ad delivery and CoinPay banking.",
5+
"license": "MIT",
6+
"type": "module",
7+
"homepage": "https://crawlproof.com",
8+
"bin": {
9+
"crawlproof": "./dist/cli.mjs"
10+
},
11+
"files": [
12+
"dist",
13+
"README.md"
14+
],
15+
"engines": {
16+
"node": ">=22.6"
17+
},
18+
"scripts": {
19+
"build": "node build.mjs",
20+
"prepublishOnly": "node build.mjs"
21+
},
22+
"dependencies": {
23+
"@profullstack/coinpay": "^0.9.0",
24+
"@profullstack/hqtui": "^0.1.11"
25+
},
26+
"devDependencies": {
27+
"esbuild": "^0.25.0"
28+
},
29+
"publishConfig": {
30+
"access": "public"
31+
},
32+
"keywords": [
33+
"crawlproof",
34+
"tui",
35+
"dashboard",
36+
"analytics",
37+
"roi"
38+
],
39+
"repository": {
40+
"type": "git",
41+
"url": "git+https://github.com/profullstack/crawlproof.com.git",
42+
"directory": "packages/cli"
43+
}
44+
}

‎packages/cli/src/bin.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
// The executable entry. No shebang here: build.mjs adds one as a banner, and
2+
// two of them is a syntax error rather than a harmless duplicate.
3+
import { main } from "./cli";
4+
5+
main(process.argv.slice(2)).then((code) => {
6+
process.exitCode = code ?? 0;
7+
});

0 commit comments

Comments
 (0)