@ryuu-reinzz/ryuu-cli 0.4.3 → 0.5.1

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.
Files changed (48) hide show
  1. package/CHANGELOG.md +100 -0
  2. package/README.md +26 -0
  3. package/docs/drive.md +484 -0
  4. package/docs/github.md +464 -0
  5. package/package.json +4 -1
  6. package/plugins/github/README.md +151 -0
  7. package/plugins/github/api.js +78 -0
  8. package/plugins/github/auth.js +45 -0
  9. package/plugins/github/backend.js +191 -0
  10. package/plugins/github/command.js +307 -0
  11. package/plugins/github/config.js +209 -0
  12. package/plugins/github/credentials.js +161 -0
  13. package/plugins/github/errors.js +49 -0
  14. package/plugins/github/index.js +41 -0
  15. package/plugins/github/manager.js +613 -0
  16. package/plugins/github/tool-helpers.js +259 -0
  17. package/plugins/github/tools-account.js +64 -0
  18. package/plugins/github/tools-actions.js +126 -0
  19. package/plugins/github/tools-issues.js +214 -0
  20. package/plugins/github/tools-pulls.js +267 -0
  21. package/plugins/github/tools-releases.js +87 -0
  22. package/plugins/github/tools-repos.js +392 -0
  23. package/plugins/github/tools.js +25 -0
  24. package/plugins/google-drive/auth.js +57 -0
  25. package/plugins/google-drive/backup.js +282 -0
  26. package/plugins/google-drive/command.js +266 -0
  27. package/plugins/google-drive/config.js +252 -0
  28. package/plugins/google-drive/credentials.js +171 -0
  29. package/plugins/google-drive/drive-api.js +304 -0
  30. package/plugins/google-drive/index.js +26 -0
  31. package/plugins/google-drive/manager.js +507 -0
  32. package/plugins/google-drive/oauth-backend.js +161 -0
  33. package/plugins/google-drive/secret.js +50 -0
  34. package/plugins/google-drive/token-client.js +80 -0
  35. package/plugins/google-drive/tool-helpers.js +46 -0
  36. package/plugins/google-drive/tools-backup.js +204 -0
  37. package/plugins/google-drive/tools-files.js +315 -0
  38. package/plugins/google-drive/tools.js +13 -0
  39. package/plugins/google-drive/zip-reader.js +217 -0
  40. package/plugins/google-drive/zip-writer.js +204 -0
  41. package/plugins/index.js +58 -0
  42. package/src/agent/agent.js +12 -2
  43. package/src/cli/app.js +16 -0
  44. package/src/cli/commands.js +44 -0
  45. package/src/cli/markdown.js +10 -5
  46. package/src/cli/renderer.js +38 -5
  47. package/src/cli/tui.js +80 -9
  48. package/src/tools/registry.js +13 -0
