@ryuu-reinzz/ryuu-cli 0.4.3 → 0.5.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/CHANGELOG.md +70 -0
- package/README.md +26 -0
- package/docs/drive.md +484 -0
- package/package.json +4 -1
- package/plugins/google-drive/auth.js +57 -0
- package/plugins/google-drive/backup.js +282 -0
- package/plugins/google-drive/command.js +266 -0
- package/plugins/google-drive/config.js +252 -0
- package/plugins/google-drive/credentials.js +171 -0
- package/plugins/google-drive/drive-api.js +304 -0
- package/plugins/google-drive/index.js +26 -0
- package/plugins/google-drive/manager.js +507 -0
- package/plugins/google-drive/oauth-backend.js +161 -0
- package/plugins/google-drive/secret.js +50 -0
- package/plugins/google-drive/token-client.js +80 -0
- package/plugins/google-drive/tool-helpers.js +46 -0
- package/plugins/google-drive/tools-backup.js +204 -0
- package/plugins/google-drive/tools-files.js +315 -0
- package/plugins/google-drive/tools.js +13 -0
- package/plugins/google-drive/zip-reader.js +217 -0
- package/plugins/google-drive/zip-writer.js +204 -0
- package/plugins/index.js +56 -0
- package/src/agent/agent.js +12 -2
- package/src/cli/app.js +16 -0
- package/src/cli/commands.js +19 -0
- package/src/cli/markdown.js +10 -5
- package/src/cli/renderer.js +38 -5
- package/src/cli/tui.js +80 -9
- package/src/tools/registry.js +13 -0
|
@@ -0,0 +1,507 @@
|
|
|
1
|
+
import { RyuuError } from '../../src/utils/errors.js';
|
|
2
|
+
import fsp from 'node:fs/promises';
|
|
3
|
+
import {
|
|
4
|
+
DEFAULT_GOOGLE_DRIVE_CONFIG,
|
|
5
|
+
describeDriveConfig,
|
|
6
|
+
drivePaths,
|
|
7
|
+
readDriveConfig,
|
|
8
|
+
updateDriveConfig,
|
|
9
|
+
validateConfigEntry,
|
|
10
|
+
writeDriveConfig,
|
|
11
|
+
} from './config.js';
|
|
12
|
+
import { clearCredentials, describeCredentials, loadCredentials, saveCredentials } from './credentials.js';
|
|
13
|
+
import { describeOAuthFailure, openBrowser } from './auth.js';
|
|
14
|
+
import {
|
|
15
|
+
refreshAccessToken,
|
|
16
|
+
revokeRemoteToken,
|
|
17
|
+
startLogin,
|
|
18
|
+
waitForTokens,
|
|
19
|
+
} from './oauth-backend.js';
|
|
20
|
+
import { BackendTokenAuth } from './token-client.js';
|
|
21
|
+
import { DriveClient } from './drive-api.js';
|
|
22
|
+
import { createDriveTools } from './tools.js';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Lifecycle manager for the Google Drive plugin.
|
|
26
|
+
*
|
|
27
|
+
* It owns the four states the CLI must honour:
|
|
28
|
+
* enabled + authenticated → tools registered and usable
|
|
29
|
+
* disabled + authenticated → tools withheld from the model
|
|
30
|
+
* enabled + unauthenticated → tools withheld, /drive login is suggested
|
|
31
|
+
* disabled + unauthenticated→ integration inactive
|
|
32
|
+
*
|
|
33
|
+
* Tool handlers re-check freshness on every call (`requireClient`), so a model
|
|
34
|
+
* holding a stale tool list cannot act after `/drive disable`.
|
|
35
|
+
*/
|
|
36
|
+
export class GoogleDrivePlugin {
|
|
37
|
+
constructor({ logger = null, workspace = null, config = null, paths = null } = {}) {
|
|
38
|
+
this.name = 'google-drive';
|
|
39
|
+
this.version = '1.0.0';
|
|
40
|
+
this.command = 'drive';
|
|
41
|
+
this.logger = logger;
|
|
42
|
+
this.workspace = workspace;
|
|
43
|
+
this.appConfig = config;
|
|
44
|
+
this.paths = paths || drivePaths();
|
|
45
|
+
this.settings = null;
|
|
46
|
+
this.settingsMtime = null;
|
|
47
|
+
this.stored = null;
|
|
48
|
+
this.client = null;
|
|
49
|
+
this.clientFor = null;
|
|
50
|
+
this.lastCheck = null;
|
|
51
|
+
this.lastCheckOk = null;
|
|
52
|
+
this.loggingIn = false;
|
|
53
|
+
this.tools = createDriveTools(this);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Absolute paths of the plugin's own configuration and credential files. */
|
|
57
|
+
get pathsInfo() {
|
|
58
|
+
return { ...this.paths };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Cached config read, invalidated by the file's mtime.
|
|
63
|
+
*
|
|
64
|
+
* The cache must not hide a change made by another process or by a test that
|
|
65
|
+
* writes the file directly — `/drive enable` would otherwise be ignored until
|
|
66
|
+
* the CLI restarted.
|
|
67
|
+
*/
|
|
68
|
+
async loadConfig({ force = false } = {}) {
|
|
69
|
+
const mtime = await fsp
|
|
70
|
+
.stat(this.paths.configFile)
|
|
71
|
+
.then((stat) => stat.mtimeMs)
|
|
72
|
+
.catch(() => null);
|
|
73
|
+
if (force || !this.settings || mtime !== this.settingsMtime) {
|
|
74
|
+
this.settings = await readDriveConfig({ paths: this.paths });
|
|
75
|
+
this.settingsMtime = mtime;
|
|
76
|
+
}
|
|
77
|
+
return this.settings;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async loadCredentials({ force = false } = {}) {
|
|
81
|
+
if (!this.stored || force) this.stored = await loadCredentials({ paths: this.paths });
|
|
82
|
+
return this.stored;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Metadata only (never a token). */
|
|
86
|
+
async authInfo() {
|
|
87
|
+
await this.loadCredentials();
|
|
88
|
+
return describeCredentials(this.stored);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async isAuthenticated() {
|
|
92
|
+
const info = await this.authInfo();
|
|
93
|
+
return info.connected;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async isEnabled() {
|
|
97
|
+
const settings = await this.loadConfig();
|
|
98
|
+
return Boolean(settings.enabled);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Token masih dianggap segar? Dipakai untuk memutuskan refresh. */
|
|
102
|
+
static isExpired(stored) {
|
|
103
|
+
if (!stored?.expiryDate) return false;
|
|
104
|
+
return Date.now() > stored.expiryDate - 60000;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Minta access token baru ke backend memakai grant hasil login. */
|
|
108
|
+
async refreshStored() {
|
|
109
|
+
const stored = await this.loadCredentials({ force: true });
|
|
110
|
+
if (!stored?.grantId) {
|
|
111
|
+
throw new RyuuError('Izin Drive tidak tersedia. Jalankan /drive login.', {
|
|
112
|
+
code: 'DRIVE_UNAUTHORIZED',
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
const tokens = await refreshAccessToken({ grantId: stored.grantId });
|
|
116
|
+
await saveCredentials(
|
|
117
|
+
{
|
|
118
|
+
tokens: {
|
|
119
|
+
grant_id: tokens.grantId || stored.grantId,
|
|
120
|
+
access_token: tokens.accessToken,
|
|
121
|
+
refresh_token: tokens.refreshToken || stored.refreshToken,
|
|
122
|
+
expiry_date: tokens.expiryDate,
|
|
123
|
+
token_type: tokens.tokenType,
|
|
124
|
+
scope: tokens.scope || stored.scope,
|
|
125
|
+
},
|
|
126
|
+
account: stored.account || null,
|
|
127
|
+
},
|
|
128
|
+
{ paths: this.paths },
|
|
129
|
+
);
|
|
130
|
+
this.stored = null;
|
|
131
|
+
this.client = null;
|
|
132
|
+
this.clientFor = null;
|
|
133
|
+
return tokens;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Build (or reuse) a Drive client bound to the stored tokens. */
|
|
137
|
+
async getClient({ refreshAccount = false } = {}) {
|
|
138
|
+
const stored = await this.loadCredentials();
|
|
139
|
+
if (!stored?.refreshToken && !stored?.accessToken) {
|
|
140
|
+
throw new RyuuError('Google Drive belum terhubung. Jalankan /drive login terlebih dahulu.', {
|
|
141
|
+
code: 'DRIVE_UNAUTHORIZED',
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
if (this.client && this.clientFor === stored.accessToken) return this.client;
|
|
145
|
+
if (GoogleDrivePlugin.isExpired(stored)) {
|
|
146
|
+
await this.refreshStored().catch((error) => {
|
|
147
|
+
this.logger?.warn('drive token refresh failed', { error: error.message });
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
const current = await this.loadCredentials({ force: true });
|
|
151
|
+
const auth = new BackendTokenAuth({
|
|
152
|
+
accessToken: current?.accessToken || null,
|
|
153
|
+
expiresAt: current?.expiryDate || null,
|
|
154
|
+
logger: this.logger,
|
|
155
|
+
onRefresh: () => this.refreshStored(),
|
|
156
|
+
});
|
|
157
|
+
const client = new DriveClient({ auth, logger: this.logger });
|
|
158
|
+
this.client = client;
|
|
159
|
+
this.clientFor = current?.accessToken || null;
|
|
160
|
+
if (refreshAccount) await this.refreshAccount();
|
|
161
|
+
return client;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Guard used by every tool and command: enabled AND authenticated. */
|
|
165
|
+
async requireClient({ refreshAccount = false } = {}) {
|
|
166
|
+
const settings = await this.loadConfig();
|
|
167
|
+
if (!settings.enabled) {
|
|
168
|
+
throw new RyuuError(
|
|
169
|
+
'Integrasi Google Drive sedang nonaktif. Jalankan /drive enable untuk mengaktifkannya.',
|
|
170
|
+
{ code: 'DRIVE_DISABLED' },
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
return this.getClient({ refreshAccount });
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Re-read account identity + quota and persist it next to the tokens. */
|
|
177
|
+
async refreshAccount() {
|
|
178
|
+
const stored = await loadCredentials({ paths: this.paths });
|
|
179
|
+
if (!stored) return null;
|
|
180
|
+
const client = this.client || (await this.getClient());
|
|
181
|
+
const about = await client.getAbout();
|
|
182
|
+
await saveCredentials(
|
|
183
|
+
{
|
|
184
|
+
tokens: {
|
|
185
|
+
access_token: stored.accessToken,
|
|
186
|
+
refresh_token: stored.refreshToken,
|
|
187
|
+
expiry_date: stored.expiryDate,
|
|
188
|
+
token_type: stored.tokenType,
|
|
189
|
+
scope: stored.scope,
|
|
190
|
+
},
|
|
191
|
+
account: about,
|
|
192
|
+
},
|
|
193
|
+
{ paths: this.paths },
|
|
194
|
+
);
|
|
195
|
+
this.stored = null;
|
|
196
|
+
return about;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Interactive OAuth login lewat backend API.
|
|
201
|
+
*
|
|
202
|
+
* CLI meminta URL otorisasi ke `website/api` (pemegang Client ID + Secret),
|
|
203
|
+
* lalu mem-poll sampai server menukar authorization code menjadi token.
|
|
204
|
+
* Tidak ada callback server lokal, dan tidak ada kredensial Google di sisi
|
|
205
|
+
* CLI. `print` menerima baris untuk pengguna; URL selalu dicetak sehingga
|
|
206
|
+
* VPS headless bisa membukanya di perangkat lain.
|
|
207
|
+
*/
|
|
208
|
+
async login({ print = () => {}, showCode = null, confirmReplace = null, openBrowser: shouldOpen = true, timeoutMs = 300000 } = {}) {
|
|
209
|
+
if (this.loggingIn) throw new RyuuError('Login Google Drive sedang berjalan.', { code: 'DRIVE_BUSY' });
|
|
210
|
+
const settings = await this.loadConfig();
|
|
211
|
+
const existing = await this.authInfo();
|
|
212
|
+
if (existing.connected && confirmReplace) {
|
|
213
|
+
const replace = await confirmReplace(
|
|
214
|
+
'Akun Google sudah terhubung. Ganti dengan akun lain? [y/N] ',
|
|
215
|
+
);
|
|
216
|
+
if (!replace) {
|
|
217
|
+
print('Login dibatalkan. Kredensial yang ada tidak diubah.');
|
|
218
|
+
return { connected: false, cancelled: true };
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
this.loggingIn = true;
|
|
223
|
+
// The TUI hands over its framed copyable block; the line renderer takes the
|
|
224
|
+
// same call. Without one, the URL is still printed so login never breaks.
|
|
225
|
+
const emitCode =
|
|
226
|
+
typeof showCode === 'function' ? showCode : (block) => print(`\n${block.text}\n`);
|
|
227
|
+
let launched = false;
|
|
228
|
+
try {
|
|
229
|
+
// Server yang membuat state, PKCE verifier, dan URL otorisasi.
|
|
230
|
+
const session = await startLogin({ loginHint: existing.email || null });
|
|
231
|
+
|
|
232
|
+
const shown = session.url;
|
|
233
|
+
if (shouldOpen) launched = openBrowser(shown);
|
|
234
|
+
print(
|
|
235
|
+
[
|
|
236
|
+
'Google Drive Authentication',
|
|
237
|
+
'',
|
|
238
|
+
launched
|
|
239
|
+
? 'Halaman autentikasi Google dibuka di browser default.'
|
|
240
|
+
: 'Browser tidak tersedia di mesin ini — buka tautan di bawah secara manual.',
|
|
241
|
+
'Login dengan akun Google lewat tautan berikut; blok di bawah bisa diklik untuk menyalin.',
|
|
242
|
+
].join('\n'),
|
|
243
|
+
);
|
|
244
|
+
emitCode({ label: 'login url', text: shown });
|
|
245
|
+
print(
|
|
246
|
+
[
|
|
247
|
+
`Callback : ${session.redirectUri || '(dikelola backend API)'}`,
|
|
248
|
+
`Otorisasi : backend API ${settings.oauthBackend}`,
|
|
249
|
+
'Kredensial Google (client id/secret) tidak disimpan di CLI.',
|
|
250
|
+
'',
|
|
251
|
+
'Menunggu persetujuan pengguna…',
|
|
252
|
+
].join('\n'),
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
const tokens = await waitForTokens({ state: session.state, timeoutMs });
|
|
256
|
+
// Token milik sesi ini: disimpan terenkripsi, tidak pernah dicetak.
|
|
257
|
+
await saveCredentials(
|
|
258
|
+
{
|
|
259
|
+
tokens: {
|
|
260
|
+
grant_id: tokens.grantId,
|
|
261
|
+
access_token: tokens.accessToken,
|
|
262
|
+
refresh_token: tokens.refreshToken,
|
|
263
|
+
expiry_date: tokens.expiryDate,
|
|
264
|
+
token_type: tokens.tokenType,
|
|
265
|
+
scope: tokens.scope || settings.scopes.join(' '),
|
|
266
|
+
},
|
|
267
|
+
account: null,
|
|
268
|
+
},
|
|
269
|
+
{ paths: this.paths },
|
|
270
|
+
);
|
|
271
|
+
this.client = null;
|
|
272
|
+
this.clientFor = null;
|
|
273
|
+
this.stored = null;
|
|
274
|
+
|
|
275
|
+
const auth = new BackendTokenAuth({
|
|
276
|
+
accessToken: tokens.accessToken,
|
|
277
|
+
expiresAt: tokens.expiryDate,
|
|
278
|
+
logger: this.logger,
|
|
279
|
+
onRefresh: () => this.refreshStored(),
|
|
280
|
+
});
|
|
281
|
+
const client = new DriveClient({ auth, logger: this.logger });
|
|
282
|
+
let about = null;
|
|
283
|
+
let verifyError = null;
|
|
284
|
+
try {
|
|
285
|
+
about = await client.getAbout();
|
|
286
|
+
await saveCredentials(
|
|
287
|
+
{
|
|
288
|
+
tokens: {
|
|
289
|
+
grant_id: tokens.grantId,
|
|
290
|
+
access_token: tokens.accessToken,
|
|
291
|
+
refresh_token: tokens.refreshToken,
|
|
292
|
+
expiry_date: tokens.expiryDate,
|
|
293
|
+
token_type: tokens.tokenType,
|
|
294
|
+
scope: tokens.scope || settings.scopes.join(' '),
|
|
295
|
+
},
|
|
296
|
+
account: about,
|
|
297
|
+
},
|
|
298
|
+
{ paths: this.paths },
|
|
299
|
+
);
|
|
300
|
+
this.stored = null;
|
|
301
|
+
} catch (error) {
|
|
302
|
+
verifyError = error;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (!settings.enabled) await updateDriveConfig('enabled', true, { paths: this.paths });
|
|
306
|
+
this.settings = null;
|
|
307
|
+
this.lastCheck = new Date().toISOString();
|
|
308
|
+
this.lastCheckOk = Boolean(about);
|
|
309
|
+
|
|
310
|
+
const scope = (tokens.scope || settings.scopes.join(' ')).split(/\s+/).filter(Boolean).join(', ');
|
|
311
|
+
const email = about?.email || '(tidak terbaca)';
|
|
312
|
+
print(
|
|
313
|
+
[
|
|
314
|
+
'[✓] Autentikasi berhasil!',
|
|
315
|
+
'',
|
|
316
|
+
`Akun : ${email}`,
|
|
317
|
+
about?.name ? `Nama : ${about.name}` : null,
|
|
318
|
+
`Permission : ${scope}`,
|
|
319
|
+
`Status : ${about ? 'Connected' : 'Token tersimpan (verifikasi gagal)'}`,
|
|
320
|
+
verifyError ? `Catatan : ${verifyError.message}` : null,
|
|
321
|
+
'',
|
|
322
|
+
'Google Drive aktif. Tools Drive sekarang tersedia untuk AI Agent.',
|
|
323
|
+
]
|
|
324
|
+
.filter((line) => line !== null)
|
|
325
|
+
.join('\n'),
|
|
326
|
+
);
|
|
327
|
+
return { connected: Boolean(about), account: about, enabled: true };
|
|
328
|
+
} catch (error) {
|
|
329
|
+
const message = error instanceof RyuuError ? error.message : `Login gagal: ${describeOAuthFailure(error)}`;
|
|
330
|
+
throw error instanceof RyuuError ? error : new RyuuError(message, { code: 'DRIVE_AUTH' });
|
|
331
|
+
} finally {
|
|
332
|
+
this.loggingIn = false;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** Logout: revoke (best effort), drop tokens, keep non-sensitive config. */
|
|
337
|
+
async logout({ print = () => {} } = {}) {
|
|
338
|
+
const stored = await loadCredentials({ paths: this.paths }).catch(() => null);
|
|
339
|
+
let revocation = { revoked: false, reason: 'tidak ada token' };
|
|
340
|
+
if (stored?.grantId || stored?.refreshToken || stored?.accessToken) {
|
|
341
|
+
revocation = await revokeRemoteToken({ grantId: stored.grantId });
|
|
342
|
+
}
|
|
343
|
+
await clearCredentials({ paths: this.paths });
|
|
344
|
+
this.client = null;
|
|
345
|
+
this.clientFor = null;
|
|
346
|
+
this.stored = null;
|
|
347
|
+
if (this.settings?.enabled) {
|
|
348
|
+
await updateDriveConfig('enabled', false, { paths: this.paths }).catch(() => {});
|
|
349
|
+
this.settings = null;
|
|
350
|
+
}
|
|
351
|
+
print('Koneksi Google Drive diputus. Token lokal dihapus, konfigurasi non-sensitif dipertahankan.');
|
|
352
|
+
print(
|
|
353
|
+
revocation.revoked
|
|
354
|
+
? 'Revocation token OAuth: berhasil.'
|
|
355
|
+
: `Revocation token OAuth: dilewati (${revocation.reason}).`,
|
|
356
|
+
);
|
|
357
|
+
print('File di Google Drive tidak dihapus.');
|
|
358
|
+
return { revoked: revocation.revoked, reason: revocation.reason };
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
async enable() {
|
|
362
|
+
const authenticated = await this.isAuthenticated();
|
|
363
|
+
if (!authenticated) {
|
|
364
|
+
throw new RyuuError('Belum ada akun Google yang terhubung. Jalankan /drive login lebih dulu.', {
|
|
365
|
+
code: 'DRIVE_UNAUTHORIZED',
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
await updateDriveConfig('enabled', true, { paths: this.paths });
|
|
369
|
+
this.settings = null;
|
|
370
|
+
return { enabled: true, tools: this.tools.map((tool) => tool.name) };
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
async disable() {
|
|
374
|
+
await updateDriveConfig('enabled', false, { paths: this.paths });
|
|
375
|
+
this.settings = null;
|
|
376
|
+
return { enabled: false };
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
async setConfig(key, value) {
|
|
380
|
+
const parsed = validateConfigEntry(key, value);
|
|
381
|
+
await updateDriveConfig(key, parsed, { paths: this.paths });
|
|
382
|
+
this.settings = null;
|
|
383
|
+
return parsed;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
async configView() {
|
|
387
|
+
const settings = await this.loadConfig();
|
|
388
|
+
return { settings, lines: describeDriveConfig(settings) };
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/** Diagnostic report; every step is independent so one failure is visible. */
|
|
392
|
+
async diagnose() {
|
|
393
|
+
const report = [];
|
|
394
|
+
const push = (label, ok, detail = '') => report.push({ label, ok, detail });
|
|
395
|
+
|
|
396
|
+
let settings;
|
|
397
|
+
try {
|
|
398
|
+
settings = await this.loadConfig({ force: true });
|
|
399
|
+
push('Konfigurasi', true, `${this.paths.configFile}`);
|
|
400
|
+
} catch (error) {
|
|
401
|
+
push('Konfigurasi', false, error.message);
|
|
402
|
+
return { ok: false, report };
|
|
403
|
+
}
|
|
404
|
+
push('Backend OAuth', true, settings.oauthBackend);
|
|
405
|
+
push(
|
|
406
|
+
'Kunci login Drive',
|
|
407
|
+
true,
|
|
408
|
+
'bawaan aplikasi (header x-ryuu-drive-auth, tidak dapat diubah)',
|
|
409
|
+
);
|
|
410
|
+
push('Mode', true, settings.enabled ? 'enabled' : 'disabled');
|
|
411
|
+
|
|
412
|
+
let stored;
|
|
413
|
+
try {
|
|
414
|
+
stored = await this.loadCredentials({ force: true });
|
|
415
|
+
} catch (error) {
|
|
416
|
+
push('Credential', false, error.message);
|
|
417
|
+
return { ok: false, report };
|
|
418
|
+
}
|
|
419
|
+
if (!stored) {
|
|
420
|
+
push('Credential', false, 'belum ada. Jalankan /drive login.');
|
|
421
|
+
return { ok: false, report };
|
|
422
|
+
}
|
|
423
|
+
push('Credential', true, `file terenkripsi: ${this.paths.credentialFile}`);
|
|
424
|
+
push('Refresh token', Boolean(stored.refreshToken), stored.refreshToken ? 'tersedia' : 'tidak ada');
|
|
425
|
+
push(
|
|
426
|
+
'Access token kedaluwarsa',
|
|
427
|
+
true,
|
|
428
|
+
stored.expiryDate ? new Date(stored.expiryDate).toISOString() : 'tidak diketahui',
|
|
429
|
+
);
|
|
430
|
+
|
|
431
|
+
let about = null;
|
|
432
|
+
try {
|
|
433
|
+
const client = await this.getClient();
|
|
434
|
+
about = await client.getAbout();
|
|
435
|
+
push('Google Drive API', true, `terhubung sebagai ${about.email || '(tanpa email)'}`);
|
|
436
|
+
this.lastCheck = new Date().toISOString();
|
|
437
|
+
this.lastCheckOk = true;
|
|
438
|
+
} catch (error) {
|
|
439
|
+
push('Google Drive API', false, error.message);
|
|
440
|
+
this.lastCheck = new Date().toISOString();
|
|
441
|
+
this.lastCheckOk = false;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
if (about?.storageQuota) {
|
|
445
|
+
const quota = about.storageQuota;
|
|
446
|
+
const fmt = (bytes) => (bytes === null || bytes === undefined ? 'tidak tersedia' : `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`);
|
|
447
|
+
push('Storage', true, `terpakai ${fmt(quota.usage)} dari ${fmt(quota.limit)}`);
|
|
448
|
+
}
|
|
449
|
+
return { ok: report.every((row) => row.ok), report, account: about };
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/** Everything /drive status prints, without any token. */
|
|
453
|
+
async status() {
|
|
454
|
+
let settings = null;
|
|
455
|
+
let configError = null;
|
|
456
|
+
try {
|
|
457
|
+
settings = await this.loadConfig();
|
|
458
|
+
} catch (error) {
|
|
459
|
+
configError = error.message;
|
|
460
|
+
}
|
|
461
|
+
let auth = { connected: false };
|
|
462
|
+
let authError = null;
|
|
463
|
+
try {
|
|
464
|
+
auth = await this.authInfo();
|
|
465
|
+
} catch (error) {
|
|
466
|
+
authError = error.message;
|
|
467
|
+
}
|
|
468
|
+
let quota = null;
|
|
469
|
+
let account = null;
|
|
470
|
+
if (auth.connected) {
|
|
471
|
+
try {
|
|
472
|
+
const stored = await this.loadCredentials();
|
|
473
|
+
account = stored?.account || null;
|
|
474
|
+
quota = stored?.account?.storageQuota || null;
|
|
475
|
+
} catch {
|
|
476
|
+
account = null;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
return {
|
|
480
|
+
enabled: Boolean(settings?.enabled),
|
|
481
|
+
authenticated: Boolean(auth.connected),
|
|
482
|
+
auth,
|
|
483
|
+
authError,
|
|
484
|
+
configError,
|
|
485
|
+
toolCount: this.tools.length,
|
|
486
|
+
toolNames: this.tools.map((tool) => tool.name),
|
|
487
|
+
settings,
|
|
488
|
+
account,
|
|
489
|
+
quota,
|
|
490
|
+
lastCheck: this.lastCheck,
|
|
491
|
+
lastCheckOk: this.lastCheckOk,
|
|
492
|
+
paths: this.paths,
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/** Reset the file-based store (used by tests). */
|
|
497
|
+
async resetConfigFile() {
|
|
498
|
+
await writeDriveConfig({ ...DEFAULT_GOOGLE_DRIVE_CONFIG }, { paths: this.paths });
|
|
499
|
+
this.settings = null;
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
export function createGoogleDrivePlugin(options = {}) {
|
|
504
|
+
return new GoogleDrivePlugin(options);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
export { DEFAULT_GOOGLE_DRIVE_CONFIG };
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OAuth Google Drive lewat backend API — sisi CLI.
|
|
3
|
+
*
|
|
4
|
+
* Ryuu CLI tidak memegang Client ID, Client Secret, PKCE verifier, maupun
|
|
5
|
+
* redirect URI. Semua itu milik `website/api`:
|
|
6
|
+
*
|
|
7
|
+
* start POST { action: 'start' } → { state, url } (URL login)
|
|
8
|
+
* poll POST { action: 'poll', state } → { found, accessToken, ... }
|
|
9
|
+
* refresh POST { action: 'refresh', refreshToken } → token baru
|
|
10
|
+
* revoke POST { action: 'revoke', token } → cabut token
|
|
11
|
+
*
|
|
12
|
+
* URL backend di-hardcode di bawah: tidak ada command, config, atau
|
|
13
|
+
* environment variable yang bisa mengubahnya.
|
|
14
|
+
*
|
|
15
|
+
* Otorisasi memakai kunci khusus login Drive (`secret.js`, header
|
|
16
|
+
* `x-ryuu-drive-auth`) yang di server hanya berlaku untuk route ini — bukan
|
|
17
|
+
* root key admin, yang tidak pernah ada di sisi CLI.
|
|
18
|
+
*/
|
|
19
|
+
import { RyuuError } from '../../src/utils/errors.js';
|
|
20
|
+
import { driveLoginHeaders } from './secret.js';
|
|
21
|
+
|
|
22
|
+
const OAUTH_ERROR = 'DRIVE_AUTH';
|
|
23
|
+
|
|
24
|
+
/** Endpoint backend tetap. Bukan konfigurasi pengguna. */
|
|
25
|
+
export const DRIVE_OAUTH_BACKEND = 'https://restapi.ryuu-dev.my.id';
|
|
26
|
+
export const DRIVE_OAUTH_CLI_PATH = '/admin/tools/drive-cli';
|
|
27
|
+
|
|
28
|
+
const STATE_RE = /^[A-Za-z0-9_-]{16,128}$/;
|
|
29
|
+
const POLL_INTERVAL_MS = 2000;
|
|
30
|
+
/** Status yang berarti gangguan sesaat; polling dilanjutkan. */
|
|
31
|
+
const RETRYABLE_STATUS = new Set([429, 500, 502, 503, 504]);
|
|
32
|
+
|
|
33
|
+
const oauthError = (message, extra = {}) => new RyuuError(message, { code: OAUTH_ERROR, ...extra });
|
|
34
|
+
|
|
35
|
+
export function backendEndpoint() {
|
|
36
|
+
return `${DRIVE_OAUTH_BACKEND}${DRIVE_OAUTH_CLI_PATH}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function post(payload, { timeoutMs = 15000 } = {}) {
|
|
40
|
+
const controller = new AbortController();
|
|
41
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
42
|
+
timer.unref?.();
|
|
43
|
+
let response;
|
|
44
|
+
try {
|
|
45
|
+
response = await fetch(backendEndpoint(), {
|
|
46
|
+
method: 'POST',
|
|
47
|
+
headers: {
|
|
48
|
+
'content-type': 'application/json',
|
|
49
|
+
...driveLoginHeaders(),
|
|
50
|
+
},
|
|
51
|
+
body: JSON.stringify(payload),
|
|
52
|
+
signal: controller.signal,
|
|
53
|
+
});
|
|
54
|
+
} catch (error) {
|
|
55
|
+
throw oauthError(`Tidak dapat menghubungi backend OAuth (${error.message}).`, {
|
|
56
|
+
status: 0,
|
|
57
|
+
retryable: true,
|
|
58
|
+
});
|
|
59
|
+
} finally {
|
|
60
|
+
clearTimeout(timer);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const body = await response.json().catch(() => null);
|
|
64
|
+
const result = body && typeof body === 'object' && 'result' in body ? body.result : body;
|
|
65
|
+
if (!response.ok || (body && body.success === false)) {
|
|
66
|
+
const message =
|
|
67
|
+
(result && typeof result === 'object' && (result.message || result.error)) ||
|
|
68
|
+
(typeof result === 'string' ? result : '') ||
|
|
69
|
+
`backend menjawab HTTP ${response.status}`;
|
|
70
|
+
throw oauthError(`Backend OAuth menolak permintaan: ${message}`, {
|
|
71
|
+
status: response.status,
|
|
72
|
+
retryable: RETRYABLE_STATUS.has(response.status),
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
return result || {};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Mulai sesi login; mengembalikan URL Google dan `state` untuk polling. */
|
|
79
|
+
export async function startLogin({ loginHint = null } = {}) {
|
|
80
|
+
const result = await post({ action: 'start', ...(loginHint ? { loginHint } : {}) });
|
|
81
|
+
if (!result.state || !result.url) throw oauthError('Backend tidak mengembalikan URL otorisasi.');
|
|
82
|
+
if (!STATE_RE.test(String(result.state))) throw oauthError('Backend mengembalikan state yang tidak valid.');
|
|
83
|
+
return {
|
|
84
|
+
state: String(result.state),
|
|
85
|
+
url: String(result.url),
|
|
86
|
+
redirectUri: result.redirectUri || null,
|
|
87
|
+
scope: result.scope || null,
|
|
88
|
+
expiresIn: Number.isFinite(result.expiresIn) ? result.expiresIn : null,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Tunggu sampai backend menerima token dari Google.
|
|
94
|
+
* Resolusi: { accessToken, refreshToken, expiryDate, scope, tokenType }.
|
|
95
|
+
*/
|
|
96
|
+
export async function waitForTokens({ state, timeoutMs = 300000, intervalMs = POLL_INTERVAL_MS, signal = null } = {}) {
|
|
97
|
+
if (!STATE_RE.test(String(state || ''))) throw oauthError('State sesi login tidak valid.');
|
|
98
|
+
const deadline = Date.now() + timeoutMs;
|
|
99
|
+
let lastRejection = null;
|
|
100
|
+
|
|
101
|
+
while (Date.now() < deadline) {
|
|
102
|
+
if (signal?.aborted) throw oauthError('Login dibatalkan.');
|
|
103
|
+
let result = null;
|
|
104
|
+
try {
|
|
105
|
+
result = await post({ action: 'poll', state });
|
|
106
|
+
lastRejection = null;
|
|
107
|
+
} catch (error) {
|
|
108
|
+
lastRejection = error;
|
|
109
|
+
// Gangguan jaringan sesaat atau rate limit: teruskan polling.
|
|
110
|
+
if (!error.retryable) throw error;
|
|
111
|
+
if (signal?.aborted) throw oauthError('Login dibatalkan.');
|
|
112
|
+
}
|
|
113
|
+
if (result?.found) {
|
|
114
|
+
if (result.error) {
|
|
115
|
+
throw oauthError(`Google menolak permintaan otorisasi: ${result.error}.`);
|
|
116
|
+
}
|
|
117
|
+
if (!result.accessToken) throw oauthError('Backend tidak mengembalikan access token.');
|
|
118
|
+
return {
|
|
119
|
+
accessToken: String(result.accessToken),
|
|
120
|
+
refreshToken: result.refreshToken ? String(result.refreshToken) : null,
|
|
121
|
+
grantId: typeof result.grantId === 'string' ? result.grantId : null,
|
|
122
|
+
expiryDate: Number.isFinite(result.expiryDate) ? result.expiryDate : null,
|
|
123
|
+
scope: result.scope || '',
|
|
124
|
+
tokenType: result.tokenType || 'Bearer',
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
|
128
|
+
}
|
|
129
|
+
if (lastRejection && !lastRejection.retryable) throw lastRejection;
|
|
130
|
+
const minutes = Math.max(1, Math.round(timeoutMs / 60000));
|
|
131
|
+
throw oauthError(`Waktu login habis (${minutes} menit). Jalankan /drive login lagi.`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Perbarui access token memakai `grantId` dari login (bukan kunci aplikasi).
|
|
136
|
+
* Server yang memegang refresh token untuk grant itu.
|
|
137
|
+
*/
|
|
138
|
+
export async function refreshAccessToken({ grantId } = {}) {
|
|
139
|
+
if (!grantId) throw oauthError('Izin Drive tidak tersedia. Jalankan /drive login.');
|
|
140
|
+
const result = await post({ action: 'refresh', grantId });
|
|
141
|
+
if (!result.accessToken) throw oauthError('Backend tidak mengembalikan access token baru.');
|
|
142
|
+
return {
|
|
143
|
+
accessToken: String(result.accessToken),
|
|
144
|
+
refreshToken: result.refreshToken ? String(result.refreshToken) : null,
|
|
145
|
+
grantId: typeof result.grantId === 'string' ? result.grantId : grantId,
|
|
146
|
+
expiryDate: Number.isFinite(result.expiryDate) ? result.expiryDate : null,
|
|
147
|
+
scope: result.scope || '',
|
|
148
|
+
tokenType: result.tokenType || 'Bearer',
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Cabut izin (best effort); kegagalan tidak boleh memblokir logout. */
|
|
153
|
+
export async function revokeRemoteToken({ grantId } = {}) {
|
|
154
|
+
if (!grantId) return { revoked: false, reason: 'tidak ada izin tersimpan' };
|
|
155
|
+
try {
|
|
156
|
+
const result = await post({ action: 'revoke', grantId }, { timeoutMs: 10000 });
|
|
157
|
+
return result.revoked ? { revoked: true } : { revoked: false, reason: 'ditolak backend' };
|
|
158
|
+
} catch (error) {
|
|
159
|
+
return { revoked: false, reason: error.message };
|
|
160
|
+
}
|
|
161
|
+
}
|