mirror of
https://github.com/magnus919/agent-skills.git
synced 2026-09-11 19:47:12 +03:00
787 lines
25 KiB
Python
787 lines
25 KiB
Python
"""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."
|
|
)
|