From aadc9cb8e029a9cb1b70331925616d30ba97b0c7 Mon Sep 17 00:00:00 2001 From: Sidney Swift <158200036+sidneyswift@users.noreply.github.com> Date: Wed, 21 Oct 2026 07:31:57 -0400 Subject: [PATCH] test: guard published CLI examples and retired email flag --- __tests__/fixtures/published-guide/README.md | 16 ++ __tests__/fixtures/published-guide/cli.mdx | 192 ++++++++++++++++++ .../fixtures/published-guide/intercept.cjs | 19 ++ .../fixtures/published-guide/provenance.json | 6 + __tests__/publishedCommands.test.ts | 109 ++++++++++ 5 files changed, 342 insertions(+) create mode 100644 __tests__/fixtures/published-guide/README.md create mode 100644 __tests__/fixtures/published-guide/cli.mdx create mode 100644 __tests__/fixtures/published-guide/intercept.cjs create mode 100644 __tests__/fixtures/published-guide/provenance.json create mode 100644 __tests__/publishedCommands.test.ts diff --git a/__tests__/fixtures/published-guide/README.md b/__tests__/fixtures/published-guide/README.md new file mode 100644 index 0000000..2daca5c --- /dev/null +++ b/__tests__/fixtures/published-guide/README.md @@ -0,0 +1,16 @@ +# Published command fixture + +`cli.mdx` is the complete, unmodified Marketing guide at the immutable source in +`provenance.json`. The test extracts its email/song examples and chat-link flag, +then runs the built CLI with synthetic credentials and intercepted native fetch. +No endpoint is contacted. Request records omit headers. + +Run `pnpm build` then `pnpm exec vitest run __tests__/publishedCommands.test.ts`. +Documentation owners can set `RECOUP_CLI_REFERENCE_PATH` to an absolute candidate +Markdown path to run the same parser/request assertions before publishing. + +When these examples intentionally change, copy the complete guide from its owning +commit, update provenance and SHA256, and review the expected requests together. +The default check protects this pinned guide; it does not monitor future live docs +or establish installed npm package behavior. Coverage is limited to email/song +examples and the retired email flag, not every CLI command or authorization rule. diff --git a/__tests__/fixtures/published-guide/cli.mdx b/__tests__/fixtures/published-guide/cli.mdx new file mode 100644 index 0000000..0ebdddc --- /dev/null +++ b/__tests__/fixtures/published-guide/cli.mdx @@ -0,0 +1,192 @@ +--- +title: "CLI" +description: "Install the Recoup CLI and interact with the platform from your terminal." +--- + +The Recoup CLI (`@recoupable/cli`) wraps the Recoup API for terminal-first workflows. It's available as a global npm package and comes pre-installed in sandbox environments. + + +**The CLI is in beta.** The reference below follows the v0.1.15 source command set. Check `recoup --version` and `--help` for the package installed in your environment. For research workflows and most content operations, call the [REST API](/api-reference) directly — the CLI is gradually catching up. + +**Agents:** if a command isn't listed below, don't retry — fall back to the corresponding REST endpoint linked under each command. + + +## Install + +```bash +npm install -g @recoupable/cli +``` + +## Authenticate + +Set your API key as an environment variable: + +```bash +export RECOUP_API_KEY=your-api-key +``` + +Verify it works: + +```bash +recoup whoami +``` + + +Get an API key from the [API Keys page](https://chat.recoupable.dev/keys) or use the [agent signup](/agents) for instant key generation. + + +## Configuration + +| Variable | Required | Default | Description | +|----------|----------|---------|-------------| +| `RECOUP_API_KEY` | Yes | — | Your Recoup API key | +| `RECOUP_API_URL` | No | `https://recoup-api.vercel.app` | API base URL override; set `https://api.recoupable.dev` to use the canonical API domain | + +All commands support `--json` for machine-readable output and `--help` for usage info. + +--- + +## Commands + +### whoami + +Show the authenticated account. See [`GET /api/accounts/id`](/api-reference/accounts/id). + +```bash +recoup whoami +recoup whoami --json +``` + +### orgs + +List organizations. See [`GET /api/organizations`](/api-reference/organizations/list). + +```bash +recoup orgs list +recoup orgs list --account +``` + +### artists + +List artists. See [`GET /api/artists`](/api-reference/artists/list). + +```bash +recoup artists list +recoup artists list --org +recoup artists list --account +``` + +### emails + +Send an email to the authenticated account. See [`POST /api/emails`](/api-reference/emails/create). + +```bash +recoup emails --subject "Pulse Report" --text "Here's your weekly summary." +recoup emails --subject "Update" --html "

Report

Details here.

