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.
Files changed (81) hide show
  1. cursor_portable/__init__.py +5 -0
  2. cursor_portable/__main__.py +6 -0
  3. cursor_portable/_version.py +102 -0
  4. cursor_portable/cli/__init__.py +1 -0
  5. cursor_portable/cli/args.py +46 -0
  6. cursor_portable/cli/disk_cmd.py +142 -0
  7. cursor_portable/cli/launch_cmd.py +175 -0
  8. cursor_portable/cli/live.py +202 -0
  9. cursor_portable/cli/main.py +90 -0
  10. cursor_portable/constants.py +93 -0
  11. cursor_portable/disk/__init__.py +1 -0
  12. cursor_portable/disk/apply.py +59 -0
  13. cursor_portable/disk/commands.py +182 -0
  14. cursor_portable/disk/elevation.py +46 -0
  15. cursor_portable/disk/filesystem.py +152 -0
  16. cursor_portable/disk/guards.py +48 -0
  17. cursor_portable/disk/listing.py +266 -0
  18. cursor_portable/disk/probe.py +79 -0
  19. cursor_portable/disk/spawn.py +274 -0
  20. cursor_portable/disk/volume.py +118 -0
  21. cursor_portable/download/__init__.py +1 -0
  22. cursor_portable/download/catalog.py +62 -0
  23. cursor_portable/download/cli_script.py +111 -0
  24. cursor_portable/download/fetch.py +161 -0
  25. cursor_portable/download/http_client.py +71 -0
  26. cursor_portable/download/resolve.py +97 -0
  27. cursor_portable/download/sum_file.py +24 -0
  28. cursor_portable/download/verify_file.py +44 -0
  29. cursor_portable/failure.py +13 -0
  30. cursor_portable/host/__init__.py +1 -0
  31. cursor_portable/host/activity.py +50 -0
  32. cursor_portable/host/cleanup.py +121 -0
  33. cursor_portable/install/__init__.py +1 -0
  34. cursor_portable/install/acquire.py +635 -0
  35. cursor_portable/install/apply.py +243 -0
  36. cursor_portable/json_io.py +44 -0
  37. cursor_portable/launcher/__init__.py +1 -0
  38. cursor_portable/launcher/cache_sync.py +43 -0
  39. cursor_portable/launcher/headless.py +62 -0
  40. cursor_portable/launcher/home_link.py +115 -0
  41. cursor_portable/launcher/host_watch.py +240 -0
  42. cursor_portable/launcher/launch_plan.py +70 -0
  43. cursor_portable/launcher/links.py +60 -0
  44. cursor_portable/launcher/path_edit.py +100 -0
  45. cursor_portable/launcher/path_store.py +251 -0
  46. cursor_portable/launcher/processes.py +71 -0
  47. cursor_portable/launcher/profile_sync.py +63 -0
  48. cursor_portable/launcher/reports.py +51 -0
  49. cursor_portable/launcher/runner.py +54 -0
  50. cursor_portable/launcher/session.py +461 -0
  51. cursor_portable/launcher/session_file.py +82 -0
  52. cursor_portable/launcher/shortcuts.py +71 -0
  53. cursor_portable/logging_config.py +37 -0
  54. cursor_portable/models.py +63 -0
  55. cursor_portable/profile/__init__.py +1 -0
  56. cursor_portable/profile/copy_profile.py +148 -0
  57. cursor_portable/profile/settings.py +34 -0
  58. cursor_portable/profile/sources.py +57 -0
  59. cursor_portable/python_runtime/__init__.py +1 -0
  60. cursor_portable/python_runtime/pin.py +53 -0
  61. cursor_portable/repack/__init__.py +1 -0
  62. cursor_portable/repack/archive.py +50 -0
  63. cursor_portable/repack/commands.py +58 -0
  64. cursor_portable/repack/copy_tree.py +213 -0
  65. cursor_portable/repack/extract_ide.py +180 -0
  66. cursor_portable/repack/launchers.py +161 -0
  67. cursor_portable/repack/layout.py +68 -0
  68. cursor_portable/repack/meta.py +94 -0
  69. cursor_portable/repack/product_json.py +78 -0
  70. cursor_portable/repack/sfx.py +100 -0
  71. cursor_portable/runtime.py +12 -0
  72. cursor_portable/wizard/__init__.py +9 -0
  73. cursor_portable/wizard/__main__.py +22 -0
  74. cursor_portable/wizard/flow.py +196 -0
  75. cursor_portable/wizard/tcl_path.py +71 -0
  76. cursor_portable/wizard/window.py +906 -0
  77. cursor_portable-1.0.4.dist-info/METADATA +107 -0
  78. cursor_portable-1.0.4.dist-info/RECORD +81 -0
  79. cursor_portable-1.0.4.dist-info/WHEEL +4 -0
  80. cursor_portable-1.0.4.dist-info/entry_points.txt +3 -0
  81. cursor_portable-1.0.4.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,5 @@
1
+ """Portable Cursor installer and launcher."""
2
+
3
+ from cursor_portable._version import __version__
4
+
5
+ __all__ = ["__version__"]
@@ -0,0 +1,6 @@
1
+ """``python -m cursor_portable``."""
2
+
3
+ from cursor_portable.cli.main import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
@@ -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