From 9e0b3f5e0324a8f4eb1cd02a65d52975ff6be9a3 Mon Sep 17 00:00:00 2001 From: joseph Rosenbaum Date: Tue, 14 Apr 2026 22:04:55 -0500 Subject: [PATCH 1/4] feat: add classify limit and portable firefox config test --- src/bookmark-classify-llm.ts | 10 ++++++---- src/cli.ts | 15 +++++++++++---- tests/config.test.ts | 7 ++++++- 3 files changed, 23 insertions(+), 9 deletions(-) diff --git a/src/bookmark-classify-llm.ts b/src/bookmark-classify-llm.ts index 82d7600..3cc395b 100644 --- a/src/bookmark-classify-llm.ts +++ b/src/bookmark-classify-llm.ts @@ -110,9 +110,10 @@ export interface LlmClassifyResult { } export async function classifyWithLlm( - options: { engine: ResolvedEngine; onBatch?: (done: number, total: number) => void }, + options: { engine: ResolvedEngine; limit?: number; onBatch?: (done: number, total: number) => void }, ): Promise { const { engine } = options; + const limitClause = options.limit && options.limit > 0 ? ` LIMIT ${Math.floor(options.limit)}` : ''; const dbPath = twitterBookmarksIndexPath(); const db = await openDb(dbPath); @@ -122,7 +123,7 @@ export async function classifyWithLlm( const rows = db.exec( `SELECT id, text, author_handle, links_json FROM bookmarks WHERE primary_category = 'unclassified' OR primary_category IS NULL - ORDER BY RANDOM()` + ORDER BY RANDOM()${limitClause}` ); if (!rows.length || !rows[0].values.length) { @@ -223,9 +224,10 @@ ${items}`; } export async function classifyDomainsWithLlm( - options: { engine: ResolvedEngine; all?: boolean; onBatch?: (done: number, total: number) => void }, + options: { engine: ResolvedEngine; all?: boolean; limit?: number; onBatch?: (done: number, total: number) => void }, ): Promise { const { engine } = options; + const limitClause = options.limit && options.limit > 0 ? ` LIMIT ${Math.floor(options.limit)}` : ''; const dbPath = twitterBookmarksIndexPath(); const db = await openDb(dbPath); @@ -240,7 +242,7 @@ export async function classifyDomainsWithLlm( : 'primary_domain IS NULL'; const rows = db.exec( `SELECT id, text, author_handle, categories FROM bookmarks - WHERE ${where} ORDER BY RANDOM()` + WHERE ${where} ORDER BY RANDOM()${limitClause}` ); if (!rows.length || !rows[0].values.length) { diff --git a/src/cli.ts b/src/cli.ts index 84fadd6..344a089 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -966,25 +966,28 @@ export function buildCli() { .command('classify') .description('Classify bookmarks by category and domain using LLM (requires claude or codex CLI)') .option('--regex', 'Use simple regex classification instead of LLM') + .option('--limit ', 'Only classify up to N bookmarks (useful for testing)', (v: string) => Number(v)) .addOption(engineOption()) .action(safe(async (options) => { if (!requireData()) return; if (options.regex) { process.stderr.write('Classifying bookmarks (regex)...\n'); const result = await classifyAndRebuild(); - console.log(`Indexed ${result.recordCount} bookmarks \u2192 ${result.dbPath}`); + console.log(`Indexed ${result.recordCount} bookmarks → ${result.dbPath}`); console.log(formatClassificationSummary(result.summary)); } else { const engine = await resolveEngine({ override: options.engine ? String(options.engine) : undefined }); + const limit = Number.isFinite(options.limit) && options.limit > 0 ? options.limit : undefined; let catStart = Date.now(); process.stderr.write('Classifying categories with LLM (batches of 50, ~2 min per batch)...\n'); const catResult = await classifyWithLlm({ engine, + limit, onBatch: (done: number, total: number) => { const pct = total > 0 ? Math.round((done / total) * 100) : 0; const elapsed = Math.round((Date.now() - catStart) / 1000); - process.stderr.write(` Categories: ${done}/${total} (${pct}%) \u2502 ${elapsed}s elapsed\n`); + process.stderr.write(` Categories: ${done}/${total} (${pct}%) │ ${elapsed}s elapsed\n`); }, }); console.log(`\nEngine: ${catResult.engine}`); @@ -995,10 +998,11 @@ export function buildCli() { const domResult = await classifyDomainsWithLlm({ engine, all: false, + limit, onBatch: (done: number, total: number) => { const pct = total > 0 ? Math.round((done / total) * 100) : 0; const elapsed = Math.round((Date.now() - domStart) / 1000); - process.stderr.write(` Domains: ${done}/${total} (${pct}%) \u2502 ${elapsed}s elapsed\n`); + process.stderr.write(` Domains: ${done}/${total} (${pct}%) │ ${elapsed}s elapsed\n`); }, }); console.log(`\nDomains: ${domResult.classified}/${domResult.totalUnclassified} classified`); @@ -1011,19 +1015,22 @@ export function buildCli() { .command('classify-domains') .description('Classify bookmarks by subject domain using LLM (ai, finance, etc.)') .option('--all', 'Re-classify all bookmarks, not just missing') + .option('--limit ', 'Only classify up to N bookmarks (useful for testing)', (v: string) => Number(v)) .addOption(engineOption()) .action(safe(async (options) => { if (!requireData()) return; const engine = await resolveEngine({ override: options.engine ? String(options.engine) : undefined }); + const limit = Number.isFinite(options.limit) && options.limit > 0 ? options.limit : undefined; const start = Date.now(); process.stderr.write('Classifying bookmark domains with LLM (batches of 50, ~2 min per batch)...\n'); const result = await classifyDomainsWithLlm({ engine, all: options.all ?? false, + limit, onBatch: (done: number, total: number) => { const pct = total > 0 ? Math.round((done / total) * 100) : 0; const elapsed = Math.round((Date.now() - start) / 1000); - process.stderr.write(` Domains: ${done}/${total} (${pct}%) \u2502 ${elapsed}s elapsed\n`); + process.stderr.write(` Domains: ${done}/${total} (${pct}%) │ ${elapsed}s elapsed\n`); }, }); console.log(`\nDomains: ${result.classified}/${result.totalUnclassified} classified`); diff --git a/tests/config.test.ts b/tests/config.test.ts index ce9a967..1c26a3c 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -1,5 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; +import { platform } from 'node:os'; import { loadChromeSessionConfig } from '../src/config.js'; test('loadChromeSessionConfig reads chrome user data dir and profile directory from env', () => { @@ -51,5 +52,9 @@ test('loadChromeSessionConfig: --browser firefox resolves correctly', () => { const config = loadChromeSessionConfig({ browserId: 'firefox' }); assert.equal(config.browser.id, 'firefox'); assert.equal(config.browser.cookieBackend, 'firefox'); - assert.match(config.chromeUserDataDir, /Firefox/); + if (platform() === 'darwin') { + assert.match(config.chromeUserDataDir, /Firefox/); + } else { + assert.match(config.chromeUserDataDir, /firefox/i); + } }); From f8097936ad34f173067438bdbf5246f5e2c191b6 Mon Sep 17 00:00:00 2001 From: joseph Rosenbaum Date: Tue, 14 Apr 2026 22:11:49 -0500 Subject: [PATCH 2/4] refactor: improve shared firefox profile discovery --- src/firefox-cookies.ts | 379 ++++++++++++++++-------- tests/firefox-profile-detection.test.ts | 107 +++++++ 2 files changed, 365 insertions(+), 121 deletions(-) create mode 100644 tests/firefox-profile-detection.test.ts diff --git a/src/firefox-cookies.ts b/src/firefox-cookies.ts index 60eb6c5..e1009e6 100644 --- a/src/firefox-cookies.ts +++ b/src/firefox-cookies.ts @@ -1,7 +1,7 @@ import { execFileSync } from 'node:child_process'; -import { existsSync, readFileSync, copyFileSync, mkdtempSync, rmSync } from 'node:fs'; +import { existsSync, readFileSync, copyFileSync, mkdtempSync, rmSync, readdirSync, statSync } from 'node:fs'; import { basename, join } from 'node:path'; -import { tmpdir, platform } from 'node:os'; +import { tmpdir, platform, homedir } from 'node:os'; import { createRequire } from 'node:module'; import type { ChromeCookieResult } from './chrome-cookies.js'; import { getBrowser, browserUserDataDir } from './browsers.js'; @@ -64,64 +64,189 @@ export function ensureFirefoxCookieBackendAvailable( // ── Profile detection ──────────────────────────────────────────────────────── -function firefoxBaseDir(): string { - const dir = browserUserDataDir(getBrowser('firefox')); - if (dir) return dir; +interface FirefoxProfileEntry { + name: string | null; + path: string; + isRelative: boolean; + isDefault: boolean; + installDefault: boolean; +} + +interface FirefoxProfileCandidate { + dir: string; + name: string | null; + isDefault: boolean; + installDefault: boolean; + modifiedMs: number; +} + +const FIREFOX_EXTRA_ROOTS: Record = { + darwin: [], + linux: [ + '.config/mozilla/firefox', + 'snap/firefox/common/.mozilla/firefox', + '.var/app/org.mozilla.firefox/.mozilla/firefox', + ], + win32: [], +}; + +function firefoxBaseDirs(): string[] { + const os = platform(); + const home = homedir(); + const browserDir = browserUserDataDir(getBrowser('firefox')); + const extraRoots = (FIREFOX_EXTRA_ROOTS[os] ?? []).map((relative) => join(home, relative)); + const candidates = [browserDir, ...extraRoots].filter((value): value is string => Boolean(value)); + if (candidates.length > 0) return [...new Set(candidates)]; + throw new Error( - `Firefox cookie extraction is not supported on this platform (detected: ${platform()}).\n` + + `Firefox cookie extraction is not supported on this platform (detected: ${os}).\n` + 'Pass cookies manually: ft sync --cookies ' ); } -export function detectFirefoxProfileDir(): string { - const base = firefoxBaseDir(); - const iniPath = join(base, 'profiles.ini'); +function parseFirefoxProfilesIni(ini: string): FirefoxProfileEntry[] { + const sections = ini + .split(/\r?\n(?=\[)/) + .map((section) => section.trim()) + .filter(Boolean); + + const installDefaults = new Set(); + const profiles: FirefoxProfileEntry[] = []; + + for (const section of sections) { + const lines = section.split(/\r?\n/).map((line) => line.trim()).filter(Boolean); + const header = lines[0]?.match(/^\[([^\]]+)\]$/)?.[1] ?? null; + if (!header) continue; + + const values: Record = {}; + for (const line of lines.slice(1)) { + if (line.startsWith(';')) continue; + const equals = line.indexOf('='); + if (equals <= 0) continue; + values[line.slice(0, equals).trim()] = line.slice(equals + 1).trim(); + } - if (!existsSync(iniPath)) { - throw new Error( - 'Firefox profiles.ini not found.\n' + - `Is Firefox installed? Expected: ${iniPath}` - ); + if (header.startsWith('Install') && values.Default) { + installDefaults.add(values.Default); + continue; + } + + if (!header.startsWith('Profile') || !values.Path) continue; + profiles.push({ + name: values.Name ?? null, + path: values.Path, + isRelative: values.IsRelative !== '0', + isDefault: values.Default === '1', + installDefault: false, + }); } - const ini = readFileSync(iniPath, 'utf8'); - const profiles: { name: string; path: string; isRelative: boolean }[] = []; - let current: { name?: string; path?: string; isRelative?: boolean } = {}; - - for (const line of ini.split('\n')) { - const trimmed = line.trim(); - if (trimmed.startsWith('[Profile')) { - if (current.path) profiles.push(current as any); - current = {}; - } else if (trimmed.startsWith('Name=')) { - current.name = trimmed.slice(5); - } else if (trimmed.startsWith('Path=')) { - current.path = trimmed.slice(5); - } else if (trimmed.startsWith('IsRelative=')) { - current.isRelative = trimmed.slice(11) === '1'; - } + return profiles.map((profile) => ({ + ...profile, + installDefault: installDefaults.has(profile.path), + })); +} + +function firefoxProfileScore(candidate: FirefoxProfileCandidate): number { + let score = 0; + if (candidate.installDefault) score += 1000; + if (candidate.isDefault) score += 500; + + const name = (candidate.name ?? '').toLowerCase(); + const base = basename(candidate.dir).toLowerCase(); + if (name === 'default-release') score += 200; + else if (name === 'default') score += 150; + else if (name.includes('default')) score += 100; + + if (base.includes('default-release')) score += 80; + else if (base.includes('default')) score += 40; + return score; +} + +function firefoxProfileModifiedMs(profileDir: string): number { + try { + return statSync(join(profileDir, 'cookies.sqlite')).mtimeMs; + } catch { + return 0; } - if (current.path) profiles.push(current as any); +} - const resolve = (p: { path: string; isRelative: boolean }) => - p.isRelative ? join(base, p.path) : p.path; +function collectFirefoxProfileCandidates(root: string): FirefoxProfileCandidate[] { + const candidates: FirefoxProfileCandidate[] = []; + const seen = new Set(); + const addCandidate = ( + dir: string, + details: { name?: string | null; isDefault?: boolean; installDefault?: boolean } = {}, + ): void => { + const cookiesPath = join(dir, 'cookies.sqlite'); + if (!existsSync(cookiesPath) || seen.has(dir)) return; + seen.add(dir); + candidates.push({ + dir, + name: details.name ?? null, + isDefault: details.isDefault ?? false, + installDefault: details.installDefault ?? false, + modifiedMs: firefoxProfileModifiedMs(dir), + }); + }; - // Prefer default-release, then any profile with cookies.sqlite - const defaultRelease = profiles.find(p => p.name === 'default-release'); - if (defaultRelease) { - const dir = resolve(defaultRelease); - if (existsSync(join(dir, 'cookies.sqlite'))) return dir; + const iniPath = join(root, 'profiles.ini'); + if (existsSync(iniPath)) { + const ini = readFileSync(iniPath, 'utf8'); + for (const profile of parseFirefoxProfilesIni(ini)) { + const dir = profile.isRelative ? join(root, profile.path) : profile.path; + addCandidate(dir, profile); + } } - for (const p of profiles) { - const dir = resolve(p); - if (existsSync(join(dir, 'cookies.sqlite'))) return dir; + try { + for (const entry of readdirSync(root, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + addCandidate(join(root, entry.name)); + } + } catch { + // Ignore unreadable roots and rely on other candidates. } - throw new Error( - 'No Firefox profile with cookies.sqlite found.\n' + - 'Open Firefox and log into x.com first, then retry.' + return candidates; +} + +function buildFirefoxProfileDiscoveryError(roots: string[], sawProfilesIni: boolean): Error { + const checked = roots.map((root) => ` ${root}`).join('\n'); + return new Error( + (sawProfilesIni + ? 'No Firefox profile with cookies.sqlite found in the standard profile roots.\n' + : 'Firefox profiles.ini was not found in the standard profile roots.\n') + + `Checked:\n${checked}\n` + + 'If auto-detect missed your profile, pass it explicitly with --firefox-profile-dir .' + ); +} + +export function listFirefoxProfileDirs(): string[] { + const roots = firefoxBaseDirs(); + const candidates: FirefoxProfileCandidate[] = []; + + for (const root of roots) { + if (!existsSync(root)) continue; + candidates.push(...collectFirefoxProfileCandidates(root)); + } + + candidates.sort((a, b) => + firefoxProfileScore(b) - firefoxProfileScore(a) + || b.modifiedMs - a.modifiedMs + || a.dir.localeCompare(b.dir) ); + + return candidates.map((candidate) => candidate.dir); +} + +export function detectFirefoxProfileDir(): string { + const roots = firefoxBaseDirs(); + const dirs = listFirefoxProfileDirs(); + if (dirs.length > 0) return dirs[0]; + + const sawProfilesIni = roots.some((root) => existsSync(join(root, 'profiles.ini'))); + throw buildFirefoxProfileDiscoveryError(roots, sawProfilesIni); } // ── Cookie query ───────────────────────────────────────────────────────────── @@ -155,34 +280,64 @@ function createFirefoxSnapshot(dbPath: string): { snapshotPath: string; cleanup: } } -function queryWithNodeSqlite( +function buildFirefoxReadError(dbPath: string, error: unknown, recoveryHint: string): Error { + const message = error instanceof Error ? error.message : String(error); + const needsNativeSqliteHint = + platform() === 'win32' && + !loadNodeSqlite() && + /sqlite3|ENOENT/i.test(message); + return new Error( + `Could not read Firefox cookies database.\n` + + `Path: ${dbPath}\n` + + `Error: ${message}\n` + + (needsNativeSqliteHint + ? 'Fix: Use Node.js 22.5+ on Windows, or install sqlite3 on PATH.\n' + : '') + + recoveryHint + ); +} + +function queryFirefoxSqlWithNodeSqlite( snapshotPath: string, - host: string, - names: string[], -): { name: string; value: string }[] | null { + sql: string, + mapRow: (row: SqliteRow) => T, +): T[] | null { const sqlite = loadNodeSqlite(); if (!sqlite) return null; const db = new sqlite.DatabaseSync(snapshotPath, { readOnly: true }); try { - const placeholders = names.map(() => '?').join(', '); - const stmt = db.prepare( - `SELECT name, value FROM moz_cookies WHERE host LIKE ? AND name IN (${placeholders});` - ); - return stmt.all(`%${host}`, ...names).map((row) => ({ - name: String(row.name ?? ''), - value: String(row.value ?? ''), - })); + return db.prepare(sql).all().map(mapRow); } finally { db.close(); } } -function queryFirefoxCookies( +function queryFirefoxSqlWithSqlite3( + snapshotPath: string, + sql: string, + mapRow: (row: SqliteRow) => T, +): T[] { + const output = execFileSync('sqlite3', ['-json', snapshotPath, sql], { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], + timeout: 10000, + }).trim(); + + if (!output || output === '[]') return []; + try { + return (JSON.parse(output) as SqliteRow[]).map(mapRow); + } catch { + return []; + } +} + +export function queryFirefoxSqlRows( dbPath: string, - host: string, - names: string[], -): { name: string; value: string }[] { + sql: string, + mapRow: (row: SqliteRow) => T, + recoveryHint: string = 'If Firefox is open, try closing it and retrying.', +): T[] { if (!existsSync(dbPath)) { throw new Error( `Firefox cookies.sqlite not found at: ${dbPath}\n` + @@ -190,54 +345,36 @@ function queryFirefoxCookies( ); } - const safeHost = host.replace(/'/g, "''"); - const nameList = names.map(n => `'${n.replace(/'/g, "''")}'`).join(','); - const sql = `SELECT name, value FROM moz_cookies WHERE host LIKE '%${safeHost}' AND name IN (${nameList});`; - const tryQueryWithBinary = (path: string): string => - execFileSync('sqlite3', ['-json', path, sql], { - encoding: 'utf8', - stdio: ['pipe', 'pipe', 'pipe'], - timeout: 10000, - }).trim(); - - const buildReadError = (error: unknown): Error => { - const message = error instanceof Error ? error.message : String(error); - const needsNativeSqliteHint = - platform() === 'win32' && - !loadNodeSqlite() && - /sqlite3|ENOENT/i.test(message); - return new Error( - `Could not read Firefox cookies database.\n` + - `Path: ${dbPath}\n` + - `Error: ${message}\n` + - (needsNativeSqliteHint - ? 'Fix: Use Node.js 22.5+ on Windows, or install sqlite3 on PATH.\n' - : '') + - 'If Firefox is open, try closing it and retrying.' - ); - }; - + const { snapshotPath, cleanup } = createFirefoxSnapshot(dbPath); try { - const { snapshotPath, cleanup } = createFirefoxSnapshot(dbPath); - try { - const nativeRows = queryWithNodeSqlite(snapshotPath, host, names); - if (nativeRows) return nativeRows; - - const output = tryQueryWithBinary(snapshotPath); - if (!output || output === '[]') return []; - try { - return JSON.parse(output); - } catch { - return []; - } - } finally { - cleanup(); - } + const nativeRows = queryFirefoxSqlWithNodeSqlite(snapshotPath, sql, mapRow); + if (nativeRows) return nativeRows; + return queryFirefoxSqlWithSqlite3(snapshotPath, sql, mapRow); } catch (error) { - throw buildReadError(error); + throw buildFirefoxReadError(dbPath, error, recoveryHint); + } finally { + cleanup(); } } +function queryFirefoxCookies( + dbPath: string, + host: string, + names: string[], +): { name: string; value: string }[] { + const safeHost = host.replace(/'/g, "''"); + const nameList = names.map((n) => `'${n.replace(/'/g, "''")}'`).join(','); + const sql = `SELECT name, value FROM moz_cookies WHERE host LIKE '%${safeHost}' AND name IN (${nameList});`; + return queryFirefoxSqlRows( + dbPath, + sql, + (row) => ({ + name: String(row.name ?? ''), + value: String(row.value ?? ''), + }), + ); +} + // ── Main export ────────────────────────────────────────────────────────────── export function extractFirefoxXCookies(profileDir?: string): ChromeCookieResult { @@ -260,26 +397,26 @@ export function extractFirefoxXCookies(profileDir?: string): ChromeCookieResult 'This means you are not logged into X in Firefox.\n\n' + 'Fix:\n' + ' 1. Open Firefox\n' + - ' 2. Go to https://x.com and log in\n' + - ' 3. Re-run this command' + ' 2. Log into x.com\n' + + ' 3. Retry: ft sync --browser firefox\n\n' + + `Checked profile: ${dir}` ); } - // Validate cookie values are printable ASCII (same check as Chrome path) - const validateCookie = (name: string, value: string): string => { - const cleaned = value.trim(); - if (!cleaned || !/^[\x21-\x7E]+$/.test(cleaned)) { - throw new Error( - `Firefox ${name} cookie appears invalid.\n` + - 'Try clearing Firefox cookies for x.com and logging in again.' - ); - } - return cleaned; - }; - - const cleanCt0 = validateCookie('ct0', ct0); - const cookieParts = [`ct0=${cleanCt0}`]; - if (authToken) cookieParts.push(`auth_token=${validateCookie('auth_token', authToken)}`); + if (!authToken) { + throw new Error( + 'No auth_token cookie found for x.com in Firefox.\n' + + 'This means Firefox has a partial/expired X session.\n\n' + + 'Fix:\n' + + ' 1. Open Firefox\n' + + ' 2. Log out of x.com and log back in\n' + + ' 3. Retry: ft sync --browser firefox\n\n' + + `Checked profile: ${dir}` + ); + } - return { csrfToken: cleanCt0, cookieHeader: cookieParts.join('; ') }; + return { + cookieHeader: `ct0=${ct0}; auth_token=${authToken}`, + csrfToken: ct0, + }; } diff --git a/tests/firefox-profile-detection.test.ts b/tests/firefox-profile-detection.test.ts new file mode 100644 index 0000000..272fb9f --- /dev/null +++ b/tests/firefox-profile-detection.test.ts @@ -0,0 +1,107 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { createDb, saveDb } from '../src/db.js'; + +function setHomeEnv(homeDir: string): () => void { + const oldHome = process.env.HOME; + const oldUserProfile = process.env.USERPROFILE; + process.env.HOME = homeDir; + process.env.USERPROFILE = homeDir; + return () => { + if (oldHome === undefined) delete process.env.HOME; + else process.env.HOME = oldHome; + if (oldUserProfile === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = oldUserProfile; + }; +} + +async function createFirefoxProfile(profileDir: string): Promise { + fs.mkdirSync(profileDir, { recursive: true }); + const dbPath = path.join(profileDir, 'cookies.sqlite'); + const db = await createDb(); + db.run(` + CREATE TABLE moz_cookies ( + id INTEGER PRIMARY KEY, + host TEXT, + name TEXT, + value TEXT, + path TEXT, + expiry INTEGER, + isSecure INTEGER, + isHttpOnly INTEGER, + inBrowserElement INTEGER, + sameSite INTEGER, + rawSameSite INTEGER, + schemeMap INTEGER, + lastAccessed INTEGER, + creationTime INTEGER + ); + `); + saveDb(db, dbPath); + db.close(); +} + +test('listFirefoxProfileDirs prefers install-default profile roots before stale alternatives', async () => { + if (process.platform !== 'linux') return; + + const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ft-firefox-detect-home-')); + const legacyRoot = path.join(homeDir, '.mozilla', 'firefox'); + const configRoot = path.join(homeDir, '.config', 'mozilla', 'firefox'); + fs.mkdirSync(legacyRoot, { recursive: true }); + fs.mkdirSync(configRoot, { recursive: true }); + + await createFirefoxProfile(path.join(legacyRoot, 'wrong.default')); + fs.writeFileSync( + path.join(legacyRoot, 'profiles.ini'), + ['[Profile0]', 'Name=default', 'IsRelative=1', 'Path=wrong.default', 'Default=1', ''].join('\n'), + 'utf8', + ); + + await createFirefoxProfile(path.join(configRoot, 'chosen.default-release')); + fs.writeFileSync( + path.join(configRoot, 'profiles.ini'), + [ + '[InstallTEST]', + 'Default=chosen.default-release', + 'Locked=1', + '', + '[Profile0]', + 'Name=default-release', + 'IsRelative=1', + 'Path=chosen.default-release', + '', + ].join('\n'), + 'utf8', + ); + + const restore = setHomeEnv(homeDir); + try { + const { listFirefoxProfileDirs, detectFirefoxProfileDir } = await import('../src/firefox-cookies.js'); + const dirs = listFirefoxProfileDirs(); + assert.equal(dirs[0], path.join(configRoot, 'chosen.default-release')); + assert.equal(detectFirefoxProfileDir(), path.join(configRoot, 'chosen.default-release')); + } finally { + restore(); + fs.rmSync(homeDir, { recursive: true, force: true }); + } +}); + +test('detectFirefoxProfileDir explains checked roots when no profiles are found', async () => { + if (process.platform !== 'linux') return; + + const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ft-firefox-detect-empty-')); + const restore = setHomeEnv(homeDir); + try { + const { detectFirefoxProfileDir } = await import('../src/firefox-cookies.js'); + assert.throws( + () => detectFirefoxProfileDir(), + /Checked:[\s\S]*--firefox-profile-dir /i, + ); + } finally { + restore(); + fs.rmSync(homeDir, { recursive: true, force: true }); + } +}); From e8f60b6463b6b7e358583a841ae168e949240ef5 Mon Sep 17 00:00:00 2001 From: joseph Rosenbaum Date: Tue, 14 Apr 2026 22:19:10 -0500 Subject: [PATCH 3/4] refactor: add reusable core status and path helpers --- src/bookmarks-service.ts | 19 +++++---- src/namespace-paths.ts | 20 ++++++++++ src/status-render.ts | 26 ++++++++++++ tests/namespace-helpers.test.ts | 70 +++++++++++++++++++++++++++++++++ 4 files changed, 128 insertions(+), 7 deletions(-) create mode 100644 src/namespace-paths.ts create mode 100644 src/status-render.ts create mode 100644 tests/namespace-helpers.test.ts diff --git a/src/bookmarks-service.ts b/src/bookmarks-service.ts index 1db4811..201f551 100644 --- a/src/bookmarks-service.ts +++ b/src/bookmarks-service.ts @@ -2,6 +2,7 @@ import { getTwitterBookmarksStatus, latestBookmarkSyncAt } from './bookmarks.js' import { buildIndex } from './bookmarks-db.js'; import { loadTwitterOAuthToken } from './xauth.js'; import { syncBookmarksGraphQL, type SyncProgress } from './graphql-bookmarks.js'; +import { renderStatusSections } from './status-render.js'; export interface BookmarkEnableResult { synced: boolean; @@ -59,13 +60,17 @@ export async function getBookmarkStatusView(): Promise { } export function formatBookmarkStatus(view: BookmarkStatusView): string { - return [ - 'Bookmarks', - ` bookmarks: ${view.bookmarkCount}`, - ` last updated: ${view.lastUpdated ?? 'never'}`, - ` sync mode: ${view.mode}`, - ` cache: ${view.cachePath}`, - ].join('\n'); + return renderStatusSections([ + { + title: 'Bookmarks', + lines: [ + { label: 'bookmarks:', value: String(view.bookmarkCount) }, + { label: 'last updated:', value: view.lastUpdated ?? 'never' }, + { label: 'sync mode:', value: view.mode }, + { label: 'cache:', value: view.cachePath }, + ], + }, + ]); } export function formatBookmarkSummary(view: BookmarkStatusView): string { diff --git a/src/namespace-paths.ts b/src/namespace-paths.ts new file mode 100644 index 0000000..b7dfb34 --- /dev/null +++ b/src/namespace-paths.ts @@ -0,0 +1,20 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { dataDir } from './paths.js'; + +export function resolveNamespaceDataDir(primaryEnv: string, legacyEnv: string, subdir: string): string { + const primaryOverride = process.env[primaryEnv]; + if (primaryOverride) return primaryOverride; + + const legacyOverride = process.env[legacyEnv]; + if (legacyOverride) return legacyOverride; + + return path.join(dataDir(), subdir); +} + +export function ensureNamespaceDataDir(dir: string): string { + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); + } + return dir; +} diff --git a/src/status-render.ts b/src/status-render.ts new file mode 100644 index 0000000..8b9d53a --- /dev/null +++ b/src/status-render.ts @@ -0,0 +1,26 @@ +export interface StatusLine { + label: string; + value: string; +} + +export interface StatusSection { + title?: string; + lines: StatusLine[]; +} + +function formatStatusLine(label: string, value: string): string { + return ` ${label.padEnd(14)} ${value}`; +} + +export function renderStatusSections(sections: StatusSection[]): string { + const chunks: string[] = []; + + for (const section of sections) { + if (section.title) chunks.push(section.title); + for (const line of section.lines) { + chunks.push(formatStatusLine(line.label, line.value)); + } + } + + return `\n${chunks.join('\n')}`; +} diff --git a/tests/namespace-helpers.test.ts b/tests/namespace-helpers.test.ts new file mode 100644 index 0000000..a1add50 --- /dev/null +++ b/tests/namespace-helpers.test.ts @@ -0,0 +1,70 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { ensureNamespaceDataDir, resolveNamespaceDataDir } from '../src/namespace-paths.js'; +import { renderStatusSections } from '../src/status-render.js'; + +test('resolveNamespaceDataDir prefers primary env, then legacy env, then dataDir subdir', () => { + const oldRoot = process.env.FT_DATA_DIR; + const oldPrimary = process.env.FT_TEST_PRIMARY_DIR; + const oldLegacy = process.env.FT_TEST_LEGACY_DIR; + + process.env.FT_DATA_DIR = '/tmp/ft-root'; + delete process.env.FT_TEST_PRIMARY_DIR; + delete process.env.FT_TEST_LEGACY_DIR; + assert.equal(resolveNamespaceDataDir('FT_TEST_PRIMARY_DIR', 'FT_TEST_LEGACY_DIR', 'demo'), '/tmp/ft-root/demo'); + + process.env.FT_TEST_LEGACY_DIR = '/tmp/legacy-demo'; + assert.equal(resolveNamespaceDataDir('FT_TEST_PRIMARY_DIR', 'FT_TEST_LEGACY_DIR', 'demo'), '/tmp/legacy-demo'); + + process.env.FT_TEST_PRIMARY_DIR = '/tmp/primary-demo'; + assert.equal(resolveNamespaceDataDir('FT_TEST_PRIMARY_DIR', 'FT_TEST_LEGACY_DIR', 'demo'), '/tmp/primary-demo'); + + if (oldRoot === undefined) delete process.env.FT_DATA_DIR; else process.env.FT_DATA_DIR = oldRoot; + if (oldPrimary === undefined) delete process.env.FT_TEST_PRIMARY_DIR; else process.env.FT_TEST_PRIMARY_DIR = oldPrimary; + if (oldLegacy === undefined) delete process.env.FT_TEST_LEGACY_DIR; else process.env.FT_TEST_LEGACY_DIR = oldLegacy; +}); + +test('resolveNamespaceDataDir ignores empty-string overrides', () => { + const oldRoot = process.env.FT_DATA_DIR; + const oldPrimary = process.env.FT_TEST_PRIMARY_DIR; + const oldLegacy = process.env.FT_TEST_LEGACY_DIR; + + process.env.FT_DATA_DIR = '/tmp/ft-root'; + process.env.FT_TEST_PRIMARY_DIR = ''; + process.env.FT_TEST_LEGACY_DIR = ''; + + assert.equal(resolveNamespaceDataDir('FT_TEST_PRIMARY_DIR', 'FT_TEST_LEGACY_DIR', 'demo'), '/tmp/ft-root/demo'); + + if (oldRoot === undefined) delete process.env.FT_DATA_DIR; else process.env.FT_DATA_DIR = oldRoot; + if (oldPrimary === undefined) delete process.env.FT_TEST_PRIMARY_DIR; else process.env.FT_TEST_PRIMARY_DIR = oldPrimary; + if (oldLegacy === undefined) delete process.env.FT_TEST_LEGACY_DIR; else process.env.FT_TEST_LEGACY_DIR = oldLegacy; +}); + +test('ensureNamespaceDataDir creates missing directory and returns it', () => { + const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ft-namespace-dir-')); + const target = path.join(tmpRoot, 'nested', 'demo'); + try { + assert.equal(fs.existsSync(target), false); + assert.equal(ensureNamespaceDataDir(target), target); + assert.equal(fs.existsSync(target), true); + } finally { + fs.rmSync(tmpRoot, { recursive: true, force: true }); + } +}); + +test('renderStatusSections renders titled padded status blocks', () => { + const rendered = renderStatusSections([ + { + title: 'Demo', + lines: [ + { label: 'alpha:', value: 'one' }, + { label: 'beta:', value: 'two' }, + ], + }, + ]); + + assert.equal(rendered, '\nDemo\n alpha: one\n beta: two'); +}); From 7dcdc2088dd24fbda08e915ae1e50a750e258d41 Mon Sep 17 00:00:00 2001 From: joseph Rosenbaum Date: Thu, 7 May 2026 03:22:30 -0500 Subject: [PATCH 4/4] fix: restore ct0 + auth_token cookie validation in Firefox extractor Accidentally dropped validateCookie() during the auth_token required-check refactor. Restore printable-ASCII validation and whitespace trimming for both ct0 and auth_token before building the cookie header. Addresses review feedback on PR #101. --- src/firefox-cookies.ts | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/firefox-cookies.ts b/src/firefox-cookies.ts index e1009e6..321ea66 100644 --- a/src/firefox-cookies.ts +++ b/src/firefox-cookies.ts @@ -415,8 +415,22 @@ export function extractFirefoxXCookies(profileDir?: string): ChromeCookieResult ); } + const validateCookie = (name: string, value: string): string => { + const cleaned = value.trim(); + if (!cleaned || !/^[\x21-\x7E]+$/.test(cleaned)) { + throw new Error( + `Firefox ${name} cookie appears invalid.\n` + + 'Try clearing Firefox cookies for x.com and logging in again.' + ); + } + return cleaned; + }; + + const cleanCt0 = validateCookie('ct0', ct0); + const cleanAuthToken = validateCookie('auth_token', authToken); + return { - cookieHeader: `ct0=${ct0}; auth_token=${authToken}`, - csrfToken: ct0, + cookieHeader: `ct0=${cleanCt0}; auth_token=${cleanAuthToken}`, + csrfToken: cleanCt0, }; }