just-bash-dropbox 0.1.0 → 0.2.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/README.md CHANGED
@@ -107,21 +107,37 @@ rm -r /path/dir # delete directory
107
107
  mkdir -p /path/deep/nested # create nested directories
108
108
  ```
109
109
 
110
+ ### Semantic notes
111
+
112
+ - `lstat` delegates to `stat` (Dropbox has no symlinks)
113
+ - `realpath` verifies the path exists and returns Dropbox's canonical casing
114
+ - `rm /dir` (without `-r`) throws `EISDIR` — matches POSIX behavior
115
+ - `>>` (append) uses optimistic locking via Dropbox's `rev` field to detect concurrent writes
116
+
110
117
  ### Not supported
111
118
 
112
- Dropbox doesn't have these concepts. These operations throw `ENOSYS`:
119
+ These operations throw `ENOSYS`:
113
120
 
114
121
  - `chmod` — no file permissions
115
122
  - `ln` / `ln -s` — no hard/symbolic links
116
- - `readlink`, `realpath` — no symlinks to resolve
123
+ - `readlink` — no symlinks
124
+ - `utimes` — Dropbox manages timestamps
125
+
126
+ ### Glob support
127
+
128
+ `getAllPaths()` returns `[]` by default (sync method can't make API calls). For glob support, call `prefetchAllPaths()` first:
129
+
130
+ ```ts
131
+ await fs.prefetchAllPaths(); // recursive listing, cached
132
+ fs.getAllPaths(); // returns all paths
133
+ ```
117
134
 
118
135
  ### Limitations
119
136
 
120
137
  - **File size**: Single uploads are limited to 150 MB
121
- - **Append**: `>>` works but downloads the entire file, appends, and re-uploads
138
+ - **Append**: `>>` downloads the file, appends, re-uploads with rev-based conflict detection
122
139
  - **Case sensitivity**: Dropbox paths are case-insensitive (`/README.md` and `/readme.md` are the same file)
123
140
  - **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
141
 
126
142
  ## Error handling
127
143
 
@@ -134,6 +150,9 @@ Errors are mapped to errno-style codes for natural bash output:
134
150
  | `path/not_file` | `EISDIR` | Expected file, got directory |
135
151
  | `path/not_folder` | `ENOTDIR` | Expected directory, got file |
136
152
  | `insufficient_quota` | `ENOSPC` | Dropbox quota exceeded |
153
+ | HTTP 401 | `EACCES` | Token expired or invalid |
154
+ | HTTP 403 | `EACCES` | Insufficient app permissions |
155
+ | `rm /dir` (no `-r`) | `EISDIR` | Use `rm -r` to delete directories |
137
156
 
138
157
  ```ts
139
158
  import { FsError } from "just-bash-dropbox";
@@ -1,38 +1,21 @@
1
+ import type { CpOptions, FileContent, FsStat, MkdirOptions, RmOptions } from "just-bash";
1
2
  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
3
  interface DirentEntry {
11
4
  name: string;
12
5
  isFile: boolean;
13
6
  isDirectory: boolean;
14
7
  isSymbolicLink: boolean;
15
8
  }
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
9
  interface ReadFileOptions {
27
10
  encoding?: string | null;
28
11
  }
29
12
  interface WriteFileOptions {
30
13
  encoding?: string;
31
14
  }
