proton-drive-mcp 1.0.35 → 1.0.37

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,60 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.0.37 — 2026-09-20
4
+
5
+ A parallel review of the whole project (security, live testing of every tool group, reliability, token cost, CI/supply chain, test-suite gaps — modelled on the Proton Mail Bridge MCP review rounds), then every finding fixed. Every behaviour change was checked live against a real account (39 end-to-end checks on scratch data) besides the automated tests. 1.0.36 was folded into this release.
6
+
7
+ **Behaviour changes to be aware of:** destructive and outward-facing tools now require `confirmed: true`; long lists return `{total, offset, limit, hasMore, items}` instead of a bare array; responses are compact JSON; tool arguments are validated against the published schema (unknown/mistyped arguments are rejected).
8
+
9
+ ### Critical
10
+ - **`npx proton-drive-mcp` never served MCP.** The entry-point check compared `import.meta.url` with `process.argv[1]`, which under `npx`, a global install or `node_modules/.bin` is the bin *symlink* — so the published server started, printed nothing and exited 0. Now compares real paths. CI gained a pack-and-install smoke test that launches the real bin symlink (the previous tests imported `dist/` directly and could not see this).
11
+ - **A failing call leaked its full command line to the client**, including the `--password` of `drive_share_set_url` and the binary path (`e.message` from Node's exec). Errors are now built from the CLI's own output; secrets are never echoed. The same change surfaces the real cause when the CLI writes it to stdout behind an `=====` banner (e.g. a missing local file), truncates huge output, tolerates ANSI noise before the JSON, and recognises more "session expired" wording.
12
+ - **`drive_rename` accepted `.` and `..`**, after which `drive_list` returned a path that resolves to the *parent* folder — a follow-up `drive_trash` on it would have trashed the wrong node. Rejected now; `list()` also builds paths by concatenation instead of `posix.join`.
13
+
14
+ ### Security
15
+ - `drive_download` with `remove` (deletes the existing local file/folder), `drive_upload` with `replace` (trashes the remote item), `photos_download` with `remove`, and overwriting a sync-folder file via `drive_write_file` now need `confirmed: true` (CLI: `--confirm`).
16
+ - Also gated: `drive_auth_logout`, `drive_share_invite`, `drive_share_revoke`, `drive_share_set_url` (public links), `drive_share_remove_url`, `drive_share_leave`, `drive_invitation_reject`, `photos_remove_from_album`.
17
+ - `drive_read_file` / `drive_write_file` no longer follow symlinks out of `PROTON_DRIVE_SYNC_PATH` (containment by realpath, `O_NOFOLLOW`), read size is checked before loading, writes are capped at 5 MB.
18
+ - Local paths given to upload/download/photos tools are checked: credential locations (`~/.ssh`, `~/.aws`, `~/.gnupg`, `~/.claude*`, keychains, `.env`, keys, …) are refused (`PROTON_DRIVE_LOCAL_ROOT` allowlist, `PROTON_DRIVE_ALLOW_SENSITIVE_PATHS=1` to opt out).
19
+ - Arguments are validated against the tool schema (unknown keys, wrong types, enums, integer bounds); validators reject non-strings (an array used to coerce into a path).
20
+
21
+ ### Fixed (data correctness)
22
+ - **`drive_share_set_url` silently turned a password-protected link into an open one** when re-run without a password (same URL). It now says so in a `warning`; `drive_share_status` reports `sharePasswordProtected`, `shareUrlExpiresAt` and `editorsCanShare`.
23
+ - **`photos_create_album` created duplicate names**, after which `update`/`delete` acted on an arbitrary one of them. Duplicates are refused, and album operations refuse an ambiguous `/albums/<name>`; `photos_list_albums` returns `uid`.
24
+ - **`drive_share_revoke` reported success for a non-member**, and for a wrong-case address the real member kept access (the CLI matches case-sensitively and exits 0 either way). Now verified against the current members, matched case-insensitively, canonical address sent. `drive_share_remove_all` on an unshared item reports "Nothing to remove".
25
+ - **`drive_list "/"` returned ten `[unnamed]` entries**; it now lists the real roots.
26
+ - **Names with a trailing space were unusable** (paths were trimmed: `list` returned `…/tsp ` but `info` then said "Node not found"; `mkdir "trail "` created `trail`). Paths, names and passwords are no longer trimmed; trailing `/` is stripped from remote paths.
27
+ - **`drive_move` across folders with a rename** could fail with a bogus collision or leave the item half-moved. It now checks the destination first, renames-first when the source's own name is taken there, rolls back on failure and reports partial state explicitly; a `X → X` no-op on a missing source now fails.
28
+ - `mkdir`/`move` split paths on the last *unescaped* slash (items created by other clients can contain `\/`).
29
+ - `{ok:false}` results are also checked for `share_leave`, invitation accept/reject and album add/remove-photo; the "name collision" hint is only added for real collisions (move-into-itself etc. get their own message).
30
+ - `drive_list_trash` returns `uid` (and trash time when the CLI reports it) — names are not unique in trash, so two items could share one path.
31
+
32
+ ### Reliability
33
+ - Cancelling a request (`notifications/cancelled`) now kills the running CLI process; SIGTERM/SIGINT, a closed stdout (EPIPE) and client disconnect kill running CLI children instead of orphaning them; a timeout kills the whole process group (grandchildren too); stdin closing mid-call no longer leaves the server lingering for up to 30 minutes.
34
+ - The startup CLI probe runs after the connection is up, so a slow `proton-drive version` can no longer delay `initialize` (hosts time out at ~5 s).
35
+
36
+ ### Token cost
37
+ - `drive_list`, `drive_list_trash`, `photos_list_timeline`, `photos_list_album_photos` take `limit`/`offset` and return `{total, offset, limit, hasMore, items}` (defaults 200 / 100 / 50 / 100). Measured on a real account before the change: the trash list alone was ~54k tokens, the photo timeline ~15.6k.
38
+ - Compact JSON (−14–16 % bytes on large lists); `drive_info` unwraps the `{ok,value}` wrappers and drops duplicate fields (`verbose: true` for the raw node).
39
+
40
+ ### Added
41
+ - `drive_copy`: `newName` (CLI `--name`) — copy under a new name or duplicate inside the same folder. CLI: `copy --name`, `info --verbose`, `album photos --load-details`.
42
+ - `photos_list_album_photos`: `loadDetails`.
43
+
44
+ ### CI / supply chain / packaging
45
+ - Actions pinned to commit SHAs, least-privilege `permissions`, concurrency group, `npm audit --audit-level=high` gate, pack-and-install smoke test, Dependabot for npm and Actions (majors of `typescript` / `@types/node` ignored).
46
+ - Publish: `id-token: write`, `npm publish --provenance`, `npm-publish` environment, tag must be reachable from `main`.
47
+ - Lockfile refreshed (6 advisories → 0), SDK range raised to `^1.29.0`.
48
+ - Dockerfile: multi-stage, non-root, production deps only, `.dockerignore`; `SECURITY.md`; `.code-review-graph/` ignored.
49
+
50
+ ### Tests and docs
51
+ - ~230 unit tests plus real-process tests that spawn the server/CLI against a fake `PROTON_DRIVE_BIN` over stdio (gates, schema enforcement, error sanitization, lifecycle, symlink launch, tool/README/glama/smithery parity) and an opt-in read-only live smoke test. The old integration test probed a nonexistent `auth status` command and never ran in CI; it is removed.
52
+ - Tool descriptions corrected where they contradicted behaviour (`photos_create_album`, `share_revoke`, `share_set_url`, `photos_delete_album`, `drive_read_file` / `drive_write_file` "requires authentication", timeline shapes). README no longer claims `--skip-thumbnails` (removed in 1.0.34) or full CLI parity; SPEC.md rewritten (it described 11 tools and a layout that never existed).
53
+
54
+ ### Investigated, not bugs
55
+ - CLI role `inherited` is rejected by the CLI itself despite its help text — the three roles offered are correct.
56
+ - Stdout hygiene, concurrent calls, the 60 s / 50 MB limits, a missing CLI and unicode/emoji/backslash names all behaved correctly.
57
+
3
58
  ## 1.0.35 — 2026-09-03
4
59
 
5
60
  Continued the live-testing bug sweep. This round found a serious silent-data-corruption bug in `drive_move`, confirmed by a full live reproduction against the real account, plus the same root cause in `drive_copy`. The same fix hardens three more structurally identical code paths (`trash`, `restore`, `delete`) against the same failure mode.
package/Dockerfile CHANGED
@@ -1,7 +1,19 @@
1
- FROM node:24-slim
1
+ # syntax=docker/dockerfile:1
2
+ FROM node:24-slim AS build
2
3
  WORKDIR /app
3
- COPY package*.json ./
4
+ COPY package.json package-lock.json tsconfig.json ./
4
5
  RUN npm ci --ignore-scripts
5
- COPY . .
6
+ COPY src ./src
6
7
  RUN npm run build
8
+
9
+ FROM node:24-slim
10
+ ENV NODE_ENV=production
11
+ WORKDIR /app
12
+ COPY package.json package-lock.json ./
13
+ RUN npm ci --omit=dev --ignore-scripts && npm cache clean --force
14
+ COPY --from=build /app/dist ./dist
15
+ # NOTE: this image does NOT include the official `proton-drive` CLI, which the
16
+ # server shells out to. Provide it separately: bind-mount the binary and set
17
+ # PROTON_DRIVE_BIN, or extend this image with a layer that installs it.
18
+ USER node
7
19
  CMD ["node", "dist/index.js"]
package/README.md CHANGED
@@ -222,7 +222,7 @@ proton-drive-cli upload ./dist /my-files/Releases/$(date +%Y-%m-%d) --file-confl
222
222
  proton-drive-cli download /my-files/Contracts ./audit/contracts
223
223
 
224
224
  # Nightly backup via cron
225
- 0 2 * * * proton-drive-cli upload ~/Documents /my-files/Backups/$(date +%Y-%m-%d) --conflict skip
225
+ 0 2 * * * proton-drive-cli upload ~/Documents /my-files/Backups/$(date +%Y-%m-%d) --file-conflict skip --folder-conflict skip
226
226
 
227
227
  # Check who has access before a team change
228
228
  proton-drive-cli share status /my-files/Projects
@@ -263,45 +263,45 @@ proton-drive-cli share status /my-files/Projects
263
263
  | Tool | Description | Key parameters |
264
264
  |------|-------------|----------------|
265
265
  | `drive_auth_status` | Check if authenticated (probes `/my-files` — no native status command) | — |
266
- | `drive_auth_logout` | Log out (clear session) | — |
266
+ | `drive_auth_logout` | Log out (clear session) ⚠️ | `confirmed: true` |
267
267
  | `drive_version` | CLI and SDK version info | — |
268
- | `drive_list` | List files and folders at a path | `path` |
269
- | `drive_info` | Get full metadata for one file/folder, including revision details | `path` |
268
+ | `drive_list` | List files and folders at a path (paginated, default 200; `/` lists the roots) | `path`, `limit?`, `offset?` |
269
+ | `drive_info` | Get metadata for one file/folder, including revision details (noise trimmed) | `path`, `verbose?` (raw CLI node) |
270
270
  | `drive_mkdir` | Create a new empty folder | `path` |
271
- | `drive_upload` | Upload local file or folder | `localPath`, `remotePath`, `fileConflictStrategy?` (skip/create-new-revision/rename/replace), `folderConflictStrategy?` (skip/merge/rename/replace) |
272
- | `drive_download` | Download to local path | `remotePath`, `localPath`, `fileConflictStrategy?` (skip/rename/remove), `folderConflictStrategy?` (skip/merge/rename/remove) |
271
+ | `drive_upload` | Upload local file or folder | `localPath`, `remotePath`, `fileConflictStrategy?` (skip/create-new-revision/rename/replace), `folderConflictStrategy?` (skip/merge/rename/replace), `confirmed?` (**required for `replace`** — it trashes the existing remote item) |
272
+ | `drive_download` | Download to local path | `remotePath`, `localPath`, `fileConflictStrategy?` (skip/rename/remove), `folderConflictStrategy?` (skip/merge/rename/remove), `confirmed?` (**required for `remove`** — it deletes the existing local item) |
273
273
  | `drive_rename` | Rename in place, no move | `path`, `newName` |
274
274
  | `drive_move` | Move and/or rename | `sourcePath`, `destinationPath` |
275
- | `drive_copy` | Copy file or folder to another Drive location | `sourcePath`, `destinationPath` |
275
+ | `drive_copy` | Copy file or folder into another Drive folder | `sourcePath`, `destinationPath` (target *parent folder*), `newName?` |
276
276
  | `drive_delete` | Permanently delete an item already in trash ⚠️ | `path`, `confirmed: true` |
277
- | `drive_list_trash` | List items currently in trash | — |
277
+ | `drive_list_trash` | List items currently in trash (paginated, default 100; includes `uid` — names are not unique in trash) | `limit?`, `offset?` |
278
278
  | `drive_share_status` | Get sharing members and URL | `path` |
279
- | `drive_share_invite` | Invite a user | `path`, `email`, `role` (viewer/editor/admin), `message?` |
280
- | `drive_share_revoke` | Revoke one person's access | `path`, `email` |
279
+ | `drive_share_invite` | Invite a user (sends an email) ⚠️ | `path`, `email`, `role` (viewer/editor/admin), `message?`, `confirmed: true` |
280
+ | `drive_share_revoke` | Revoke one person's access (fails if not a member) ⚠️ | `path`, `email`, `confirmed: true` |
281
281
  | `drive_share_remove_all` | Remove every member + pending invitation at once ⚠️ | `path`, `confirmed: true` |
282
- | `drive_share_set_url` | Create/update a public share link | `path`, `role?` (viewer/editor), `password?`, `expiration?` |
283
- | `drive_share_remove_url` | Remove the public share link ⚠️ | `path` |
282
+ | `drive_share_set_url` | Create/replace a public share link ⚠️ (re-running without `password`/`expiration` removes them; expiry max ~90 days) | `path`, `role?` (viewer/editor), `password?`, `expiration?`, `confirmed: true` |
283
+ | `drive_share_remove_url` | Remove the public share link ⚠️ | `path`, `confirmed: true` |
284
284
  | `drive_trash` | Move to trash | `path` |
285
285
  | `drive_restore` | Restore from trash | `path` |
286
286
  | `drive_empty_trash` | Permanently delete all trash ⚠️ | `confirmed: true` |
287
287
  | `drive_read_file` | Read text file from local sync folder | `path` |
288
- | `drive_write_file` | Write text file to local sync folder ⚠️ | `path`, `content` |
288
+ | `drive_write_file` | Write text file to local sync folder ⚠️ (overwriting an existing file needs confirmation; max 5 MB) | `path`, `content`, `confirmed?` |
289
289
  | `drive_list_invitations` | List pending sharing invitations received | — |
290
290
  | `drive_invitation_accept` | Accept a pending invitation | `uid` (from `drive_list_invitations`) |
291
- | `drive_invitation_reject` | Reject a pending invitation ⚠️ | `uid` (from `drive_list_invitations`) |
292
- | `drive_share_leave` | Leave a shared folder shared with you ⚠️ | `path` |
291
+ | `drive_invitation_reject` | Reject a pending invitation ⚠️ | `uid` (from `drive_list_invitations`), `confirmed: true` |
292
+ | `drive_share_leave` | Leave a shared folder shared with you ⚠️ | `path`, `confirmed: true` |
293
293
  | `photos_list_albums` | List all Proton Photos albums | — |
294
294
  | `photos_create_album` | Create a new empty album | `name` |
295
295
  | `photos_update_album` | Rename an album or change its cover photo | `albumPath`, `name?`, `coverPhotoUid?` |
296
296
  | `photos_delete_album` | Delete an album ⚠️ | `albumPath`, `confirmed: true`, `force?`, `save?` |
297
- | `photos_list_album_photos` | List photos in an album | `albumPath` |
297
+ | `photos_list_album_photos` | List photos in an album (paginated, default 100) | `albumPath`, `loadDetails?`, `limit?`, `offset?` |
298
298
  | `photos_add_to_album` | Add a photo from your library to an album | `albumPath`, `photoPath` |
299
- | `photos_remove_from_album` | Remove a photo from an album (keeps it in library) ⚠️ | `albumPath`, `photoPath` |
300
- | `photos_list_timeline` | List photos in your full library timeline | `loadDetails?` |
301
- | `photos_download` | Download photos to a local folder | `photoPaths`, `localFolder`, `conflictStrategy?` (skip/rename/remove) |
299
+ | `photos_remove_from_album` | Remove a photo from an album (keeps it in library) ⚠️ | `albumPath`, `photoPath`, `confirmed: true` |
300
+ | `photos_list_timeline` | List photos in your full library timeline (paginated, default 50) | `loadDetails?`, `limit?`, `offset?` |
301
+ | `photos_download` | Download photos to a local folder | `photoPaths`, `localFolder`, `conflictStrategy?` (skip/rename/remove), `confirmed?` (**required for `remove`**) |
302
302
  | `photos_upload` | Upload local files directly into your Photos library | `localPaths`, `conflictStrategy?` (skip/rename) |
303
303
 
304
- > ⚠️ **Destructive tools** require `confirmed: true`. Use `drive_list_trash` first so you know what will be deleted, then pass `confirmed: true` to proceed.
304
+ > ⚠️ **Destructive and outward-facing tools** (deleting, sharing, public links, logout, and the `replace`/`remove` conflict strategies) require `confirmed: true`, and the CLI requires `--confirm` for the destructive ones. Describe the action to the user first, then pass `confirmed: true`. Tool arguments are validated against the published schema — unknown or mistyped arguments are rejected.
305
305
 
306
306
  ---
307
307
 
@@ -313,14 +313,17 @@ proton-drive-cli share status /my-files/Projects
313
313
  | Credential exposure | API keys in config | Zero — OS keychain only |
314
314
  | Sharing & invitations | Rarely | Full (invite, revoke, status) |
315
315
  | Trash & restore | Rarely | Full |
316
- | CLI parity | No | Full CLI mirrors all MCP tools |
316
+ | CLI parity | No | The CLI mirrors every MCP tool except `drive_read_file` / `drive_write_file` |
317
317
  | Shell injection safe | Varies | Yes — `execFile` only |
318
318
 
319
319
  ---
320
320
 
321
321
  ## Operational notes
322
322
 
323
- - `drive_upload` passes `--skip-thumbnails` by default. Remove it from the subprocess args if you want WebP preview generation (requires Bun 1.3.14+ installed).
323
+ - Long listings are paginated (`limit` / `offset`, response `{total, offset, limit, hasMore, items}`) to keep responses small. Responses are compact JSON.
324
+ - Local paths passed to upload/download/photos tools are checked: credential locations (`~/.ssh`, `~/.aws`, `~/.gnupg`, `~/.claude*`, keychains, `.env` files, …) are refused. Set `PROTON_DRIVE_LOCAL_ROOT` to allow only specific directories.
325
+ - The sync-folder tools (`drive_read_file` / `drive_write_file`) refuse to follow symlinks out of `PROTON_DRIVE_SYNC_PATH`.
326
+ - Error messages come from the CLI's own output; the command line (and therefore any `--password`) is never echoed back.
324
327
  - `drive_move` accepts a full destination path (parent + new name) for a familiar interface, but the underlying CLI only has separate `move` (change parent) and `rename` (change name) commands — this MCP translates automatically, issuing one or both as needed.
325
328
  - `drive_delete` only works on items already in `/trash` or `/photos-trash` — the CLI rejects live paths. Trash an item first with `drive_trash`, or use `drive_empty_trash` to clear everything at once.
326
329
  - `drive_auth_status` has no native CLI equivalent — it probes by resolving `/my-files` and reports authenticated based on whether that succeeds.
@@ -335,6 +338,8 @@ proton-drive-cli share status /my-files/Projects
335
338
  |---|---|---|
336
339
  | `PROTON_DRIVE_SYNC_PATH` | Optional | Absolute path to your local Proton Drive sync folder root (e.g. `/Users/you/Proton Drive`). Required only for `drive_read_file` and `drive_write_file`. The Proton Drive desktop app must be running to sync written files to the cloud. |
337
340
  | `PROTON_DRIVE_BIN` | Optional | Override the `proton-drive` binary name or path (default: `proton-drive`). Useful for non-standard installations. |
341
+ | `PROTON_DRIVE_LOCAL_ROOT` | Optional | Path-delimiter-separated list of local directories that upload/download/photos tools may touch. Unset = any path except the built-in credential denylist. |
342
+ | `PROTON_DRIVE_ALLOW_SENSITIVE_PATHS` | Optional | Set to `1` to disable the built-in credential-location denylist (not recommended). |
338
343
 
339
344
  ---
340
345
 
package/dist/cli.js CHANGED
@@ -22,12 +22,14 @@ Commands:
22
22
  auth logout Log out
23
23
  version Show CLI/SDK version
24
24
  list <path> List files at path
25
- info <path> Show full node metadata for one path
25
+ info <path> [--verbose] Show node metadata (--verbose = raw CLI node)
26
26
  mkdir <path> Create a new folder
27
- upload <local> <remote> [--file-conflict X] [--folder-conflict X]
28
- Upload file/folder (files: skip/create-new-revision/rename/replace; folders: skip/merge/rename/replace)
29
- download <remote> <local> [--file-conflict X] [--folder-conflict X]
30
- Download file/folder (files: skip/rename/remove; folders: skip/merge/rename/remove)
27
+ upload <local> <remote> [--file-conflict X] [--folder-conflict X] [--confirm]
28
+ Upload file/folder (files: skip/create-new-revision/rename/replace; folders: skip/merge/rename/replace;
29
+ 'replace' trashes the existing remote item and needs --confirm)
30
+ download <remote> <local> [--file-conflict X] [--folder-conflict X] [--confirm]
31
+ Download file/folder (files: skip/rename/remove; folders: skip/merge/rename/remove;
32
+ 'remove' deletes the existing LOCAL item and needs --confirm)
31
33
  rename <path> <new-name> Rename in place, no move
32
34
  move <src> <dst> Move and/or rename
33
35
  delete <path> --confirm Delete a file/folder already in trash, permanently
@@ -38,7 +40,7 @@ Commands:
38
40
  share set-url <path> [--role] [--password] [--expiration] Create/update public link
39
41
  share remove-url <path> Remove public link
40
42
  share leave <path> Leave a folder shared with you
41
- copy <src> <dst> Copy file/folder
43
+ copy <src> <dst-parent-folder> [--name N] Copy file/folder into a folder (optionally under a new name)
42
44
  trash <path> Move to trash
43
45
  trash list List trash contents
44
46
  trash empty --confirm Permanently delete all trash
@@ -50,11 +52,11 @@ Commands:
50
52
  album create <name> Create a new album
51
53
  album update <path> [--name] [--cover-photo-uid] Rename/update album cover
52
54
  album delete <path> --confirm [--force] [--save] Delete an album
53
- album photos <path> List photos in an album
55
+ album photos <path> [--load-details] List photos in an album
54
56
  album add-photo <album> <photo> Add a photo to an album
55
57
  album remove-photo <album> <photo> Remove a photo from an album
56
58
  photo timeline [--load-details] List photos in your timeline
57
- photo download <photo>... <local> [--conflict X] Download photos (skip/rename/remove)
59
+ photo download <photo>... <local> [--conflict X] [--confirm] Download photos (skip/rename/remove; 'remove' needs --confirm)
58
60
  photo upload <local>... [--conflict X] Upload photos to your library (skip/rename)
59
61
 
60
62
  Flags:
@@ -142,7 +144,7 @@ async function run() {
142
144
  print(await drive.list(requirePath(sub, "list <path>")));
143
145
  break;
144
146
  case "info":
145
- print(await drive.info(requirePath(sub, "info <path>")));
147
+ print(await drive.info(requirePath(sub, "info <path> [--verbose]"), args.includes("--verbose")));
146
148
  break;
147
149
  case "upload": {
148
150
  const rawLocal = sub;
@@ -170,6 +172,10 @@ async function run() {
170
172
  console.error(`Invalid --folder-conflict value: ${folderConflictRaw}. Must be skip, merge, rename, or replace.`);
171
173
  process.exit(1);
172
174
  }
175
+ if ((fileConflictRaw === "replace" || folderConflictRaw === "replace") && !args.includes("--confirm")) {
176
+ console.error("Strategy 'replace' trashes the existing remote item.\nPass --confirm to proceed.");
177
+ process.exit(1);
178
+ }
173
179
  print(await drive.upload(local, remote, fileConflictRaw, folderConflictRaw));
174
180
  break;
175
181
  }
@@ -200,6 +206,10 @@ async function run() {
200
206
  console.error(`Invalid --folder-conflict value: ${folderConflictRaw}. Must be skip, merge, rename, or remove.`);
201
207
  process.exit(1);
202
208
  }
209
+ if ((fileConflictRaw === "remove" || folderConflictRaw === "remove") && !args.includes("--confirm")) {
210
+ console.error("Strategy 'remove' deletes the existing LOCAL file or folder before downloading.\nPass --confirm to proceed.");
211
+ process.exit(1);
212
+ }
203
213
  print(await drive.download(remote, local, fileConflictRaw, folderConflictRaw));
204
214
  break;
205
215
  }
@@ -278,8 +288,10 @@ async function run() {
278
288
  console.error(`This removes everyone's access to: ${removeAllPath}\nPass --confirm to proceed.`);
279
289
  process.exit(1);
280
290
  }
281
- await drive.shareRemove(removeAllPath, [], true);
282
- console.log(`Removed all access to: ${removeAllPath}`);
291
+ const removedCount = await drive.shareRemoveAll(removeAllPath);
292
+ console.log(removedCount === 0
293
+ ? `Nothing to remove: ${removeAllPath} has no members or pending invitations.`
294
+ : `Removed all access (${removedCount} member/invitation(s)) to: ${removeAllPath}`);
283
295
  }
