sshler 0.3.2__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.
sshler/__init__.py ADDED
@@ -0,0 +1,10 @@
1
+ """Package metadata export for sshler."""
2
+
3
+ from importlib import metadata
4
+
5
+ __all__ = ["__version__"]
6
+
7
+ try:
8
+ __version__ = metadata.version("sshler")
9
+ except metadata.PackageNotFoundError: # pragma: no cover - fallback for editable installs
10
+ __version__ = "0.0.0"
sshler/cli.py ADDED
@@ -0,0 +1,161 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import secrets
5
+ import threading
6
+ import webbrowser
7
+
8
+ import uvicorn
9
+
10
+ from .webapp import ServerSettings, make_app
11
+
12
+
13
+ # open the user's browser after uvicorn starts listening
14
+ def _open_browser_later(application_url: str, delay: float = 0.8) -> None:
15
+ def open_browser() -> None:
16
+ try:
17
+ webbrowser.open(application_url)
18
+ except Exception:
19
+ pass
20
+
21
+ timer = threading.Timer(delay, open_browser)
22
+ timer.daemon = True
23
+ timer.start()
24
+
25
+
26
+ def serve(
27
+ host: str = "127.0.0.1",
28
+ port: int = 8822,
29
+ reload: bool = False,
30
+ allow_origins: list[str] | None = None,
31
+ basic_auth: tuple[str, str] | None = None,
32
+ max_upload_mb: int = 50,
33
+ allow_ssh_alias: bool = True,
34
+ log_level: str = "info",
35
+ open_browser: bool = True,
36
+ token: str | None = None,
37
+ ) -> None:
38
+ """Start the sshler FastAPI application via uvicorn.
39
+
40
+ English:
41
+ Bootstraps the FastAPI app with the provided security settings and begins
42
+ serving requests on the chosen host/port.
43
+
44
+ 日本語:
45
+ 指定されたセキュリティ設定で FastAPI アプリケーションを初期化し、
46
+ 指定したホストとポートでリクエスト受付を開始します。
47
+ """
48
+
49
+ settings = ServerSettings(
50
+ allow_origins=allow_origins or [],
51
+ csrf_token=token or secrets.token_urlsafe(32),
52
+ max_upload_bytes=max_upload_mb * 1024 * 1024,
53
+ allow_ssh_alias=allow_ssh_alias,
54
+ basic_auth=basic_auth,
55
+ )
56
+
57
+ fastapi_application = make_app(settings)
58
+ application_url = f"http://{host}:{port}"
59
+ if open_browser and host in {"127.0.0.1", "localhost"}:
60
+ _open_browser_later(application_url)
61
+
62
+ print(f"[sshler] listening on {application_url}")
63
+ print(f"[sshler] X-SSHLER-TOKEN={settings.csrf_token}")
64
+ if basic_auth:
65
+ print(f"[sshler] Basic auth enabled for user '{basic_auth[0]}'")
66
+ if settings.allow_origins:
67
+ print(f"[sshler] Additional allowed origins: {', '.join(settings.allow_origins)}")
68
+
69
+ uvicorn.run(
70
+ fastapi_application,
71
+ host=host,
72
+ port=port,
73
+ reload=reload,
74
+ log_level=log_level,
75
+ )
76
+
77
+
78
+ def main() -> None:
79
+ """Parse CLI arguments and invoke the requested subcommand.
80
+
81
+ English:
82
+ Handles ``sshler`` command-line parsing and dispatches to ``serve`` when
83
+ no subcommand is explicitly provided.
84
+
85
+ 日本語:
86
+ ``sshler`` のコマンドライン引数を解析し、サブコマンドが指定されて
87
+ いない場合は ``serve`` を実行します。
88
+ """
89
+
90
+ parser = argparse.ArgumentParser(prog="sshler", description="Local SSH tmux-in-browser")
91
+ subcommands = parser.add_subparsers(dest="command")
92
+
93
+ serve_parser = subcommands.add_parser("serve", help="Start the sshler web app")
94
+ serve_parser.add_argument(
95
+ "--host",
96
+ default="127.0.0.1",
97
+ help="Interface to bind (default: 127.0.0.1)",
98
+ )
99
+ serve_parser.add_argument("--bind", default=None, help="Alias for --host")
100
+ serve_parser.add_argument("--port", type=int, default=8822)
101
+ serve_parser.add_argument("--reload", action="store_true")
102
+ serve_parser.add_argument(
103
+ "--allow-origin",
104
+ action="append",
105
+ dest="allow_origins",
106
+ default=[],
107
+ help="Allow cross-origin requests from this origin (repeatable)",
108
+ )
109
+ serve_parser.add_argument(
110
+ "--auth",
111
+ help="Enable HTTP basic auth with 'username:password'",
112
+ )
113
+ serve_parser.add_argument(
114
+ "--max-upload-mb",
115
+ type=int,
116
+ default=50,
117
+ help="Maximum upload size in MB (default: 50)",
118
+ )
119
+ serve_parser.add_argument(
120
+ "--no-ssh-alias",
121
+ action="store_true",
122
+ help="Disable SSH config alias expansion",
123
+ )
124
+ serve_parser.add_argument(
125
+ "--log-level",
126
+ default="info",
127
+ choices=["critical", "error", "warning", "info", "debug", "trace"],
128
+ help="Uvicorn log level",
129
+ )
130
+ serve_parser.add_argument("--token", help="Provide a fixed X-SSHLER-TOKEN value")
131
+ serve_parser.add_argument(
132
+ "--no-browser",
133
+ dest="open_browser",
134
+ action="store_false",
135
+ help="Do not automatically open a browser window",
136
+ )
137
+ serve_parser.set_defaults(open_browser=True)
138
+
139
+ parsed_args = parser.parse_args()
140
+ if parsed_args.command in (None, "serve"):
141
+ bind_host = getattr(parsed_args, "bind", None) or getattr(parsed_args, "host", "127.0.0.1")
142
+ basic_auth: tuple[str, str] | None = None
143
+ auth_value = getattr(parsed_args, "auth", None)
144
+ if auth_value:
145
+ if ":" not in auth_value:
146
+ parser.error("--auth must be in the form username:password")
147
+ basic_auth = tuple(auth_value.split(":", 1)) # type: ignore[assignment]
148
+ serve(
149
+ host=bind_host,
150
+ port=getattr(parsed_args, "port", 8822),
151
+ reload=getattr(parsed_args, "reload", False),
152
+ allow_origins=getattr(parsed_args, "allow_origins", []) or [],
153
+ basic_auth=basic_auth,
154
+ max_upload_mb=getattr(parsed_args, "max_upload_mb", 50),
155
+ allow_ssh_alias=not getattr(parsed_args, "no_ssh_alias", False),
156
+ log_level=getattr(parsed_args, "log_level", "info"),
157
+ open_browser=getattr(parsed_args, "open_browser", True),
158
+ token=getattr(parsed_args, "token", None),
159
+ )
160
+ else:
161
+ parser.print_help()
sshler/config.py ADDED
@@ -0,0 +1,425 @@
1
+ from __future__ import annotations
2
+
3
+ import getpass
4
+ import os
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ import yaml
10
+ from platformdirs import user_config_dir
11
+
12
+ from . import state
13
+ from .ssh_config import HostConfig, load_ssh_config
14
+
15
+ ENV_CONFIG_DIR = "SSHLER_CONFIG_DIR"
16
+
17
+
18
+ @dataclass
19
+ class StoredBox:
20
+ """User-defined overrides and custom boxes persisted in YAML.
21
+
22
+ English:
23
+ Represents a single box entry saved by the user. Values here override
24
+ hosts discovered from OpenSSH configuration files.
25
+
26
+ 日本語:
27
+ ユーザーが保存したボックス定義を表します。OpenSSH の設定から検出した
28
+ ホスト情報に対する上書き値として利用されます。
29
+ """
30
+
31
+ name: str
32
+ host: str | None = None
33
+ user: str | None = None
34
+ port: int | None = None
35
+ keyfile: str | None = None
36
+ agent: bool = False
37
+ favorites: list[str] = field(default_factory=list)
38
+ default_dir: str | None = None
39
+ known_hosts: str | None = None
40
+ ssh_alias: str | None = None
41
+
42
+
43
+ @dataclass
44
+ class Box:
45
+ """Concrete SSH box presented in the UI after merging sources.
46
+
47
+ English:
48
+ Materialised box configuration shown in the UI after combining SSH
49
+ config values with stored overrides and synthetic entries such as the
50
+ local workspace.
51
+
52
+ 日本語:
53
+ SSH 設定、保存済みの上書き、ローカルワークスペースなどを統合した結果を
54
+ UI に表示するための構造体です。
55
+ """
56
+
57
+ name: str
58
+ connect_host: str
59
+ display_host: str
60
+ user: str
61
+ port: int = 22
62
+ keyfile: str | None = None
63
+ agent: bool = False
64
+ favorites: list[str] = field(default_factory=list)
65
+ default_dir: str | None = None
66
+ known_hosts: str | None = None
67
+ source: str = "custom"
68
+ ssh_alias: str | None = None
69
+ resolved_host: str | None = None
70
+ transport: str = "ssh"
71
+
72
+
73
+ @dataclass
74
+ class AppConfig:
75
+ """Complete configuration containing merged boxes and stored overrides.
76
+
77
+ English:
78
+ In-memory representation of all known boxes plus metadata such as the
79
+ resolved SSH config path.
80
+
81
+ 日本語:
82
+ 既知のすべてのボックス情報と、解決済みの SSH 設定パスなどのメタデータを
83
+ 保持するメモリ上の設定です。
84
+ """
85
+
86
+ boxes: list[Box] = field(default_factory=list)
87
+ stored: dict[str, StoredBox] = field(default_factory=dict)
88
+ ssh_config_path: str | None = None
89
+
90
+ def get_box(self, name: str) -> Box | None:
91
+ for box in self.boxes:
92
+ if box.name == name:
93
+ return box
94
+ return None
95
+
96
+ def get_or_create_stored(self, name: str) -> StoredBox:
97
+ stored = self.stored.get(name)
98
+ if stored is None:
99
+ stored = StoredBox(name=name)
100
+ self.stored[name] = stored
101
+ return stored
102
+
103
+
104
+ DEFAULT_CONFIGURATION_TEMPLATE: dict[str, Any] = {"boxes": []}
105
+
106
+
107
+ def get_config_dir() -> Path:
108
+ """Return the configuration directory, creating it when missing.
109
+
110
+ English:
111
+ Determines the directory that will contain ``boxes.yaml`` and creates
112
+ it if necessary.
113
+
114
+ 日本語:
115
+ ``boxes.yaml`` を格納するディレクトリを決定し、存在しない場合は作成します。
116
+ """
117
+
118
+ override_directory = os.getenv(ENV_CONFIG_DIR)
119
+ if override_directory:
120
+ configuration_dir = Path(override_directory).expanduser()
121
+ else:
122
+ configuration_dir = Path(user_config_dir(appname="sshler", appauthor=False))
123
+ configuration_dir.mkdir(parents=True, exist_ok=True)
124
+ return configuration_dir
125
+
126
+
127
+ def get_config_path() -> Path:
128
+ """Return the path to the boxes configuration file.
129
+
130
+ English:
131
+ Combines :func:`get_config_dir` with ``boxes.yaml`` to produce the full
132
+ configuration filename.
133
+
134
+ 日本語:
135
+ :func:`get_config_dir` の結果に ``boxes.yaml`` を連結した設定ファイルのパスを
136
+ 返します。
137
+ """
138
+
139
+ return get_config_dir() / "boxes.yaml"
140
+
141
+
142
+ def ensure_config() -> Path:
143
+ """Create a default configuration file when none exists.
144
+
145
+ English:
146
+ Writes an empty configuration file so later reads never fail because the
147
+ file is missing.
148
+
149
+ 日本語:
150
+ 設定ファイルが存在しない場合に空のファイルを作成し、読み込みに失敗しない
151
+ ようにします。
152
+ """
153
+
154
+ config_path = get_config_path()
155
+ if not config_path.exists():
156
+ with config_path.open("w", encoding="utf-8") as file_pointer:
157
+ yaml.safe_dump(DEFAULT_CONFIGURATION_TEMPLATE, file_pointer, sort_keys=False)
158
+ return config_path
159
+
160
+
161
+ def load_config(ssh_config_path: str | None = None) -> AppConfig:
162
+ """Load the application configuration from disk and merge SSH config hosts.
163
+
164
+ English:
165
+ Reads ``boxes.yaml``, normalises data, merges it with OpenSSH hosts, and
166
+ returns a populated :class:`AppConfig` including the synthetic local box.
167
+
168
+ 日本語:
169
+ ``boxes.yaml`` を読み込んで正規化し、OpenSSH のホスト情報と統合したうえで
170
+ ローカルボックスを含む :class:`AppConfig` を返します。
171
+ """
172
+
173
+ config_dir = get_config_dir()
174
+ state.initialize(config_dir)
175
+ config_path = ensure_config()
176
+ with config_path.open("r", encoding="utf-8") as file_pointer:
177
+ raw_data = yaml.safe_load(file_pointer) or {}
178
+
179
+ stored = {}
180
+ for entry in raw_data.get("boxes", []):
181
+ stored_box = _stored_box_from_dict(entry)
182
+ stored[stored_box.name] = stored_box
183
+
184
+ migrated = state.migrate_legacy_favorites(stored)
185
+ _remove_legacy_seed(stored)
186
+
187
+ resolved_path = _resolve_ssh_config_path(ssh_config_path)
188
+ boxes = _build_boxes(stored, load_ssh_config(resolved_path))
189
+ boxes.insert(0, _build_local_box())
190
+ _apply_favorites(boxes)
191
+ app_config = AppConfig(
192
+ boxes=boxes,
193
+ stored=stored,
194
+ ssh_config_path=str(resolved_path) if resolved_path else None,
195
+ )
196
+ if migrated:
197
+ save_config(app_config)
198
+ return app_config
199
+
200
+
201
+ def save_config(application_config: AppConfig) -> None:
202
+ """Persist stored overrides to disk.
203
+
204
+ English:
205
+ Serialises the ``stored`` mapping to ``boxes.yaml`` so user-created
206
+ overrides survive restarts.
207
+
208
+ 日本語:
209
+ ``stored`` マッピングを ``boxes.yaml`` に書き出し、ユーザーが作成した上書き
210
+ 設定を永続化します。
211
+ """
212
+
213
+ config_path = get_config_path()
214
+ payload = {
215
+ "boxes": [
216
+ _stored_box_to_dict(stored)
217
+ for stored in sorted(
218
+ application_config.stored.values(), key=lambda item: item.name.lower()
219
+ )
220
+ ]
221
+ }
222
+ with config_path.open("w", encoding="utf-8") as file_pointer:
223
+ yaml.safe_dump(payload, file_pointer, sort_keys=False)
224
+
225
+
226
+ def find_box(application_config: AppConfig, name: str) -> Box | None:
227
+ """Return the box matching ``name`` when present.
228
+
229
+ English:
230
+ Helper used by request handlers to locate a box by name.
231
+
232
+ 日本語:
233
+ リクエストハンドラがボックス名で検索するためのヘルパー関数です。
234
+ """
235
+
236
+ return application_config.get_box(name)
237
+
238
+
239
+ def rebuild_boxes(application_config: AppConfig, ssh_config_path: str | None = None) -> None:
240
+ """Refresh the merged box list after stored overrides change.
241
+
242
+ English:
243
+ Recomputes the list of concrete boxes based on the latest stored data
244
+ and (optionally) a new SSH config file path.
245
+
246
+ 日本語:
247
+ 最新の保存情報と SSH 設定パス (必要に応じて) を基に、利用可能なボックスの
248
+ リストを再構築します。
249
+ """
250
+
251
+ state.initialize(get_config_dir())
252
+ resolved_path = _resolve_ssh_config_path(ssh_config_path or application_config.ssh_config_path)
253
+ application_config.ssh_config_path = str(resolved_path) if resolved_path else None
254
+ application_config.boxes = _build_boxes(
255
+ application_config.stored, load_ssh_config(resolved_path)
256
+ )
257
+ _apply_favorites(application_config.boxes)
258
+
259
+
260
+ def _apply_favorites(boxes: list[Box]) -> None:
261
+ if not boxes:
262
+ return
263
+ mapping = state.favorites_map([box.name for box in boxes])
264
+ for box in boxes:
265
+ box.favorites = mapping.get(box.name, [])
266
+
267
+
268
+ def _stored_box_from_dict(data: dict[str, Any]) -> StoredBox:
269
+ favorites = data.get("favorites") or []
270
+ return StoredBox(
271
+ name=data["name"],
272
+ host=data.get("host"),
273
+ user=data.get("user"),
274
+ port=int(data["port"]) if "port" in data and data["port"] is not None else None,
275
+ keyfile=data.get("keyfile"),
276
+ agent=bool(data.get("agent", False)),
277
+ favorites=list(favorites),
278
+ default_dir=data.get("default_dir"),
279
+ known_hosts=data.get("known_hosts"),
280
+ ssh_alias=data.get("ssh_alias"),
281
+ )
282
+
283
+
284
+ def _stored_box_to_dict(stored: StoredBox) -> dict[str, Any]:
285
+ result: dict[str, Any] = {"name": stored.name}
286
+ if stored.host:
287
+ result["host"] = stored.host
288
+ if stored.user:
289
+ result["user"] = stored.user
290
+ if stored.port is not None:
291
+ result["port"] = int(stored.port)
292
+ if stored.keyfile:
293
+ result["keyfile"] = stored.keyfile
294
+ if stored.agent:
295
+ result["agent"] = stored.agent
296
+ if stored.default_dir:
297
+ result["default_dir"] = stored.default_dir
298
+ if stored.known_hosts:
299
+ result["known_hosts"] = stored.known_hosts
300
+ if stored.ssh_alias:
301
+ result["ssh_alias"] = stored.ssh_alias
302
+ return result
303
+
304
+
305
+ def _build_boxes(stored: dict[str, StoredBox], ssh_hosts: dict[str, HostConfig]) -> list[Box]:
306
+ boxes: list[Box] = []
307
+ seen: set[str] = set()
308
+
309
+ for name, host_config in ssh_hosts.items():
310
+ stored_override = stored.get(name)
311
+ boxes.append(_merge_host(name, host_config, stored_override))
312
+ seen.add(name)
313
+
314
+ for name, stored_override in stored.items():
315
+ if name not in seen:
316
+ boxes.append(_merge_host(name, None, stored_override))
317
+
318
+ boxes.sort(key=lambda item: item.name.lower())
319
+ return boxes
320
+
321
+
322
+ def _build_local_box() -> Box:
323
+ home_directory = str(Path.home())
324
+ return Box(
325
+ name="local",
326
+ connect_host="local",
327
+ display_host="localhost",
328
+ user=_default_user(),
329
+ port=0,
330
+ agent=False,
331
+ favorites=[],
332
+ default_dir=home_directory,
333
+ known_hosts=None,
334
+ source="local",
335
+ ssh_alias=None,
336
+ resolved_host=None,
337
+ transport="local",
338
+ )
339
+
340
+
341
+ def _merge_host(
342
+ name: str, host_config: HostConfig | None, stored_override: StoredBox | None
343
+ ) -> Box:
344
+ if stored_override and stored_override.host:
345
+ connect_host = stored_override.host
346
+ elif host_config and host_config.hostname:
347
+ connect_host = host_config.hostname
348
+ else:
349
+ connect_host = name
350
+
351
+ if stored_override and stored_override.host:
352
+ display_host = stored_override.host
353
+ else:
354
+ display_host = name
355
+
356
+ if stored_override and stored_override.ssh_alias:
357
+ ssh_alias = stored_override.ssh_alias
358
+ else:
359
+ ssh_alias = name
360
+
361
+ resolved_host = host_config.hostname if host_config and host_config.hostname else None
362
+
363
+ base_user = stored_override.user if stored_override and stored_override.user else None
364
+ if base_user is None and host_config and host_config.user:
365
+ base_user = host_config.user
366
+ if base_user is None:
367
+ base_user = _default_user()
368
+
369
+ base_port = stored_override.port if stored_override and stored_override.port else None
370
+ if base_port is None and host_config and host_config.port:
371
+ base_port = host_config.port
372
+ if base_port is None:
373
+ base_port = 22
374
+
375
+ base_keyfile = stored_override.keyfile if stored_override and stored_override.keyfile else None
376
+ if base_keyfile is None and host_config and host_config.identity_files:
377
+ base_keyfile = host_config.identity_files[0]
378
+
379
+ default_dir = stored_override.default_dir if stored_override else None
380
+ known_hosts = stored_override.known_hosts if stored_override else None
381
+ agent = stored_override.agent if stored_override else False
382
+ source = "ssh_config" if host_config else "custom"
383
+
384
+ return Box(
385
+ name=name,
386
+ connect_host=connect_host,
387
+ display_host=display_host,
388
+ user=base_user,
389
+ port=base_port,
390
+ keyfile=base_keyfile,
391
+ agent=agent,
392
+ favorites=[],
393
+ default_dir=default_dir,
394
+ known_hosts=known_hosts,
395
+ source=source,
396
+ ssh_alias=ssh_alias,
397
+ resolved_host=resolved_host,
398
+ transport="ssh",
399
+ )
400
+
401
+
402
+ def _default_user() -> str:
403
+ try:
404
+ return getpass.getuser()
405
+ except Exception:
406
+ return ""
407
+
408
+
409
+ def _remove_legacy_seed(stored: dict[str, StoredBox]) -> None:
410
+ legacy = stored.get("gabu-server")
411
+ if not legacy:
412
+ return
413
+ if legacy.host == "example.tailnet.ts.net" and legacy.user == "gabu":
414
+ if legacy.favorites:
415
+ legacy.favorites.clear()
416
+
417
+
418
+ def _resolve_ssh_config_path(explicit: str | None = None) -> Path | None:
419
+ if explicit:
420
+ return Path(explicit).expanduser()
421
+ env_override = os.getenv("SSHLER_SSH_CONFIG")
422
+ if env_override:
423
+ return Path(env_override).expanduser()
424
+ default_path = Path.home() / ".ssh" / "config"
425
+ return default_path if default_path.exists() else default_path
@@ -0,0 +1,28 @@
1
+ param(
2
+ [string]$TaskName = 'sshler',
3
+ [string]$Host = '127.0.0.1',
4
+ [int]$Port = 8822,
5
+ [switch]$Force
6
+ )
7
+
8
+ $ErrorActionPreference = 'Stop'
9
+
10
+ Import-Module ScheduledTasks
11
+
12
+ $repoRoot = Resolve-Path -Path (Join-Path $PSScriptRoot '..')
13
+ $runScript = Resolve-Path -Path (Join-Path $PSScriptRoot 'run-sshler.ps1')
14
+
15
+ $argumentLine = "-NoProfile -ExecutionPolicy Bypass -File `"$runScript`" -Host $Host -Port $Port"
16
+ $action = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument $argumentLine -WorkingDirectory $repoRoot
17
+ $trigger = New-ScheduledTaskTrigger -AtLogOn
18
+ $settings = New-ScheduledTaskSettingsSet -StartWhenAvailable -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries
19
+ $principal = New-ScheduledTaskPrincipal -UserId $env:USERNAME -LogonType Interactive -RunLevel Highest
20
+
21
+ $task = New-ScheduledTask -Action $action -Trigger $trigger -Settings $settings -Principal $principal
22
+
23
+ Register-ScheduledTask -TaskName $TaskName -InputObject $task -Force:$Force.IsPresent | Out-Null
24
+
25
+ Write-Host "Registered scheduled task '$TaskName'.":
26
+ Write-Host " - Host : $Host"
27
+ Write-Host " - Port : $Port"
28
+ Write-Host "Logs will stream to '$(Join-Path $repoRoot "logs")'."
@@ -0,0 +1,15 @@
1
+ param(
2
+ [string]$TaskName = 'sshler'
3
+ )
4
+
5
+ $ErrorActionPreference = 'Stop'
6
+
7
+ Import-Module ScheduledTasks
8
+
9
+ if (-not (Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue)) {
10
+ Write-Host "Scheduled task '$TaskName' was not found."
11
+ return
12
+ }
13
+
14
+ Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false
15
+ Write-Host "Removed scheduled task '$TaskName'."
@@ -0,0 +1,24 @@
1
+ param(
2
+ [string]$Host = "127.0.0.1",
3
+ [int]$Port = 8822
4
+ )
5
+
6
+ $ErrorActionPreference = 'Stop'
7
+
8
+ $repoRoot = Resolve-Path -Path (Join-Path $PSScriptRoot "..")
9
+ Set-Location $repoRoot
10
+
11
+ $logDir = Join-Path $repoRoot 'logs'
12
+ New-Item -ItemType Directory -Path $logDir -Force | Out-Null
13
+ $stdoutLog = Join-Path $logDir 'sshler.stdout.log'
14
+ $stderrLog = Join-Path $logDir 'sshler.stderr.log'
15
+
16
+ $arguments = @('run', 'sshler', 'serve', '--host', $Host, '--port', $Port)
17
+
18
+ Start-Process -FilePath 'uv' `
19
+ -ArgumentList $arguments `
20
+ -WorkingDirectory $repoRoot `
21
+ -NoNewWindow `
22
+ -Wait `
23
+ -RedirectStandardOutput $stdoutLog `
24
+ -RedirectStandardError $stderrLog