" +``` + +| Flag | Required | Description | +|------|----------|-------------| +| `--subject ` | No | Email subject line; defaults from the body when omitted | +| `--text ` | No | Plain text or Markdown body | +| `--html ` | No | Raw HTML body (takes precedence over `--text`) | +| `--cc ` | No | CC recipient (repeatable) | +| `--chat-id ` | No | Chat ID for a chat link in the email footer | +| `--account ` | No | Send to a specific account (org keys only) | + +### chats + +Manage chats. See [`GET /api/chats`](/api-reference/chat/chats) and [`POST /api/chats`](/api-reference/chat/create). + +```bash +recoup chats list +recoup chats create --name "My Topic" +recoup chats create --name "My Topic" --artist +``` + +### sandboxes + +Manage sandboxes. See [`GET /api/sandboxes`](/api-reference/sandboxes/list) and [`POST /api/sandboxes`](/api-reference/sandboxes/create). + +```bash +recoup sandboxes list +recoup sandboxes create +recoup sandboxes create --command "ls -la" +``` + +### tasks + +Check background task status. See [`GET /api/tasks/runs`](/api-reference/tasks/runs). + +```bash +recoup tasks status --run +``` + +### songs + +Run AI music analysis. See [`POST /api/songs/analyze`](/api-reference/songs/analyze) and [`GET /api/songs/analyze/presets`](/api-reference/songs/analyze-presets). + +```bash +recoup songs presets +recoup songs analyze --preset catalog_metadata --audio https://example.com/track.mp3 +recoup songs analyze --prompt "Describe the production style" --audio https://example.com/track.mp3 +``` + +Exactly one of `--preset` or `--prompt` is required, together with `--audio`. The API rejects both selectors together and verifies the audio URL before inference. The coordinated CLI validation fix reports these errors before sending a request; older installed packages may send them to the API. + +| Flag | Required | Description | +|------|----------|-------------| +| `--preset ` | Conditional | Curated analysis preset. Required when `--prompt` is omitted. | +| `--prompt ` | Conditional | Custom text prompt. Required when `--preset` is omitted. | +| `--audio ` | Yes | Public URL to an audio file (MP3, WAV, FLAC), required for every preset and custom prompt. Metadata-only analysis is not supported. | +| `--max-tokens ` | No | Max tokens to generate (default 512). | + +--- + +## content + +Content creation pipeline — generate AI-powered social videos for artists. + +### List templates + +```bash +recoup content templates +``` + +### Validate an artist + +Check that an artist has the required assets before creating content. See [`GET /api/content/validate`](/api-reference/content/validate). + +```bash +recoup content validate --artist +``` + +### Estimate cost + +Preview estimated cost and duration before kicking off the pipeline. See [`GET /api/content/estimate`](/api-reference/content/estimate). + +```bash +recoup content estimate +recoup content estimate --lipsync --batch 2 --compare +``` + +### Create content + +Run the full content-creation pipeline for an artist. See [`POST /api/content/create`](/api-reference/content/create). + +```bash +recoup content create --artist +recoup content create --artist --template --lipsync --upscale +``` + +| Flag | Required | Description | +|------|----------|-------------| +| `--artist ` | Yes | Artist account ID | +| `--template ` | No | Template name (default: random) | +| `--lipsync` | No | Enable lipsync mode | +| `--upscale` | No | Enable upscaling | +| `--caption-length ` | No | Max caption length in characters | + + +For finer-grained control (individual image, video, caption, transcription, edit, upscale, or analyze operations), call the [content REST endpoints](/api-reference/content/generate-image) directly. Those primitives aren't yet exposed as individual CLI subcommands. + diff --git a/__tests__/fixtures/published-guide/intercept.cjs b/__tests__/fixtures/published-guide/intercept.cjs new file mode 100644 index 0000000..5573ab8 --- /dev/null +++ b/__tests__/fixtures/published-guide/intercept.cjs @@ -0,0 +1,19 @@ +const { appendFileSync } = require("node:fs"); + +globalThis.fetch = async (input, options = {}) => { + const url = new URL(String(input)); + if (url.origin !== "https://cli-fixture.invalid") { + throw new Error("Unexpected fixture origin"); + } + appendFileSync(process.env.CLI_FIXTURE_REQUEST_LOG, JSON.stringify({ + method: options.method, + path: url.pathname, + body: options.body ? JSON.parse(options.body) : null, + }) + "\n"); + return new Response(JSON.stringify({ + message: "Synthetic email response", + response: "Synthetic analysis response", + elapsed_seconds: 0, + presets: [], + }), { status: 200, headers: { "Content-Type": "application/json" } }); +}; diff --git a/__tests__/fixtures/published-guide/provenance.json b/__tests__/fixtures/published-guide/provenance.json new file mode 100644 index 0000000..e681e29 --- /dev/null +++ b/__tests__/fixtures/published-guide/provenance.json @@ -0,0 +1,6 @@ +{ + "repository": "https://github.com/recoupable/marketing", + "commit": "e1cc8eaf09c19a037e5615bffde76b3e8cc573c3", + "path": "content/docs/source/cli.mdx", + "sha256": "960556fcd300c5216fa708db8f1ca9379ae4592788b4df57b7cfcda2f08c3eae" +} diff --git a/__tests__/publishedCommands.test.ts b/__tests__/publishedCommands.test.ts new file mode 100644 index 0000000..ff5f1e4 --- /dev/null +++ b/__tests__/publishedCommands.test.ts @@ -0,0 +1,109 @@ +import { createHash } from "node:crypto"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync, existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import provenance from "./fixtures/published-guide/provenance.json"; + +const fixtures = join(__dirname, "fixtures", "published-guide"); +const snapshot = readFileSync(join(fixtures, "cli.mdx"), "utf8"); +// Documentation owners can run the same guard against a candidate guide. +const guide = process.env.RECOUP_CLI_REFERENCE_PATH + ? readFileSync(process.env.RECOUP_CLI_REFERENCE_PATH, "utf8") + : snapshot; + +function section(name: string) { + const heading = `### ${name}\n`; + expect(guide.split(heading)).toHaveLength(2); + return guide.split(heading)[1].split(/^#{1,3} /m)[0]; +} + +function examples(name: string) { + return [...section(name).matchAll(/```bash\n([\s\S]*?)```/g)] + .flatMap((match) => match[1].trim().split("\n")); +} + +function run(command: string) { + // These published examples use double-quoted arguments, without shell expansion. + // Reject unsupported syntax rather than accidentally executing a shell. + const tokens = command.match(/"[^"\n]*"|[^\s"]+/g) ?? []; + expect(tokens[0]).toBe("recoup"); + expect(tokens.join(" ")).toBe(command); + const args = tokens.slice(1).map((token) => token.replace(/^"|"$/g, "")); + const directory = mkdtempSync(join(tmpdir(), "recoup-cli-guide-")); + const log = join(directory, "requests.jsonl"); + try { + const result = spawnSync(process.execPath, [ + "--require", join(fixtures, "intercept.cjs"), + join(__dirname, "..", "dist", "bin.cjs"), ...args, + ], { + encoding: "utf8", + timeout: 10000, + env: { + RECOUP_API_KEY: "fixture-not-a-real-key", + RECOUP_API_URL: "https://cli-fixture.invalid", + CLI_FIXTURE_REQUEST_LOG: log, + }, + }); + expect(result.error).toBeUndefined(); + const requests = existsSync(log) + ? readFileSync(log, "utf8").trim().split("\n").map((line) => JSON.parse(line)) + : []; + return { ...result, requests }; + } finally { + rmSync(directory, { recursive: true, force: true }); + } +} + +describe("published command contract (built CLI, intercepted requests)", () => { + it("pins the unmodified maintained guide to its recorded source", () => { + expect(createHash("sha256").update(snapshot).digest("hex")).toBe(provenance.sha256); + }); + + it("runs every email example from the guide", () => { + const commands = examples("emails"); + expect(commands).toHaveLength(2); + const expected = [ + { subject: "Pulse Report", text: "Here's your weekly summary." }, + { subject: "Update", html: "

