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