just-bash-gdrive 0.1.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,57 @@
1
+ # Contributing
2
+
3
+ ## Setup
4
+
5
+ ```bash
6
+ bun install
7
+ ```
8
+
9
+ ## Development
10
+
11
+ ```bash
12
+ bun run typecheck # TypeScript type check
13
+ bun run test # Run vitest suite
14
+ bun run build # Compile to dist/
15
+ ```
16
+
17
+ ## Testing
18
+
19
+ Unit tests use vitest with a fetch mock — no real Drive credentials needed:
20
+
21
+ ```bash
22
+ bun run test
23
+ ```
24
+
25
+ For live integration testing against real Drive, set up OAuth2 credentials and run:
26
+
27
+ ```bash
28
+ bun test-live.ts [optional-folder-id]
29
+ ```
30
+
31
+ ## Structure
32
+
33
+ ```
34
+ src/
35
+ index.ts — Public exports
36
+ gdrive-fs.ts — IFileSystem implementation
37
+ gdrive-client.ts — Drive API wrapper (thin fetch layer)
38
+ path-cache.ts — Bidirectional path↔ID cache
39
+ errors.ts — Drive API errors → POSIX errnos
40
+ types.ts — Drive API response shapes + options
41
+ type-check.ts — Compile-time IFileSystem conformance check
42
+ gdrive-fs.test.ts — Unit tests
43
+ ```
44
+
45
+ ## Adding a feature
46
+
47
+ 1. The `IFileSystem` interface is in `node_modules/just-bash/dist/fs/interface.d.ts`
48
+ 2. All methods must match the interface exactly
49
+ 3. Run `bun run typecheck` to verify conformance
50
+ 4. Add tests for any new behavior
51
+
52
+ ## Publishing
53
+
54
+ ```bash
55
+ bun run build
56
+ npm publish
57
+ ```
package/README.md ADDED
@@ -0,0 +1,121 @@
1
+ # just-bash-gdrive
2
+
3
+ Google Drive filesystem adapter for [just-bash](https://github.com/vercel-labs/just-bash).
4
+
5
+ Lets AI agents interact with Google Drive files using standard bash commands (`ls`, `cat`, `cp`, `grep`, etc.) without needing any Drive API knowledge.
6
+
7
+ Inspired by [just-bash-dropbox](https://github.com/manishrc/just-bash-dropbox) — the same pattern, applied to Google Drive.
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ npm install just-bash-gdrive just-bash
13
+ ```
14
+
15
+ ## Usage
16
+
17
+ ```ts
18
+ import { Bash } from "just-bash";
19
+ import { GDriveFs } from "just-bash-gdrive";
20
+
21
+ const fs = new GDriveFs({
22
+ // Static token or async provider for OAuth2 refresh
23
+ accessToken: () => getAccessToken(),
24
+ // Constrain agent to a specific folder (optional)
25
+ rootFolderId: "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgVE2upms",
26
+ });
27
+
28
+ const bash = new Bash({ fs });
29
+ const result = await bash.exec("ls -la /");
30
+ ```
31
+
32
+ ### Safe mode (read-only Drive, writes go to memory)
33
+
34
+ ```ts
35
+ import { Bash, MountableFs, InMemoryFs } from "just-bash";
36
+ import { GDriveFs } from "just-bash-gdrive";
37
+
38
+ const drive = new GDriveFs({ accessToken: () => getAccessToken() });
39
+ const memory = new InMemoryFs();
40
+ const mountable = new MountableFs(memory);
41
+
42
+ // Mount Drive at /drive — writes go to memory, not Drive
43
+ await mountable.mount("/drive", drive);
44
+
45
+ const bash = new Bash({ fs: mountable });
46
+
47
+ // Reads come from Drive, writes stay in memory
48
+ await bash.exec("cat /drive/my-doc.txt");
49
+ await bash.exec("echo 'draft' > /draft.txt"); // memory only
50
+ ```
51
+
52
+ ### Prefetch for glob support
53
+
54
+ ```ts
55
+ const fs = new GDriveFs({ accessToken: token, rootFolderId: myFolderId });
56
+
57
+ // Recursively cache all paths for glob/find support
58
+ await fs.prefetchAllPaths();
59
+
60
+ const bash = new Bash({ fs });
61
+ await bash.exec("find / -name '*.md'"); // works after prefetch
62
+ ```
63
+
64
+ ### AI SDK tool
65
+
66
+ ```ts
67
+ import { generateText } from "ai";
68
+ import { bashTool } from "just-bash/ai-sdk";
69
+
70
+ const { text } = await generateText({
71
+ model: yourModel,
72
+ tools: { bash: bashTool({ fs }) },
73
+ prompt: "List all markdown files in my Drive and summarize what they contain",
74
+ });
75
+ ```
76
+
77
+ ### Getting an access token
78
+
79
+ Use your preferred Google OAuth2 library. The `accessToken` option accepts either a static string or an async function — use the async form for long-running agents so the token refreshes automatically:
80
+
81
+ ```ts
82
+ import { google } from "googleapis";
83
+
84
+ const auth = new google.auth.OAuth2(CLIENT_ID, CLIENT_SECRET, REDIRECT_URI);
85
+ auth.setCredentials({ refresh_token: REFRESH_TOKEN });
86
+
87
+ const fs = new GDriveFs({
88
+ accessToken: async () => {
89
+ const { token } = await auth.getAccessToken();
90
+ return token!;
91
+ },
92
+ });
93
+ ```
94
+
95
+ ## Options
96
+
97
+ | Option | Type | Default | Description |
98
+ |--------|------|---------|-------------|
99
+ | `accessToken` | `string \| () => Promise<string>` | required | OAuth2 access token or async provider |
100
+ | `rootFolderId` | `string` | `"root"` | Constrain agent to this Drive folder ID |
101
+
102
+ ## How it works
103
+
104
+ Google Drive uses opaque file IDs rather than paths. `GDriveFs` maintains a bidirectional path-to-ID cache that is built lazily as you navigate the filesystem, or all at once via `prefetchAllPaths()`. Every bash command (`ls`, `cat`, `cp`, etc.) resolves paths through this cache before hitting the Drive API.
105
+
106
+ Rate limit handling: automatically retries on HTTP 429 with `Retry-After` backoff (up to 3 attempts).
107
+
108
+ ## Limitations
109
+
110
+ - `chmod`, `symlink`, `link`, `readlink`, `utimes` throw `ENOSYS` — Drive has no POSIX permission or symlink concept
111
+ - `getAllPaths()` returns `[]` until `prefetchAllPaths()` is called — glob operations require prefetch
112
+ - `appendFile` reads the existing file, appends, then rewrites — Drive has no atomic append
113
+ - Google Workspace files (Docs, Sheets, Slides) cannot be read as raw content; use `gog-andy` or the Drive export API for those
114
+
115
+ ## Inspiration
116
+
117
+ This adapter was built following the pattern established by [just-bash-dropbox](https://github.com/manishrc/just-bash-dropbox) by [@manishrc](https://github.com/manishrc). The `IFileSystem` interface, the token provider pattern, the `prefetchAllPaths` approach for glob support, and the `MountableFs` safe mode pattern are all drawn from that work. Thanks Manish.
118
+
119
+ ## License
120
+
121
+ Apache-2.0
@@ -0,0 +1,16 @@
1
+ export declare class FsError extends Error {
2
+ code: string;
3
+ constructor(code: string, message: string);
4
+ }
5
+ export declare const enoent: (path: string) => FsError;
6
+ export declare const enotdir: (path: string) => FsError;
7
+ export declare const eisdir: (path: string) => FsError;
8
+ export declare const eexist: (path: string) => FsError;
9
+ export declare const enosys: (op: string) => FsError;
10
+ export declare const enospc: () => FsError;
11
+ export declare class DriveApiError extends Error {
12
+ status: number;
13
+ body: unknown;
14
+ constructor(status: number, body: unknown);
15
+ }
16
+ export declare function mapDriveError(err: DriveApiError, path: string): FsError;
package/dist/errors.js ADDED
@@ -0,0 +1,39 @@
1
+ export class FsError extends Error {
2
+ code;
3
+ constructor(code, message) {
4
+ super(message);
5
+ this.code = code;
6
+ this.name = "FsError";
7
+ }
8
+ }
9
+ export const enoent = (path) => new FsError("ENOENT", `no such file or directory: ${path}`);
10
+ export const enotdir = (path) => new FsError("ENOTDIR", `not a directory: ${path}`);
11
+ export const eisdir = (path) => new FsError("EISDIR", `illegal operation on a directory: ${path}`);
12
+ export const eexist = (path) => new FsError("EEXIST", `file already exists: ${path}`);
13
+ export const enosys = (op) => new FsError("ENOSYS", `${op} not supported`);
14
+ export const enospc = () => new FsError("ENOSPC", `no space left on device`);
15
+ export class DriveApiError extends Error {
16
+ status;
17
+ body;
18
+ constructor(status, body) {
19
+ const msg = typeof body === "object" && body !== null && "error" in body
20
+ ? body.error.message
21
+ : String(body);
22
+ super(msg);
23
+ this.status = status;
24
+ this.body = body;
25
+ this.name = "DriveApiError";
26
+ }
27
+ }
28
+ export function mapDriveError(err, path) {
29
+ switch (err.status) {
30
+ case 404:
31
+ return enoent(path);
32
+ case 403:
33
+ return new FsError("EACCES", `permission denied: ${path}`);
34
+ case 507:
35
+ return enospc();
36
+ default:
37
+ return new FsError("EIO", `drive api error ${err.status}: ${err.message}`);
38
+ }
39
+ }
@@ -0,0 +1,29 @@
1
+ import type { DriveFileMetadata, DriveAbout } from "./types.js";
2
+ export declare class GDriveClient {
3
+ private getToken;
4
+ constructor(accessToken: string | (() => Promise<string>));
5
+ private headers;
6
+ private request;
7
+ /** List files in a folder. Paginates automatically. */
8
+ listFolder(folderId: string): Promise<DriveFileMetadata[]>;
9
+ /** Get file metadata by ID */
10
+ getFile(fileId: string): Promise<DriveFileMetadata>;
11
+ /** Get file metadata by name within a parent folder */
12
+ findFile(name: string, parentId: string): Promise<DriveFileMetadata | null>;
13
+ /** Download file content */
14
+ downloadFile(fileId: string): Promise<Uint8Array>;
15
+ /** Create a new file with content */
16
+ createFile(name: string, parentId: string, content: Uint8Array, mimeType?: string): Promise<DriveFileMetadata>;
17
+ /** Update existing file content */
18
+ updateFile(fileId: string, content: Uint8Array): Promise<DriveFileMetadata>;
19
+ /** Create a folder */
20
+ createFolder(name: string, parentId: string): Promise<DriveFileMetadata>;
21
+ /** Delete a file or folder (moves to trash) */
22
+ delete(fileId: string): Promise<void>;
23
+ /** Copy a file to a new parent/name */
24
+ copy(fileId: string, name: string, parentId: string): Promise<DriveFileMetadata>;
25
+ /** Move a file by updating its parent */
26
+ move(fileId: string, name: string, newParentId: string, oldParentId: string): Promise<DriveFileMetadata>;
27
+ /** Get storage quota info */
28
+ getAbout(): Promise<DriveAbout>;
29
+ }
@@ -0,0 +1,152 @@
1
+ import { DriveApiError } from "./errors.js";
2
+ import { FOLDER_MIME } from "./types.js";
3
+ const API_BASE = "https://www.googleapis.com/drive/v3";
4
+ const UPLOAD_BASE = "https://www.googleapis.com/upload/drive/v3";
5
+ export class GDriveClient {
6
+ getToken;
7
+ constructor(accessToken) {
8
+ this.getToken =
9
+ typeof accessToken === "string" ? () => Promise.resolve(accessToken) : accessToken;
10
+ }
11
+ async headers() {
12
+ const token = await this.getToken();
13
+ return { Authorization: `Bearer ${token}`, "Content-Type": "application/json" };
14
+ }
15
+ async request(url, init = {}, retries = 3) {
16
+ const headers = await this.headers();
17
+ const res = await fetch(url, {
18
+ ...init,
19
+ headers: { ...headers, ...(init.headers ?? {}) },
20
+ });
21
+ if (res.status === 429 && retries > 0) {
22
+ const retryAfter = parseInt(res.headers.get("Retry-After") ?? "2", 10);
23
+ await new Promise((r) => setTimeout(r, retryAfter * 1000));
24
+ return this.request(url, init, retries - 1);
25
+ }
26
+ if (!res.ok) {
27
+ const body = await res.json().catch(() => res.statusText);
28
+ throw new DriveApiError(res.status, body);
29
+ }
30
+ return res.json();
31
+ }
32
+ /** List files in a folder. Paginates automatically. */
33
+ async listFolder(folderId) {
34
+ const files = [];
35
+ let pageToken;
36
+ do {
37
+ const params = new URLSearchParams({
38
+ q: `'${folderId}' in parents and trashed = false`,
39
+ fields: "nextPageToken,files(id,name,mimeType,size,modifiedTime,createdTime,parents)",
40
+ pageSize: "1000",
41
+ ...(pageToken ? { pageToken } : {}),
42
+ });
43
+ const res = await this.request(`${API_BASE}/files?${params}`);
44
+ files.push(...res.files);
45
+ pageToken = res.nextPageToken;
46
+ } while (pageToken);
47
+ return files;
48
+ }
49
+ /** Get file metadata by ID */
50
+ async getFile(fileId) {
51
+ const params = new URLSearchParams({
52
+ fields: "id,name,mimeType,size,modifiedTime,createdTime,parents",
53
+ });
54
+ return this.request(`${API_BASE}/files/${fileId}?${params}`);
55
+ }
56
+ /** Get file metadata by name within a parent folder */
57
+ async findFile(name, parentId) {
58
+ const params = new URLSearchParams({
59
+ q: `name = '${name.replace(/'/g, "\\'")}' and '${parentId}' in parents and trashed = false`,
60
+ fields: "files(id,name,mimeType,size,modifiedTime,createdTime,parents)",
61
+ pageSize: "1",
62
+ });
63
+ const res = await this.request(`${API_BASE}/files?${params}`);
64
+ return res.files[0] ?? null;
65
+ }
66
+ /** Download file content */
67
+ async downloadFile(fileId) {
68
+ const token = await this.getToken();
69
+ const res = await fetch(`${API_BASE}/files/${fileId}?alt=media`, {
70
+ headers: { Authorization: `Bearer ${token}` },
71
+ });
72
+ if (!res.ok) {
73
+ const body = await res.json().catch(() => res.statusText);
74
+ throw new DriveApiError(res.status, body);
75
+ }
76
+ return new Uint8Array(await res.arrayBuffer());
77
+ }
78
+ /** Create a new file with content */
79
+ async createFile(name, parentId, content, mimeType = "application/octet-stream") {
80
+ const metadata = { name, parents: [parentId] };
81
+ const form = new FormData();
82
+ form.append("metadata", new Blob([JSON.stringify(metadata)], { type: "application/json" }));
83
+ form.append("file", new Blob([content.buffer], { type: mimeType }));
84
+ const token = await this.getToken();
85
+ const res = await fetch(`${UPLOAD_BASE}/files?uploadType=multipart&fields=id,name,mimeType,size,modifiedTime,createdTime,parents`, {
86
+ method: "POST",
87
+ headers: { Authorization: `Bearer ${token}` },
88
+ body: form,
89
+ });
90
+ if (!res.ok) {
91
+ const body = await res.json().catch(() => res.statusText);
92
+ throw new DriveApiError(res.status, body);
93
+ }
94
+ return res.json();
95
+ }
96
+ /** Update existing file content */
97
+ async updateFile(fileId, content) {
98
+ const token = await this.getToken();
99
+ const res = await fetch(`${UPLOAD_BASE}/files/${fileId}?uploadType=media&fields=id,name,mimeType,size,modifiedTime`, {
100
+ method: "PATCH",
101
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/octet-stream" },
102
+ body: content.buffer,
103
+ });
104
+ if (!res.ok) {
105
+ const body = await res.json().catch(() => res.statusText);
106
+ throw new DriveApiError(res.status, body);
107
+ }
108
+ return res.json();
109
+ }
110
+ /** Create a folder */
111
+ async createFolder(name, parentId) {
112
+ return this.request(`${API_BASE}/files`, {
113
+ method: "POST",
114
+ body: JSON.stringify({ name, mimeType: FOLDER_MIME, parents: [parentId] }),
115
+ });
116
+ }
117
+ /** Delete a file or folder (moves to trash) */
118
+ async delete(fileId) {
119
+ const token = await this.getToken();
120
+ const res = await fetch(`${API_BASE}/files/${fileId}`, {
121
+ method: "DELETE",
122
+ headers: { Authorization: `Bearer ${token}` },
123
+ });
124
+ if (!res.ok && res.status !== 204) {
125
+ const body = await res.json().catch(() => res.statusText);
126
+ throw new DriveApiError(res.status, body);
127
+ }
128
+ }
129
+ /** Copy a file to a new parent/name */
130
+ async copy(fileId, name, parentId) {
131
+ return this.request(`${API_BASE}/files/${fileId}/copy`, {
132
+ method: "POST",
133
+ body: JSON.stringify({ name, parents: [parentId] }),
134
+ });
135
+ }
136
+ /** Move a file by updating its parent */
137
+ async move(fileId, name, newParentId, oldParentId) {
138
+ const params = new URLSearchParams({
139
+ addParents: newParentId,
140
+ removeParents: oldParentId,
141
+ fields: "id,name,mimeType,size,modifiedTime,parents",
142
+ });
143
+ return this.request(`${API_BASE}/files/${fileId}?${params}`, {
144
+ method: "PATCH",
145
+ body: JSON.stringify({ name }),
146
+ });
147
+ }
148
+ /** Get storage quota info */
149
+ async getAbout() {
150
+ return this.request(`${API_BASE}/about?fields=storageQuota`);
151
+ }
152
+ }
@@ -0,0 +1,59 @@
1
+ import type { IFileSystem } from "just-bash";
2
+ import type { GDriveFsOptions } from "./types.js";
3
+ export declare class GDriveFs implements IFileSystem {
4
+ private client;
5
+ private cache;
6
+ private rootFolderId;
7
+ private prefetched;
8
+ constructor(options: GDriveFsOptions);
9
+ resolvePath(base: string, target: string): string;
10
+ /** Resolve a path to a Drive file ID, fetching parent chain if needed */
11
+ private resolveId;
12
+ private metaToEntry;
13
+ prefetchAllPaths(folderId?: string, basePath?: string): Promise<void>;
14
+ getAllPaths(): string[];
15
+ stat(path: string): Promise<{
16
+ isFile: boolean;
17
+ isDirectory: boolean;
18
+ isSymbolicLink: boolean;
19
+ size: number;
20
+ mtime: Date;
21
+ mode: number;
22
+ }>;
23
+ lstat(path: string): Promise<{
24
+ isFile: boolean;
25
+ isDirectory: boolean;
26
+ isSymbolicLink: boolean;
27
+ size: number;
28
+ mtime: Date;
29
+ mode: number;
30
+ }>;
31
+ private entryToStat;
32
+ exists(path: string): Promise<boolean>;
33
+ realpath(path: string): Promise<string>;
34
+ readFileBuffer(path: string): Promise<Uint8Array>;
35
+ readFile(path: string): Promise<string>;
36
+ readdir(path: string): Promise<string[]>;
37
+ readdirWithFileTypes(path: string): Promise<Array<{
38
+ name: string;
39
+ isDirectory: boolean;
40
+ isFile: boolean;
41
+ isSymbolicLink: boolean;
42
+ }>>;
43
+ writeFile(path: string, content: string | Uint8Array): Promise<void>;
44
+ appendFile(path: string, content: string | Uint8Array): Promise<void>;
45
+ mkdir(path: string, options?: {
46
+ recursive?: boolean;
47
+ }): Promise<void>;
48
+ rm(path: string, options?: {
49
+ recursive?: boolean;
50
+ force?: boolean;
51
+ }): Promise<void>;
52
+ cp(src: string, dest: string): Promise<void>;
53
+ mv(src: string, dest: string): Promise<void>;
54
+ chmod(_path: string, _mode: number): Promise<void>;
55
+ symlink(_target: string, _path: string): Promise<void>;
56
+ link(_existingPath: string, _newPath: string): Promise<void>;
57
+ readlink(path: string): Promise<string>;
58
+ utimes(_path: string, _atime: Date, _mtime: Date): Promise<void>;
59
+ }
@@ -0,0 +1,312 @@
1
+ import { GDriveClient } from "./gdrive-client.js";
2
+ import { PathCache, normalizePath, dirname, basename, joinPath } from "./path-cache.js";
3
+ import { enoent, eisdir, enotdir, eexist, enosys, mapDriveError, DriveApiError } from "./errors.js";
4
+ import { FOLDER_MIME } from "./types.js";
5
+ export class GDriveFs {
6
+ client;
7
+ cache;
8
+ rootFolderId;
9
+ prefetched = false;
10
+ constructor(options) {
11
+ this.client = new GDriveClient(options.accessToken);
12
+ this.cache = new PathCache();
13
+ this.rootFolderId = options.rootFolderId ?? "root";
14
+ // Seed root
15
+ this.cache.set("/", {
16
+ id: this.rootFolderId,
17
+ isFolder: true,
18
+ mimeType: FOLDER_MIME,
19
+ });
20
+ }
21
+ // ── Path resolution ──────────────────────────────────────────────────────
22
+ resolvePath(base, target) {
23
+ if (target.startsWith("/"))
24
+ return normalizePath(target);
25
+ return normalizePath(base + "/" + target);
26
+ }
27
+ /** Resolve a path to a Drive file ID, fetching parent chain if needed */
28
+ async resolveId(path) {
29
+ const normalized = normalizePath(path);
30
+ const cached = this.cache.get(normalized);
31
+ if (cached)
32
+ return cached.id;
33
+ // Walk the path components to build the cache entry
34
+ const parts = normalized.split("/").filter(Boolean);
35
+ let parentId = this.rootFolderId;
36
+ let currentPath = "/";
37
+ for (const part of parts) {
38
+ currentPath = joinPath(currentPath, part);
39
+ const existing = this.cache.get(currentPath);
40
+ if (existing) {
41
+ parentId = existing.id;
42
+ continue;
43
+ }
44
+ try {
45
+ const file = await this.client.findFile(part, parentId);
46
+ if (!file)
47
+ throw enoent(currentPath);
48
+ this.cache.set(currentPath, this.metaToEntry(file));
49
+ parentId = file.id;
50
+ }
51
+ catch (err) {
52
+ if (err instanceof DriveApiError)
53
+ throw mapDriveError(err, currentPath);
54
+ throw err;
55
+ }
56
+ }
57
+ return this.cache.get(normalized).id;
58
+ }
59
+ metaToEntry(file) {
60
+ return {
61
+ id: file.id,
62
+ isFolder: file.mimeType === FOLDER_MIME,
63
+ mimeType: file.mimeType,
64
+ size: file.size ? parseInt(file.size, 10) : undefined,
65
+ modifiedTime: file.modifiedTime ? new Date(file.modifiedTime) : undefined,
66
+ createdTime: file.createdTime ? new Date(file.createdTime) : undefined,
67
+ parentId: file.parents?.[0],
68
+ };
69
+ }
70
+ // ── Prefetch (enables glob support) ─────────────────────────────────────
71
+ async prefetchAllPaths(folderId, basePath = "/") {
72
+ const id = folderId ?? this.rootFolderId;
73
+ const files = await this.client.listFolder(id);
74
+ for (const file of files) {
75
+ const filePath = joinPath(basePath, file.name);
76
+ this.cache.set(filePath, this.metaToEntry(file));
77
+ if (file.mimeType === FOLDER_MIME) {
78
+ await this.prefetchAllPaths(file.id, filePath);
79
+ }
80
+ }
81
+ if (!folderId)
82
+ this.prefetched = true;
83
+ }
84
+ getAllPaths() {
85
+ return this.prefetched ? this.cache.getAllPaths() : [];
86
+ }
87
+ // ── Stat / exists ────────────────────────────────────────────────────────
88
+ async stat(path) {
89
+ const normalized = normalizePath(path);
90
+ const cached = this.cache.get(normalized);
91
+ if (cached)
92
+ return this.entryToStat(normalized, cached);
93
+ const id = await this.resolveId(normalized);
94
+ const entry = this.cache.get(normalized);
95
+ return this.entryToStat(normalized, entry);
96
+ }
97
+ async lstat(path) {
98
+ return this.stat(path); // Drive has no symlinks
99
+ }
100
+ entryToStat(_path, entry) {
101
+ return {
102
+ isFile: !entry.isFolder,
103
+ isDirectory: entry.isFolder,
104
+ isSymbolicLink: false,
105
+ size: entry.size ?? 0,
106
+ mtime: entry.modifiedTime ?? new Date(0),
107
+ mode: entry.isFolder ? 0o755 : 0o644,
108
+ };
109
+ }
110
+ async exists(path) {
111
+ try {
112
+ await this.resolveId(path);
113
+ return true;
114
+ }
115
+ catch {
116
+ return false;
117
+ }
118
+ }
119
+ async realpath(path) {
120
+ await this.resolveId(path); // throws if not found
121
+ return normalizePath(path);
122
+ }
123
+ // ── Read ─────────────────────────────────────────────────────────────────
124
+ async readFileBuffer(path) {
125
+ const normalized = normalizePath(path);
126
+ const entry = this.cache.get(normalized);
127
+ if (entry?.isFolder)
128
+ throw eisdir(normalized);
129
+ try {
130
+ const id = await this.resolveId(normalized);
131
+ const cached = this.cache.get(normalized);
132
+ if (cached.isFolder)
133
+ throw eisdir(normalized);
134
+ return await this.client.downloadFile(id);
135
+ }
136
+ catch (err) {
137
+ if (err instanceof DriveApiError)
138
+ throw mapDriveError(err, normalized);
139
+ throw err;
140
+ }
141
+ }
142
+ async readFile(path) {
143
+ const buf = await this.readFileBuffer(path);
144
+ return new TextDecoder().decode(buf);
145
+ }
146
+ async readdir(path) {
147
+ const entries = await this.readdirWithFileTypes(path);
148
+ return entries.map((e) => e.name);
149
+ }
150
+ async readdirWithFileTypes(path) {
151
+ const normalized = normalizePath(path);
152
+ const id = await this.resolveId(normalized);
153
+ const entry = this.cache.get(normalized);
154
+ if (!entry.isFolder)
155
+ throw enotdir(normalized);
156
+ try {
157
+ const files = await this.client.listFolder(id);
158
+ return files.map((file) => {
159
+ const filePath = joinPath(normalized, file.name);
160
+ this.cache.set(filePath, this.metaToEntry(file));
161
+ const isDir = file.mimeType === FOLDER_MIME;
162
+ return {
163
+ name: file.name,
164
+ isDirectory: isDir,
165
+ isFile: !isDir,
166
+ isSymbolicLink: false,
167
+ };
168
+ });
169
+ }
170
+ catch (err) {
171
+ if (err instanceof DriveApiError)
172
+ throw mapDriveError(err, normalized);
173
+ throw err;
174
+ }
175
+ }
176
+ // ── Write ────────────────────────────────────────────────────────────────
177
+ async writeFile(path, content) {
178
+ const normalized = normalizePath(path);
179
+ const data = typeof content === "string" ? new TextEncoder().encode(content) : content;
180
+ const parentPath = dirname(normalized);
181
+ const name = basename(normalized);
182
+ try {
183
+ const parentId = await this.resolveId(parentPath);
184
+ const existing = this.cache.get(normalized);
185
+ if (existing) {
186
+ if (existing.isFolder)
187
+ throw eisdir(normalized);
188
+ await this.client.updateFile(existing.id, data);
189
+ this.cache.set(normalized, { ...existing, size: data.byteLength, modifiedTime: new Date() });
190
+ }
191
+ else {
192
+ const file = await this.client.createFile(name, parentId, data);
193
+ this.cache.set(normalized, this.metaToEntry(file));
194
+ }
195
+ }
196
+ catch (err) {
197
+ if (err instanceof DriveApiError)
198
+ throw mapDriveError(err, normalized);
199
+ throw err;
200
+ }
201
+ }
202
+ async appendFile(path, content) {
203
+ const normalized = normalizePath(path);
204
+ let existing = new Uint8Array(0);
205
+ if (await this.exists(normalized)) {
206
+ existing = await this.readFileBuffer(normalized);
207
+ }
208
+ const append = typeof content === "string" ? new TextEncoder().encode(content) : content;
209
+ const combined = new Uint8Array(existing.byteLength + append.byteLength);
210
+ combined.set(existing, 0);
211
+ combined.set(append, existing.byteLength);
212
+ await this.writeFile(normalized, combined);
213
+ }
214
+ async mkdir(path, options) {
215
+ const normalized = normalizePath(path);
216
+ if (await this.exists(normalized)) {
217
+ if (!options?.recursive)
218
+ throw eexist(normalized);
219
+ return;
220
+ }
221
+ if (options?.recursive) {
222
+ const parent = dirname(normalized);
223
+ if (parent !== normalized)
224
+ await this.mkdir(parent, { recursive: true });
225
+ }
226
+ const parentPath = dirname(normalized);
227
+ const name = basename(normalized);
228
+ try {
229
+ const parentId = await this.resolveId(parentPath);
230
+ const folder = await this.client.createFolder(name, parentId);
231
+ this.cache.set(normalized, this.metaToEntry(folder));
232
+ }
233
+ catch (err) {
234
+ if (err instanceof DriveApiError)
235
+ throw mapDriveError(err, normalized);
236
+ throw err;
237
+ }
238
+ }
239
+ // ── Delete / move / copy ─────────────────────────────────────────────────
240
+ async rm(path, options) {
241
+ const normalized = normalizePath(path);
242
+ try {
243
+ const id = await this.resolveId(normalized);
244
+ const entry = this.cache.get(normalized);
245
+ if (entry.isFolder && !options?.recursive) {
246
+ throw new (await import("./errors.js")).FsError("EISDIR", `is a directory: ${normalized}`);
247
+ }
248
+ await this.client.delete(id);
249
+ // Purge from cache (including children)
250
+ for (const p of this.cache.getAllPaths()) {
251
+ if (p === normalized || p.startsWith(normalized + "/"))
252
+ this.cache.delete(p);
253
+ }
254
+ }
255
+ catch (err) {
256
+ if (options?.force)
257
+ return;
258
+ if (err instanceof DriveApiError)
259
+ throw mapDriveError(err, normalized);
260
+ throw err;
261
+ }
262
+ }
263
+ async cp(src, dest) {
264
+ const srcNorm = normalizePath(src);
265
+ const destNorm = normalizePath(dest);
266
+ try {
267
+ const srcId = await this.resolveId(srcNorm);
268
+ const destParentId = await this.resolveId(dirname(destNorm));
269
+ const file = await this.client.copy(srcId, basename(destNorm), destParentId);
270
+ this.cache.set(destNorm, this.metaToEntry(file));
271
+ }
272
+ catch (err) {
273
+ if (err instanceof DriveApiError)
274
+ throw mapDriveError(err, srcNorm);
275
+ throw err;
276
+ }
277
+ }
278
+ async mv(src, dest) {
279
+ const srcNorm = normalizePath(src);
280
+ const destNorm = normalizePath(dest);
281
+ try {
282
+ const srcId = await this.resolveId(srcNorm);
283
+ const srcEntry = this.cache.get(srcNorm);
284
+ const newParentId = await this.resolveId(dirname(destNorm));
285
+ const oldParentId = srcEntry.parentId ?? this.rootFolderId;
286
+ const file = await this.client.move(srcId, basename(destNorm), newParentId, oldParentId);
287
+ this.cache.delete(srcNorm);
288
+ this.cache.set(destNorm, this.metaToEntry(file));
289
+ }
290
+ catch (err) {
291
+ if (err instanceof DriveApiError)
292
+ throw mapDriveError(err, srcNorm);
293
+ throw err;
294
+ }
295
+ }
296
+ // ── Unsupported POSIX ops ────────────────────────────────────────────────
297
+ async chmod(_path, _mode) {
298
+ throw enosys("chmod");
299
+ }
300
+ async symlink(_target, _path) {
301
+ throw enosys("symlink");
302
+ }
303
+ async link(_existingPath, _newPath) {
304
+ throw enosys("link");
305
+ }
306
+ async readlink(path) {
307
+ throw enosys("readlink");
308
+ }
309
+ async utimes(_path, _atime, _mtime) {
310
+ throw enosys("utimes");
311
+ }
312
+ }
@@ -0,0 +1,3 @@
1
+ export { GDriveFs } from "./gdrive-fs.js";
2
+ export { FsError } from "./errors.js";
3
+ export type { GDriveFsOptions } from "./types.js";
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export { GDriveFs } from "./gdrive-fs.js";
2
+ export { FsError } from "./errors.js";
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Path-to-ID cache for Google Drive.
3
+ *
4
+ * Drive uses opaque file IDs, not paths. This cache maintains a
5
+ * bidirectional map between POSIX paths and Drive file IDs so that
6
+ * all filesystem operations can resolve paths without extra API calls
7
+ * after an initial prefetch.
8
+ */
9
+ export interface CacheEntry {
10
+ id: string;
11
+ isFolder: boolean;
12
+ mimeType: string;
13
+ size?: number;
14
+ modifiedTime?: Date;
15
+ createdTime?: Date;
16
+ parentId?: string;
17
+ }
18
+ export declare class PathCache {
19
+ private pathToEntry;
20
+ private idToPath;
21
+ set(path: string, entry: CacheEntry): void;
22
+ get(path: string): CacheEntry | undefined;
23
+ getById(id: string): string | undefined;
24
+ delete(path: string): void;
25
+ /** Move: update path mapping when a file is renamed/moved */
26
+ move(oldPath: string, newPath: string): void;
27
+ children(parentPath: string): Array<[string, CacheEntry]>;
28
+ getAllPaths(): string[];
29
+ has(path: string): boolean;
30
+ clear(): void;
31
+ }
32
+ export declare function normalizePath(p: string): string;
33
+ export declare function dirname(p: string): string;
34
+ export declare function basename(p: string): string;
35
+ export declare function joinPath(...parts: string[]): string;
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Path-to-ID cache for Google Drive.
3
+ *
4
+ * Drive uses opaque file IDs, not paths. This cache maintains a
5
+ * bidirectional map between POSIX paths and Drive file IDs so that
6
+ * all filesystem operations can resolve paths without extra API calls
7
+ * after an initial prefetch.
8
+ */
9
+ export class PathCache {
10
+ pathToEntry = new Map();
11
+ idToPath = new Map();
12
+ set(path, entry) {
13
+ const normalized = normalizePath(path);
14
+ this.pathToEntry.set(normalized, entry);
15
+ this.idToPath.set(entry.id, normalized);
16
+ }
17
+ get(path) {
18
+ return this.pathToEntry.get(normalizePath(path));
19
+ }
20
+ getById(id) {
21
+ return this.idToPath.get(id);
22
+ }
23
+ delete(path) {
24
+ const normalized = normalizePath(path);
25
+ const entry = this.pathToEntry.get(normalized);
26
+ if (entry) {
27
+ this.idToPath.delete(entry.id);
28
+ this.pathToEntry.delete(normalized);
29
+ }
30
+ }
31
+ /** Move: update path mapping when a file is renamed/moved */
32
+ move(oldPath, newPath) {
33
+ const entry = this.pathToEntry.get(normalizePath(oldPath));
34
+ if (entry) {
35
+ this.delete(oldPath);
36
+ this.set(newPath, entry);
37
+ }
38
+ }
39
+ children(parentPath) {
40
+ const normalized = normalizePath(parentPath);
41
+ const prefix = normalized === "/" ? "/" : normalized + "/";
42
+ const result = [];
43
+ for (const [path, entry] of this.pathToEntry) {
44
+ if (path === normalized)
45
+ continue;
46
+ if (!path.startsWith(prefix))
47
+ continue;
48
+ // Only direct children (no additional slashes after prefix)
49
+ const rest = path.slice(prefix.length);
50
+ if (!rest.includes("/"))
51
+ result.push([path, entry]);
52
+ }
53
+ return result;
54
+ }
55
+ getAllPaths() {
56
+ return Array.from(this.pathToEntry.keys());
57
+ }
58
+ has(path) {
59
+ return this.pathToEntry.has(normalizePath(path));
60
+ }
61
+ clear() {
62
+ this.pathToEntry.clear();
63
+ this.idToPath.clear();
64
+ }
65
+ }
66
+ export function normalizePath(p) {
67
+ // Collapse double slashes, ensure leading slash, strip trailing slash
68
+ let normalized = p.replace(/\/+/g, "/");
69
+ if (!normalized.startsWith("/"))
70
+ normalized = "/" + normalized;
71
+ if (normalized !== "/" && normalized.endsWith("/")) {
72
+ normalized = normalized.slice(0, -1);
73
+ }
74
+ return normalized;
75
+ }
76
+ export function dirname(p) {
77
+ const normalized = normalizePath(p);
78
+ const idx = normalized.lastIndexOf("/");
79
+ if (idx <= 0)
80
+ return "/";
81
+ return normalized.slice(0, idx);
82
+ }
83
+ export function basename(p) {
84
+ const normalized = normalizePath(p);
85
+ return normalized.slice(normalized.lastIndexOf("/") + 1);
86
+ }
87
+ export function joinPath(...parts) {
88
+ return normalizePath(parts.join("/"));
89
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,3 @@
1
+ const _check = {};
2
+ void _check;
3
+ export {};
@@ -0,0 +1,28 @@
1
+ export interface GDriveFsOptions {
2
+ /** OAuth2 access token or async provider for token refresh */
3
+ accessToken: string | (() => Promise<string>);
4
+ /** Constrain agent to a specific folder by Drive folder ID. Defaults to root ("root") */
5
+ rootFolderId?: string;
6
+ }
7
+ export interface DriveFileMetadata {
8
+ id: string;
9
+ name: string;
10
+ mimeType: string;
11
+ size?: string;
12
+ modifiedTime?: string;
13
+ createdTime?: string;
14
+ parents?: string[];
15
+ md5Checksum?: string;
16
+ }
17
+ export interface DriveFileList {
18
+ nextPageToken?: string;
19
+ files: DriveFileMetadata[];
20
+ }
21
+ export interface DriveAbout {
22
+ storageQuota: {
23
+ limit?: string;
24
+ usage: string;
25
+ usageInDrive: string;
26
+ };
27
+ }
28
+ export declare const FOLDER_MIME = "application/vnd.google-apps.folder";
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export const FOLDER_MIME = "application/vnd.google-apps.folder";
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "just-bash-gdrive",
3
+ "version": "0.1.0",
4
+ "description": "Google Drive filesystem adapter for just-bash",
5
+ "type": "module",
6
+ "license": "Apache-2.0",
7
+ "main": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "import": "./dist/index.js",
12
+ "types": "./dist/index.d.ts"
13
+ }
14
+ },
15
+ "scripts": {
16
+ "build": "tsc",
17
+ "typecheck": "tsc --noEmit",
18
+ "test": "vitest run"
19
+ },
20
+ "peerDependencies": {
21
+ "just-bash": ">=2.0.0"
22
+ },
23
+ "devDependencies": {
24
+ "just-bash": "^2.14.0",
25
+ "typescript": "^5.9.3",
26
+ "vitest": "^3.0.0"
27
+ }
28
+ }