just-bash-dropbox 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.
package/AGENTS.md ADDED
@@ -0,0 +1,84 @@
1
+ # AGENTS.md - just-bash-dropbox
2
+
3
+ Instructions for AI agents using just-bash-dropbox in projects.
4
+
5
+ ## What is just-bash-dropbox?
6
+
7
+ A Dropbox filesystem adapter for [just-bash](https://github.com/vercel-labs/just-bash). It implements the `IFileSystem` interface so you can access Dropbox files through bash commands — `ls`, `cat`, `grep`, `awk`, and everything else just-bash supports.
8
+
9
+ ## Quick Reference
10
+
11
+ ```typescript
12
+ import { Bash } from "just-bash";
13
+ import { DropboxFs } from "just-bash-dropbox";
14
+
15
+ const fs = new DropboxFs({ accessToken: process.env.DROPBOX_TOKEN });
16
+ const bash = new Bash({ fs });
17
+
18
+ const result = await bash.exec("cat /Documents/report.csv | head -5");
19
+ // result.stdout - file content
20
+ // result.stderr - error output
21
+ // result.exitCode - 0 = success
22
+ ```
23
+
24
+ ## Key Behaviors
25
+
26
+ 1. **Every operation is an API call**: `readdir`, `stat`, `readFile` each hit the Dropbox API. There is no local cache. For repeated reads, wrap with `OverlayFs`.
27
+
28
+ 2. **Paths are case-insensitive**: `/README.md` and `/readme.md` are the same file. This matches Dropbox behavior.
29
+
30
+ 3. **Write operations are real**: `echo "x" > /file` uploads to Dropbox. Use `MountableFs` for safe, memory-only writes.
31
+
32
+ 4. **Token expiry**: Access tokens expire after ~4 hours. Use `getAccessToken` for long sessions.
33
+
34
+ 5. **Rate limits**: Automatically retried with backoff. Avoid tight loops over large directories.
35
+
36
+ ## Safe Mode
37
+
38
+ **Recommended for untrusted code.** Mount Dropbox at a path, writes go to in-memory base:
39
+
40
+ ```typescript
41
+ import { Bash, MountableFs } from "just-bash";
42
+ import { DropboxFs } from "just-bash-dropbox";
43
+
44
+ const dropbox = new DropboxFs({ accessToken: "..." });
45
+ const mfs = new MountableFs();
46
+ mfs.mount("/dropbox", dropbox);
47
+ const bash = new Bash({ fs: mfs });
48
+ // reads /dropbox/* from Dropbox, writes anywhere else go to memory
49
+ ```
50
+
51
+ ## Scoping
52
+
53
+ Restrict access to a subfolder:
54
+
55
+ ```typescript
56
+ const fs = new DropboxFs({
57
+ accessToken: "...",
58
+ rootPath: "/work/project-x",
59
+ });
60
+ // Agent sees "/" but can only access /work/project-x and below
61
+ ```
62
+
63
+ ## Unsupported Operations
64
+
65
+ These throw `ENOSYS` — Dropbox doesn't support them:
66
+
67
+ - `chmod`, `ln`, `ln -s`, `readlink`, `realpath`
68
+
69
+ ## Error Codes
70
+
71
+ | Code | Meaning |
72
+ |------|---------|
73
+ | `ENOENT` | File/directory not found |
74
+ | `EEXIST` | Already exists |
75
+ | `EISDIR` | Is a directory (expected file) |
76
+ | `ENOTDIR` | Not a directory (expected directory) |
77
+ | `ENOSPC` | Dropbox quota exceeded |
78
+ | `ENOSYS` | Operation not supported |
79
+
80
+ ## Limitations
81
+
82
+ - Single uploads: 150 MB max
83
+ - `>>` (append): downloads, appends, re-uploads — slow for large files
84
+ - `getAllPaths()` returns `[]` — glob works via readdir + stat
package/README.md ADDED
@@ -0,0 +1,188 @@
1
+ # just-bash-dropbox
2
+
3
+ > **Beta** — API may change. Use at your own risk.
4
+
5
+ A Dropbox filesystem adapter for [just-bash](https://github.com/vercel-labs/just-bash). Lets AI agents access Dropbox files through bash commands.
6
+
7
+ ```ts
8
+ import { Bash } from "just-bash";
9
+ import { DropboxFs } from "just-bash-dropbox";
10
+
11
+ const fs = new DropboxFs({ accessToken: process.env.DROPBOX_TOKEN });
12
+ const bash = new Bash({ fs });
13
+
14
+ const { stdout } = await bash.exec("ls /Documents");
15
+ // "report.csv\nphotos\n"
16
+
17
+ await bash.exec("cat /reports/q4-summary.md");
18
+ await bash.exec("grep 'revenue' /reports/*.csv | sort -t, -k2 -rn");
19
+ ```
20
+
21
+ ## Install
22
+
23
+ ```bash
24
+ npm install just-bash-dropbox just-bash
25
+ ```
26
+
27
+ No other dependencies. Uses native `fetch` — Node 18+.
28
+
29
+ ## Authentication
30
+
31
+ `DropboxFs` needs a Dropbox access token. Provide it directly or via a function that returns a fresh token.
32
+
33
+ ```ts
34
+ // Static token — scripts, testing, short-lived sessions
35
+ const fs = new DropboxFs({ accessToken: "sl...." });
36
+
37
+ // Token provider — production, long-running agents
38
+ // Called before each API request. You handle refresh and persistence.
39
+ const fs = new DropboxFs({
40
+ getAccessToken: async () => {
41
+ return await myTokenStore.getFreshToken(userId);
42
+ },
43
+ });
44
+ ```
45
+
46
+ Get a token: create a Dropbox app at [dropbox.com/developers](https://www.dropbox.com/developers) and generate an access token, or implement the [OAuth2 flow](https://www.dropbox.com/developers/documentation/http/documentation#authorization).
47
+
48
+ Access tokens expire after ~4 hours. For agents that run longer, use `getAccessToken` with a refresh token flow (see `examples/token-provider.ts`).
49
+
50
+ ## Scoping to a folder
51
+
52
+ Restrict the filesystem to a Dropbox subfolder. The agent sees `/` but operations are scoped underneath. Use this to limit what an agent can access.
53
+
54
+ ```ts
55
+ const fs = new DropboxFs({
56
+ accessToken: "...",
57
+ rootPath: "/work/project-x",
58
+ });
59
+
60
+ const bash = new Bash({ fs });
61
+ await bash.exec("ls /"); // lists /work/project-x
62
+ await bash.exec("cat /readme.md"); // reads /work/project-x/readme.md
63
+ ```
64
+
65
+ ## Safe mode (read-only mount)
66
+
67
+ Use just-bash's `MountableFs` to mount Dropbox at a path while keeping the rest in memory. Writes go to the in-memory base filesystem, not to Dropbox. **Recommended for untrusted agents.**
68
+
69
+ ```ts
70
+ import { Bash, MountableFs } from "just-bash";
71
+ import { DropboxFs } from "just-bash-dropbox";
72
+
73
+ const dropbox = new DropboxFs({ accessToken: "..." });
74
+ const mfs = new MountableFs();
75
+ mfs.mount("/dropbox", dropbox);
76
+ const bash = new Bash({ fs: mfs });
77
+
78
+ await bash.exec("cat /dropbox/data.csv"); // reads from Dropbox
79
+ await bash.exec("echo 'hello' > /notes.txt"); // writes to in-memory fs
80
+ await bash.exec("cp /dropbox/data.csv /local-copy.csv"); // copies to memory
81
+ ```
82
+
83
+ ## API
84
+
85
+ ### `new DropboxFs(options)`
86
+
87
+ | Option | Type | Default | Description |
88
+ |--------|------|---------|-------------|
89
+ | `accessToken` | `string` | — | Static Dropbox access token |
90
+ | `getAccessToken` | `() => string \| Promise<string>` | — | Returns a fresh token per request |
91
+ | `rootPath` | `string` | `"/"` (root) | Scope all operations to this folder |
92
+
93
+ Provide either `accessToken` or `getAccessToken`, not both.
94
+
95
+ ### Supported operations
96
+
97
+ All standard bash file operations work:
98
+
99
+ ```bash
100
+ ls /path # list directory
101
+ cat /path/file.txt # read file
102
+ echo "content" > /path/file.txt # write file
103
+ cp /src /dest # copy
104
+ mv /src /dest # move
105
+ rm /path/file.txt # delete
106
+ rm -r /path/dir # delete directory
107
+ mkdir -p /path/deep/nested # create nested directories
108
+ ```
109
+
110
+ ### Not supported
111
+
112
+ Dropbox doesn't have these concepts. These operations throw `ENOSYS`:
113
+
114
+ - `chmod` — no file permissions
115
+ - `ln` / `ln -s` — no hard/symbolic links
116
+ - `readlink`, `realpath` — no symlinks to resolve
117
+
118
+ ### Limitations
119
+
120
+ - **File size**: Single uploads are limited to 150 MB
121
+ - **Append**: `>>` works but downloads the entire file, appends, and re-uploads
122
+ - **Case sensitivity**: Dropbox paths are case-insensitive (`/README.md` and `/readme.md` are the same file)
123
+ - **Rate limits**: Handled automatically with retry + backoff; heavy usage may still hit Dropbox's per-user limits
124
+ - **No glob via `getAllPaths`**: Returns `[]` — glob matching works through `readdir` + `stat` instead
125
+
126
+ ## Error handling
127
+
128
+ Errors are mapped to errno-style codes for natural bash output:
129
+
130
+ | Dropbox error | Mapped code | Meaning |
131
+ |---------------|-------------|---------|
132
+ | `path/not_found` | `ENOENT` | File or directory doesn't exist |
133
+ | `path/conflict` | `EEXIST` | Already exists |
134
+ | `path/not_file` | `EISDIR` | Expected file, got directory |
135
+ | `path/not_folder` | `ENOTDIR` | Expected directory, got file |
136
+ | `insufficient_quota` | `ENOSPC` | Dropbox quota exceeded |
137
+
138
+ ```ts
139
+ import { FsError } from "just-bash-dropbox";
140
+
141
+ try {
142
+ await fs.readFile("/missing.txt");
143
+ } catch (err) {
144
+ if (err instanceof FsError && err.code === "ENOENT") {
145
+ // file not found
146
+ }
147
+ }
148
+ ```
149
+
150
+ ## Interactive CLI
151
+
152
+ Chat with an AI that has full access to your Dropbox:
153
+
154
+ ```bash
155
+ DROPBOX_TOKEN=sl.xxx AI_GATEWAY_API_KEY=xxx npx tsx examples/chat-cli.ts
156
+ ```
157
+
158
+ ```
159
+ you: what files do I have?
160
+ $ ls -la /
161
+ welcome.md
162
+
163
+ ai: You have one file — `welcome.md`. Want me to read it?
164
+
165
+ you: create a project plan in /plan.md
166
+ $ echo "# Project Plan" > /plan.md
167
+ $ echo "## Phase 1: Research" >> /plan.md
168
+
169
+ ai: Created /plan.md with a project plan outline.
170
+ ```
171
+
172
+ See [`examples/chat-cli.ts`](./examples/chat-cli.ts) for the implementation — it's ~100 lines using the Vercel AI SDK.
173
+
174
+ ## Examples
175
+
176
+ See the [`examples/`](./examples) directory:
177
+
178
+ - **`chat-cli.ts`** — Interactive CLI chat agent (recommended starting point)
179
+ - **`ai-agent-sdk.ts`** — Single-prompt AI agent with Vercel AI SDK
180
+ - **`safe-mode.ts`** — Read-only mount with `MountableFs`
181
+ - **`token-provider.ts`** — Production auth with auto-refresh
182
+ - **`basic-browsing.ts`** — List and read files
183
+ - **`file-search.ts`** — `grep` and `awk` on Dropbox files
184
+ - **`ai-agent.ts`** — Scripted agent workflow (no LLM)
185
+
186
+ ## License
187
+
188
+ Apache-2.0
@@ -0,0 +1,38 @@
1
+ import type { DropboxFsOptions } from "./types.js";
2
+ export declare class DropboxApiError extends Error {
3
+ readonly status: number;
4
+ readonly errorSummary: string;
5
+ readonly errorBody: unknown;
6
+ constructor(status: number, errorSummary: string, errorBody: unknown);
7
+ }
8
+ export declare class DropboxClient {
9
+ private readonly getToken;
10
+ constructor(options: Pick<DropboxFsOptions, "accessToken" | "getAccessToken">);
11
+ /**
12
+ * RPC-style request: JSON in body, JSON response.
13
+ * Used for list_folder, get_metadata, create_folder, delete, copy, move.
14
+ */
15
+ rpc<T>(endpoint: string, args: Record<string, unknown>): Promise<T>;
16
+ /**
17
+ * Content-upload: args in Dropbox-API-Arg header, file bytes in body.
18
+ * Used for upload.
19
+ */
20
+ contentUpload<T>(endpoint: string, args: Record<string, unknown>, content: Uint8Array): Promise<T>;
21
+ /**
22
+ * Content-download: args in Dropbox-API-Arg header, file bytes in response.
23
+ * Used for download.
24
+ */
25
+ contentDownload(endpoint: string, args: Record<string, unknown>): Promise<{
26
+ metadata: Record<string, unknown>;
27
+ content: Uint8Array;
28
+ }>;
29
+ /**
30
+ * Low-level request with retry on 429.
31
+ * Parses JSON response and throws DropboxApiError on error status.
32
+ */
33
+ private request;
34
+ /**
35
+ * Raw request with retry logic. Returns the Response object.
36
+ */
37
+ private requestRaw;
38
+ }
@@ -0,0 +1,126 @@
1
+ const RPC_HOST = "https://api.dropboxapi.com";
2
+ const CONTENT_HOST = "https://content.dropboxapi.com";
3
+ const MAX_RETRIES = 3;
4
+ export class DropboxApiError extends Error {
5
+ status;
6
+ errorSummary;
7
+ errorBody;
8
+ constructor(status, errorSummary, errorBody) {
9
+ super(errorSummary);
10
+ this.status = status;
11
+ this.errorSummary = errorSummary;
12
+ this.errorBody = errorBody;
13
+ this.name = "DropboxApiError";
14
+ }
15
+ }
16
+ export class DropboxClient {
17
+ getToken;
18
+ constructor(options) {
19
+ if (options.accessToken && options.getAccessToken) {
20
+ throw new Error("Provide either accessToken or getAccessToken, not both");
21
+ }
22
+ if (!options.accessToken && !options.getAccessToken) {
23
+ throw new Error("Provide either accessToken or getAccessToken");
24
+ }
25
+ this.getToken =
26
+ options.getAccessToken ?? (() => options.accessToken);
27
+ }
28
+ /**
29
+ * RPC-style request: JSON in body, JSON response.
30
+ * Used for list_folder, get_metadata, create_folder, delete, copy, move.
31
+ */
32
+ async rpc(endpoint, args) {
33
+ return this.request(async (token) => {
34
+ const response = await fetch(`${RPC_HOST}${endpoint}`, {
35
+ method: "POST",
36
+ headers: {
37
+ Authorization: `Bearer ${token}`,
38
+ "Content-Type": "application/json",
39
+ },
40
+ body: JSON.stringify(args),
41
+ });
42
+ return response;
43
+ });
44
+ }
45
+ /**
46
+ * Content-upload: args in Dropbox-API-Arg header, file bytes in body.
47
+ * Used for upload.
48
+ */
49
+ async contentUpload(endpoint, args, content) {
50
+ return this.request(async (token) => {
51
+ const response = await fetch(`${CONTENT_HOST}${endpoint}`, {
52
+ method: "POST",
53
+ headers: {
54
+ Authorization: `Bearer ${token}`,
55
+ "Content-Type": "application/octet-stream",
56
+ "Dropbox-API-Arg": JSON.stringify(args),
57
+ },
58
+ body: content,
59
+ });
60
+ return response;
61
+ });
62
+ }
63
+ /**
64
+ * Content-download: args in Dropbox-API-Arg header, file bytes in response.
65
+ * Used for download.
66
+ */
67
+ async contentDownload(endpoint, args) {
68
+ const response = await this.requestRaw(async (token) => {
69
+ const resp = await fetch(`${CONTENT_HOST}${endpoint}`, {
70
+ method: "POST",
71
+ headers: {
72
+ Authorization: `Bearer ${token}`,
73
+ "Dropbox-API-Arg": JSON.stringify(args),
74
+ },
75
+ });
76
+ return resp;
77
+ });
78
+ const resultHeader = response.headers.get("Dropbox-API-Result");
79
+ const metadata = resultHeader ? JSON.parse(resultHeader) : {};
80
+ const content = new Uint8Array(await response.arrayBuffer());
81
+ return { metadata, content };
82
+ }
83
+ /**
84
+ * Low-level request with retry on 429.
85
+ * Parses JSON response and throws DropboxApiError on error status.
86
+ */
87
+ async request(doFetch) {
88
+ const response = await this.requestRaw(doFetch);
89
+ return response.json();
90
+ }
91
+ /**
92
+ * Raw request with retry logic. Returns the Response object.
93
+ */
94
+ async requestRaw(doFetch) {
95
+ let lastError;
96
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
97
+ const token = await this.getToken();
98
+ const response = await doFetch(token);
99
+ if (response.ok) {
100
+ return response;
101
+ }
102
+ if (response.status === 429) {
103
+ const parsed = Number(response.headers.get("Retry-After"));
104
+ const retryAfter = Number.isFinite(parsed) ? parsed : 1;
105
+ // Consume body to avoid leak
106
+ await response.text();
107
+ await sleep(retryAfter * 1000);
108
+ lastError = new Error("Rate limited");
109
+ continue;
110
+ }
111
+ // Parse error body for 409 and other errors
112
+ const body = await response.json().catch(() => ({}));
113
+ const summary = typeof body === "object" &&
114
+ body !== null &&
115
+ "error_summary" in body &&
116
+ typeof body.error_summary === "string"
117
+ ? body.error_summary
118
+ : `HTTP ${response.status}`;
119
+ throw new DropboxApiError(response.status, summary, body);
120
+ }
121
+ throw lastError ?? new Error("Request failed after retries");
122
+ }
123
+ }
124
+ function sleep(ms) {
125
+ return new Promise((resolve) => setTimeout(resolve, ms));
126
+ }
@@ -0,0 +1,62 @@
1
+ import type { DropboxFsOptions } from "./types.js";
2
+ interface FsStat {
3
+ isFile: boolean;
4
+ isDirectory: boolean;
5
+ isSymbolicLink: boolean;
6
+ mode: number;
7
+ size: number;
8
+ mtime: Date;
9
+ }
10
+ interface DirentEntry {
11
+ name: string;
12
+ isFile: boolean;
13
+ isDirectory: boolean;
14
+ isSymbolicLink: boolean;
15
+ }
16
+ interface MkdirOptions {
17
+ recursive?: boolean;
18
+ }
19
+ interface RmOptions {
20
+ recursive?: boolean;
21
+ force?: boolean;
22
+ }
23
+ interface CpOptions {
24
+ recursive?: boolean;
25
+ }
26
+ interface ReadFileOptions {
27
+ encoding?: string | null;
28
+ }
29
+ interface WriteFileOptions {
30
+ encoding?: string;
31
+ }
32
+ type FileContent = string | Uint8Array;
33
+ export declare class DropboxFs {
34
+ private readonly client;
35
+ private readonly rootPath;
36
+ constructor(options: DropboxFsOptions);
37
+ readdir(path: string): Promise<string[]>;
38
+ readdirWithFileTypes(path: string): Promise<DirentEntry[]>;
39
+ readFile(path: string, _options?: ReadFileOptions | string): Promise<string>;
40
+ readFileBuffer(path: string): Promise<Uint8Array>;
41
+ stat(path: string): Promise<FsStat>;
42
+ exists(path: string): Promise<boolean>;
43
+ writeFile(path: string, content: FileContent, _options?: WriteFileOptions | string): Promise<void>;
44
+ appendFile(path: string, content: FileContent, _options?: WriteFileOptions | string): Promise<void>;
45
+ mkdir(path: string, options?: MkdirOptions): Promise<void>;
46
+ rm(path: string, options?: RmOptions): Promise<void>;
47
+ cp(src: string, dest: string, _options?: CpOptions): Promise<void>;
48
+ mv(src: string, dest: string): Promise<void>;
49
+ resolvePath(base: string, target: string): string;
50
+ getAllPaths(): string[];
51
+ chmod(_path: string, _mode: number): Promise<void>;
52
+ symlink(_target: string, _linkPath: string): Promise<void>;
53
+ link(_existingPath: string, _newPath: string): Promise<void>;
54
+ readlink(_path: string): Promise<string>;
55
+ lstat(_path: string): Promise<FsStat>;
56
+ realpath(_path: string): Promise<string>;
57
+ utimes(_path: string, _atime: Date, _mtime: Date): Promise<void>;
58
+ private toPath;
59
+ private listFolder;
60
+ private mkdirRecursive;
61
+ }
62
+ export {};
@@ -0,0 +1,259 @@
1
+ import { DropboxApiError, DropboxClient } from "./dropbox-client.js";
2
+ import { enosys, FsError, mapDropboxError } from "./errors.js";
3
+ import { resolvePath as resolvePathUtil, toDropboxPath } from "./paths.js";
4
+ export class DropboxFs {
5
+ client;
6
+ rootPath;
7
+ constructor(options) {
8
+ this.client = new DropboxClient({
9
+ accessToken: options.accessToken,
10
+ getAccessToken: options.getAccessToken,
11
+ });
12
+ this.rootPath = options.rootPath;
13
+ }
14
+ // ─── Read operations ────────────────────────────────
15
+ async readdir(path) {
16
+ const entries = await this.listFolder(path);
17
+ return entries.map((e) => e.name);
18
+ }
19
+ async readdirWithFileTypes(path) {
20
+ const entries = await this.listFolder(path);
21
+ return entries.map((e) => ({
22
+ name: e.name,
23
+ isFile: e[".tag"] === "file",
24
+ isDirectory: e[".tag"] === "folder",
25
+ isSymbolicLink: false,
26
+ }));
27
+ }
28
+ async readFile(path, _options) {
29
+ try {
30
+ const dbxPath = this.toPath(path);
31
+ const result = await this.client.contentDownload("/2/files/download", {
32
+ path: dbxPath,
33
+ });
34
+ return new TextDecoder().decode(result.content);
35
+ }
36
+ catch (err) {
37
+ throw mapDropboxError(err, path);
38
+ }
39
+ }
40
+ async readFileBuffer(path) {
41
+ try {
42
+ const dbxPath = this.toPath(path);
43
+ const result = await this.client.contentDownload("/2/files/download", {
44
+ path: dbxPath,
45
+ });
46
+ return result.content;
47
+ }
48
+ catch (err) {
49
+ throw mapDropboxError(err, path);
50
+ }
51
+ }
52
+ async stat(path) {
53
+ const dbxPath = this.toPath(path);
54
+ // Dropbox API does not support get_metadata on root folder
55
+ if (dbxPath === "" || dbxPath === "/") {
56
+ return {
57
+ isFile: false,
58
+ isDirectory: true,
59
+ isSymbolicLink: false,
60
+ mode: 0o755,
61
+ size: 0,
62
+ mtime: new Date(0),
63
+ };
64
+ }
65
+ try {
66
+ const metadata = await this.client.rpc("/2/files/get_metadata", { path: dbxPath });
67
+ return metadataToStat(metadata);
68
+ }
69
+ catch (err) {
70
+ throw mapDropboxError(err, path);
71
+ }
72
+ }
73
+ async exists(path) {
74
+ try {
75
+ await this.stat(path);
76
+ return true;
77
+ }
78
+ catch (err) {
79
+ if (err instanceof Error && "code" in err && err.code === "ENOENT") {
80
+ return false;
81
+ }
82
+ throw err;
83
+ }
84
+ }
85
+ // ─── Write operations ───────────────────────────────
86
+ async writeFile(path, content, _options) {
87
+ const dbxPath = this.toPath(path);
88
+ const bytes = typeof content === "string" ? new TextEncoder().encode(content) : content;
89
+ try {
90
+ await this.client.contentUpload("/2/files/upload", { path: dbxPath, mode: "overwrite", autorename: false, mute: true }, bytes);
91
+ }
92
+ catch (err) {
93
+ throw mapDropboxError(err, path);
94
+ }
95
+ }
96
+ async appendFile(path, content, _options) {
97
+ let existing;
98
+ try {
99
+ existing = await this.readFileBuffer(path);
100
+ }
101
+ catch (err) {
102
+ if (err instanceof Error && "code" in err && err.code === "ENOENT") {
103
+ // File doesn't exist — create it
104
+ await this.writeFile(path, content);
105
+ return;
106
+ }
107
+ throw err;
108
+ }
109
+ const appendBytes = typeof content === "string" ? new TextEncoder().encode(content) : content;
110
+ const combined = new Uint8Array(existing.length + appendBytes.length);
111
+ combined.set(existing, 0);
112
+ combined.set(appendBytes, existing.length);
113
+ await this.writeFile(path, combined);
114
+ }
115
+ async mkdir(path, options) {
116
+ const dbxPath = this.toPath(path);
117
+ if (options?.recursive) {
118
+ await this.mkdirRecursive(dbxPath, path);
119
+ return;
120
+ }
121
+ try {
122
+ await this.client.rpc("/2/files/create_folder_v2", { path: dbxPath });
123
+ }
124
+ catch (err) {
125
+ throw mapDropboxError(err, path);
126
+ }
127
+ }
128
+ async rm(path, options) {
129
+ const dbxPath = this.toPath(path);
130
+ try {
131
+ await this.client.rpc("/2/files/delete_v2", { path: dbxPath });
132
+ }
133
+ catch (err) {
134
+ if (options?.force &&
135
+ err instanceof DropboxApiError &&
136
+ err.errorSummary.startsWith("path_lookup/not_found")) {
137
+ return;
138
+ }
139
+ throw mapDropboxError(err, path);
140
+ }
141
+ }
142
+ async cp(src, dest, _options) {
143
+ const fromPath = this.toPath(src);
144
+ const toPath = this.toPath(dest);
145
+ try {
146
+ await this.client.rpc("/2/files/copy_v2", {
147
+ from_path: fromPath,
148
+ to_path: toPath,
149
+ });
150
+ }
151
+ catch (err) {
152
+ throw mapDropboxError(err, src);
153
+ }
154
+ }
155
+ async mv(src, dest) {
156
+ const fromPath = this.toPath(src);
157
+ const toPath = this.toPath(dest);
158
+ try {
159
+ await this.client.rpc("/2/files/move_v2", {
160
+ from_path: fromPath,
161
+ to_path: toPath,
162
+ });
163
+ }
164
+ catch (err) {
165
+ throw mapDropboxError(err, src);
166
+ }
167
+ }
168
+ // ─── Path operations ───────────────────────────────
169
+ resolvePath(base, target) {
170
+ return resolvePathUtil(base, target);
171
+ }
172
+ getAllPaths() {
173
+ return [];
174
+ }
175
+ // ─── Unsupported operations ─────────────────────────
176
+ async chmod(_path, _mode) {
177
+ throw enosys("chmod");
178
+ }
179
+ async symlink(_target, _linkPath) {
180
+ throw enosys("symlink");
181
+ }
182
+ async link(_existingPath, _newPath) {
183
+ throw enosys("link");
184
+ }
185
+ async readlink(_path) {
186
+ throw enosys("readlink");
187
+ }
188
+ async lstat(_path) {
189
+ throw enosys("lstat");
190
+ }
191
+ async realpath(_path) {
192
+ throw enosys("realpath");
193
+ }
194
+ async utimes(_path, _atime, _mtime) {
195
+ throw enosys("utimes");
196
+ }
197
+ // ─── Private helpers ───────────────────────────────
198
+ toPath(path) {
199
+ return toDropboxPath(path, this.rootPath);
200
+ }
201
+ async listFolder(path) {
202
+ const dbxPath = this.toPath(path);
203
+ const allEntries = [];
204
+ try {
205
+ let result = await this.client.rpc("/2/files/list_folder", { path: dbxPath, include_deleted: false });
206
+ allEntries.push(...result.entries);
207
+ while (result.has_more) {
208
+ result = await this.client.rpc("/2/files/list_folder/continue", { cursor: result.cursor });
209
+ allEntries.push(...result.entries);
210
+ }
211
+ }
212
+ catch (err) {
213
+ throw mapDropboxError(err, path);
214
+ }
215
+ return allEntries;
216
+ }
217
+ async mkdirRecursive(dbxPath, originalPath) {
218
+ // Build list of paths to create from root to leaf
219
+ const parts = dbxPath.split("/").filter(Boolean);
220
+ for (let i = 1; i <= parts.length; i++) {
221
+ const segment = `/${parts.slice(0, i).join("/")}`;
222
+ try {
223
+ await this.client.rpc("/2/files/create_folder_v2", { path: segment });
224
+ }
225
+ catch (err) {
226
+ // Ignore "already exists" errors during recursive creation
227
+ if (err instanceof DropboxApiError &&
228
+ err.errorSummary.startsWith("path/conflict")) {
229
+ continue;
230
+ }
231
+ throw mapDropboxError(err, originalPath);
232
+ }
233
+ }
234
+ }
235
+ }
236
+ function metadataToStat(metadata) {
237
+ switch (metadata[".tag"]) {
238
+ case "file":
239
+ return {
240
+ isFile: true,
241
+ isDirectory: false,
242
+ isSymbolicLink: false,
243
+ mode: 0o644,
244
+ size: metadata.size,
245
+ mtime: new Date(metadata.server_modified),
246
+ };
247
+ case "folder":
248
+ return {
249
+ isFile: false,
250
+ isDirectory: true,
251
+ isSymbolicLink: false,
252
+ mode: 0o755,
253
+ size: 0,
254
+ mtime: new Date(),
255
+ };
256
+ case "deleted":
257
+ throw new FsError("ENOENT", "no such file or directory (deleted)");
258
+ }
259
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Maps Dropbox API errors to filesystem-style errors.
3
+ * Uses errno-like codes so bash commands produce natural error output.
4
+ */
5
+ export declare class FsError extends Error {
6
+ readonly code: string;
7
+ readonly path?: string | undefined;
8
+ constructor(code: string, message: string, path?: string | undefined);
9
+ }
10
+ export declare function enoent(path: string): FsError;
11
+ export declare function enotdir(path: string): FsError;
12
+ export declare function eisdir(path: string): FsError;
13
+ export declare function eexist(path: string): FsError;
14
+ export declare function enosys(operation: string): FsError;
15
+ export declare function enospc(path: string): FsError;
16
+ /**
17
+ * Maps a DropboxApiError to a filesystem error.
18
+ * Uses error_summary prefix matching as recommended by Dropbox docs.
19
+ */
20
+ export declare function mapDropboxError(err: unknown, path: string): FsError;
package/dist/errors.js ADDED
@@ -0,0 +1,73 @@
1
+ import { DropboxApiError } from "./dropbox-client.js";
2
+ /**
3
+ * Maps Dropbox API errors to filesystem-style errors.
4
+ * Uses errno-like codes so bash commands produce natural error output.
5
+ */
6
+ export class FsError extends Error {
7
+ code;
8
+ path;
9
+ constructor(code, message, path) {
10
+ super(path ? `${code}: ${message}, '${path}'` : `${code}: ${message}`);
11
+ this.code = code;
12
+ this.path = path;
13
+ this.name = "FsError";
14
+ }
15
+ }
16
+ export function enoent(path) {
17
+ return new FsError("ENOENT", "no such file or directory", path);
18
+ }
19
+ export function enotdir(path) {
20
+ return new FsError("ENOTDIR", "not a directory", path);
21
+ }
22
+ export function eisdir(path) {
23
+ return new FsError("EISDIR", "is a directory", path);
24
+ }
25
+ export function eexist(path) {
26
+ return new FsError("EEXIST", "file already exists", path);
27
+ }
28
+ export function enosys(operation) {
29
+ return new FsError("ENOSYS", `${operation} is not supported on Dropbox`);
30
+ }
31
+ export function enospc(path) {
32
+ return new FsError("ENOSPC", "no space left (Dropbox quota exceeded)", path);
33
+ }
34
+ /**
35
+ * Maps a DropboxApiError to a filesystem error.
36
+ * Uses error_summary prefix matching as recommended by Dropbox docs.
37
+ */
38
+ export function mapDropboxError(err, path) {
39
+ if (!(err instanceof DropboxApiError)) {
40
+ throw err;
41
+ }
42
+ const summary = err.errorSummary;
43
+ if (summary.startsWith("path/not_found") ||
44
+ summary.startsWith("path_lookup/not_found")) {
45
+ return enoent(path);
46
+ }
47
+ if (summary.startsWith("path/conflict") ||
48
+ summary.startsWith("to/conflict")) {
49
+ return eexist(path);
50
+ }
51
+ if (summary.startsWith("path/not_file")) {
52
+ return eisdir(path);
53
+ }
54
+ if (summary.startsWith("path/not_folder")) {
55
+ return enotdir(path);
56
+ }
57
+ if (summary.startsWith("path/insufficient_space") ||
58
+ summary.startsWith("insufficient_quota")) {
59
+ return enospc(path);
60
+ }
61
+ if (summary.startsWith("from_lookup/not_found")) {
62
+ return enoent(path);
63
+ }
64
+ // HTTP status-based mapping
65
+ if (err.status === 401) {
66
+ return new FsError("EACCES", "access token expired or invalid — refresh your token", path);
67
+ }
68
+ if (err.status === 403) {
69
+ return new FsError("EACCES", "access denied — check app permissions", path);
70
+ }
71
+ // For unmapped errors, wrap as-is with the path context
72
+ return new FsError("EIO", summary, path);
73
+ }
@@ -0,0 +1,4 @@
1
+ export { DropboxApiError, DropboxClient } from "./dropbox-client.js";
2
+ export { DropboxFs } from "./dropbox-fs.js";
3
+ export { FsError } from "./errors.js";
4
+ export type { DropboxFileMetadata, DropboxFolderMetadata, DropboxFsOptions, DropboxMetadata, } from "./types.js";
package/dist/index.js ADDED
@@ -0,0 +1,3 @@
1
+ export { DropboxApiError, DropboxClient } from "./dropbox-client.js";
2
+ export { DropboxFs } from "./dropbox-fs.js";
3
+ export { FsError } from "./errors.js";
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Path normalization for Dropbox API.
3
+ *
4
+ * Dropbox quirks:
5
+ * - Root is "" (empty string), not "/"
6
+ * - All other paths start with "/"
7
+ * - Paths are case-insensitive but case-preserving
8
+ */
9
+ /**
10
+ * Normalize a path for Dropbox API consumption.
11
+ * Removes trailing slashes, collapses doubles, handles root.
12
+ */
13
+ export declare function toDropboxPath(path: string, rootPath?: string): string;
14
+ /**
15
+ * Resolve a relative path against a base path.
16
+ * Pure function — no Dropbox API calls.
17
+ */
18
+ export declare function resolvePath(base: string, target: string): string;
package/dist/paths.js ADDED
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Path normalization for Dropbox API.
3
+ *
4
+ * Dropbox quirks:
5
+ * - Root is "" (empty string), not "/"
6
+ * - All other paths start with "/"
7
+ * - Paths are case-insensitive but case-preserving
8
+ */
9
+ /**
10
+ * Normalize a path for Dropbox API consumption.
11
+ * Removes trailing slashes, collapses doubles, handles root.
12
+ */
13
+ export function toDropboxPath(path, rootPath) {
14
+ // Normalize separators and collapse doubles
15
+ let normalized = path.replace(/\/+/g, "/");
16
+ // Remove trailing slash (unless root)
17
+ if (normalized.length > 1 && normalized.endsWith("/")) {
18
+ normalized = normalized.slice(0, -1);
19
+ }
20
+ // Apply rootPath prefix
21
+ if (rootPath) {
22
+ const cleanRoot = rootPath.replace(/\/+$/, "");
23
+ if (normalized === "/") {
24
+ normalized = cleanRoot;
25
+ }
26
+ else {
27
+ normalized = `${cleanRoot}${normalized}`;
28
+ }
29
+ }
30
+ // Dropbox root is "" not "/"
31
+ if (normalized === "/") {
32
+ return "";
33
+ }
34
+ // Ensure path starts with /
35
+ if (!normalized.startsWith("/")) {
36
+ normalized = `/${normalized}`;
37
+ }
38
+ return normalized;
39
+ }
40
+ /**
41
+ * Resolve a relative path against a base path.
42
+ * Pure function — no Dropbox API calls.
43
+ */
44
+ export function resolvePath(base, target) {
45
+ // Absolute path — ignore base
46
+ if (target.startsWith("/")) {
47
+ return normalizePath(target);
48
+ }
49
+ // Relative path — join with base
50
+ const combined = base.endsWith("/")
51
+ ? `${base}${target}`
52
+ : `${base}/${target}`;
53
+ return normalizePath(combined);
54
+ }
55
+ /**
56
+ * Normalize a path: resolve . and .., collapse separators.
57
+ */
58
+ function normalizePath(path) {
59
+ const parts = path.split("/");
60
+ const resolved = [];
61
+ for (const part of parts) {
62
+ if (part === "" || part === ".") {
63
+ continue;
64
+ }
65
+ if (part === "..") {
66
+ resolved.pop();
67
+ }
68
+ else {
69
+ resolved.push(part);
70
+ }
71
+ }
72
+ return `/${resolved.join("/")}`;
73
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Options for creating a DropboxFs instance.
3
+ */
4
+ export interface DropboxFsOptions {
5
+ /** Static Dropbox access token. */
6
+ accessToken?: string;
7
+ /** Function that returns a fresh access token. Called before each API request. */
8
+ getAccessToken?: () => string | Promise<string>;
9
+ /** Scope all operations to this Dropbox folder (default: root). */
10
+ rootPath?: string;
11
+ }
12
+ export interface DropboxFileMetadata {
13
+ ".tag": "file";
14
+ name: string;
15
+ id: string;
16
+ path_lower: string;
17
+ path_display: string;
18
+ rev: string;
19
+ size: number;
20
+ client_modified: string;
21
+ server_modified: string;
22
+ content_hash?: string;
23
+ is_downloadable?: boolean;
24
+ }
25
+ export interface DropboxFolderMetadata {
26
+ ".tag": "folder";
27
+ name: string;
28
+ id: string;
29
+ path_lower: string;
30
+ path_display: string;
31
+ }
32
+ export interface DropboxDeletedMetadata {
33
+ ".tag": "deleted";
34
+ name: string;
35
+ path_lower: string;
36
+ path_display: string;
37
+ }
38
+ export type DropboxMetadata = DropboxFileMetadata | DropboxFolderMetadata | DropboxDeletedMetadata;
39
+ export interface DropboxListFolderResult {
40
+ entries: DropboxMetadata[];
41
+ cursor: string;
42
+ has_more: boolean;
43
+ }
44
+ export interface DropboxErrorResponse {
45
+ error_summary: string;
46
+ error: {
47
+ ".tag": string;
48
+ [key: string]: unknown;
49
+ };
50
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,62 @@
1
+ {
2
+ "name": "just-bash-dropbox",
3
+ "version": "0.1.0",
4
+ "description": "Dropbox filesystem adapter for just-bash",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/manishrc/just-bash-dropbox.git"
8
+ },
9
+ "type": "module",
10
+ "main": "dist/index.js",
11
+ "types": "dist/index.d.ts",
12
+ "exports": {
13
+ ".": {
14
+ "types": "./dist/index.d.ts",
15
+ "import": "./dist/index.js"
16
+ }
17
+ },
18
+ "files": [
19
+ "dist/",
20
+ "README.md",
21
+ "AGENTS.md"
22
+ ],
23
+ "publishConfig": {
24
+ "access": "public"
25
+ },
26
+ "engines": {
27
+ "node": ">=18.0.0"
28
+ },
29
+ "scripts": {
30
+ "build": "rm -rf dist && tsc",
31
+ "typecheck": "tsc --noEmit",
32
+ "test": "vitest",
33
+ "test:run": "vitest run",
34
+ "lint": "biome check .",
35
+ "lint:fix": "biome check --write .",
36
+ "validate": "biome check . && tsc --noEmit && vitest run && rm -rf dist && tsc",
37
+ "prepublishOnly": "npm run validate"
38
+ },
39
+ "keywords": [
40
+ "dropbox",
41
+ "bash",
42
+ "filesystem",
43
+ "ai-agent",
44
+ "just-bash"
45
+ ],
46
+ "author": "",
47
+ "license": "Apache-2.0",
48
+ "devDependencies": {
49
+ "@biomejs/biome": "^2.3.10",
50
+ "@types/node": "^22.0.0",
51
+ "typescript": "^5.9.3",
52
+ "vitest": "^4.0.16"
53
+ },
54
+ "peerDependencies": {
55
+ "just-bash": ">=2.0.0"
56
+ },
57
+ "peerDependenciesMeta": {
58
+ "just-bash": {
59
+ "optional": true
60
+ }
61
+ }
62
+ }