From 6b46f0c2ac490fbe60b3f46abf3c4a71fcfab303 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sun, 4 Oct 2026 13:55:43 +0000 Subject: [PATCH] fix(cli): ship `crawlproof actors` in the published CLI (0.4.0) #339 added `actors` to the in-repo CLI (cli/index.ts) only. The `crawlproof` on PATH is the published @profullstack/crawlproof (packages/cli, 0.3.0), which answered 'unknown command: actors'. The command now lives in lib/tracker/actorsCli.ts and both CLIs call it, the same way lib/emailTracking/cli is shared, so they cannot drift. Package bumped to 0.4.0. Co-Authored-By: Claude Opus 5.5 --- cli/index.ts | 141 +---------------------- lib/tracker/actorsCli.ts | 158 ++++++++++++++++++++++++++ packages/cli/package.json | 2 +- packages/cli/src/cli.ts | 10 +- tests/tracker-declared-actors.test.ts | 43 ++++++- 5 files changed, 216 insertions(+), 138 deletions(-) create mode 100644 lib/tracker/actorsCli.ts diff --git a/cli/index.ts b/cli/index.ts index e4cc7fa..4bd0b65 100644 --- a/cli/index.ts +++ b/cli/index.ts @@ -15,6 +15,7 @@ import { readFileSync } from "node:fs"; import { EMAIL_TRACKING_USAGE, runEmailTracking } from "../lib/emailTracking/cli"; +import { ACTORS_USAGE, runActors } from "../lib/tracker/actorsCli"; import { isAllowedTargetUrl } from "../lib/rateLimit"; @@ -572,126 +573,6 @@ async function cmdStats(args: Args): Promise { return 0; } -/** - * `crawlproof actors` — declared actors: who you are when you visit a tracked - * site, and whether you are a person. Opt-in and self-reported; an agent is - * believed, a human never overrides bot detection (lib/tracker/actors.ts). - */ -async function cmdActors(args: Args): Promise { - const sub = args.positional[0] ?? "list"; - const json = Boolean(args.flags.json); - const fail = (what: string, r: { status: number; json: Record }) => { - console.error(`actors ${what} failed: ${r.status} ${String(r.json.error ?? "")}`); - return 1; - }; - type Row = { id: string; email: string; name: string; kind: string; email_verified: boolean; visibility: string; tokens: { id: string; prefix: string; label: string; last_used_at: string | null }[]; last30: { events: number; pageviews: number; contradictions: number; sites: number } }; - const list = async () => { - const r = await apiCall(args, "GET", "/api/tracker/v1/actors"); - return { r, actors: (r.json.actors as Row[] | undefined) ?? [] }; - }; - const find = (actors: Row[], key: string | undefined) => - key ? actors.find((a) => a.id === key || a.email === key.toLowerCase()) : undefined; - - if (sub === "list") { - const { r, actors } = await list(); - if (r.status >= 400) return fail("list", r); - if (json) { - process.stdout.write(`${JSON.stringify(actors, null, 2)}\n`); - return 0; - } - if (!actors.length) process.stdout.write("No actors yet. crawlproof actors add --kind=human|agent\n"); - for (const a of actors) { - const verified = a.email_verified ? "verified" : "unverified"; - const u = a.last30; - const flags = u.contradictions ? `, ${u.contradictions} contradicted` : ""; - process.stdout.write(`${a.kind.padEnd(5)} ${a.email}${a.name ? ` (${a.name})` : ""} ${verified}, ${a.visibility} ${a.id}\n`); - process.stdout.write(` 30d: ${u.pageviews} pv, ${u.events} ev on ${u.sites} site${u.sites === 1 ? "" : "s"}${flags}\n`); - for (const t of a.tokens) { - process.stdout.write(` token ${t.prefix}… ${t.label || "(no label)"} last used ${t.last_used_at?.slice(0, 16).replace("T", " ") ?? "never"} ${t.id}\n`); - } - } - return 0; - } - - if (sub === "add") { - const email = args.positional[1]; - const kind = args.flags.kind as string | undefined; - if (!email || (kind !== "human" && kind !== "agent")) { - console.error("usage: crawlproof actors add --kind=human|agent [--name=…] [--operator=] [--public] [--token-label=…] [--no-token] [--json]"); - return 2; - } - const body: Record = { email, kind, visibility: args.flags.public ? "public" : "private" }; - if (typeof args.flags.name === "string") body.name = args.flags.name; - if (typeof args.flags.operator === "string") body.operator = args.flags.operator; - if (!args.flags["no-token"]) body.token_label = typeof args.flags["token-label"] === "string" ? args.flags["token-label"] : "cli"; - const r = await apiCall(args, "POST", "/api/tracker/v1/actors", body); - if (r.status >= 400) return fail("add", r); - if (json) { - process.stdout.write(`${JSON.stringify(r.json, null, 2)}\n`); - return 0; - } - const actor = r.json.actor as { id: string; email: string; kind: string }; - const verification = { - "owner-login": "verified (it is your login)", - sent: "verification email sent", - "not-sent": `NOT verified: email could not be sent (${String(r.json.verificationError ?? "unknown")})`, - }[String(r.json.verification)] ?? ""; - process.stdout.write(`${actor.kind} ${actor.email} ${actor.id}\n${verification}\n`); - if (r.json.token) process.stdout.write(`\n${actorTokenHowTo(String(r.json.token))}`); - return 0; - } - - if (sub === "token") { - const { r, actors } = await list(); - if (r.status >= 400) return fail("token", r); - const actor = find(actors, args.positional[1]); - if (!actor) { - console.error("usage: crawlproof actors token [--label=…]"); - return 2; - } - const label = typeof args.flags.label === "string" ? args.flags.label : "cli"; - const m = await apiCall(args, "POST", `/api/tracker/v1/actors/${actor.id}/tokens`, { label }); - if (m.status >= 400) return fail("token", m); - if (json) process.stdout.write(`${JSON.stringify(m.json, null, 2)}\n`); - else process.stdout.write(actorTokenHowTo(String(m.json.token))); - return 0; - } - - if (sub === "revoke") { - const { r, actors } = await list(); - if (r.status >= 400) return fail("revoke", r); - const actor = find(actors, args.positional[1]); - if (!actor) { - console.error("usage: crawlproof actors revoke [--token=] (no --token revokes the actor and every token)"); - return 2; - } - const tokenId = args.flags.token as string | undefined; - const d = tokenId - ? await apiCall(args, "DELETE", `/api/tracker/v1/actors/${actor.id}/tokens?token=${encodeURIComponent(tokenId)}`) - : await apiCall(args, "DELETE", `/api/tracker/v1/actors/${actor.id}`); - if (d.status >= 400) return fail("revoke", d); - process.stdout.write(tokenId ? `revoked token ${tokenId} of ${actor.email}\n` : `revoked ${actor.email} and all its tokens\n`); - return 0; - } - - console.error(`unknown: crawlproof actors ${sub} (expected: list | add | token | revoke)`); - return 2; -} - -/** How to send a fresh actor token. Pure, for tests. */ -export function actorTokenHowTo(token: string): string { - return [ - `token (shown once): ${token}`, - "", - "Send it with your visits by any of:", - ` header Crawlproof-Actor: ${token} (Playwright extraHTTPHeaders, Puppeteer setExtraHTTPHeaders)`, - ` link https:///?crp_actor=${token} (stored for that site, stripped from the URL)`, - ` script crawlproof('actor', '${token}')`, - "Or, for a person: Dashboard → Settings → Declared actors → Declare this browser.", - "", - ].join("\n"); -} - async function cmdSlots(args: Args): Promise { const sub = args.positional[0]; if (sub === "create") { @@ -1095,20 +976,7 @@ ${EMAIL_TRACKING_USAGE} project name; with one project it can be left out. Needs an API token. Lists declared actors seen on the site when there are any. - actors [list] [--json] - actors add --kind=human|agent [--name=…] [--operator=] - [--public] [--token-label=…] [--no-token] [--json] - actors token [--label=…] - actors revoke [--token=] - Declared actors: say who you are, and whether you are a person, on every - site with the CrawlProof tracker. Opt-in and self-reported. A token - (cpa_…) is the credential, never the email; send it as the - Crawlproof-Actor header or open a site once with ?crp_actor=. An - agent is believed; a human never overrides bot detection and a mismatch - is counted as a contradiction. Names are visible to you only unless - --public. Your login address is verified on creation; any other gets a - verification email. - +${ACTORS_USAGE} dashboard [--range=1h|4h|1d|1w|1m] [--who=humans|bots|all] [--interval=60] [--sites=a.com,b.com] [--sort=score|visitors|pageviews] [--concurrency=8] [--no-coinpay] [--json] @@ -1186,7 +1054,10 @@ async function main() { case "stats": return await cmdStats(args); case "actors": - return await cmdActors(args); + return await runActors(args.positional, args.flags as Record, (method, path, body) => apiCall(args, method, path, body), { + write: (line: string) => process.stdout.write(`${line}\n`), + error: (line: string) => console.error(line), + }); case "dashboard": case "roi": case "tui": diff --git a/lib/tracker/actorsCli.ts b/lib/tracker/actorsCli.ts new file mode 100644 index 0000000..9ed9873 --- /dev/null +++ b/lib/tracker/actorsCli.ts @@ -0,0 +1,158 @@ +// `crawlproof actors`: declared actors from the command line. +// +// Shared by both CLIs, the in-repo one (cli/index.ts) and the published +// @profullstack/crawlproof (packages/cli), the same way lib/emailTracking/cli +// is. It only talks to /api/tracker/v1/actors through the caller's `call`, so +// each CLI keeps its own token and base-URL handling. +// +// Model and trust rule: lib/tracker/actors.ts. Opt-in and self-reported; an +// agent is believed, a human never overrides bot detection. + +type Method = "GET" | "POST" | "PATCH" | "DELETE"; +type ApiCall = (method: Method, path: string, body?: Record) => Promise<{ status: number; json: Record }>; +type Out = { write: (line: string) => void; error: (line: string) => void }; + +type Row = { + id: string; + email: string; + name: string; + kind: string; + email_verified: boolean; + visibility: string; + tokens: { id: string; prefix: string; label: string; last_used_at: string | null }[]; + last30: { events: number; pageviews: number; contradictions: number; sites: number }; +}; + +export const ACTORS_USAGE = ` actors [list] [--json] + actors add --kind=human|agent [--name=…] [--operator=] + [--public] [--token-label=…] [--no-token] [--json] + actors token [--label=…] + actors revoke [--token=] + Declared actors: say who you are, and whether you are a person, on every + site with the CrawlProof tracker. Opt-in and self-reported. A token + (cpa_…) is the credential, never the email; send it as the + Crawlproof-Actor header or open a site once with ?crp_actor=. An + agent is believed; a human never overrides bot detection and a mismatch + is counted as a contradiction. Names are visible to you only unless + --public. Your login address is verified on creation; any other gets a + verification email. Needs an API token. +`; + +/** How to send a fresh actor token. Pure, for tests. */ +export function actorTokenHowTo(token: string): string { + return [ + `token (shown once): ${token}`, + "", + "Send it with your visits by any of:", + ` header Crawlproof-Actor: ${token} (Playwright extraHTTPHeaders, Puppeteer setExtraHTTPHeaders)`, + ` link https:///?crp_actor=${token} (stored for that site, stripped from the URL)`, + ` script crawlproof('actor', '${token}')`, + "Or, for a person: Dashboard → Settings → Declared actors → Declare this browser.", + "", + ].join("\n"); +} + +export async function runActors( + positional: string[], + flags: Record, + call: ApiCall, + out: Out, +): Promise { + const sub = positional[0] ?? "list"; + const json = Boolean(flags.json); + const fail = (what: string, r: { status: number; json: Record }) => { + out.error(`actors ${what} failed: ${r.status} ${String(r.json.error ?? "")}`.trim()); + return 1; + }; + const list = async () => { + const r = await call("GET", "/api/tracker/v1/actors"); + return { r, actors: (r.json.actors as Row[] | undefined) ?? [] }; + }; + const find = (actors: Row[], key: string | undefined) => + key ? actors.find((a) => a.id === key || a.email === key.toLowerCase()) : undefined; + + if (sub === "list") { + const { r, actors } = await list(); + if (r.status >= 400) return fail("list", r); + if (json) { + out.write(JSON.stringify(actors, null, 2)); + return 0; + } + if (!actors.length) out.write("No actors yet. crawlproof actors add --kind=human|agent"); + for (const a of actors) { + const u = a.last30; + const flagged = u.contradictions ? `, ${u.contradictions} contradicted` : ""; + out.write(`${a.kind.padEnd(5)} ${a.email}${a.name ? ` (${a.name})` : ""} ${a.email_verified ? "verified" : "unverified"}, ${a.visibility} ${a.id}`); + out.write(` 30d: ${u.pageviews} pv, ${u.events} ev on ${u.sites} site${u.sites === 1 ? "" : "s"}${flagged}`); + for (const t of a.tokens) { + out.write(` token ${t.prefix}… ${t.label || "(no label)"} last used ${t.last_used_at?.slice(0, 16).replace("T", " ") ?? "never"} ${t.id}`); + } + } + return 0; + } + + if (sub === "add") { + const email = positional[1]; + const kind = flags.kind; + if (!email || (kind !== "human" && kind !== "agent")) { + out.error("usage: crawlproof actors add --kind=human|agent [--name=…] [--operator=] [--public] [--token-label=…] [--no-token] [--json]"); + return 2; + } + const body: Record = { email, kind, visibility: flags.public ? "public" : "private" }; + if (typeof flags.name === "string") body.name = flags.name; + if (typeof flags.operator === "string") body.operator = flags.operator; + if (!flags["no-token"]) body.token_label = typeof flags["token-label"] === "string" ? flags["token-label"] : "cli"; + const r = await call("POST", "/api/tracker/v1/actors", body); + if (r.status >= 400) return fail("add", r); + if (json) { + out.write(JSON.stringify(r.json, null, 2)); + return 0; + } + const actor = r.json.actor as { id: string; email: string; kind: string }; + const verification = + { + "owner-login": "verified (it is your login)", + sent: "verification email sent", + "not-sent": `NOT verified: email could not be sent (${String(r.json.verificationError ?? "unknown")})`, + }[String(r.json.verification)] ?? ""; + out.write(`${actor.kind} ${actor.email} ${actor.id}`); + out.write(verification); + if (r.json.token) out.write(`\n${actorTokenHowTo(String(r.json.token))}`); + return 0; + } + + if (sub === "token") { + const { r, actors } = await list(); + if (r.status >= 400) return fail("token", r); + const actor = find(actors, positional[1]); + if (!actor) { + out.error("usage: crawlproof actors token [--label=…] (crawlproof actors list shows yours)"); + return 2; + } + const label = typeof flags.label === "string" ? flags.label : "cli"; + const m = await call("POST", `/api/tracker/v1/actors/${actor.id}/tokens`, { label }); + if (m.status >= 400) return fail("token", m); + out.write(json ? JSON.stringify(m.json, null, 2) : actorTokenHowTo(String(m.json.token))); + return 0; + } + + if (sub === "revoke") { + const { r, actors } = await list(); + if (r.status >= 400) return fail("revoke", r); + const actor = find(actors, positional[1]); + if (!actor) { + out.error("usage: crawlproof actors revoke [--token=] (no --token revokes the actor and every token)"); + return 2; + } + const tokenId = typeof flags.token === "string" ? flags.token : undefined; + const d = tokenId + ? await call("DELETE", `/api/tracker/v1/actors/${actor.id}/tokens?token=${encodeURIComponent(tokenId)}`) + : await call("DELETE", `/api/tracker/v1/actors/${actor.id}`); + if (d.status >= 400) return fail("revoke", d); + out.write(tokenId ? `revoked token ${tokenId} of ${actor.email}` : `revoked ${actor.email} and all its tokens`); + return 0; + } + + out.error(`unknown: crawlproof actors ${sub} (expected: list | add | token | revoke)`); + return 2; +} diff --git a/packages/cli/package.json b/packages/cli/package.json index 8f2b6b1..2d65921 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@profullstack/crawlproof", - "version": "0.3.0", + "version": "0.4.0", "description": "What the fleet costs and what it returns: a live terminal dashboard over CrawlProof traffic, ad delivery and CoinPay banking.", "license": "MIT", "type": "module", diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index cbb0b31..7a40c91 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -15,8 +15,9 @@ import { collectDashboard } from "../../../lib/dashboard/collect"; import { renderStats } from "../../../lib/dashboard/stats-text"; import { FINANCE_DAYS, runDashboard } from "../../../cli/dashboard"; import { EMAIL_TRACKING_USAGE, runEmailTracking } from "../../../lib/emailTracking/cli"; +import { ACTORS_USAGE, runActors } from "../../../lib/tracker/actorsCli"; -export const VERSION = "0.3.0"; +export const VERSION = "0.4.0"; type Args = { command: string; @@ -131,7 +132,9 @@ COMMANDS Who arrived and from where: sources, referrers and top pages. Defaults to the last day and humans only, because a launch is invisible inside a month of crawler traffic. With one project the site can be left out. + Lists declared actors seen on the site when there are any. +${ACTORS_USAGE} ad [--name=N] [--budget=CENTS] [--bid=CREDITS] [--draft] [--json] Run an ad for a URL. CrawlProof reads the page, writes the creatives and starts serving. A URL that already has a live campaign gets that campaign @@ -413,6 +416,11 @@ export async function main(argv: string[]): Promise { return await cmdAd(args); case "ads": return await cmdAds(args); + case "actors": + return await runActors(args.positional, args.flags, (method, path, body) => apiCall(args, method, path, body), { + write: (line: string) => process.stdout.write(`${line}\n`), + error: (line: string) => console.error(line), + }); case "email-tracking": return await runEmailTracking(args.positional, args.flags, (method, path) => apiCall(args, method, path), { write: (line: string) => process.stdout.write(`${line}\n`), diff --git a/tests/tracker-declared-actors.test.ts b/tests/tracker-declared-actors.test.ts index 9245395..a507880 100644 --- a/tests/tracker-declared-actors.test.ts +++ b/tests/tracker-declared-actors.test.ts @@ -18,7 +18,8 @@ import { hashApiToken } from "@/lib/sp/apiToken"; import { rangeSinceDay } from "@/lib/tracker/actorStore"; import { trackerRange } from "@/lib/tracker/ranges"; import { renderStats } from "@/lib/dashboard/stats-text"; -import { actorTokenHowTo } from "@/cli/index"; +import { actorTokenHowTo, runActors } from "@/lib/tracker/actorsCli"; +import { readFileSync } from "node:fs"; // Declared actors are on the honor system, and others will try to game it. // These pin the parts that make lying cheap to spot and useless to profit @@ -286,3 +287,43 @@ describe("/api/track with a declared actor", () => { expect(calls.find(([n]) => n === "tracker_touch_actor")![1].p_contradiction).toBe(false); }); }); + +describe("runActors (shared by both CLIs)", () => { + function harness(reply: { status: number; json: Record }) { + const calls: [string, string, Record | undefined][] = []; + const lines: string[] = []; + const errors: string[] = []; + const call = async (method: string, path: string, body?: Record) => { + calls.push([method, path, body]); + return reply; + }; + return { calls, lines, errors, out: { write: (l: string) => lines.push(l), error: (l: string) => errors.push(l) }, call }; + } + + it("runs `actors add --kind=agent --operator=` as one POST", async () => { + const h = harness({ status: 201, json: { actor: { id: "a2", email: "riotcoder@profullstack.com", kind: "agent" }, verification: "sent", token: TOKEN } }); + const code = await runActors(["add", "riotcoder@profullstack.com"], { kind: "agent", operator: "anthony@profullstack.com" }, h.call, h.out); + expect(code).toBe(0); + expect(h.calls).toEqual([["POST", "/api/tracker/v1/actors", { email: "riotcoder@profullstack.com", kind: "agent", visibility: "private", operator: "anthony@profullstack.com", token_label: "cli" }]]); + expect(h.lines.join("\n")).toContain("verification email sent"); + expect(h.lines.join("\n")).toContain(`Crawlproof-Actor: ${TOKEN}`); + }); + + it("says why when the server refuses, e.g. an actor that already exists", async () => { + const h = harness({ status: 409, json: { error: "anthony@profullstack.com is already an actor on this account." } }); + expect(await runActors(["add", "anthony@profullstack.com"], { kind: "human" }, h.call, h.out)).toBe(1); + expect(h.errors[0]).toBe("actors add failed: 409 anthony@profullstack.com is already an actor on this account."); + }); + + it("prints usage without --kind instead of guessing", async () => { + const h = harness({ status: 200, json: {} }); + expect(await runActors(["add", "x@example.com"], {}, h.call, h.out)).toBe(2); + expect(h.calls).toEqual([]); + }); + + it("is wired into the published CLI, not only the in-repo one", () => { + const src = readFileSync("packages/cli/src/cli.ts", "utf8"); + expect(src).toContain('case "actors":'); + expect(src).toContain('export const VERSION = "0.4.0";'); + }); +});