cursor-portable 1.0.4__py3-none-any.whl
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.
- cursor_portable/__init__.py +5 -0
- cursor_portable/__main__.py +6 -0
- cursor_portable/_version.py +102 -0
- cursor_portable/cli/__init__.py +1 -0
- cursor_portable/cli/args.py +46 -0
- cursor_portable/cli/disk_cmd.py +142 -0
- cursor_portable/cli/launch_cmd.py +175 -0
- cursor_portable/cli/live.py +202 -0
- cursor_portable/cli/main.py +90 -0
- cursor_portable/constants.py +93 -0
- cursor_portable/disk/__init__.py +1 -0
- cursor_portable/disk/apply.py +59 -0
- cursor_portable/disk/commands.py +182 -0
- cursor_portable/disk/elevation.py +46 -0
- cursor_portable/disk/filesystem.py +152 -0
- cursor_portable/disk/guards.py +48 -0
- cursor_portable/disk/listing.py +266 -0
- cursor_portable/disk/probe.py +79 -0
- cursor_portable/disk/spawn.py +274 -0
- cursor_portable/disk/volume.py +118 -0
- cursor_portable/download/__init__.py +1 -0
- cursor_portable/download/catalog.py +62 -0
- cursor_portable/download/cli_script.py +111 -0
- cursor_portable/download/fetch.py +161 -0
- cursor_portable/download/http_client.py +71 -0
- cursor_portable/download/resolve.py +97 -0
- cursor_portable/download/sum_file.py +24 -0
- cursor_portable/download/verify_file.py +44 -0
- cursor_portable/failure.py +13 -0
- cursor_portable/host/__init__.py +1 -0
- cursor_portable/host/activity.py +50 -0
- cursor_portable/host/cleanup.py +121 -0
- cursor_portable/install/__init__.py +1 -0
- cursor_portable/install/acquire.py +635 -0
- cursor_portable/install/apply.py +243 -0
- cursor_portable/json_io.py +44 -0
- cursor_portable/launcher/__init__.py +1 -0
- cursor_portable/launcher/cache_sync.py +43 -0
- cursor_portable/launcher/headless.py +62 -0
- cursor_portable/launcher/home_link.py +115 -0
- cursor_portable/launcher/host_watch.py +240 -0
- cursor_portable/launcher/launch_plan.py +70 -0
- cursor_portable/launcher/links.py +60 -0
- cursor_portable/launcher/path_edit.py +100 -0
- cursor_portable/launcher/path_store.py +251 -0
- cursor_portable/launcher/processes.py +71 -0
- cursor_portable/launcher/profile_sync.py +63 -0
- cursor_portable/launcher/reports.py +51 -0
- cursor_portable/launcher/runner.py +54 -0
- cursor_portable/launcher/session.py +461 -0
- cursor_portable/launcher/session_file.py +82 -0
- cursor_portable/launcher/shortcuts.py +71 -0
- cursor_portable/logging_config.py +37 -0
- cursor_portable/models.py +63 -0
- cursor_portable/profile/__init__.py +1 -0
- cursor_portable/profile/copy_profile.py +148 -0
- cursor_portable/profile/settings.py +34 -0
- cursor_portable/profile/sources.py +57 -0
- cursor_portable/python_runtime/__init__.py +1 -0
- cursor_portable/python_runtime/pin.py +53 -0
- cursor_portable/repack/__init__.py +1 -0
- cursor_portable/repack/archive.py +50 -0
- cursor_portable/repack/commands.py +58 -0
- cursor_portable/repack/copy_tree.py +213 -0
- cursor_portable/repack/extract_ide.py +180 -0
- cursor_portable/repack/launchers.py +161 -0
- cursor_portable/repack/layout.py +68 -0
- cursor_portable/repack/meta.py +94 -0
- cursor_portable/repack/product_json.py +78 -0
- cursor_portable/repack/sfx.py +100 -0
- cursor_portable/runtime.py +12 -0
- cursor_portable/wizard/__init__.py +9 -0
- cursor_portable/wizard/__main__.py +22 -0
- cursor_portable/wizard/flow.py +196 -0
- cursor_portable/wizard/tcl_path.py +71 -0
- cursor_portable/wizard/window.py +906 -0
- cursor_portable-1.0.4.dist-info/METADATA +107 -0
- cursor_portable-1.0.4.dist-info/RECORD +81 -0
- cursor_portable-1.0.4.dist-info/WHEEL +4 -0
- cursor_portable-1.0.4.dist-info/entry_points.txt +3 -0
- cursor_portable-1.0.4.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Package version.
|
|
2
|
+
|
|
3
|
+
The only edited copy is ``[project].version`` in ``pyproject.toml``.
|
|
4
|
+
Copies of this package that leave the repository get a ``VERSION`` stamp.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import importlib.metadata
|
|
10
|
+
import logging
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
import re
|
|
13
|
+
import tomllib
|
|
14
|
+
|
|
15
|
+
LOGGER = logging.getLogger(__name__)
|
|
16
|
+
PROJECT_NAME = "cursor-portable"
|
|
17
|
+
RELEASE_VERSION = re.compile(r"^\d+\.\d+\.\d+$")
|
|
18
|
+
_STAMP = "VERSION"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def read_project_fields(text: str) -> tuple[str | None, str | None]:
|
|
22
|
+
"""Return the project name and version from pyproject text.
|
|
23
|
+
|
|
24
|
+
Invalid TOML raises ``ValueError``. A file without a project table
|
|
25
|
+
returns ``(None, None)``.
|
|
26
|
+
"""
|
|
27
|
+
try:
|
|
28
|
+
document: object = tomllib.loads(text)
|
|
29
|
+
except tomllib.TOMLDecodeError as exc:
|
|
30
|
+
raise ValueError(f"pyproject.toml is invalid: {exc}") from exc
|
|
31
|
+
if not isinstance(document, dict):
|
|
32
|
+
raise ValueError("pyproject.toml is not a table")
|
|
33
|
+
project = document.get("project")
|
|
34
|
+
if not isinstance(project, dict):
|
|
35
|
+
return None, None
|
|
36
|
+
name = project.get("name")
|
|
37
|
+
version = project.get("version")
|
|
38
|
+
project_name = name.strip() if isinstance(name, str) else ""
|
|
39
|
+
project_version = version.strip() if isinstance(version, str) else ""
|
|
40
|
+
return project_name or None, project_version or None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def read_stamp(package_dir: Path) -> str | None:
|
|
44
|
+
"""Return the version stamped beside a copied package."""
|
|
45
|
+
path = package_dir / _STAMP
|
|
46
|
+
if not path.is_file():
|
|
47
|
+
return None
|
|
48
|
+
text = path.read_text(encoding="utf-8").strip()
|
|
49
|
+
if not text:
|
|
50
|
+
return None
|
|
51
|
+
LOGGER.debug("Using stamped version %s from %s", text, path)
|
|
52
|
+
return text
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def nearest_project_version(start: Path) -> str | None:
|
|
56
|
+
"""Return this project's version from the nearest pyproject.toml."""
|
|
57
|
+
for directory in (start, *start.parents):
|
|
58
|
+
candidate = directory / "pyproject.toml"
|
|
59
|
+
if not candidate.is_file():
|
|
60
|
+
continue
|
|
61
|
+
name, version = read_project_fields(candidate.read_text(encoding="utf-8"))
|
|
62
|
+
if name == PROJECT_NAME and version:
|
|
63
|
+
LOGGER.debug("Using version %s from %s", version, candidate)
|
|
64
|
+
return version
|
|
65
|
+
LOGGER.debug("Ignoring %s while resolving the package version", candidate)
|
|
66
|
+
return None
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def resolve_version(package_dir: Path | None = None) -> str:
|
|
71
|
+
"""Return the package version from a stamp, pyproject.toml, or metadata."""
|
|
72
|
+
origin = Path(__file__).resolve().parent if package_dir is None else package_dir
|
|
73
|
+
stamped = read_stamp(origin)
|
|
74
|
+
if stamped:
|
|
75
|
+
return stamped
|
|
76
|
+
found = nearest_project_version(origin)
|
|
77
|
+
if found:
|
|
78
|
+
return found
|
|
79
|
+
try:
|
|
80
|
+
installed = importlib.metadata.version(PROJECT_NAME)
|
|
81
|
+
except importlib.metadata.PackageNotFoundError:
|
|
82
|
+
installed = ""
|
|
83
|
+
if installed:
|
|
84
|
+
LOGGER.debug("Using installed metadata version %s", installed)
|
|
85
|
+
return installed
|
|
86
|
+
raise RuntimeError(
|
|
87
|
+
"cursor-portable version is not in a VERSION stamp, pyproject.toml, "
|
|
88
|
+
"or package metadata."
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def write_version_stamp(package_dir: Path, version: str) -> None:
|
|
93
|
+
"""Write the version next to a copied package."""
|
|
94
|
+
text = version.strip()
|
|
95
|
+
if not text:
|
|
96
|
+
raise ValueError("Refusing to stamp an empty version")
|
|
97
|
+
path = package_dir / _STAMP
|
|
98
|
+
path.write_text(f"{text}\n", encoding="utf-8")
|
|
99
|
+
LOGGER.info("Stamped %s %s at %s", PROJECT_NAME, text, path)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
__version__ = resolve_version()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Command line entry points."""
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Argument parser for ``cursor-portable``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
9
|
+
"""Return the command line parser."""
|
|
10
|
+
parser = argparse.ArgumentParser(prog="cursor-portable")
|
|
11
|
+
parser.add_argument("--version", action="store_true")
|
|
12
|
+
commands = parser.add_subparsers(dest="command")
|
|
13
|
+
commands.add_parser("wizard")
|
|
14
|
+
launch = commands.add_parser("launch")
|
|
15
|
+
launch.add_argument("--root", required=True)
|
|
16
|
+
commands.add_parser("uninstall-host")
|
|
17
|
+
commands.add_parser("uninstall")
|
|
18
|
+
pack = commands.add_parser("pack-sfx")
|
|
19
|
+
pack.add_argument("--module", required=True)
|
|
20
|
+
pack.add_argument("--archive", required=True)
|
|
21
|
+
pack.add_argument("--output", required=True)
|
|
22
|
+
posix = commands.add_parser("pack-posix")
|
|
23
|
+
posix.add_argument("--extractor", required=True)
|
|
24
|
+
posix.add_argument("--archive", required=True)
|
|
25
|
+
posix.add_argument("--output", required=True)
|
|
26
|
+
repair = commands.add_parser("repair")
|
|
27
|
+
_add_disk_arguments(repair)
|
|
28
|
+
repair.add_argument("--apply", action="store_true")
|
|
29
|
+
fmt = commands.add_parser("format-volume")
|
|
30
|
+
_add_disk_arguments(fmt)
|
|
31
|
+
fmt.add_argument("--apply", action="store_true")
|
|
32
|
+
return parser
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _add_disk_arguments(parser: argparse.ArgumentParser) -> None:
|
|
36
|
+
parser.add_argument("--device", required=True)
|
|
37
|
+
parser.add_argument("--mount", required=True)
|
|
38
|
+
parser.add_argument("--label", default="")
|
|
39
|
+
parser.add_argument("--filesystem", required=True)
|
|
40
|
+
parser.add_argument("--typed", required=True)
|
|
41
|
+
parser.add_argument("--removable", action="store_true")
|
|
42
|
+
parser.add_argument("--system-disk", action="store_true")
|
|
43
|
+
parser.add_argument("--boot-disk", action="store_true")
|
|
44
|
+
parser.add_argument("--size", type=int, default=0)
|
|
45
|
+
parser.add_argument("--target", default="")
|
|
46
|
+
parser.add_argument("--read-only", action="store_true")
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Format and repair commands that print a plan unless ``--apply`` is set."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import logging
|
|
7
|
+
|
|
8
|
+
from cursor_portable.disk.apply import apply_repair
|
|
9
|
+
from cursor_portable.disk.commands import DiskPlan, RepairMode, format_plan, repair_plan
|
|
10
|
+
from cursor_portable.disk.listing import load_probed_volumes
|
|
11
|
+
from cursor_portable.disk.spawn import capture_command
|
|
12
|
+
from cursor_portable.disk.volume import Volume
|
|
13
|
+
from cursor_portable.failure import Failure
|
|
14
|
+
from cursor_portable.host.activity import volume_busy
|
|
15
|
+
from cursor_portable.models import HostSystem
|
|
16
|
+
|
|
17
|
+
LOGGER = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def volume_from_args(args: argparse.Namespace) -> Volume:
|
|
21
|
+
"""Build a volume from explicit command arguments."""
|
|
22
|
+
return Volume(
|
|
23
|
+
device_id=str(args.device),
|
|
24
|
+
mount_point=str(args.mount),
|
|
25
|
+
label=str(args.label),
|
|
26
|
+
filesystem=str(args.filesystem),
|
|
27
|
+
size_bytes=int(args.size),
|
|
28
|
+
removable=bool(args.removable),
|
|
29
|
+
is_system=bool(args.system_disk),
|
|
30
|
+
is_boot=bool(args.boot_disk),
|
|
31
|
+
writable=not bool(getattr(args, "read_only", False)),
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _probed_volume(
|
|
36
|
+
device_id: str,
|
|
37
|
+
system: HostSystem,
|
|
38
|
+
probed: tuple[Volume, ...] | None,
|
|
39
|
+
) -> Volume | Failure:
|
|
40
|
+
volumes = probed if probed is not None else load_probed_volumes(system)
|
|
41
|
+
if isinstance(volumes, Failure):
|
|
42
|
+
return volumes
|
|
43
|
+
for item in volumes:
|
|
44
|
+
if item.device_id.casefold() == device_id.casefold():
|
|
45
|
+
return item
|
|
46
|
+
return Failure("missing-volume", f"{device_id} is not a mounted volume.")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def chosen_target(args: argparse.Namespace, host: HostSystem) -> HostSystem | Failure:
|
|
50
|
+
"""Return ``--target`` or the computer running the command."""
|
|
51
|
+
raw = str(getattr(args, "target", "") or "")
|
|
52
|
+
if not raw:
|
|
53
|
+
return host
|
|
54
|
+
try:
|
|
55
|
+
return HostSystem(raw)
|
|
56
|
+
except ValueError:
|
|
57
|
+
return Failure("target", f"Unknown target system: {raw}.")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def run_disk_command(
|
|
61
|
+
args: argparse.Namespace,
|
|
62
|
+
system: HostSystem,
|
|
63
|
+
*,
|
|
64
|
+
repair: bool,
|
|
65
|
+
probed: tuple[Volume, ...] | None = None,
|
|
66
|
+
) -> int:
|
|
67
|
+
"""Print the plan. Run it only when ``--apply`` is present."""
|
|
68
|
+
volume = volume_from_args(args)
|
|
69
|
+
if args.apply:
|
|
70
|
+
matched = _probed_volume(volume.device_id, system, probed)
|
|
71
|
+
if isinstance(matched, Failure):
|
|
72
|
+
LOGGER.error("%s", matched.message)
|
|
73
|
+
print(matched.message)
|
|
74
|
+
return 1
|
|
75
|
+
volume = matched
|
|
76
|
+
busy = volume_busy(volume, system)
|
|
77
|
+
target = chosen_target(args, system)
|
|
78
|
+
if isinstance(target, Failure):
|
|
79
|
+
LOGGER.error("%s", target.message)
|
|
80
|
+
print(target.message)
|
|
81
|
+
return 1
|
|
82
|
+
if repair:
|
|
83
|
+
plan: DiskPlan | Failure = repair_plan(
|
|
84
|
+
volume,
|
|
85
|
+
str(args.typed),
|
|
86
|
+
system,
|
|
87
|
+
RepairMode.APPLY if args.apply else RepairMode.CHECK,
|
|
88
|
+
busy=busy,
|
|
89
|
+
target=target,
|
|
90
|
+
)
|
|
91
|
+
else:
|
|
92
|
+
plan = format_plan(
|
|
93
|
+
volume,
|
|
94
|
+
str(args.typed),
|
|
95
|
+
system,
|
|
96
|
+
busy=busy,
|
|
97
|
+
target=target,
|
|
98
|
+
)
|
|
99
|
+
if isinstance(plan, Failure):
|
|
100
|
+
LOGGER.error("%s", plan.message)
|
|
101
|
+
print(plan.message)
|
|
102
|
+
return 1
|
|
103
|
+
print(plan.summary)
|
|
104
|
+
print(" ".join(plan.command))
|
|
105
|
+
if not args.apply:
|
|
106
|
+
return 0
|
|
107
|
+
LOGGER.info("Applying disk command: %s", plan.command)
|
|
108
|
+
if repair:
|
|
109
|
+
failure = apply_repair(plan)
|
|
110
|
+
if failure is not None:
|
|
111
|
+
LOGGER.error("%s", failure.message)
|
|
112
|
+
print(failure.message)
|
|
113
|
+
return 1
|
|
114
|
+
return 0
|
|
115
|
+
return execute_plan(plan)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def execute_plan_result(plan: DiskPlan) -> Failure | None:
|
|
119
|
+
"""Run a format command. Windows asks for approval when this process is not elevated."""
|
|
120
|
+
captured = capture_command(plan.command, elevate=plan.needs_elevation)
|
|
121
|
+
if isinstance(captured, Failure):
|
|
122
|
+
return captured
|
|
123
|
+
code, output = captured
|
|
124
|
+
if output.strip():
|
|
125
|
+
LOGGER.info("%s", output[-4000:])
|
|
126
|
+
if code != 0:
|
|
127
|
+
detail = output.strip().splitlines()[-1] if output.strip() else ""
|
|
128
|
+
message = f"Disk command exited {code}."
|
|
129
|
+
if detail:
|
|
130
|
+
message = f"{message} {detail}"
|
|
131
|
+
return Failure("disk", message)
|
|
132
|
+
return None
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def execute_plan(plan: DiskPlan) -> int:
|
|
136
|
+
"""Run a format or repair command that already passed the guards."""
|
|
137
|
+
failure = execute_plan_result(plan)
|
|
138
|
+
if failure is None:
|
|
139
|
+
return 0
|
|
140
|
+
LOGGER.error("%s", failure.message)
|
|
141
|
+
print(failure.message)
|
|
142
|
+
return 1
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Launch and host uninstall. These talk to the real user profile."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from cursor_portable.host.cleanup import (
|
|
11
|
+
cache_directory,
|
|
12
|
+
cleanup_session,
|
|
13
|
+
default_host_root,
|
|
14
|
+
delete_managed_shortcuts,
|
|
15
|
+
delete_runtime_cache,
|
|
16
|
+
)
|
|
17
|
+
from cursor_portable.launcher.host_watch import (
|
|
18
|
+
read_processes,
|
|
19
|
+
read_shortcuts,
|
|
20
|
+
restore_shortcuts,
|
|
21
|
+
stop_processes,
|
|
22
|
+
write_managed_shortcut,
|
|
23
|
+
)
|
|
24
|
+
from cursor_portable.launcher.launch_plan import LaunchCommand, build_launch
|
|
25
|
+
from cursor_portable.launcher.links import DirectoryLinker
|
|
26
|
+
from cursor_portable.launcher.path_store import (
|
|
27
|
+
PathStore,
|
|
28
|
+
path_store_for,
|
|
29
|
+
shell_rc_paths,
|
|
30
|
+
)
|
|
31
|
+
from cursor_portable.launcher.processes import pid_alive
|
|
32
|
+
from cursor_portable.launcher.runner import run_until_exit
|
|
33
|
+
from cursor_portable.launcher.session import LaunchHooks, run_launch
|
|
34
|
+
from cursor_portable.launcher.session_file import read_session
|
|
35
|
+
from cursor_portable.launcher.shortcuts import Shortcut
|
|
36
|
+
from cursor_portable.models import HostSystem, host_system_name
|
|
37
|
+
from cursor_portable.repack.layout import StickLayout
|
|
38
|
+
from cursor_portable.runtime import is_windows
|
|
39
|
+
|
|
40
|
+
LOGGER = logging.getLogger(__name__)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _alive(pid: int) -> bool:
|
|
44
|
+
if pid <= 0:
|
|
45
|
+
return False
|
|
46
|
+
if is_windows():
|
|
47
|
+
return pid_alive(pid)
|
|
48
|
+
try:
|
|
49
|
+
os.kill(pid, 0)
|
|
50
|
+
except OSError:
|
|
51
|
+
return False
|
|
52
|
+
return True
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _host_root(system: HostSystem) -> Path:
|
|
56
|
+
local = os.environ.get("LOCALAPPDATA")
|
|
57
|
+
local_path = Path(local) if local else None
|
|
58
|
+
return default_host_root(
|
|
59
|
+
Path.home(),
|
|
60
|
+
local_path,
|
|
61
|
+
windows=system is HostSystem.WINDOWS,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _run_command(command: LaunchCommand, on_tick: Callable[[], None]) -> int:
|
|
66
|
+
return run_until_exit(command, dict(os.environ), on_tick)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _path_store(system: HostSystem) -> PathStore:
|
|
70
|
+
paths = shell_rc_paths(Path.home(), system)
|
|
71
|
+
return path_store_for(system, None if not paths else paths)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _hooks(system: HostSystem, layout: StickLayout) -> LaunchHooks:
|
|
75
|
+
launcher = (
|
|
76
|
+
layout.windows_launcher
|
|
77
|
+
if system is HostSystem.WINDOWS
|
|
78
|
+
else layout.unix_launcher
|
|
79
|
+
)
|
|
80
|
+
return LaunchHooks(
|
|
81
|
+
store=_path_store(system),
|
|
82
|
+
linker=DirectoryLinker(),
|
|
83
|
+
run=_run_command,
|
|
84
|
+
processes=read_processes,
|
|
85
|
+
stop=stop_processes,
|
|
86
|
+
read_shortcuts=read_shortcuts,
|
|
87
|
+
restore_shortcuts=restore_shortcuts,
|
|
88
|
+
alive=_alive,
|
|
89
|
+
ensure_shortcut=lambda: write_managed_shortcut(
|
|
90
|
+
launcher,
|
|
91
|
+
windows=system is HostSystem.WINDOWS,
|
|
92
|
+
),
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _stop_nothing(pids: tuple[int, ...]) -> None:
|
|
97
|
+
LOGGER.info("Installer pids not stopped by the default hook: %s", pids)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _restore_nothing(items: tuple[Shortcut, ...]) -> None:
|
|
101
|
+
LOGGER.info("Shortcut restore received %s items", len(items))
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def launch_root(root: Path) -> int:
|
|
105
|
+
"""Start Cursor from a ``CursorPortable`` directory."""
|
|
106
|
+
system = host_system_name()
|
|
107
|
+
layout = StickLayout.from_root(root)
|
|
108
|
+
host = _host_root(system)
|
|
109
|
+
cache = cache_directory(host, layout.root)
|
|
110
|
+
scratch = host / "scratch"
|
|
111
|
+
LOGGER.info("Launching %s with cache %s", layout.root, cache)
|
|
112
|
+
return run_launch(
|
|
113
|
+
layout,
|
|
114
|
+
host,
|
|
115
|
+
cache,
|
|
116
|
+
scratch,
|
|
117
|
+
Path.home() / ".cursor",
|
|
118
|
+
system,
|
|
119
|
+
_hooks(system, layout),
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def uninstall_host() -> int:
|
|
124
|
+
"""Remove host leftovers. The removable drive is not changed."""
|
|
125
|
+
system = host_system_name()
|
|
126
|
+
host = _host_root(system)
|
|
127
|
+
session_path = host / "session.txt"
|
|
128
|
+
record = read_session(session_path)
|
|
129
|
+
store = _path_store(system)
|
|
130
|
+
if record is not None and _session_busy(record.pid):
|
|
131
|
+
LOGGER.error("Cursor Portable is still running as pid %s", record.pid)
|
|
132
|
+
return 3
|
|
133
|
+
if record is not None:
|
|
134
|
+
cleanup_session(record, store, DirectoryLinker(), delete_cache=True)
|
|
135
|
+
session_path.unlink(missing_ok=True)
|
|
136
|
+
delete_runtime_cache(host)
|
|
137
|
+
removed = delete_managed_shortcuts(_shortcut_candidates())
|
|
138
|
+
LOGGER.info("Removed %s managed shortcuts", len(removed))
|
|
139
|
+
return 0
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _session_busy(pid_text: str) -> bool:
|
|
143
|
+
try:
|
|
144
|
+
previous = int(pid_text)
|
|
145
|
+
except ValueError:
|
|
146
|
+
return False
|
|
147
|
+
return _alive(previous)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _shortcut_candidates() -> tuple[Path, ...]:
|
|
151
|
+
home = Path.home()
|
|
152
|
+
roots = [
|
|
153
|
+
home / "Desktop",
|
|
154
|
+
home / ".local" / "share" / "applications",
|
|
155
|
+
]
|
|
156
|
+
appdata = os.environ.get("APPDATA")
|
|
157
|
+
if appdata:
|
|
158
|
+
roots.append(
|
|
159
|
+
Path(appdata) / "Microsoft" / "Windows" / "Start Menu" / "Programs"
|
|
160
|
+
)
|
|
161
|
+
names = ("Cursor Portable.lnk", "cursor-portable.desktop")
|
|
162
|
+
found: list[Path] = []
|
|
163
|
+
for root in roots:
|
|
164
|
+
for name in names:
|
|
165
|
+
candidate = root / name
|
|
166
|
+
if candidate.is_file():
|
|
167
|
+
found.append(candidate)
|
|
168
|
+
return tuple(found)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def describe_launch(root: Path, system: HostSystem) -> tuple[str, ...]:
|
|
172
|
+
"""Return the Cursor command without starting it."""
|
|
173
|
+
layout = StickLayout.from_root(root)
|
|
174
|
+
command = build_launch(layout, root / "cache", root / "scratch", system)
|
|
175
|
+
return command.argv
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""Wire the wizard to a payload directory. Disk plans still go through the guards."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from cursor_portable.cli.disk_cmd import execute_plan_result
|
|
11
|
+
from cursor_portable.cli.launch_cmd import uninstall_host
|
|
12
|
+
from cursor_portable.disk.apply import apply_repair
|
|
13
|
+
from cursor_portable.disk.commands import DiskPlan
|
|
14
|
+
from cursor_portable.disk.filesystem import display_filesystem, filesystem_for
|
|
15
|
+
from cursor_portable.disk.listing import load_probed_volumes
|
|
16
|
+
from cursor_portable.disk.volume import Volume, confirmation_hint, label_matches
|
|
17
|
+
from cursor_portable.failure import Failure
|
|
18
|
+
from cursor_portable.host.activity import cursor_editor_running, volume_busy
|
|
19
|
+
from cursor_portable.install.acquire import Progress, acquire_payload, payload_cache
|
|
20
|
+
from cursor_portable.install.apply import install_tree, scope_dir
|
|
21
|
+
from cursor_portable.models import (
|
|
22
|
+
HostSystem,
|
|
23
|
+
InstallChoices,
|
|
24
|
+
ProfileMode,
|
|
25
|
+
host_system_name,
|
|
26
|
+
)
|
|
27
|
+
from cursor_portable.profile.sources import profile_sources
|
|
28
|
+
from cursor_portable.repack.archive import run_command, seven_zip_extract
|
|
29
|
+
from cursor_portable.repack.layout import StickLayout
|
|
30
|
+
from cursor_portable.wizard.flow import DriveDisposition, classify_drive
|
|
31
|
+
from cursor_portable.wizard.window import WizardCallbacks
|
|
32
|
+
|
|
33
|
+
LOGGER = logging.getLogger(__name__)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def payload_directory(argv: list[str] | None = None) -> Path | None:
|
|
37
|
+
"""Read ``--payload-dir`` or ``--payload``."""
|
|
38
|
+
parser = argparse.ArgumentParser(add_help=False)
|
|
39
|
+
parser.add_argument("--payload-dir")
|
|
40
|
+
parser.add_argument("--payload")
|
|
41
|
+
known, _unused = parser.parse_known_args(argv)
|
|
42
|
+
if known.payload_dir:
|
|
43
|
+
return Path(str(known.payload_dir))
|
|
44
|
+
if known.payload:
|
|
45
|
+
return Path(str(known.payload))
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def bundled_extractor() -> str:
|
|
50
|
+
"""Use the extractor shipped beside the wizard, then a system 7-Zip."""
|
|
51
|
+
override = os.environ.get("CURSOR_PORTABLE_7Z")
|
|
52
|
+
if override:
|
|
53
|
+
return override
|
|
54
|
+
for name in ("7zz.exe", "7zz", "7z.exe", "7z"):
|
|
55
|
+
candidate = Path.cwd() / name
|
|
56
|
+
if candidate.is_file():
|
|
57
|
+
return str(candidate)
|
|
58
|
+
return "7z"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _extract_payload(path: Path) -> Path | Failure:
|
|
62
|
+
if path.is_dir():
|
|
63
|
+
return path
|
|
64
|
+
extractor = bundled_extractor()
|
|
65
|
+
destination = path.parent / f"{path.stem}-extracted"
|
|
66
|
+
failure = run_command(seven_zip_extract(extractor, path, destination))
|
|
67
|
+
if failure is not None:
|
|
68
|
+
return failure
|
|
69
|
+
return destination
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _run_plan(plan: DiskPlan) -> Failure | None:
|
|
73
|
+
return execute_plan_result(plan)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _refuse_unready(
|
|
77
|
+
choices: InstallChoices,
|
|
78
|
+
volume: Volume,
|
|
79
|
+
host: HostSystem,
|
|
80
|
+
) -> Failure | None:
|
|
81
|
+
if not label_matches(volume, choices.typed_label):
|
|
82
|
+
message = (
|
|
83
|
+
"Type the volume label exactly."
|
|
84
|
+
if volume.label.strip()
|
|
85
|
+
else confirmation_hint(volume)
|
|
86
|
+
)
|
|
87
|
+
return Failure("label-mismatch", message)
|
|
88
|
+
disposition = classify_drive(
|
|
89
|
+
volume,
|
|
90
|
+
host,
|
|
91
|
+
choices.target_os,
|
|
92
|
+
busy=volume_busy(volume, host),
|
|
93
|
+
)
|
|
94
|
+
if isinstance(disposition, Failure):
|
|
95
|
+
return disposition
|
|
96
|
+
if disposition is DriveDisposition.READY:
|
|
97
|
+
return None
|
|
98
|
+
expected = display_filesystem(filesystem_for(choices.target_os))
|
|
99
|
+
return Failure(
|
|
100
|
+
"needs-format",
|
|
101
|
+
f"Format the drive as {expected} before installing.",
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _acquire_payload(
|
|
106
|
+
choices: InstallChoices,
|
|
107
|
+
host: HostSystem,
|
|
108
|
+
payload: Path | None,
|
|
109
|
+
progress: Progress | None,
|
|
110
|
+
) -> Path | Failure:
|
|
111
|
+
if payload is None:
|
|
112
|
+
payload = payload_cache()
|
|
113
|
+
LOGGER.info("No payload was provided. Downloading into %s", payload)
|
|
114
|
+
if progress is not None:
|
|
115
|
+
progress("Downloading Cursor. This can take several minutes.")
|
|
116
|
+
downloaded = acquire_payload(choices, host, payload, progress=progress)
|
|
117
|
+
if downloaded is not None:
|
|
118
|
+
return downloaded
|
|
119
|
+
return _extract_payload(payload)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def install_payload(
|
|
123
|
+
choices: InstallChoices,
|
|
124
|
+
volume: Volume,
|
|
125
|
+
payload: Path | None,
|
|
126
|
+
*,
|
|
127
|
+
system: HostSystem | None = None,
|
|
128
|
+
progress: Progress | None = None,
|
|
129
|
+
) -> Failure | None:
|
|
130
|
+
"""Install after the wizard has already checked the label and filesystem."""
|
|
131
|
+
host = host_system_name() if system is None else system
|
|
132
|
+
refused = _refuse_unready(choices, volume, host)
|
|
133
|
+
if refused is not None:
|
|
134
|
+
return refused
|
|
135
|
+
if choices.profile is ProfileMode.COPY and choices.target_os is not host:
|
|
136
|
+
return Failure(
|
|
137
|
+
"profile",
|
|
138
|
+
"Settings from this computer are only copied onto a stick for the same system.",
|
|
139
|
+
)
|
|
140
|
+
opened = _acquire_payload(choices, host, payload, progress)
|
|
141
|
+
if isinstance(opened, Failure):
|
|
142
|
+
return opened
|
|
143
|
+
sources = None
|
|
144
|
+
if choices.profile.value == "copy":
|
|
145
|
+
loaded = profile_sources(host)
|
|
146
|
+
if isinstance(loaded, Failure):
|
|
147
|
+
return loaded
|
|
148
|
+
sources = loaded
|
|
149
|
+
return install_tree(
|
|
150
|
+
scope_dir(opened, choices.scope),
|
|
151
|
+
StickLayout.from_mount(Path(volume.mount_point)),
|
|
152
|
+
choices,
|
|
153
|
+
host,
|
|
154
|
+
sources=sources,
|
|
155
|
+
cursor_running=cursor_editor_running(),
|
|
156
|
+
python_source=opened / "python" if (opened / "python").is_dir() else None,
|
|
157
|
+
cli_source=opened / "cli" if (opened / "cli").is_dir() else None,
|
|
158
|
+
tool_source=opened / "tool" if (opened / "tool").is_dir() else None,
|
|
159
|
+
progress=progress,
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def wizard_callbacks(
|
|
164
|
+
volumes: tuple[Volume, ...] = (),
|
|
165
|
+
payload: Path | None = None,
|
|
166
|
+
system: HostSystem | None = None,
|
|
167
|
+
*,
|
|
168
|
+
probe: bool = False,
|
|
169
|
+
) -> WizardCallbacks:
|
|
170
|
+
"""Build wizard callbacks. Volumes default to the list the caller supplies."""
|
|
171
|
+
host = host_system_name() if system is None else system
|
|
172
|
+
|
|
173
|
+
def listed() -> tuple[Volume, ...] | Failure:
|
|
174
|
+
if volumes:
|
|
175
|
+
return volumes
|
|
176
|
+
if probe:
|
|
177
|
+
return load_probed_volumes(host)
|
|
178
|
+
return ()
|
|
179
|
+
|
|
180
|
+
callbacks_ref: list[WizardCallbacks] = []
|
|
181
|
+
|
|
182
|
+
def install(choices: InstallChoices, volume: Volume) -> Failure | None:
|
|
183
|
+
current = callbacks_ref[0].progress if callbacks_ref else None
|
|
184
|
+
return install_payload(choices, volume, payload, system=host, progress=current)
|
|
185
|
+
|
|
186
|
+
def run_format(plan: DiskPlan) -> Failure | None:
|
|
187
|
+
return _run_plan(plan)
|
|
188
|
+
|
|
189
|
+
def run_repair(plan: DiskPlan) -> Failure | None:
|
|
190
|
+
return apply_repair(plan)
|
|
191
|
+
|
|
192
|
+
built = WizardCallbacks(
|
|
193
|
+
volumes=listed,
|
|
194
|
+
busy=lambda volume: volume_busy(volume, host),
|
|
195
|
+
run_format=run_format,
|
|
196
|
+
run_repair=run_repair,
|
|
197
|
+
install=install,
|
|
198
|
+
system=host,
|
|
199
|
+
uninstall=uninstall_host,
|
|
200
|
+
)
|
|
201
|
+
callbacks_ref.append(built)
|
|
202
|
+
return built
|