@papercraneai/cli 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.
- package/LICENSE +201 -0
- package/README.md +172 -0
- package/bin/papercrane.js +209 -0
- package/lib/callback-server.js +92 -0
- package/lib/cloud-client.js +193 -0
- package/lib/config.js +97 -0
- package/lib/facebook-auth.js +148 -0
- package/lib/facebook-callback-server.js +105 -0
- package/lib/function-client.js +451 -0
- package/lib/google-auth.js +134 -0
- package/lib/list-credentials.js +116 -0
- package/lib/pkce.js +55 -0
- package/lib/storage.js +234 -0
- package/package.json +30 -0
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import axios from 'axios';
|
|
2
|
+
import { getApiKey, getApiBaseUrl } from './config.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Get cloud credentials for a specific provider and scope
|
|
6
|
+
* @param {string} provider - Provider name ('google', 'facebook', etc.)
|
|
7
|
+
* @param {string} instanceName - Instance name (defaults to 'Default')
|
|
8
|
+
* @returns {Promise<Object|null>} Credentials object or null if not found
|
|
9
|
+
*/
|
|
10
|
+
export async function fetchCloudCredentials(provider, instanceName = 'Default') {
|
|
11
|
+
const apiKey = await getApiKey();
|
|
12
|
+
if (!apiKey) {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const baseUrl = await getApiBaseUrl();
|
|
17
|
+
|
|
18
|
+
try {
|
|
19
|
+
const response = await axios.get(`${baseUrl}/api/sdk/credentials/${provider}/${instanceName}`, {
|
|
20
|
+
headers: {
|
|
21
|
+
'Authorization': `Bearer ${apiKey}`
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
return response.data.credentials || null;
|
|
26
|
+
} catch (error) {
|
|
27
|
+
// If 404 or credential not found, return null
|
|
28
|
+
if (error.response?.status === 404) {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// If unauthorized, throw specific error
|
|
33
|
+
if (error.response?.status === 401) {
|
|
34
|
+
throw new Error('Invalid or expired API key. Please run: papercrane login');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Other errors
|
|
38
|
+
throw error;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* List all cloud credentials
|
|
44
|
+
* @returns {Promise<Array>} Array of credential objects
|
|
45
|
+
*/
|
|
46
|
+
export async function listCloudCredentials() {
|
|
47
|
+
const apiKey = await getApiKey();
|
|
48
|
+
if (!apiKey) {
|
|
49
|
+
return [];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const baseUrl = await getApiBaseUrl();
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
const response = await axios.get(`${baseUrl}/api/sdk/credentials`, {
|
|
56
|
+
headers: {
|
|
57
|
+
'Authorization': `Bearer ${apiKey}`
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
return response.data.credentials || [];
|
|
62
|
+
} catch (error) {
|
|
63
|
+
if (error.response?.status === 401) {
|
|
64
|
+
throw new Error('Invalid or expired API key. Please run: papercrane login');
|
|
65
|
+
}
|
|
66
|
+
throw error;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Validate the current API key by attempting to access the health endpoint
|
|
72
|
+
* @returns {Promise<boolean>} True if valid, false otherwise
|
|
73
|
+
*/
|
|
74
|
+
export async function validateApiKey() {
|
|
75
|
+
const apiKey = await getApiKey();
|
|
76
|
+
if (!apiKey) {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const baseUrl = await getApiBaseUrl();
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
// Try to fetch a known integration to validate the key
|
|
84
|
+
// We expect either a 404 (integration not found, but key is valid)
|
|
85
|
+
// or a 200 (key is valid and integration exists)
|
|
86
|
+
// A 401 means the key is invalid
|
|
87
|
+
await axios.get(`${baseUrl}/api/sdk/credentials/google/Default`, {
|
|
88
|
+
headers: {
|
|
89
|
+
'Authorization': `Bearer ${apiKey}`
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
return true;
|
|
94
|
+
} catch (error) {
|
|
95
|
+
// If we get 401, the key is invalid
|
|
96
|
+
if (error.response?.status === 401) {
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
// 404 means the integration doesn't exist, but the key is valid
|
|
100
|
+
if (error.response?.status === 404) {
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
// Any other error, consider the key invalid
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Refresh credentials for a specific provider (currently only supports airtable)
|
|
110
|
+
* @param {string} provider - Provider name (e.g., 'airtable')
|
|
111
|
+
* @param {string} instanceName - Instance name (defaults to 'Default')
|
|
112
|
+
* @returns {Promise<Object|null>} Fresh credentials object or null if failed
|
|
113
|
+
*/
|
|
114
|
+
export async function refreshCloudCredentials(provider, instanceName = 'Default') {
|
|
115
|
+
const apiKey = await getApiKey();
|
|
116
|
+
if (!apiKey) {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const baseUrl = await getApiBaseUrl();
|
|
121
|
+
|
|
122
|
+
try {
|
|
123
|
+
const response = await axios.post(`${baseUrl}/api/sdk/credentials/${provider}/${instanceName}/refresh`, {}, {
|
|
124
|
+
headers: {
|
|
125
|
+
'Authorization': `Bearer ${apiKey}`
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
return response.data.credentials || null;
|
|
130
|
+
} catch (error) {
|
|
131
|
+
if (error.response?.status === 404) {
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
if (error.response?.status === 401) {
|
|
135
|
+
throw new Error('Invalid or expired API key. Please run: papercrane login');
|
|
136
|
+
}
|
|
137
|
+
throw error;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Push local credentials to cloud (idempotent)
|
|
143
|
+
* @param {string} provider - Provider name ('google', 'facebook', etc.)
|
|
144
|
+
* @param {string} credentialId - Local credential UUID
|
|
145
|
+
* @param {Object} credentials - Credentials object to upload
|
|
146
|
+
* @param {string} scope - OAuth scope string
|
|
147
|
+
* @param {string} name - Optional name for the credential
|
|
148
|
+
* @returns {Promise<{cloud_id: string, status: string, message: string}>}
|
|
149
|
+
*/
|
|
150
|
+
export async function pushCredentials(provider, credentialId, credentials, scope, name) {
|
|
151
|
+
const apiKey = await getApiKey();
|
|
152
|
+
if (!apiKey) {
|
|
153
|
+
throw new Error('Not logged in. Please run: papercrane login');
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const baseUrl = await getApiBaseUrl();
|
|
157
|
+
|
|
158
|
+
const response = await axios.post(`${baseUrl}/api/sdk/credentials/push`, {
|
|
159
|
+
provider,
|
|
160
|
+
credential_id: credentialId,
|
|
161
|
+
credentials,
|
|
162
|
+
scope,
|
|
163
|
+
name
|
|
164
|
+
}, {
|
|
165
|
+
headers: {
|
|
166
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
167
|
+
'Content-Type': 'application/json'
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
return response.data;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Pull all credentials from cloud
|
|
176
|
+
* @returns {Promise<Array>} Array of credential objects with cloud_id
|
|
177
|
+
*/
|
|
178
|
+
export async function pullCredentials() {
|
|
179
|
+
const apiKey = await getApiKey();
|
|
180
|
+
if (!apiKey) {
|
|
181
|
+
throw new Error('Not logged in. Please run: papercrane login');
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const baseUrl = await getApiBaseUrl();
|
|
185
|
+
|
|
186
|
+
const response = await axios.get(`${baseUrl}/api/sdk/credentials/pull`, {
|
|
187
|
+
headers: {
|
|
188
|
+
'Authorization': `Bearer ${apiKey}`
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
return response.data.credentials || [];
|
|
193
|
+
}
|
package/lib/config.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import fs from 'fs/promises';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
|
|
5
|
+
const PAPERCRANE_DIR = path.join(os.homedir(), '.papercrane');
|
|
6
|
+
const CONFIG_FILE = path.join(PAPERCRANE_DIR, 'config.json');
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Ensures the config directory exists
|
|
10
|
+
*/
|
|
11
|
+
async function ensureConfigDir() {
|
|
12
|
+
await fs.mkdir(PAPERCRANE_DIR, { recursive: true });
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Load configuration from disk
|
|
17
|
+
* @returns {Promise<Object>}
|
|
18
|
+
*/
|
|
19
|
+
export async function loadConfig() {
|
|
20
|
+
try {
|
|
21
|
+
await ensureConfigDir();
|
|
22
|
+
const content = await fs.readFile(CONFIG_FILE, 'utf-8');
|
|
23
|
+
return JSON.parse(content);
|
|
24
|
+
} catch (error) {
|
|
25
|
+
if (error.code === 'ENOENT') {
|
|
26
|
+
// Config file doesn't exist, return empty config
|
|
27
|
+
return {};
|
|
28
|
+
}
|
|
29
|
+
throw error;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Save configuration to disk
|
|
35
|
+
* @param {Object} config - Configuration object
|
|
36
|
+
*/
|
|
37
|
+
export async function saveConfig(config) {
|
|
38
|
+
await ensureConfigDir();
|
|
39
|
+
await fs.writeFile(CONFIG_FILE, JSON.stringify(config, null, 2));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Get the API key from config
|
|
44
|
+
* @returns {Promise<string|null>}
|
|
45
|
+
*/
|
|
46
|
+
export async function getApiKey() {
|
|
47
|
+
const config = await loadConfig();
|
|
48
|
+
return config.apiKey || null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Set the API key in config
|
|
53
|
+
* @param {string} apiKey - The API key to store
|
|
54
|
+
*/
|
|
55
|
+
export async function setApiKey(apiKey) {
|
|
56
|
+
const config = await loadConfig();
|
|
57
|
+
config.apiKey = apiKey;
|
|
58
|
+
await saveConfig(config);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Get the API base URL from config
|
|
63
|
+
* @returns {Promise<string>}
|
|
64
|
+
*/
|
|
65
|
+
export async function getApiBaseUrl() {
|
|
66
|
+
const config = await loadConfig();
|
|
67
|
+
const url = config.apiBaseUrl || 'https://fly.papercrane.ai';
|
|
68
|
+
// Strip trailing slash to avoid double-slash issues when appending paths
|
|
69
|
+
return url.replace(/\/+$/, '');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Set the API base URL in config
|
|
74
|
+
* @param {string} url - The API base URL
|
|
75
|
+
*/
|
|
76
|
+
export async function setApiBaseUrl(url) {
|
|
77
|
+
const config = await loadConfig();
|
|
78
|
+
config.apiBaseUrl = url;
|
|
79
|
+
await saveConfig(config);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Check if user is logged in (has API key)
|
|
84
|
+
* @returns {Promise<boolean>}
|
|
85
|
+
*/
|
|
86
|
+
export async function isLoggedIn() {
|
|
87
|
+
const apiKey = await getApiKey();
|
|
88
|
+
return !!apiKey;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Clear all config (logout)
|
|
93
|
+
*/
|
|
94
|
+
export async function clearConfig() {
|
|
95
|
+
await ensureConfigDir();
|
|
96
|
+
await fs.writeFile(CONFIG_FILE, JSON.stringify({}, null, 2));
|
|
97
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import axios from 'axios';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import { saveFacebookCredentials } from './storage.js';
|
|
4
|
+
|
|
5
|
+
const DEVICE_LOGIN_URL = 'https://graph.facebook.com/v21.0/device/login';
|
|
6
|
+
const DEVICE_STATUS_URL = 'https://graph.facebook.com/v21.0/device/login_status';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Handles the Facebook Device Login Flow
|
|
10
|
+
* @param {string[]} scopes - Array of OAuth scopes to request (e.g., ['ads_read', 'ads_management'])
|
|
11
|
+
* @param {string} appId - Facebook App ID
|
|
12
|
+
* @param {string} clientToken - Facebook Client Token
|
|
13
|
+
*/
|
|
14
|
+
export async function handleFacebookAuth(scopes, appId, clientToken) {
|
|
15
|
+
console.log(chalk.bold('\n🔐 Starting Facebook Device Login...\n'));
|
|
16
|
+
|
|
17
|
+
// Prompt for App ID if not provided
|
|
18
|
+
if (!appId) {
|
|
19
|
+
console.log(chalk.yellow('Facebook App ID is required.'));
|
|
20
|
+
console.log(chalk.dim('You can find this in your Facebook App dashboard at:'));
|
|
21
|
+
console.log(chalk.dim('https://developers.facebook.com/apps/\n'));
|
|
22
|
+
throw new Error('App ID is required. Pass it as: papercrane facebook <scopes> --app-id YOUR_APP_ID');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Prompt for Client Token if not provided
|
|
26
|
+
if (!clientToken) {
|
|
27
|
+
console.log(chalk.yellow('Facebook Client Token is required.'));
|
|
28
|
+
console.log(chalk.dim('You can find this in App Settings -> Advanced -> Client Token'));
|
|
29
|
+
console.log(chalk.dim('https://developers.facebook.com/apps/\n'));
|
|
30
|
+
throw new Error('Client Token is required. Pass it as: --client-token YOUR_CLIENT_TOKEN');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Handle Ctrl+C gracefully
|
|
34
|
+
let cancelled = false;
|
|
35
|
+
const handleExit = () => {
|
|
36
|
+
cancelled = true;
|
|
37
|
+
console.log(chalk.yellow('\n\n⚠️ Authentication cancelled by user'));
|
|
38
|
+
process.exit(0);
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
process.on('SIGINT', handleExit);
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
const scopeString = scopes.join(',');
|
|
45
|
+
const accessToken = `${appId}|${clientToken}`;
|
|
46
|
+
|
|
47
|
+
// Step 1: Request device code
|
|
48
|
+
console.log(chalk.cyan('📱 Requesting device code...'));
|
|
49
|
+
const deviceResponse = await axios.post(DEVICE_LOGIN_URL, null, {
|
|
50
|
+
params: {
|
|
51
|
+
access_token: accessToken,
|
|
52
|
+
scope: scopeString
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const {
|
|
57
|
+
code,
|
|
58
|
+
user_code,
|
|
59
|
+
verification_uri,
|
|
60
|
+
expires_in,
|
|
61
|
+
interval
|
|
62
|
+
} = deviceResponse.data;
|
|
63
|
+
|
|
64
|
+
// Step 2: Display code to user
|
|
65
|
+
console.log(chalk.bold.green('\n✅ Device code received!\n'));
|
|
66
|
+
console.log(chalk.bold.cyan('━'.repeat(60)));
|
|
67
|
+
console.log(chalk.bold('\n 📋 To authenticate:\n'));
|
|
68
|
+
console.log(chalk.bold.yellow(` 1. Visit: ${chalk.underline(verification_uri)}`));
|
|
69
|
+
console.log(chalk.bold.yellow(` 2. Enter code: ${chalk.bold.white(user_code)}\n`));
|
|
70
|
+
console.log(chalk.bold.cyan('━'.repeat(60)));
|
|
71
|
+
console.log(chalk.dim(`\n⏱ Code expires in ${expires_in} seconds`));
|
|
72
|
+
console.log(chalk.cyan('\n⏳ Waiting for authorization...\n'));
|
|
73
|
+
|
|
74
|
+
// Step 3: Poll for authorization
|
|
75
|
+
const pollInterval = (interval || 5) * 1000; // Convert to milliseconds
|
|
76
|
+
const startTime = Date.now();
|
|
77
|
+
const expiresAt = startTime + (expires_in * 1000);
|
|
78
|
+
|
|
79
|
+
while (!cancelled && Date.now() < expiresAt) {
|
|
80
|
+
await new Promise(resolve => setTimeout(resolve, pollInterval));
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
const statusResponse = await axios.post(DEVICE_STATUS_URL, null, {
|
|
84
|
+
params: {
|
|
85
|
+
access_token: accessToken,
|
|
86
|
+
code: code
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const { access_token } = statusResponse.data;
|
|
91
|
+
|
|
92
|
+
if (access_token) {
|
|
93
|
+
console.log(chalk.green('\n✓ Authorization successful!'));
|
|
94
|
+
console.log(chalk.green('✓ Device Login tokens are valid for up to 60 days'));
|
|
95
|
+
|
|
96
|
+
// Save credentials (Device Login tokens are already long-lived)
|
|
97
|
+
console.log(chalk.cyan('💾 Saving credentials...'));
|
|
98
|
+
const credentials = {
|
|
99
|
+
access_token: access_token,
|
|
100
|
+
token_type: 'bearer',
|
|
101
|
+
// Device login doesn't return expires_in, but tokens last ~60 days
|
|
102
|
+
expires_in: 5184000 // 60 days in seconds
|
|
103
|
+
};
|
|
104
|
+
const filepath = await saveFacebookCredentials(credentials, appId, scopeString);
|
|
105
|
+
console.log(chalk.green(`✓ Credentials saved to: ${filepath}`));
|
|
106
|
+
|
|
107
|
+
console.log(chalk.bold.green('\n✅ Authentication completed successfully!\n'));
|
|
108
|
+
console.log(chalk.dim('Granted scopes:'));
|
|
109
|
+
console.log(chalk.dim(` ${scopeString}\n`));
|
|
110
|
+
console.log(chalk.yellow('⚠️ Note: Facebook access tokens expire after ~60 days. Re-authenticate when needed.\n'));
|
|
111
|
+
|
|
112
|
+
process.removeListener('SIGINT', handleExit);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
} catch (error) {
|
|
116
|
+
// Check if it's an authorization_pending error (expected while waiting)
|
|
117
|
+
if (error.response?.data?.error?.code === 1349174) {
|
|
118
|
+
// Authorization pending - continue polling
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Check if it's a slow_down error
|
|
123
|
+
if (error.response?.data?.error?.code === 1349172) {
|
|
124
|
+
// Slow down - wait a bit longer
|
|
125
|
+
await new Promise(resolve => setTimeout(resolve, 5000));
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Other errors should be thrown
|
|
130
|
+
throw error;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// If we get here, the code expired
|
|
135
|
+
console.error(chalk.red('\n❌ Device code expired. Please try again.\n'));
|
|
136
|
+
process.exit(1);
|
|
137
|
+
|
|
138
|
+
} catch (error) {
|
|
139
|
+
process.removeListener('SIGINT', handleExit);
|
|
140
|
+
|
|
141
|
+
if (error.response) {
|
|
142
|
+
console.error(chalk.red('\n❌ Authentication failed:'));
|
|
143
|
+
console.error(chalk.red(` ${JSON.stringify(error.response.data, null, 2)}`));
|
|
144
|
+
} else {
|
|
145
|
+
throw error;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import http from 'http';
|
|
2
|
+
import { URL } from 'url';
|
|
3
|
+
import chalk from 'chalk';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Starts a local HTTP server to handle Facebook OAuth callback with token in URL fragment
|
|
7
|
+
* @param {number} port - Port to listen on (default: 8080)
|
|
8
|
+
* @returns {Promise<{access_token: string, server: http.Server, port: number}>}
|
|
9
|
+
*/
|
|
10
|
+
export function startFacebookCallbackServer(port = 8080) {
|
|
11
|
+
return new Promise((resolve, reject) => {
|
|
12
|
+
const server = http.createServer((req, res) => {
|
|
13
|
+
const url = new URL(req.url, `http://localhost:${port}`);
|
|
14
|
+
|
|
15
|
+
// Handle the OAuth callback - serve HTML page
|
|
16
|
+
if (url.pathname === '/callback') {
|
|
17
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
18
|
+
res.end(`
|
|
19
|
+
<html>
|
|
20
|
+
<head><title>Facebook Authentication</title></head>
|
|
21
|
+
<body style="font-family: Arial, sans-serif; padding: 50px; text-align: center;">
|
|
22
|
+
<h1 style="color: #1877f2;">Authenticating with Facebook...</h1>
|
|
23
|
+
<p id="status">Processing...</p>
|
|
24
|
+
<script>
|
|
25
|
+
// Extract token from URL fragment
|
|
26
|
+
const hash = window.location.hash.substring(1);
|
|
27
|
+
const params = new URLSearchParams(hash);
|
|
28
|
+
const accessToken = params.get('access_token');
|
|
29
|
+
const error = params.get('error');
|
|
30
|
+
|
|
31
|
+
if (error) {
|
|
32
|
+
document.getElementById('status').innerHTML =
|
|
33
|
+
'<span style="color: #e74c3c;">❌ Authentication Failed: ' + error + '</span>';
|
|
34
|
+
// Send error to server
|
|
35
|
+
fetch('/complete?error=' + encodeURIComponent(error));
|
|
36
|
+
} else if (accessToken) {
|
|
37
|
+
document.getElementById('status').innerHTML =
|
|
38
|
+
'<span style="color: #27ae60;">✅ Authentication Successful!</span><br>' +
|
|
39
|
+
'<p>You can close this window and return to the terminal.</p>';
|
|
40
|
+
// Send token to server
|
|
41
|
+
fetch('/complete?access_token=' + encodeURIComponent(accessToken));
|
|
42
|
+
} else {
|
|
43
|
+
document.getElementById('status').innerHTML =
|
|
44
|
+
'<span style="color: #e74c3c;">❌ No access token received</span>';
|
|
45
|
+
fetch('/complete?error=no_token');
|
|
46
|
+
}
|
|
47
|
+
</script>
|
|
48
|
+
</body>
|
|
49
|
+
</html>
|
|
50
|
+
`);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Handle the completion endpoint (receives token from JavaScript)
|
|
55
|
+
if (url.pathname === '/complete') {
|
|
56
|
+
const accessToken = url.searchParams.get('access_token');
|
|
57
|
+
const error = url.searchParams.get('error');
|
|
58
|
+
|
|
59
|
+
if (error) {
|
|
60
|
+
res.writeHead(200);
|
|
61
|
+
res.end('OK');
|
|
62
|
+
reject(new Error(`OAuth error: ${error}`));
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (accessToken) {
|
|
67
|
+
res.writeHead(200);
|
|
68
|
+
res.end('OK');
|
|
69
|
+
resolve({ access_token: accessToken, server, port });
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
res.writeHead(400);
|
|
74
|
+
res.end('Missing parameters');
|
|
75
|
+
} else {
|
|
76
|
+
res.writeHead(404);
|
|
77
|
+
res.end('Not Found');
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
server.on('error', (err) => {
|
|
82
|
+
if (err.code === 'EADDRINUSE') {
|
|
83
|
+
reject(new Error(`Port ${port} is already in use. Please try again.`));
|
|
84
|
+
} else {
|
|
85
|
+
reject(err);
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
server.listen(port, '127.0.0.1', () => {
|
|
90
|
+
console.log(chalk.cyan(`\n🌐 Local server started on http://127.0.0.1:${port}`));
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Stops the callback server
|
|
97
|
+
* @param {http.Server} server
|
|
98
|
+
*/
|
|
99
|
+
export function stopCallbackServer(server) {
|
|
100
|
+
return new Promise((resolve) => {
|
|
101
|
+
server.close(() => {
|
|
102
|
+
resolve();
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
}
|