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