ssh-docs 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.
- ssh_docs/__init__.py +19 -0
- ssh_docs/__main__.py +6 -0
- ssh_docs/cli.py +392 -0
- ssh_docs/config.py +251 -0
- ssh_docs/server.py +234 -0
- ssh_docs/shell.py +307 -0
- ssh_docs-0.1.0.dist-info/METADATA +392 -0
- ssh_docs-0.1.0.dist-info/RECORD +11 -0
- ssh_docs-0.1.0.dist-info/WHEEL +5 -0
- ssh_docs-0.1.0.dist-info/entry_points.txt +2 -0
- ssh_docs-0.1.0.dist-info/top_level.txt +1 -0
ssh_docs/__init__.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""SSH-Docs: Expose documentation via SSH.
|
|
2
|
+
|
|
3
|
+
Browse your documentation using familiar Unix commands over SSH.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
__version__ = "0.1.0"
|
|
7
|
+
__author__ = "SSH-Docs Contributors"
|
|
8
|
+
|
|
9
|
+
from .config import Config, load_config
|
|
10
|
+
from .server import SSHDocsServer, run_server
|
|
11
|
+
from .shell import SSHDocsShell
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"Config",
|
|
15
|
+
"load_config",
|
|
16
|
+
"SSHDocsServer",
|
|
17
|
+
"run_server",
|
|
18
|
+
"SSHDocsShell",
|
|
19
|
+
]
|
ssh_docs/__main__.py
ADDED
ssh_docs/cli.py
ADDED
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
"""Command-line interface for SSH-Docs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
import click
|
|
11
|
+
|
|
12
|
+
from .config import (
|
|
13
|
+
Config,
|
|
14
|
+
auto_detect_content_root,
|
|
15
|
+
auto_detect_site_name,
|
|
16
|
+
generate_default_config,
|
|
17
|
+
load_config,
|
|
18
|
+
)
|
|
19
|
+
from .server import run_server
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@click.group()
|
|
23
|
+
@click.version_option(version="0.1.0", prog_name="ssh-docs")
|
|
24
|
+
def cli() -> None:
|
|
25
|
+
"""SSH-Docs: Expose documentation via SSH.
|
|
26
|
+
|
|
27
|
+
Browse your documentation using familiar Unix commands over SSH.
|
|
28
|
+
"""
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@cli.command()
|
|
33
|
+
@click.argument("content_dir", type=click.Path(exists=True), required=False)
|
|
34
|
+
@click.option(
|
|
35
|
+
"-p",
|
|
36
|
+
"--port",
|
|
37
|
+
type=int,
|
|
38
|
+
default=None,
|
|
39
|
+
help="Port to listen on [default: 2222]",
|
|
40
|
+
)
|
|
41
|
+
@click.option(
|
|
42
|
+
"-n",
|
|
43
|
+
"--site-name",
|
|
44
|
+
type=str,
|
|
45
|
+
default=None,
|
|
46
|
+
help="Site name for banner [default: auto-detect]",
|
|
47
|
+
)
|
|
48
|
+
@click.option(
|
|
49
|
+
"-c",
|
|
50
|
+
"--config",
|
|
51
|
+
type=click.Path(exists=True),
|
|
52
|
+
default=None,
|
|
53
|
+
help="Config file path [default: .ssh-docs.yml]",
|
|
54
|
+
)
|
|
55
|
+
@click.option(
|
|
56
|
+
"--host",
|
|
57
|
+
type=str,
|
|
58
|
+
default=None,
|
|
59
|
+
help="Host to bind to [default: 0.0.0.0]",
|
|
60
|
+
)
|
|
61
|
+
@click.option(
|
|
62
|
+
"--auth",
|
|
63
|
+
type=click.Choice(["public", "key", "password"]),
|
|
64
|
+
default=None,
|
|
65
|
+
help="Auth type [default: public]",
|
|
66
|
+
)
|
|
67
|
+
@click.option(
|
|
68
|
+
"--keys-file",
|
|
69
|
+
type=click.Path(exists=True),
|
|
70
|
+
default=None,
|
|
71
|
+
help="Authorized keys file (for key auth)",
|
|
72
|
+
)
|
|
73
|
+
@click.option(
|
|
74
|
+
"--password",
|
|
75
|
+
type=str,
|
|
76
|
+
default=None,
|
|
77
|
+
help="Password (for password auth)",
|
|
78
|
+
)
|
|
79
|
+
@click.option(
|
|
80
|
+
"--no-config",
|
|
81
|
+
is_flag=True,
|
|
82
|
+
help="Ignore config file",
|
|
83
|
+
)
|
|
84
|
+
@click.option(
|
|
85
|
+
"--log-level",
|
|
86
|
+
type=click.Choice(["debug", "info", "warn", "error"]),
|
|
87
|
+
default=None,
|
|
88
|
+
help="Log level [default: info]",
|
|
89
|
+
)
|
|
90
|
+
def serve(
|
|
91
|
+
content_dir: Optional[str],
|
|
92
|
+
port: Optional[int],
|
|
93
|
+
site_name: Optional[str],
|
|
94
|
+
config: Optional[str],
|
|
95
|
+
host: Optional[str],
|
|
96
|
+
auth: Optional[str],
|
|
97
|
+
keys_file: Optional[str],
|
|
98
|
+
password: Optional[str],
|
|
99
|
+
no_config: bool,
|
|
100
|
+
log_level: Optional[str],
|
|
101
|
+
) -> None:
|
|
102
|
+
"""Start SSH documentation server.
|
|
103
|
+
|
|
104
|
+
Examples:
|
|
105
|
+
|
|
106
|
+
\b
|
|
107
|
+
# Serve current directory on default port
|
|
108
|
+
ssh-docs serve
|
|
109
|
+
|
|
110
|
+
\b
|
|
111
|
+
# Serve specific directory
|
|
112
|
+
ssh-docs serve ./docs
|
|
113
|
+
|
|
114
|
+
\b
|
|
115
|
+
# Custom port and site name
|
|
116
|
+
ssh-docs serve ./docs -p 3000 -n "My API Docs"
|
|
117
|
+
|
|
118
|
+
\b
|
|
119
|
+
# Use config file
|
|
120
|
+
ssh-docs serve --config custom-config.yml
|
|
121
|
+
|
|
122
|
+
\b
|
|
123
|
+
# Password authentication
|
|
124
|
+
ssh-docs serve --auth password --password secret123
|
|
125
|
+
"""
|
|
126
|
+
# Load config from file if not disabled
|
|
127
|
+
if not no_config:
|
|
128
|
+
try:
|
|
129
|
+
cfg = load_config(config)
|
|
130
|
+
except Exception as e:
|
|
131
|
+
if config: # Only error if explicitly specified
|
|
132
|
+
click.echo(f"Error loading config: {e}", err=True)
|
|
133
|
+
sys.exit(1)
|
|
134
|
+
cfg = Config()
|
|
135
|
+
else:
|
|
136
|
+
cfg = Config()
|
|
137
|
+
|
|
138
|
+
# Override with CLI arguments
|
|
139
|
+
if content_dir:
|
|
140
|
+
cfg.content_root = Path(content_dir)
|
|
141
|
+
elif cfg.content_root == Path("."):
|
|
142
|
+
# Auto-detect if not specified
|
|
143
|
+
cfg.content_root = auto_detect_content_root()
|
|
144
|
+
|
|
145
|
+
if port is not None:
|
|
146
|
+
cfg.port = port
|
|
147
|
+
|
|
148
|
+
if site_name:
|
|
149
|
+
cfg.site_name = site_name
|
|
150
|
+
elif cfg.site_name == "Documentation":
|
|
151
|
+
# Auto-detect if default
|
|
152
|
+
cfg.site_name = auto_detect_site_name()
|
|
153
|
+
|
|
154
|
+
if host:
|
|
155
|
+
cfg.host = host
|
|
156
|
+
|
|
157
|
+
if auth:
|
|
158
|
+
cfg.auth_type = auth
|
|
159
|
+
|
|
160
|
+
if keys_file:
|
|
161
|
+
cfg.authorized_keys = keys_file
|
|
162
|
+
|
|
163
|
+
if password:
|
|
164
|
+
cfg.password = password
|
|
165
|
+
|
|
166
|
+
if log_level:
|
|
167
|
+
cfg.log_level = log_level
|
|
168
|
+
|
|
169
|
+
# Validate configuration
|
|
170
|
+
if not cfg.content_root.exists():
|
|
171
|
+
click.echo(f"Error: Content directory does not exist: {cfg.content_root}", err=True)
|
|
172
|
+
sys.exit(1)
|
|
173
|
+
|
|
174
|
+
if not cfg.content_root.is_dir():
|
|
175
|
+
click.echo(f"Error: Content path is not a directory: {cfg.content_root}", err=True)
|
|
176
|
+
sys.exit(1)
|
|
177
|
+
|
|
178
|
+
if cfg.auth_type == "password" and not cfg.password:
|
|
179
|
+
click.echo("Error: Password authentication requires --password option", err=True)
|
|
180
|
+
sys.exit(1)
|
|
181
|
+
|
|
182
|
+
if cfg.auth_type == "key" and not cfg.authorized_keys:
|
|
183
|
+
click.echo("Error: Key authentication requires --keys-file option", err=True)
|
|
184
|
+
sys.exit(1)
|
|
185
|
+
|
|
186
|
+
# Run server
|
|
187
|
+
try:
|
|
188
|
+
asyncio.run(run_server(cfg))
|
|
189
|
+
except KeyboardInterrupt:
|
|
190
|
+
click.echo("\nServer stopped")
|
|
191
|
+
except Exception as e:
|
|
192
|
+
click.echo(f"Error: {e}", err=True)
|
|
193
|
+
sys.exit(1)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
@cli.command()
|
|
197
|
+
@click.option(
|
|
198
|
+
"--interactive",
|
|
199
|
+
is_flag=True,
|
|
200
|
+
help="Interactive setup wizard",
|
|
201
|
+
)
|
|
202
|
+
@click.option(
|
|
203
|
+
"--template",
|
|
204
|
+
type=click.Choice(["basic", "advanced"]),
|
|
205
|
+
default="basic",
|
|
206
|
+
help="Config template to use",
|
|
207
|
+
)
|
|
208
|
+
def init(interactive: bool, template: str) -> None:
|
|
209
|
+
"""Initialize .ssh-docs.yml config file.
|
|
210
|
+
|
|
211
|
+
Creates a configuration file with sensible defaults.
|
|
212
|
+
|
|
213
|
+
Examples:
|
|
214
|
+
|
|
215
|
+
\b
|
|
216
|
+
# Create basic config
|
|
217
|
+
ssh-docs init
|
|
218
|
+
|
|
219
|
+
\b
|
|
220
|
+
# Interactive setup
|
|
221
|
+
ssh-docs init --interactive
|
|
222
|
+
"""
|
|
223
|
+
config_path = Path(".ssh-docs.yml")
|
|
224
|
+
|
|
225
|
+
if config_path.exists():
|
|
226
|
+
if not click.confirm(f"{config_path} already exists. Overwrite?"):
|
|
227
|
+
click.echo("Aborted")
|
|
228
|
+
return
|
|
229
|
+
|
|
230
|
+
if interactive:
|
|
231
|
+
click.echo("SSH-Docs Configuration Setup\n")
|
|
232
|
+
|
|
233
|
+
site_name = click.prompt("Site name", default="My Documentation")
|
|
234
|
+
|
|
235
|
+
# Auto-detect content directory
|
|
236
|
+
detected = auto_detect_content_root()
|
|
237
|
+
content_dir = click.prompt(
|
|
238
|
+
"Content directory",
|
|
239
|
+
default=str(detected),
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
port = click.prompt("Port", default=2222, type=int)
|
|
243
|
+
|
|
244
|
+
auth_type = click.prompt(
|
|
245
|
+
"Authentication type",
|
|
246
|
+
type=click.Choice(["public", "key", "password"]),
|
|
247
|
+
default="public",
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
config_content = generate_default_config(site_name, content_dir, port)
|
|
251
|
+
|
|
252
|
+
# Modify auth section if needed
|
|
253
|
+
if auth_type != "public":
|
|
254
|
+
lines = config_content.split("\n")
|
|
255
|
+
for i, line in enumerate(lines):
|
|
256
|
+
if 'type: "public"' in line:
|
|
257
|
+
lines[i] = f' type: "{auth_type}"'
|
|
258
|
+
break
|
|
259
|
+
config_content = "\n".join(lines)
|
|
260
|
+
else:
|
|
261
|
+
# Use defaults with auto-detection
|
|
262
|
+
detected_root = auto_detect_content_root()
|
|
263
|
+
detected_name = auto_detect_site_name()
|
|
264
|
+
config_content = generate_default_config(
|
|
265
|
+
detected_name,
|
|
266
|
+
str(detected_root),
|
|
267
|
+
2222,
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
# Write config file
|
|
271
|
+
config_path.write_text(config_content, encoding="utf-8")
|
|
272
|
+
|
|
273
|
+
click.echo(f"✓ Created {config_path}")
|
|
274
|
+
click.echo(f"\nTo start the server, run:")
|
|
275
|
+
click.echo(f" ssh-docs serve")
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
@cli.command()
|
|
279
|
+
@click.argument(
|
|
280
|
+
"config_file",
|
|
281
|
+
type=click.Path(exists=True),
|
|
282
|
+
default=".ssh-docs.yml",
|
|
283
|
+
required=False,
|
|
284
|
+
)
|
|
285
|
+
def validate(config_file: str) -> None:
|
|
286
|
+
"""Validate configuration file.
|
|
287
|
+
|
|
288
|
+
Checks syntax and settings in the config file.
|
|
289
|
+
|
|
290
|
+
Examples:
|
|
291
|
+
|
|
292
|
+
\b
|
|
293
|
+
# Validate default config
|
|
294
|
+
ssh-docs validate
|
|
295
|
+
|
|
296
|
+
\b
|
|
297
|
+
# Validate specific config
|
|
298
|
+
ssh-docs validate custom-config.yml
|
|
299
|
+
"""
|
|
300
|
+
try:
|
|
301
|
+
cfg = load_config(config_file)
|
|
302
|
+
|
|
303
|
+
click.echo(f"✓ Configuration is valid")
|
|
304
|
+
click.echo(f"\nSettings:")
|
|
305
|
+
click.echo(f" Site name: {cfg.site_name}")
|
|
306
|
+
click.echo(f" Content root: {cfg.content_root}")
|
|
307
|
+
click.echo(f" Port: {cfg.port}")
|
|
308
|
+
click.echo(f" Host: {cfg.host}")
|
|
309
|
+
click.echo(f" Auth type: {cfg.auth_type}")
|
|
310
|
+
|
|
311
|
+
# Check content root
|
|
312
|
+
if not cfg.content_root.exists():
|
|
313
|
+
click.echo(f"\n⚠ Warning: Content root does not exist: {cfg.content_root}", err=True)
|
|
314
|
+
elif not cfg.content_root.is_dir():
|
|
315
|
+
click.echo(f"\n⚠ Warning: Content root is not a directory: {cfg.content_root}", err=True)
|
|
316
|
+
|
|
317
|
+
except FileNotFoundError:
|
|
318
|
+
click.echo(f"Error: Config file not found: {config_file}", err=True)
|
|
319
|
+
sys.exit(1)
|
|
320
|
+
except Exception as e:
|
|
321
|
+
click.echo(f"Error: {e}", err=True)
|
|
322
|
+
sys.exit(1)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
@cli.command()
|
|
326
|
+
@click.option(
|
|
327
|
+
"--output-dir",
|
|
328
|
+
type=click.Path(),
|
|
329
|
+
default=None,
|
|
330
|
+
help="Where to save keys [default: ~/.ssh-docs/keys]",
|
|
331
|
+
)
|
|
332
|
+
@click.option(
|
|
333
|
+
"--force",
|
|
334
|
+
is_flag=True,
|
|
335
|
+
help="Overwrite existing keys",
|
|
336
|
+
)
|
|
337
|
+
def keygen(output_dir: Optional[str], force: bool) -> None:
|
|
338
|
+
"""Generate SSH host keys for the server.
|
|
339
|
+
|
|
340
|
+
Creates RSA key pair for SSH server authentication.
|
|
341
|
+
|
|
342
|
+
Examples:
|
|
343
|
+
|
|
344
|
+
\b
|
|
345
|
+
# Generate keys in default location
|
|
346
|
+
ssh-docs keygen
|
|
347
|
+
|
|
348
|
+
\b
|
|
349
|
+
# Generate keys in custom location
|
|
350
|
+
ssh-docs keygen --output-dir ./keys
|
|
351
|
+
"""
|
|
352
|
+
try:
|
|
353
|
+
import asyncssh
|
|
354
|
+
except ImportError:
|
|
355
|
+
click.echo("Error: asyncssh is required. Install with: pip install asyncssh", err=True)
|
|
356
|
+
sys.exit(1)
|
|
357
|
+
|
|
358
|
+
if output_dir:
|
|
359
|
+
key_dir = Path(output_dir)
|
|
360
|
+
else:
|
|
361
|
+
key_dir = Path.home() / ".ssh-docs" / "keys"
|
|
362
|
+
|
|
363
|
+
key_dir.mkdir(parents=True, exist_ok=True)
|
|
364
|
+
key_path = key_dir / "ssh_host_rsa_key"
|
|
365
|
+
|
|
366
|
+
if key_path.exists() and not force:
|
|
367
|
+
click.echo(f"Error: Key already exists: {key_path}", err=True)
|
|
368
|
+
click.echo("Use --force to overwrite", err=True)
|
|
369
|
+
sys.exit(1)
|
|
370
|
+
|
|
371
|
+
click.echo(f"Generating RSA key pair...")
|
|
372
|
+
|
|
373
|
+
key = asyncssh.generate_private_key("ssh-rsa")
|
|
374
|
+
key.write_private_key(str(key_path))
|
|
375
|
+
|
|
376
|
+
# Also write public key
|
|
377
|
+
pub_key_path = key_path.with_suffix(".pub")
|
|
378
|
+
key.write_public_key(str(pub_key_path))
|
|
379
|
+
|
|
380
|
+
click.echo(f"✓ Private key: {key_path}")
|
|
381
|
+
click.echo(f"✓ Public key: {pub_key_path}")
|
|
382
|
+
click.echo(f"\nTo use this key, add to your config:")
|
|
383
|
+
click.echo(f" host_key: {key_path}")
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def main() -> None:
|
|
387
|
+
"""Main entry point for CLI."""
|
|
388
|
+
cli()
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
if __name__ == "__main__":
|
|
392
|
+
main()
|
ssh_docs/config.py
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
"""Configuration management for SSH-Docs server."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Optional
|
|
8
|
+
|
|
9
|
+
try:
|
|
10
|
+
import yaml
|
|
11
|
+
HAS_YAML = True
|
|
12
|
+
except ImportError:
|
|
13
|
+
HAS_YAML = False
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Config:
|
|
17
|
+
"""Configuration container for SSH-Docs server."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, data: Optional[dict[str, Any]] = None) -> None:
|
|
20
|
+
data = data or {}
|
|
21
|
+
|
|
22
|
+
# Basic settings
|
|
23
|
+
self.site_name: str = data.get("site_name", "Documentation")
|
|
24
|
+
self.content_root: Path = Path(data.get("content_root", ".")).expanduser()
|
|
25
|
+
self.port: int = data.get("port", 2222)
|
|
26
|
+
self.host: str = data.get("host", "0.0.0.0")
|
|
27
|
+
|
|
28
|
+
# Authentication
|
|
29
|
+
auth_data = data.get("auth", {})
|
|
30
|
+
self.auth_type: str = auth_data.get("type", "public")
|
|
31
|
+
self.authorized_keys: Optional[str] = auth_data.get("authorized_keys")
|
|
32
|
+
self.password: Optional[str] = auth_data.get("password")
|
|
33
|
+
|
|
34
|
+
# Server settings
|
|
35
|
+
server_data = data.get("server", {})
|
|
36
|
+
self.banner: Optional[str] = server_data.get("banner")
|
|
37
|
+
self.max_connections: int = server_data.get("max_connections", 10)
|
|
38
|
+
self.timeout: int = server_data.get("timeout", 300)
|
|
39
|
+
self.log_level: str = server_data.get("log_level", "info")
|
|
40
|
+
self.log_file: Optional[str] = server_data.get("log_file")
|
|
41
|
+
|
|
42
|
+
# Features
|
|
43
|
+
features_data = data.get("features", {})
|
|
44
|
+
self.syntax_highlighting: bool = features_data.get("syntax_highlighting", False)
|
|
45
|
+
self.line_numbers: bool = features_data.get("line_numbers", False)
|
|
46
|
+
self.search_index: bool = features_data.get("search_index", False)
|
|
47
|
+
|
|
48
|
+
# Custom commands
|
|
49
|
+
self.custom_commands: list[dict[str, Any]] = data.get("custom_commands", [])
|
|
50
|
+
|
|
51
|
+
# Path mappings
|
|
52
|
+
self.mounts: list[dict[str, str]] = data.get("mounts", [])
|
|
53
|
+
|
|
54
|
+
# Ignore patterns
|
|
55
|
+
self.ignore: list[str] = data.get("ignore", [])
|
|
56
|
+
|
|
57
|
+
# Host key path
|
|
58
|
+
self.host_key: Optional[Path] = None
|
|
59
|
+
if "host_key" in data:
|
|
60
|
+
self.host_key = Path(data["host_key"]).expanduser()
|
|
61
|
+
|
|
62
|
+
def __repr__(self) -> str:
|
|
63
|
+
return f"Config(site_name={self.site_name!r}, port={self.port}, content_root={self.content_root})"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def load_config(config_path: Optional[str] = None) -> Config:
|
|
67
|
+
"""Load configuration from YAML file.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
config_path: Path to config file. If None, looks for .ssh-docs.yml in current directory.
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
Config object with loaded settings.
|
|
74
|
+
|
|
75
|
+
Raises:
|
|
76
|
+
FileNotFoundError: If config file doesn't exist.
|
|
77
|
+
ValueError: If YAML parsing fails or yaml module not installed.
|
|
78
|
+
"""
|
|
79
|
+
if config_path is None:
|
|
80
|
+
config_path = ".ssh-docs.yml"
|
|
81
|
+
|
|
82
|
+
path = Path(config_path)
|
|
83
|
+
|
|
84
|
+
if not path.exists():
|
|
85
|
+
# Return default config if file doesn't exist
|
|
86
|
+
return Config()
|
|
87
|
+
|
|
88
|
+
if not HAS_YAML:
|
|
89
|
+
raise ValueError(
|
|
90
|
+
"PyYAML is required to load config files. Install with: pip install pyyaml"
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
try:
|
|
94
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
95
|
+
data = yaml.safe_load(f)
|
|
96
|
+
|
|
97
|
+
if data is None:
|
|
98
|
+
data = {}
|
|
99
|
+
|
|
100
|
+
# Resolve paths relative to config file location
|
|
101
|
+
if "content_root" in data:
|
|
102
|
+
content_root = Path(data["content_root"])
|
|
103
|
+
if not content_root.is_absolute():
|
|
104
|
+
data["content_root"] = str(path.parent / content_root)
|
|
105
|
+
|
|
106
|
+
# Expand environment variables in password
|
|
107
|
+
if "auth" in data and "password" in data["auth"]:
|
|
108
|
+
password = data["auth"]["password"]
|
|
109
|
+
if password.startswith("${") and password.endswith("}"):
|
|
110
|
+
env_var = password[2:-1]
|
|
111
|
+
data["auth"]["password"] = os.environ.get(env_var)
|
|
112
|
+
|
|
113
|
+
return Config(data)
|
|
114
|
+
|
|
115
|
+
except yaml.YAMLError as e:
|
|
116
|
+
raise ValueError(f"Failed to parse config file: {e}")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def generate_default_config(
|
|
120
|
+
site_name: str = "My Documentation",
|
|
121
|
+
content_root: str = "./docs",
|
|
122
|
+
port: int = 2222,
|
|
123
|
+
) -> str:
|
|
124
|
+
"""Generate a default configuration file content.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
site_name: Name of the documentation site.
|
|
128
|
+
content_root: Path to documentation content.
|
|
129
|
+
port: Port to listen on.
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
YAML configuration file content as string.
|
|
133
|
+
"""
|
|
134
|
+
return f"""# SSH-Docs Configuration File
|
|
135
|
+
|
|
136
|
+
# Basic settings
|
|
137
|
+
site_name: "{site_name}"
|
|
138
|
+
content_root: "{content_root}"
|
|
139
|
+
port: {port}
|
|
140
|
+
host: "0.0.0.0"
|
|
141
|
+
|
|
142
|
+
# Authentication
|
|
143
|
+
auth:
|
|
144
|
+
type: "public" # Options: public, key, password
|
|
145
|
+
# For key-based auth:
|
|
146
|
+
# authorized_keys: "~/.ssh/authorized_keys"
|
|
147
|
+
# For password auth:
|
|
148
|
+
# password: "${{SSH_DOCS_PASSWORD}}" # Use environment variable
|
|
149
|
+
|
|
150
|
+
# Server settings
|
|
151
|
+
server:
|
|
152
|
+
banner: |
|
|
153
|
+
Welcome to {{site_name}} Documentation
|
|
154
|
+
Type 'help' for available commands
|
|
155
|
+
max_connections: 10
|
|
156
|
+
timeout: 300 # seconds
|
|
157
|
+
log_level: "info" # Options: debug, info, warn, error
|
|
158
|
+
# log_file: "./ssh-docs.log"
|
|
159
|
+
|
|
160
|
+
# Features
|
|
161
|
+
features:
|
|
162
|
+
syntax_highlighting: false
|
|
163
|
+
line_numbers: false
|
|
164
|
+
search_index: false
|
|
165
|
+
|
|
166
|
+
# Custom commands (optional)
|
|
167
|
+
# custom_commands:
|
|
168
|
+
# - name: "changelog"
|
|
169
|
+
# description: "Show changelog"
|
|
170
|
+
# type: "file"
|
|
171
|
+
# path: "/CHANGELOG.md"
|
|
172
|
+
|
|
173
|
+
# Path mappings (optional)
|
|
174
|
+
# mounts:
|
|
175
|
+
# - virtual: "/api"
|
|
176
|
+
# real: "./api-docs"
|
|
177
|
+
|
|
178
|
+
# Ignore patterns (like .gitignore)
|
|
179
|
+
ignore:
|
|
180
|
+
- "*.pyc"
|
|
181
|
+
- "__pycache__"
|
|
182
|
+
- ".git"
|
|
183
|
+
- "node_modules"
|
|
184
|
+
"""
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def auto_detect_content_root() -> Path:
|
|
188
|
+
"""Auto-detect documentation directory.
|
|
189
|
+
|
|
190
|
+
Looks for common documentation directories in order:
|
|
191
|
+
- docs/
|
|
192
|
+
- documentation/
|
|
193
|
+
- public/
|
|
194
|
+
- dist/
|
|
195
|
+
- Current directory as fallback
|
|
196
|
+
|
|
197
|
+
Returns:
|
|
198
|
+
Path to detected content directory.
|
|
199
|
+
"""
|
|
200
|
+
candidates = ["docs", "documentation", "public", "dist"]
|
|
201
|
+
|
|
202
|
+
for candidate in candidates:
|
|
203
|
+
path = Path(candidate)
|
|
204
|
+
if path.exists() and path.is_dir():
|
|
205
|
+
return path
|
|
206
|
+
|
|
207
|
+
return Path(".")
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def auto_detect_site_name() -> str:
|
|
211
|
+
"""Auto-detect site name from project files.
|
|
212
|
+
|
|
213
|
+
Checks in order:
|
|
214
|
+
- package.json (name field)
|
|
215
|
+
- pyproject.toml (project.name field)
|
|
216
|
+
- Directory name
|
|
217
|
+
|
|
218
|
+
Returns:
|
|
219
|
+
Detected site name.
|
|
220
|
+
"""
|
|
221
|
+
# Try package.json
|
|
222
|
+
package_json = Path("package.json")
|
|
223
|
+
if package_json.exists():
|
|
224
|
+
try:
|
|
225
|
+
import json
|
|
226
|
+
with open(package_json, "r") as f:
|
|
227
|
+
data = json.load(f)
|
|
228
|
+
if "name" in data:
|
|
229
|
+
return data["name"]
|
|
230
|
+
except Exception:
|
|
231
|
+
pass
|
|
232
|
+
|
|
233
|
+
# Try pyproject.toml
|
|
234
|
+
pyproject = Path("pyproject.toml")
|
|
235
|
+
if pyproject.exists():
|
|
236
|
+
try:
|
|
237
|
+
try:
|
|
238
|
+
import tomllib
|
|
239
|
+
except ImportError:
|
|
240
|
+
tomllib = None
|
|
241
|
+
|
|
242
|
+
if tomllib:
|
|
243
|
+
with open(pyproject, "rb") as f:
|
|
244
|
+
data = tomllib.load(f)
|
|
245
|
+
if "project" in data and "name" in data["project"]:
|
|
246
|
+
return data["project"]["name"]
|
|
247
|
+
except Exception:
|
|
248
|
+
pass
|
|
249
|
+
|
|
250
|
+
# Fallback to directory name
|
|
251
|
+
return Path.cwd().name
|