@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.
@@ -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
+ }