From 3f370cc4b6e7bed54f241e29daf33b2e0a820878 Mon Sep 17 00:00:00 2001 From: yuta Date: Wed, 24 Jun 2026 18:30:24 +0900 Subject: [PATCH] feat: support relay profile selection --- README.md | 8 ++++- src/cli/program.ts | 5 ++-- src/cli/shared.ts | 11 +++++++ src/commands/bookmarks.ts | 3 +- src/commands/check.ts | 3 +- src/commands/home.ts | 3 +- src/commands/lists.ts | 6 ++-- src/commands/news.ts | 3 +- src/commands/post.ts | 6 ++-- src/commands/read.ts | 9 ++++-- src/commands/search.ts | 6 ++-- src/commands/unbookmark.ts | 3 +- src/commands/user-tweets.ts | 3 +- src/commands/users.ts | 12 +++++--- src/lib/twitter-client-base.ts | 13 ++++++-- src/lib/twitter-client-types.ts | 1 + tests/cli.shared.test.ts | 16 ++++++++++ tests/commands.bookmarks.test.ts | 1 + tests/commands.news.test.ts | 1 + tests/commands.read.test.ts | 34 +++++++++++++++++++++ tests/commands.search.test.ts | 1 + tests/commands.users.test.ts | 1 + tests/help-output.test.ts | 1 + tests/twitter-client-coverage.core.test.ts | 35 ++++++++++++++++++++++ 24 files changed, 160 insertions(+), 25 deletions(-) create mode 100644 tests/cli.shared.test.ts diff --git a/README.md b/README.md index 386aef8..616c322 100644 --- a/README.md +++ b/README.md @@ -134,7 +134,8 @@ By default, the command fetches from For You, News, Sports, and Entertainment ta import { TwitterClient } from '@yuta/bird'; const client = new TwitterClient({ - relayBaseUrl: process.env.TWITTER_RELAY_BASE_URL + relayBaseUrl: process.env.TWITTER_RELAY_BASE_URL, + profileName: process.env.BIRD_PROFILE_NAME }); // Search for tweets @@ -185,6 +186,7 @@ const sportsNews = await client.getNews(10, { Global options: - `--relay-base-url `: twitter safe relay base URL (defaults to `TWITTER_RELAY_BASE_URL`). +- `--profile-name `: twitter safe relay profile name (sent as `x-profile-name`). - `--timeout `: abort requests after the given timeout (milliseconds). - `--quote-depth `: max quoted tweet depth in JSON output (default: 1; 0 disables). - `--plain`: stable output (no emoji, no color). @@ -203,6 +205,8 @@ Write operations: - If GraphQL returns error `226` (“automated request”), `bird` falls back to the legacy `statuses/update.json` endpoint. Configure the relay URL with `TWITTER_RELAY_BASE_URL` or `--relay-base-url`. +When your relay has multiple profiles, select one with `--profile-name`, `BIRD_PROFILE_NAME`, or `profileName` +in config; bird forwards it as the `x-profile-name` header. ## Config (JSON5) @@ -216,6 +220,7 @@ Example `~/.config/bird/config.json5`: ```json5 { relayBaseUrl: "http://localhost:6900", + profileName: "main", timeoutMs: 20000, quoteDepth: 1 } @@ -223,6 +228,7 @@ Example `~/.config/bird/config.json5`: Environment shortcuts: - `TWITTER_RELAY_BASE_URL` +- `BIRD_PROFILE_NAME` - `BIRD_TIMEOUT_MS` - `BIRD_QUOTE_DEPTH` diff --git a/src/cli/program.ts b/src/cli/program.ts index d1b5566..e60c601 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -105,14 +105,15 @@ export function createProgram(ctx: CliContext): Command { `\n\n${ctx.colors.section('Config')}\n${ctx.colors.muted( ` Reads ${ctx.colors.argument('~/.config/bird/config.json5')} and ${ctx.colors.argument('./.birdrc.json5')} (JSON5)`, )}\n${ctx.colors.muted( - ` Supports: relayBaseUrl, timeoutMs, quoteDepth`, + ` Supports: relayBaseUrl, profileName, timeoutMs, quoteDepth`, )}\n\n${ctx.colors.section('Env')}\n${ctx.colors.muted( - ` ${ctx.colors.option('TWITTER_RELAY_BASE_URL')}, ${ctx.colors.option('NO_COLOR')}, ${ctx.colors.option('BIRD_TIMEOUT_MS')}, ${ctx.colors.option('BIRD_QUOTE_DEPTH')}`, + ` ${ctx.colors.option('TWITTER_RELAY_BASE_URL')}, ${ctx.colors.option('BIRD_PROFILE_NAME')}, ${ctx.colors.option('NO_COLOR')}, ${ctx.colors.option('BIRD_TIMEOUT_MS')}, ${ctx.colors.option('BIRD_QUOTE_DEPTH')}`, )}`, ); program .option('--relay-base-url ', 'twitter safe relay base URL', ctx.config.relayBaseUrl) + .option('--profile-name ', 'twitter safe relay profile name') .option('--media ', 'Attach media file (repeatable, up to 4 images or 1 video)', collect) .option('--alt ', 'Alt text for the corresponding --media (repeatable)', collect) .option('--timeout ', 'Request timeout in milliseconds') diff --git a/src/cli/shared.ts b/src/cli/shared.ts index 4aa150b..e85cd2a 100644 --- a/src/cli/shared.ts +++ b/src/cli/shared.ts @@ -17,6 +17,7 @@ import type { TweetData } from '../lib/twitter-client.js'; export type BirdConfig = { relayBaseUrl?: string; + profileName?: string; timeoutMs?: number; quoteDepth?: number; }; @@ -44,6 +45,7 @@ export type CliContext = { applyOutputFromCommand: (command: Command) => void; resolveTimeoutFromOptions: (options: { timeout?: string | number }) => number | undefined; resolveQuoteDepthFromOptions: (options: { quoteDepth?: string | number }) => number | undefined; + resolveProfileNameFromOptions: (opts: ProfileOptions) => string | undefined; resolveRelayBaseUrlFromOptions: (opts: RelayOptions) => string | undefined; loadMedia: (opts: { media: string[]; alts: string[] }) => MediaSpec[]; printTweets: (tweets: TweetData[], opts?: { json?: boolean; emptyMessage?: string; showSeparator?: boolean }) => void; @@ -138,6 +140,10 @@ type RelayOptions = { relayBaseUrl?: string; }; +type ProfileOptions = { + profileName?: string; +}; + export function createCliContext(normalizedArgs: string[], env: NodeJS.ProcessEnv = process.env): CliContext { const isTty = process.stdout.isTTY; let output: OutputConfig = resolveOutputConfigFromArgv(normalizedArgs, env, isTty); @@ -235,6 +241,10 @@ export function createCliContext(normalizedArgs: string[], env: NodeJS.ProcessEn return opts.relayBaseUrl || config.relayBaseUrl || env.TWITTER_RELAY_BASE_URL; } + function resolveProfileNameFromOptions(opts: ProfileOptions): string | undefined { + return opts.profileName || env.BIRD_PROFILE_NAME || config.profileName; + } + function loadMedia(opts: { media: string[]; alts: string[] }): MediaSpec[] { if (opts.media.length === 0) { return []; @@ -367,6 +377,7 @@ export function createCliContext(normalizedArgs: string[], env: NodeJS.ProcessEn applyOutputFromCommand, resolveTimeoutFromOptions, resolveQuoteDepthFromOptions, + resolveProfileNameFromOptions, resolveRelayBaseUrlFromOptions, loadMedia, printTweets, diff --git a/src/commands/bookmarks.ts b/src/commands/bookmarks.ts index b8a9bcf..772b176 100644 --- a/src/commands/bookmarks.ts +++ b/src/commands/bookmarks.ts @@ -35,6 +35,7 @@ export function registerBookmarksCommand(program: Command, ctx: CliContext): voi } const maxPages = maxPagesParsed.value; const relayBaseUrl = ctx.resolveRelayBaseUrlFromOptions(opts); + const profileName = ctx.resolveProfileNameFromOptions(opts); const usePagination = Boolean(cmdOpts.all || cmdOpts.cursor); if (maxPages !== undefined && !usePagination) { @@ -46,7 +47,7 @@ export function registerBookmarksCommand(program: Command, ctx: CliContext): voi process.exit(1); } - const client = new TwitterClient({ relayBaseUrl, timeoutMs }); + const client = new TwitterClient({ relayBaseUrl, profileName, timeoutMs }); const folderId = cmdOpts.folderId ? extractBookmarkFolderId(cmdOpts.folderId) : null; if (cmdOpts.folderId && !folderId) { console.error(`${ctx.p('err')}Invalid --folder-id. Expected numeric ID or https://x.com/i/bookmarks/.`); diff --git a/src/commands/check.ts b/src/commands/check.ts index 7933ee8..276b340 100644 --- a/src/commands/check.ts +++ b/src/commands/check.ts @@ -10,6 +10,7 @@ export function registerCheckCommand(program: Command, ctx: CliContext): void { const opts = program.opts(); const timeoutMs = ctx.resolveTimeoutFromOptions(opts); const relayBaseUrl = ctx.resolveRelayBaseUrlFromOptions(opts); + const profileName = ctx.resolveProfileNameFromOptions(opts); console.log(`${ctx.p('info')}Relay check`); console.log('-'.repeat(40)); @@ -21,7 +22,7 @@ export function registerCheckCommand(program: Command, ctx: CliContext): void { console.log(`${ctx.p('ok')}relay: ${relayBaseUrl}`); - const client = new TwitterClient({ relayBaseUrl, timeoutMs }); + const client = new TwitterClient({ relayBaseUrl, profileName, timeoutMs }); const result = await client.getCurrentUser(); if (result.success && result.user) { diff --git a/src/commands/home.ts b/src/commands/home.ts index 190871d..9ad2391 100644 --- a/src/commands/home.ts +++ b/src/commands/home.ts @@ -15,13 +15,14 @@ export function registerHomeCommand(program: Command, ctx: CliContext): void { const timeoutMs = ctx.resolveTimeoutFromOptions(opts); const count = Number.parseInt(cmdOpts.count || '20', 10); const relayBaseUrl = ctx.resolveRelayBaseUrlFromOptions(opts); + const profileName = ctx.resolveProfileNameFromOptions(opts); if (!Number.isFinite(count) || count <= 0) { console.error(`${ctx.p('err')}Invalid --count. Expected a positive integer.`); process.exit(1); } - const client = new TwitterClient({ relayBaseUrl, timeoutMs }); + const client = new TwitterClient({ relayBaseUrl, profileName, timeoutMs }); const includeRaw = cmdOpts.jsonFull ?? false; const result = cmdOpts.following diff --git a/src/commands/lists.ts b/src/commands/lists.ts index f67adef..73215a6 100644 --- a/src/commands/lists.ts +++ b/src/commands/lists.ts @@ -43,8 +43,9 @@ export function registerListsCommand(program: Command, ctx: CliContext): void { const timeoutMs = ctx.resolveTimeoutFromOptions(opts); const count = Number.parseInt(cmdOpts.count || '100', 10); const relayBaseUrl = ctx.resolveRelayBaseUrlFromOptions(opts); + const profileName = ctx.resolveProfileNameFromOptions(opts); - const client = new TwitterClient({ relayBaseUrl, timeoutMs }); + const client = new TwitterClient({ relayBaseUrl, profileName, timeoutMs }); const result = cmdOpts.memberOf ? await client.getListMemberships(count) : await client.getOwnedLists(count); @@ -109,8 +110,9 @@ export function registerListsCommand(program: Command, ctx: CliContext): void { process.exit(1); } const relayBaseUrl = ctx.resolveRelayBaseUrlFromOptions(opts); + const profileName = ctx.resolveProfileNameFromOptions(opts); - const client = new TwitterClient({ relayBaseUrl, timeoutMs, quoteDepth }); + const client = new TwitterClient({ relayBaseUrl, profileName, timeoutMs, quoteDepth }); const includeRaw = cmdOpts.jsonFull ?? false; const timelineOptions = { includeRaw }; const paginationOptions = { includeRaw, maxPages: pagination.maxPages, cursor: pagination.cursor }; diff --git a/src/commands/news.ts b/src/commands/news.ts index 0a6fb28..6bafb07 100644 --- a/src/commands/news.ts +++ b/src/commands/news.ts @@ -102,6 +102,7 @@ export function registerNewsCommand(program: Command, ctx: CliContext): void { const count = Number.parseInt(cmdOpts.count || '10', 10); const tweetsPerItem = Number.parseInt(cmdOpts.tweetsPerItem || '5', 10); const relayBaseUrl = ctx.resolveRelayBaseUrlFromOptions(opts); + const profileName = ctx.resolveProfileNameFromOptions(opts); if (Number.isNaN(count) || count < 1) { console.error(`${ctx.p('err')}--count must be a positive number`); @@ -134,7 +135,7 @@ export function registerNewsCommand(program: Command, ctx: CliContext): void { // If no specific tabs selected, use defaults (all tabs except trending) const tabsToFetch = tabs.length > 0 ? tabs : undefined; - const client = new TwitterClient({ relayBaseUrl, timeoutMs, quoteDepth }); + const client = new TwitterClient({ relayBaseUrl, profileName, timeoutMs, quoteDepth }); const includeRaw = cmdOpts.jsonFull ?? false; const withTweets = cmdOpts.withTweets ?? false; const aiOnly = cmdOpts.aiOnly ?? false; diff --git a/src/commands/post.ts b/src/commands/post.ts index 0c2de86..a23cfd1 100644 --- a/src/commands/post.ts +++ b/src/commands/post.ts @@ -41,7 +41,8 @@ export function registerPostCommands(program: Command, ctx: CliContext): void { process.exit(1); } const relayBaseUrl = ctx.resolveRelayBaseUrlFromOptions(opts); - const client = new TwitterClient({ relayBaseUrl, timeoutMs, quoteDepth }); + const profileName = ctx.resolveProfileNameFromOptions(opts); + const client = new TwitterClient({ relayBaseUrl, profileName, timeoutMs, quoteDepth }); const mediaIds = await uploadMediaOrExit(client, media, ctx); const result = await client.tweet(text, mediaIds); @@ -72,9 +73,10 @@ export function registerPostCommands(program: Command, ctx: CliContext): void { } const tweetId = ctx.extractTweetId(tweetIdOrUrl); const relayBaseUrl = ctx.resolveRelayBaseUrlFromOptions(opts); + const profileName = ctx.resolveProfileNameFromOptions(opts); console.error(`${ctx.p('info')}Replying to tweet: ${tweetId}`); - const client = new TwitterClient({ relayBaseUrl, timeoutMs, quoteDepth }); + const client = new TwitterClient({ relayBaseUrl, profileName, timeoutMs, quoteDepth }); const mediaIds = await uploadMediaOrExit(client, media, ctx); const result = await client.reply(text, tweetId, mediaIds); diff --git a/src/commands/read.ts b/src/commands/read.ts index 29938c7..c6d91c9 100644 --- a/src/commands/read.ts +++ b/src/commands/read.ts @@ -18,8 +18,9 @@ export function registerReadCommands(program: Command, ctx: CliContext): void { const tweetId = ctx.extractTweetId(tweetIdOrUrl); const relayBaseUrl = ctx.resolveRelayBaseUrlFromOptions(opts); + const profileName = ctx.resolveProfileNameFromOptions(opts); - const client = new TwitterClient({ relayBaseUrl, timeoutMs, quoteDepth }); + const client = new TwitterClient({ relayBaseUrl, profileName, timeoutMs, quoteDepth }); const includeRaw = cmdOpts.jsonFull ?? false; const result = await client.getTweet(tweetId, { includeRaw }); @@ -69,8 +70,9 @@ export function registerReadCommands(program: Command, ctx: CliContext): void { process.exit(1); } const relayBaseUrl = ctx.resolveRelayBaseUrlFromOptions(opts); + const profileName = ctx.resolveProfileNameFromOptions(opts); - const client = new TwitterClient({ relayBaseUrl, timeoutMs, quoteDepth }); + const client = new TwitterClient({ relayBaseUrl, profileName, timeoutMs, quoteDepth }); const includeRaw = cmdOpts.jsonFull ?? false; const result = pagination.usePagination @@ -136,8 +138,9 @@ export function registerReadCommands(program: Command, ctx: CliContext): void { process.exit(1); } const relayBaseUrl = ctx.resolveRelayBaseUrlFromOptions(opts); + const profileName = ctx.resolveProfileNameFromOptions(opts); - const client = new TwitterClient({ relayBaseUrl, timeoutMs, quoteDepth }); + const client = new TwitterClient({ relayBaseUrl, profileName, timeoutMs, quoteDepth }); const includeRaw = cmdOpts.jsonFull ?? false; const result = pagination.usePagination diff --git a/src/commands/search.ts b/src/commands/search.ts index f99f555..f47685f 100644 --- a/src/commands/search.ts +++ b/src/commands/search.ts @@ -32,6 +32,7 @@ export function registerSearchCommands(program: Command, ctx: CliContext): void const count = Number.parseInt(cmdOpts.count || '10', 10); const maxPages = cmdOpts.maxPages ? Number.parseInt(cmdOpts.maxPages, 10) : undefined; const relayBaseUrl = ctx.resolveRelayBaseUrlFromOptions(opts); + const profileName = ctx.resolveProfileNameFromOptions(opts); const usePagination = cmdOpts.all || cmdOpts.cursor; if (maxPages !== undefined && !usePagination) { @@ -47,7 +48,7 @@ export function registerSearchCommands(program: Command, ctx: CliContext): void process.exit(1); } - const client = new TwitterClient({ relayBaseUrl, timeoutMs, quoteDepth }); + const client = new TwitterClient({ relayBaseUrl, profileName, timeoutMs, quoteDepth }); const includeRaw = cmdOpts.jsonFull ?? false; const searchOptions = { includeRaw }; const paginationOptions = { includeRaw, maxPages, cursor: cmdOpts.cursor }; @@ -90,8 +91,9 @@ export function registerSearchCommands(program: Command, ctx: CliContext): void let query: string | null = fromUserOpt.query; const relayBaseUrl = ctx.resolveRelayBaseUrlFromOptions(opts); + const profileName = ctx.resolveProfileNameFromOptions(opts); - const client = new TwitterClient({ relayBaseUrl, timeoutMs, quoteDepth }); + const client = new TwitterClient({ relayBaseUrl, profileName, timeoutMs, quoteDepth }); if (!query) { const who = await client.getCurrentUser(); diff --git a/src/commands/unbookmark.ts b/src/commands/unbookmark.ts index f5dbe08..aa05684 100644 --- a/src/commands/unbookmark.ts +++ b/src/commands/unbookmark.ts @@ -11,8 +11,9 @@ export function registerUnbookmarkCommand(program: Command, ctx: CliContext): vo const opts = program.opts(); const timeoutMs = ctx.resolveTimeoutFromOptions(opts); const relayBaseUrl = ctx.resolveRelayBaseUrlFromOptions(opts); + const profileName = ctx.resolveProfileNameFromOptions(opts); - const client = new TwitterClient({ relayBaseUrl, timeoutMs }); + const client = new TwitterClient({ relayBaseUrl, profileName, timeoutMs }); let failures = 0; for (const input of tweetIdOrUrls) { diff --git a/src/commands/user-tweets.ts b/src/commands/user-tweets.ts index 1bc7ef6..d2627e7 100644 --- a/src/commands/user-tweets.ts +++ b/src/commands/user-tweets.ts @@ -87,8 +87,9 @@ export function registerUserTweetsCommand(program: Command, ctx: CliContext): vo process.exit(2); } const relayBaseUrl = ctx.resolveRelayBaseUrlFromOptions(opts); + const profileName = ctx.resolveProfileNameFromOptions(opts); - const client = new TwitterClient({ relayBaseUrl, timeoutMs, quoteDepth }); + const client = new TwitterClient({ relayBaseUrl, profileName, timeoutMs, quoteDepth }); // Look up user ID from username console.error(`${ctx.p('info')}Looking up @${username}...`); diff --git a/src/commands/users.ts b/src/commands/users.ts index d433294..fa249d4 100644 --- a/src/commands/users.ts +++ b/src/commands/users.ts @@ -37,8 +37,9 @@ export function registerUserCommands(program: Command, ctx: CliContext): void { process.exit(1); } const relayBaseUrl = ctx.resolveRelayBaseUrlFromOptions(opts); + const profileName = ctx.resolveProfileNameFromOptions(opts); - const client = new TwitterClient({ relayBaseUrl, timeoutMs }); + const client = new TwitterClient({ relayBaseUrl, profileName, timeoutMs }); let userId = cmdOpts.user; if (!userId) { @@ -187,8 +188,9 @@ export function registerUserCommands(program: Command, ctx: CliContext): void { process.exit(1); } const relayBaseUrl = ctx.resolveRelayBaseUrlFromOptions(opts); + const profileName = ctx.resolveProfileNameFromOptions(opts); - const client = new TwitterClient({ relayBaseUrl, timeoutMs }); + const client = new TwitterClient({ relayBaseUrl, profileName, timeoutMs }); let userId = cmdOpts.user; if (!userId) { @@ -328,6 +330,7 @@ export function registerUserCommands(program: Command, ctx: CliContext): void { const count = Number.parseInt(cmdOpts.count || '20', 10); const maxPages = cmdOpts.maxPages ? Number.parseInt(cmdOpts.maxPages, 10) : undefined; const relayBaseUrl = ctx.resolveRelayBaseUrlFromOptions(opts); + const profileName = ctx.resolveProfileNameFromOptions(opts); const usePagination = cmdOpts.all || cmdOpts.cursor; if (maxPages !== undefined && !usePagination) { @@ -343,7 +346,7 @@ export function registerUserCommands(program: Command, ctx: CliContext): void { process.exit(1); } - const client = new TwitterClient({ relayBaseUrl, timeoutMs, quoteDepth }); + const client = new TwitterClient({ relayBaseUrl, profileName, timeoutMs, quoteDepth }); const includeRaw = cmdOpts.jsonFull ?? false; const timelineOptions = { includeRaw }; const paginationOptions = { includeRaw, maxPages, cursor: cmdOpts.cursor }; @@ -373,8 +376,9 @@ export function registerUserCommands(program: Command, ctx: CliContext): void { const timeoutMs = ctx.resolveTimeoutFromOptions(opts); const quoteDepth = ctx.resolveQuoteDepthFromOptions(opts); const relayBaseUrl = ctx.resolveRelayBaseUrlFromOptions(opts); + const profileName = ctx.resolveProfileNameFromOptions(opts); - const client = new TwitterClient({ relayBaseUrl, timeoutMs, quoteDepth }); + const client = new TwitterClient({ relayBaseUrl, profileName, timeoutMs, quoteDepth }); const result = await client.getCurrentUser(); if (result.success && result.user) { diff --git a/src/lib/twitter-client-base.ts b/src/lib/twitter-client-base.ts index f9d0ec4..4f92101 100644 --- a/src/lib/twitter-client-base.ts +++ b/src/lib/twitter-client-base.ts @@ -15,12 +15,14 @@ export type Mixin, TAdded> export abstract class TwitterClientBase { protected relayBaseUrl: string; + protected profileName?: string; protected timeoutMs?: number; protected quoteDepth: number; protected clientUserId?: string; constructor(options: TwitterClientOptions) { this.relayBaseUrl = this.normalizeRelayBaseUrl(options.relayBaseUrl ?? process.env.TWITTER_RELAY_BASE_URL); + this.profileName = this.normalizeProfileName(options.profileName); this.timeoutMs = options.timeoutMs; this.quoteDepth = normalizeQuoteDepth(options.quoteDepth); } @@ -73,7 +75,7 @@ export abstract class TwitterClientBase { } protected getHeaders(): Record { - return {}; + return this.profileName ? { 'x-profile-name': this.profileName } : {}; } private normalizeRelayBaseUrl(value: string | undefined): string { @@ -84,6 +86,11 @@ export abstract class TwitterClientBase { return trimmed.replace(TRAILING_SLASHES, ''); } + private normalizeProfileName(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed || undefined; + } + private toRelayUrl(url: string): string { const parsed = new URL(url); if (!['x.com', 'twitter.com', 'api.twitter.com', 'upload.twitter.com'].includes(parsed.hostname)) { @@ -93,11 +100,11 @@ export abstract class TwitterClientBase { } protected getJsonHeaders(): Record { - return { 'content-type': 'application/json' }; + return { 'content-type': 'application/json', ...this.getHeaders() }; } protected getUploadHeaders(): Record { - return {}; + return this.getHeaders(); } protected async ensureClientUserId(): Promise { diff --git a/src/lib/twitter-client-types.ts b/src/lib/twitter-client-types.ts index 25ae562..3f31545 100644 --- a/src/lib/twitter-client-types.ts +++ b/src/lib/twitter-client-types.ts @@ -326,6 +326,7 @@ export interface FollowingResult { export interface TwitterClientOptions { relayBaseUrl?: string; + profileName?: string; timeoutMs?: number; // Max depth for quoted tweets (0 disables). Defaults to 1. quoteDepth?: number; diff --git a/tests/cli.shared.test.ts b/tests/cli.shared.test.ts new file mode 100644 index 0000000..6930e59 --- /dev/null +++ b/tests/cli.shared.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest'; +import { createCliContext } from '../src/cli/shared.js'; + +describe('createCliContext', () => { + it('resolves profileName from CLI options before env', () => { + const ctx = createCliContext([], { BIRD_PROFILE_NAME: 'env-profile' } as NodeJS.ProcessEnv); + + expect(ctx.resolveProfileNameFromOptions({ profileName: 'cli-profile' })).toBe('cli-profile'); + }); + + it('resolves profileName from env when CLI option is absent', () => { + const ctx = createCliContext([], { BIRD_PROFILE_NAME: 'env-profile' } as NodeJS.ProcessEnv); + + expect(ctx.resolveProfileNameFromOptions({})).toBe('env-profile'); + }); +}); diff --git a/tests/commands.bookmarks.test.ts b/tests/commands.bookmarks.test.ts index 9432e7e..90cd023 100644 --- a/tests/commands.bookmarks.test.ts +++ b/tests/commands.bookmarks.test.ts @@ -9,6 +9,7 @@ describe('bookmarks command', () => { const ctx = { resolveTimeoutFromOptions: () => undefined, resolveRelayBaseUrlFromOptions: () => 'https://relay.example', + resolveProfileNameFromOptions: () => undefined, p: () => '', printTweets: () => undefined, } as unknown as CliContext; diff --git a/tests/commands.news.test.ts b/tests/commands.news.test.ts index 9d5096b..38a925f 100644 --- a/tests/commands.news.test.ts +++ b/tests/commands.news.test.ts @@ -14,6 +14,7 @@ describe('news command', () => { resolveTimeoutFromOptions: () => 30000, resolveQuoteDepthFromOptions: () => undefined, resolveRelayBaseUrlFromOptions: () => 'https://relay.example', + resolveProfileNameFromOptions: () => undefined, p: (type: string) => `[${type}] `, colors: { accent: (text: string) => text, diff --git a/tests/commands.read.test.ts b/tests/commands.read.test.ts index 13c2fd8..95c9b6f 100644 --- a/tests/commands.read.test.ts +++ b/tests/commands.read.test.ts @@ -9,6 +9,7 @@ describe('replies command', () => { ({ resolveTimeoutFromOptions: () => undefined, resolveQuoteDepthFromOptions: () => 1, + resolveProfileNameFromOptions: () => undefined, extractTweetId: (input: string) => input, resolveRelayBaseUrlFromOptions: () => 'https://relay.example', p: () => '', @@ -74,6 +75,38 @@ describe('replies command', () => { errorSpy.mockRestore(); } }); + + it('passes profileName to TwitterClient', async () => { + const program = new Command(); + registerReadCommands(program, { + ...createMockContext(), + resolveProfileNameFromOptions: () => 'work', + }); + + const getTweetSpy = vi.spyOn(TwitterClient.prototype, 'getTweet').mockImplementation(async function () { + const headers = (this as unknown as { getHeaders: () => Record }).getHeaders(); + expect(headers).toEqual({ 'x-profile-name': 'work' }); + return { + success: true, + tweet: { + id: '123', + text: 'hello', + author: { username: 'tester', name: 'Tester' }, + createdAt: '2024-01-01T00:00:00Z', + url: 'https://x.com/tester/status/123', + }, + }; + }); + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + try { + await program.parseAsync(['node', 'bird', 'read', '123', '--json']); + expect(getTweetSpy).toHaveBeenCalledTimes(1); + } finally { + getTweetSpy.mockRestore(); + logSpy.mockRestore(); + } + }); }); describe('thread command', () => { @@ -81,6 +114,7 @@ describe('thread command', () => { ({ resolveTimeoutFromOptions: () => undefined, resolveQuoteDepthFromOptions: () => 1, + resolveProfileNameFromOptions: () => undefined, extractTweetId: (input: string) => input, resolveRelayBaseUrlFromOptions: () => 'https://relay.example', p: () => '', diff --git a/tests/commands.search.test.ts b/tests/commands.search.test.ts index 183d120..ffbfc37 100644 --- a/tests/commands.search.test.ts +++ b/tests/commands.search.test.ts @@ -14,6 +14,7 @@ describe('search command', () => { resolveTimeoutFromOptions: () => undefined, resolveQuoteDepthFromOptions: () => undefined, resolveRelayBaseUrlFromOptions: () => 'https://relay.example', + resolveProfileNameFromOptions: () => undefined, p: () => '', printTweetsResult: vi.fn(), }; diff --git a/tests/commands.users.test.ts b/tests/commands.users.test.ts index 61ab1d8..bdf7271 100644 --- a/tests/commands.users.test.ts +++ b/tests/commands.users.test.ts @@ -7,6 +7,7 @@ import { TwitterClient } from '../src/lib/twitter-client.js'; const baseCtx = { resolveTimeoutFromOptions: () => undefined, resolveRelayBaseUrlFromOptions: () => 'https://relay.example', + resolveProfileNameFromOptions: () => undefined, p: () => '', printTweets: () => undefined, } as unknown as CliContext; diff --git a/tests/help-output.test.ts b/tests/help-output.test.ts index 12bb432..8576269 100644 --- a/tests/help-output.test.ts +++ b/tests/help-output.test.ts @@ -46,6 +46,7 @@ describe('root help output', () => { cmd.outputHelp(); expect(help).toContain('--relay-base-url'); + expect(help).toContain('--profile-name'); expect(help).not.toContain('--auth-token'); expect(help).not.toContain('--ct0'); expect(help).toContain('--timeout'); diff --git a/tests/twitter-client-coverage.core.test.ts b/tests/twitter-client-coverage.core.test.ts index 1991809..b97eb2f 100644 --- a/tests/twitter-client-coverage.core.test.ts +++ b/tests/twitter-client-coverage.core.test.ts @@ -124,6 +124,41 @@ describe('TwitterClient core coverage', () => { expect(headers).toEqual({}); }); + it('adds x-profile-name header when profileName is set', () => { + const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl, profileName: 'work' }); + const clientPrivate = client as unknown as TwitterClientPrivate; + + expect(clientPrivate.getHeaders()).toEqual({ 'x-profile-name': 'work' }); + }); + + it('ignores blank profileName values', () => { + const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl, profileName: ' ' }); + const clientPrivate = client as unknown as TwitterClientPrivate; + + expect(clientPrivate.getHeaders()).toEqual({}); + }); + + it('adds x-profile-name to JSON headers when profileName is set', () => { + const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl, profileName: 'work' }); + const clientPrivate = client as unknown as TwitterClientPrivate & { + getJsonHeaders: () => Record; + }; + + expect(clientPrivate.getJsonHeaders()).toEqual({ + 'content-type': 'application/json', + 'x-profile-name': 'work', + }); + }); + + it('adds x-profile-name to upload headers when profileName is set', () => { + const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl, profileName: 'work' }); + const clientPrivate = client as unknown as TwitterClientPrivate & { + getUploadHeaders: () => Record; + }; + + expect(clientPrivate.getUploadHeaders()).toEqual({ 'x-profile-name': 'work' }); + }); + describe('fetchUserArticlePlainText failures', () => { it('returns empty object when response is not ok', async () => { const mockFetch = vi.fn().mockResolvedValueOnce(makeResponse({ ok: false, status: 500 }));