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 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
@@ -0,0 +1,6 @@
1
+ """Allow running ssh-docs as a module: python -m ssh_docs"""
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
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