@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,451 @@
1
+ import axios from "axios"
2
+ import chalk from "chalk"
3
+ import { getApiKey, getApiBaseUrl } from "./config.js"
4
+
5
+ // Streaming content types that should be piped directly to stdout
6
+ const STREAMING_CONTENT_TYPES = [
7
+ "application/x-ndjson",
8
+ "application/jsonl",
9
+ "text/event-stream",
10
+ "application/stream+json"
11
+ ]
12
+
13
+ /**
14
+ * Check if content type indicates a streaming response
15
+ * @param {string} contentType - Content-Type header value
16
+ * @returns {boolean}
17
+ */
18
+ function isStreamingContentType(contentType) {
19
+ if (!contentType) return false
20
+ const normalized = contentType.toLowerCase().split(";")[0].trim()
21
+ return STREAMING_CONTENT_TYPES.includes(normalized)
22
+ }
23
+
24
+ /**
25
+ * Make a request to the function API
26
+ * @param {string} path - Function path (e.g., "google-ads-api.Customer.123.campaigns.list")
27
+ * @param {'GET' | 'POST'} method - HTTP method
28
+ * @param {Object} params - Parameters for POST requests
29
+ * @param {string} instance - Integration instance name
30
+ * @param {string} mode - Mode for GET requests: "list" or "describe"
31
+ * @param {Object} extraQueryParams - Additional query parameters
32
+ * @returns {Promise<Object>} API response data
33
+ */
34
+ async function functionRequest(
35
+ path,
36
+ method,
37
+ params = {},
38
+ instance = "Default",
39
+ mode = undefined,
40
+ extraQueryParams = {}
41
+ ) {
42
+ const apiKey = await getApiKey()
43
+ if (!apiKey) {
44
+ throw new Error("Not logged in. Please run: papercrane login")
45
+ }
46
+
47
+ const baseUrl = await getApiBaseUrl()
48
+ const url = path ? `${baseUrl}/function/${path}` : `${baseUrl}/function`
49
+
50
+ try {
51
+ const queryParams = { instance, ...extraQueryParams }
52
+ if (mode) {
53
+ queryParams.mode = mode
54
+ }
55
+
56
+ // For POST requests, use streaming to handle large/streaming responses
57
+ if (method === "POST") {
58
+ const response = await axios({
59
+ method,
60
+ url,
61
+ headers: {
62
+ Authorization: `Bearer ${apiKey}`,
63
+ "Content-Type": "application/json"
64
+ },
65
+ params: queryParams,
66
+ data: params,
67
+ responseType: "stream",
68
+ validateStatus: () => true // Don't throw on any status, we'll handle it
69
+ })
70
+
71
+ const contentType = response.headers["content-type"]
72
+ const status = response.status
73
+
74
+ // For error responses, read the body and throw with the error message
75
+ if (status >= 400) {
76
+ const chunks = []
77
+ for await (const chunk of response.data) {
78
+ chunks.push(chunk)
79
+ }
80
+ const body = Buffer.concat(chunks).toString("utf-8")
81
+ try {
82
+ const errorData = JSON.parse(body)
83
+ // error can be a string or an object with a message field
84
+ const err = errorData.error
85
+ const message = typeof err === "string" ? err : err?.message || body
86
+
87
+ // Display schema if present in the error response
88
+ if (errorData.schema) {
89
+ console.error(chalk.yellow('\nExpected parameters:'))
90
+ for (const [name, info] of Object.entries(errorData.schema)) {
91
+ const req = info.required ? chalk.red('required') : chalk.dim('optional')
92
+ console.error(` ${chalk.cyan(name)} (${info.type}) ${req}`)
93
+ if (info.description) {
94
+ console.error(` ${chalk.dim(info.description)}`)
95
+ }
96
+ }
97
+ console.error('')
98
+ }
99
+
100
+ throw new Error(message)
101
+ } catch (parseError) {
102
+ // If it's our error, rethrow it
103
+ if (parseError instanceof Error && parseError.message !== "Unexpected token") throw parseError
104
+ // Otherwise show the raw body
105
+ throw new Error(body)
106
+ }
107
+ }
108
+
109
+ // If streaming content type, pipe directly to stdout and return marker
110
+ if (isStreamingContentType(contentType)) {
111
+ return new Promise((resolve, reject) => {
112
+ response.data.pipe(process.stdout)
113
+ response.data.on("end", () => resolve({ __streamed: true }))
114
+ response.data.on("error", reject)
115
+ })
116
+ }
117
+
118
+ // Otherwise, buffer the response and parse as JSON
119
+ const chunks = []
120
+ for await (const chunk of response.data) {
121
+ chunks.push(chunk)
122
+ }
123
+ const body = Buffer.concat(chunks).toString("utf-8")
124
+ return JSON.parse(body)
125
+ }
126
+
127
+ // GET requests - use regular axios behavior
128
+ const response = await axios({
129
+ method,
130
+ url,
131
+ headers: {
132
+ Authorization: `Bearer ${apiKey}`,
133
+ "Content-Type": "application/json"
134
+ },
135
+ params: queryParams
136
+ })
137
+
138
+ return response.data
139
+ } catch (error) {
140
+ // Connection errors (server not running, network issues)
141
+ if (error.code === "ECONNREFUSED") {
142
+ const { getApiBaseUrl } = await import("./config.js")
143
+ const baseUrl = await getApiBaseUrl()
144
+ throw new Error(`Cannot connect to server at ${baseUrl}. Is it running?`)
145
+ }
146
+ if (error.code === "ENOTFOUND" || error.code === "EAI_AGAIN") {
147
+ throw new Error(`Cannot resolve server address. Check your --url setting.`)
148
+ }
149
+ if (error.response?.status === 401) {
150
+ throw new Error("Invalid or expired API key. Please run: papercrane login")
151
+ }
152
+ if (error.response?.data) {
153
+ // Include full error context for AI consumers
154
+ const errorData = error.response.data
155
+ if (typeof errorData === "object") {
156
+ // If there's structured error data, include it all
157
+ const errorMessage = errorData.error || "Unknown error"
158
+ const extraContext = Object.entries(errorData)
159
+ .filter(([k]) => k !== "error")
160
+ .map(([k, v]) => `${k}: ${JSON.stringify(v)}`)
161
+ .join(", ")
162
+ throw new Error(extraContext ? `${errorMessage} (${extraContext})` : errorMessage)
163
+ }
164
+ throw new Error(String(errorData))
165
+ }
166
+ throw error
167
+ }
168
+ }
169
+
170
+ /**
171
+ * List available functions at a path (names only, no descriptions)
172
+ * @param {string} path - Optional path to list at
173
+ * @param {string} instance - Integration instance name
174
+ * @param {boolean} all - If true, recursively list all nested functions
175
+ * @param {boolean} showUnavailable - If true, include functions without credentials
176
+ * @returns {Promise<Object>} List response with sdks or next items
177
+ */
178
+ export async function listFunctions(path = "", instance = "Default", all = false, showUnavailable = false) {
179
+ const mode = all ? "list-all" : "list"
180
+ // Add showUnavailable as 'all' query param when in list mode (not list-all)
181
+ const extraParams = !all && showUnavailable ? { all: "true" } : {}
182
+ return functionRequest(path, "GET", {}, instance, mode, extraParams)
183
+ }
184
+
185
+ /**
186
+ * Get detailed info about a function (description, params, etc.)
187
+ * @param {string} path - Function path
188
+ * @param {string} instance - Integration instance name
189
+ * @returns {Promise<Object>} Function info with description, params, available sub-paths
190
+ */
191
+ export async function getFunction(path, instance = "Default") {
192
+ return functionRequest(path, "GET", {}, instance, "describe")
193
+ }
194
+
195
+ /**
196
+ * Execute a function
197
+ * @param {string} path - Function path
198
+ * @param {Object} params - Parameters to pass to the function
199
+ * @param {string} instance - Integration instance name
200
+ * @returns {Promise<Object>} Function result
201
+ */
202
+ export async function runFunction(path, params = {}, instance = "Default") {
203
+ return functionRequest(path, "POST", params, instance)
204
+ }
205
+
206
+ /**
207
+ * Format the root-level describe output (no path given).
208
+ * Shows connected integrations with their endpoints.
209
+ * @param {Object} data - Response from listFunctions (mode=list at root)
210
+ */
211
+ export function formatDescribeRoot(data) {
212
+ if (data.items !== undefined) {
213
+ if (data.items.length > 0) {
214
+ console.log(chalk.bold("\nConnected modules:\n"))
215
+
216
+ let hasDynamic = false
217
+ data.items.forEach((item) => {
218
+ const displayPath = item.isGroup ? `${item.path}.*` : item.path
219
+
220
+ const instanceSuffix =
221
+ item.instances && item.instances.length > 1 ? chalk.dim(` (via: ${item.instances.join(", ")})`) : ""
222
+
223
+ if (item.isDynamic) {
224
+ hasDynamic = true
225
+ console.log(` ${chalk.yellow(displayPath)}${instanceSuffix}`)
226
+ } else if (item.isGroup) {
227
+ console.log(` ${chalk.cyan(displayPath)}${instanceSuffix}`)
228
+ } else {
229
+ console.log(` ${chalk.green(displayPath)}${instanceSuffix}`)
230
+ }
231
+
232
+ if (item.description) {
233
+ console.log(` ${chalk.dim(item.description)}`)
234
+ }
235
+ })
236
+ console.log()
237
+
238
+ console.log(chalk.dim('Run "papercrane describe <module>" to see endpoints, or "papercrane describe <module> --flat" for all paths.'))
239
+ console.log()
240
+ } else {
241
+ console.log(chalk.dim("\nNo connected modules.\n"))
242
+ console.log(chalk.dim('Run "papercrane connect" to see available integrations.\n'))
243
+ }
244
+ return
245
+ }
246
+
247
+ // Legacy: SDK list format
248
+ if (data.sdks) {
249
+ console.log(chalk.bold("\nConnected modules:\n"))
250
+ data.sdks.forEach((sdk) => {
251
+ console.log(` ${chalk.cyan(sdk.name)}`)
252
+ if (sdk.description) {
253
+ console.log(` ${chalk.dim(sdk.description)}`)
254
+ }
255
+ })
256
+ console.log()
257
+ }
258
+ }
259
+
260
+ /**
261
+ * Format describe output for a specific path (branch or leaf).
262
+ * @param {Object} data - Response from getFunction (mode=describe)
263
+ * @param {string} path - Function path
264
+ */
265
+ export function formatDescribe(data, path) {
266
+ console.log(chalk.bold(`\n${path}\n`))
267
+ console.log(` ${data.description}\n`)
268
+
269
+ // Show strategic usage guidance
270
+ if (data.guide) {
271
+ console.log(chalk.bold("Guide:\n"))
272
+ data.guide.split("\n").forEach((line) => {
273
+ console.log(` ${chalk.dim(line)}`)
274
+ })
275
+ console.log()
276
+ }
277
+
278
+ // Show required scopes
279
+ if (data.scopes && data.scopes.length > 0) {
280
+ console.log(chalk.bold("Required scopes:\n"))
281
+ data.scopes.forEach((scope) => {
282
+ console.log(` ${chalk.dim("•")} ${scope}`)
283
+ })
284
+ console.log()
285
+ }
286
+
287
+ // Show params if this is a runnable function
288
+ if (data.params) {
289
+ console.log(chalk.bold("Parameters:\n"))
290
+ formatParamsSchema(data.params, " ")
291
+ }
292
+
293
+ // Show available sub-paths, separated by type
294
+ if (data.next && data.next.length > 0) {
295
+ const endpoints = data.next.filter((item) => item.isEndpoint)
296
+ const modules = data.next.filter((item) => !item.isEndpoint)
297
+
298
+ if (endpoints.length > 0) {
299
+ console.log(chalk.bold("Endpoints:\n"))
300
+ endpoints.forEach((item) => {
301
+ const name = item.isDynamic ? chalk.yellow(item.name) : chalk.green(item.name)
302
+ console.log(` ${name} - ${chalk.dim(item.description)}`)
303
+ })
304
+ console.log()
305
+ }
306
+
307
+ if (modules.length > 0) {
308
+ console.log(chalk.bold("Modules:\n"))
309
+ modules.forEach((item) => {
310
+ const name = item.isDynamic ? chalk.yellow(item.name) : chalk.cyan(item.name)
311
+ console.log(` ${name} - ${chalk.dim(item.description)}`)
312
+ })
313
+ console.log()
314
+ }
315
+ }
316
+
317
+ // Show example usage for runnable functions
318
+ if (!data.next) {
319
+ console.log(chalk.bold("Example:\n"))
320
+ if (data.params && data.params.properties) {
321
+ const exampleParams = buildExampleParams(data.params)
322
+ console.log(chalk.dim(` papercrane call ${path} '${JSON.stringify(exampleParams)}'`))
323
+ } else {
324
+ console.log(chalk.dim(` papercrane call ${path}`))
325
+ }
326
+ console.log()
327
+ }
328
+ }
329
+
330
+ /**
331
+ * Format flat listing of all endpoint paths.
332
+ * @param {Object} data - Response from listFunctions with all=true (mode=list-all)
333
+ */
334
+ export function formatFlat(data) {
335
+ if (data.paths && data.paths.length > 0) {
336
+ console.log()
337
+ data.paths.forEach((p) => {
338
+ const display = p.isDynamic ? chalk.yellow(p.path) : chalk.green(p.path)
339
+ console.log(` ${display}`)
340
+ })
341
+ console.log()
342
+ } else {
343
+ console.log(chalk.dim("\nNo endpoints found.\n"))
344
+ }
345
+ }
346
+
347
+ /**
348
+ * Format unconnected integrations for the `connect` command.
349
+ * @param {Object} data - Response from listFunctions (mode=list at root)
350
+ */
351
+ export function formatUnconnected(data) {
352
+ if (data.notConnected && data.notConnected.length > 0) {
353
+ console.log(chalk.bold("\nAvailable to connect:\n"))
354
+ data.notConnected.forEach((item) => {
355
+ console.log(` ${chalk.cyan(item.sdk)}`)
356
+ console.log(` ${chalk.dim(item.displayName)}`)
357
+ })
358
+ console.log()
359
+ console.log(chalk.dim('Run "papercrane connect <name>" to get started.\n'))
360
+ } else {
361
+ console.log(chalk.dim("\nAll integrations are already connected.\n"))
362
+ }
363
+ }
364
+
365
+ /**
366
+ * Format JSON schema params for display
367
+ * @param {Object} schema - JSON Schema object
368
+ * @param {string} indent - Indentation string
369
+ */
370
+ function formatParamsSchema(schema, indent = "") {
371
+ if (!schema.properties) {
372
+ console.log(`${indent}${chalk.dim("(no parameters)")}\n`)
373
+ return
374
+ }
375
+
376
+ const required = new Set(schema.required || [])
377
+
378
+ Object.entries(schema.properties).forEach(([name, prop]) => {
379
+ const isRequired = required.has(name)
380
+ const reqLabel = isRequired ? chalk.red("required") : chalk.dim("optional")
381
+
382
+ let typeStr = prop.type || "any"
383
+ if (prop.enum) {
384
+ typeStr = `enum (${prop.enum.join(" | ")})`
385
+ }
386
+ if (prop.default !== undefined) {
387
+ typeStr += `, default: ${JSON.stringify(prop.default)}`
388
+ }
389
+ if (prop.maximum !== undefined) {
390
+ typeStr += `, max: ${prop.maximum}`
391
+ }
392
+
393
+ console.log(`${indent}${chalk.cyan(name)} ${chalk.dim(typeStr)} ${reqLabel}`)
394
+ if (prop.description) {
395
+ console.log(`${indent} ${chalk.dim(prop.description)}`)
396
+ }
397
+ })
398
+ console.log()
399
+ }
400
+
401
+ /**
402
+ * Build example params from JSON schema
403
+ * @param {Object} schema - JSON Schema object
404
+ * @returns {Object} Example params object
405
+ */
406
+ function buildExampleParams(schema) {
407
+ const params = {}
408
+ if (!schema.properties) return params
409
+
410
+ Object.entries(schema.properties).forEach(([name, prop]) => {
411
+ if (prop.enum) {
412
+ params[name] = prop.enum[0]
413
+ } else if (prop.default !== undefined) {
414
+ params[name] = prop.default
415
+ } else if (prop.type === "string") {
416
+ if (prop.pattern) {
417
+ // Try to generate from pattern
418
+ if (prop.pattern.includes("\\d{4}-\\d{2}-\\d{2}")) {
419
+ params[name] = "2024-01-01"
420
+ } else {
421
+ params[name] = "<value>"
422
+ }
423
+ } else {
424
+ params[name] = "<value>"
425
+ }
426
+ } else if (prop.type === "number" || prop.type === "integer") {
427
+ params[name] = prop.default || 10
428
+ } else if (prop.type === "boolean") {
429
+ params[name] = true
430
+ }
431
+ })
432
+
433
+ return params
434
+ }
435
+
436
+ /**
437
+ * Format function result output for display
438
+ * @param {Object} data - Response from runFunction
439
+ */
440
+ export function formatResult(data) {
441
+ // If response was streamed directly to stdout, nothing more to do
442
+ if (data.__streamed) {
443
+ return
444
+ }
445
+
446
+ if (data.result !== undefined) {
447
+ console.log(JSON.stringify(data.result, null, 2))
448
+ } else {
449
+ console.log(JSON.stringify(data, null, 2))
450
+ }
451
+ }
@@ -0,0 +1,134 @@
1
+ import axios from 'axios';
2
+ import chalk from 'chalk';
3
+ import open from 'open';
4
+ import { generatePKCE } from './pkce.js';
5
+ import { startCallbackServer, stopCallbackServer } from './callback-server.js';
6
+ import { saveGoogleCredentials } from './storage.js';
7
+
8
+ const GOOGLE_AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth';
9
+ const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token';
10
+ const REDIRECT_URI = 'http://127.0.0.1:8080/callback';
11
+ const PORT = 8080;
12
+
13
+ /**
14
+ * Handles the Google OAuth authentication flow
15
+ * @param {string[]} scopes - Array of OAuth scopes to request
16
+ * @param {string} clientId - Google OAuth Client ID
17
+ * @param {string} clientSecret - Google OAuth Client Secret
18
+ */
19
+ export async function handleGoogleAuth(scopes, clientId, clientSecret) {
20
+ console.log(chalk.bold('\nšŸ” Starting Google OAuth flow...\n'));
21
+
22
+ // Validate required credentials
23
+ if (!clientId) {
24
+ console.log(chalk.yellow('Google OAuth Client ID is required.'));
25
+ console.log(chalk.dim('You can create one in the Google Cloud Console:'));
26
+ console.log(chalk.dim('https://console.cloud.google.com/apis/credentials\n'));
27
+ throw new Error('Client ID is required. Pass it as: papercrane google <scopes> --client-id YOUR_CLIENT_ID');
28
+ }
29
+
30
+ if (!clientSecret) {
31
+ console.log(chalk.yellow('Google OAuth Client Secret is required.'));
32
+ console.log(chalk.dim('You can find this in the Google Cloud Console:'));
33
+ console.log(chalk.dim('https://console.cloud.google.com/apis/credentials\n'));
34
+ throw new Error('Client Secret is required. Pass it as: --client-secret YOUR_CLIENT_SECRET');
35
+ }
36
+
37
+ // Generate PKCE credentials
38
+ const pkce = generatePKCE();
39
+ console.log(chalk.green('āœ“ Generated PKCE credentials'));
40
+
41
+ // Start local callback server
42
+ let server;
43
+ let cleanupDone = false;
44
+
45
+ // Handle Ctrl+C gracefully
46
+ const handleExit = async () => {
47
+ if (!cleanupDone && server) {
48
+ cleanupDone = true;
49
+ console.log(chalk.yellow('\n\nāš ļø Authentication cancelled by user'));
50
+ await stopCallbackServer(server);
51
+ process.exit(0);
52
+ }
53
+ };
54
+
55
+ process.on('SIGINT', handleExit);
56
+
57
+ try {
58
+ const serverPromise = startCallbackServer(PORT);
59
+
60
+ // Build authorization URL
61
+ const scopeString = scopes.join(' ');
62
+ const authParams = new URLSearchParams({
63
+ client_id: clientId,
64
+ redirect_uri: REDIRECT_URI,
65
+ response_type: 'code',
66
+ scope: scopeString,
67
+ access_type: 'offline',
68
+ code_challenge: pkce.challenge,
69
+ code_challenge_method: 'S256',
70
+ prompt: 'consent'
71
+ });
72
+
73
+ const authUrl = `${GOOGLE_AUTH_URL}?${authParams.toString()}`;
74
+
75
+ console.log(chalk.green('āœ“ Authorization URL created'));
76
+ console.log(chalk.cyan('\n🌐 Opening browser for authentication...'));
77
+ console.log(chalk.dim(`If the browser doesn't open, visit: ${authUrl}\n`));
78
+
79
+ // Open the authorization URL in the default browser
80
+ await open(authUrl);
81
+
82
+ // Wait for the callback
83
+ const { code, server: callbackServer } = await serverPromise;
84
+ server = callbackServer;
85
+
86
+ console.log(chalk.green('\nāœ“ Authorization code received'));
87
+
88
+ // Exchange authorization code for tokens
89
+ console.log(chalk.cyan('šŸ”„ Exchanging authorization code for tokens...'));
90
+
91
+ const tokenResponse = await axios.post(
92
+ GOOGLE_TOKEN_URL,
93
+ new URLSearchParams({
94
+ code,
95
+ client_id: clientId,
96
+ client_secret: clientSecret,
97
+ redirect_uri: REDIRECT_URI,
98
+ grant_type: 'authorization_code',
99
+ code_verifier: pkce.verifier
100
+ }),
101
+ {
102
+ headers: {
103
+ 'Content-Type': 'application/x-www-form-urlencoded'
104
+ }
105
+ }
106
+ );
107
+
108
+ const credentials = tokenResponse.data;
109
+ console.log(chalk.green('āœ“ Tokens received successfully'));
110
+
111
+ // Save credentials
112
+ console.log(chalk.cyan('šŸ’¾ Saving credentials...'));
113
+ const filepath = await saveGoogleCredentials(credentials, clientId, clientSecret);
114
+ console.log(chalk.green(`āœ“ Credentials saved to: ${filepath}`));
115
+
116
+ console.log(chalk.bold.green('\nāœ… Authentication completed successfully!\n'));
117
+ console.log(chalk.dim('Granted scopes:'));
118
+ console.log(chalk.dim(` ${credentials.scope}\n`));
119
+
120
+ // Stop the server
121
+ await stopCallbackServer(server);
122
+ } catch (error) {
123
+ if (server) {
124
+ await stopCallbackServer(server);
125
+ }
126
+
127
+ if (error.response) {
128
+ console.error(chalk.red('\nāŒ Token exchange failed:'));
129
+ console.error(chalk.red(` ${error.response.data.error_description || error.response.data.error}`));
130
+ } else {
131
+ throw error;
132
+ }
133
+ }
134
+ }
@@ -0,0 +1,116 @@
1
+ import chalk from 'chalk';
2
+ import {
3
+ listGoogleCredentials,
4
+ getGoogleCredentialsDir,
5
+ listFacebookCredentials,
6
+ getFacebookCredentialsDir
7
+ } from './storage.js';
8
+ import { listCloudCredentials } from './cloud-client.js';
9
+ import { isLoggedIn } from './config.js';
10
+
11
+ /**
12
+ * Lists all stored credentials
13
+ */
14
+ export async function listCredentials() {
15
+ console.log(chalk.bold('\nšŸ“‹ Stored Credentials\n'));
16
+
17
+ // Check for cloud credentials
18
+ let cloudCredentials = [];
19
+ const loggedIn = await isLoggedIn();
20
+
21
+ if (loggedIn) {
22
+ try {
23
+ cloudCredentials = await listCloudCredentials();
24
+ } catch (error) {
25
+ console.log(chalk.yellow('āš ļø Unable to fetch cloud credentials:'), error.message);
26
+ console.log();
27
+ }
28
+ }
29
+
30
+ const googleCredentials = await listGoogleCredentials();
31
+ const facebookCredentials = await listFacebookCredentials();
32
+
33
+ if (googleCredentials.length === 0 && facebookCredentials.length === 0 && cloudCredentials.length === 0) {
34
+ console.log(chalk.yellow('No credentials found.'));
35
+ console.log(chalk.dim('\nUse "papercrane google <scopes>" or "papercrane facebook <scopes> --app-id YOUR_APP_ID" to authenticate.\n'));
36
+ if (!loggedIn) {
37
+ console.log(chalk.dim('Or run "papercrane login" to use cloud credentials.\n'));
38
+ }
39
+ return;
40
+ }
41
+
42
+ // Display Cloud credentials
43
+ if (cloudCredentials.length > 0) {
44
+ console.log(chalk.bold.magenta('ā˜ļø Cloud Credentials:'));
45
+ console.log(chalk.dim(`Found ${cloudCredentials.length} credential(s) in cloud storage\n`));
46
+
47
+ cloudCredentials.forEach((cred, index) => {
48
+ const createdAt = cred.createdAt ? new Date(cred.createdAt) : null;
49
+ const expiresAt = cred.expiresAt ? new Date(cred.expiresAt) : null;
50
+ const isExpired = expiresAt ? Date.now() > cred.expiresAt : false;
51
+ const scopeDisplay = Array.isArray(cred.scopes) ? cred.scopes.join(' ') : (cred.scopes || '');
52
+
53
+ console.log(chalk.bold(` ${index + 1}. ${cred.displayName || cred.provider} - ${cred.instanceName || 'Default'}`));
54
+ if (createdAt) {
55
+ console.log(` Created: ${createdAt.toLocaleString()}`);
56
+ }
57
+ if (expiresAt) {
58
+ console.log(` Expires: ${expiresAt.toLocaleString()} ${isExpired ? chalk.red('(EXPIRED)') : chalk.green('(Valid)')}`);
59
+ }
60
+ if (scopeDisplay) {
61
+ console.log(` Scopes: ${chalk.cyan(scopeDisplay)}`);
62
+ }
63
+ console.log(` Provider: ${cred.provider}`);
64
+ console.log(` Source: ${chalk.magenta('Cloud')}`);
65
+ console.log();
66
+ });
67
+ }
68
+
69
+ // Display Google credentials
70
+ if (googleCredentials.length > 0) {
71
+ console.log(chalk.bold.cyan('Google Credentials:'));
72
+ console.log(chalk.dim(`Found ${googleCredentials.length} credential file(s)\n`));
73
+
74
+ googleCredentials.forEach((cred, index) => {
75
+ const { file, filepath, data } = cred;
76
+ const createdAt = new Date(data.created_at);
77
+ const expiresAt = new Date(data.expires_at);
78
+ const isExpired = Date.now() > data.expires_at;
79
+
80
+ console.log(chalk.bold(` ${index + 1}. ${file}`));
81
+ console.log(chalk.dim(` Path: ${filepath}`));
82
+ console.log(` Created: ${createdAt.toLocaleString()}`);
83
+ console.log(` Expires: ${expiresAt.toLocaleString()} ${isExpired ? chalk.red('(EXPIRED)') : chalk.green('(Valid)')}`);
84
+ console.log(` Token Type: ${data.token_type}`);
85
+ console.log(` Scopes: ${chalk.cyan(data.scope)}`);
86
+ console.log(` Has Refresh Token: ${data.refresh_token ? chalk.green('Yes') : chalk.red('No')}`);
87
+ console.log();
88
+ });
89
+
90
+ console.log(chalk.dim(` Storage directory: ${getGoogleCredentialsDir()}\n`));
91
+ }
92
+
93
+ // Display Facebook credentials
94
+ if (facebookCredentials.length > 0) {
95
+ console.log(chalk.bold.blue('Facebook Credentials:'));
96
+ console.log(chalk.dim(`Found ${facebookCredentials.length} credential file(s)\n`));
97
+
98
+ facebookCredentials.forEach((cred, index) => {
99
+ const { file, filepath, data } = cred;
100
+ const createdAt = new Date(data.created_at);
101
+ const expiresAt = new Date(data.expires_at);
102
+ const isExpired = Date.now() > data.expires_at;
103
+
104
+ console.log(chalk.bold(` ${index + 1}. ${file}`));
105
+ console.log(chalk.dim(` Path: ${filepath}`));
106
+ console.log(` Created: ${createdAt.toLocaleString()}`);
107
+ console.log(` Expires: ${expiresAt.toLocaleString()} ${isExpired ? chalk.red('(EXPIRED)') : chalk.green('(Valid)')}`);
108
+ console.log(` Token Type: ${data.token_type}`);
109
+ console.log(` Scopes: ${chalk.cyan(data.scope)}`);
110
+ console.log(` App ID: ${data.app_id}`);
111
+ console.log();
112
+ });
113
+
114
+ console.log(chalk.dim(` Storage directory: ${getFacebookCredentialsDir()}\n`));
115
+ }
116
+ }