Report

Details here.

" }, + ]; + commands.forEach((command, index) => { + const result = run(command); + expect(result.status).toBe(0); + expect(result.requests).toEqual([{ method: "POST", path: "/api/emails", body: expected[index] }]); + }); + }); + + it("maps the published chat link option to chat_id", () => { + const option = section("emails").match(/\| `(--[^`]+)` \| No \| Chat ID for a chat link/); + expect(option).not.toBeNull(); + const result = run(`${examples("emails")[0]} ${option![1].replace("", "fixture-chat")}`); + expect(result.status).toBe(0); + expect(result.requests).toEqual([{ method: "POST", path: "/api/emails", body: { + subject: "Pulse Report", text: "Here's your weekly summary.", chat_id: "fixture-chat", + } }]); + }); + + it("rejects retired --room-id before any request", () => { + const result = run(`${examples("emails")[0]} --room-id fixture-chat`); + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("unknown option '--room-id'"); + expect(result.requests).toEqual([]); + }); + + it("runs every song example from the guide", () => { + const commands = examples("songs"); + expect(commands).toHaveLength(3); + const expected = [ + { method: "GET", path: "/api/songs/analyze/presets", body: null }, + { method: "POST", path: "/api/songs/analyze", body: { preset: "catalog_metadata", audio_url: "https://example.com/track.mp3" } }, + { method: "POST", path: "/api/songs/analyze", body: { prompt: "Describe the production style", audio_url: "https://example.com/track.mp3" } }, + ]; + commands.forEach((command, index) => { + const result = run(command); + expect(result.status).toBe(0); + expect(result.requests).toEqual([expected[index]]); + }); + }); +});