From 92bb1e8fd51c68ca7d8d0c9c2b5812af819f56bb Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sat, 5 Sep 2026 23:12:13 +0000 Subject: [PATCH] Manage a post's ad from myna: show, pause, resume, budget, delete myna crawlproof ads show|pause|resume|budget|delete talk to the campaign route crawlproof gained in #238, so the ad a post opened can be read (delivery, spend, the visits it sent) and changed from the terminal it was posted from. delete needs --yes; pausing keeps the history. Version 0.7.0. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01YafYxayh7Gqe5MWNNQMev2 --- README.md | 2 + apps/api/package.json | 2 +- apps/cli/package.json | 2 +- apps/desktop/package.json | 2 +- apps/web/package.json | 2 +- bun.lock | 16 ++--- packages/core/package.json | 2 +- packages/core/src/version.ts | 2 +- packages/mcp/package.json | 2 +- packages/plugin-crawlproof/package.json | 2 +- packages/plugin-crawlproof/src/index.ts | 64 ++++++++++++++++++- .../plugin-crawlproof/test/plugin.test.ts | 37 +++++++++++ packages/plugin-outreachgraph/package.json | 2 +- 13 files changed, 119 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index d646292..cc9503a 100644 --- a/README.md +++ b/README.md @@ -329,6 +329,8 @@ myna crawlproof login # paste an API token from Social → API t myna post --to htmlblog < post.md # …and the new page gets a campaign myna crawlproof ad https://example.com/launch --budget 300 myna crawlproof ads # campaigns, newest first +myna crawlproof ads show crawlproof-ad-144 # delivery, and the visits it sent +myna crawlproof ads pause crawlproof-ad-144 # or resume, budget , delete --yes myna crawlproof auto off # stop the automatic ones ``` diff --git a/apps/api/package.json b/apps/api/package.json index 363a93b..c7157af 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -1,6 +1,6 @@ { "name": "@profullstack/myna-api", - "version": "0.6.0", + "version": "0.7.0", "private": true, "type": "module", "scripts": { diff --git a/apps/cli/package.json b/apps/cli/package.json index d952eca..3bcfe55 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,6 +1,6 @@ { "name": "@profullstack/myna", - "version": "0.6.0", + "version": "0.7.0", "description": "A terminal social media manager. Log in, compose, schedule and post to every network from one TUI.", "license": "MIT", "type": "module", diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 4cdd454..15ae88c 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@profullstack/myna-desktop", - "version": "0.6.0", + "version": "0.7.0", "description": "The myna desktop app.", "license": "MIT", "private": true, diff --git a/apps/web/package.json b/apps/web/package.json index bee7a14..ae55c36 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@profullstack/myna-web", - "version": "0.6.0", + "version": "0.7.0", "private": true, "type": "module", "scripts": { diff --git a/bun.lock b/bun.lock index 74a62e5..c714eee 100644 --- a/bun.lock +++ b/bun.lock @@ -13,7 +13,7 @@ }, "apps/api": { "name": "@profullstack/myna-api", - "version": "0.6.0", + "version": "0.7.0", "dependencies": { "@profullstack/myna-core": "workspace:*", "@profullstack/myna-mcp": "workspace:*", @@ -23,7 +23,7 @@ }, "apps/cli": { "name": "@profullstack/myna", - "version": "0.6.0", + "version": "0.7.0", "bin": { "myna": "./bin/myna.mjs", }, @@ -36,7 +36,7 @@ }, "apps/desktop": { "name": "@profullstack/myna-desktop", - "version": "0.6.0", + "version": "0.7.0", "dependencies": { "@profullstack/myna-core": "workspace:*", }, @@ -47,7 +47,7 @@ }, "apps/web": { "name": "@profullstack/myna-web", - "version": "0.6.0", + "version": "0.7.0", "dependencies": { "@profullstack/hqtui": "^0.1.9", "@profullstack/myna-core": "workspace:*", @@ -55,14 +55,14 @@ }, "packages/core": { "name": "@profullstack/myna-core", - "version": "0.6.0", + "version": "0.7.0", "dependencies": { "@anthropic-ai/sdk": "^0.123.0", }, }, "packages/mcp": { "name": "@profullstack/myna-mcp", - "version": "0.6.0", + "version": "0.7.0", "bin": { "myna-mcp": "./src/server.ts", }, @@ -73,7 +73,7 @@ }, "packages/plugin-crawlproof": { "name": "@profullstack/myna-plugin-crawlproof", - "version": "0.6.0", + "version": "0.7.0", "devDependencies": { "@profullstack/myna-core": "workspace:*", }, @@ -83,7 +83,7 @@ }, "packages/plugin-outreachgraph": { "name": "@profullstack/myna-plugin-outreachgraph", - "version": "0.6.0", + "version": "0.7.0", "devDependencies": { "@profullstack/myna-core": "workspace:*", }, diff --git a/packages/core/package.json b/packages/core/package.json index a7b1774..9186a01 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@profullstack/myna-core", - "version": "0.6.0", + "version": "0.7.0", "description": "Network adapters, credential vault, scheduling and the AI writer behind myna.", "license": "MIT", "type": "module", diff --git a/packages/core/src/version.ts b/packages/core/src/version.ts index 808eeee..349a437 100644 --- a/packages/core/src/version.ts +++ b/packages/core/src/version.ts @@ -6,4 +6,4 @@ * binary reporting the wrong version, and nothing would fail. A test asserts * this matches the package.json it is published under. */ -export const VERSION = "0.6.0"; +export const VERSION = "0.7.0"; diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 7974311..809c599 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -1,6 +1,6 @@ { "name": "@profullstack/myna-mcp", - "version": "0.6.0", + "version": "0.7.0", "description": "MCP server for myna. Lets an agent post, schedule and read across every connected social account.", "license": "MIT", "type": "module", diff --git a/packages/plugin-crawlproof/package.json b/packages/plugin-crawlproof/package.json index 6a3b6d8..4cf6425 100644 --- a/packages/plugin-crawlproof/package.json +++ b/packages/plugin-crawlproof/package.json @@ -1,6 +1,6 @@ { "name": "@profullstack/myna-plugin-crawlproof", - "version": "0.6.0", + "version": "0.7.0", "description": "myna plugin: run a CrawlProof ad for every blog post myna publishes.", "license": "MIT", "type": "module", diff --git a/packages/plugin-crawlproof/src/index.ts b/packages/plugin-crawlproof/src/index.ts index f54fc01..2b6baea 100644 --- a/packages/plugin-crawlproof/src/index.ts +++ b/packages/plugin-crawlproof/src/index.ts @@ -10,6 +10,8 @@ * myna crawlproof login paste a CrawlProof API token (crp_…) * myna crawlproof ad [--name N] [--budget CENTS] [--draft true] * myna crawlproof ads campaigns, newest first + * myna crawlproof ads show one campaign with its delivery + * myna crawlproof ads pause|resume|budget|delete * myna crawlproof auto on|off run an ad for every blog post (on) * myna crawlproof logout * @@ -109,6 +111,30 @@ export async function createAd(secrets: Secrets, url: string, options: AdOptions }); } +export interface CampaignStats { + impressions: number; + clicks: number; + spent_cents: number; + free_impressions: number; + free_clicks: number; + visits: { total: number; days: { day: string; visits: number }[] }; +} + +/** One campaign by ref slug or id, with its delivery. */ +export async function showAd(secrets: Secrets, ref: string): Promise { + return call(secrets, "GET", `/api/ads/v1/campaigns/${encodeURIComponent(ref)}`); +} + +/** Change a campaign in place: status, budget, bid, name. */ +export async function patchAd(secrets: Secrets, ref: string, patch: Record): Promise { + return call(secrets, "PATCH", `/api/ads/v1/campaigns/${encodeURIComponent(ref)}`, patch); +} + +/** Remove a campaign, metering included. Pausing keeps the history. */ +export async function deleteAd(secrets: Secrets, ref: string): Promise<{ ok: boolean; deleted?: string }> { + return call<{ ok: boolean; deleted?: string }>(secrets, "DELETE", `/api/ads/v1/campaigns/${encodeURIComponent(ref)}`); +} + export async function listAds(secrets: Secrets, limit = 20): Promise { const result = await call<{ campaigns?: Campaign[] }>(secrets, "GET", `/api/ads/v1/campaigns?limit=${limit}`); return result.campaigns ?? []; @@ -141,7 +167,7 @@ export function urlsToPromote(event: PostedEvent): string[] { const plugin: MynaPlugin = { id: "crawlproof", name: "CrawlProof ads", - version: "0.6.0", + version: "0.7.0", description: "Run a CrawlProof ad campaign for every blog post myna publishes, or for any URL by hand.", commands: [ @@ -152,6 +178,10 @@ const plugin: MynaPlugin = { "crawlproof login Paste a CrawlProof API token (Social → API tokens)", "crawlproof ad [--name N] [--budget CENTS] [--draft true]", "crawlproof ads [--limit N] Campaigns, newest first", + "crawlproof ads show One campaign with its delivery and attributed visits", + "crawlproof ads pause|resume Stop or restart it; the history stays", + "crawlproof ads budget Daily budget", + "crawlproof ads delete --yes Remove it, metering included", "crawlproof auto on|off Run an ad for every blog post myna publishes (on by default)", "crawlproof budget Daily budget for automatic campaigns", "crawlproof status | logout", @@ -219,6 +249,38 @@ const plugin: MynaPlugin = { case "ads": { const secrets = secretsOf(ctx); if (!secrets) throw new Error("Not connected. Run: myna crawlproof login"); + const [verb, ref, value] = rest; + if (verb === "show" || verb === "pause" || verb === "resume" || verb === "budget" || verb === "delete") { + if (!ref) throw new Error(`Usage: myna crawlproof ads ${verb} ${verb === "budget" ? " " : ""}`); + if (verb === "delete") { + if (!ctx.flags.yes) throw new Error("delete removes the campaign and its metering; pass --yes. Pause keeps the history."); + const gone = await deleteAd(secrets, ref); + ctx.out(`deleted ${gone.deleted ?? ref}`); + return 0; + } + if (verb === "budget") { + const cents = Number(value); + if (!Number.isInteger(cents) || cents < 0) throw new Error("Usage: myna crawlproof ads budget "); + ctx.out(describe(await patchAd(secrets, ref, { daily_budget_cents: cents }))); + return 0; + } + if (verb === "pause" || verb === "resume") { + ctx.out(describe(await patchAd(secrets, ref, { status: verb === "pause" ? "paused" : "active" }))); + return 0; + } + const campaign = await showAd(secrets, ref); + if (ctx.flags.json) { + ctx.out(JSON.stringify(campaign, null, 2)); + return 0; + } + ctx.out(`${describe(campaign)}\n ${campaign.daily_budget_cents ?? "?"}¢/day${campaign.dashboard_url ? ` ${campaign.dashboard_url}` : ""}`); + const s = campaign.stats; + if (s) { + ctx.out(` impressions ${s.impressions} (+${s.free_impressions} free) · clicks ${s.clicks} (+${s.free_clicks} free) · spent ${s.spent_cents}¢ · visits attributed ${s.visits?.total ?? 0}`); + for (const day of (s.visits?.days ?? []).slice(0, 7)) ctx.out(` ${day.day} ${day.visits} visit${day.visits === 1 ? "" : "s"}`); + } + return 0; + } const limit = typeof ctx.flags.limit === "string" ? Number(ctx.flags.limit) || 20 : 20; const campaigns = await listAds(secrets, limit); if (ctx.flags.json) { diff --git a/packages/plugin-crawlproof/test/plugin.test.ts b/packages/plugin-crawlproof/test/plugin.test.ts index fbcf921..fb0316a 100644 --- a/packages/plugin-crawlproof/test/plugin.test.ts +++ b/packages/plugin-crawlproof/test/plugin.test.ts @@ -107,3 +107,40 @@ test("the command refuses a token that is not a CrawlProof token, and stores a g expect(good.ctx.secrets.get().budgetCents).toBe("300"); await expect(command.run(["auto", "maybe"], good.ctx)).rejects.toThrow(/on\|off/); }); + +test("ads show, pause, budget and delete talk to the campaign route by ref", async () => { + const command = plugin.commands![0]; + const calls: { method: string; url: string; body?: string }[] = []; + const fake = (url: string, init?: RequestInit) => { + calls.push({ method: init?.method ?? "GET", url: String(url), body: init?.body ? String(init.body) : undefined }); + if (init?.method === "DELETE") return new Response(JSON.stringify({ ok: true, deleted: "crawlproof-ad-144" }), { status: 200 }); + return new Response( + JSON.stringify({ + id: "1", + ref_slug: "crawlproof-ad-144", + name: "Post", + status: init?.method === "PATCH" ? "paused" : "active", + destination_url: "https://x.y/blog/044-post.html", + daily_budget_cents: 500, + stats: { impressions: 12, clicks: 3, spent_cents: 30, free_impressions: 100, free_clicks: 2, visits: { total: 5, days: [{ day: "2026-09-05", visits: 5 }] } }, + }), + { status: 200 }, + ); + }; + const { ctx, lines } = context({ token: "crp_x" }); + await withFetch(fake, () => command.run(["ads", "show", "crawlproof-ad-144"], ctx)); + expect(calls[0]).toMatchObject({ method: "GET", url: `${DEFAULT_URL}/api/ads/v1/campaigns/crawlproof-ad-144` }); + expect(lines.join("\n")).toContain("impressions 12 (+100 free) · clicks 3 (+2 free) · spent 30¢ · visits attributed 5"); + + await withFetch(fake, () => command.run(["ads", "pause", "crawlproof-ad-144"], ctx)); + expect(calls[1]).toMatchObject({ method: "PATCH", body: JSON.stringify({ status: "paused" }) }); + await withFetch(fake, () => command.run(["ads", "budget", "crawlproof-ad-144", "300"], ctx)); + expect(calls[2]).toMatchObject({ method: "PATCH", body: JSON.stringify({ daily_budget_cents: 300 }) }); + await expect(command.run(["ads", "budget", "crawlproof-ad-144", "lots"], ctx)).rejects.toThrow(/cents per day/); + + await expect(command.run(["ads", "delete", "crawlproof-ad-144"], ctx)).rejects.toThrow(/--yes/); + const yes = context({ token: "crp_x" }, { yes: true }); + await withFetch(fake, () => command.run(["ads", "delete", "crawlproof-ad-144"], yes.ctx)); + expect(calls[3]).toMatchObject({ method: "DELETE" }); + expect(yes.lines).toEqual(["deleted crawlproof-ad-144"]); +}); diff --git a/packages/plugin-outreachgraph/package.json b/packages/plugin-outreachgraph/package.json index 06ff7ae..9247e36 100644 --- a/packages/plugin-outreachgraph/package.json +++ b/packages/plugin-outreachgraph/package.json @@ -1,6 +1,6 @@ { "name": "@profullstack/myna-plugin-outreachgraph", - "version": "0.6.0", + "version": "0.7.0", "description": "myna plugin: pull the people OutreachGraph ranks for you into the follow graph as seeds.", "license": "MIT", "type": "module",