chore: merge origin/main

This commit is contained in:
Peter Steinberger
2026-01-01 10:46:10 +01:00
28 changed files with 2302 additions and 176 deletions
+114 -34
View File
@@ -17,6 +17,7 @@ import JSON5 from 'json5';
import kleur from 'kleur';
import { resolveCliInvocation } from './lib/cli-args.js';
import { type CookieSource, resolveCredentials } from './lib/cookies.js';
import { extractBookmarkFolderId } from './lib/extract-bookmark-folder-id.js';
import { extractTweetId } from './lib/extract-tweet-id.js';
import { mentionsQueryFromUserOption, normalizeHandle } from './lib/normalize-handle.js';
import {
@@ -32,16 +33,19 @@ import { runtimeQueryIds } from './lib/runtime-query-ids.js';
import { type TweetData, TwitterClient } from './lib/twitter-client.js';
import { getCliVersion } from './lib/version.js';
const program = new Command();
const program: Command = new Command();
const rawArgs = process.argv.slice(2);
const normalizedArgs = rawArgs[0] === '--' ? rawArgs.slice(1) : rawArgs;
const isTty = process.stdout.isTTY;
const rawArgs: string[] = process.argv.slice(2);
const normalizedArgs: string[] = rawArgs[0] === '--' ? rawArgs.slice(1) : rawArgs;
const isTty: boolean = process.stdout.isTTY;
let output: OutputConfig = resolveOutputConfigFromArgv(normalizedArgs, process.env, isTty);
kleur.enabled = output.color;
const wrap = (styler: (text: string) => string) => (text: string) => (isTty ? styler(text) : text);
const collect = (value: string, previous: string[] = []) => {
const wrap =
(styler: (text: string) => string): ((text: string) => string) =>
(text: string): string =>
isTty ? styler(text) : text;
const collect = (value: string, previous: string[] = []): string[] => {
previous.push(value);
return previous;
};
@@ -50,16 +54,22 @@ const COOKIE_SOURCES: CookieSource[] = ['safari', 'chrome', 'firefox'];
function parseCookieSource(value: string): CookieSource {
const normalized = value.trim().toLowerCase();
if (normalized === 'safari' || normalized === 'chrome' || normalized === 'firefox') return normalized;
if (normalized === 'safari' || normalized === 'chrome' || normalized === 'firefox') {
return normalized;
}
throw new Error(`Invalid --cookie-source "${value}". Allowed: safari, chrome, firefox.`);
}
function resolveCookieSourceOrder(input: unknown): CookieSource[] | undefined {
if (typeof input === 'string') return [parseCookieSource(input)];
if (typeof input === 'string') {
return [parseCookieSource(input)];
}
if (Array.isArray(input)) {
const result: CookieSource[] = [];
for (const entry of input) {
if (typeof entry !== 'string') continue;
if (typeof entry !== 'string') {
continue;
}
result.push(parseCookieSource(entry));
}
return result.length > 0 ? result : undefined;
@@ -67,15 +77,15 @@ function resolveCookieSourceOrder(input: unknown): CookieSource[] | undefined {
return undefined;
}
const collectCookieSource = (value: string, previous: CookieSource[] = []) => {
const collectCookieSource = (value: string, previous: CookieSource[] = []): CookieSource[] => {
previous.push(parseCookieSource(value));
return previous;
};
const p = (kind: Parameters<typeof statusPrefix>[0]) => statusPrefix(kind, output);
const l = (kind: Parameters<typeof labelPrefix>[0]) => labelPrefix(kind, output);
const p = (kind: Parameters<typeof statusPrefix>[0]): string => statusPrefix(kind, output);
const l = (kind: Parameters<typeof labelPrefix>[0]): string => labelPrefix(kind, output);
function applyOutputFromCommand(command: Command) {
function applyOutputFromCommand(command: Command): void {
const opts = command.optsWithGlobals() as { plain?: boolean; emoji?: boolean; color?: boolean };
output = resolveOutputConfigFromCommander(opts, process.env, isTty);
kleur.enabled = output.color;
@@ -103,7 +113,9 @@ type BirdConfig = {
};
function readConfigFile(path: string): Partial<BirdConfig> {
if (!existsSync(path)) return {};
if (!existsSync(path)) {
return {};
}
try {
const raw = readFileSync(path, 'utf8');
const parsed = JSON5.parse(raw) as Partial<BirdConfig>;
@@ -140,6 +152,7 @@ const KNOWN_COMMANDS = new Set([
'search',
'mentions',
'bookmarks',
'likes',
'help',
'whoami',
'check',
@@ -152,7 +165,7 @@ program.addHelpText(
program.name('bird').description('Post tweets and replies via Twitter/X GraphQL API').version(getCliVersion());
const formatExample = (command: string, description: string) =>
const formatExample = (command: string, description: string): string =>
`${colors.command(` ${command}`)}\n${colors.muted(` ${description}`)}`;
program.addHelpText(
@@ -194,7 +207,7 @@ type CredentialsOptions = {
cookieSource?: CookieSource[];
};
function resolveCredentialsFromOptions(opts: CredentialsOptions) {
function resolveCredentialsFromOptions(opts: CredentialsOptions): ReturnType<typeof resolveCredentials> {
const cookieSource = opts.cookieSource?.length
? opts.cookieSource
: (resolveCookieSourceOrder(config.cookieSource) ?? COOKIE_SOURCES);
@@ -215,9 +228,13 @@ type MediaSpec = { path: string; alt?: string; mime: string; buffer: Buffer };
function resolveTimeoutMs(...values: Array<string | number | undefined | null>): number | undefined {
for (const value of values) {
if (value === undefined || value === null || value === '') continue;
if (value === undefined || value === null || value === '') {
continue;
}
const parsed = typeof value === 'number' ? value : Number(value);
if (Number.isFinite(parsed) && parsed > 0) return parsed;
if (Number.isFinite(parsed) && parsed > 0) {
return parsed;
}
}
return undefined;
}
@@ -241,17 +258,31 @@ function resolveQuoteDepthFromOptions(options: { quoteDepth?: string | number })
function detectMime(path: string): string | null {
const ext = path.toLowerCase();
if (ext.endsWith('.jpg') || ext.endsWith('.jpeg')) return 'image/jpeg';
if (ext.endsWith('.png')) return 'image/png';
if (ext.endsWith('.webp')) return 'image/webp';
if (ext.endsWith('.gif')) return 'image/gif';
if (ext.endsWith('.mp4') || ext.endsWith('.m4v')) return 'video/mp4';
if (ext.endsWith('.mov')) return 'video/quicktime';
if (ext.endsWith('.jpg') || ext.endsWith('.jpeg')) {
return 'image/jpeg';
}
if (ext.endsWith('.png')) {
return 'image/png';
}
if (ext.endsWith('.webp')) {
return 'image/webp';
}
if (ext.endsWith('.gif')) {
return 'image/gif';
}
if (ext.endsWith('.mp4') || ext.endsWith('.m4v')) {
return 'video/mp4';
}
if (ext.endsWith('.mov')) {
return 'video/quicktime';
}
return null;
}
function loadMedia(opts: { media: string[]; alts: string[] }): MediaSpec[] {
if (opts.media.length === 0) return [];
if (opts.media.length === 0) {
return [];
}
const specs: MediaSpec[] = [];
for (const [index, path] of opts.media.entries()) {
const mime = detectMime(path);
@@ -263,9 +294,15 @@ function loadMedia(opts: { media: string[]; alts: string[] }): MediaSpec[] {
}
const videoCount = specs.filter((m) => m.mime.startsWith('video/')).length;
if (videoCount > 1) throw new Error('Only one video can be attached');
if (videoCount === 1 && specs.length > 1) throw new Error('Video cannot be combined with other media');
if (specs.length > 4) throw new Error('Maximum 4 media attachments');
if (videoCount > 1) {
throw new Error('Only one video can be attached');
}
if (videoCount === 1 && specs.length > 1) {
throw new Error('Video cannot be combined with other media');
}
if (specs.length > 4) {
throw new Error('Maximum 4 media attachments');
}
return specs;
}
@@ -327,6 +364,7 @@ program
'SearchTimeline',
'UserArticlesTweets',
'Bookmarks',
'Likes',
];
if (cmdOpts.fresh) {
@@ -675,13 +713,13 @@ program
if (!query) {
const who = await client.getCurrentUser();
const handle = normalizeHandle(who.user?.username);
if (!handle) {
if (handle) {
query = `@${handle}`;
} else {
console.error(
`${p('err')}Could not determine current user (${who.error ?? 'Unknown error'}). Use --user <handle>.`,
);
process.exit(1);
} else {
query = `@${handle}`;
}
}
@@ -700,6 +738,48 @@ program
.command('bookmarks')
.description('Get your bookmarked tweets')
.option('-n, --count <number>', 'Number of bookmarks to fetch', '20')
.option('--folder-id <id>', 'Bookmark folder (collection) id')
.option('--json', 'Output as JSON')
.action(async (cmdOpts: { count?: string; json?: boolean; folderId?: string }) => {
const opts = program.opts();
const timeoutMs = resolveTimeoutFromOptions(opts);
const count = Number.parseInt(cmdOpts.count || '20', 10);
const { cookies, warnings } = await resolveCredentialsFromOptions(opts);
for (const warning of warnings) {
console.error(`${p('warn')}${warning}`);
}
if (!cookies.authToken || !cookies.ct0) {
console.error(`${p('err')}Missing required credentials`);
process.exit(1);
}
const client = new TwitterClient({ cookies, timeoutMs });
const folderId = cmdOpts.folderId ? extractBookmarkFolderId(cmdOpts.folderId) : null;
if (cmdOpts.folderId && !folderId) {
console.error(`${p('err')}Invalid --folder-id. Expected numeric ID or https://x.com/i/bookmarks/<id>.`);
process.exit(1);
}
const result = folderId
? await client.getBookmarkFolderTimeline(folderId, count)
: await client.getBookmarks(count);
if (result.success && result.tweets) {
const emptyMessage = folderId ? 'No bookmarks found in folder.' : 'No bookmarks found.';
printTweets(result.tweets, { json: cmdOpts.json, emptyMessage });
} else {
console.error(`${p('err')}Failed to fetch bookmarks: ${result.error}`);
process.exit(1);
}
});
// Likes command - get user's liked tweets
program
.command('likes')
.description('Get your liked tweets')
.option('-n, --count <number>', 'Number of likes to fetch', '20')
.option('--json', 'Output as JSON')
.action(async (cmdOpts: { count?: string; json?: boolean }) => {
const opts = program.opts();
@@ -719,12 +799,12 @@ program
}
const client = new TwitterClient({ cookies, timeoutMs, quoteDepth });
const result = await client.getBookmarks(count);
const result = await client.getLikes(count);
if (result.success && result.tweets) {
printTweets(result.tweets, { json: cmdOpts.json, emptyMessage: 'No bookmarks found.' });
printTweets(result.tweets, { json: cmdOpts.json, emptyMessage: 'No liked tweets found.' });
} else {
console.error(`${p('err')}Failed to fetch bookmarks: ${result.error}`);
console.error(`${p('err')}Failed to fetch likes: ${result.error}`);
process.exit(1);
}
});
+3 -1
View File
@@ -8,7 +8,9 @@ const TWEET_ID_REGEX = /^\d{8,}$/;
export function looksLikeTweetInput(value: string): boolean {
const trimmed = value.trim();
if (!trimmed) return false;
if (!trimmed) {
return false;
}
return TWEET_URL_REGEX.test(trimmed) || TWEET_ID_REGEX.test(trimmed);
}
+40 -14
View File
@@ -21,10 +21,12 @@ export type CookieSource = 'safari' | 'chrome' | 'firefox';
const TWITTER_COOKIE_NAMES = ['auth_token', 'ct0'] as const;
const TWITTER_URL = 'https://x.com/';
const TWITTER_ORIGINS = ['https://x.com/', 'https://twitter.com/'];
const TWITTER_ORIGINS: string[] = ['https://x.com/', 'https://twitter.com/'];
function normalizeValue(value: unknown): string | null {
if (typeof value !== 'string') return null;
if (typeof value !== 'string') {
return null;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
@@ -38,24 +40,36 @@ function buildEmpty(): TwitterCookies {
}
function readEnvCookie(cookies: TwitterCookies, keys: readonly string[], field: 'authToken' | 'ct0'): void {
if (cookies[field]) return;
if (cookies[field]) {
return;
}
for (const key of keys) {
const value = normalizeValue(process.env[key]);
if (!value) continue;
if (!value) {
continue;
}
cookies[field] = value;
if (!cookies.source) cookies.source = `env ${key}`;
if (!cookies.source) {
cookies.source = `env ${key}`;
}
break;
}
}
function resolveSources(cookieSource?: CookieSource | CookieSource[]): CookieSource[] {
if (Array.isArray(cookieSource)) return cookieSource;
if (cookieSource) return [cookieSource];
if (Array.isArray(cookieSource)) {
return cookieSource;
}
if (cookieSource) {
return [cookieSource];
}
return ['safari', 'chrome', 'firefox'];
}
function labelForSource(source: CookieSource, profile?: string): string {
if (source === 'safari') return 'Safari';
if (source === 'safari') {
return 'Safari';
}
if (source === 'chrome') {
return profile ? `Chrome profile "${profile}"` : 'Chrome default profile';
}
@@ -67,13 +81,19 @@ function pickCookieValue(
name: (typeof TWITTER_COOKIE_NAMES)[number],
): string | null {
const matches = cookies.filter((c) => c?.name === name && typeof c.value === 'string');
if (matches.length === 0) return null;
if (matches.length === 0) {
return null;
}
const preferred = matches.find((c) => (c.domain ?? '').endsWith('x.com'));
if (preferred?.value) return preferred.value;
if (preferred?.value) {
return preferred.value;
}
const twitter = matches.find((c) => (c.domain ?? '').endsWith('twitter.com'));
if (twitter?.value) return twitter.value;
if (twitter?.value) {
return twitter.value;
}
return matches[0]?.value ?? null;
}
@@ -99,8 +119,12 @@ async function readTwitterCookiesFromBrowser(options: {
const authToken = pickCookieValue(cookies, 'auth_token');
const ct0 = pickCookieValue(cookies, 'ct0');
if (authToken) out.authToken = authToken;
if (ct0) out.ct0 = ct0;
if (authToken) {
out.authToken = authToken;
}
if (ct0) {
out.ct0 = ct0;
}
if (out.authToken && out.ct0) {
out.cookieHeader = cookieHeader(out.authToken, out.ct0);
@@ -156,7 +180,9 @@ export async function resolveCredentials(options: {
}
if (options.ct0) {
cookies.ct0 = options.ct0;
if (!cookies.source) cookies.source = 'CLI argument';
if (!cookies.source) {
cookies.source = 'CLI argument';
}
}
readEnvCookie(cookies, ['AUTH_TOKEN', 'TWITTER_AUTH_TOKEN'], 'authToken');
+20
View File
@@ -0,0 +1,20 @@
/**
* Extract bookmark folder (collection) ID from an X/Twitter bookmarks URL.
*/
const BOOKMARK_FOLDER_URL_REGEX = /(?:twitter\.com|x\.com)\/i\/bookmarks\/(\d+)/i;
const BOOKMARK_FOLDER_ID_REGEX = /^\d{5,}$/;
export function extractBookmarkFolderId(input: string): string | null {
const trimmed = input.trim();
if (!trimmed) {
return null;
}
const urlMatch = BOOKMARK_FOLDER_URL_REGEX.exec(trimmed);
if (urlMatch) {
return urlMatch[1];
}
if (BOOKMARK_FOLDER_ID_REGEX.test(trimmed)) {
return trimmed;
}
return null;
}
+3 -1
View File
@@ -1,9 +1,11 @@
/**
* Extract tweet ID from a Twitter/X URL or return the input unchanged if it's already an ID.
*/
const TWEET_URL_REGEX = /(?:twitter\.com|x\.com)\/\w+\/status\/(\d+)/;
export function extractTweetId(input: string): string {
// If it's a URL, extract the tweet ID
const urlMatch = input.match(/(?:twitter\.com|x\.com)\/\w+\/status\/(\d+)/);
const urlMatch = TWEET_URL_REGEX.exec(input);
if (urlMatch) {
return urlMatch[1];
}
+14 -4
View File
@@ -1,13 +1,21 @@
const HANDLE_REGEX = /^[A-Za-z0-9_]{1,15}$/;
export function normalizeHandle(input?: string | null): string | null {
const raw = (input ?? '').trim();
if (!raw) return null;
if (!raw) {
return null;
}
const withoutAt = raw.startsWith('@') ? raw.slice(1) : raw;
const handle = withoutAt.trim();
if (!handle) return null;
if (!handle) {
return null;
}
// X/Twitter handles are traditionally max 15 chars; keep strict to avoid surprising queries.
if (!/^[A-Za-z0-9_]{1,15}$/.test(handle)) return null;
if (!HANDLE_REGEX.test(handle)) {
return null;
}
return handle;
}
@@ -16,7 +24,9 @@ export function mentionsQueryFromUserOption(userOption?: string | null): {
query: string | null;
error: string | null;
} {
if (typeof userOption === 'undefined') return { query: null, error: null };
if (typeof userOption === 'undefined') {
return { query: null, error: null };
}
const handle = normalizeHandle(userOption);
if (!handle) {
+18 -6
View File
@@ -53,14 +53,22 @@ export function resolveOutputConfigFromCommander(
}
export function statusPrefix(kind: StatusKind, cfg: OutputConfig): string {
if (cfg.plain) return `${STATUS[kind].plain} `;
if (cfg.emoji) return `${STATUS[kind].emoji} `;
if (cfg.plain) {
return `${STATUS[kind].plain} `;
}
if (cfg.emoji) {
return `${STATUS[kind].emoji} `;
}
return `${STATUS[kind].text} `;
}
export function labelPrefix(kind: LabelKind, cfg: OutputConfig): string {
if (cfg.plain) return `${LABELS[kind].plain} `;
if (cfg.emoji) return `${LABELS[kind].emoji} `;
if (cfg.plain) {
return `${LABELS[kind].plain} `;
}
if (cfg.emoji) {
return `${LABELS[kind].emoji} `;
}
return `${LABELS[kind].text} `;
}
@@ -72,8 +80,12 @@ export function formatStatsLine(
const retweetCount = stats.retweetCount ?? 0;
const replyCount = stats.replyCount ?? 0;
if (cfg.plain) return `likes: ${likeCount} retweets: ${retweetCount} replies: ${replyCount}`;
if (!cfg.emoji) return `Likes ${likeCount} Retweets ${retweetCount} Replies ${replyCount}`;
if (cfg.plain) {
return `likes: ${likeCount} retweets: ${retweetCount} replies: ${replyCount}`;
}
if (!cfg.emoji) {
return `Likes ${likeCount} Retweets ${retweetCount} Replies ${replyCount}`;
}
return `❤️ ${likeCount} 🔁 ${retweetCount} 💬 ${replyCount}`;
}
+2 -1
View File
@@ -3,5 +3,6 @@
"CreateRetweet": "LFho5rIi4xcKO90p9jwG7A",
"FavoriteTweet": "lI07N6Otwv1PhnEgXILM7A",
"TweetDetail": "97JF30KziU00483E_8elBA",
"SearchTimeline": "M1jEez78PEfVfbQLvlWMvQ"
"SearchTimeline": "M1jEez78PEfVfbQLvlWMvQ",
"Likes": "JR2gceKucIKcVNB_9JkhsA"
}
+59 -21
View File
@@ -3,9 +3,9 @@ import { homedir } from 'node:os';
import path from 'node:path';
const DEFAULT_CACHE_FILENAME = 'query-ids-cache.json';
const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000;
const DEFAULT_TTL_MS: number = 24 * 60 * 60 * 1000;
const DISCOVERY_PAGES = [
const DISCOVERY_PAGES: string[] = [
'https://x.com/?lang=en',
'https://x.com/explore',
'https://x.com/notifications',
@@ -13,6 +13,7 @@ const DISCOVERY_PAGES = [
];
const BUNDLE_URL_REGEX = /https:\/\/abs\.twimg\.com\/responsive-web\/client-web(?:-legacy)?\/[A-Za-z0-9.-]+\.js/g;
const QUERY_ID_REGEX = /^[a-zA-Z0-9_-]+$/;
const OPERATION_PATTERNS = [
{
@@ -67,6 +68,15 @@ export type RuntimeQueryIdsOptions = {
fetchImpl?: typeof fetch;
};
export type RuntimeQueryIdStore = {
cachePath: string;
ttlMs: number;
getSnapshotInfo: () => Promise<RuntimeQueryIdSnapshotInfo | null>;
getQueryId: (operationName: string) => Promise<string | null>;
refresh: (operationNames: string[], opts?: { force?: boolean }) => Promise<RuntimeQueryIdSnapshotInfo | null>;
clearMemory: () => void;
};
async function fetchText(fetchImpl: typeof fetch, url: string): Promise<string> {
const response = await fetchImpl(url, { headers: HEADERS });
if (!response.ok) {
@@ -85,7 +95,9 @@ function resolveDefaultCachePath(): string {
}
function parseSnapshot(raw: unknown): RuntimeQueryIdSnapshot | null {
if (!raw || typeof raw !== 'object') return null;
if (!raw || typeof raw !== 'object') {
return null;
}
const record = raw as Record<string, unknown>;
const fetchedAt = typeof record.fetchedAt === 'string' ? record.fetchedAt : null;
const ttlMs = typeof record.ttlMs === 'number' && Number.isFinite(record.ttlMs) ? record.ttlMs : null;
@@ -93,10 +105,14 @@ function parseSnapshot(raw: unknown): RuntimeQueryIdSnapshot | null {
const discovery =
record.discovery && typeof record.discovery === 'object' ? (record.discovery as Record<string, unknown>) : null;
if (!fetchedAt || !ttlMs || !ids || !discovery) return null;
if (!fetchedAt || !ttlMs || !ids || !discovery) {
return null;
}
const pages = Array.isArray(discovery.pages) ? discovery.pages : null;
const bundles = Array.isArray(discovery.bundles) ? discovery.bundles : null;
if (!pages || !bundles) return null;
if (!pages || !bundles) {
return null;
}
const normalizedIds: Record<string, string> = {};
for (const [key, value] of Object.entries(ids)) {
@@ -120,9 +136,7 @@ async function readSnapshotFromDisk(cachePath: string): Promise<RuntimeQueryIdSn
try {
const raw = await readFile(cachePath, 'utf8');
return parseSnapshot(JSON.parse(raw));
} catch (error) {
const code = error && typeof error === 'object' ? String((error as { code?: unknown }).code ?? '') : '';
if (code !== 'ENOENT') return null;
} catch {
return null;
}
}
@@ -161,15 +175,27 @@ function extractOperations(
pattern.regex.lastIndex = 0;
while (true) {
const match = pattern.regex.exec(bundleContents);
if (match === null) break;
if (match === null) {
break;
}
const operationName = match[pattern.operationGroup];
const queryId = match[pattern.queryIdGroup];
if (!operationName || !queryId) continue;
if (!targets.has(operationName)) continue;
if (!/^[a-zA-Z0-9_-]+$/.test(queryId)) continue;
if (discovered.has(operationName)) continue;
if (!operationName || !queryId) {
continue;
}
if (!targets.has(operationName)) {
continue;
}
if (!QUERY_ID_REGEX.test(queryId)) {
continue;
}
if (discovered.has(operationName)) {
continue;
}
discovered.set(operationName, { queryId, bundle: bundleLabel });
if (discovered.size === targets.size) return;
if (discovered.size === targets.size) {
return;
}
}
}
}
@@ -186,7 +212,9 @@ async function fetchAndExtract(
const chunk = bundleUrls.slice(i, i + CONCURRENCY);
await Promise.all(
chunk.map(async (url) => {
if (discovered.size === targets.size) return;
if (discovered.size === targets.size) {
return;
}
const label = url.split('/').at(-1) ?? url;
try {
const js = await fetchText(fetchImpl, url);
@@ -196,13 +224,15 @@ async function fetchAndExtract(
}
}),
);
if (discovered.size === targets.size) break;
if (discovered.size === targets.size) {
break;
}
}
return discovered;
}
export function createRuntimeQueryIdStore(options: RuntimeQueryIdsOptions = {}) {
export function createRuntimeQueryIdStore(options: RuntimeQueryIdsOptions = {}): RuntimeQueryIdStore {
const fetchImpl = options.fetchImpl ?? fetch;
const ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
const cachePath = options.cachePath ? path.resolve(options.cachePath) : resolveDefaultCachePath();
@@ -212,7 +242,9 @@ export function createRuntimeQueryIdStore(options: RuntimeQueryIdsOptions = {})
let refreshInFlight: Promise<RuntimeQueryIdSnapshotInfo | null> | null = null;
const loadSnapshot = async (): Promise<RuntimeQueryIdSnapshot | null> => {
if (memorySnapshot) return memorySnapshot;
if (memorySnapshot) {
return memorySnapshot;
}
if (!loadOnce) {
loadOnce = (async () => {
const fromDisk = await readSnapshotFromDisk(cachePath);
@@ -225,7 +257,9 @@ export function createRuntimeQueryIdStore(options: RuntimeQueryIdsOptions = {})
const getSnapshotInfo = async (): Promise<RuntimeQueryIdSnapshotInfo | null> => {
const snapshot = await loadSnapshot();
if (!snapshot) return null;
if (!snapshot) {
return null;
}
const fetchedAtMs = new Date(snapshot.fetchedAt).getTime();
const ageMs = Number.isFinite(fetchedAtMs) ? Math.max(0, Date.now() - fetchedAtMs) : Number.POSITIVE_INFINITY;
const effectiveTtl = Number.isFinite(snapshot.ttlMs) ? snapshot.ttlMs : ttlMs;
@@ -235,7 +269,9 @@ export function createRuntimeQueryIdStore(options: RuntimeQueryIdsOptions = {})
const getQueryId = async (operationName: string): Promise<string | null> => {
const info = await getSnapshotInfo();
if (!info) return null;
if (!info) {
return null;
}
return info.snapshot.ids[operationName] ?? null;
};
@@ -243,7 +279,9 @@ export function createRuntimeQueryIdStore(options: RuntimeQueryIdsOptions = {})
operationNames: string[],
opts: { force?: boolean } = {},
): Promise<RuntimeQueryIdSnapshotInfo | null> => {
if (refreshInFlight) return refreshInFlight;
if (refreshInFlight) {
return refreshInFlight;
}
refreshInFlight = (async () => {
const current = await getSnapshotInfo();
+359 -45
View File
@@ -4,6 +4,7 @@
import { randomBytes, randomUUID } from 'node:crypto';
import type { TwitterCookies } from './cookies.js';
// biome-ignore lint/correctness/useImportExtensions: JSON module import doesn't use .js extension.
import queryIds from './query-ids.json' with { type: 'json' };
import { runtimeQueryIds } from './runtime-query-ids.js';
@@ -12,6 +13,9 @@ const TWITTER_GRAPHQL_POST_URL = 'https://x.com/i/api/graphql';
const TWITTER_UPLOAD_URL = 'https://upload.twitter.com/i/media/upload.json';
const TWITTER_MEDIA_METADATA_URL = 'https://x.com/i/api/1.1/media/metadata/create.json';
const TWITTER_STATUS_UPDATE_URL = 'https://x.com/i/api/1.1/statuses/update.json';
const SETTINGS_SCREEN_NAME_REGEX = /"screen_name":"([^"]+)"/;
const SETTINGS_USER_ID_REGEX = /"user_id"\s*:\s*"(\d+)"/;
const SETTINGS_NAME_REGEX = /"name":"([^"\\]*(?:\\.[^"\\]*)*)"/;
// Query IDs rotate frequently; the values in query-ids.json are refreshed by
// scripts/update-query-ids.ts. The fallback values keep the client usable if
@@ -24,6 +28,8 @@ const FALLBACK_QUERY_IDS = {
SearchTimeline: 'M1jEez78PEfVfbQLvlWMvQ',
UserArticlesTweets: '8zBy9h4L90aDL02RsBcCFg',
Bookmarks: 'RV1g3b8n_SGOHwkqKYSCFw',
Likes: 'JR2gceKucIKcVNB_9JkhsA',
BookmarkFolderTimeline: 'KJIQpsvxrTfRIlbaRIySHQ',
} as const;
type OperationName = keyof typeof FALLBACK_QUERY_IDS;
@@ -356,7 +362,9 @@ export class TwitterClient {
| undefined,
tweetId: string,
) {
if (!instructions) return undefined;
if (!instructions) {
return undefined;
}
for (const instruction of instructions) {
for (const entry of instruction.entries || []) {
@@ -418,10 +426,14 @@ export class TwitterClient {
private mediaCategoryForMime(mimeType: string): string | null {
if (mimeType.startsWith('image/')) {
if (mimeType === 'image/gif') return 'tweet_gif';
if (mimeType === 'image/gif') {
return 'tweet_gif';
}
return 'tweet_image';
}
if (mimeType.startsWith('video/')) return 'tweet_video';
if (mimeType.startsWith('video/')) {
return 'tweet_video';
}
return null;
}
@@ -431,7 +443,9 @@ export class TwitterClient {
async uploadMedia(input: { data: Uint8Array; mimeType: string; alt?: string }): Promise<UploadMediaResult> {
const category = this.mediaCategoryForMime(input.mimeType);
if (!category) return { success: false, error: `Unsupported media type: ${input.mimeType}` };
if (!category) {
return { success: false, error: `Unsupported media type: ${input.mimeType}` };
}
try {
const initParams = new URLSearchParams({
@@ -459,7 +473,9 @@ export class TwitterClient {
: initBody.media_id !== undefined
? String(initBody.media_id)
: undefined;
if (!mediaId) return { success: false, error: 'Media upload INIT did not return media_id' };
if (!mediaId) {
return { success: false, error: 'Media upload INIT did not return media_id' };
}
const chunkSize = 5 * 1024 * 1024;
let segmentIndex = 0;
@@ -515,7 +531,10 @@ export class TwitterClient {
const delaySecs = Number.isFinite(info.check_after_secs) ? Math.max(1, info.check_after_secs as number) : 2;
await this.sleep(delaySecs * 1000);
const statusUrl = `${TWITTER_UPLOAD_URL}?${new URLSearchParams({ command: 'STATUS', media_id: mediaId })}`;
const statusUrl = `${TWITTER_UPLOAD_URL}?${new URLSearchParams({
command: 'STATUS',
media_id: mediaId,
}).toString()}`;
const statusResp = await this.fetchWithTimeout(statusUrl, {
method: 'GET',
headers: this.getUploadHeaders(),
@@ -533,11 +552,15 @@ export class TwitterClient {
error?: { message?: string; name?: string };
};
};
if (!statusBody.processing_info) break;
if (!statusBody.processing_info) {
break;
}
info.state = statusBody.processing_info.state;
info.check_after_secs = statusBody.processing_info.check_after_secs;
info.error = statusBody.processing_info.error;
if (info.state === 'succeeded') break;
if (info.state === 'succeeded') {
break;
}
attempts += 1;
}
}
@@ -564,15 +587,21 @@ export class TwitterClient {
for (const value of values) {
if (typeof value === 'string') {
const trimmed = value.trim();
if (trimmed) return trimmed;
if (trimmed) {
return trimmed;
}
}
}
return undefined;
}
private collectTextFields(value: unknown, keys: Set<string>, output: string[]): void {
if (!value) return;
if (typeof value === 'string') return;
if (!value) {
return;
}
if (typeof value === 'string') {
return;
}
if (Array.isArray(value)) {
for (const item of value) {
@@ -586,7 +615,9 @@ export class TwitterClient {
if (keys.has(key)) {
if (typeof nested === 'string') {
const trimmed = nested.trim();
if (trimmed) output.push(trimmed);
if (trimmed) {
output.push(trimmed);
}
continue;
}
}
@@ -599,7 +630,9 @@ export class TwitterClient {
const seen = new Set<string>();
const result: string[] = [];
for (const value of values) {
if (seen.has(value)) continue;
if (seen.has(value)) {
continue;
}
seen.add(value);
result.push(value);
}
@@ -608,7 +641,9 @@ export class TwitterClient {
private extractArticleText(result: GraphqlTweetResult | undefined): string | undefined {
const article = result?.article;
if (!article) return undefined;
if (!article) {
return undefined;
}
const articleResult = article.article_results?.result ?? article;
if (process.env.BIRD_DEBUG_ARTICLE === '1') {
@@ -673,7 +708,9 @@ export class TwitterClient {
private extractNoteTweetText(result: GraphqlTweetResult | undefined): string | undefined {
const note = result?.note_tweet?.note_tweet_results?.result;
if (!note) return undefined;
if (!note) {
return undefined;
}
return this.firstText(
note.text,
@@ -704,10 +741,14 @@ export class TwitterClient {
const username = userLegacy?.screen_name ?? userCore?.screen_name;
const name = userLegacy?.name ?? userCore?.name ?? username;
const userId = userResult?.rest_id;
if (!result?.rest_id || !username) return undefined;
if (!result?.rest_id || !username) {
return undefined;
}
const text = this.extractTweetText(result);
if (!text) return undefined;
if (!text) {
return undefined;
}
let quotedTweet: TweetData | undefined;
if (quoteDepth > 0) {
@@ -774,7 +815,9 @@ export class TwitterClient {
}): GraphqlTweetResult[] {
const results: GraphqlTweetResult[] = [];
const pushResult = (result?: GraphqlTweetResult) => {
if (result?.rest_id) results.push(result);
if (result?.rest_id) {
results.push(result);
}
};
const content = entry.content;
@@ -841,7 +884,9 @@ export class TwitterClient {
const results = this.collectTweetResultsFromEntry(entry);
for (const result of results) {
const mapped = this.mapTweetResult(result);
if (!mapped || seen.has(mapped.id)) continue;
if (!mapped || seen.has(mapped.id)) {
continue;
}
seen.add(mapped.id);
tweets.push(mapped);
}
@@ -988,7 +1033,7 @@ export class TwitterClient {
});
const queryId = await this.getQueryId('UserArticlesTweets');
const url = `${TWITTER_API_BASE}/${queryId}/UserArticlesTweets?${params}`;
const url = `${TWITTER_API_BASE}/${queryId}/UserArticlesTweets?${params.toString()}`;
try {
const response = await this.fetchWithTimeout(url, { method: 'GET', headers: this.getHeaders() });
@@ -1022,7 +1067,9 @@ export class TwitterClient {
for (const instruction of instructions) {
for (const entry of instruction.entries ?? []) {
const result = entry.content?.itemContent?.tweet_results?.result;
if (result?.rest_id !== tweetId) continue;
if (result?.rest_id !== tweetId) {
continue;
}
const articleResult = result.article?.article_results?.result;
const title = this.firstText(articleResult?.title, result.article?.title);
const plainText = this.firstText(articleResult?.plain_text, result.article?.plain_text);
@@ -1131,7 +1178,7 @@ export class TwitterClient {
const queryIds = await this.getTweetDetailQueryIds();
for (const queryId of queryIds) {
const url = `${TWITTER_API_BASE}/${queryId}/TweetDetail?${params}`;
const url = `${TWITTER_API_BASE}/${queryId}/TweetDetail?${params.toString()}`;
const response = await this.fetchWithTimeout(url, {
method: 'GET',
headers: this.getHeaders(),
@@ -1384,12 +1431,16 @@ export class TwitterClient {
if (data.errors && data.errors.length > 0) {
const fallback = await this.tryStatusUpdateFallback(data.errors, variables);
if (fallback) return fallback;
if (fallback) {
return fallback;
}
return { success: false, error: this.formatErrors(data.errors) };
}
const tweetId = data.data?.create_tweet?.tweet_results?.result?.rest_id;
if (tweetId) return { success: true, tweetId };
if (tweetId) {
return { success: true, tweetId };
}
return { success: false, error: 'Tweet created but no ID returned' };
}
@@ -1407,7 +1458,9 @@ export class TwitterClient {
if (data.errors && data.errors.length > 0) {
const fallback = await this.tryStatusUpdateFallback(data.errors, variables);
if (fallback) return fallback;
if (fallback) {
return fallback;
}
return {
success: false,
error: this.formatErrors(data.errors),
@@ -1446,7 +1499,9 @@ export class TwitterClient {
mediaIds?: string[];
} | null {
const text = typeof variables.tweet_text === 'string' ? variables.tweet_text : null;
if (!text) return null;
if (!text) {
return null;
}
const reply = variables.reply;
const inReplyToTweetId =
@@ -1518,7 +1573,9 @@ export class TwitterClient {
const tweetId =
typeof data.id_str === 'string' ? data.id_str : data.id !== undefined ? String(data.id) : undefined;
if (tweetId) return { success: true, tweetId };
if (tweetId) {
return { success: true, tweetId };
}
return { success: false, error: 'Tweet created but no ID returned' };
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) };
@@ -1529,12 +1586,18 @@ export class TwitterClient {
errors: Array<{ message: string; code?: number }>,
variables: Record<string, unknown>,
): Promise<TweetResult | null> {
if (!errors.some((error) => error.code === 226)) return null;
if (!errors.some((error) => error.code === 226)) {
return null;
}
const input = this.statusUpdateInputFromCreateTweetVariables(variables);
if (!input) return null;
if (!input) {
return null;
}
const fallback = await this.postStatusUpdate(input);
if (fallback.success) return fallback;
if (fallback.success) {
return fallback;
}
return {
success: false,
@@ -1543,8 +1606,12 @@ export class TwitterClient {
}
private async ensureClientUserId(): Promise<void> {
if (process.env.NODE_ENV === 'test') return;
if (this.clientUserId) return;
if (process.env.NODE_ENV === 'test') {
return;
}
if (this.clientUserId) {
return;
}
const result = await this.getCurrentUser();
if (result.success && result.user?.id) {
this.clientUserId = result.user.id;
@@ -1574,7 +1641,7 @@ export class TwitterClient {
const queryIds = await this.getSearchTimelineQueryIds();
for (const queryId of queryIds) {
const url = `${TWITTER_API_BASE}/${queryId}/SearchTimeline?${params}`;
const url = `${TWITTER_API_BASE}/${queryId}/SearchTimeline?${params.toString()}`;
try {
const response = await this.fetchWithTimeout(url, {
@@ -1654,12 +1721,16 @@ export class TwitterClient {
};
const firstAttempt = await tryOnce();
if (firstAttempt.success) return { success: true, tweets: firstAttempt.tweets };
if (firstAttempt.success) {
return { success: true, tweets: firstAttempt.tweets };
}
if (firstAttempt.had404) {
await this.refreshQueryIds();
const secondAttempt = await tryOnce();
if (secondAttempt.success) return { success: true, tweets: secondAttempt.tweets };
if (secondAttempt.success) {
return { success: true, tweets: secondAttempt.tweets };
}
return { success: false, error: secondAttempt.error };
}
@@ -1761,9 +1832,9 @@ export class TwitterClient {
}
const html = await response.text();
const usernameMatch = html.match(/"screen_name":"([^"]+)"/);
const idMatch = html.match(/"user_id"\s*:\s*"(\d+)"/);
const nameMatch = html.match(/"name":"([^"\\]*(?:\\.[^"\\]*)*)"/);
const usernameMatch = SETTINGS_SCREEN_NAME_REGEX.exec(html);
const idMatch = SETTINGS_USER_ID_REGEX.exec(html);
const nameMatch = SETTINGS_NAME_REGEX.exec(html);
const username = usernameMatch?.[1];
const userId = idMatch?.[1];
@@ -1797,7 +1868,9 @@ export class TwitterClient {
*/
async getReplies(tweetId: string): Promise<SearchResult> {
const response = await this.fetchTweetDetail(tweetId);
if (!response.success) return response;
if (!response.success) {
return response;
}
const instructions = response.data.threaded_conversation_with_injections_v2?.instructions;
const tweets = this.parseTweetsFromInstructions(instructions);
@@ -1811,7 +1884,9 @@ export class TwitterClient {
*/
async getThread(tweetId: string): Promise<SearchResult> {
const response = await this.fetchTweetDetail(tweetId);
if (!response.success) return response;
if (!response.success) {
return response;
}
const instructions = response.data.threaded_conversation_with_injections_v2?.instructions;
const tweets = this.parseTweetsFromInstructions(instructions);
@@ -1829,10 +1904,10 @@ export class TwitterClient {
return { success: true, tweets: thread };
}
private buildBookmarksFeatures(): Record<string, boolean> {
// Shared timeline features for likes/bookmarks-style timelines.
private buildTimelineFeatures(): Record<string, boolean> {
return {
...this.buildSearchFeatures(),
graphql_timeline_v2_bookmark_timeline: true,
blue_business_profile_image_shape_enabled: true,
responsive_web_text_conversations_enabled: false,
tweetypie_unmention_optimization_enabled: true,
@@ -1844,11 +1919,27 @@ export class TwitterClient {
};
}
private buildBookmarksFeatures(): Record<string, boolean> {
return {
...this.buildTimelineFeatures(),
graphql_timeline_v2_bookmark_timeline: true,
};
}
private buildLikesFeatures(): Record<string, boolean> {
return this.buildTimelineFeatures();
}
private async getBookmarksQueryIds(): Promise<string[]> {
const primary = await this.getQueryId('Bookmarks');
return Array.from(new Set([primary, 'RV1g3b8n_SGOHwkqKYSCFw', 'tmd4ifV8RHltzn8ymGg1aw']));
}
private async getBookmarkFolderQueryIds(): Promise<string[]> {
const primary = await this.getQueryId('BookmarkFolderTimeline');
return Array.from(new Set([primary, 'KJIQpsvxrTfRIlbaRIySHQ']));
}
/**
* Get the authenticated user's bookmarks
*/
@@ -1874,7 +1965,7 @@ export class TwitterClient {
const queryIds = await this.getBookmarksQueryIds();
for (const queryId of queryIds) {
const url = `${TWITTER_API_BASE}/${queryId}/Bookmarks?${params}`;
const url = `${TWITTER_API_BASE}/${queryId}/Bookmarks?${params.toString()}`;
try {
const response = await this.fetchWithTimeout(url, {
@@ -1931,12 +2022,235 @@ export class TwitterClient {
};
const firstAttempt = await tryOnce();
if (firstAttempt.success) return { success: true, tweets: firstAttempt.tweets };
if (firstAttempt.success) {
return { success: true, tweets: firstAttempt.tweets };
}
if (firstAttempt.had404) {
await this.refreshQueryIds();
const secondAttempt = await tryOnce();
if (secondAttempt.success) return { success: true, tweets: secondAttempt.tweets };
if (secondAttempt.success) {
return { success: true, tweets: secondAttempt.tweets };
}
return { success: false, error: secondAttempt.error };
}
return { success: false, error: firstAttempt.error };
}
private async getLikesQueryIds(): Promise<string[]> {
const primary = await this.getQueryId('Likes');
return Array.from(new Set([primary, 'JR2gceKucIKcVNB_9JkhsA']));
}
/**
* Get the authenticated user's liked tweets
*/
async getLikes(count = 20): Promise<SearchResult> {
const userResult = await this.getCurrentUser();
if (!userResult.success || !userResult.user) {
return { success: false, error: userResult.error ?? 'Could not determine current user' };
}
const variables = {
userId: userResult.user.id,
count,
includePromotedContent: false,
withClientEventToken: false,
withBirdwatchNotes: false,
withVoice: true,
};
const features = this.buildLikesFeatures();
const params = new URLSearchParams({
variables: JSON.stringify(variables),
features: JSON.stringify(features),
});
const tryOnce = async () => {
let lastError: string | undefined;
let had404 = false;
const queryIds = await this.getLikesQueryIds();
for (const queryId of queryIds) {
const url = `${TWITTER_API_BASE}/${queryId}/Likes?${params.toString()}`;
try {
const response = await this.fetchWithTimeout(url, {
method: 'GET',
headers: this.getHeaders(),
});
if (response.status === 404) {
had404 = true;
lastError = `HTTP ${response.status}`;
continue;
}
if (!response.ok) {
const text = await response.text();
return { success: false as const, error: `HTTP ${response.status}: ${text.slice(0, 200)}`, had404 };
}
const data = (await response.json()) as {
data?: {
user?: {
result?: {
timeline?: {
timeline?: {
instructions?: Array<{
entries?: Array<{
content?: {
itemContent?: {
tweet_results?: {
result?: GraphqlTweetResult;
};
};
};
}>;
}>;
};
};
};
};
};
errors?: Array<{ message: string }>;
};
if (data.errors && data.errors.length > 0) {
return { success: false as const, error: data.errors.map((e) => e.message).join(', '), had404 };
}
const instructions = data.data?.user?.result?.timeline?.timeline?.instructions;
const tweets = this.parseTweetsFromInstructions(instructions);
return { success: true as const, tweets, had404 };
} catch (error) {
lastError = error instanceof Error ? error.message : String(error);
}
}
return { success: false as const, error: lastError ?? 'Unknown error fetching likes', had404 };
};
const firstAttempt = await tryOnce();
if (firstAttempt.success) {
return { success: true, tweets: firstAttempt.tweets };
}
if (firstAttempt.had404) {
await this.refreshQueryIds();
const secondAttempt = await tryOnce();
if (secondAttempt.success) {
return { success: true, tweets: secondAttempt.tweets };
}
return { success: false, error: secondAttempt.error };
}
return { success: false, error: firstAttempt.error };
}
/**
* Get the authenticated user's bookmark folder timeline
*/
async getBookmarkFolderTimeline(folderId: string, count = 20): Promise<SearchResult> {
const variablesWithCount = {
bookmark_collection_id: folderId,
includePromotedContent: true,
count,
};
const variablesWithoutCount = {
bookmark_collection_id: folderId,
includePromotedContent: true,
};
const features = this.buildBookmarksFeatures();
const tryOnce = async (variables: Record<string, unknown>) => {
let lastError: string | undefined;
let had404 = false;
const queryIds = await this.getBookmarkFolderQueryIds();
const params = new URLSearchParams({
variables: JSON.stringify(variables),
features: JSON.stringify(features),
});
for (const queryId of queryIds) {
const url = `${TWITTER_API_BASE}/${queryId}/BookmarkFolderTimeline?${params.toString()}`;
try {
const response = await this.fetchWithTimeout(url, {
method: 'GET',
headers: this.getHeaders(),
});
if (response.status === 404) {
had404 = true;
lastError = `HTTP ${response.status}`;
continue;
}
if (!response.ok) {
const text = await response.text();
return { success: false as const, error: `HTTP ${response.status}: ${text.slice(0, 200)}`, had404 };
}
const data = (await response.json()) as {
data?: {
bookmark_collection_timeline?: {
timeline?: {
instructions?: Array<{
entries?: Array<{
content?: {
itemContent?: {
tweet_results?: {
result?: GraphqlTweetResult;
};
};
};
}>;
}>;
};
};
};
errors?: Array<{ message: string }>;
};
if (data.errors && data.errors.length > 0) {
return { success: false as const, error: data.errors.map((e) => e.message).join(', '), had404 };
}
const instructions = data.data?.bookmark_collection_timeline?.timeline?.instructions;
const tweets = this.parseTweetsFromInstructions(instructions);
return { success: true as const, tweets, had404 };
} catch (error) {
lastError = error instanceof Error ? error.message : String(error);
}
}
return { success: false as const, error: lastError ?? 'Unknown error fetching bookmark folder', had404 };
};
let firstAttempt = await tryOnce(variablesWithCount);
if (!firstAttempt.success && firstAttempt.error?.includes('Variable "$count"')) {
firstAttempt = await tryOnce(variablesWithoutCount);
}
if (firstAttempt.success) {
return { success: true, tweets: firstAttempt.tweets };
}
if (firstAttempt.had404) {
await this.refreshQueryIds();
let secondAttempt = await tryOnce(variablesWithCount);
if (!secondAttempt.success && secondAttempt.error?.includes('Variable "$count"')) {
secondAttempt = await tryOnce(variablesWithoutCount);
}
if (secondAttempt.success) {
return { success: true, tweets: secondAttempt.tweets };
}
return { success: false, error: secondAttempt.error };
}
+46 -17
View File
@@ -5,6 +5,9 @@ import { fileURLToPath } from 'node:url';
declare const __dirname: string | undefined;
export const FALLBACK_VERSION = 'unknown';
const REF_PREFIX_REGEX = /^ref:\s*/i;
const LINE_SPLIT_REGEX = /\r?\n/;
const GITDIR_REGEX = /gitdir:\s*(.+)\s*$/i;
function readPackageVersionFromJsonFile(candidate: string): string | null {
try {
@@ -37,7 +40,9 @@ function resolveStartDir(importMetaUrl?: string): string {
}
}
if (typeof __dirname === 'string' && __dirname.length > 0) return __dirname;
if (typeof __dirname === 'string' && __dirname.length > 0) {
return __dirname;
}
return process.cwd();
}
@@ -47,27 +52,37 @@ export function resolvePackageVersion(importMetaUrl?: string): string {
typeof process !== 'undefined' && typeof process.env.BIRD_VERSION === 'string'
? process.env.BIRD_VERSION.trim()
: '';
if (injected.length > 0) return injected;
if (injected.length > 0) {
return injected;
}
let dir = resolveStartDir(importMetaUrl);
for (let i = 0; i < 10; i += 1) {
const version =
readPackageVersionFromJsonFile(path.join(dir, 'package.json')) ??
readVersionFromTextFile(path.join(dir, 'VERSION'));
if (version) return version;
if (version) {
return version;
}
const parent = path.dirname(dir);
if (parent === dir) break;
if (parent === dir) {
break;
}
dir = parent;
}
return FALLBACK_VERSION;
}
function truncateSha(sha: string, length = 8): string {
function truncateSha(sha: string, length: number = 8): string {
const trimmed = sha.trim();
if (!trimmed) return '';
if (trimmed.length <= length) return trimmed;
if (!trimmed) {
return '';
}
if (trimmed.length <= length) {
return trimmed;
}
return trimmed.slice(0, length);
}
@@ -80,14 +95,18 @@ function resolveGitShaFromGitDir(gitDir: string): string | null {
return null;
}
if (!head) return null;
if (!head) {
return null;
}
if (!head.startsWith('ref:')) {
const sha = truncateSha(head);
return sha.length > 0 ? sha : null;
}
const ref = head.replace(/^ref:\s*/i, '').trim();
if (!ref) return null;
const ref = head.replace(REF_PREFIX_REGEX, '').trim();
if (!ref) {
return null;
}
const refPath = path.join(gitDir, ref);
try {
@@ -100,9 +119,11 @@ function resolveGitShaFromGitDir(gitDir: string): string | null {
const packedRefsPath = path.join(gitDir, 'packed-refs');
try {
const packed = fs.readFileSync(packedRefsPath, 'utf8');
const lines = packed.split(/\r?\n/);
const lines = packed.split(LINE_SPLIT_REGEX);
for (const line of lines) {
if (!line || line.startsWith('#') || line.startsWith('^')) continue;
if (!line || line.startsWith('#') || line.startsWith('^')) {
continue;
}
const [shaRaw, refName] = line.split(' ');
if (refName?.trim() === ref) {
const sha = truncateSha(shaRaw ?? '');
@@ -121,7 +142,9 @@ export function resolveGitSha(importMetaUrl?: string): string | null {
typeof process !== 'undefined' && typeof process.env.BIRD_GIT_SHA === 'string'
? process.env.BIRD_GIT_SHA.trim()
: '';
if (injected.length > 0) return truncateSha(injected);
if (injected.length > 0) {
return truncateSha(injected);
}
let dir = resolveStartDir(importMetaUrl);
for (let i = 0; i < 10; i += 1) {
@@ -130,15 +153,19 @@ export function resolveGitSha(importMetaUrl?: string): string | null {
const stat = fs.statSync(dotGit);
if (stat.isDirectory()) {
const sha = resolveGitShaFromGitDir(dotGit);
if (sha) return sha;
if (sha) {
return sha;
}
} else if (stat.isFile()) {
const txt = fs.readFileSync(dotGit, 'utf8');
const match = txt.match(/gitdir:\s*(.+)\s*$/i);
const match = GITDIR_REGEX.exec(txt);
const gitDir = match?.[1]?.trim();
if (gitDir) {
const resolved = path.isAbsolute(gitDir) ? gitDir : path.resolve(dir, gitDir);
const sha = resolveGitShaFromGitDir(resolved);
if (sha) return sha;
if (sha) {
return sha;
}
}
}
} catch {
@@ -146,7 +173,9 @@ export function resolveGitSha(importMetaUrl?: string): string | null {
}
const parent = path.dirname(dir);
if (parent === dir) break;
if (parent === dir) {
break;
}
dir = parent;
}