@libai168/dsh-tool-google-drive 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +129 -0
- package/README.zh.md +129 -0
- package/lib/client.js +467 -0
- package/lib/index.js +339 -0
- package/lib/types/client.d.ts +225 -0
- package/lib/types/index.d.ts +16 -0
- package/package.json +70 -0
- package/scripts/auth-google.mjs +355 -0
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createHash, randomBytes } from 'node:crypto'
|
|
3
|
+
import { createServer } from 'node:http'
|
|
4
|
+
import { spawn } from 'node:child_process'
|
|
5
|
+
import process from 'node:process'
|
|
6
|
+
|
|
7
|
+
const AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth'
|
|
8
|
+
const TOKEN_URL = 'https://oauth2.googleapis.com/token'
|
|
9
|
+
const DEFAULT_PORT = 53682
|
|
10
|
+
const DEFAULT_CALLBACK_PATH = '/oauth2callback'
|
|
11
|
+
const DEFAULT_SCOPES = [
|
|
12
|
+
'https://www.googleapis.com/auth/drive.readonly',
|
|
13
|
+
'https://www.googleapis.com/auth/drive.metadata.readonly',
|
|
14
|
+
'https://www.googleapis.com/auth/documents.readonly',
|
|
15
|
+
'https://www.googleapis.com/auth/spreadsheets.readonly',
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
function printHelp() {
|
|
19
|
+
console.log(`Google OAuth helper for @libai168/dsh-tool-google-drive
|
|
20
|
+
|
|
21
|
+
Usage:
|
|
22
|
+
npm run auth:google -- --client-id <id> --client-secret <secret>
|
|
23
|
+
npm run auth:google -- --client-id <id> --client-secret <secret> --no-open
|
|
24
|
+
npm run auth:google -- --client-id <id> --client-secret <secret> --code <authorization-code>
|
|
25
|
+
npm run auth:google -- --client-id <id> --print-url
|
|
26
|
+
|
|
27
|
+
Options:
|
|
28
|
+
--client-id <id> Google OAuth client ID. Env: GOOGLE_CLIENT_ID
|
|
29
|
+
--client-secret <secret> Google OAuth client secret. Env: GOOGLE_CLIENT_SECRET
|
|
30
|
+
--redirect-uri <uri> OAuth redirect URI. Env: GOOGLE_REDIRECT_URI
|
|
31
|
+
Default: http://127.0.0.1:${DEFAULT_PORT}${DEFAULT_CALLBACK_PATH}
|
|
32
|
+
--port <number> Local callback server port when --redirect-uri is omitted.
|
|
33
|
+
--scope <scope> Add one scope. May be repeated. Defaults to full read-only coverage.
|
|
34
|
+
--scopes <list> Space or comma separated scopes. Overrides defaults.
|
|
35
|
+
--code <code> Exchange an authorization code directly instead of starting server.
|
|
36
|
+
--code-verifier <value> PKCE verifier for a code produced by a prior --print-url run.
|
|
37
|
+
--no-open Print the URL, but do not open a browser.
|
|
38
|
+
--print-url Print the authorization URL and exit without network calls.
|
|
39
|
+
--json After token exchange, also print the raw token response as JSON.
|
|
40
|
+
-h, --help Show this help.
|
|
41
|
+
|
|
42
|
+
Notes:
|
|
43
|
+
- The authorization URL intentionally never includes the client secret.
|
|
44
|
+
- Use access_type=offline and prompt=consent so Google can return a refresh token.
|
|
45
|
+
- The redirect URI must match your Google Cloud OAuth client configuration.
|
|
46
|
+
- Prefer the normal callback flow; use --code only when you cannot receive loopback callbacks.
|
|
47
|
+
`)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function takeValue(argv, index, flag) {
|
|
51
|
+
const value = argv[index + 1]
|
|
52
|
+
if (!value || value.startsWith('--')) throw new Error(`${flag} requires a value`)
|
|
53
|
+
return value
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function splitScopes(value) {
|
|
57
|
+
return value.split(/[\s,]+/).map((scope) => scope.trim()).filter(Boolean)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function parseArgs(argv) {
|
|
61
|
+
const args = {
|
|
62
|
+
clientId: process.env.GOOGLE_CLIENT_ID || '',
|
|
63
|
+
clientSecret: process.env.GOOGLE_CLIENT_SECRET || '',
|
|
64
|
+
redirectUri: process.env.GOOGLE_REDIRECT_URI || '',
|
|
65
|
+
port: DEFAULT_PORT,
|
|
66
|
+
scopes: [...DEFAULT_SCOPES],
|
|
67
|
+
scopesSpecified: false,
|
|
68
|
+
code: '',
|
|
69
|
+
codeVerifier: '',
|
|
70
|
+
noOpen: false,
|
|
71
|
+
printUrl: false,
|
|
72
|
+
json: false,
|
|
73
|
+
help: false,
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
77
|
+
const arg = argv[i]
|
|
78
|
+
switch (arg) {
|
|
79
|
+
case '--client-id':
|
|
80
|
+
args.clientId = takeValue(argv, i, arg); i += 1; break
|
|
81
|
+
case '--client-secret':
|
|
82
|
+
args.clientSecret = takeValue(argv, i, arg); i += 1; break
|
|
83
|
+
case '--redirect-uri':
|
|
84
|
+
args.redirectUri = takeValue(argv, i, arg); i += 1; break
|
|
85
|
+
case '--port': {
|
|
86
|
+
const raw = takeValue(argv, i, arg); i += 1
|
|
87
|
+
const port = Number(raw)
|
|
88
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error('--port must be an integer from 1 to 65535')
|
|
89
|
+
args.port = port
|
|
90
|
+
break
|
|
91
|
+
}
|
|
92
|
+
case '--scope': {
|
|
93
|
+
const value = takeValue(argv, i, arg); i += 1
|
|
94
|
+
if (!args.scopesSpecified) { args.scopes = []; args.scopesSpecified = true }
|
|
95
|
+
args.scopes.push(...splitScopes(value))
|
|
96
|
+
break
|
|
97
|
+
}
|
|
98
|
+
case '--scopes': {
|
|
99
|
+
const value = takeValue(argv, i, arg); i += 1
|
|
100
|
+
args.scopes = splitScopes(value)
|
|
101
|
+
args.scopesSpecified = true
|
|
102
|
+
break
|
|
103
|
+
}
|
|
104
|
+
case '--code':
|
|
105
|
+
args.code = takeValue(argv, i, arg); i += 1; break
|
|
106
|
+
case '--code-verifier':
|
|
107
|
+
args.codeVerifier = takeValue(argv, i, arg); i += 1; break
|
|
108
|
+
case '--no-open':
|
|
109
|
+
args.noOpen = true; break
|
|
110
|
+
case '--print-url':
|
|
111
|
+
args.printUrl = true; args.noOpen = true; break
|
|
112
|
+
case '--json':
|
|
113
|
+
args.json = true; break
|
|
114
|
+
case '-h':
|
|
115
|
+
case '--help':
|
|
116
|
+
args.help = true; break
|
|
117
|
+
default:
|
|
118
|
+
throw new Error(`Unknown option: ${arg}`)
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (!args.redirectUri) args.redirectUri = `http://127.0.0.1:${args.port}${DEFAULT_CALLBACK_PATH}`
|
|
123
|
+
args.scopes = [...new Set(args.scopes)]
|
|
124
|
+
return args
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function requireAuthConfig(args) {
|
|
128
|
+
if (!args.clientId) throw new Error('Missing --client-id or GOOGLE_CLIENT_ID')
|
|
129
|
+
if (!args.clientSecret && !args.printUrl) throw new Error('Missing --client-secret or GOOGLE_CLIENT_SECRET')
|
|
130
|
+
if (!args.scopes.length) throw new Error('At least one OAuth scope is required')
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function base64Url(buffer) {
|
|
134
|
+
return Buffer.from(buffer).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '')
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function createPkcePair() {
|
|
138
|
+
const verifier = base64Url(randomBytes(64))
|
|
139
|
+
const challenge = base64Url(createHash('sha256').update(verifier).digest())
|
|
140
|
+
return { verifier, challenge, method: 'S256' }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function buildAuthorizationUrl({ clientId, redirectUri, scopes, state, codeChallenge }) {
|
|
144
|
+
const url = new URL(AUTH_URL)
|
|
145
|
+
url.searchParams.set('client_id', clientId)
|
|
146
|
+
url.searchParams.set('redirect_uri', redirectUri)
|
|
147
|
+
url.searchParams.set('response_type', 'code')
|
|
148
|
+
url.searchParams.set('scope', scopes.join(' '))
|
|
149
|
+
url.searchParams.set('access_type', 'offline')
|
|
150
|
+
url.searchParams.set('prompt', 'consent')
|
|
151
|
+
url.searchParams.set('include_granted_scopes', 'true')
|
|
152
|
+
url.searchParams.set('state', state)
|
|
153
|
+
if (codeChallenge) {
|
|
154
|
+
url.searchParams.set('code_challenge', codeChallenge)
|
|
155
|
+
url.searchParams.set('code_challenge_method', 'S256')
|
|
156
|
+
}
|
|
157
|
+
return url.toString()
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function openBrowser(url) {
|
|
161
|
+
const commands = process.platform === 'darwin'
|
|
162
|
+
? ['open', url]
|
|
163
|
+
: process.platform === 'win32'
|
|
164
|
+
? ['cmd', '/c', 'start', '', url]
|
|
165
|
+
: ['xdg-open', url]
|
|
166
|
+
|
|
167
|
+
const child = spawn(commands[0], commands.slice(1), { stdio: 'ignore', detached: true })
|
|
168
|
+
child.unref()
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function html(title, body) {
|
|
172
|
+
return `<!doctype html><html><head><meta charset="utf-8"><title>${title}</title></head><body><h1>${title}</h1><p>${body}</p></body></html>`
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function waitForAuthorizationCode({ redirectUri, expectedState }) {
|
|
176
|
+
const redirect = new URL(redirectUri)
|
|
177
|
+
if (!['127.0.0.1', 'localhost'].includes(redirect.hostname)) {
|
|
178
|
+
throw new Error('Automatic callback server only supports localhost/127.0.0.1 redirect URIs. Use --code for non-local redirects.')
|
|
179
|
+
}
|
|
180
|
+
if (redirect.protocol !== 'http:') throw new Error('Automatic callback server requires an http:// loopback redirect URI')
|
|
181
|
+
|
|
182
|
+
let settled = false
|
|
183
|
+
let resolveCode
|
|
184
|
+
let rejectCode
|
|
185
|
+
const codePromise = new Promise((resolve, reject) => { resolveCode = resolve; rejectCode = reject })
|
|
186
|
+
|
|
187
|
+
const server = createServer((req, res) => {
|
|
188
|
+
const requestUrl = new URL(req.url || '/', redirect.origin)
|
|
189
|
+
if (requestUrl.pathname !== redirect.pathname) {
|
|
190
|
+
res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' })
|
|
191
|
+
res.end('Not found')
|
|
192
|
+
return
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const error = requestUrl.searchParams.get('error')
|
|
196
|
+
const code = requestUrl.searchParams.get('code')
|
|
197
|
+
const state = requestUrl.searchParams.get('state')
|
|
198
|
+
|
|
199
|
+
if (error) {
|
|
200
|
+
settled = true
|
|
201
|
+
res.writeHead(400, { 'content-type': 'text/html; charset=utf-8' })
|
|
202
|
+
res.end(html('Google OAuth failed', `Google returned: ${error}. You can close this tab.`))
|
|
203
|
+
rejectCode(new Error(`Google OAuth error: ${error}`))
|
|
204
|
+
return
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (state !== expectedState) {
|
|
208
|
+
settled = true
|
|
209
|
+
res.writeHead(400, { 'content-type': 'text/html; charset=utf-8' })
|
|
210
|
+
res.end(html('Google OAuth failed', 'State mismatch. You can close this tab.'))
|
|
211
|
+
rejectCode(new Error('OAuth state mismatch'))
|
|
212
|
+
return
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (!code) {
|
|
216
|
+
res.writeHead(400, { 'content-type': 'text/plain; charset=utf-8' })
|
|
217
|
+
res.end('Missing code')
|
|
218
|
+
return
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
settled = true
|
|
222
|
+
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' })
|
|
223
|
+
res.end(html('Google OAuth complete', 'Authorization code received. Return to your terminal.'))
|
|
224
|
+
resolveCode(code)
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
await new Promise((resolve, reject) => {
|
|
228
|
+
const onError = (error) => reject(error)
|
|
229
|
+
server.once('error', onError)
|
|
230
|
+
server.listen(Number(redirect.port), redirect.hostname, () => {
|
|
231
|
+
server.off('error', onError)
|
|
232
|
+
resolve()
|
|
233
|
+
})
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
try {
|
|
237
|
+
return await codePromise
|
|
238
|
+
} finally {
|
|
239
|
+
if (!settled) server.close()
|
|
240
|
+
else server.close()
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export async function exchangeAuthorizationCode({ clientId, clientSecret, redirectUri, code, codeVerifier }) {
|
|
245
|
+
const body = new URLSearchParams({
|
|
246
|
+
client_id: clientId,
|
|
247
|
+
client_secret: clientSecret,
|
|
248
|
+
code,
|
|
249
|
+
redirect_uri: redirectUri,
|
|
250
|
+
grant_type: 'authorization_code',
|
|
251
|
+
})
|
|
252
|
+
if (codeVerifier) body.set('code_verifier', codeVerifier)
|
|
253
|
+
|
|
254
|
+
const response = await fetch(TOKEN_URL, {
|
|
255
|
+
method: 'POST',
|
|
256
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
257
|
+
body,
|
|
258
|
+
})
|
|
259
|
+
const text = await response.text()
|
|
260
|
+
let parsed
|
|
261
|
+
try { parsed = text ? JSON.parse(text) : {} } catch { parsed = { raw: text } }
|
|
262
|
+
|
|
263
|
+
if (!response.ok) {
|
|
264
|
+
const detail = parsed.error_description || parsed.error || text || `${response.status} ${response.statusText}`
|
|
265
|
+
throw new Error(`Token exchange failed: ${detail}`)
|
|
266
|
+
}
|
|
267
|
+
return parsed
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function yamlSingleQuote(value) {
|
|
271
|
+
return `'${String(value).replace(/'/g, "''")}'`
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export function renderCordisSnippet({ clientId, clientSecret, refreshToken }) {
|
|
275
|
+
return `- name: 'github:LJH-snow/dsh-tool-google-drive'\n config:\n clientId: ${yamlSingleQuote(clientId)}\n clientSecret: ${yamlSingleQuote(clientSecret)}\n refreshToken: ${yamlSingleQuote(refreshToken)}\n`
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function printNextSteps(args, tokenResponse) {
|
|
279
|
+
if (!tokenResponse.refresh_token) {
|
|
280
|
+
console.error('\nNo refresh_token was returned by Google.')
|
|
281
|
+
console.error('Try again with prompt=consent (already enabled here), or remove the old grant for this app and reauthorize.')
|
|
282
|
+
process.exitCode = 2
|
|
283
|
+
return
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
console.log('\nSuccess. Copy this snippet into your DeepSeek Harness / Cordis config:\n')
|
|
287
|
+
console.log(renderCordisSnippet({ clientId: args.clientId, clientSecret: args.clientSecret, refreshToken: tokenResponse.refresh_token }))
|
|
288
|
+
console.log('Keep the refresh token and client secret private. Do not commit them to git.')
|
|
289
|
+
if (args.json) {
|
|
290
|
+
console.log('\nRaw token response JSON:\n')
|
|
291
|
+
console.log(JSON.stringify(tokenResponse, null, 2))
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export async function main(argv = process.argv.slice(2)) {
|
|
296
|
+
const args = parseArgs(argv)
|
|
297
|
+
if (args.help) { printHelp(); return }
|
|
298
|
+
requireAuthConfig(args)
|
|
299
|
+
|
|
300
|
+
if (args.code) {
|
|
301
|
+
console.log('Exchanging supplied authorization code for tokens...')
|
|
302
|
+
const tokenResponse = await exchangeAuthorizationCode({
|
|
303
|
+
clientId: args.clientId,
|
|
304
|
+
clientSecret: args.clientSecret,
|
|
305
|
+
redirectUri: args.redirectUri,
|
|
306
|
+
code: args.code,
|
|
307
|
+
codeVerifier: args.codeVerifier,
|
|
308
|
+
})
|
|
309
|
+
printNextSteps(args, tokenResponse)
|
|
310
|
+
return
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const state = base64Url(randomBytes(24))
|
|
314
|
+
const pkce = createPkcePair()
|
|
315
|
+
const url = buildAuthorizationUrl({
|
|
316
|
+
clientId: args.clientId,
|
|
317
|
+
redirectUri: args.redirectUri,
|
|
318
|
+
scopes: args.scopes,
|
|
319
|
+
state,
|
|
320
|
+
codeChallenge: pkce.challenge,
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
console.log('Google authorization URL:\n')
|
|
324
|
+
console.log(url)
|
|
325
|
+
console.log('\nRequested scopes:')
|
|
326
|
+
for (const scope of args.scopes) console.log(`- ${scope}`)
|
|
327
|
+
|
|
328
|
+
if (args.printUrl) {
|
|
329
|
+
console.log('\nPKCE code verifier for later --code exchange (keep temporary/private):')
|
|
330
|
+
console.log(pkce.verifier)
|
|
331
|
+
return
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
console.log(`\nWaiting for OAuth callback at ${args.redirectUri}`)
|
|
335
|
+
if (!args.noOpen) await openBrowser(url)
|
|
336
|
+
else console.log('Browser auto-open disabled. Open the URL above manually.')
|
|
337
|
+
const code = await waitForAuthorizationCode({ redirectUri: args.redirectUri, expectedState: state })
|
|
338
|
+
|
|
339
|
+
console.log('\nExchanging authorization code for tokens...')
|
|
340
|
+
const tokenResponse = await exchangeAuthorizationCode({
|
|
341
|
+
clientId: args.clientId,
|
|
342
|
+
clientSecret: args.clientSecret,
|
|
343
|
+
redirectUri: args.redirectUri,
|
|
344
|
+
code,
|
|
345
|
+
codeVerifier: pkce.verifier,
|
|
346
|
+
})
|
|
347
|
+
printNextSteps(args, tokenResponse)
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
351
|
+
main().catch((error) => {
|
|
352
|
+
console.error(`\n${error instanceof Error ? error.message : String(error)}`)
|
|
353
|
+
process.exitCode = 1
|
|
354
|
+
})
|
|
355
|
+
}
|