mirror of
https://github.com/magnus919/agent-skills.git
synced 2026-09-11 19:47:12 +03:00
fix: relocate binary analysis skill
This commit is contained in:
@@ -0,0 +1,786 @@
|
||||
"""Project command — manage analysis workspaces.
|
||||
|
||||
Subcommands: create, list, status, clean, remove, migrate.
|
||||
|
||||
Implements the full project lifecycle with state machine enforcement,
|
||||
atomic manifest writes, file-based locking, and confirmation gates for
|
||||
destructive operations.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from binary_analysis.cli.helpers import (
|
||||
build_paginated_response,
|
||||
clamp_page_size,
|
||||
)
|
||||
from binary_analysis.domain.enums import AuditResult, ProjectState
|
||||
from binary_analysis.domain.errors import (
|
||||
InvalidArgsError,
|
||||
ProjectNotFoundError,
|
||||
)
|
||||
from binary_analysis.projects.cache import cache_clear
|
||||
from binary_analysis.projects.lock import (
|
||||
get_lock_holder,
|
||||
is_locked,
|
||||
)
|
||||
from binary_analysis.projects.manifest import (
|
||||
create_manifest,
|
||||
load_manifest,
|
||||
save_manifest,
|
||||
update_manifest_field,
|
||||
)
|
||||
from binary_analysis.projects.state_machine import (
|
||||
can_clean,
|
||||
should_reject_migrate,
|
||||
)
|
||||
from binary_analysis.projects.workspace import (
|
||||
create_workspace,
|
||||
get_project_path,
|
||||
get_workspace_subdirs,
|
||||
list_workspaces,
|
||||
remove_workspace,
|
||||
validate_project_name,
|
||||
workspace_exists,
|
||||
)
|
||||
from binary_analysis.reporting.audit import write_audit_event
|
||||
|
||||
# Current workspace version for migration
|
||||
_WORKSPACE_VERSION = "1"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Subparser registration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _build_project_subparsers(subparsers: Any) -> None:
|
||||
"""Register project sub-subcommands."""
|
||||
create_parser: argparse.ArgumentParser = subparsers.add_parser(
|
||||
"create", help="Create a new project workspace."
|
||||
)
|
||||
create_parser.add_argument("name", help="Project name.")
|
||||
create_parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="Preview creation without mutating.",
|
||||
)
|
||||
|
||||
list_parser = subparsers.add_parser("list", help="List projects with pagination.")
|
||||
# --limit is read from the root parser (consumed before the subparser by
|
||||
# _extract_globals). The list subparser does not register its own --limit
|
||||
# to avoid overwriting the root parser's value.
|
||||
list_parser.add_argument(
|
||||
"--page-token",
|
||||
default=None,
|
||||
help="Opaque pagination cursor from previous response (next_page_token).",
|
||||
)
|
||||
|
||||
status_parser = subparsers.add_parser("status", help="Show project state and metadata.")
|
||||
status_parser.add_argument("project", help="Project name or UUID.")
|
||||
|
||||
clean_parser = subparsers.add_parser("clean", help="Reset a FAILED project to CREATED.")
|
||||
clean_parser.add_argument("project", help="Project name or UUID.")
|
||||
clean_parser.add_argument("--yes", action="store_true", help="Skip confirmation prompt.")
|
||||
clean_parser.add_argument(
|
||||
"--force", action="store_true", help="Force clean without confirmation."
|
||||
)
|
||||
|
||||
remove_parser = subparsers.add_parser("remove", help="Delete a project workspace.")
|
||||
remove_parser.add_argument("project", help="Project name or UUID.")
|
||||
remove_parser.add_argument("--yes", action="store_true", help="Skip confirmation prompt.")
|
||||
remove_parser.add_argument(
|
||||
"--force", action="store_true", help="Force removal without confirmation."
|
||||
)
|
||||
remove_parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="Preview deletion paths without mutating.",
|
||||
)
|
||||
|
||||
migrate_parser = subparsers.add_parser("migrate", help="Upgrade project workspace format.")
|
||||
migrate_parser.add_argument("project", help="Project name or UUID.")
|
||||
migrate_parser.add_argument(
|
||||
"--plan",
|
||||
action="store_true",
|
||||
help="Show migration plan without mutating.",
|
||||
)
|
||||
migrate_parser.add_argument(
|
||||
"--apply",
|
||||
action="store_true",
|
||||
help="Perform the workspace format upgrade.",
|
||||
)
|
||||
migrate_parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="Preview migration plan without mutating.",
|
||||
)
|
||||
|
||||
|
||||
def add_subparser(subparsers: Any) -> argparse.ArgumentParser:
|
||||
"""Register the project subcommand with sub-subcommands."""
|
||||
parser: argparse.ArgumentParser = subparsers.add_parser(
|
||||
"project",
|
||||
help="Manage analysis workspaces.",
|
||||
)
|
||||
project_sub = parser.add_subparsers(dest="project_command", help="Project subcommands")
|
||||
_build_project_subparsers(project_sub)
|
||||
return parser
|
||||
|
||||
|
||||
def _positive_int(value: str) -> int:
|
||||
"""Validate a positive integer argument."""
|
||||
try:
|
||||
number = int(value)
|
||||
except ValueError:
|
||||
raise argparse.ArgumentTypeError("limit must be a positive integer") from None
|
||||
if number <= 0:
|
||||
raise argparse.ArgumentTypeError("limit must be a positive integer")
|
||||
return number
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Command execution dispatch
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def execute(args: argparse.Namespace) -> dict[str, Any]:
|
||||
"""Run a project subcommand.
|
||||
|
||||
Dispatches to the appropriate handler based on project_command.
|
||||
Returns a result dict compatible with the JSON envelope builder.
|
||||
"""
|
||||
subcommand = getattr(args, "project_command", None)
|
||||
if subcommand is None:
|
||||
raise InvalidArgsError(
|
||||
"No project subcommand specified. "
|
||||
"Available: create, list, status, clean, remove, migrate."
|
||||
)
|
||||
|
||||
handlers: dict[str, Any] = {
|
||||
"create": _execute_create,
|
||||
"list": _execute_list,
|
||||
"status": _execute_status,
|
||||
"clean": _execute_clean,
|
||||
"remove": _execute_remove,
|
||||
"migrate": _execute_migrate,
|
||||
}
|
||||
|
||||
handler = handlers.get(subcommand)
|
||||
if handler is None:
|
||||
raise InvalidArgsError(f"Unknown project subcommand: {subcommand}")
|
||||
|
||||
result: dict[str, Any] = handler(args)
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Project path resolution
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _resolve_project_path(project_name: str) -> str:
|
||||
"""Resolve a project name to its workspace path.
|
||||
|
||||
Also tries to resolve by UUID by scanning workspace directories.
|
||||
|
||||
Args:
|
||||
project_name: Project name or UUID string.
|
||||
|
||||
Returns:
|
||||
Absolute path to the project workspace directory.
|
||||
|
||||
Raises:
|
||||
ProjectNotFoundError: If the project doesn't exist.
|
||||
"""
|
||||
# First, try by name
|
||||
if workspace_exists(project_name):
|
||||
return str(get_project_path(project_name))
|
||||
|
||||
# Try by UUID — scan all workspaces
|
||||
for ws_name in list_workspaces():
|
||||
ws_path = str(get_project_path(ws_name))
|
||||
try:
|
||||
manifest = load_manifest(ws_path)
|
||||
if manifest.get("id") == project_name:
|
||||
return ws_path
|
||||
except Exception:
|
||||
continue # Skip corrupted manifests
|
||||
|
||||
raise ProjectNotFoundError(project_name)
|
||||
|
||||
|
||||
def _resolve_project_name(project_name_or_id: str) -> str:
|
||||
"""Resolve a project name or UUID to the project's directory name.
|
||||
|
||||
Returns the directory name used in the workspace root.
|
||||
"""
|
||||
if workspace_exists(project_name_or_id):
|
||||
return project_name_or_id
|
||||
|
||||
# Try UUID lookup
|
||||
for ws_name in list_workspaces():
|
||||
try:
|
||||
ws_path = str(get_project_path(ws_name))
|
||||
manifest = load_manifest(ws_path)
|
||||
if manifest.get("id") == project_name_or_id:
|
||||
return ws_name
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
raise ProjectNotFoundError(project_name_or_id)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Project create
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _execute_create(args: argparse.Namespace) -> dict[str, Any]:
|
||||
"""Execute the 'project create' subcommand."""
|
||||
t_start = time.perf_counter()
|
||||
project_name = args.name
|
||||
dry_run = getattr(args, "dry_run", False)
|
||||
|
||||
# Validate project name
|
||||
try:
|
||||
validate_project_name(project_name)
|
||||
except ValueError as e:
|
||||
raise InvalidArgsError(str(e)) from e
|
||||
|
||||
# Check for duplicates
|
||||
if workspace_exists(project_name):
|
||||
return {
|
||||
"success": False,
|
||||
"partial": False,
|
||||
"warnings": [],
|
||||
"diagnostics": [
|
||||
{
|
||||
"severity": "ERROR",
|
||||
"message": f"Project '{project_name}' already exists.",
|
||||
"category": "project",
|
||||
}
|
||||
],
|
||||
"data": None,
|
||||
}
|
||||
|
||||
# Dry-run: report plan without mutating
|
||||
if dry_run:
|
||||
project_dir_path = get_project_path(project_name)
|
||||
return {
|
||||
"success": True,
|
||||
"partial": False,
|
||||
"warnings": [],
|
||||
"diagnostics": [],
|
||||
"data": {
|
||||
"dry_run": True,
|
||||
"name": project_name,
|
||||
"directory": str(project_dir_path),
|
||||
"state": ProjectState.CREATED.value,
|
||||
},
|
||||
}
|
||||
|
||||
# Create workspace + manifest
|
||||
project_dir_str = str(create_workspace(project_name))
|
||||
manifest = create_manifest(project_name)
|
||||
save_manifest(project_dir_str, manifest)
|
||||
|
||||
# Record audit event
|
||||
duration_ms = int((time.perf_counter() - t_start) * 1000)
|
||||
write_audit_event(
|
||||
project_dir_str,
|
||||
command="project create",
|
||||
result=AuditResult.SUCCESS,
|
||||
duration_ms=duration_ms,
|
||||
args={"name": project_name},
|
||||
project_id=manifest["id"],
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"partial": False,
|
||||
"warnings": [],
|
||||
"diagnostics": [],
|
||||
"data": {
|
||||
"id": manifest["id"],
|
||||
"name": project_name,
|
||||
"state": manifest["state"],
|
||||
"created_at": manifest["created_at"],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Project list
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _execute_list(args: argparse.Namespace) -> dict[str, Any]:
|
||||
"""Execute the 'project list' subcommand with cursor-based pagination."""
|
||||
# Read limit from global args (consumed by argparse before subparser).
|
||||
limit, _clamp_warning = clamp_page_size(getattr(args, "limit", None))
|
||||
page_token_str: str | None = getattr(args, "page_token", None)
|
||||
|
||||
# Build warnings
|
||||
warnings: list[dict[str, Any]] = []
|
||||
if _clamp_warning:
|
||||
from binary_analysis.cli.helpers import make_warning
|
||||
|
||||
warnings.append(make_warning(_clamp_warning, severity="WARNING", category="pagination"))
|
||||
|
||||
# Collect all project names
|
||||
all_names = list_workspaces()
|
||||
|
||||
# Decode cursor if present (opaque base64-encoded JSON with offset)
|
||||
start_index = 0
|
||||
if page_token_str:
|
||||
try:
|
||||
import base64
|
||||
|
||||
cursor_data = json.loads(base64.urlsafe_b64decode(page_token_str.encode("ascii")))
|
||||
start_index = cursor_data.get("offset", 0)
|
||||
except Exception:
|
||||
raise InvalidArgsError("Invalid page_token value") from None
|
||||
|
||||
# Slice for pagination
|
||||
total = len(all_names)
|
||||
page_names = all_names[start_index : start_index + limit]
|
||||
|
||||
# Load manifests for each project in the page
|
||||
items: list[dict[str, Any]] = []
|
||||
for name in page_names:
|
||||
try:
|
||||
ws_path = str(get_project_path(name))
|
||||
manifest = load_manifest(ws_path)
|
||||
items.append(
|
||||
{
|
||||
"id": manifest.get("id"),
|
||||
"name": manifest.get("name", name),
|
||||
"state": manifest.get("state"),
|
||||
"created_at": manifest.get("created_at"),
|
||||
"binary_count": manifest.get("binary_count", 0),
|
||||
"is_stale": manifest.get("is_stale", False),
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
# Skip corrupted/missing projects in listing
|
||||
items.append(
|
||||
{
|
||||
"name": name,
|
||||
"state": "UNKNOWN",
|
||||
}
|
||||
)
|
||||
|
||||
paginated = build_paginated_response(
|
||||
items=items,
|
||||
total=total,
|
||||
offset=start_index,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"partial": False,
|
||||
"warnings": warnings,
|
||||
"diagnostics": [],
|
||||
"data": paginated,
|
||||
}
|
||||
|
||||
|
||||
def _encode_cursor(data: dict[str, Any]) -> str:
|
||||
"""Encode a cursor dict as a base64-encoded JSON string (opaque cursor)."""
|
||||
import base64
|
||||
|
||||
json_bytes = json.dumps(data).encode("utf-8")
|
||||
return base64.urlsafe_b64encode(json_bytes).decode("ascii")
|
||||
|
||||
|
||||
def _decode_cursor(cursor_str: str) -> dict[str, Any]:
|
||||
"""Decode a base64-encoded cursor string back to a dict."""
|
||||
import base64
|
||||
|
||||
json_bytes = base64.urlsafe_b64decode(cursor_str.encode("ascii"))
|
||||
result: dict[str, Any] = json.loads(json_bytes)
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Project status
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _execute_status(args: argparse.Namespace) -> dict[str, Any]:
|
||||
"""Execute the 'project status' subcommand."""
|
||||
project_name = args.project
|
||||
project_path = _resolve_project_path(project_name)
|
||||
pw_name = _resolve_project_name(project_name)
|
||||
manifest = load_manifest(project_path)
|
||||
|
||||
# Get lock information
|
||||
lock_holder = get_lock_holder(project_path)
|
||||
lock_info: dict[str, Any] | None = None
|
||||
if lock_holder:
|
||||
lock_info = {"holder": lock_holder}
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"partial": False,
|
||||
"warnings": [],
|
||||
"diagnostics": [],
|
||||
"data": {
|
||||
"id": manifest.get("id"),
|
||||
"name": manifest.get("name", pw_name),
|
||||
"state": manifest.get("state"),
|
||||
"binary_count": manifest.get("binary_count", 0),
|
||||
"created_at": manifest.get("created_at"),
|
||||
"updated_at": manifest.get("updated_at"),
|
||||
"workspace_version": manifest.get("workspace_version"),
|
||||
"is_stale": manifest.get("is_stale", False),
|
||||
"lock": lock_info,
|
||||
"description": manifest.get("description"),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Project clean
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _execute_clean(args: argparse.Namespace) -> dict[str, Any]:
|
||||
"""Execute the 'project clean' subcommand.
|
||||
|
||||
Resets a FAILED project back to CREATED state, clearing cache and
|
||||
diagnostics. Only operates on FAILED projects. Requires user
|
||||
confirmation unless --yes or --force is provided.
|
||||
"""
|
||||
project_name = args.project
|
||||
yes = getattr(args, "yes", False)
|
||||
force = getattr(args, "force", False)
|
||||
skip_confirmation = yes or force
|
||||
|
||||
project_path = _resolve_project_path(project_name)
|
||||
manifest = load_manifest(project_path)
|
||||
|
||||
current_state_str = manifest.get("state", "")
|
||||
try:
|
||||
current_state = ProjectState(current_state_str)
|
||||
except ValueError:
|
||||
current_state = ProjectState.CREATED
|
||||
|
||||
# Confirmation check (VAL-PROJ-009: must come before state validation)
|
||||
if not skip_confirmation:
|
||||
try:
|
||||
prompt = (
|
||||
f"This will reset project '{project_name}' from FAILED to CREATED, "
|
||||
f"clearing all cached data and diagnostics. Continue? [y/N]: "
|
||||
)
|
||||
print(prompt, file=sys.stderr, end="", flush=True)
|
||||
response = sys.stdin.readline().strip().lower()
|
||||
if response not in ("y", "yes"):
|
||||
return {
|
||||
"success": False,
|
||||
"partial": False,
|
||||
"warnings": [],
|
||||
"diagnostics": [
|
||||
{
|
||||
"severity": "INFO",
|
||||
"message": "Clean operation cancelled by user.",
|
||||
"category": "user",
|
||||
}
|
||||
],
|
||||
"data": None,
|
||||
}
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
return {
|
||||
"success": False,
|
||||
"partial": False,
|
||||
"warnings": [],
|
||||
"diagnostics": [
|
||||
{
|
||||
"severity": "INFO",
|
||||
"message": "Clean operation cancelled.",
|
||||
"category": "user",
|
||||
}
|
||||
],
|
||||
"data": None,
|
||||
}
|
||||
|
||||
# Only FAILED projects can be cleaned (validated after confirmation)
|
||||
if not can_clean(current_state):
|
||||
return {
|
||||
"success": False,
|
||||
"partial": False,
|
||||
"warnings": [],
|
||||
"diagnostics": [
|
||||
{
|
||||
"severity": "ERROR",
|
||||
"message": (
|
||||
f"Clean is only allowed on FAILED projects. "
|
||||
f"Current state: {current_state.value}. "
|
||||
f"Use 'project remove' to delete this project."
|
||||
),
|
||||
"category": "state_machine",
|
||||
}
|
||||
],
|
||||
"data": None,
|
||||
}
|
||||
|
||||
# Clear cache
|
||||
cache_clear(project_path)
|
||||
|
||||
# Reset state to CREATED, clear diagnostics
|
||||
update_manifest_field(
|
||||
project_path,
|
||||
{
|
||||
"state": ProjectState.CREATED.value,
|
||||
"is_stale": False,
|
||||
"diagnostics": [],
|
||||
},
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"partial": False,
|
||||
"warnings": [],
|
||||
"diagnostics": [],
|
||||
"data": {
|
||||
"name": manifest.get("name", project_name),
|
||||
"id": manifest.get("id"),
|
||||
"state": ProjectState.CREATED.value,
|
||||
"previous_state": current_state.value,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Project remove
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _execute_remove(args: argparse.Namespace) -> dict[str, Any]:
|
||||
"""Execute the 'project remove' subcommand.
|
||||
|
||||
Deletes the entire project workspace. Requires user confirmation
|
||||
unless --yes or --force is provided. Supports --dry-run for preview.
|
||||
"""
|
||||
project_name = args.project
|
||||
yes = getattr(args, "yes", False)
|
||||
force = getattr(args, "force", False)
|
||||
dry_run = getattr(args, "dry_run", False)
|
||||
skip_confirmation = yes or force or dry_run
|
||||
|
||||
pw_name = _resolve_project_name(project_name)
|
||||
project_path = str(get_project_path(pw_name))
|
||||
|
||||
# Get paths that would be deleted
|
||||
paths_to_delete: list[str] = []
|
||||
try:
|
||||
subdirs = get_workspace_subdirs(pw_name)
|
||||
for _name, dir_path in sorted(subdirs.items()):
|
||||
paths_to_delete.append(str(dir_path))
|
||||
except Exception:
|
||||
paths_to_delete.append(project_path)
|
||||
|
||||
# Dry-run: preview without deleting
|
||||
if dry_run:
|
||||
return {
|
||||
"success": True,
|
||||
"partial": False,
|
||||
"warnings": [],
|
||||
"diagnostics": [],
|
||||
"data": {
|
||||
"dry_run": True,
|
||||
"name": pw_name,
|
||||
"paths": paths_to_delete,
|
||||
},
|
||||
}
|
||||
|
||||
# Confirmation check
|
||||
if not skip_confirmation:
|
||||
try:
|
||||
prompt = (
|
||||
f"This will permanently delete project '{pw_name}' "
|
||||
f"and all its contents ({len(paths_to_delete)} directories). "
|
||||
f"Continue? [y/N]: "
|
||||
)
|
||||
print(prompt, file=sys.stderr, end="", flush=True)
|
||||
response = sys.stdin.readline().strip().lower()
|
||||
if response not in ("y", "yes"):
|
||||
return {
|
||||
"success": False,
|
||||
"partial": False,
|
||||
"warnings": [],
|
||||
"diagnostics": [
|
||||
{
|
||||
"severity": "INFO",
|
||||
"message": "Remove operation cancelled by user.",
|
||||
"category": "user",
|
||||
}
|
||||
],
|
||||
"data": None,
|
||||
}
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
return {
|
||||
"success": False,
|
||||
"partial": False,
|
||||
"warnings": [],
|
||||
"diagnostics": [
|
||||
{
|
||||
"severity": "INFO",
|
||||
"message": "Remove operation cancelled.",
|
||||
"category": "user",
|
||||
}
|
||||
],
|
||||
"data": None,
|
||||
}
|
||||
|
||||
# Perform deletion
|
||||
remove_workspace(pw_name)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"partial": False,
|
||||
"warnings": [],
|
||||
"diagnostics": [],
|
||||
"data": {
|
||||
"name": pw_name,
|
||||
"removed": True,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Project migrate
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _execute_migrate(args: argparse.Namespace) -> dict[str, Any]:
|
||||
"""Execute the 'project migrate' subcommand.
|
||||
|
||||
Supports --plan (show upgrade path), --apply (perform upgrade),
|
||||
and --dry-run (preview without mutation). Rejects migrate on locked
|
||||
projects.
|
||||
"""
|
||||
project_name = args.project
|
||||
plan = getattr(args, "plan", False)
|
||||
apply_flag = getattr(args, "apply", False)
|
||||
dry_run = getattr(args, "dry_run", False)
|
||||
|
||||
# --dry-run is equivalent to --plan for preview
|
||||
is_preview = plan or dry_run
|
||||
is_apply = apply_flag
|
||||
|
||||
project_path = _resolve_project_path(project_name)
|
||||
manifest = load_manifest(project_path)
|
||||
|
||||
current_version = manifest.get("workspace_version", "1")
|
||||
target_version = _WORKSPACE_VERSION
|
||||
|
||||
# Read current state
|
||||
current_state_str = manifest.get("state", "")
|
||||
try:
|
||||
current_state = ProjectState(current_state_str)
|
||||
except ValueError:
|
||||
current_state = ProjectState.CREATED
|
||||
|
||||
locked = is_locked(project_path)
|
||||
|
||||
# Reject migrate on locked projects
|
||||
if is_apply and should_reject_migrate(current_state, locked):
|
||||
reason_parts = []
|
||||
if locked:
|
||||
reason_parts.append("project is currently locked")
|
||||
if current_state == ProjectState.ANALYZING:
|
||||
reason_parts.append("project is in ANALYZING state")
|
||||
return {
|
||||
"success": False,
|
||||
"partial": False,
|
||||
"warnings": [],
|
||||
"diagnostics": [
|
||||
{
|
||||
"severity": "ERROR",
|
||||
"message": (
|
||||
f"Cannot migrate: {'; '.join(reason_parts)}. "
|
||||
f"Wait for the operation to complete or release the lock."
|
||||
),
|
||||
"category": "state_machine",
|
||||
}
|
||||
],
|
||||
"data": None,
|
||||
}
|
||||
|
||||
# Build migration steps
|
||||
if current_version == target_version:
|
||||
migration_steps: list[dict[str, str]] = []
|
||||
message = "Project is already at the latest workspace version."
|
||||
else:
|
||||
migration_steps = [
|
||||
{
|
||||
"from_version": current_version,
|
||||
"to_version": target_version,
|
||||
"description": f"Upgrade workspace from v{current_version} to v{target_version}",
|
||||
}
|
||||
]
|
||||
message = f"Upgrade from v{current_version} to v{target_version} available."
|
||||
|
||||
# Preview mode
|
||||
if is_preview:
|
||||
return {
|
||||
"success": True,
|
||||
"partial": False,
|
||||
"warnings": [],
|
||||
"diagnostics": [],
|
||||
"data": {
|
||||
"current_version": current_version,
|
||||
"target_version": target_version,
|
||||
"migration_steps": migration_steps,
|
||||
"message": message,
|
||||
"dry_run": True,
|
||||
},
|
||||
}
|
||||
|
||||
# Apply migration
|
||||
if is_apply:
|
||||
if current_version == target_version:
|
||||
# Already at target — no-op success
|
||||
return {
|
||||
"success": True,
|
||||
"partial": False,
|
||||
"warnings": [],
|
||||
"diagnostics": [],
|
||||
"data": {
|
||||
"current_version": current_version,
|
||||
"target_version": target_version,
|
||||
"migration_steps": migration_steps,
|
||||
"applied": False,
|
||||
"message": "Already at target version; no migration needed.",
|
||||
},
|
||||
}
|
||||
|
||||
# Perform the upgrade
|
||||
update_manifest_field(project_path, {"workspace_version": target_version})
|
||||
return {
|
||||
"success": True,
|
||||
"partial": False,
|
||||
"warnings": [],
|
||||
"diagnostics": [],
|
||||
"data": {
|
||||
"current_version": target_version,
|
||||
"target_version": target_version,
|
||||
"migration_steps": migration_steps,
|
||||
"applied": True,
|
||||
"message": f"Migrated from v{current_version} to v{target_version}.",
|
||||
},
|
||||
}
|
||||
|
||||
# Neither --plan, --dry-run, nor --apply specified — show error
|
||||
raise InvalidArgsError(
|
||||
"Migrate requires --plan, --apply, or --dry-run. "
|
||||
"Use --plan to preview the migration, --apply to perform it."
|
||||
)
|
||||
Reference in New Issue
Block a user