fieldtheory 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,190 @@
1
+ import { pathExists, readJson, readJsonLines, writeJson, writeJsonLines } from './fs.js';
2
+ import { ensureDataDir, twitterBookmarksCachePath, twitterBookmarksMetaPath } from './paths.js';
3
+ import { loadTwitterOAuthToken } from './xauth.js';
4
+ function makeBookmark(record) {
5
+ return {
6
+ id: record.id,
7
+ tweetId: record.tweetId,
8
+ url: record.url,
9
+ text: record.text,
10
+ authorHandle: record.authorHandle,
11
+ authorName: record.authorName,
12
+ bookmarkedAt: record.bookmarkedAt,
13
+ syncedAt: record.syncedAt ?? new Date().toISOString(),
14
+ media: record.media ?? [],
15
+ links: record.links ?? [],
16
+ tags: record.tags ?? [],
17
+ };
18
+ }
19
+ async function fetchJsonWithUserToken(url, accessToken) {
20
+ const response = await fetch(url, {
21
+ headers: {
22
+ Authorization: `Bearer ${accessToken}`,
23
+ 'Content-Type': 'application/json',
24
+ },
25
+ });
26
+ const text = await response.text();
27
+ let parsed = null;
28
+ try {
29
+ parsed = JSON.parse(text);
30
+ }
31
+ catch {
32
+ parsed = null;
33
+ }
34
+ return {
35
+ ok: response.ok,
36
+ status: response.status,
37
+ parsed,
38
+ text,
39
+ };
40
+ }
41
+ async function fetchCurrentUserId(accessToken) {
42
+ const result = await fetchJsonWithUserToken('https://api.x.com/2/users/me', accessToken);
43
+ if (!result.ok) {
44
+ return {
45
+ ok: false,
46
+ status: result.status,
47
+ detail: result.parsed ? JSON.stringify(result.parsed) : result.text,
48
+ };
49
+ }
50
+ const id = result.parsed?.data?.id;
51
+ if (!id) {
52
+ return {
53
+ ok: false,
54
+ status: result.status,
55
+ detail: 'Could not find user id in /2/users/me response',
56
+ };
57
+ }
58
+ return {
59
+ ok: true,
60
+ id: String(id),
61
+ status: result.status,
62
+ detail: 'Resolved current user id',
63
+ };
64
+ }
65
+ function normalizeBookmarkPage(page, syncedAt) {
66
+ const userMap = new Map();
67
+ for (const user of page.includes?.users ?? []) {
68
+ userMap.set(String(user.id), { username: user.username, name: user.name });
69
+ }
70
+ return (page.data ?? []).map((tweet) => {
71
+ const user = tweet.author_id ? userMap.get(String(tweet.author_id)) : undefined;
72
+ const tweetId = String(tweet.id);
73
+ return makeBookmark({
74
+ id: tweetId,
75
+ tweetId,
76
+ url: `https://x.com/${user?.username ?? 'i'}/status/${tweetId}`,
77
+ text: tweet.text ?? '',
78
+ authorHandle: user?.username,
79
+ authorName: user?.name,
80
+ bookmarkedAt: tweet.created_at,
81
+ syncedAt,
82
+ links: (tweet.entities?.urls ?? []).map((u) => u.expanded_url ?? u.url ?? '').filter(Boolean),
83
+ });
84
+ });
85
+ }
86
+ async function fetchBookmarksPage(accessToken, userId, nextToken) {
87
+ const url = new URL(`https://api.x.com/2/users/${userId}/bookmarks`);
88
+ url.searchParams.set('max_results', '100');
89
+ url.searchParams.set('tweet.fields', 'created_at,author_id,entities');
90
+ url.searchParams.set('expansions', 'author_id');
91
+ url.searchParams.set('user.fields', 'username,name');
92
+ if (nextToken)
93
+ url.searchParams.set('pagination_token', nextToken);
94
+ const result = await fetchJsonWithUserToken(url.toString(), accessToken);
95
+ if (!result.ok) {
96
+ return {
97
+ ok: false,
98
+ status: result.status,
99
+ detail: result.parsed ? JSON.stringify(result.parsed) : result.text,
100
+ requestUrl: url.toString(),
101
+ };
102
+ }
103
+ return {
104
+ ok: true,
105
+ status: result.status,
106
+ detail: 'ok',
107
+ page: result.parsed,
108
+ requestUrl: url.toString(),
109
+ };
110
+ }
111
+ export async function syncTwitterBookmarks(mode, options = {}) {
112
+ const token = await loadTwitterOAuthToken();
113
+ if (!token?.access_token) {
114
+ throw new Error('Missing user-context OAuth token. Run: ft auth');
115
+ }
116
+ const me = await fetchCurrentUserId(token.access_token);
117
+ if (!me.ok || !me.id) {
118
+ throw new Error(`Could not resolve current user id: ${me.detail}`);
119
+ }
120
+ ensureDataDir();
121
+ const cachePath = twitterBookmarksCachePath();
122
+ const metaPath = twitterBookmarksMetaPath();
123
+ const now = new Date().toISOString();
124
+ const existing = await readJsonLines(cachePath);
125
+ const existingById = new Map(existing.map((item) => [item.id, item]));
126
+ const allFetched = [];
127
+ let nextToken;
128
+ let pages = 0;
129
+ const maxPages = mode === 'full' ? 20 : 2;
130
+ while (pages < maxPages) {
131
+ const pageResult = await fetchBookmarksPage(token.access_token, me.id, nextToken);
132
+ if (!pageResult.ok || !pageResult.page) {
133
+ throw new Error(`Bookmark fetch failed (${pageResult.status}): ${pageResult.detail}`);
134
+ }
135
+ const normalized = normalizeBookmarkPage(pageResult.page, now);
136
+ allFetched.push(...normalized);
137
+ nextToken = pageResult.page.meta?.next_token;
138
+ pages += 1;
139
+ if (!nextToken)
140
+ break;
141
+ if (mode === 'incremental' && normalized.every((item) => existingById.has(item.id)))
142
+ break;
143
+ if (typeof options.targetAdds === 'number') {
144
+ const uniqueAddsSoFar = allFetched.filter((item, index, arr) => arr.findIndex((x) => x.id === item.id) === index).filter((item) => !existingById.has(item.id)).length;
145
+ if (uniqueAddsSoFar >= options.targetAdds)
146
+ break;
147
+ }
148
+ }
149
+ const merged = [...existing];
150
+ let added = 0;
151
+ for (const record of allFetched) {
152
+ if (!existingById.has(record.id)) {
153
+ merged.push(record);
154
+ existingById.set(record.id, record);
155
+ added += 1;
156
+ if (typeof options.targetAdds === 'number' && added >= options.targetAdds)
157
+ break;
158
+ }
159
+ }
160
+ merged.sort((a, b) => String(b.bookmarkedAt ?? b.syncedAt).localeCompare(String(a.bookmarkedAt ?? a.syncedAt)));
161
+ await writeJsonLines(cachePath, merged);
162
+ const previousMeta = (await pathExists(metaPath)) ? await readJson(metaPath) : undefined;
163
+ const meta = {
164
+ provider: 'twitter',
165
+ schemaVersion: 1,
166
+ lastFullSyncAt: mode === 'full' ? now : previousMeta?.lastFullSyncAt,
167
+ lastIncrementalSyncAt: mode === 'incremental' ? now : previousMeta?.lastIncrementalSyncAt,
168
+ totalBookmarks: merged.length,
169
+ };
170
+ await writeJson(metaPath, meta);
171
+ return {
172
+ mode,
173
+ totalBookmarks: merged.length,
174
+ added,
175
+ cachePath,
176
+ metaPath,
177
+ };
178
+ }
179
+ export async function getTwitterBookmarksStatus() {
180
+ const cachePath = twitterBookmarksCachePath();
181
+ const metaPath = twitterBookmarksMetaPath();
182
+ const meta = (await pathExists(metaPath))
183
+ ? await readJson(metaPath)
184
+ : { provider: 'twitter', schemaVersion: 1, totalBookmarks: 0 };
185
+ return {
186
+ ...meta,
187
+ cachePath,
188
+ metaPath,
189
+ };
190
+ }
@@ -0,0 +1,146 @@
1
+ import { execSync } from 'node:child_process';
2
+ import { existsSync, unlinkSync, copyFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { tmpdir, platform } from 'node:os';
5
+ import { pbkdf2Sync, createDecipheriv, randomUUID } from 'node:crypto';
6
+ function getMacOSChromeKey() {
7
+ const candidates = [
8
+ { service: 'Chrome Safe Storage', account: 'Chrome' },
9
+ { service: 'Chrome Safe Storage', account: 'Google Chrome' },
10
+ { service: 'Google Chrome Safe Storage', account: 'Chrome' },
11
+ { service: 'Google Chrome Safe Storage', account: 'Google Chrome' },
12
+ { service: 'Chromium Safe Storage', account: 'Chromium' },
13
+ { service: 'Brave Safe Storage', account: 'Brave' },
14
+ { service: 'Brave Browser Safe Storage', account: 'Brave Browser' },
15
+ ];
16
+ for (const candidate of candidates) {
17
+ try {
18
+ const password = execSync(`security find-generic-password -w -s "${candidate.service}" -a "${candidate.account}"`, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
19
+ if (password) {
20
+ return pbkdf2Sync(password, 'saltysalt', 1003, 16, 'sha1');
21
+ }
22
+ }
23
+ catch {
24
+ // Try the next known browser/keychain naming pair.
25
+ }
26
+ }
27
+ throw new Error('Could not read a browser Safe Storage password from the macOS Keychain.\n' +
28
+ 'This is needed to decrypt Chrome-family cookies.\n' +
29
+ 'Fix: open the browser profile that is logged into X, then retry.\n' +
30
+ 'If you already use the API flow, prefer: ft sync --api');
31
+ }
32
+ function sanitizeCookieValue(name, value) {
33
+ const cleaned = value.replace(/\0+$/g, '').trim();
34
+ if (!cleaned) {
35
+ throw new Error(`Cookie ${name} was empty after decryption.`);
36
+ }
37
+ if (!/^[\x21-\x7E]+$/.test(cleaned)) {
38
+ throw new Error(`Could not decrypt the ${name} cookie into a valid ASCII header value.\n` +
39
+ 'This usually means the wrong browser profile was selected or the cookie format changed.\n' +
40
+ 'Try a different Chrome profile, or use: ft sync --api');
41
+ }
42
+ return cleaned;
43
+ }
44
+ export function decryptCookieValue(encryptedValue, key) {
45
+ if (encryptedValue.length === 0)
46
+ return '';
47
+ if (encryptedValue[0] === 0x76 && encryptedValue[1] === 0x31 && encryptedValue[2] === 0x30) {
48
+ const iv = Buffer.alloc(16, 0x20); // 16 spaces
49
+ const ciphertext = encryptedValue.subarray(3);
50
+ const decipher = createDecipheriv('aes-128-cbc', key, iv);
51
+ let decrypted = decipher.update(ciphertext);
52
+ decrypted = Buffer.concat([decrypted, decipher.final()]);
53
+ return decrypted.toString('utf8');
54
+ }
55
+ return encryptedValue.toString('utf8');
56
+ }
57
+ function queryCookies(dbPath, domain, names) {
58
+ if (!existsSync(dbPath)) {
59
+ throw new Error(`Chrome Cookies database not found at: ${dbPath}\n` +
60
+ 'Fix: Make sure Google Chrome is installed and has been opened at least once.\n' +
61
+ 'If you use a non-default Chrome profile, pass --chrome-profile-directory <name>.');
62
+ }
63
+ const safeDomain = domain.replace(/'/g, "''");
64
+ const nameList = names.map(n => `'${n.replace(/'/g, "''")}'`).join(',');
65
+ const sql = `SELECT name, hex(encrypted_value) as encrypted_value_hex, value FROM cookies WHERE host_key LIKE '%${safeDomain}' AND name IN (${nameList});`;
66
+ const tryQuery = (path) => execSync(`sqlite3 -json "${path}" "${sql}"`, {
67
+ encoding: 'utf8',
68
+ stdio: ['pipe', 'pipe', 'pipe'],
69
+ timeout: 10000,
70
+ }).trim();
71
+ let output;
72
+ try {
73
+ output = tryQuery(dbPath);
74
+ }
75
+ catch {
76
+ const tmpDb = join(tmpdir(), `ft-cookies-${randomUUID()}.db`);
77
+ try {
78
+ copyFileSync(dbPath, tmpDb);
79
+ output = tryQuery(tmpDb);
80
+ }
81
+ catch (e2) {
82
+ throw new Error(`Could not read Chrome Cookies database.\n` +
83
+ `Path: ${dbPath}\n` +
84
+ `Error: ${e2.message}\n` +
85
+ 'Fix: If Chrome is open, close it and retry. The database may be locked.');
86
+ }
87
+ finally {
88
+ try {
89
+ unlinkSync(tmpDb);
90
+ }
91
+ catch { }
92
+ }
93
+ }
94
+ if (!output || output === '[]')
95
+ return [];
96
+ try {
97
+ return JSON.parse(output);
98
+ }
99
+ catch {
100
+ return [];
101
+ }
102
+ }
103
+ export function extractChromeXCookies(chromeUserDataDir, profileDirectory = 'Default') {
104
+ const os = platform();
105
+ if (os !== 'darwin') {
106
+ throw new Error(`Direct cookie extraction is currently supported on macOS only.\n` +
107
+ `Detected platform: ${os}\n` +
108
+ 'Fix: Pass --csrf-token and --cookie-header directly, or contribute Linux/Windows support.');
109
+ }
110
+ const dbPath = join(chromeUserDataDir, profileDirectory, 'Cookies');
111
+ const key = getMacOSChromeKey();
112
+ let cookies = queryCookies(dbPath, '.x.com', ['ct0', 'auth_token']);
113
+ if (cookies.length === 0) {
114
+ cookies = queryCookies(dbPath, '.twitter.com', ['ct0', 'auth_token']);
115
+ }
116
+ const decrypted = new Map();
117
+ for (const cookie of cookies) {
118
+ const hexVal = cookie.encrypted_value_hex;
119
+ if (hexVal && hexVal.length > 0) {
120
+ const buf = Buffer.from(hexVal, 'hex');
121
+ decrypted.set(cookie.name, decryptCookieValue(buf, key));
122
+ }
123
+ else if (cookie.value) {
124
+ decrypted.set(cookie.name, cookie.value);
125
+ }
126
+ }
127
+ const ct0 = decrypted.get('ct0');
128
+ const authToken = decrypted.get('auth_token');
129
+ if (!ct0) {
130
+ throw new Error('No ct0 CSRF cookie found for x.com in Chrome.\n' +
131
+ 'This means you are not logged into X in Chrome.\n\n' +
132
+ 'Fix:\n' +
133
+ ' 1. Open Google Chrome\n' +
134
+ ' 2. Go to https://x.com and log in\n' +
135
+ ' 3. Re-run this command\n\n' +
136
+ (profileDirectory !== 'Default'
137
+ ? `Using Chrome profile: "${profileDirectory}"\n`
138
+ : 'Using the Default Chrome profile. If your X login is in a different profile,\n' +
139
+ 'pass --chrome-profile-directory <name> (e.g., "Profile 1").\n'));
140
+ }
141
+ const cookieParts = [`ct0=${sanitizeCookieValue('ct0', ct0)}`];
142
+ if (authToken)
143
+ cookieParts.push(`auth_token=${sanitizeCookieValue('auth_token', authToken)}`);
144
+ const cookieHeader = cookieParts.join('; ');
145
+ return { csrfToken: sanitizeCookieValue('ct0', ct0), cookieHeader };
146
+ }