diff --git a/CHANGELOG.md b/CHANGELOG.md index df3c853..cee1646 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## Unreleased + +### Added + +- `drives` command to list shared drives (Team Drives) you are a member of +- `--drive ` option on `ls` and `search` to scope results to one shared drive + +### Fixed + +- Shared drive content is now visible to `ls` and `search`. Previously both were + limited to My Drive, so shared drive files silently returned no results. +- `get`, `download`, `upload`, `mkdir`, `delete`, `move`, `rename`, `share`, + `unshare` and `permissions` now work on shared drive items instead of failing + with "File not found". + ## [0.1.1] - 2025-12-12 ### Added diff --git a/README.md b/README.md index 0618d56..bf3859f 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ gdcli accounts add you@gmail.com ``` You can reuse credentials from gmcli or gccli: + ```bash gdcli accounts credentials ~/.gmcli/credentials.json ``` @@ -52,37 +53,61 @@ gdcli accounts add --manual # Add account (browserless, paste redir gdcli accounts remove # Remove account ``` +### drives + +List shared drives (Team Drives) you are a member of. + +```bash +gdcli drives +``` + +Example: + +```bash +gdcli you@gmail.com drives +``` + ### ls -List files in a folder (default: root). +List files in a folder (default: root). Results include shared drive items as +well as My Drive. ```bash gdcli ls [folderId] [options] ``` Options: + - `--max ` - Max results (default: 20) - `--page ` - Page token for pagination - `--query ` - Drive query filter +- `--drive ` - Restrict results to a single shared drive Examples: + ```bash gdcli you@gmail.com ls gdcli you@gmail.com ls 1ABC123 --max 50 gdcli you@gmail.com ls --query "mimeType='image/png'" +gdcli you@gmail.com ls 0ADriveId --drive 0ADriveId # top level of a shared drive ``` +A shared drive's top-level folder id is the drive id itself, as reported by +`gdcli drives`. + ### search -Full-text search across all files. +Full-text search across all files, including shared drives. ```bash -gdcli search [--max N] [--page TOKEN] +gdcli search [--max N] [--page TOKEN] [--drive ] ``` -Example: +Examples: + ```bash gdcli you@gmail.com search "quarterly report" +gdcli you@gmail.com search "contract" --drive 0ADriveId ``` ### get @@ -104,6 +129,7 @@ gdcli download [destPath] Default destination: `~/.gdcli/downloads/` Examples: + ```bash gdcli you@gmail.com download 1ABC123 gdcli you@gmail.com download 1ABC123 ./myfile.pdf @@ -118,10 +144,12 @@ gdcli upload [options] ``` Options: + - `--name ` - Override filename - `--folder ` - Destination folder Examples: + ```bash gdcli you@gmail.com upload ./report.pdf gdcli you@gmail.com upload ./report.pdf --folder 1ABC123 --name "Q4 Report.pdf" @@ -136,6 +164,7 @@ gdcli mkdir [--parent ] ``` Example: + ```bash gdcli you@gmail.com mkdir "New Folder" --parent 1ABC123 ``` @@ -173,11 +202,13 @@ gdcli share [options] ``` Options: + - `--anyone` - Make publicly accessible (anyone with link) - `--email ` - Share with specific user - `--role ` - Permission level: `reader` (default) or `writer` Examples: + ```bash gdcli you@gmail.com share 1ABC123 --anyone gdcli you@gmail.com share 1ABC123 --email friend@gmail.com --role writer @@ -212,6 +243,7 @@ gdcli url ## Data Storage All data is stored in `~/.gdcli/`: + - `credentials.json` - OAuth client credentials - `accounts.json` - Account tokens - `downloads/` - Downloaded files diff --git a/src/cli.ts b/src/cli.ts index d7923b6..1bd61fa 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -23,15 +23,20 @@ ACCOUNT COMMANDS DRIVE COMMANDS + gdcli drives + List shared drives (Team Drives) you are a member of. + gdcli ls [folderId] [options] List files in a folder (default: root). + Includes shared drive items as well as My Drive. Options: --max Max results (default: 20) --page Page token for pagination --query Drive query filter (e.g., "mimeType='image/png'") + --drive Restrict to a single shared drive - gdcli search [--max N] [--page TOKEN] - Full-text search across all files. + gdcli search [--max N] [--page TOKEN] [--drive ] + Full-text search across all files, including shared drives. gdcli get Get file metadata. @@ -77,8 +82,11 @@ DRIVE COMMANDS EXAMPLES gdcli accounts list + gdcli you@gmail.com drives gdcli you@gmail.com ls gdcli you@gmail.com ls 1ABC123 --max 50 + gdcli you@gmail.com ls 0ADriveId --drive 0ADriveId # top level of a shared drive + gdcli you@gmail.com search "contract" --drive 0ADriveId gdcli you@gmail.com search "quarterly report" gdcli you@gmail.com get 1ABC123 gdcli you@gmail.com download 1ABC123 @@ -140,6 +148,9 @@ async function main() { } switch (command) { + case "drives": + await handleDrives(account); + break; case "ls": await handleLs(account, commandArgs); break; @@ -238,6 +249,18 @@ async function handleAccounts(args: string[]) { } } +async function handleDrives(account: string) { + const drives = await service.listDrives(account); + if (drives.length === 0) { + console.log("No shared drives"); + return; + } + console.log("ID\tNAME"); + for (const d of drives) { + console.log(`${d.id}\t${d.name}`); + } +} + async function handleLs(account: string, args: string[]) { const { values, positionals } = parseArgs({ args, @@ -245,6 +268,7 @@ async function handleLs(account: string, args: string[]) { max: { type: "string" }, page: { type: "string" }, query: { type: "string" }, + drive: { type: "string" }, }, allowPositionals: true, }); @@ -256,6 +280,7 @@ async function handleLs(account: string, args: string[]) { maxResults: values.max ? Number(values.max) : 20, pageToken: values.page, query: values.query, + driveId: values.drive, }); if (result.files.length === 0) { @@ -279,6 +304,7 @@ async function handleSearch(account: string, args: string[]) { options: { max: { type: "string" }, page: { type: "string" }, + drive: { type: "string" }, }, allowPositionals: true, }); @@ -286,7 +312,7 @@ async function handleSearch(account: string, args: string[]) { const query = positionals.join(" "); if (!query) error("Usage: search "); - const result = await service.search(account, query, values.max ? Number(values.max) : 20, values.page); + const result = await service.search(account, query, values.max ? Number(values.max) : 20, values.page, values.drive); if (result.files.length === 0) { console.log("No results"); diff --git a/src/drive-service.ts b/src/drive-service.ts index ffef9d9..fe466ca 100644 --- a/src/drive-service.ts +++ b/src/drive-service.ts @@ -14,6 +14,19 @@ export interface FileListResult { nextPageToken?: string; } +export interface SharedDrive { + id: string; + name: string; +} + +// The Drive API hides shared drive content unless the caller opts in: +// supportsAllDrives declares that this client understands shared drives, and +// includeItemsFromAllDrives surfaces their items alongside My Drive ones. +// Omitting either makes every shared drive file invisible to list/search and +// makes get/update/delete fail with a 404 on those files. +const ALL_DRIVES = { supportsAllDrives: true } as const; +const ALL_DRIVES_LIST = { supportsAllDrives: true, includeItemsFromAllDrives: true } as const; + export interface DownloadResult { success: boolean; path?: string; @@ -91,6 +104,7 @@ export class DriveService { maxResults?: number; pageToken?: string; orderBy?: string; + driveId?: string; } = {}, ): Promise { const drive = this.getDriveClient(email); @@ -110,7 +124,10 @@ export class DriveService { pageSize: options.maxResults || 20, pageToken: options.pageToken, orderBy: options.orderBy || "modifiedTime desc", - fields: "nextPageToken, files(id, name, mimeType, size, modifiedTime, parents, webViewLink)", + fields: "nextPageToken, files(id, name, mimeType, size, modifiedTime, parents, webViewLink, driveId)", + ...ALL_DRIVES_LIST, + // Scoping to a single shared drive requires corpora=drive alongside driveId. + ...(options.driveId ? { corpora: "drive", driveId: options.driveId } : {}), }); return { @@ -119,11 +136,23 @@ export class DriveService { }; } + async listDrives(email: string): Promise { + const drive = this.getDriveClient(email); + const response = await drive.drives.list({ + pageSize: 100, + fields: "drives(id, name)", + }); + + return (response.data.drives || []).map((d) => ({ id: d.id || "", name: d.name || "" })); + } + async getFile(email: string, fileId: string): Promise { const drive = this.getDriveClient(email); const response = await drive.files.get({ fileId, - fields: "id, name, mimeType, size, modifiedTime, createdTime, parents, webViewLink, description, starred", + fields: + "id, name, mimeType, size, modifiedTime, createdTime, parents, webViewLink, description, starred, driveId", + ...ALL_DRIVES, }); return response.data; } @@ -167,7 +196,7 @@ export class DriveService { return { success: true, path: exportPath, size: stats.size }; } // Download regular files - const response = await drive.files.get({ fileId, alt: "media" }, { responseType: "stream" }); + const response = await drive.files.get({ fileId, alt: "media", ...ALL_DRIVES }, { responseType: "stream" }); const dest = fs.createWriteStream(filePath); await new Promise((resolve, reject) => { @@ -227,6 +256,7 @@ export class DriveService { requestBody: fileMetadata, media, fields: "id, name, mimeType, size, webViewLink", + ...ALL_DRIVES, }); return response.data; @@ -260,7 +290,7 @@ export class DriveService { async delete(email: string, fileId: string): Promise { const drive = this.getDriveClient(email); - await drive.files.delete({ fileId }); + await drive.files.delete({ fileId, ...ALL_DRIVES }); } async mkdir(email: string, name: string, parentId?: string): Promise { @@ -275,6 +305,7 @@ export class DriveService { const response = await drive.files.create({ requestBody: fileMetadata, fields: "id, name, mimeType, webViewLink", + ...ALL_DRIVES, }); return response.data; @@ -292,6 +323,7 @@ export class DriveService { addParents: newParentId, removeParents: previousParents, fields: "id, name, mimeType, parents, webViewLink", + ...ALL_DRIVES, }); return response.data; @@ -304,6 +336,7 @@ export class DriveService { fileId, requestBody: { name: newName }, fields: "id, name, mimeType, webViewLink", + ...ALL_DRIVES, }); return response.data; @@ -331,12 +364,14 @@ export class DriveService { fileId, requestBody: permission, fields: "id", + ...ALL_DRIVES, }); // Get the shareable link const file = await drive.files.get({ fileId, fields: "webViewLink", + ...ALL_DRIVES, }); return { @@ -347,7 +382,7 @@ export class DriveService { async unshare(email: string, fileId: string, permissionId: string): Promise { const drive = this.getDriveClient(email); - await drive.permissions.delete({ fileId, permissionId }); + await drive.permissions.delete({ fileId, permissionId, ...ALL_DRIVES }); } async listPermissions( @@ -358,6 +393,7 @@ export class DriveService { const response = await drive.permissions.list({ fileId, fields: "permissions(id, type, role, emailAddress)", + ...ALL_DRIVES, }); return (response.data.permissions || []).map((p) => ({ @@ -368,7 +404,13 @@ export class DriveService { })); } - async search(email: string, query: string, maxResults = 20, pageToken?: string): Promise { + async search( + email: string, + query: string, + maxResults = 20, + pageToken?: string, + driveId?: string, + ): Promise { const drive = this.getDriveClient(email); // Full-text search @@ -378,7 +420,9 @@ export class DriveService { q, pageSize: maxResults, pageToken, - fields: "nextPageToken, files(id, name, mimeType, size, modifiedTime, parents, webViewLink)", + fields: "nextPageToken, files(id, name, mimeType, size, modifiedTime, parents, webViewLink, driveId)", + ...ALL_DRIVES_LIST, + ...(driveId ? { corpora: "drive", driveId } : {}), }); return {