diff --git a/README.md b/README.md index 7d281de..35e5190 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ TypeScript, installed as executables on `PATH`. | [`blog-post`](#blog-post) | Publish to a plain-HTML blog without breaking the feed | | [`ask-web`](#ask-web) | Answer a question from the live web, with its sources | | [`tts`](#tts) | Read text aloud and keep the audio | +| [`affiliate`](#affiliate) | Work through a list of programs you mean to sign up for | ## Requirements @@ -492,6 +493,55 @@ ask: a full settings object would override whatever the voice was tuned with in the dashboard, on an account other people share. Synthesis spends characters from that shared quota, and nothing here retries, so a failed call never costs twice. +### `affiliate` + +Walks a list of signup pages one at a time, remembers which you have dealt +with, and keeps the referral link each one hands back: + +```sh +affiliate list --file programs.md +affiliate next --open # open the next one you have not done +affiliate join elevenlabs https://try.elevenlabs.io/abc123 +affiliate skip notion --note "closed to new affiliates" +affiliate links --format markdown # → - [ElevenLabs](https://try.…) +``` + +**The list is any text with links in it.** A bare column of URLs, a markdown +table, a bullet list, a CSV someone exported — the first URL on a line is the +program and whatever precedes it is the name. Rather than asking which format it +is, it takes the first URL and works out the name from context: the bracketed +text of a markdown link, the first cell of a table row, or the host when there +is nothing else. A line with no URL is a heading, not an error. + +Two entries that differ only by `utm_*` are one program, so the same page shared +from a newsletter and from a tweet does not ask you to sign up twice. + +Progress lives in `~/.config/cli-tools/affiliates.json` and the application +answers in `affiliate-profile.json`, both `0600` in a `0700` directory. **An +entry you have joined survives being removed from the list** — it moves to the +end rather than disappearing, because losing a referral link to somebody tidying +the source file would be the worst thing this could do. + +The contact address resolves from `--email`, then `$AFFILIATE_EMAIL`, then the +profile file, then whoever `moshcode whoami` reports. The account is last on +purpose: it is the one you cannot override in the moment, and signing up as the +wrong identity is not undone by re-running the command. + +```sh +affiliate profile --site https://profullstack.com --audience "…" +affiliate answers # the four things every application asks, ready to paste +``` + +`answers` prints gaps as `(not set)` rather than filling them in. A form that +asks for audience size and receives a number nobody checked is the fastest way +to lose the account it was meant to open. + +**It never submits anything.** Signing up accepts terms and hands over payout +identity as a named person, which is not something a script should do on +someone's behalf — and every one of these is behind an email-verification loop +regardless, so a form-filler would stop at the same wall with one more moving +part. + ## As a moshcode plugin This repo is also a plugin marketplace: diff --git a/bin/affiliate.ts b/bin/affiliate.ts new file mode 100755 index 0000000..5e5571a --- /dev/null +++ b/bin/affiliate.ts @@ -0,0 +1,264 @@ +#!/usr/bin/env -S npx --yes tsx +/** + * affiliate — work through a list of programs you mean to sign up for. + * + * affiliate list --file programs.md + * affiliate next --open # open the next one you have not done + * affiliate join elevenlabs https://try.elevenlabs.io/abc123 + * affiliate links --format markdown + * + * It opens pages and remembers what came back. It does not fill the forms in: + * accepting terms and entering payout identity is the applicant's to do, and it + * is behind an email-verification loop regardless. + */ + +import { spawnSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; + +import { UsageError, parseArgs } from '../src/args.ts'; +import { + type LinkFormat, + ListError, + type Profile, + type Row, + applyStatus, + findRow, + formatLinks, + formatRows, + merge, + nextPending, + openCommand, + parseList, + renderAnswers, +} from '../src/affiliates.ts'; +import { + loadProfile, + loadState, + profilePath, + saveProfile, + saveState, + statePath, +} from '../src/affiliates-store.ts'; +import { isMain } from '../src/is-main.ts'; + +const USAGE = `Usage: + affiliate list [--file LIST] + affiliate next [--open] + affiliate open + affiliate join [REFERRAL_URL] + affiliate skip + affiliate links [--format text|markdown|json] + affiliate answers + affiliate profile [--email E] [--site S] [--audience A] [--promotion P] + +Walks a list of signup pages one at a time, remembers which you have dealt +with, and keeps the referral link each one gives back. + +The list is any text with links in it — a bare column of URLs, a markdown +table, a bullet list, a CSV. The first URL on a line is the program and +whatever precedes it is the name. + +Options: + --file LIST the list to read ("-" for stdin; default: $AFFILIATE_LIST) + --open with "next", open it in the browser as well + --all with "open", open every entry not yet done + --format F with "links": text | markdown | json + --note TEXT with "join" or "skip", a line to remember why + -h, --help show this help + +Progress lives in ~/.config/cli-tools/affiliates.json and the application +answers in affiliate-profile.json, both 0600 ($CLI_TOOLS_AFFILIATES and +$CLI_TOOLS_AFFILIATE_PROFILE override). + +The contact address is your --email, then $AFFILIATE_EMAIL, then the profile +file, then whoever \`moshcode whoami\` reports. + +It never submits anything. Signing up accepts terms and hands over payout +identity as a named person, which is not something a script should do on +someone's behalf. +`; + +function readList(values: Map, env: NodeJS.ProcessEnv): string { + const path = values.get('--file') ?? env.AFFILIATE_LIST; + if (!path) { + throw new UsageError('no list — pass --file LIST, or set $AFFILIATE_LIST'); + } + + try { + return readFileSync(path === '-' ? 0 : path, 'utf8'); + } catch (error) { + throw new UsageError(`cannot read ${path}: ${(error as Error).message}`); + } +} + +function rowsFor(values: Map, env: NodeJS.ProcessEnv): Row[] { + return merge(parseList(readList(values, env)), loadState(env)); +} + +/** Open a URL without a shell, so nothing in the list can become shell syntax. */ +function openUrl(url: string, env: NodeJS.ProcessEnv): void { + const [command, ...args] = openCommand(url, env); + const result = spawnSync(command!, args, { stdio: 'ignore' }); + if (result.error) { + throw new Error(`could not open a browser (${command}): ${result.error.message}`); + } +} + +if (isMain(import.meta.url)) { + try { + const { flags, values, positional } = parseArgs(process.argv.slice(2), { + boolean: ['-h', '--help', '--open', '--all'], + string: [ + '--file', '--format', '--note', + '--email', '--site', '--audience', '--promotion', + ], + }); + + const command = positional[0]; + if (flags.has('-h') || flags.has('--help') || !command) { + process.stdout.write(USAGE); + process.exit(command ? 0 : 1); + } + + const env = process.env; + + switch (command) { + case 'list': { + process.stdout.write(formatRows(rowsFor(values, env))); + break; + } + + case 'next': { + const rows = rowsFor(values, env); + const row = nextPending(rows); + if (!row) { + process.stderr.write('affiliate: nothing left — every entry is joined or skipped\n'); + break; + } + + process.stdout.write(`${row.index} ${row.name}\n${row.url}\n`); + if (flags.has('--open')) { + openUrl(row.url, env); + saveState(applyStatus(loadState(env), row, 'opened'), env); + process.stderr.write('opened · record the link with `affiliate join`\n'); + } + break; + } + + case 'open': { + const rows = rowsFor(values, env); + const targets = flags.has('--all') + ? rows.filter((row) => row.status === 'pending' || row.status === 'opened') + : [findRow(rows, positional[1] ?? '')]; + + if (!flags.has('--all') && !positional[1]) { + throw new UsageError('which one? give an index or a name, or --all'); + } + + let state = loadState(env); + for (const row of targets) { + openUrl(row.url, env); + state = applyStatus(state, row, 'opened'); + process.stdout.write(`${row.name}\t${row.url}\n`); + } + saveState(state, env); + break; + } + + case 'join': + case 'skip': { + const rows = rowsFor(values, env); + if (!positional[1]) throw new UsageError(`which one? \`affiliate ${command} \``); + + const row = findRow(rows, positional[1]); + const referral = command === 'join' ? positional[2] : undefined; + if (referral && !/^https?:\/\//i.test(referral)) { + throw new UsageError(`that does not look like a link: ${referral}`); + } + + const note = values.get('--note'); + saveState( + applyStatus( + loadState(env), + row, + command === 'join' ? 'joined' : 'skipped', + { ...(referral ? { referral } : {}), ...(note ? { note } : {}) }, + ), + env, + ); + + process.stdout.write( + `${command === 'join' ? '✓' : '–'} ${row.name}${referral ? ` → ${referral}` : ''}\n`, + ); + if (command === 'join' && !referral) { + process.stderr.write( + 'no referral link recorded — add it with ' + + `\`affiliate join ${row.index} \` when you have it\n`, + ); + } + break; + } + + case 'links': { + const format = (values.get('--format') ?? 'text') as LinkFormat; + if (!['text', 'markdown', 'json'].includes(format)) { + throw new UsageError(`--format must be text, markdown or json, got "${format}"`); + } + + // The state alone is enough here: a link you have earned should print + // whether or not the list it came from is still on this machine. + const rows = merge([], loadState(env)); + const out = formatLinks(rows, format); + if (!out) { + process.stderr.write('affiliate: no referral links recorded yet\n'); + break; + } + process.stdout.write(out); + break; + } + + case 'answers': { + process.stdout.write(renderAnswers(loadProfile(env))); + process.stderr.write(`${profilePath(env)}\n`); + break; + } + + case 'profile': { + const updates: Partial = {}; + for (const field of ['email', 'site', 'audience', 'promotion'] as const) { + const value = values.get(`--${field}`); + if (value !== undefined) updates[field] = value; + } + + if (Object.keys(updates).length > 0) { + const path = saveProfile(updates, env); + process.stderr.write(`affiliate: written to ${path}\n`); + } + + const profile = loadProfile(env); + process.stdout.write( + `email ${profile.email ?? '(not set)'}\n` + + `site ${profile.site ?? '(not set)'}\n` + + `audience ${profile.audience ?? '(not set)'}\n` + + `promotion ${profile.promotion ?? '(not set)'}\n`, + ); + break; + } + + case 'where': { + process.stdout.write(`${statePath(env)}\n${profilePath(env)}\n`); + break; + } + + default: + throw new UsageError(`unknown command: ${command}`); + } + } catch (error) { + if (error instanceof UsageError || error instanceof ListError) { + process.stderr.write(`affiliate: ${error.message}\n`); + process.exit(1); + } + process.stderr.write(`affiliate: ${error instanceof Error ? error.message : error}\n`); + process.exit(2); + } +} diff --git a/src/affiliates-store.ts b/src/affiliates-store.ts new file mode 100644 index 0000000..53d493b --- /dev/null +++ b/src/affiliates-store.ts @@ -0,0 +1,112 @@ +/** + * Where the progress and the profile live. + * + * Same shape and the same reasoning as {@link ./credentials.ts}: one machine's + * own file, 0600 in a 0700 directory, never synced. A referral link is not a + * secret in the way an API key is, but the profile beside it carries a contact + * address, and the two belong under the same permissions rather than in two + * places with two answers about who can read them. + */ + +import { chmodSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { spawnSync } from 'node:child_process'; +import { homedir } from 'node:os'; +import { dirname, join } from 'node:path'; + +import { type Profile, type State, extractEmail } from './affiliates.ts'; + +function xdgConfigHome(env: NodeJS.ProcessEnv): string { + return env.XDG_CONFIG_HOME || join(homedir(), '.config'); +} + +export function statePath(env: NodeJS.ProcessEnv = process.env): string { + return env.CLI_TOOLS_AFFILIATES || join(xdgConfigHome(env), 'cli-tools', 'affiliates.json'); +} + +export function profilePath(env: NodeJS.ProcessEnv = process.env): string { + return ( + env.CLI_TOOLS_AFFILIATE_PROFILE || + join(xdgConfigHome(env), 'cli-tools', 'affiliate-profile.json') + ); +} + +/** + * Read a JSON file that is allowed not to exist. + * + * A missing file is the normal first run. Malformed JSON is an error and not an + * empty object, because silently starting over would present a list with every + * row back at "pending" and no indication that the record of what you already + * joined is sitting on disk one syntax error away. + */ +function readJson(path: string): Record { + let text: string; + try { + text = readFileSync(path, 'utf8'); + } catch { + return {}; + } + + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch (error) { + throw new Error(`${path}: not valid JSON — ${(error as Error).message}`); + } + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {}; + return parsed as Record; +} + +function writeJson(path: string, value: unknown): string { + mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); + writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 }); + // The mode above applies only on creation, so an existing file keeps whatever + // it had — including a permissive mode from a hand edit. + chmodSync(path, 0o600); + return path; +} + +export function loadState(env: NodeJS.ProcessEnv = process.env): State { + return readJson(statePath(env)) as State; +} + +export function saveState(state: State, env: NodeJS.ProcessEnv = process.env): string { + return writeJson(statePath(env), state); +} + +export type AccountReader = () => string | null; + +/** Ask moshcode who is logged in. Absent or not logged in is not an error. */ +export const moshcodeAccount: AccountReader = () => { + const result = spawnSync('moshcode', ['whoami'], { encoding: 'utf8', timeout: 15_000 }); + if (result.error || result.status !== 0) return null; + return extractEmail(result.stdout ?? ''); +}; + +export function loadProfile( + env: NodeJS.ProcessEnv = process.env, + account: AccountReader = moshcodeAccount, +): Profile { + const stored = readJson(profilePath(env)) as Partial>; + const text = (value: unknown): string | null => + typeof value === 'string' && value.trim() ? value.trim() : null; + + return { + email: text(env.AFFILIATE_EMAIL) ?? text(stored.email) ?? account(), + site: text(stored.site), + audience: text(stored.audience), + promotion: text(stored.promotion), + }; +} + +export function saveProfile( + profile: Partial, + env: NodeJS.ProcessEnv = process.env, +): string { + const stored = readJson(profilePath(env)); + const merged: Record = { ...stored }; + for (const [key, value] of Object.entries(profile)) { + if (value === null || value === undefined || value === '') delete merged[key]; + else merged[key] = value; + } + return writeJson(profilePath(env), merged); +} diff --git a/src/affiliates.ts b/src/affiliates.ts new file mode 100644 index 0000000..02004eb --- /dev/null +++ b/src/affiliates.ts @@ -0,0 +1,396 @@ +/** + * Work through a list of programs you mean to sign up for, one at a time. + * + * The list is yours — any text file with links in it. Affiliate programs are + * the case this was written for, but nothing here knows that: it is a cursor + * over a list of URLs that remembers which ones you have dealt with and what + * each one gave you back. + * + * What it deliberately does not do is fill the forms in. Every one of these + * ends in accepting terms and entering payout identity as a named person, which + * is the one step that has to be the person — and it is gated behind an email + * verification loop anyway, so an automated filler would stop at the same wall + * having added a second thing that can break. Opening the next one, having the + * answers ready to paste, and keeping the resulting links in one place is the + * part that was actually tedious. + */ + +export type Status = 'pending' | 'opened' | 'joined' | 'skipped'; + +/** + * Something wrong with what was typed, rather than with the machinery — a name + * that matches nothing, an index past the end. Separated so the entry point can + * exit 1 for "you typed it wrong" and keep 2 for "this broke", which is the + * difference a script wrapping this needs. + */ +export class ListError extends Error {} + +export interface Entry { + name: string; + url: string; +} + +export interface Record_ { + name: string; + url: string; + status: Status; + /** The referral link the program issued, once there is one. */ + referral?: string; + note?: string; + updated?: string; +} + +export type State = Record; + +export interface Row extends Record_ { + /** 1-based, as printed. */ + index: number; + key: string; +} + +/** + * Anything that looks like an http(s) URL, up to whitespace. + * + * A closing bracket is *not* a terminator, even though the common case — a + * markdown link — ends in one. Excluding it truncates any URL with a bracket in + * the path, and those exist (wiki articles especially). Taking too much and + * balancing it back in {@link stripTrailingPunctuation} handles both. + */ +const URL_PATTERN = /https?:\/\/[^\s<>"']+/i; + +/** + * Pull entries out of whatever the list happens to be. + * + * Lists like this arrive as a bare column of URLs, a markdown table, a + * hand-written `- Name — https://…` bullet list, or a CSV someone exported. All + * of them have the same two facts on a line, so rather than asking which format + * it is, take the first URL on the line and treat whatever precedes it as the + * name. A line with no URL is not an error; it is a heading or a blank. + */ +export function parseList(text: string): Entry[] { + const entries: Entry[] = []; + const seen = new Set(); + + for (const raw of text.split('\n')) { + const line = raw.trim(); + if (!line || line.startsWith('#')) continue; + + const match = line.match(URL_PATTERN); + if (!match) continue; + + const url = stripTrailingPunctuation(match[0]); + const key = normalizeKey(url); + if (seen.has(key)) continue; + seen.add(key); + + entries.push({ name: nameFor(line, match.index ?? 0, url), url }); + } + + return entries; +} + +/** + * A URL at the end of a sentence collects the punctuation after it. Brackets + * are balanced rather than stripped blindly, because a legitimate path can end + * in one. + */ +function stripTrailingPunctuation(url: string): string { + let out = url; + while (out.length > 1 && /[.,;:!?]$/.test(out)) out = out.slice(0, -1); + while (out.endsWith(')') && count(out, ')') > count(out, '(')) out = out.slice(0, -1); + while (out.endsWith(']') && count(out, ']') > count(out, '[')) out = out.slice(0, -1); + return out; +} + +function count(text: string, character: string): number { + let total = 0; + for (const c of text) if (c === character) total += 1; + return total; +} + +/** + * The name is the text before the URL, with list and table punctuation taken + * off. When there is nothing usable there — a bare column of links — the host + * is a better label than an empty string. + */ +function nameFor(line: string, at: number, url: string): string { + let before = line.slice(0, at); + + // A markdown link puts the name in brackets immediately before the URL. + const markdown = before.match(/\[([^\]]+)\]\($/); + if (markdown) return markdown[1]!.trim(); + + // In a table row the name is the first cell, not every cell up to the link: + // taking the text before the URL would drag the commission column in with it. + if (line.trimStart().startsWith('|')) { + const cell = before + .split('|') + .map((part) => part.replace(/\*\*/g, '').trim()) + .find(Boolean); + if (cell) return cell; + } + + before = before + .replace(/^[\s|>*_-]+/, '') + .replace(/[\s|,;:—–-]+$/, '') + .replace(/\*\*/g, '') + .trim(); + + return before || hostOf(url); +} + +export function hostOf(url: string): string { + try { + return new URL(url).host.replace(/^www\./, ''); + } catch { + return url; + } +} + +/** + * The identity of an entry, so the same program in two differently-written + * lists is one row. + * + * Tracking parameters are dropped deliberately: the same signup page shared + * from a newsletter and from a tweet differs only by `utm_*`, and treating + * those as two programs would ask you to sign up twice. + */ +export function normalizeKey(url: string): string { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return url.trim().toLowerCase(); + } + + for (const name of [...parsed.searchParams.keys()]) { + if (/^(utm_|_bhlid$|ref$|source$)/i.test(name)) parsed.searchParams.delete(name); + } + + const host = parsed.host.replace(/^www\./, '').toLowerCase(); + const path = parsed.pathname.replace(/\/+$/, ''); + return `${host}${path}${parsed.search}`.toLowerCase(); +} + +/** + * Lay the list over what is already known. + * + * The list is the order; the state file is the memory. A program that has been + * dealt with but has since dropped off the list is still returned, at the end — + * losing a referral link because someone tidied the source list would be the + * worst failure this can have. + */ +export function merge(entries: readonly Entry[], state: State): Row[] { + const rows: Row[] = []; + const used = new Set(); + + for (const entry of entries) { + const key = normalizeKey(entry.url); + used.add(key); + const known = state[key]; + rows.push({ + index: rows.length + 1, + key, + name: known?.name || entry.name, + url: entry.url, + status: known?.status ?? 'pending', + ...(known?.referral ? { referral: known.referral } : {}), + ...(known?.note ? { note: known.note } : {}), + ...(known?.updated ? { updated: known.updated } : {}), + }); + } + + for (const [key, known] of Object.entries(state)) { + if (used.has(key)) continue; + rows.push({ ...known, index: rows.length + 1, key }); + } + + return rows; +} + +/** The next one to deal with: anything not yet joined or explicitly skipped. */ +export function nextPending(rows: readonly Row[]): Row | null { + return rows.find((row) => row.status === 'pending' || row.status === 'opened') ?? null; +} + +/** + * Find a row by printed index, host, or name. + * + * The index is what you just read off the screen, so it is accepted first; but + * an index shifts when the list changes, and a command typed from scrollback + * would then hit the wrong program. Hosts and names do not shift, so they are + * accepted too and are the safer thing to use in a script. + */ +export function findRow(rows: readonly Row[], query: string): Row { + const trimmed = query.trim(); + + if (/^\d+$/.test(trimmed)) { + const row = rows[Number(trimmed) - 1]; + if (!row) throw new ListError(`no entry at index ${trimmed} (there are ${rows.length})`); + return row; + } + + const folded = trimmed.toLowerCase(); + const matches = rows.filter( + (row) => + hostOf(row.url).toLowerCase() === folded || + row.name.toLowerCase() === folded || + row.key === normalizeKey(trimmed), + ); + if (matches.length === 1) return matches[0]!; + if (matches.length > 1) { + throw new ListError(`"${trimmed}" matches ${matches.length} entries — use the index`); + } + + const loose = rows.filter( + (row) => + row.name.toLowerCase().startsWith(folded) || hostOf(row.url).toLowerCase().includes(folded), + ); + if (loose.length === 1) return loose[0]!; + if (loose.length > 1) { + throw new ListError(`"${trimmed}" matches ${loose.length} entries — use the index`); + } + + throw new ListError(`nothing matching "${trimmed}" in the list`); +} + +export function applyStatus( + state: State, + row: Row, + status: Status, + extra: { referral?: string; note?: string } = {}, + now: string = new Date().toISOString(), +): State { + const existing = state[row.key]; + return { + ...state, + [row.key]: { + name: row.name, + url: row.url, + status, + // A referral link already recorded survives a later status change; losing + // it to `skip` typed at the wrong index would be unrecoverable from here. + ...(extra.referral ?? existing?.referral + ? { referral: extra.referral ?? existing!.referral! } + : {}), + ...(extra.note ?? existing?.note ? { note: extra.note ?? existing!.note! } : {}), + updated: now, + }, + }; +} + +const MARKS: Record = { + pending: ' ', + opened: '·', + joined: '✓', + skipped: '–', +}; + +export function formatRows(rows: readonly Row[]): string { + if (rows.length === 0) return 'nothing in the list\n'; + + const width = Math.max(...rows.map((row) => row.name.length)); + const lines = rows.map((row) => { + const head = `${String(row.index).padStart(3)} ${MARKS[row.status]} ${row.name.padEnd(width)}`; + return row.referral ? `${head} ${row.referral}` : `${head} ${row.url}`; + }); + + const counts = { pending: 0, opened: 0, joined: 0, skipped: 0 }; + for (const row of rows) counts[row.status] += 1; + lines.push( + '', + `${counts.joined} joined · ${counts.opened} opened · ${counts.pending} pending · ${counts.skipped} skipped`, + ); + + return `${lines.join('\n')}\n`; +} + +export type LinkFormat = 'text' | 'markdown' | 'json'; + +/** Emit the referral links that exist, for pasting into a page or a feed. */ +export function formatLinks(rows: readonly Row[], format: LinkFormat = 'text'): string { + const joined = rows.filter((row) => row.referral); + if (joined.length === 0) return ''; + + if (format === 'json') { + return `${JSON.stringify( + joined.map((row) => ({ name: row.name, url: row.url, referral: row.referral })), + null, + 2, + )}\n`; + } + if (format === 'markdown') { + return `${joined.map((row) => `- [${row.name}](${row.referral})`).join('\n')}\n`; + } + return `${joined.map((row) => `${row.name}\t${row.referral}`).join('\n')}\n`; +} + +export interface Profile { + email: string | null; + site: string | null; + audience: string | null; + promotion: string | null; +} + +/** The first email address in a blob of text, or null. */ +export function extractEmail(text: string): string | null { + const match = text.match(/[^\s<>@]+@[^\s<>@]+\.[a-z]{2,}/i); + return match ? match[0] : null; +} + +/** + * Resolve the contact address. + * + * The moshcode account is the last resort rather than the first because it is + * the one an operator cannot override in the moment: an explicit flag, then the + * environment, then the profile file they wrote, and only then "whoever is + * logged in". Signing up as the wrong identity is not something you can undo by + * re-running the command. + */ +export function resolveEmail( + sources: { + flag?: string | undefined; + env?: string | undefined; + profile?: string | null | undefined; + account?: string | null | undefined; + } = {}, +): string | null { + return sources.flag || sources.env || sources.profile || sources.account || null; +} + +/** + * How to open a URL in the operator's actual browser. + * + * `$BROWSER` wins because it is the setting a person deliberately made; the + * per-platform openers are the fallback. Nothing here shells through a string, + * so a URL cannot become shell syntax. + */ +export function openCommand( + url: string, + env: NodeJS.ProcessEnv = process.env, + platform: string = process.platform, +): string[] { + if (env.BROWSER) return [env.BROWSER, url]; + if (platform === 'darwin') return ['open', url]; + if (platform === 'win32') return ['cmd', '/c', 'start', '', url]; + return ['xdg-open', url]; +} + +/** + * The answers these forms ask for, ready to paste. + * + * Printed rather than submitted, and printed with the gaps visible: a form that + * asks for audience size and receives a number nobody checked is the fastest + * way to lose an account, so an unset field says so instead of guessing. + */ +export function renderAnswers(profile: Profile): string { + const line = (label: string, value: string | null): string => + `${label}\n ${value ?? '(not set — cli-tools affiliate profile --help)'}\n`; + + return [ + line('Contact email', profile.email), + line('Website / where you will promote', profile.site), + line('Audience', profile.audience), + line('How will you promote us?', profile.promotion), + ].join('\n'); +} diff --git a/src/registry.ts b/src/registry.ts index 2e24ec7..17bbd3c 100644 --- a/src/registry.ts +++ b/src/registry.ts @@ -27,6 +27,7 @@ export interface Command { /** One-line summaries, so `cli-tools list` says what each command is for. */ const SUMMARIES: Record = { + affiliate: 'Work through a list of programs you mean to sign up for', 'ask-web': 'Answer a question from the live web, with its sources', 'blog-post': 'Publish to a plain-HTML blog without breaking the feed', 'cli-tools': 'This dispatcher: list, update and wire up the others', diff --git a/test/affiliates.test.ts b/test/affiliates.test.ts new file mode 100644 index 0000000..90f706d --- /dev/null +++ b/test/affiliates.test.ts @@ -0,0 +1,277 @@ +import { describe, expect, it } from 'vitest'; + +import { + type Row, + type State, + applyStatus, + extractEmail, + findRow, + formatLinks, + formatRows, + hostOf, + merge, + nextPending, + normalizeKey, + openCommand, + parseList, + renderAnswers, + resolveEmail, +} from '../src/affiliates.ts'; + +describe('parseList', () => { + // These lists arrive in whatever shape someone had to hand. Rather than + // asking which format it is, take the first URL on the line. + it('reads a bare column of URLs', () => { + expect(parseList('https://elevenlabs.io/affiliates\nhttps://descript.com/affiliate')).toEqual([ + { name: 'elevenlabs.io', url: 'https://elevenlabs.io/affiliates' }, + { name: 'descript.com', url: 'https://descript.com/affiliate' }, + ]); + }); + + it('reads a markdown bullet list', () => { + expect(parseList('- ElevenLabs — https://elevenlabs.io/affiliates')).toEqual([ + { name: 'ElevenLabs', url: 'https://elevenlabs.io/affiliates' }, + ]); + }); + + it('reads a markdown link', () => { + expect(parseList('* [Descript](https://www.descript.com/affiliate) — 15%')).toEqual([ + { name: 'Descript', url: 'https://www.descript.com/affiliate' }, + ]); + }); + + it('reads a markdown table row', () => { + expect( + parseList('| **Gamma** | 30% recurring | https://gammaapp.partnerstack.com/ | 90d |'), + ).toEqual([{ name: 'Gamma', url: 'https://gammaapp.partnerstack.com/' }]); + }); + + it('skips headings, blanks and comments', () => { + expect(parseList('# Tier 1\n\n## nope\n# https://commented.example\n')).toEqual([]); + }); + + // A URL at the end of a sentence collects the punctuation after it. + it('trims trailing punctuation without eating a legitimate bracket', () => { + expect(parseList('see https://a.example/x.').map((e) => e.url)).toEqual([ + 'https://a.example/x', + ]); + expect(parseList('[x](https://a.example/wiki_(y))').map((e) => e.url)).toEqual([ + 'https://a.example/wiki_(y)', + ]); + }); + + it('drops a duplicate that differs only by tracking parameters', () => { + const entries = parseList( + 'https://a.example/join?utm_source=x\nhttps://a.example/join?utm_source=y', + ); + expect(entries).toHaveLength(1); + }); +}); + +describe('normalizeKey', () => { + // The same page shared from a newsletter and from a tweet differs only by + // utm_*; two rows would ask you to sign up twice. + it('ignores tracking parameters, www and a trailing slash', () => { + expect(normalizeKey('https://www.A.example/Join/?utm_campaign=x&_bhlid=y')).toBe( + 'a.example/join', + ); + }); + + it('keeps a meaningful query', () => { + expect(normalizeKey('https://gammaapp.partnerstack.com/?group=affiliates')).toBe( + 'gammaapp.partnerstack.com?group=affiliates', + ); + }); +}); + +describe('merge', () => { + const entries = [ + { name: 'ElevenLabs', url: 'https://elevenlabs.io/affiliates' }, + { name: 'Descript', url: 'https://www.descript.com/affiliate' }, + ]; + + it('lays remembered status over the list order', () => { + const state: State = { + 'elevenlabs.io/affiliates': { + name: 'ElevenLabs', + url: 'https://elevenlabs.io/affiliates', + status: 'joined', + referral: 'https://try.elevenlabs.io/abc', + }, + }; + const rows = merge(entries, state); + expect(rows.map((row) => row.status)).toEqual(['joined', 'pending']); + expect(rows[0]!.referral).toBe('https://try.elevenlabs.io/abc'); + }); + + // Losing a referral link because someone tidied the source list would be the + // worst failure this can have. + it('keeps a remembered entry that has dropped off the list', () => { + const state: State = { + 'gone.example/join': { + name: 'Gone', + url: 'https://gone.example/join', + status: 'joined', + referral: 'https://gone.example/r/1', + }, + }; + const rows = merge(entries, state); + expect(rows).toHaveLength(3); + expect(rows[2]).toMatchObject({ name: 'Gone', index: 3 }); + }); +}); + +describe('nextPending', () => { + const row = (index: number, status: Row['status']): Row => ({ + index, + key: `k${index}`, + name: `n${index}`, + url: `https://e${index}.example`, + status, + }); + + it('returns the first that is neither joined nor skipped', () => { + expect(nextPending([row(1, 'joined'), row(2, 'skipped'), row(3, 'opened')])?.index).toBe(3); + }); + + it('is null when everything is dealt with', () => { + expect(nextPending([row(1, 'joined'), row(2, 'skipped')])).toBeNull(); + }); +}); + +describe('findRow', () => { + const rows = merge( + [ + { name: 'ElevenLabs', url: 'https://elevenlabs.io/affiliates' }, + { name: 'Descript', url: 'https://www.descript.com/affiliate' }, + ], + {}, + ); + + it('takes the printed index', () => { + expect(findRow(rows, '2').name).toBe('Descript'); + }); + + it('takes a host or a name, ignoring case', () => { + expect(findRow(rows, 'elevenlabs.io').name).toBe('ElevenLabs'); + expect(findRow(rows, 'descript').name).toBe('Descript'); + }); + + it('says so when nothing matches', () => { + expect(() => findRow(rows, 'nope')).toThrow(/nothing matching/); + }); + + it('refuses an index past the end', () => { + expect(() => findRow(rows, '9')).toThrow(/no entry at index 9/); + }); +}); + +describe('applyStatus', () => { + const rows = merge([{ name: 'A', url: 'https://a.example/join' }], {}); + + it('records the referral link and when', () => { + const state = applyStatus({}, rows[0]!, 'joined', { referral: 'https://a.example/r/1' }, 'T'); + expect(state['a.example/join']).toMatchObject({ + status: 'joined', + referral: 'https://a.example/r/1', + updated: 'T', + }); + }); + + // `skip` typed at the wrong index would otherwise be unrecoverable. + it('keeps an existing referral link through a later status change', () => { + const joined = applyStatus({}, rows[0]!, 'joined', { referral: 'https://a.example/r/1' }, 'T'); + const skipped = applyStatus(joined, rows[0]!, 'skipped', {}, 'U'); + expect(skipped['a.example/join']!.referral).toBe('https://a.example/r/1'); + }); +}); + +describe('formatRows', () => { + it('marks status and counts the tail', () => { + const rows = merge([{ name: 'A', url: 'https://a.example/join' }], {}); + const out = formatRows(rows); + expect(out).toContain('1 A https://a.example/join'); + expect(out).toContain('0 joined · 0 opened · 1 pending · 0 skipped'); + }); + + it('says so when there is nothing', () => { + expect(formatRows([])).toBe('nothing in the list\n'); + }); +}); + +describe('formatLinks', () => { + const rows = merge([], { + 'a.example/join': { + name: 'A', + url: 'https://a.example/join', + status: 'joined', + referral: 'https://a.example/r/1', + }, + 'b.example/join': { name: 'B', url: 'https://b.example/join', status: 'pending' }, + }); + + it('emits only the ones with a link', () => { + expect(formatLinks(rows, 'markdown')).toBe('- [A](https://a.example/r/1)\n'); + expect(formatLinks(rows, 'text')).toBe('A\thttps://a.example/r/1\n'); + expect(JSON.parse(formatLinks(rows, 'json'))).toHaveLength(1); + }); + + it('is empty when nothing has been joined', () => { + expect(formatLinks(merge([], {}))).toBe(''); + }); +}); + +describe('openCommand', () => { + // $BROWSER is the setting a person deliberately made. + it('prefers $BROWSER, then the platform opener', () => { + expect(openCommand('https://a.example', { BROWSER: 'firefox' }, 'linux')).toEqual([ + 'firefox', + 'https://a.example', + ]); + expect(openCommand('https://a.example', {}, 'linux')).toEqual([ + 'xdg-open', + 'https://a.example', + ]); + expect(openCommand('https://a.example', {}, 'darwin')).toEqual(['open', 'https://a.example']); + }); +}); + +describe('resolveEmail', () => { + // The account is last because it is the one you cannot override in the + // moment, and signing up as the wrong identity does not undo. + it('prefers the flag, then env, then profile, then the account', () => { + expect(resolveEmail({ flag: 'a@x', env: 'b@x', profile: 'c@x', account: 'd@x' })).toBe('a@x'); + expect(resolveEmail({ env: 'b@x', profile: 'c@x', account: 'd@x' })).toBe('b@x'); + expect(resolveEmail({ profile: 'c@x', account: 'd@x' })).toBe('c@x'); + expect(resolveEmail({ account: 'd@x' })).toBe('d@x'); + expect(resolveEmail({})).toBeNull(); + }); +}); + +describe('extractEmail', () => { + it('finds the address in moshcode whoami output', () => { + expect(extractEmail('anthony@profullstack.com 🤘 (87 credits) @ https://app.moshcode.sh')).toBe( + 'anthony@profullstack.com', + ); + }); + + it('is null when there is none', () => { + expect(extractEmail('not logged in')).toBeNull(); + }); +}); + +describe('renderAnswers', () => { + // A form that asks for audience size and gets a number nobody checked is the + // fastest way to lose an account. + it('names the gaps rather than guessing', () => { + const out = renderAnswers({ email: 'a@x', site: null, audience: null, promotion: null }); + expect(out).toContain('a@x'); + expect(out).toContain('(not set'); + }); +}); + +describe('hostOf', () => { + it('drops www', () => { + expect(hostOf('https://www.descript.com/affiliate')).toBe('descript.com'); + }); +});