284
296
  else if (sub === "set-url") {
285
297
  const setUrlPath = requirePath(rest[0], "share set-url <path> [--role] [--password] [--expiration]");
@@ -337,8 +349,18 @@ async function run() {
337
349
  case "copy": {
338
350
  const copySrc = requirePath(sub, "copy <src> <dst>");
339
351
  const copyDst = requirePath(rest[0], "copy <src> <dst>");
340
- await drive.copy(copySrc, copyDst);
341
- console.log(`Copied: ${copySrc} → ${copyDst}`);
352
+ const rawCopyName = getFlag("--name");
353
+ let copyName;
354
+ try {
355
+ copyName = rawCopyName !== undefined ? validateName(rawCopyName) : undefined;
356
+ }
357
+ catch (e) {
358
+ console.error(e instanceof Error ? e.message : String(e));
359
+ process.exit(1);
360
+ return;
361
+ }
362
+ await drive.copy(copySrc, copyDst, copyName);
363
+ console.log(`Copied: ${copySrc} → ${copyDst}${copyName ? ` as '${copyName}'` : ""}`);
342
364
  break;
343
365
  }
344
366
  case "invitation":
@@ -443,7 +465,7 @@ async function run() {
443
465
  }
444
466
  else if (sub === "photos") {
445
467
  const albumPath = requirePath(rest[0], "album photos <path>");
446
- print(await drive.listAlbumPhotos(albumPath));
468
+ print(await drive.listAlbumPhotos(albumPath, args.includes("--load-details")));
447
469
  }
448
470
  else if (sub === "add-photo") {
449
471
  const albumPath = requirePath(rest[0], "album add-photo <album-path> <photo-path>");
@@ -465,9 +487,9 @@ async function run() {
465
487
  case "photo": {
466
488
  // Strip --conflict <value> out of a variadic positional list.
467
489
  const conflictFlagIdx = rest.indexOf("--conflict");
468
- const positionals = conflictFlagIdx === -1
490
+ const positionals = (conflictFlagIdx === -1
469
491
  ? rest
470
- : [...rest.slice(0, conflictFlagIdx), ...rest.slice(conflictFlagIdx + 2)];
492
+ : [...rest.slice(0, conflictFlagIdx), ...rest.slice(conflictFlagIdx + 2)]).filter((x) => x !== "--confirm" && x !== "--load-details");
471
493
  const conflictRaw = getFlag("--conflict") ?? "skip";
472
494
  if (sub === "timeline") {
473
495
  print(await drive.photoTimeline(args.includes("--load-details")));
@@ -493,6 +515,10 @@ async function run() {
493
515
  console.error(`Invalid --conflict value: ${conflictRaw}. Must be skip, rename, or remove.`);
494
516
  process.exit(1);
495
517
  }
518
+ if (conflictRaw === "remove" && !args.includes("--confirm")) {
519
+ console.error("Strategy 'remove' deletes the existing LOCAL file before downloading.\nPass --confirm to proceed.");
520
+ process.exit(1);
521
+ }
496
522
  print(await drive.photoDownload(photoPaths, localFolderValidated, conflictRaw));
497
523
  }
498
524
  else if (sub === "upload") {