@@ -0,0 +1,304 @@
1
+ import fs from 'node:fs';
2
+ import fsp from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { google } from 'googleapis';
5
+ import { RyuuError } from '../../src/utils/errors.js';
6
+ import { describeOAuthFailure } from './auth.js';
7
+
8
+ /**
9
+ * Thin, guarded wrapper around Google Drive API v3.
10
+ *
11
+ * Every method returns plain data (no googleapis response envelope) and maps
12
+ * failures onto RyuuError with an Indonesian message. Nothing here ever returns
13
+ * an access token.
14
+ */
15
+
16
+ const API = 'DRIVE_API';
17
+ const FILE_FIELDS = 'id,name,mimeType,size,createdTime,modifiedTime,webViewLink,parents,trashed,owners(displayName,emailAddress)';
18
+ const FOLDER_MIME = 'application/vnd.google-apps.folder';
19
+
20
+ function apiError(error, action) {
21
+ const status = error?.response?.status || error?.code;
22
+ const data = error?.response?.data || {};
23
+ const reason = data?.error?.errors?.[0]?.reason || data?.error || '';
24
+ const base = { code: API, status, reason: String(reason || '') };
25
+ if (status === 401) {
26
+ return new RyuuError(`Akses Google Drive ditolak (401). Jalankan /drive login.`, base);
27
+ }
28
+ if (status === 403) {
29
+ if (/quota/i.test(String(reason)) || /storageQuota/i.test(String(data?.error?.message || ''))) {
30
+ return new RyuuError('Kuota Google Drive habis atau rate limit API tercapai. Coba lagi nanti.', base);
31
+ }
32
+ return new RyuuError(
33
+ `Google Drive menolak akses (403): ${reason || 'permission denied'}. Pastikan scope drive.file mencakup file ini (file harus dibuat/dibuka oleh Ryuu).`,
34
+ base,
35
+ );
36
+ }
37
+ if (status === 404) return new RyuuError('File atau folder Google Drive tidak ditemukan, atau bukan milik akun ini.', base);
38
+ if (status === 429) return new RyuuError('Rate limit Google Drive tercapai (429). Coba lagi beberapa saat.', base);
39
+ if (status >= 500) return new RyuuError(`Google Drive sedang bermasalah (${status}). Coba lagi nanti.`, base);
40
+ if (['ENOTFOUND', 'ECONNREFUSED', 'ETIMEDOUT', 'EAI_AGAIN'].includes(status)) {
41
+ return new RyuuError('Tidak dapat menghubungi Google Drive (masalah jaringan).', base);
42
+ }
43
+ if (status === 'ENOSPC') return new RyuuError('Disk penuh: tidak ada ruang untuk menulis file.', base);
44
+ return new RyuuError(`${action} gagal: ${error?.message || describeOAuthFailure(error)}`, base);
45
+ }
46
+
47
+ function escapeQueryText(value) {
48
+ return String(value).replace(/\\/g, '\\\\').replace(/'/g, "\\'");
49
+ }
50
+
51
+ /** Build a Drive `q` expression from structured filters. */
52
+ export function buildSearchQuery({ query = null, name = null, mimeType = null, folderId = null, trashed = false } = {}) {
53
+ const parts = [];
54
+ if (query) parts.push(`(${String(query)})`);
55
+ if (name) parts.push(`name contains '${escapeQueryText(name)}'`);
56
+ if (mimeType) parts.push(`mimeType = '${escapeQueryText(mimeType)}'`);
57
+ if (folderId) parts.push(`'${escapeQueryText(folderId)}' in parents`);
58
+ parts.push(trashed ? 'trashed = true' : 'trashed = false');
59
+ return parts.join(' and ');
60
+ }
61
+
62
+ function toFileSummary(file) {
63
+ return {
64
+ id: file.id,
65
+ name: file.name,
66
+ mimeType: file.mimeType,
67
+ isFolder: file.mimeType === FOLDER_MIME,
68
+ size: file.size ? Number(file.size) : null,
69
+ createdTime: file.createdTime || null,
70
+ modifiedTime: file.modifiedTime || null,
71
+ webViewLink: file.webViewLink || null,
72
+ parents: file.parents || [],
73
+ };
74
+ }
75
+
76
+ export class DriveClient {
77
+ constructor({ auth, logger = null }) {
78
+ this.auth = auth;
79
+ this.logger = logger;
80
+ this.drive = google.drive({ version: 'v3', auth });
81
+ }
82
+
83
+ /** Account identity + storage quota, read from the Drive API itself. */
84
+ async getAbout() {
85
+ try {
86
+ const { data } = await this.drive.about.get({ fields: 'user(displayName,emailAddress,permissionId),storageQuota' });
87
+ return {
88
+ email: data.user?.emailAddress || null,
89
+ name: data.user?.displayName || null,
90
+ permissionId: data.user?.permissionId || null,
91
+ storageQuota: data.storageQuota
92
+ ? {
93
+ limit: data.storageQuota.limit ? Number(data.storageQuota.limit) : null,
94
+ usage: data.storageQuota.usage ? Number(data.storageQuota.usage) : null,
95
+ usageInDrive: data.storageQuota.usageInDrive ? Number(data.storageQuota.usageInDrive) : null,
96
+ usageInDriveTrash: data.storageQuota.usageInDriveTrash
97
+ ? Number(data.storageQuota.usageInDriveTrash)
98
+ : null,
99
+ }
100
+ : null,
101
+ };
102
+ } catch (error) {
103
+ throw apiError(error, 'Membaca informasi akun');
104
+ }
105
+ }
106
+
107
+ /** Search files; supports Shared Drive items explicitly. */
108
+ async search({ query = null, name = null, mimeType = null, folderId = null, pageSize = 25, pageToken = null, orderBy = 'modifiedTime desc', trashed = false } = {}) {
109
+ const size = Math.min(Math.max(Number(pageSize) || 25, 1), 100);
110
+ try {
111
+ const { data } = await this.drive.files.list({
112
+ q: buildSearchQuery({ query, name, mimeType, folderId, trashed }),
113
+ pageSize: size,
114
+ pageToken: pageToken || undefined,
115
+ orderBy: orderBy || undefined,
116
+ fields: `nextPageToken, files(${FILE_FIELDS})`,
117
+ supportsAllDrives: true,
118
+ includeItemsFromAllDrives: true,
119
+ });
120
+ return { files: (data.files || []).map(toFileSummary), nextPageToken: data.nextPageToken || null };
121
+ } catch (error) {
122
+ throw apiError(error, 'Pencarian file');
123
+ }
124
+ }
125
+
126
+ /** List a folder's direct children ('root' when folderId is omitted). */
127
+ async list({ folderId = 'root', pageSize = 50, pageToken = null, orderBy = 'folder,name' } = {}) {
128
+ const id = folderId || 'root';
129
+ const size = Math.min(Math.max(Number(pageSize) || 50, 1), 200);
130
+ try {
131
+ const { data } = await this.drive.files.list({
132
+ q: `'${escapeQueryText(id)}' in parents and trashed = false`,
133
+ pageSize: size,
134
+ pageToken: pageToken || undefined,
135
+ orderBy: orderBy || undefined,
136
+ fields: `nextPageToken, files(${FILE_FIELDS})`,
137
+ supportsAllDrives: true,
138
+ includeItemsFromAllDrives: true,
139
+ });
140
+ return { folderId: id, files: (data.files || []).map(toFileSummary), nextPageToken: data.nextPageToken || null };
141
+ } catch (error) {
142
+ throw apiError(error, 'Membaca isi folder');
143
+ }
144
+ }
145
+
146
+ async getMetadata({ fileId }) {
147
+ try {
148
+ const { data } = await this.drive.files.get({
149
+ fileId,
150
+ fields: `${FILE_FIELDS},description,shared,capabilities(canDownload,canEdit,canDelete),shortcutDetails`,
151
+ supportsAllDrives: true,
152
+ });
153
+ return { ...toFileSummary(data), description: data.description || null, shared: Boolean(data.shared), capabilities: data.capabilities || null };
154
+ } catch (error) {
155
+ throw apiError(error, 'Membaca metadata file');
156
+ }
157
+ }
158
+
159
+ async createFolder({ name, parentId = 'root' }) {
160
+ try {
161
+ const { data } = await this.drive.files.create({
162
+ requestBody: { name, mimeType: FOLDER_MIME, parents: [parentId || 'root'] },
163
+ fields: FILE_FIELDS,
164
+ supportsAllDrives: true,
165
+ });
166
+ return toFileSummary(data);
167
+ } catch (error) {
168
+ throw apiError(error, 'Membuat folder');
169
+ }
170
+ }
171
+
172
+ /** Find a child folder by exact name, creating it when missing. */
173
+ async ensureFolder({ name, parentId = 'root' }) {
174
+ const existing = await this.search({
175
+ name,
176
+ mimeType: FOLDER_MIME,
177
+ folderId: parentId || 'root',
178
+ pageSize: 10,
179
+ });
180
+ const hit = existing.files.find((file) => file.name === name && file.isFolder);
181
+ if (hit) return hit;
182
+ return this.createFolder({ name, parentId });
183
+ }
184
+
185
+ /** Streaming upload from a local file (no full buffer in memory). */
186
+ async uploadFile({ filePath, fileName = null, folderId = 'root', mimeType = 'application/octet-stream', onProgress = null }) {
187
+ let size;
188
+ try {
189
+ size = (await fsp.stat(filePath)).size;
190
+ } catch (error) {
191
+ throw new RyuuError(`File lokal tidak dapat dibaca: ${filePath} (${error.message})`, { code: 'DRIVE_LOCAL' });
192
+ }
193
+ try {
194
+ const { data } = await this.drive.files.create(
195
+ {
196
+ requestBody: { name: fileName || path.basename(filePath), parents: [folderId || 'root'], mimeType },
197
+ media: { mimeType, body: fs.createReadStream(filePath) },
198
+ fields: FILE_FIELDS,
199
+ supportsAllDrives: true,
200
+ },
201
+ onProgress ? { onUploadProgress: (event) => onProgress({ loaded: event.bytesRead, total: size }) } : undefined,
202
+ );
203
+ return { ...toFileSummary(data), uploadedBytes: size };
204
+ } catch (error) {
205
+ throw apiError(error, 'Upload file');
206
+ }
207
+ }
208
+
209
+ /** Streaming download to a local path that the caller already validated. */
210
+ async downloadFile({ fileId, destination, onProgress = null }) {
211
+ try {
212
+ const meta = await this.getMetadata({ fileId });
213
+ if (meta.isFolder) {
214
+ throw new RyuuError('Objek yang diminta adalah folder. Gunakan drive_restore untuk arsip backup.', { code: API });
215
+ }
216
+ const response = await this.drive.files.get(
217
+ { fileId, alt: 'media', supportsAllDrives: true },
218
+ { responseType: 'stream' },
219
+ );
220
+ await fsp.mkdir(path.dirname(destination), { recursive: true });
221
+ const writer = fs.createWriteStream(destination);
222
+ let bytes = 0;
223
+ await new Promise((resolve, reject) => {
224
+ response.data.on('data', (chunk) => {
225
+ bytes += chunk.length;
226
+ if (onProgress) onProgress({ loaded: bytes, total: meta.size });
227
+ });
228
+ response.data.on('error', reject);
229
+ writer.on('error', reject);
230
+ writer.on('finish', resolve);
231
+ response.data.pipe(writer);
232
+ });
233
+ return { ...meta, destination, bytes };
234
+ } catch (error) {
235
+ if (error instanceof RyuuError) throw error;
236
+ throw apiError(error, 'Download file');
237
+ }
238
+ }
239
+
240
+ async move({ fileId, toFolderId }) {
241
+ try {
242
+ const meta = await this.getMetadata({ fileId });
243
+ const { data } = await this.drive.files.update({
244
+ fileId,
245
+ addParents: toFolderId,
246
+ removeParents: (meta.parents || []).join(',') || undefined,
247
+ fields: FILE_FIELDS,
248
+ supportsAllDrives: true,
249
+ });
250
+ return toFileSummary(data);
251
+ } catch (error) {
252
+ throw apiError(error, 'Memindahkan file');
253
+ }
254
+ }
255
+
256
+ async rename({ fileId, name }) {
257
+ try {
258
+ const { data } = await this.drive.files.update({
259
+ fileId,
260
+ requestBody: { name },
261
+ fields: FILE_FIELDS,
262
+ supportsAllDrives: true,
263
+ });
264
+ return toFileSummary(data);
265
+ } catch (error) {
266
+ throw apiError(error, 'Mengganti nama file');
267
+ }
268
+ }
269
+
270
+ async copy({ fileId, name = null, folderId = null }) {
271
+ try {
272
+ const { data } = await this.drive.files.copy({
273
+ fileId,
274
+ requestBody: { ...(name ? { name } : {}), ...(folderId ? { parents: [folderId] } : {}) },
275
+ fields: FILE_FIELDS,
276
+ supportsAllDrives: true,
277
+ });
278
+ return toFileSummary(data);
279
+ } catch (error) {
280
+ throw apiError(error, 'Menyalin file');
281
+ }
282
+ }
283
+
284
+ async remove({ fileId }) {
285
+ try {
286
+ await this.drive.files.delete({ fileId, supportsAllDrives: true });
287
+ return { id: fileId, deleted: true };
288
+ } catch (error) {
289
+ throw apiError(error, 'Menghapus file');
290
+ }
291
+ }
292
+
293
+ /** Confirm that a folder id exists and is a folder (Shared Drives included). */
294
+ async assertFolder({ folderId, label = 'Folder tujuan' }) {
295
+ if (!folderId || folderId === 'root') return { id: 'root', name: 'My Drive' };
296
+ const meta = await this.getMetadata({ fileId: folderId });
297
+ if (!meta.isFolder) {
298
+ throw new RyuuError(`${label} ${folderId} bukan folder Google Drive.`, { code: API });
299
+ }
300
+ return meta;
301
+ }
302
+ }
303
+
304
+ export { FOLDER_MIME, toFileSummary, apiError };
@@ -0,0 +1,26 @@
1
+ import { createGoogleDrivePlugin } from './manager.js';
2
+
3
+ export { GoogleDrivePlugin, createGoogleDrivePlugin } from './manager.js';
4
+ export { DEFAULT_GOOGLE_DRIVE_CONFIG, EDITABLE_KEYS, drivePaths, readDriveConfig, validateConfigEntry } from './config.js';
5
+ export { describeCredentials } from './credentials.js';
6
+
7
+ /**
8
+ * Plugin descriptor consumed by the host application.
9
+ *
10
+ * The host only needs three things: an id/name, a factory, and `tools`. Tools
11
+ * are registered with `registry.registerPlugin(plugin)` when the integration is
12
+ * enabled AND authenticated, and they are never handed to the model otherwise.
13
+ */
14
+ export const googleDrivePluginDescriptor = Object.freeze({
15
+ name: 'google-drive',
16
+ version: '1.0.0',
17
+ description: 'Google Drive integration: search, upload/download, folder management, backup and restore.',
18
+ command: 'drive',
19
+ dependencies: ['googleapis'],
20
+ });
21
+
22
+ export function createPlugin(options = {}) {
23
+ return createGoogleDrivePlugin(options);
24
+ }
25
+
26
+ export default googleDrivePluginDescriptor;