fieldtheory-cli-windowsport 0.1.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.
- package/LICENSE +21 -0
- package/README.md +161 -0
- package/bin/ft.mjs +15 -0
- package/dist/bookmark-classify-llm.js +247 -0
- package/dist/bookmark-classify.js +223 -0
- package/dist/bookmark-media.js +186 -0
- package/dist/bookmarks-db.js +644 -0
- package/dist/bookmarks-service.js +49 -0
- package/dist/bookmarks-viz.js +597 -0
- package/dist/bookmarks.js +190 -0
- package/dist/chrome-cookies.js +239 -0
- package/dist/cli.js +642 -0
- package/dist/command-path.js +58 -0
- package/dist/config.js +54 -0
- package/dist/db.js +33 -0
- package/dist/fs.js +45 -0
- package/dist/graphql-bookmarks.js +398 -0
- package/dist/paths.js +43 -0
- package/dist/types.js +1 -0
- package/dist/xauth.js +135 -0
- package/package.json +63 -0
|
@@ -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: ftx 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,239 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { copyFileSync, existsSync, readFileSync, unlinkSync } from 'node:fs';
|
|
3
|
+
import { createDecipheriv, createHash, pbkdf2Sync, randomUUID } from 'node:crypto';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { platform, tmpdir } from 'node:os';
|
|
6
|
+
import { openDb } from './db.js';
|
|
7
|
+
function getMacOSChromeKey() {
|
|
8
|
+
const candidates = [
|
|
9
|
+
{ service: 'Chrome Safe Storage', account: 'Chrome' },
|
|
10
|
+
{ service: 'Chrome Safe Storage', account: 'Google Chrome' },
|
|
11
|
+
{ service: 'Google Chrome Safe Storage', account: 'Chrome' },
|
|
12
|
+
{ service: 'Google Chrome Safe Storage', account: 'Google Chrome' },
|
|
13
|
+
{ service: 'Chromium Safe Storage', account: 'Chromium' },
|
|
14
|
+
{ service: 'Brave Safe Storage', account: 'Brave' },
|
|
15
|
+
{ service: 'Brave Browser Safe Storage', account: 'Brave Browser' },
|
|
16
|
+
];
|
|
17
|
+
for (const candidate of candidates) {
|
|
18
|
+
try {
|
|
19
|
+
const password = execFileSync('security', ['find-generic-password', '-w', '-s', candidate.service, '-a', candidate.account], { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
|
|
20
|
+
if (password) {
|
|
21
|
+
return pbkdf2Sync(password, 'saltysalt', 1003, 16, 'sha1');
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
// Try the next known browser/keychain naming pair.
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
throw new Error('Could not read a browser Safe Storage password from the macOS Keychain.\n' +
|
|
29
|
+
'This is needed to decrypt Chrome-family cookies.\n' +
|
|
30
|
+
'Fix: open the browser profile that is logged into X, then retry.\n' +
|
|
31
|
+
'If you already use the API flow, prefer: ftx sync --api');
|
|
32
|
+
}
|
|
33
|
+
function sanitizeCookieValue(name, value) {
|
|
34
|
+
const cleaned = value.replace(/\0+$/g, '').trim();
|
|
35
|
+
if (!cleaned) {
|
|
36
|
+
throw new Error(`Cookie ${name} was empty after decryption.\n\n` +
|
|
37
|
+
'This usually happens when Chrome is open or the wrong profile is selected.\n\n' +
|
|
38
|
+
'Try:\n' +
|
|
39
|
+
' 1. Close Chrome completely and run ftx sync again\n' +
|
|
40
|
+
' 2. If that does not work, try a different profile:\n' +
|
|
41
|
+
' ftx sync --chrome-profile-directory "Profile 1"\n' +
|
|
42
|
+
' 3. Or use the API method instead:\n' +
|
|
43
|
+
' ftx auth && ftx sync --api');
|
|
44
|
+
}
|
|
45
|
+
if (!/^[\x21-\x7E]+$/.test(cleaned)) {
|
|
46
|
+
throw new Error(`Could not decrypt the ${name} cookie.\n\n` +
|
|
47
|
+
'This usually happens when Chrome is open or the wrong profile is selected.\n\n' +
|
|
48
|
+
'Try:\n' +
|
|
49
|
+
' 1. Close Chrome completely and run ftx sync again\n' +
|
|
50
|
+
' 2. Try a different profile:\n' +
|
|
51
|
+
' ftx sync --chrome-profile-directory "Profile 1"\n' +
|
|
52
|
+
' 3. Or use the API method instead:\n' +
|
|
53
|
+
' ftx auth && ftx sync --api');
|
|
54
|
+
}
|
|
55
|
+
return cleaned;
|
|
56
|
+
}
|
|
57
|
+
export function decryptCookieValue(encryptedValue, key, dbVersion = 0) {
|
|
58
|
+
if (encryptedValue.length === 0)
|
|
59
|
+
return '';
|
|
60
|
+
if (encryptedValue[0] === 0x76 && encryptedValue[1] === 0x31 && encryptedValue[2] === 0x30) {
|
|
61
|
+
const iv = Buffer.alloc(16, 0x20);
|
|
62
|
+
const ciphertext = encryptedValue.subarray(3);
|
|
63
|
+
const decipher = createDecipheriv('aes-128-cbc', key, iv);
|
|
64
|
+
let decrypted = decipher.update(ciphertext);
|
|
65
|
+
decrypted = Buffer.concat([decrypted, decipher.final()]);
|
|
66
|
+
if (dbVersion >= 24 && decrypted.length > 32) {
|
|
67
|
+
decrypted = decrypted.subarray(32);
|
|
68
|
+
}
|
|
69
|
+
return decrypted.toString('utf8');
|
|
70
|
+
}
|
|
71
|
+
return encryptedValue.toString('utf8');
|
|
72
|
+
}
|
|
73
|
+
export function decryptWindowsCookieValue(encryptedValue, masterKey, hostKey, dbVersion = 0) {
|
|
74
|
+
if (encryptedValue.length === 0)
|
|
75
|
+
return '';
|
|
76
|
+
const versionTag = encryptedValue.subarray(0, 3).toString('utf8');
|
|
77
|
+
if (versionTag === 'v10' || versionTag === 'v11') {
|
|
78
|
+
const iv = encryptedValue.subarray(3, 15);
|
|
79
|
+
const ciphertext = encryptedValue.subarray(15, encryptedValue.length - 16);
|
|
80
|
+
const authTag = encryptedValue.subarray(encryptedValue.length - 16);
|
|
81
|
+
const decipher = createDecipheriv('aes-256-gcm', masterKey, iv);
|
|
82
|
+
decipher.setAuthTag(authTag);
|
|
83
|
+
let decrypted = decipher.update(ciphertext);
|
|
84
|
+
decrypted = Buffer.concat([decrypted, decipher.final()]);
|
|
85
|
+
if (dbVersion >= 24 && decrypted.length > 32) {
|
|
86
|
+
const expectedHostHash = createHash('sha256').update(hostKey).digest();
|
|
87
|
+
if (decrypted.subarray(0, 32).equals(expectedHostHash)) {
|
|
88
|
+
decrypted = decrypted.subarray(32);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return decrypted.toString('utf8');
|
|
92
|
+
}
|
|
93
|
+
return unprotectWindowsData(encryptedValue).toString('utf8');
|
|
94
|
+
}
|
|
95
|
+
function runPowerShell(script) {
|
|
96
|
+
return execFileSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script], {
|
|
97
|
+
encoding: 'utf8',
|
|
98
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
99
|
+
timeout: 15000,
|
|
100
|
+
}).trim();
|
|
101
|
+
}
|
|
102
|
+
function unprotectWindowsData(data) {
|
|
103
|
+
const base64 = data.toString('base64');
|
|
104
|
+
const script = `Add-Type -AssemblyName System.Security; ` +
|
|
105
|
+
`$bytes = [Convert]::FromBase64String('${base64}'); ` +
|
|
106
|
+
`$plain = [System.Security.Cryptography.ProtectedData]::Unprotect(` +
|
|
107
|
+
`$bytes, $null, [System.Security.Cryptography.DataProtectionScope]::CurrentUser); ` +
|
|
108
|
+
`[Convert]::ToBase64String($plain)`;
|
|
109
|
+
const output = runPowerShell(script);
|
|
110
|
+
if (!output) {
|
|
111
|
+
throw new Error('Windows DPAPI returned an empty response while decrypting Chrome cookies.');
|
|
112
|
+
}
|
|
113
|
+
return Buffer.from(output, 'base64');
|
|
114
|
+
}
|
|
115
|
+
function getWindowsChromeMasterKey(chromeUserDataDir) {
|
|
116
|
+
const localStatePath = join(chromeUserDataDir, 'Local State');
|
|
117
|
+
if (!existsSync(localStatePath)) {
|
|
118
|
+
throw new Error(`Chrome Local State not found at: ${localStatePath}\n` +
|
|
119
|
+
'Fix: Make sure Google Chrome is installed and has been opened at least once.');
|
|
120
|
+
}
|
|
121
|
+
const localState = JSON.parse(readFileSync(localStatePath, 'utf8'));
|
|
122
|
+
const encodedKey = localState.os_crypt?.encrypted_key;
|
|
123
|
+
if (!encodedKey) {
|
|
124
|
+
throw new Error('Chrome Local State does not contain os_crypt.encrypted_key.');
|
|
125
|
+
}
|
|
126
|
+
const encryptedKey = Buffer.from(encodedKey, 'base64');
|
|
127
|
+
const dpapiPrefix = Buffer.from('DPAPI');
|
|
128
|
+
const keyPayload = encryptedKey.subarray(0, 5).equals(dpapiPrefix)
|
|
129
|
+
? encryptedKey.subarray(5)
|
|
130
|
+
: encryptedKey;
|
|
131
|
+
return unprotectWindowsData(keyPayload);
|
|
132
|
+
}
|
|
133
|
+
async function queryCookies(dbPath, domain, names) {
|
|
134
|
+
if (!existsSync(dbPath)) {
|
|
135
|
+
throw new Error(`Chrome Cookies database not found at: ${dbPath}\n` +
|
|
136
|
+
'Fix: Make sure Google Chrome is installed and has been opened at least once.\n' +
|
|
137
|
+
'If you use a non-default Chrome profile, pass --chrome-profile-directory <name>.');
|
|
138
|
+
}
|
|
139
|
+
const tempDbPath = join(tmpdir(), `ftx-cookies-${randomUUID()}.db`);
|
|
140
|
+
let queryPath = dbPath;
|
|
141
|
+
try {
|
|
142
|
+
copyFileSync(dbPath, tempDbPath);
|
|
143
|
+
queryPath = tempDbPath;
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
queryPath = dbPath;
|
|
147
|
+
}
|
|
148
|
+
const safeDomain = domain.replace(/'/g, "''");
|
|
149
|
+
const nameList = names.map((name) => `'${name.replace(/'/g, "''")}'`).join(', ');
|
|
150
|
+
const sql = `
|
|
151
|
+
SELECT
|
|
152
|
+
name,
|
|
153
|
+
host_key,
|
|
154
|
+
hex(encrypted_value) AS encrypted_value_hex,
|
|
155
|
+
value
|
|
156
|
+
FROM cookies
|
|
157
|
+
WHERE host_key LIKE '%${safeDomain}' AND name IN (${nameList})
|
|
158
|
+
`;
|
|
159
|
+
const db = await openDb(queryPath);
|
|
160
|
+
try {
|
|
161
|
+
const cookieRows = db.exec(sql);
|
|
162
|
+
const metaRows = db.exec("SELECT value FROM meta WHERE key = 'version' LIMIT 1");
|
|
163
|
+
const dbVersion = Number(metaRows[0]?.values?.[0]?.[0] ?? 0);
|
|
164
|
+
const cookies = (cookieRows[0]?.values ?? []).map((row) => ({
|
|
165
|
+
name: String(row[0] ?? ''),
|
|
166
|
+
hostKey: String(row[1] ?? ''),
|
|
167
|
+
encryptedValueHex: String(row[2] ?? ''),
|
|
168
|
+
value: String(row[3] ?? ''),
|
|
169
|
+
}));
|
|
170
|
+
return { cookies, dbVersion };
|
|
171
|
+
}
|
|
172
|
+
catch (error) {
|
|
173
|
+
throw new Error(`Could not read Chrome Cookies database.\n` +
|
|
174
|
+
`Path: ${dbPath}\n` +
|
|
175
|
+
`Error: ${error.message}\n` +
|
|
176
|
+
'Fix: If Chrome is open, close it and retry. The database may be locked.');
|
|
177
|
+
}
|
|
178
|
+
finally {
|
|
179
|
+
db.close();
|
|
180
|
+
if (queryPath === tempDbPath) {
|
|
181
|
+
try {
|
|
182
|
+
unlinkSync(tempDbPath);
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
// Best-effort cleanup.
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
export async function extractChromeXCookies(chromeUserDataDir, profileDirectory = 'Default') {
|
|
191
|
+
const os = platform();
|
|
192
|
+
if (os !== 'darwin' && os !== 'win32') {
|
|
193
|
+
throw new Error(`Direct cookie extraction is supported on macOS and Windows.\n` +
|
|
194
|
+
`Detected platform: ${os}\n` +
|
|
195
|
+
'Fix: Pass --csrf-token and --cookie-header directly, or use the OAuth API flow.');
|
|
196
|
+
}
|
|
197
|
+
const dbPath = join(chromeUserDataDir, profileDirectory, 'Cookies');
|
|
198
|
+
const key = os === 'darwin' ? getMacOSChromeKey() : getWindowsChromeMasterKey(chromeUserDataDir);
|
|
199
|
+
let result = await queryCookies(dbPath, '.x.com', ['ct0', 'auth_token']);
|
|
200
|
+
if (result.cookies.length === 0) {
|
|
201
|
+
result = await queryCookies(dbPath, '.twitter.com', ['ct0', 'auth_token']);
|
|
202
|
+
}
|
|
203
|
+
const decrypted = new Map();
|
|
204
|
+
for (const cookie of result.cookies) {
|
|
205
|
+
const hexValue = cookie.encryptedValueHex;
|
|
206
|
+
if (hexValue && hexValue.length > 0) {
|
|
207
|
+
const buffer = Buffer.from(hexValue, 'hex');
|
|
208
|
+
const value = os === 'darwin'
|
|
209
|
+
? decryptCookieValue(buffer, key, result.dbVersion)
|
|
210
|
+
: decryptWindowsCookieValue(buffer, key, cookie.hostKey, result.dbVersion);
|
|
211
|
+
decrypted.set(cookie.name, value);
|
|
212
|
+
}
|
|
213
|
+
else if (cookie.value) {
|
|
214
|
+
decrypted.set(cookie.name, cookie.value);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
const ct0 = decrypted.get('ct0');
|
|
218
|
+
const authToken = decrypted.get('auth_token');
|
|
219
|
+
if (!ct0) {
|
|
220
|
+
throw new Error('No ct0 CSRF cookie found for x.com in Chrome.\n' +
|
|
221
|
+
'This means you are not logged into X in the selected Chrome profile.\n\n' +
|
|
222
|
+
'Fix:\n' +
|
|
223
|
+
' 1. Open Google Chrome\n' +
|
|
224
|
+
' 2. Go to https://x.com and log in\n' +
|
|
225
|
+
' 3. Close Chrome completely\n' +
|
|
226
|
+
' 4. Re-run this command\n\n' +
|
|
227
|
+
(profileDirectory !== 'Default'
|
|
228
|
+
? `Using Chrome profile: "${profileDirectory}"\n`
|
|
229
|
+
: 'Using the Default Chrome profile. If your X login is in a different profile,\n' +
|
|
230
|
+
'pass --chrome-profile-directory <name> (for example "Profile 1").\n'));
|
|
231
|
+
}
|
|
232
|
+
const cookieParts = [`ct0=${sanitizeCookieValue('ct0', ct0)}`];
|
|
233
|
+
if (authToken)
|
|
234
|
+
cookieParts.push(`auth_token=${sanitizeCookieValue('auth_token', authToken)}`);
|
|
235
|
+
return {
|
|
236
|
+
csrfToken: sanitizeCookieValue('ct0', ct0),
|
|
237
|
+
cookieHeader: cookieParts.join('; '),
|
|
238
|
+
};
|
|
239
|
+
}
|