32
- type FileContent = string | Uint8Array;
33
15
  export declare class DropboxFs {
34
16
  private readonly client;
35
17
  private readonly rootPath;
18
+ private cachedPaths;
36
19
  constructor(options: DropboxFsOptions);
37
20
  readdir(path: string): Promise<string[]>;
38
21
  readdirWithFileTypes(path: string): Promise<DirentEntry[]>;
@@ -48,12 +31,18 @@ export declare class DropboxFs {
48
31
  mv(src: string, dest: string): Promise<void>;
49
32
  resolvePath(base: string, target: string): string;
50
33
  getAllPaths(): string[];
34
+ /**
35
+ * Prefetch all file and folder paths via recursive list_folder.
36
+ * Call this before operations that need getAllPaths() (like glob).
37
+ * Results are cached until the next call to prefetchAllPaths().
38
+ */
39
+ prefetchAllPaths(): Promise<string[]>;
51
40
  chmod(_path: string, _mode: number): Promise<void>;
52
41
  symlink(_target: string, _linkPath: string): Promise<void>;
53
42
  link(_existingPath: string, _newPath: string): Promise<void>;
54
43
  readlink(_path: string): Promise<string>;
55
- lstat(_path: string): Promise<FsStat>;
56
- realpath(_path: string): Promise<string>;
44
+ lstat(path: string): Promise<FsStat>;
45
+ realpath(path: string): Promise<string>;
57
46
  utimes(_path: string, _atime: Date, _mtime: Date): Promise<void>;
58
47
  private toPath;
59
48
  private listFolder;
@@ -1,9 +1,10 @@
1
1
  import { DropboxApiError, DropboxClient } from "./dropbox-client.js";
2
- import { enosys, FsError, mapDropboxError } from "./errors.js";
2
+ import { eisdir, enosys, FsError, mapDropboxError } from "./errors.js";
3
3
  import { resolvePath as resolvePathUtil, toDropboxPath } from "./paths.js";
4
4
  export class DropboxFs {
5
5
  client;
6
6
  rootPath;
7
+ cachedPaths = [];
7
8
  constructor(options) {
8
9
  this.client = new DropboxClient({
9
10
  accessToken: options.accessToken,
@@ -94,23 +95,40 @@ export class DropboxFs {
94
95
  }
95
96
  }
96
97
  async appendFile(path, content, _options) {
98
+ const dbxPath = this.toPath(path);
99
+ // Download existing file with metadata (to get rev for optimistic locking)
97
100
  let existing;
101
+ let rev;
98
102
  try {
99
- existing = await this.readFileBuffer(path);
103
+ const result = await this.client.contentDownload("/2/files/download", {
104
+ path: dbxPath,
105
+ });
106
+ existing = result.content;
107
+ rev = result.metadata.rev;
100
108
  }
101
109
  catch (err) {
102
- if (err instanceof Error && "code" in err && err.code === "ENOENT") {
110
+ if (err instanceof DropboxApiError &&
111
+ err.errorSummary.startsWith("path/not_found")) {
103
112
  // File doesn't exist — create it
104
113
  await this.writeFile(path, content);
105
114
  return;
106
115
  }
107
- throw err;
116
+ throw mapDropboxError(err, path);
108
117
  }
109
118
  const appendBytes = typeof content === "string" ? new TextEncoder().encode(content) : content;
110
119
  const combined = new Uint8Array(existing.length + appendBytes.length);
111
120
  combined.set(existing, 0);
112
121
  combined.set(appendBytes, existing.length);
113
- await this.writeFile(path, combined);
122
+ // Upload with rev-based optimistic locking to detect concurrent writes
123
+ const mode = rev
124
+ ? { ".tag": "update", update: rev }
125
+ : "overwrite";
126
+ try {
127
+ await this.client.contentUpload("/2/files/upload", { path: dbxPath, mode, autorename: false, mute: true }, combined);
128
+ }
129
+ catch (err) {
130
+ throw mapDropboxError(err, path);
131
+ }
114
132
  }
115
133
  async mkdir(path, options) {
116
134
  const dbxPath = this.toPath(path);
@@ -127,6 +145,20 @@ export class DropboxFs {
127
145
  }
128
146
  async rm(path, options) {
129
147
  const dbxPath = this.toPath(path);
148
+ // POSIX: rm without -r on a directory should fail
149
+ if (!options?.recursive) {
150
+ try {
151
+ const s = await this.stat(path);
152
+ if (s.isDirectory) {
153
+ throw eisdir(path);
154
+ }
155
+ }
156
+ catch (err) {
157
+ if (err instanceof FsError && err.code === "EISDIR")
158
+ throw err;
159
+ // If stat fails (e.g. ENOENT), let delete_v2 handle it below
160
+ }
161
+ }
130
162
  try {
131
163
  await this.client.rpc("/2/files/delete_v2", { path: dbxPath });
132
164
  }
@@ -170,7 +202,33 @@ export class DropboxFs {
170
202
  return resolvePathUtil(base, target);
171
203
  }
172
204
  getAllPaths() {
173
- return [];
205
+ return this.cachedPaths;
206
+ }
207
+ /**
208
+ * Prefetch all file and folder paths via recursive list_folder.
209
+ * Call this before operations that need getAllPaths() (like glob).
210
+ * Results are cached until the next call to prefetchAllPaths().
211
+ */
212
+ async prefetchAllPaths() {
213
+ const dbxPath = this.toPath("/");
214
+ const allPaths = [];
215
+ try {
216
+ let result = await this.client.rpc("/2/files/list_folder", { path: dbxPath, recursive: true, include_deleted: false });
217
+ for (const entry of result.entries) {
218
+ allPaths.push(entry.path_display);
219
+ }
220
+ while (result.has_more) {
221
+ result = await this.client.rpc("/2/files/list_folder/continue", { cursor: result.cursor });
222
+ for (const entry of result.entries) {
223
+ allPaths.push(entry.path_display);
224
+ }
225
+ }
226
+ }
227
+ catch (err) {
228
+ throw mapDropboxError(err, "/");
229
+ }
230
+ this.cachedPaths = allPaths;
231
+ return allPaths;
174
232
  }
175
233
  // ─── Unsupported operations ─────────────────────────
176
234
  async chmod(_path, _mode) {
@@ -185,11 +243,24 @@ export class DropboxFs {
185
243
  async readlink(_path) {
186
244
  throw enosys("readlink");
187
245
  }
188
- async lstat(_path) {
189
- throw enosys("lstat");
246
+ async lstat(path) {
247
+ // Dropbox has no symlinks — lstat is identical to stat
248
+ return this.stat(path);
190
249
  }
191
- async realpath(_path) {
192
- throw enosys("realpath");
250
+ async realpath(path) {
251
+ const dbxPath = this.toPath(path);
252
+ // Root has no metadata endpoint
253
+ if (dbxPath === "" || dbxPath === "/") {
254
+ return "/";
255
+ }
256
+ try {
257
+ const metadata = await this.client.rpc("/2/files/get_metadata", { path: dbxPath });
258
+ // Return canonical casing from Dropbox
259
+ return metadata.path_display;
260
+ }
261
+ catch (err) {
262
+ throw mapDropboxError(err, path);
263
+ }
193
264
  }
194
265
  async utimes(_path, _atime, _mtime) {
195
266
  throw enosys("utimes");
@@ -251,7 +322,7 @@ function metadataToStat(metadata) {
251
322
  isSymbolicLink: false,
252
323
  mode: 0o755,
253
324
  size: 0,
254
- mtime: new Date(),
325
+ mtime: new Date(0),
255
326
  };
256
327
  case "deleted":
257
328
  throw new FsError("ENOENT", "no such file or directory (deleted)");
package/dist/index.d.ts CHANGED
@@ -1,4 +1,3 @@
1
- export { DropboxApiError, DropboxClient } from "./dropbox-client.js";
2
1
  export { DropboxFs } from "./dropbox-fs.js";
3
2
  export { FsError } from "./errors.js";
4
- export type { DropboxFileMetadata, DropboxFolderMetadata, DropboxFsOptions, DropboxMetadata, } from "./types.js";
3
+ export type { DropboxFsOptions } from "./types.js";
package/dist/index.js CHANGED
@@ -1,3 +1,2 @@
1
- export { DropboxApiError, DropboxClient } from "./dropbox-client.js";
2
1
  export { DropboxFs } from "./dropbox-fs.js";
3
2
  export { FsError } from "./errors.js";
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,4 @@
1
+ // Fails to compile if DropboxFs drifts from IFileSystem
2
+ const _check = {};
3
+ void _check;
4
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "just-bash-dropbox",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Dropbox filesystem adapter for just-bash",
5
5
  "repository": {
6
6
  "type": "git",
@@ -49,6 +49,7 @@
49
49
  "@biomejs/biome": "^2.3.10",
50
50
  "@types/node": "^22.0.0",
51
51
  "typescript": "^5.9.3",
52
+ "just-bash": "^2.14.0",
52
53
  "vitest": "^4.0.16"
53
54
  },
54
55
  "peerDependencies": {