Skip to content

Commit 6361574

Browse files
authored
fix: basic memory home env var not respected when project path is changed. (#239)
Signed-off-by: Joe P <joe@basicmemory.com>
1 parent 24a1d61 commit 6361574

10 files changed

Lines changed: 551 additions & 30 deletions

File tree

src/basic_memory/api/routers/project_router.py

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Router for project management."""
22

3+
import os
34
from fastapi import APIRouter, HTTPException, Path, Body
45
from typing import Optional
56

@@ -32,40 +33,47 @@ async def get_project_info(
3233
@project_router.patch("/{name}", response_model=ProjectStatusResponse)
3334
async def update_project(
3435
project_service: ProjectServiceDep,
35-
project_name: str = Path(..., description="Name of the project to update"),
36-
path: Optional[str] = Body(None, description="New path for the project"),
36+
name: str = Path(..., description="Name of the project to update"),
37+
path: Optional[str] = Body(None, description="New absolute path for the project"),
3738
is_active: Optional[bool] = Body(None, description="Status of the project (active/inactive)"),
3839
) -> ProjectStatusResponse:
3940
"""Update a project's information in configuration and database.
4041
4142
Args:
42-
project_name: The name of the project to update
43-
path: Optional new path for the project
43+
name: The name of the project to update
44+
path: Optional new absolute path for the project
4445
is_active: Optional status update for the project
4546
4647
Returns:
4748
Response confirming the project was updated
4849
"""
49-
try: # pragma: no cover
50+
try:
51+
# Validate that path is absolute if provided
52+
if path and not os.path.isabs(path):
53+
raise HTTPException(status_code=400, detail="Path must be absolute")
54+
5055
# Get original project info for the response
5156
old_project_info = ProjectItem(
52-
name=project_name,
53-
path=project_service.projects.get(project_name, ""),
57+
name=name,
58+
path=project_service.projects.get(name, ""),
5459
)
5560

56-
await project_service.update_project(project_name, updated_path=path, is_active=is_active)
61+
if path:
62+
await project_service.move_project(name, path)
63+
elif is_active is not None:
64+
await project_service.update_project(name, is_active=is_active)
5765

5866
# Get updated project info
59-
updated_path = path if path else project_service.projects.get(project_name, "")
67+
updated_path = path if path else project_service.projects.get(name, "")
6068

6169
return ProjectStatusResponse(
62-
message=f"Project '{project_name}' updated successfully",
70+
message=f"Project '{name}' updated successfully",
6371
status="success",
64-
default=(project_name == project_service.default_project),
72+
default=(name == project_service.default_project),
6573
old_project=old_project_info,
66-
new_project=ProjectItem(name=project_name, path=updated_path),
74+
new_project=ProjectItem(name=name, path=updated_path),
6775
)
68-
except ValueError as e: # pragma: no cover
76+
except ValueError as e:
6977
raise HTTPException(status_code=400, detail=str(e))
7078

7179

src/basic_memory/cli/commands/project.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
from basic_memory.schemas.project_info import ProjectStatusResponse
2424
from basic_memory.mcp.tools.utils import call_delete
2525
from basic_memory.mcp.tools.utils import call_put
26+
from basic_memory.mcp.tools.utils import call_patch
2627
from basic_memory.utils import generate_permalink
2728

2829
console = Console()
@@ -148,6 +149,42 @@ def synchronize_projects() -> None:
148149
raise typer.Exit(1)
149150

150151

152+
@project_app.command("move")
153+
def move_project(
154+
name: str = typer.Argument(..., help="Name of the project to move"),
155+
new_path: str = typer.Argument(..., help="New absolute path for the project"),
156+
) -> None:
157+
"""Move a project to a new location."""
158+
# Resolve to absolute path
159+
resolved_path = os.path.abspath(os.path.expanduser(new_path))
160+
161+
try:
162+
data = {"path": resolved_path}
163+
project_name = generate_permalink(name)
164+
165+
current_project = session.get_current_project()
166+
response = asyncio.run(call_patch(client, f"/{current_project}/project/{project_name}", json=data))
167+
result = ProjectStatusResponse.model_validate(response.json())
168+
169+
console.print(f"[green]{result.message}[/green]")
170+
171+
# Show important file movement reminder
172+
console.print() # Empty line for spacing
173+
console.print(Panel(
174+
"[bold red]IMPORTANT:[/bold red] Project configuration updated successfully.\n\n"
175+
"[yellow]You must manually move your project files from the old location to:[/yellow]\n"
176+
f"[cyan]{resolved_path}[/cyan]\n\n"
177+
"[dim]Basic Memory has only updated the configuration - your files remain in their original location.[/dim]",
178+
title="⚠️ Manual File Movement Required",
179+
border_style="yellow",
180+
expand=False
181+
))
182+
183+
except Exception as e:
184+
console.print(f"[red]Error moving project: {str(e)}[/red]")
185+
raise typer.Exit(1)
186+
187+
151188
@project_app.command("info")
152189
def display_project_info(
153190
json_output: bool = typer.Option(False, "--json", help="Output in JSON format"),

src/basic_memory/config.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -182,10 +182,8 @@ def load_config(self) -> BasicMemoryConfig:
182182
data = json.loads(self.config_file.read_text(encoding="utf-8"))
183183
return BasicMemoryConfig(**data)
184184
except Exception as e: # pragma: no cover
185-
logger.error(f"Failed to load config: {e}")
186-
config = BasicMemoryConfig()
187-
self.save_config(config)
188-
return config
185+
logger.exception(f"Failed to load config: {e}")
186+
raise e
189187
else:
190188
config = BasicMemoryConfig()
191189
self.save_config(config)

src/basic_memory/repository/project_repository.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,3 +83,21 @@ async def set_as_default(self, project_id: int) -> Optional[Project]:
8383
await session.flush()
8484
return target_project
8585
return None # pragma: no cover
86+
87+
async def update_path(self, project_id: int, new_path: str) -> Optional[Project]:
88+
"""Update project path.
89+
90+
Args:
91+
project_id: ID of the project to update
92+
new_path: New filesystem path for the project
93+
94+
Returns:
95+
The updated project if found, None otherwise
96+
"""
97+
async with db.scoped_session(self.session_maker) as session:
98+
project = await self.select_by_id(session, project_id)
99+
if project:
100+
project.path = new_path
101+
await session.flush()
102+
return project
103+
return None

src/basic_memory/services/project_service.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -309,6 +309,47 @@ async def synchronize_projects(self) -> None: # pragma: no cover
309309
# MCP components might not be available in all contexts
310310
logger.debug("MCP session not available, skipping session refresh")
311311

312+
async def move_project(self, name: str, new_path: str) -> None:
313+
"""Move a project to a new location.
314+
315+
Args:
316+
name: The name of the project to move
317+
new_path: The new absolute path for the project
318+
319+
Raises:
320+
ValueError: If the project doesn't exist or repository isn't initialized
321+
"""
322+
if not self.repository:
323+
raise ValueError("Repository is required for move_project")
324+
325+
# Resolve to absolute path
326+
resolved_path = os.path.abspath(os.path.expanduser(new_path))
327+
328+
# Validate project exists in config
329+
if name not in self.config_manager.projects:
330+
raise ValueError(f"Project '{name}' not found in configuration")
331+
332+
# Create the new directory if it doesn't exist
333+
Path(resolved_path).mkdir(parents=True, exist_ok=True)
334+
335+
# Update in configuration
336+
config = self.config_manager.load_config()
337+
old_path = config.projects[name]
338+
config.projects[name] = resolved_path
339+
self.config_manager.save_config(config)
340+
341+
# Update in database
342+
project = await self.repository.get_by_name(name)
343+
if project:
344+
await self.repository.update_path(project.id, resolved_path)
345+
logger.info(f"Moved project '{name}' from {old_path} to {resolved_path}")
346+
else:
347+
logger.error(f"Project '{name}' exists in config but not in database")
348+
# Restore the old path in config since DB update failed
349+
config.projects[name] = old_path
350+
self.config_manager.save_config(config)
351+
raise ValueError(f"Project '{name}' not found in database")
352+
312353
async def update_project( # pragma: no cover
313354
self, name: str, updated_path: Optional[str] = None, is_active: Optional[bool] = None
314355
) -> None:

src/basic_memory/utils.py

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -146,19 +146,19 @@ def setup_logging(
146146
# logger.remove()
147147

148148
# Add file handler if we are not running tests and a log file is specified
149-
# if log_file and env != "test":
150-
# # Setup file logger
151-
# log_path = home_dir / log_file
152-
# logger.add(
153-
# str(log_path),
154-
# level=log_level,
155-
# rotation="10 MB",
156-
# retention="10 days",
157-
# backtrace=True,
158-
# diagnose=True,
159-
# enqueue=True,
160-
# colorize=False,
161-
# )
149+
if log_file and env != "test":
150+
# Setup file logger
151+
log_path = home_dir / log_file
152+
logger.add(
153+
str(log_path),
154+
level=log_level,
155+
rotation="10 MB",
156+
retention="10 days",
157+
backtrace=True,
158+
diagnose=True,
159+
enqueue=True,
160+
colorize=False,
161+
)
162162

163163
# Add console logger if requested or in test mode
164164
# if env == "test" or console:

0 commit comments

Comments
 (0)