@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,50 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Kunci khusus login Google Drive.
|
|
5
|
+
*
|
|
6
|
+
* Terpisah total dari root key proyek API (`ROOT_KEY_SECRET`): root key itu
|
|
7
|
+
* membuka seluruh endpoint admin, sedangkan kunci ini hanya menerima satu
|
|
8
|
+
* endpoint OAuth Drive dan tidak berlaku di tempat lain.
|
|
9
|
+
*
|
|
10
|
+
* Penyimpanan: 32 byte bahan dipecah menjadi empat potongan 8 byte, tiap
|
|
11
|
+
* potongan di-XOR dengan mask sendiri, lalu ditulis sebagai hex supaya tidak
|
|
12
|
+
* terlihat sebagai kunci saat file dibaca sekilas. Nilai yang dikirim ke
|
|
13
|
+
* jaringan bukan bahan itu sendiri, melainkan turunan HMAC-SHA256-nya,
|
|
14
|
+
* sehingga string di dalam file tidak bisa langsung dipakai sebagai header.
|
|
15
|
+
*
|
|
16
|
+
* Tidak ada command, config, atau environment variable yang dapat mengubah
|
|
17
|
+
* kunci ini: nilainya bawaan aplikasi dan hanya dipakai saat `/drive login`
|
|
18
|
+
* (serta refresh/revoke token, yang tetap bagian dari siklus login).
|
|
19
|
+
*/
|
|
20
|
+
export const DRIVE_LOGIN_HEADER = 'x-ryuu-drive-auth';
|
|
21
|
+
|
|
22
|
+
// 4 x 8 byte. `_m` = mask XOR tiap potongan.
|
|
23
|
+
const _p = [
|
|
24
|
+
['4fbd2524004ef3b6', 90],
|
|
25
|
+
['61d480876c0cfe13', 63],
|
|
26
|
+
['906bb071fff129ea', 199],
|
|
27
|
+
['d581636291c5b1f4', 145],
|
|
28
|
+
];
|
|
29
|
+
const _l = 'ryuu.drive.login.v1';
|
|
30
|
+
|
|
31
|
+
let _cached = null;
|
|
32
|
+
|
|
33
|
+
function material() {
|
|
34
|
+
if (!_cached) {
|
|
35
|
+
_cached = Buffer.concat(
|
|
36
|
+
_p.map(([hex, mask]) => Buffer.from(hex, 'hex').map((byte) => byte ^ mask)),
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
return _cached;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Token header turunan (base64url, 43 karakter). Tidak pernah dicetak. */
|
|
43
|
+
export function driveLoginToken() {
|
|
44
|
+
return crypto.createHmac('sha256', material()).update(_l).digest('base64url');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Header siap pakai untuk backend API. */
|
|
48
|
+
export function driveLoginHeaders() {
|
|
49
|
+
return { [DRIVE_LOGIN_HEADER]: driveLoginToken() };
|
|
50
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { request as gaxiosRequest } from 'gaxios';
|
|
2
|
+
import { RyuuError } from '../../src/utils/errors.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Auth client minimal untuk googleapis.
|
|
6
|
+
*
|
|
7
|
+
* googleapis hanya butuh objek dengan `request()` (dan `getRequestHeaders()`
|
|
8
|
+
* untuk HTTP/2), jadi plugin tidak memakai google-auth-library sama sekali:
|
|
9
|
+
* tidak ada client id, client secret, atau refresh langsung ke Google. Saat
|
|
10
|
+
* access token kedaluwarsa, `onRefresh` meminta token baru ke backend API
|
|
11
|
+
* (satu-satunya pemegang Client Secret).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const AUTH_ERROR = 'DRIVE_AUTH';
|
|
15
|
+
const REFRESH_MARGIN_MS = 60 * 1000;
|
|
16
|
+
|
|
17
|
+
export class BackendTokenAuth {
|
|
18
|
+
constructor({ accessToken, expiresAt = null, onRefresh = null, logger = null }) {
|
|
19
|
+
this.accessToken = accessToken || null;
|
|
20
|
+
this.expiresAt = Number.isFinite(expiresAt) ? expiresAt : null;
|
|
21
|
+
this.onRefresh = onRefresh;
|
|
22
|
+
this.logger = logger;
|
|
23
|
+
this.refreshing = null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
setTokens({ accessToken, expiresAt = null }) {
|
|
27
|
+
this.accessToken = accessToken || null;
|
|
28
|
+
this.expiresAt = Number.isFinite(expiresAt) ? expiresAt : null;
|
|
29
|
+
return this;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
get expired() {
|
|
33
|
+
if (!this.expiresAt) return false;
|
|
34
|
+
return Date.now() > this.expiresAt - REFRESH_MARGIN_MS;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async ensureToken() {
|
|
38
|
+
if (!this.accessToken) {
|
|
39
|
+
throw new RyuuError('Access token Google Drive tidak tersedia. Jalankan /drive login.', {
|
|
40
|
+
code: 'DRIVE_UNAUTHORIZED',
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
if (!this.expired || typeof this.onRefresh !== 'function') return this.accessToken;
|
|
44
|
+
if (!this.refreshing) {
|
|
45
|
+
this.refreshing = Promise.resolve()
|
|
46
|
+
.then(() => this.onRefresh())
|
|
47
|
+
.then((tokens) => {
|
|
48
|
+
if (tokens?.accessToken) {
|
|
49
|
+
this.setTokens({ accessToken: tokens.accessToken, expiresAt: tokens.expiryDate });
|
|
50
|
+
}
|
|
51
|
+
return this.accessToken;
|
|
52
|
+
})
|
|
53
|
+
.finally(() => {
|
|
54
|
+
this.refreshing = null;
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
return this.refreshing;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async getRequestHeaders() {
|
|
61
|
+
const token = await this.ensureToken();
|
|
62
|
+
return { Authorization: `Bearer ${token}` };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async getAccessToken() {
|
|
66
|
+
return { token: await this.ensureToken() };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async getUniverseDomain() {
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Dipakai googleapis-common: setiap request Drive lewat jalur ini. */
|
|
74
|
+
async request(options = {}) {
|
|
75
|
+
const token = await this.ensureToken();
|
|
76
|
+
const headers = { ...(options.headers || {}), Authorization: `Bearer ${token}` };
|
|
77
|
+
const response = await gaxiosRequest({ ...options, headers });
|
|
78
|
+
return response;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { RyuuError, PathEscapeError } from '../../src/utils/errors.js';
|
|
3
|
+
|
|
4
|
+
/** Shared helpers for the Google Drive tool definitions. */
|
|
5
|
+
|
|
6
|
+
const KB = 1024;
|
|
7
|
+
const MB = KB * 1024;
|
|
8
|
+
const GB = MB * 1024;
|
|
9
|
+
|
|
10
|
+
export function formatBytes(bytes) {
|
|
11
|
+
if (bytes === null || bytes === undefined || Number.isNaN(Number(bytes))) return 'tidak diketahui';
|
|
12
|
+
const value = Number(bytes);
|
|
13
|
+
if (value < KB) return `${value} B`;
|
|
14
|
+
if (value < MB) return `${(value / KB).toFixed(1)} KB`;
|
|
15
|
+
if (value < GB) return `${(value / MB).toFixed(1)} MB`;
|
|
16
|
+
return `${(value / GB).toFixed(2)} GB`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Resolve a model-supplied local path inside the workspace, or refuse it. */
|
|
20
|
+
export function resolveLocal(ctx, plugin, inputPath, { mustExist = false } = {}) {
|
|
21
|
+
const workspace = ctx?.workspace || plugin.workspace;
|
|
22
|
+
if (!workspace) {
|
|
23
|
+
throw new RyuuError('Workspace tidak tersedia untuk memvalidasi path lokal.', { code: 'DRIVE_PATH' });
|
|
24
|
+
}
|
|
25
|
+
const requested = String(inputPath ?? '').trim();
|
|
26
|
+
if (!requested) throw new RyuuError('Path lokal wajib diisi.', { code: 'DRIVE_PATH' });
|
|
27
|
+
if (path.isAbsolute(requested)) {
|
|
28
|
+
throw new PathEscapeError(
|
|
29
|
+
`Path lokal harus relatif terhadap workspace, bukan absolut: ${requested}`,
|
|
30
|
+
{ workspace: workspace.root, requested },
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
const resolved = workspace.resolve(requested, { mustExist });
|
|
34
|
+
workspace.assertNotSecret?.(resolved.relPath);
|
|
35
|
+
return resolved;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Settings needed by the tools, loaded once per call. */
|
|
39
|
+
export async function loadSettings(plugin) {
|
|
40
|
+
return plugin.loadConfig();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function fileLine(file) {
|
|
44
|
+
const kind = file.isFolder ? 'folder' : formatBytes(file.size);
|
|
45
|
+
return ` ${file.name} · ${kind} · id: ${file.id} · ${file.modifiedTime || '-'}`;
|
|
46
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { defineTool } from '../../src/tools/registry.js';
|
|
2
|
+
import { formatBytes, resolveLocal } from './tool-helpers.js';
|
|
3
|
+
import { backupToDrive, deleteBackup, listBackups, restoreFromDrive } from './backup.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Backup, restore and Drive-information tools.
|
|
7
|
+
*
|
|
8
|
+
* `drive_delete_backup` and a restore that overwrites local files are declared
|
|
9
|
+
* as `destructive`, so the CLI approval manager asks the user before they run.
|
|
10
|
+
*/
|
|
11
|
+
export function createBackupTools(plugin) {
|
|
12
|
+
const destructiveRestore = (args) => (args?.overwrite ? 'critical' : 'normal');
|
|
13
|
+
|
|
14
|
+
return [
|
|
15
|
+
defineTool({
|
|
16
|
+
name: 'drive_backup',
|
|
17
|
+
description:
|
|
18
|
+
'Backup a local project directory into a ZIP archive on Google Drive. Use this instead of making an archive yourself: it walks the directory, applies exclusion patterns, streams the ZIP, uploads it and returns the file id and Drive link.',
|
|
19
|
+
risk: 'write',
|
|
20
|
+
requiresApproval: true,
|
|
21
|
+
schema: {
|
|
22
|
+
type: 'object',
|
|
23
|
+
properties: {
|
|
24
|
+
sourcePath: {
|
|
25
|
+
type: 'string',
|
|
26
|
+
description: 'Directory to back up, relative to the workspace root. Default ".".',
|
|
27
|
+
},
|
|
28
|
+
destinationFolderId: {
|
|
29
|
+
type: 'string',
|
|
30
|
+
description: 'Google Drive folder id for the archive. Omit to use the default backup folder, or "root".',
|
|
31
|
+
},
|
|
32
|
+
archiveName: {
|
|
33
|
+
type: 'string',
|
|
34
|
+
description: 'Archive file name; must end in .zip. Omit for <name>-backup-YYYYMMDD-HHmmss.zip.',
|
|
35
|
+
},
|
|
36
|
+
excludePatterns: {
|
|
37
|
+
type: 'array',
|
|
38
|
+
items: { type: 'string' },
|
|
39
|
+
description:
|
|
40
|
+
'Extra glob patterns to exclude (e.g. ["dist", "*.sqlite"]). node_modules, .git, .env, keys and caches are always excluded.',
|
|
41
|
+
},
|
|
42
|
+
includePatterns: {
|
|
43
|
+
type: 'array',
|
|
44
|
+
items: { type: 'string' },
|
|
45
|
+
description: 'Optional allow-list of glob patterns; when set, only matching files are archived.',
|
|
46
|
+
},
|
|
47
|
+
compressionLevel: {
|
|
48
|
+
type: 'integer',
|
|
49
|
+
minimum: 0,
|
|
50
|
+
maximum: 9,
|
|
51
|
+
description: 'Deflate level 0-9 (0 stores files without compression). Default 6.',
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
required: ['sourcePath'],
|
|
55
|
+
},
|
|
56
|
+
async handler(args, ctx) {
|
|
57
|
+
const drive = await plugin.requireClient();
|
|
58
|
+
const settings = await plugin.loadConfig();
|
|
59
|
+
const source = resolveLocal(ctx, plugin, args.sourcePath || '.', { mustExist: true });
|
|
60
|
+
const result = await backupToDrive({
|
|
61
|
+
drive,
|
|
62
|
+
sourceRoot: source.realPath,
|
|
63
|
+
destinationFolderId: args.destinationFolderId || settings.defaultBackupFolder || 'root',
|
|
64
|
+
archiveName: args.archiveName || null,
|
|
65
|
+
excludePatterns: [...(args.excludePatterns || [])],
|
|
66
|
+
includePatterns: args.includePatterns || [],
|
|
67
|
+
compressionLevel: args.compressionLevel ?? 6,
|
|
68
|
+
maxUploadSize: settings.maxUploadSize,
|
|
69
|
+
signal: ctx?.signal || null,
|
|
70
|
+
logger: plugin.logger,
|
|
71
|
+
});
|
|
72
|
+
return {
|
|
73
|
+
ok: true,
|
|
74
|
+
...result,
|
|
75
|
+
sizeLabel: formatBytes(result.size),
|
|
76
|
+
excludedPatterns: result.excludedPatterns,
|
|
77
|
+
};
|
|
78
|
+
},
|
|
79
|
+
}),
|
|
80
|
+
|
|
81
|
+
defineTool({
|
|
82
|
+
name: 'drive_restore',
|
|
83
|
+
description:
|
|
84
|
+
'Download a backup ZIP from Google Drive and extract it into a local directory. Use drive_list_backups first to pick the right archive. Existing files are never replaced unless overwrite=true is passed explicitly.',
|
|
85
|
+
risk: 'write',
|
|
86
|
+
requiresApproval: true,
|
|
87
|
+
assess: destructiveRestore,
|
|
88
|
+
schema: {
|
|
89
|
+
type: 'object',
|
|
90
|
+
properties: {
|
|
91
|
+
fileId: { type: 'string', description: 'Google Drive file id of the backup archive.' },
|
|
92
|
+
destinationPath: {
|
|
93
|
+
type: 'string',
|
|
94
|
+
description: 'Destination directory relative to the workspace root (created when missing). Default "restore".',
|
|
95
|
+
},
|
|
96
|
+
overwrite: {
|
|
97
|
+
type: 'boolean',
|
|
98
|
+
description: 'Replace files that already exist. Default false, which stops the restore instead of overwriting.',
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
required: ['fileId'],
|
|
102
|
+
},
|
|
103
|
+
async handler(args, ctx) {
|
|
104
|
+
const drive = await plugin.requireClient();
|
|
105
|
+
const target = resolveLocal(ctx, plugin, args.destinationPath || 'restore');
|
|
106
|
+
const result = await restoreFromDrive({
|
|
107
|
+
drive,
|
|
108
|
+
fileId: args.fileId,
|
|
109
|
+
destinationRoot: target.realPath,
|
|
110
|
+
overwrite: Boolean(args.overwrite),
|
|
111
|
+
signal: ctx?.signal || null,
|
|
112
|
+
logger: plugin.logger,
|
|
113
|
+
});
|
|
114
|
+
return { ok: true, ...result, bytesLabel: formatBytes(result.bytes) };
|
|
115
|
+
},
|
|
116
|
+
}),
|
|
117
|
+
|
|
118
|
+
defineTool({
|
|
119
|
+
name: 'drive_list_backups',
|
|
120
|
+
description: 'List ZIP backup archives stored in a Google Drive folder, newest first.',
|
|
121
|
+
risk: 'read',
|
|
122
|
+
schema: {
|
|
123
|
+
type: 'object',
|
|
124
|
+
properties: {
|
|
125
|
+
folderId: { type: 'string', description: 'Folder id to list. Omit for the default backup folder, or "root".' },
|
|
126
|
+
pageSize: { type: 'integer', minimum: 1, maximum: 100, description: 'Maximum archives to return (default 25).' },
|
|
127
|
+
pageToken: { type: 'string', description: 'Opaque token from a previous call, for the next page.' },
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
async handler(args) {
|
|
131
|
+
const drive = await plugin.requireClient();
|
|
132
|
+
const settings = await plugin.loadConfig();
|
|
133
|
+
const result = await listBackups({
|
|
134
|
+
drive,
|
|
135
|
+
folderId: args.folderId || settings.defaultBackupFolder || 'root',
|
|
136
|
+
pageSize: args.pageSize ?? 25,
|
|
137
|
+
pageToken: args.pageToken || null,
|
|
138
|
+
});
|
|
139
|
+
return {
|
|
140
|
+
ok: true,
|
|
141
|
+
folderId: result.folderId ?? args.folderId ?? settings.defaultBackupFolder ?? 'root',
|
|
142
|
+
backups: result.backups.map((file) => ({ ...file, sizeLabel: formatBytes(file.size) })),
|
|
143
|
+
count: result.backups.length,
|
|
144
|
+
nextPageToken: result.nextPageToken,
|
|
145
|
+
};
|
|
146
|
+
},
|
|
147
|
+
}),
|
|
148
|
+
|
|
149
|
+
defineTool({
|
|
150
|
+
name: 'drive_delete_backup',
|
|
151
|
+
description:
|
|
152
|
+
'Permanently delete one backup archive (.zip) from Google Drive. This is destructive and only accepts archive files: ask the user for explicit confirmation before calling it.',
|
|
153
|
+
risk: 'destructive',
|
|
154
|
+
requiresApproval: true,
|
|
155
|
+
assess: () => 'critical',
|
|
156
|
+
schema: {
|
|
157
|
+
type: 'object',
|
|
158
|
+
properties: {
|
|
159
|
+
fileId: { type: 'string', description: 'Google Drive file id of the backup archive to delete.' },
|
|
160
|
+
},
|
|
161
|
+
required: ['fileId'],
|
|
162
|
+
},
|
|
163
|
+
async handler(args) {
|
|
164
|
+
const drive = await plugin.requireClient();
|
|
165
|
+
return { ok: true, ...(await deleteBackup({ drive, fileId: args.fileId })) };
|
|
166
|
+
},
|
|
167
|
+
}),
|
|
168
|
+
|
|
169
|
+
defineTool({
|
|
170
|
+
name: 'drive_storage_info',
|
|
171
|
+
description: 'Read the connected Google account storage quota from the Drive API (limit, usage and Drive usage).',
|
|
172
|
+
risk: 'read',
|
|
173
|
+
schema: { type: 'object', properties: {} },
|
|
174
|
+
async handler() {
|
|
175
|
+
const client = await plugin.requireClient();
|
|
176
|
+
const about = await client.getAbout();
|
|
177
|
+
const quota = about.storageQuota || {};
|
|
178
|
+
return {
|
|
179
|
+
ok: true,
|
|
180
|
+
email: about.email,
|
|
181
|
+
limit: quota.limit ?? null,
|
|
182
|
+
usage: quota.usage ?? null,
|
|
183
|
+
usageInDrive: quota.usageInDrive ?? null,
|
|
184
|
+
usageInDriveTrash: quota.usageInDriveTrash ?? null,
|
|
185
|
+
limitLabel: formatBytes(quota.limit),
|
|
186
|
+
usageLabel: formatBytes(quota.usage),
|
|
187
|
+
freeLabel: quota.limit && quota.usage !== null ? formatBytes(Math.max(quota.limit - quota.usage, 0)) : 'tidak tersedia',
|
|
188
|
+
};
|
|
189
|
+
},
|
|
190
|
+
}),
|
|
191
|
+
|
|
192
|
+
defineTool({
|
|
193
|
+
name: 'drive_get_account',
|
|
194
|
+
description: 'Return the Google account currently connected to Google Drive (name, email, permission id).',
|
|
195
|
+
risk: 'read',
|
|
196
|
+
schema: { type: 'object', properties: {} },
|
|
197
|
+
async handler() {
|
|
198
|
+
const client = await plugin.requireClient();
|
|
199
|
+
const about = await client.getAbout();
|
|
200
|
+
return { ok: true, ...about };
|
|
201
|
+
},
|
|
202
|
+
}),
|
|
203
|
+
];
|
|
204
|
+
}
|