dosctl 0.1.0__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.
- dosctl/__init__.py +0 -0
- dosctl/collections/__init__.py +0 -0
- dosctl/collections/archive_org.py +227 -0
- dosctl/collections/base.py +21 -0
- dosctl/collections/factory.py +35 -0
- dosctl/commands/delete.py +42 -0
- dosctl/commands/inspect.py +47 -0
- dosctl/commands/list.py +25 -0
- dosctl/commands/refresh.py +25 -0
- dosctl/commands/run.py +119 -0
- dosctl/commands/search.py +47 -0
- dosctl/config.py +23 -0
- dosctl/lib/__init__.py +0 -0
- dosctl/lib/config_store.py +44 -0
- dosctl/lib/decorators.py +23 -0
- dosctl/lib/display.py +19 -0
- dosctl/lib/executables.py +20 -0
- dosctl/lib/game.py +32 -0
- dosctl/lib/system.py +5 -0
- dosctl/lib/ui.py +405 -0
- dosctl/main.py +22 -0
- dosctl-0.1.0.dist-info/METADATA +238 -0
- dosctl-0.1.0.dist-info/RECORD +27 -0
- dosctl-0.1.0.dist-info/WHEEL +5 -0
- dosctl-0.1.0.dist-info/entry_points.txt +2 -0
- dosctl-0.1.0.dist-info/licenses/LICENSE +19 -0
- dosctl-0.1.0.dist-info/top_level.txt +1 -0
dosctl/__init__.py
ADDED
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import re
|
|
3
|
+
import requests
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import List, Dict, Optional
|
|
6
|
+
import zipfile
|
|
7
|
+
from urllib.parse import quote, unquote
|
|
8
|
+
from tqdm import tqdm
|
|
9
|
+
|
|
10
|
+
from .base import BaseCollection
|
|
11
|
+
|
|
12
|
+
class ArchiveOrgCollection(BaseCollection):
|
|
13
|
+
"""
|
|
14
|
+
Base class for Archive.org collection backends.
|
|
15
|
+
Handles common functionality like downloading, caching, and parsing.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __init__(self, source: str, cache_dir: str, collection_name: str):
|
|
19
|
+
super().__init__(source)
|
|
20
|
+
self.cache_dir = Path(cache_dir)
|
|
21
|
+
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
|
22
|
+
self.collection_name = collection_name
|
|
23
|
+
self._games_data: List[Dict] = []
|
|
24
|
+
|
|
25
|
+
# The download URL for a file is different from the source URL of the list.
|
|
26
|
+
# We derive the base download URL from the source URL's item name.
|
|
27
|
+
# e.g., https://.../items/ITEM_NAME/file.txt -> https://archive.org/download/ITEM_NAME
|
|
28
|
+
self.item_name = self.source.split("/")[-2]
|
|
29
|
+
self.download_base_url = f"https://archive.org/download/{self.item_name}"
|
|
30
|
+
|
|
31
|
+
def ensure_cache_is_present(self, force_refresh: bool = False) -> None:
|
|
32
|
+
"""
|
|
33
|
+
Ensures the game list cache exists, downloading it if it's missing or
|
|
34
|
+
if a refresh is forced.
|
|
35
|
+
"""
|
|
36
|
+
cache_file = self.cache_dir / "games.txt"
|
|
37
|
+
if force_refresh or not cache_file.exists():
|
|
38
|
+
print(f"Downloading game list from {self.source}...")
|
|
39
|
+
headers = {'User-Agent': 'Mozilla/5.0'}
|
|
40
|
+
response = requests.get(self.source, headers=headers)
|
|
41
|
+
response.raise_for_status()
|
|
42
|
+
with open(cache_file, "w", encoding="utf-8") as f:
|
|
43
|
+
f.write(response.text)
|
|
44
|
+
print("✅ Game list refreshed successfully.")
|
|
45
|
+
|
|
46
|
+
def load(self, force_refresh: bool = False) -> None:
|
|
47
|
+
self.ensure_cache_is_present(force_refresh=force_refresh)
|
|
48
|
+
self._populate_games_data()
|
|
49
|
+
|
|
50
|
+
def _parse_filename(self, filename: str) -> Dict:
|
|
51
|
+
"""
|
|
52
|
+
Parses a filename to extract the year and a clean name.
|
|
53
|
+
Can be overridden by subclasses for different parsing logic.
|
|
54
|
+
"""
|
|
55
|
+
name_part = filename.replace(".zip", "")
|
|
56
|
+
year = None
|
|
57
|
+
|
|
58
|
+
# Try to find a year like (1995) in the name
|
|
59
|
+
match = re.search(r'\(([0-9]{4})\)', name_part)
|
|
60
|
+
if match:
|
|
61
|
+
year = match.group(1)
|
|
62
|
+
|
|
63
|
+
return {"name": name_part, "year": year}
|
|
64
|
+
|
|
65
|
+
def _populate_games_data(self) -> None:
|
|
66
|
+
cache_file = self.cache_dir / "games.txt"
|
|
67
|
+
if not cache_file.exists():
|
|
68
|
+
return
|
|
69
|
+
|
|
70
|
+
self._games_data = []
|
|
71
|
+
with open(cache_file, "r", encoding="utf-8") as f:
|
|
72
|
+
content = f.read()
|
|
73
|
+
|
|
74
|
+
zip_hrefs = re.findall(r'href="(.+?\.zip)"', content)
|
|
75
|
+
|
|
76
|
+
for href in zip_hrefs:
|
|
77
|
+
encoded_path = href.split("/")[-1]
|
|
78
|
+
full_path = unquote(encoded_path)
|
|
79
|
+
|
|
80
|
+
filename_with_ext = Path(full_path).name
|
|
81
|
+
|
|
82
|
+
# Use the parser (can be overridden by subclasses)
|
|
83
|
+
parsed_details = self._parse_filename(filename_with_ext)
|
|
84
|
+
|
|
85
|
+
# The game's unique ID is a short, stable hash of its full path.
|
|
86
|
+
# This prevents collisions and ensures the ID is always the same.
|
|
87
|
+
game_hash = hashlib.sha1(full_path.encode()).hexdigest()
|
|
88
|
+
game_id = game_hash[:8]
|
|
89
|
+
|
|
90
|
+
self._games_data.append({
|
|
91
|
+
"id": game_id,
|
|
92
|
+
"name": parsed_details["name"],
|
|
93
|
+
"year": parsed_details["year"],
|
|
94
|
+
"full_path": full_path,
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
def get_games(self) -> List[Dict]:
|
|
98
|
+
if not self._games_data:
|
|
99
|
+
self._populate_games_data()
|
|
100
|
+
return self._games_data
|
|
101
|
+
|
|
102
|
+
def _find_game(self, game_id: str) -> Optional[Dict]:
|
|
103
|
+
if not self._games_data:
|
|
104
|
+
self._populate_games_data()
|
|
105
|
+
for game in self._games_data:
|
|
106
|
+
if game["id"] == game_id:
|
|
107
|
+
return game
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
def get_download_url(self, game_id: str) -> Optional[str]:
|
|
111
|
+
"""
|
|
112
|
+
Constructs the download URL for a specific game.
|
|
113
|
+
Can be overridden by subclasses for different URL patterns.
|
|
114
|
+
"""
|
|
115
|
+
game = self._find_game(game_id)
|
|
116
|
+
if not game:
|
|
117
|
+
return None
|
|
118
|
+
|
|
119
|
+
# The download URL is constructed from the base item name and the full path
|
|
120
|
+
encoded_full_path = quote(game["full_path"])
|
|
121
|
+
return self._build_download_url(encoded_full_path)
|
|
122
|
+
|
|
123
|
+
def _build_download_url(self, encoded_full_path: str) -> str:
|
|
124
|
+
"""
|
|
125
|
+
Builds the actual download URL. Override this in subclasses for different URL patterns.
|
|
126
|
+
"""
|
|
127
|
+
raise NotImplementedError("Subclasses must implement _build_download_url")
|
|
128
|
+
|
|
129
|
+
def download_game(self, game_id: str, destination: str, force: bool = False) -> None:
|
|
130
|
+
download_url = self.get_download_url(game_id)
|
|
131
|
+
if not download_url:
|
|
132
|
+
raise FileNotFoundError(f"Game with ID '{game_id}' not found.")
|
|
133
|
+
|
|
134
|
+
game = self._find_game(game_id)
|
|
135
|
+
destination_path = Path(destination)
|
|
136
|
+
destination_path.mkdir(parents=True, exist_ok=True)
|
|
137
|
+
|
|
138
|
+
filename = game["name"] + ".zip"
|
|
139
|
+
local_zip_path = destination_path / filename
|
|
140
|
+
|
|
141
|
+
if local_zip_path.exists() and not force:
|
|
142
|
+
print(f"'{filename}' already exists in '{destination_path}'. Use --force to overwrite.")
|
|
143
|
+
return local_zip_path
|
|
144
|
+
|
|
145
|
+
headers = {'User-Agent': 'Mozilla/5.0'}
|
|
146
|
+
|
|
147
|
+
# Use a try...except block for more specific error handling
|
|
148
|
+
try:
|
|
149
|
+
with requests.get(download_url, headers=headers, stream=True) as r:
|
|
150
|
+
r.raise_for_status()
|
|
151
|
+
|
|
152
|
+
total_size = int(r.headers.get('content-length', 0))
|
|
153
|
+
|
|
154
|
+
with tqdm.wrapattr(open(local_zip_path, "wb"), "write",
|
|
155
|
+
miniters=1,
|
|
156
|
+
total=total_size,
|
|
157
|
+
desc=f"Downloading '{filename}'") as fout:
|
|
158
|
+
for chunk in r.iter_content(chunk_size=8192):
|
|
159
|
+
fout.write(chunk)
|
|
160
|
+
|
|
161
|
+
print(f"Successfully downloaded '{filename}'")
|
|
162
|
+
except requests.exceptions.RequestException as e:
|
|
163
|
+
print(f"\nError downloading '{filename}': {e}")
|
|
164
|
+
# Clean up partially downloaded file
|
|
165
|
+
if local_zip_path.exists():
|
|
166
|
+
local_zip_path.unlink()
|
|
167
|
+
return None
|
|
168
|
+
|
|
169
|
+
return local_zip_path
|
|
170
|
+
|
|
171
|
+
def unzip_game(self, game_id: str, download_path: Path, install_path: Path) -> None:
|
|
172
|
+
"""
|
|
173
|
+
Unzips a downloaded game to a specified installation directory.
|
|
174
|
+
"""
|
|
175
|
+
game = self._find_game(game_id)
|
|
176
|
+
if not game:
|
|
177
|
+
raise FileNotFoundError(f"Game with ID '{game_id}' not found.")
|
|
178
|
+
|
|
179
|
+
zip_filename = game["name"] + ".zip"
|
|
180
|
+
zip_filepath = download_path / zip_filename
|
|
181
|
+
|
|
182
|
+
if not zip_filepath.exists():
|
|
183
|
+
raise FileNotFoundError(f"Downloaded game zip not found at '{zip_filepath}'")
|
|
184
|
+
|
|
185
|
+
print(f"Unzipping '{zip_filename}' to '{install_path}'...")
|
|
186
|
+
with zipfile.ZipFile(zip_filepath, 'r') as zip_ref:
|
|
187
|
+
zip_ref.extractall(install_path)
|
|
188
|
+
print("Unzip complete.")
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class TotalDOSCollectionRelease14(ArchiveOrgCollection):
|
|
192
|
+
"""
|
|
193
|
+
Specific implementation for Total DOS Collection Release 14.
|
|
194
|
+
"""
|
|
195
|
+
|
|
196
|
+
def __init__(self, source: str, cache_dir: str):
|
|
197
|
+
super().__init__(source, cache_dir, "Total DOS Collection Release 14")
|
|
198
|
+
|
|
199
|
+
def _build_download_url(self, encoded_full_path: str) -> str:
|
|
200
|
+
"""
|
|
201
|
+
Builds the download URL specific to TDC Release 14 structure.
|
|
202
|
+
"""
|
|
203
|
+
return f"https://archive.org/download/{self.item_name}/TDC_Release_14.zip/{encoded_full_path}"
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
# Example of how to add a new version:
|
|
207
|
+
#
|
|
208
|
+
# class TotalDOSCollectionRelease15(ArchiveOrgCollection):
|
|
209
|
+
# """
|
|
210
|
+
# Specific implementation for Total DOS Collection Release 15.
|
|
211
|
+
# """
|
|
212
|
+
#
|
|
213
|
+
# def __init__(self, source: str, cache_dir: str):
|
|
214
|
+
# super().__init__(source, cache_dir, "Total DOS Collection Release 15")
|
|
215
|
+
#
|
|
216
|
+
# def _build_download_url(self, encoded_full_path: str) -> str:
|
|
217
|
+
# """
|
|
218
|
+
# Builds the download URL specific to TDC Release 15 structure.
|
|
219
|
+
# """
|
|
220
|
+
# return f"https://archive.org/download/{self.item_name}/TDC_Release_15.zip/{encoded_full_path}"
|
|
221
|
+
#
|
|
222
|
+
# def _parse_filename(self, filename: str) -> Dict:
|
|
223
|
+
# """
|
|
224
|
+
# Override if Release 15 has different filename patterns.
|
|
225
|
+
# """
|
|
226
|
+
# # Custom parsing logic for Release 15 if needed
|
|
227
|
+
# return super()._parse_filename(filename)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from typing import List, Dict
|
|
3
|
+
|
|
4
|
+
class BaseCollection(ABC):
|
|
5
|
+
"""
|
|
6
|
+
Abstract base class for a game collection.
|
|
7
|
+
"""
|
|
8
|
+
def __init__(self, source: str):
|
|
9
|
+
self.source = source
|
|
10
|
+
|
|
11
|
+
@abstractmethod
|
|
12
|
+
def load(self) -> None:
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
@abstractmethod
|
|
16
|
+
def get_games(self) -> List[Dict]:
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
@abstractmethod
|
|
20
|
+
def download_game(self, game_name: str, destination: str) -> None:
|
|
21
|
+
pass
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Collection factory for creating appropriate collection instances."""
|
|
2
|
+
from .archive_org import TotalDOSCollectionRelease14
|
|
3
|
+
|
|
4
|
+
# Registry of available collection implementations
|
|
5
|
+
COLLECTION_REGISTRY = {
|
|
6
|
+
"tdc_release_14": TotalDOSCollectionRelease14,
|
|
7
|
+
# Future versions can be added here:
|
|
8
|
+
# "tdc_release_15": TotalDOSCollectionRelease15,
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
def create_collection(collection_type: str, source: str, cache_dir: str):
|
|
12
|
+
"""
|
|
13
|
+
Factory function to create collection instances.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
collection_type: The type of collection (e.g., "tdc_release_14")
|
|
17
|
+
source: The source URL for the collection
|
|
18
|
+
cache_dir: Directory for caching collection data
|
|
19
|
+
|
|
20
|
+
Returns:
|
|
21
|
+
An instance of the appropriate collection class
|
|
22
|
+
|
|
23
|
+
Raises:
|
|
24
|
+
ValueError: If the collection_type is not supported
|
|
25
|
+
"""
|
|
26
|
+
if collection_type not in COLLECTION_REGISTRY:
|
|
27
|
+
available = ", ".join(COLLECTION_REGISTRY.keys())
|
|
28
|
+
raise ValueError(f"Unknown collection type '{collection_type}'. Available: {available}")
|
|
29
|
+
|
|
30
|
+
collection_class = COLLECTION_REGISTRY[collection_type]
|
|
31
|
+
return collection_class(source, cache_dir)
|
|
32
|
+
|
|
33
|
+
def get_available_collections():
|
|
34
|
+
"""Returns a list of available collection types."""
|
|
35
|
+
return list(COLLECTION_REGISTRY.keys())
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import click
|
|
2
|
+
import shutil
|
|
3
|
+
from dosctl.config import INSTALLED_DIR, DOWNLOADS_DIR
|
|
4
|
+
from dosctl.lib.decorators import ensure_cache
|
|
5
|
+
|
|
6
|
+
@click.command()
|
|
7
|
+
@click.argument('game_id')
|
|
8
|
+
@ensure_cache
|
|
9
|
+
def delete(collection, game_id):
|
|
10
|
+
"""
|
|
11
|
+
Deletes an installed game.
|
|
12
|
+
"""
|
|
13
|
+
game_install_path = INSTALLED_DIR / game_id
|
|
14
|
+
|
|
15
|
+
if not game_install_path.exists() or not game_install_path.is_dir():
|
|
16
|
+
click.echo(f"Error: Game with ID '{game_id}' is not installed.", err=True)
|
|
17
|
+
return
|
|
18
|
+
|
|
19
|
+
game = collection._find_game(game_id)
|
|
20
|
+
game_name = game['name'] if game else f"Game ID {game_id}"
|
|
21
|
+
downloaded_zip = DOWNLOADS_DIR / f"{game_name}.zip"
|
|
22
|
+
|
|
23
|
+
click.echo(f"You are about to delete the files for '{game_name}'.")
|
|
24
|
+
click.echo(f"Installation: {game_install_path}")
|
|
25
|
+
if downloaded_zip.exists():
|
|
26
|
+
click.echo(f"Downloaded Archive: {downloaded_zip}")
|
|
27
|
+
|
|
28
|
+
if not click.confirm("Are you sure you want to continue?"):
|
|
29
|
+
click.echo("Deletion cancelled.")
|
|
30
|
+
return
|
|
31
|
+
|
|
32
|
+
try:
|
|
33
|
+
if game_install_path.exists():
|
|
34
|
+
shutil.rmtree(game_install_path)
|
|
35
|
+
click.echo(f"✅ Successfully deleted installation directory.")
|
|
36
|
+
|
|
37
|
+
if downloaded_zip.exists():
|
|
38
|
+
downloaded_zip.unlink()
|
|
39
|
+
click.echo(f"✅ Successfully deleted downloaded archive.")
|
|
40
|
+
|
|
41
|
+
except Exception as e:
|
|
42
|
+
click.echo(f"An error occurred during deletion: {e}", err=True)
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import click
|
|
2
|
+
from dosctl.config import INSTALLED_DIR
|
|
3
|
+
from dosctl.lib.decorators import ensure_cache
|
|
4
|
+
|
|
5
|
+
@click.command()
|
|
6
|
+
@click.argument('game_id')
|
|
7
|
+
@click.option('-e', '--executables', is_flag=True, help='Show only executable files (.exe, .com, .bat).')
|
|
8
|
+
@ensure_cache
|
|
9
|
+
def inspect(collection, game_id, executables):
|
|
10
|
+
"""
|
|
11
|
+
Inspects the installed files for a given game.
|
|
12
|
+
"""
|
|
13
|
+
game_install_path = INSTALLED_DIR / game_id
|
|
14
|
+
|
|
15
|
+
if not game_install_path.exists() or not game_install_path.is_dir():
|
|
16
|
+
click.echo(f"Error: Game with ID '{game_id}' is not installed.", err=True)
|
|
17
|
+
return
|
|
18
|
+
|
|
19
|
+
game = collection._find_game(game_id)
|
|
20
|
+
game_name = game['name'] if game else "Unknown Game"
|
|
21
|
+
|
|
22
|
+
click.echo(f"Inspecting files for '{game_name}' (ID: {game_id})")
|
|
23
|
+
click.echo(f"Location: {game_install_path}")
|
|
24
|
+
click.echo("-" * 40)
|
|
25
|
+
|
|
26
|
+
# Use rglob to list all files recursively
|
|
27
|
+
files = sorted([f for f in game_install_path.rglob('*') if f.is_file()])
|
|
28
|
+
|
|
29
|
+
if executables:
|
|
30
|
+
# Filter to only executable file extensions
|
|
31
|
+
executable_extensions = {'.exe', '.com', '.bat'}
|
|
32
|
+
files = [f for f in files if f.suffix.lower() in executable_extensions]
|
|
33
|
+
|
|
34
|
+
if not files:
|
|
35
|
+
if executables:
|
|
36
|
+
click.echo("No executable files found in the installation directory.")
|
|
37
|
+
else:
|
|
38
|
+
click.echo("No files found in the installation directory.")
|
|
39
|
+
return
|
|
40
|
+
|
|
41
|
+
if executables:
|
|
42
|
+
click.echo("Executable files:")
|
|
43
|
+
|
|
44
|
+
for file_path in files:
|
|
45
|
+
# Get the path relative to the game's install directory
|
|
46
|
+
relative_path = file_path.relative_to(game_install_path)
|
|
47
|
+
click.echo(f" {relative_path}")
|
dosctl/commands/list.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import click
|
|
2
|
+
from dosctl.lib.decorators import ensure_cache
|
|
3
|
+
from dosctl.lib.display import sort_games, display_games
|
|
4
|
+
from dosctl.config import INSTALLED_DIR
|
|
5
|
+
|
|
6
|
+
@click.command(name="list")
|
|
7
|
+
@click.option('-s', '--sort-by', type=click.Choice(['name', 'year'], case_sensitive=False), default='name', help='Sort the list by name or year.')
|
|
8
|
+
@click.option('-i', '--installed', is_flag=True, default=False, help='Only list installed games.')
|
|
9
|
+
@ensure_cache
|
|
10
|
+
def list_games(collection, sort_by, installed):
|
|
11
|
+
"""Lists all available games from the local cache."""
|
|
12
|
+
|
|
13
|
+
games = collection.get_games()
|
|
14
|
+
|
|
15
|
+
if installed:
|
|
16
|
+
installed_ids = {p.name for p in INSTALLED_DIR.iterdir() if p.is_dir()}
|
|
17
|
+
games = [g for g in games if g['id'] in installed_ids]
|
|
18
|
+
|
|
19
|
+
if not games:
|
|
20
|
+
message = "No games are currently installed." if installed else "No games found in cache."
|
|
21
|
+
click.echo(message)
|
|
22
|
+
return
|
|
23
|
+
|
|
24
|
+
games = sort_games(games, sort_by)
|
|
25
|
+
display_games(games)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import click
|
|
2
|
+
from dosctl.collections.archive_org import TotalDOSCollectionRelease14
|
|
3
|
+
from dosctl.config import DEFAULT_COLLECTION_SOURCE, COLLECTION_CACHE_DIR, ensure_dirs_exist
|
|
4
|
+
|
|
5
|
+
@click.command()
|
|
6
|
+
@click.option('--force', is_flag=True, default=False, help='Force a re-download of the game list.')
|
|
7
|
+
def refresh(force):
|
|
8
|
+
"""
|
|
9
|
+
Downloads the latest game list from the collection source.
|
|
10
|
+
"""
|
|
11
|
+
if not force:
|
|
12
|
+
click.echo("This command will re-download the entire game list.")
|
|
13
|
+
click.echo("Use 'dosctl refresh --force' to confirm.")
|
|
14
|
+
return
|
|
15
|
+
|
|
16
|
+
click.echo("Ensuring application directories exist...")
|
|
17
|
+
ensure_dirs_exist()
|
|
18
|
+
|
|
19
|
+
collection = TotalDOSCollectionRelease14(
|
|
20
|
+
source=DEFAULT_COLLECTION_SOURCE,
|
|
21
|
+
cache_dir=COLLECTION_CACHE_DIR
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
click.echo("Forcing a refresh of the game lists...")
|
|
25
|
+
collection.ensure_cache_is_present(force_refresh=True)
|
dosctl/commands/run.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import click
|
|
2
|
+
import subprocess
|
|
3
|
+
import textwrap
|
|
4
|
+
from dosctl.lib.decorators import ensure_cache
|
|
5
|
+
from dosctl.lib.game import install_game
|
|
6
|
+
from dosctl.lib.config_store import get_game_command, set_game_command
|
|
7
|
+
from dosctl.lib.system import is_dosbox_installed
|
|
8
|
+
from dosctl.lib.executables import find_executables, executable_exists
|
|
9
|
+
|
|
10
|
+
@click.command()
|
|
11
|
+
@click.argument('game_id')
|
|
12
|
+
@click.argument('command_parts', nargs=-1)
|
|
13
|
+
@click.option('-c', '--configure', is_flag=True, default=False, help='Force re-selection of the default executable.')
|
|
14
|
+
@ensure_cache
|
|
15
|
+
def run(collection, game_id, command_parts, configure):
|
|
16
|
+
"""
|
|
17
|
+
Runs a game. Prompts for an executable on the first run or when --configure is used.
|
|
18
|
+
"""
|
|
19
|
+
if not is_dosbox_installed():
|
|
20
|
+
help_text = textwrap.dedent("""
|
|
21
|
+
Error: 'dosbox' command not found in your PATH.
|
|
22
|
+
|
|
23
|
+
Please install DOSBox. We recommend DOSBox Staging for the best experience.
|
|
24
|
+
|
|
25
|
+
To install with Homebrew on macOS:
|
|
26
|
+
brew install dosbox # For standard DOSBox
|
|
27
|
+
brew install dosbox-staging # For DOSBox Staging (recommended)
|
|
28
|
+
|
|
29
|
+
If you install DOSBox Staging, you may need to create a symlink so `dosctl` can find it:
|
|
30
|
+
ln -s "$(brew --prefix dosbox-staging)/bin/dosbox-staging" ~/.local/bin/dosbox
|
|
31
|
+
""")
|
|
32
|
+
click.echo(help_text, err=True)
|
|
33
|
+
return
|
|
34
|
+
|
|
35
|
+
try:
|
|
36
|
+
_, game_install_path = install_game(collection, game_id)
|
|
37
|
+
if not game_install_path:
|
|
38
|
+
return
|
|
39
|
+
|
|
40
|
+
# Determine the command to run
|
|
41
|
+
chosen_command_str = _get_or_prompt_command(game_id, game_install_path, command_parts, configure)
|
|
42
|
+
if not chosen_command_str:
|
|
43
|
+
return
|
|
44
|
+
|
|
45
|
+
# Save the chosen command for future runs
|
|
46
|
+
set_game_command(game_id, chosen_command_str)
|
|
47
|
+
|
|
48
|
+
# Validate that the chosen executable exists
|
|
49
|
+
executable_name = chosen_command_str.split()[0]
|
|
50
|
+
if not executable_exists(game_install_path, executable_name):
|
|
51
|
+
click.echo(f"Error: Executable '{executable_name}' not found.", err=True)
|
|
52
|
+
set_game_command(game_id, None)
|
|
53
|
+
return
|
|
54
|
+
|
|
55
|
+
# Launch the game
|
|
56
|
+
_launch_game(game_install_path, chosen_command_str)
|
|
57
|
+
|
|
58
|
+
except FileNotFoundError as e:
|
|
59
|
+
click.echo(f"Error: {e}", err=True)
|
|
60
|
+
except Exception as e:
|
|
61
|
+
click.echo(f"An unexpected error occurred: {e}", err=True)
|
|
62
|
+
|
|
63
|
+
def _get_or_prompt_command(game_id, game_install_path, command_parts, configure):
|
|
64
|
+
"""Get the command to run, prompting if necessary."""
|
|
65
|
+
if configure:
|
|
66
|
+
return _prompt_for_executable(game_install_path, configure)
|
|
67
|
+
|
|
68
|
+
# If user provides command on CLI, use it
|
|
69
|
+
if command_parts:
|
|
70
|
+
return " ".join(command_parts)
|
|
71
|
+
|
|
72
|
+
# Check for saved command
|
|
73
|
+
saved_command = get_game_command(game_id)
|
|
74
|
+
if saved_command:
|
|
75
|
+
return saved_command
|
|
76
|
+
|
|
77
|
+
# First run - prompt for executable
|
|
78
|
+
click.echo(f"No default executable set for game '{game_id}'. Searching...")
|
|
79
|
+
return _prompt_for_executable(game_install_path, configure)
|
|
80
|
+
|
|
81
|
+
def _prompt_for_executable(game_install_path, force_menu=False):
|
|
82
|
+
"""Prompt user to select an executable."""
|
|
83
|
+
executables = find_executables(game_install_path)
|
|
84
|
+
|
|
85
|
+
if not executables:
|
|
86
|
+
click.echo(f"Error: No executables (.exe, .com, .bat) found in the archive.", err=True)
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
# If there's only one option and we're not forcing the menu, choose it automatically
|
|
90
|
+
if len(executables) == 1 and not force_menu:
|
|
91
|
+
click.echo(f"Found a single executable: '{executables[0]}'. Setting as default.")
|
|
92
|
+
return executables[0]
|
|
93
|
+
|
|
94
|
+
# Show menu for user selection
|
|
95
|
+
while True:
|
|
96
|
+
menu_items = [f" {i}: {exe_name.upper()}" for i, exe_name in enumerate(executables, 1)]
|
|
97
|
+
click.echo("Please choose one of the following to run:")
|
|
98
|
+
click.echo("\n".join(menu_items))
|
|
99
|
+
|
|
100
|
+
choice = click.prompt("Select a file to execute", type=int)
|
|
101
|
+
if 1 <= choice <= len(executables):
|
|
102
|
+
return executables[choice - 1]
|
|
103
|
+
else:
|
|
104
|
+
click.echo("Invalid choice. Please try again.", err=True)
|
|
105
|
+
|
|
106
|
+
def _launch_game(game_install_path, chosen_command_str):
|
|
107
|
+
"""Launch the game with DOSBox."""
|
|
108
|
+
click.echo(f"Starting '{chosen_command_str.upper()}' with DOSBox...")
|
|
109
|
+
|
|
110
|
+
command = [
|
|
111
|
+
'dosbox',
|
|
112
|
+
'-c', f'MOUNT C "{game_install_path}"',
|
|
113
|
+
'-c', 'C:',
|
|
114
|
+
'-c', chosen_command_str
|
|
115
|
+
]
|
|
116
|
+
|
|
117
|
+
subprocess.Popen(command,
|
|
118
|
+
stdout=subprocess.DEVNULL,
|
|
119
|
+
stderr=subprocess.DEVNULL)
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import click
|
|
2
|
+
import re
|
|
3
|
+
from dosctl.lib.decorators import ensure_cache
|
|
4
|
+
from dosctl.lib.display import sort_games, display_games
|
|
5
|
+
|
|
6
|
+
@click.command()
|
|
7
|
+
@click.argument('query', required=False)
|
|
8
|
+
@click.option('-c', '--case-sensitive', is_flag=True, default=False, help='Perform a case-sensitive search.')
|
|
9
|
+
@click.option('-y', '--year', type=int, help='Filter search results by a specific year.')
|
|
10
|
+
@click.option('-s', '--sort-by', type=click.Choice(['name', 'year'], case_sensitive=False), default='name', help='Sort the results by name or year.')
|
|
11
|
+
@ensure_cache
|
|
12
|
+
def search(collection, query, case_sensitive, year, sort_by):
|
|
13
|
+
"""
|
|
14
|
+
Searches for games in the local cache.
|
|
15
|
+
"""
|
|
16
|
+
if not query and not year:
|
|
17
|
+
click.echo("Error: You must provide a search query or a year.", err=True)
|
|
18
|
+
return
|
|
19
|
+
|
|
20
|
+
games = collection.get_games()
|
|
21
|
+
|
|
22
|
+
if not games:
|
|
23
|
+
click.echo("No games found in cache.")
|
|
24
|
+
return
|
|
25
|
+
|
|
26
|
+
# Search Logic
|
|
27
|
+
results = []
|
|
28
|
+
flags = 0 if case_sensitive else re.IGNORECASE
|
|
29
|
+
|
|
30
|
+
for game in games:
|
|
31
|
+
# Year filter
|
|
32
|
+
if year and str(game.get('year')) != str(year):
|
|
33
|
+
continue
|
|
34
|
+
|
|
35
|
+
# Name filter (only if query is provided)
|
|
36
|
+
if query and not re.search(query, game['name'], flags):
|
|
37
|
+
continue
|
|
38
|
+
|
|
39
|
+
results.append(game)
|
|
40
|
+
|
|
41
|
+
# Display Logic
|
|
42
|
+
if not results:
|
|
43
|
+
click.echo("No games found matching your criteria.")
|
|
44
|
+
return
|
|
45
|
+
|
|
46
|
+
results = sort_games(results, sort_by)
|
|
47
|
+
display_games(results, f"Found {len(results)} game(s):")
|
dosctl/config.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
# Define the base directories for config and data
|
|
4
|
+
CONFIG_DIR = Path.home() / ".config" / "dosctl"
|
|
5
|
+
DATA_DIR = Path.home() / ".local" / "share" / "dosctl"
|
|
6
|
+
|
|
7
|
+
# Define the specific cache directory for collections
|
|
8
|
+
COLLECTION_CACHE_DIR = DATA_DIR / "collections"
|
|
9
|
+
|
|
10
|
+
# Define game-specific directories
|
|
11
|
+
DOWNLOADS_DIR = DATA_DIR / "downloads"
|
|
12
|
+
INSTALLED_DIR = DATA_DIR / "installed"
|
|
13
|
+
|
|
14
|
+
# Define the default source URL for the main game collection
|
|
15
|
+
DEFAULT_COLLECTION_SOURCE = "https://ia800906.us.archive.org/view_archive.php?archive=/4/items/Total_DOS_Collection_Release_14/TDC_Release_14.zip"
|
|
16
|
+
|
|
17
|
+
def ensure_dirs_exist():
|
|
18
|
+
"""Create the config and data directories if they don't exist."""
|
|
19
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
20
|
+
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
|
21
|
+
COLLECTION_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
|
22
|
+
DOWNLOADS_DIR.mkdir(parents=True, exist_ok=True)
|
|
23
|
+
INSTALLED_DIR.mkdir(parents=True, exist_ok=True)
|
dosctl/lib/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from dosctl.config import CONFIG_DIR
|
|
4
|
+
|
|
5
|
+
CONFIG_FILE = CONFIG_DIR / "run_config.json"
|
|
6
|
+
|
|
7
|
+
def load_run_config() -> dict:
|
|
8
|
+
"""Loads the executable configuration file."""
|
|
9
|
+
if not CONFIG_FILE.exists():
|
|
10
|
+
return {}
|
|
11
|
+
try:
|
|
12
|
+
with open(CONFIG_FILE, "r") as f:
|
|
13
|
+
return json.load(f)
|
|
14
|
+
except (json.JSONDecodeError, OSError):
|
|
15
|
+
# If the file is corrupted or unreadable, treat it as empty
|
|
16
|
+
return {}
|
|
17
|
+
|
|
18
|
+
def save_run_config(config: dict) -> None:
|
|
19
|
+
"""Saves the executable configuration file."""
|
|
20
|
+
# Ensure the config directory exists
|
|
21
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
22
|
+
|
|
23
|
+
try:
|
|
24
|
+
with open(CONFIG_FILE, "w") as f:
|
|
25
|
+
json.dump(config, f, indent=2)
|
|
26
|
+
except OSError as e:
|
|
27
|
+
# Handle write errors gracefully
|
|
28
|
+
print(f"Warning: Could not save configuration: {e}")
|
|
29
|
+
|
|
30
|
+
def get_game_command(game_id: str) -> str | None:
|
|
31
|
+
"""Gets the saved command for a specific game."""
|
|
32
|
+
config = load_run_config()
|
|
33
|
+
return config.get(game_id)
|
|
34
|
+
|
|
35
|
+
def set_game_command(game_id: str, command: str | None) -> None:
|
|
36
|
+
"""Saves the chosen command for a specific game. If command is None, removes the entry."""
|
|
37
|
+
config = load_run_config()
|
|
38
|
+
|
|
39
|
+
if command is None:
|
|
40
|
+
config.pop(game_id, None) # Remove entry if exists
|
|
41
|
+
else:
|
|
42
|
+
config[game_id] = command
|
|
43
|
+
|
|
44
|
+
save_run_config(config)
|