Skip to content

Commit 5258f45

Browse files
phernandezclaude
andauthored
feat: Add WebDAV upload command for cloud projects (#356)
Signed-off-by: phernandez <[email protected]> Signed-off-by: Pablo Hernandez <[email protected]> Co-authored-by: Claude <[email protected]>
1 parent e773c00 commit 5258f45

File tree

11 files changed

+1050
-68
lines changed

11 files changed

+1050
-68
lines changed

docs/cloud-cli.md

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ The Basic Memory Cloud CLI provides seamless integration between local and cloud
77
The cloud CLI enables you to:
88
- **Toggle cloud mode** with `bm cloud login` / `bm cloud logout`
99
- **Use regular commands in cloud mode**: `bm project`, `bm sync`, `bm tool` all work with cloud
10+
- **Upload local files** directly to cloud projects via `bm cloud upload`
1011
- **Bidirectional sync** with rclone bisync (recommended for most users)
1112
- **Direct file access** via rclone mount (alternative workflow)
1213
- **Integrity verification** with `bm cloud check`
@@ -160,6 +161,69 @@ bm project list
160161

161162
This Dropbox-like workflow means you don't need to manually coordinate projects between local and cloud.
162163

164+
### Uploading Local Files
165+
166+
You can directly upload local files or directories to cloud projects using `bm cloud upload`. This is useful for:
167+
- Migrating existing local projects to the cloud
168+
- Quickly uploading specific files or directories
169+
- One-time bulk uploads without setting up sync
170+
171+
**Basic Usage:**
172+
173+
```bash
174+
# Upload a directory to existing project
175+
bm cloud upload ~/my-notes --project research
176+
177+
# Upload a single file
178+
bm cloud upload important-doc.md --project research
179+
```
180+
181+
**Create Project On-the-Fly:**
182+
183+
If the target project doesn't exist yet, use `--create-project`:
184+
185+
```bash
186+
# Upload and create project in one step
187+
bm cloud upload ~/local-project --project new-research --create-project
188+
```
189+
190+
**Skip Automatic Sync:**
191+
192+
By default, the command syncs the project after upload to index the files. To skip this:
193+
194+
```bash
195+
# Upload without triggering sync
196+
bm cloud upload ~/bulk-data --project archives --no-sync
197+
```
198+
199+
**File Filtering:**
200+
201+
The upload command respects `.bmignore` and `.gitignore` patterns, automatically excluding:
202+
- Hidden files (`.git`, `.DS_Store`)
203+
- Build artifacts (`node_modules`, `__pycache__`)
204+
- Database files (`*.db`, `*.db-wal`)
205+
- Environment files (`.env`)
206+
207+
To customize what gets uploaded, edit `~/.basic-memory/.bmignore`.
208+
209+
**Complete Example:**
210+
211+
```bash
212+
# 1. Login to cloud
213+
bm cloud login
214+
215+
# 2. Upload local project (creates project if needed)
216+
bm cloud upload ~/Documents/research-notes --project research --create-project
217+
218+
# 3. Verify upload
219+
bm project list
220+
```
221+
222+
**Notes:**
223+
- Files are uploaded directly via WebDAV (no sync setup required)
224+
- Uploads are immediate and don't require bisync or mount
225+
- Use this for migration or one-time uploads; use `bm sync` for ongoing synchronization
226+
163227
## File Synchronization
164228

165229
### The `bm sync` Command (Cloud Mode Aware)
@@ -628,6 +692,15 @@ bm cloud check # Full integrity check
628692
bm cloud check --one-way # Faster one-way check
629693
```
630694

695+
### File Upload
696+
697+
```bash
698+
# Upload files/directories to cloud projects
699+
bm cloud upload <path> --project <name> # Upload to existing project
700+
bm cloud upload <path> -p <name> --create-project # Upload and create project
701+
bm cloud upload <path> -p <name> --no-sync # Upload without syncing
702+
```
703+
631704
### Direct File Access (Mount)
632705

633706
```bash

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ dependencies = [
3434
"pyjwt>=2.10.1",
3535
"python-dotenv>=1.1.0",
3636
"pytest-aio>=1.9.0",
37-
"aiofiles>=24.1.0", # Async file I/O
37+
"aiofiles>=24.1.0", # Async file I/O
3838
]
3939

4040

src/basic_memory/cli/commands/cloud/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,5 @@
22

33
# Import all commands to register them with typer
44
from basic_memory.cli.commands.cloud.core_commands import * # noqa: F401,F403
5-
from basic_memory.cli.commands.cloud.api_client import get_authenticated_headers # noqa: F401
5+
from basic_memory.cli.commands.cloud.api_client import get_authenticated_headers, get_cloud_config # noqa: F401
6+
from basic_memory.cli.commands.cloud.upload_command import * # noqa: F401,F403

src/basic_memory/cli/commands/cloud/bisync_commands.py

Lines changed: 4 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@
1212
from rich.table import Table
1313

1414
from basic_memory.cli.commands.cloud.api_client import CloudAPIError, make_api_request
15+
from basic_memory.cli.commands.cloud.cloud_utils import (
16+
create_cloud_project,
17+
fetch_cloud_projects,
18+
)
1519
from basic_memory.cli.commands.cloud.rclone_config import (
1620
add_tenant_to_rclone_config,
1721
)
@@ -21,11 +25,7 @@
2125
from basic_memory.schemas.cloud import (
2226
TenantMountInfo,
2327
MountCredentials,
24-
CloudProjectList,
25-
CloudProjectCreateRequest,
26-
CloudProjectCreateResponse,
2728
)
28-
from basic_memory.utils import generate_permalink
2929

3030
console = Console()
3131

@@ -110,24 +110,6 @@ async def generate_mount_credentials(tenant_id: str) -> MountCredentials:
110110
raise BisyncError(f"Failed to generate credentials: {e}") from e
111111

112112

113-
async def fetch_cloud_projects() -> CloudProjectList:
114-
"""Fetch list of projects from cloud API.
115-
116-
Returns:
117-
CloudProjectList with projects from cloud
118-
"""
119-
try:
120-
config_manager = ConfigManager()
121-
config = config_manager.config
122-
host_url = config.cloud_host.rstrip("/")
123-
124-
response = await make_api_request(method="GET", url=f"{host_url}/proxy/projects/projects")
125-
126-
return CloudProjectList.model_validate(response.json())
127-
except Exception as e:
128-
raise BisyncError(f"Failed to fetch cloud projects: {e}") from e
129-
130-
131113
def scan_local_directories(sync_dir: Path) -> list[str]:
132114
"""Scan local sync directory for project folders.
133115
@@ -148,41 +130,6 @@ def scan_local_directories(sync_dir: Path) -> list[str]:
148130
return directories
149131

150132

151-
async def create_cloud_project(project_name: str) -> CloudProjectCreateResponse:
152-
"""Create a new project on cloud.
153-
154-
Args:
155-
project_name: Name of project to create
156-
157-
Returns:
158-
CloudProjectCreateResponse with project details from API
159-
"""
160-
try:
161-
config_manager = ConfigManager()
162-
config = config_manager.config
163-
host_url = config.cloud_host.rstrip("/")
164-
165-
# Use generate_permalink to ensure consistent naming
166-
project_path = generate_permalink(project_name)
167-
168-
project_data = CloudProjectCreateRequest(
169-
name=project_name,
170-
path=project_path,
171-
set_default=False,
172-
)
173-
174-
response = await make_api_request(
175-
method="POST",
176-
url=f"{host_url}/proxy/projects/projects",
177-
headers={"Content-Type": "application/json"},
178-
json_data=project_data.model_dump(),
179-
)
180-
181-
return CloudProjectCreateResponse.model_validate(response.json())
182-
except Exception as e:
183-
raise BisyncError(f"Failed to create cloud project '{project_name}': {e}") from e
184-
185-
186133
def get_bisync_state_path(tenant_id: str) -> Path:
187134
"""Get path to bisync state directory."""
188135
return Path.home() / ".basic-memory" / "bisync-state" / tenant_id
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
"""Shared utilities for cloud operations."""
2+
3+
from basic_memory.cli.commands.cloud.api_client import make_api_request
4+
from basic_memory.config import ConfigManager
5+
from basic_memory.schemas.cloud import (
6+
CloudProjectList,
7+
CloudProjectCreateRequest,
8+
CloudProjectCreateResponse,
9+
)
10+
from basic_memory.utils import generate_permalink
11+
12+
13+
class CloudUtilsError(Exception):
14+
"""Exception raised for cloud utility errors."""
15+
16+
pass
17+
18+
19+
async def fetch_cloud_projects() -> CloudProjectList:
20+
"""Fetch list of projects from cloud API.
21+
22+
Returns:
23+
CloudProjectList with projects from cloud
24+
"""
25+
try:
26+
config_manager = ConfigManager()
27+
config = config_manager.config
28+
host_url = config.cloud_host.rstrip("/")
29+
30+
response = await make_api_request(method="GET", url=f"{host_url}/proxy/projects/projects")
31+
32+
return CloudProjectList.model_validate(response.json())
33+
except Exception as e:
34+
raise CloudUtilsError(f"Failed to fetch cloud projects: {e}") from e
35+
36+
37+
async def create_cloud_project(project_name: str) -> CloudProjectCreateResponse:
38+
"""Create a new project on cloud.
39+
40+
Args:
41+
project_name: Name of project to create
42+
43+
Returns:
44+
CloudProjectCreateResponse with project details from API
45+
"""
46+
try:
47+
config_manager = ConfigManager()
48+
config = config_manager.config
49+
host_url = config.cloud_host.rstrip("/")
50+
51+
# Use generate_permalink to ensure consistent naming
52+
project_path = generate_permalink(project_name)
53+
54+
project_data = CloudProjectCreateRequest(
55+
name=project_name,
56+
path=project_path,
57+
set_default=False,
58+
)
59+
60+
response = await make_api_request(
61+
method="POST",
62+
url=f"{host_url}/proxy/projects/projects",
63+
headers={"Content-Type": "application/json"},
64+
json_data=project_data.model_dump(),
65+
)
66+
67+
return CloudProjectCreateResponse.model_validate(response.json())
68+
except Exception as e:
69+
raise CloudUtilsError(f"Failed to create cloud project '{project_name}': {e}") from e
70+
71+
72+
async def sync_project(project_name: str) -> None:
73+
"""Trigger sync for a specific project on cloud.
74+
75+
Args:
76+
project_name: Name of project to sync
77+
"""
78+
try:
79+
from basic_memory.cli.commands.command_utils import run_sync
80+
81+
await run_sync(project=project_name)
82+
except Exception as e:
83+
raise CloudUtilsError(f"Failed to sync project '{project_name}': {e}") from e
84+
85+
86+
async def project_exists(project_name: str) -> bool:
87+
"""Check if a project exists on cloud.
88+
89+
Args:
90+
project_name: Name of project to check
91+
92+
Returns:
93+
True if project exists, False otherwise
94+
"""
95+
try:
96+
projects = await fetch_cloud_projects()
97+
project_names = {p.name for p in projects.projects}
98+
return project_name in project_names
99+
except Exception:
100+
return False

0 commit comments

Comments
 (0)