toss-cli 1.0.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.
toss_cli/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "1.0.0"
toss_cli/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from toss_cli.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
toss_cli/cli.py ADDED
@@ -0,0 +1,96 @@
1
+ import argparse
2
+ import sys
3
+
4
+ from toss_cli.config import init_config, load_config
5
+ from toss_cli.deploy import deploy
6
+ from toss_cli.remote import get_listings, hide_slug, undeploy_slug, unhide_slug
7
+ from toss_cli.ssh import validate_slug
8
+
9
+
10
+ def _cmd_list() -> None:
11
+ config = load_config()
12
+ entries = get_listings(config)
13
+ if not entries:
14
+ print("No deployments found.")
15
+ return
16
+ col = max(len(slug) for slug, _, _ in entries)
17
+ col = max(col, 4)
18
+ domain = config["domain"]
19
+ print(f"{'SLUG':<{col}} {'LINK':<{len(domain) + col + 2}} SIZE")
20
+ print("-" * (col + len(domain) + col + 12))
21
+ for slug, hidden, size in sorted(entries):
22
+ link = "[hidden]" if hidden else f"{domain}/{slug}/"
23
+ print(f"{slug:<{col}} {link:<{len(domain) + col + 2}} {size}")
24
+
25
+
26
+ def main() -> None:
27
+ parser = argparse.ArgumentParser(
28
+ prog="toss",
29
+ description="Deploy static sites, HTML, and Markdown to your own server.",
30
+ )
31
+ subparsers = parser.add_subparsers(dest="command")
32
+
33
+ subparsers.add_parser("init", help="interactive configuration setup")
34
+
35
+ p_deploy = subparsers.add_parser("deploy", help="deploy a file or directory")
36
+ p_deploy.add_argument("path", nargs="?", help="file or directory to deploy")
37
+ p_deploy.add_argument("--slug", help="custom slug (default: random)")
38
+ p_deploy.add_argument("--build", metavar="CMD", help="run a build command first")
39
+ p_deploy.add_argument("--out", metavar="DIR", help="build output directory (with --build)")
40
+
41
+ subparsers.add_parser("list", help="list all deployments")
42
+
43
+ p_hide = subparsers.add_parser("hide", help="make a deployment inaccessible")
44
+ p_hide.add_argument("slug")
45
+
46
+ p_unhide = subparsers.add_parser("unhide", help="restore a hidden deployment")
47
+ p_unhide.add_argument("slug")
48
+
49
+ p_undeploy = subparsers.add_parser("undeploy", help="permanently delete a deployment")
50
+ p_undeploy.add_argument("slug")
51
+
52
+ args = parser.parse_args()
53
+
54
+ if args.command is None:
55
+ parser.print_help()
56
+ sys.exit(1)
57
+
58
+ try:
59
+ if args.command == "init":
60
+ init_config()
61
+
62
+ elif args.command == "deploy":
63
+ if not args.build and not args.path:
64
+ p_deploy.error("path is required unless --build is specified")
65
+ url = deploy(path=args.path, slug=args.slug, build_cmd=args.build, out_dir=args.out)
66
+ print(url)
67
+
68
+ elif args.command == "list":
69
+ _cmd_list()
70
+
71
+ elif args.command == "hide":
72
+ validate_slug(args.slug)
73
+ hide_slug(load_config(), args.slug)
74
+ print(f"Hidden: {args.slug}")
75
+
76
+ elif args.command == "unhide":
77
+ validate_slug(args.slug)
78
+ unhide_slug(load_config(), args.slug)
79
+ print(f"Restored: {args.slug}")
80
+
81
+ elif args.command == "undeploy":
82
+ validate_slug(args.slug)
83
+ answer = input(f"Permanently delete '{args.slug}'? [y/N] ").strip().lower()
84
+ if answer != "y":
85
+ print("Cancelled.")
86
+ return
87
+ undeploy_slug(load_config(), args.slug)
88
+ print(f"Deleted: {args.slug}")
89
+
90
+ except (FileNotFoundError, RuntimeError, ValueError) as e:
91
+ print(f"error: {e}", file=sys.stderr)
92
+ sys.exit(1)
93
+
94
+
95
+ if __name__ == "__main__":
96
+ main()
toss_cli/config.py ADDED
@@ -0,0 +1,101 @@
1
+ import shutil
2
+ import time
3
+ from pathlib import Path
4
+
5
+ import tomllib
6
+
7
+ from toss_cli.ssh import run_ssh
8
+
9
+ CONFIG_PATH = Path.home() / ".config" / "toss" / "config.toml"
10
+
11
+ DEFAULTS: dict[str, object] = {
12
+ "remote_path": "/srv/sites",
13
+ "slug_length": 6,
14
+ }
15
+
16
+
17
+ REQUIRED_KEYS = ("host", "domain", "remote_path", "slug_length")
18
+
19
+
20
+ def load_config() -> dict[str, object]:
21
+ if not CONFIG_PATH.exists():
22
+ raise FileNotFoundError("no config found - run `toss init` to set up")
23
+ with open(CONFIG_PATH, "rb") as f:
24
+ config = tomllib.load(f)
25
+ missing = [k for k in REQUIRED_KEYS if k not in config]
26
+ if missing:
27
+ raise ValueError(f"config missing required keys: {', '.join(missing)}\nrun `toss init` to fix your configuration.")
28
+ return config
29
+
30
+
31
+ def _check_tools() -> None:
32
+ missing = [t for t in ("rsync", "ssh") if shutil.which(t) is None]
33
+ if missing:
34
+ raise RuntimeError(
35
+ f"Missing the following dependencies: {', '.join(missing)}\nPlease them via your system package manager (e.g. apt install rsync openssh-client)."
36
+ )
37
+
38
+
39
+ def _prompt(label: str, default: str | int | None = None) -> str:
40
+ hint = f" [{default}]" if default is not None else ""
41
+ while True:
42
+ value = input(f"{label}{hint}: ").strip()
43
+ if value:
44
+ return value
45
+ if default is not None:
46
+ return str(default)
47
+ print(f" {label} is required")
48
+
49
+
50
+ def _validate_ssh(host: str) -> tuple[bool, float, str]:
51
+ start = time.monotonic()
52
+ result = run_ssh(host, "exit", opts=["-q", "-o", "ConnectTimeout=5", "-o", "BatchMode=yes"])
53
+ elapsed = time.monotonic() - start
54
+ return result.returncode == 0, elapsed, result.stderr.strip()
55
+
56
+
57
+ def _toml_str(value: str) -> str:
58
+ return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"'
59
+
60
+
61
+ def init_config() -> None:
62
+ _check_tools()
63
+
64
+ print("=== toss init - interactive configuration ===\n")
65
+
66
+ existing: dict[str, object] = {}
67
+ if CONFIG_PATH.exists():
68
+ with open(CONFIG_PATH, "rb") as f:
69
+ existing = tomllib.load(f)
70
+ print(f"A config file was found at {CONFIG_PATH}, spam enter to keep current values\n")
71
+
72
+ host = _prompt("SSH host (e.g. user@myserver)", existing.get("host"))
73
+ domain = _prompt("domain (e.g. share.mydomain.com)", existing.get("domain"))
74
+ remote_path = _prompt("remote path", existing.get("remote_path", DEFAULTS["remote_path"]))
75
+ slug_length_raw = _prompt("slug length", existing.get("slug_length", DEFAULTS["slug_length"]))
76
+
77
+ try:
78
+ slug_length = int(slug_length_raw)
79
+ if slug_length < 2:
80
+ print("Warning: slug length below 2 risks frequent collisions.")
81
+ except ValueError:
82
+ print("Warning: invalid slug length, using default (6).")
83
+ slug_length = 6
84
+
85
+ print(f"\nTesting SSH connection to {host}...")
86
+ ok, elapsed, err = _validate_ssh(host)
87
+ if ok:
88
+ print(f"SSH connection OK! ({elapsed:.2f}s)")
89
+ else:
90
+ print(f"Warning: could not connect to {host}. Saving config anyway.")
91
+ if err:
92
+ print(f" {err}")
93
+
94
+ CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
95
+ with open(CONFIG_PATH, "w", encoding="utf-8") as f:
96
+ f.write(f"host = {_toml_str(host)}\n")
97
+ f.write(f"domain = {_toml_str(domain)}\n")
98
+ f.write(f"remote_path = {_toml_str(remote_path)}\n")
99
+ f.write(f"slug_length = {slug_length}\n")
100
+
101
+ print(f"\nConfig saved to {CONFIG_PATH}")
toss_cli/deploy.py ADDED
@@ -0,0 +1,106 @@
1
+ import random
2
+ import shutil
3
+ import string
4
+ import subprocess
5
+ import tempfile
6
+ from pathlib import Path
7
+
8
+ from toss_cli import remote, templates
9
+ from toss_cli.ssh import validate_slug
10
+
11
+ _BUILD_OUT_DIRS = ("dist", "build", "out", "public")
12
+
13
+
14
+ def _random_slug(length: int) -> str:
15
+ chars = string.ascii_lowercase + string.digits
16
+ return "".join(random.choices(chars, k=length))
17
+
18
+
19
+ def _find_free_slug(config: dict) -> str:
20
+ for _ in range(5):
21
+ slug = _random_slug(config["slug_length"])
22
+ if not remote.check_slug_exists(config, slug):
23
+ return slug
24
+ raise RuntimeError("Could not find a free slug after 5 attempts, try again")
25
+
26
+
27
+ def _detect_input(path: Path) -> str:
28
+ if path.is_dir():
29
+ return "directory"
30
+ if path.suffix == ".md":
31
+ return "md"
32
+ if path.suffix in (".html", ".htm"):
33
+ return "html"
34
+ raise ValueError(f"Unsupported file type: {path.suffix} (expected .md, .html, or a directory)")
35
+
36
+
37
+ def _prepare_build(cmd: str, out_dir: str | None) -> Path:
38
+ result = subprocess.run(cmd, shell=True)
39
+ if result.returncode != 0:
40
+ raise RuntimeError(f"Build command failed with exit code {result.returncode}")
41
+ if out_dir:
42
+ p = Path(out_dir)
43
+ if not p.is_dir():
44
+ raise RuntimeError(f"--out directory not found: {out_dir}")
45
+ return p
46
+ for name in _BUILD_OUT_DIRS:
47
+ p = Path(name)
48
+ if p.is_dir():
49
+ return p
50
+ raise RuntimeError("Build succeeded but no output directory found, use --out")
51
+
52
+
53
+ def deploy(
54
+ path: str,
55
+ slug: str | None = None,
56
+ build_cmd: str | None = None,
57
+ out_dir: str | None = None,
58
+ ) -> str:
59
+ from toss_cli.config import load_config
60
+
61
+ config = load_config()
62
+
63
+ # build mode
64
+ if build_cmd:
65
+ local_dir = _prepare_build(build_cmd, out_dir)
66
+ input_type = "directory"
67
+ else:
68
+ p = Path(path)
69
+ if not p.exists():
70
+ raise FileNotFoundError(f"Path not found: {path}")
71
+ input_type = _detect_input(p)
72
+ local_dir = None
73
+
74
+ # resolve slug
75
+ if slug:
76
+ validate_slug(slug)
77
+ if remote.check_slug_exists(config, slug):
78
+ answer = input(f"'{slug}' is already taken, overwrite? [y/N] ").strip().lower()
79
+ if answer != "y":
80
+ raise RuntimeError("Deploy cancelled")
81
+ else:
82
+ slug = _find_free_slug(config)
83
+
84
+ # prepare local staging dir
85
+ tmp = None
86
+ try:
87
+ if input_type == "md":
88
+ md_text = Path(path).read_text(encoding="utf-8")
89
+ title = Path(path).stem.replace("-", " ").replace("_", " ").title()
90
+ html = templates.render_page(title, md_text)
91
+ tmp = tempfile.mkdtemp()
92
+ Path(tmp, "index.html").write_text(html, encoding="utf-8")
93
+ local_dir = tmp
94
+ elif input_type == "html":
95
+ tmp = tempfile.mkdtemp()
96
+ shutil.copy(Path(path), Path(tmp, "index.html"))
97
+ local_dir = tmp
98
+ else:
99
+ local_dir = str(local_dir or path)
100
+
101
+ remote.rsync_deploy(config, str(local_dir), slug)
102
+ finally:
103
+ if tmp:
104
+ shutil.rmtree(tmp, ignore_errors=True)
105
+
106
+ return f"https://{config['domain']}/{slug}/"
toss_cli/remote.py ADDED
@@ -0,0 +1,92 @@
1
+ import shlex
2
+ import subprocess
3
+ from pathlib import PurePosixPath
4
+
5
+ from toss_cli.ssh import run_ssh
6
+
7
+
8
+ def _q(path) -> str:
9
+ return shlex.quote(str(path))
10
+
11
+
12
+ def check_slug_exists(config: dict, slug: str) -> bool:
13
+ remote = PurePosixPath(config["remote_path"]) / slug
14
+ result = run_ssh(config["host"], f"test -d {_q(remote)}")
15
+ return result.returncode == 0
16
+
17
+
18
+ def _check_hidden_exists(config: dict, slug: str) -> bool:
19
+ remote = PurePosixPath(config["remote_path"]) / f".{slug}"
20
+ result = run_ssh(config["host"], f"test -d {_q(remote)}")
21
+ return result.returncode == 0
22
+
23
+
24
+ def get_listings(config: dict) -> list[tuple[str, bool, str]]:
25
+ """Return [(slug, is_hidden, size)] for all deployments."""
26
+ remote = _q(config["remote_path"])
27
+ cmd = f"find {remote} -mindepth 1 -maxdepth 1 -type d | xargs -I{{}} du -sh {{}} 2>/dev/null"
28
+ result = run_ssh(config["host"], cmd)
29
+ if result.returncode != 0 and result.stderr.strip():
30
+ raise RuntimeError(f"List failed: {result.stderr.strip()}")
31
+
32
+ entries = []
33
+ for line in result.stdout.splitlines():
34
+ line = line.strip()
35
+ if not line:
36
+ continue
37
+ size, path = line.split("\t", 1)
38
+ name = PurePosixPath(path).name
39
+ if name.startswith("."):
40
+ entries.append((name[1:], True, size))
41
+ else:
42
+ entries.append((name, False, size))
43
+ return entries
44
+
45
+
46
+ def hide_slug(config: dict, slug: str) -> None:
47
+ if _check_hidden_exists(config, slug):
48
+ raise ValueError(f"'{slug}' is already hidden")
49
+ if not check_slug_exists(config, slug):
50
+ raise ValueError(f"'{slug}' not found")
51
+ base = PurePosixPath(config["remote_path"])
52
+ result = run_ssh(config["host"], f"mv {_q(base / slug)} {_q(base / ('.' + slug))}")
53
+ if result.returncode != 0:
54
+ raise RuntimeError(f"Hide failed: {result.stderr.strip()}")
55
+
56
+
57
+ def unhide_slug(config: dict, slug: str) -> None:
58
+ if check_slug_exists(config, slug):
59
+ raise ValueError(f"'{slug}' is already visible")
60
+ if not _check_hidden_exists(config, slug):
61
+ raise ValueError(f"'{slug}' not found")
62
+ base = PurePosixPath(config["remote_path"])
63
+ result = run_ssh(config["host"], f"mv {_q(base / ('.' + slug))} {_q(base / slug)}")
64
+ if result.returncode != 0:
65
+ raise RuntimeError(f"Unhide failed: {result.stderr.strip()}")
66
+
67
+
68
+ def undeploy_slug(config: dict, slug: str) -> None:
69
+ if not check_slug_exists(config, slug) and not _check_hidden_exists(config, slug):
70
+ raise ValueError(f"'{slug}' not found")
71
+ base = PurePosixPath(config["remote_path"])
72
+ # remove whichever form exists (visible or hidden)
73
+ target = base / (f".{slug}" if _check_hidden_exists(config, slug) else slug)
74
+ result = run_ssh(config["host"], f"rm -rf {_q(target)}")
75
+ if result.returncode != 0:
76
+ raise RuntimeError(f"Undeploy failed: {result.stderr.strip()}")
77
+
78
+
79
+ def rsync_deploy(config: dict, local_dir: str, slug: str) -> None:
80
+ remote_dest = f"{config['host']}:{config['remote_path']}/{slug}/"
81
+ result = subprocess.run(
82
+ ["rsync", "-az", "--delete", "-e", "ssh", f"{local_dir}/", remote_dest],
83
+ stderr=subprocess.PIPE,
84
+ text=True,
85
+ )
86
+ if result.returncode != 0:
87
+ stderr = result.stderr.strip()
88
+ if "Permission denied" in stderr:
89
+ raise RuntimeError(f"Deploy failed: permission denied on remote\n {stderr}")
90
+ if "Connection refused" in stderr or "No route to host" in stderr:
91
+ raise RuntimeError(f"Deploy failed: could not reach host\n {stderr}")
92
+ raise RuntimeError(f"Deploy failed\n {stderr}")
toss_cli/ssh.py ADDED
@@ -0,0 +1,17 @@
1
+ import re
2
+ import subprocess
3
+
4
+ _SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$")
5
+
6
+
7
+ def validate_slug(slug: str) -> None:
8
+ if not _SLUG_RE.match(slug):
9
+ raise ValueError(f"Invalid slug '{slug}': use lowercase letters, digits, and hyphens only")
10
+
11
+
12
+ def run_ssh(host: str, cmd: str, opts: list[str] | None = None) -> subprocess.CompletedProcess:
13
+ return subprocess.run(
14
+ ["ssh", *(opts or []), host, cmd],
15
+ capture_output=True,
16
+ text=True,
17
+ )
@@ -0,0 +1,8 @@
1
+ import html
2
+ import json
3
+ from importlib.resources import files
4
+
5
+
6
+ def render_page(title: str, content: str) -> str:
7
+ template = files(__name__).joinpath("markdown_page.html").read_text(encoding="utf-8")
8
+ return template.replace("TOSS_TITLE", html.escape(title)).replace("TOSS_CONTENT", json.dumps(content))
@@ -0,0 +1,163 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>TOSS_TITLE</title>
7
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16/dist/katex.min.css">
8
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/styles/monokai-sublime.min.css">
9
+ <style>
10
+ body {
11
+ max-width: 740px;
12
+ margin: 4rem auto;
13
+ padding: 0 1.5rem;
14
+ font-family: Georgia, 'Times New Roman', serif;
15
+ font-size: 1.1rem;
16
+ line-height: 1.7;
17
+ color: #f8f8f2;
18
+ background: #272822;
19
+ }
20
+ h1, h2, h3, h4, h5, h6 {
21
+ font-family: system-ui, sans-serif;
22
+ line-height: 1.3;
23
+ margin-top: 2rem;
24
+ color: #f8f8f2;
25
+ }
26
+ a { color: #66d9ef; }
27
+ a:hover { color: #ae81ff; }
28
+ code {
29
+ font-family: 'SF Mono', 'Fira Code', 'JetBrains Mono', monospace;
30
+ font-size: 0.88em;
31
+ background: #3e3d32;
32
+ padding: 0.15em 0.35em;
33
+ border-radius: 3px;
34
+ color: #a6e22e;
35
+ }
36
+ pre {
37
+ border-radius: 6px;
38
+ overflow-x: auto;
39
+ margin: 1.5rem 0;
40
+ }
41
+ pre code {
42
+ font-family: 'SF Mono', 'Fira Code', 'JetBrains Mono', monospace;
43
+ font-size: 0.9em;
44
+ background: none;
45
+ padding: 0;
46
+ border-radius: 0;
47
+ color: inherit;
48
+ }
49
+ blockquote {
50
+ border-left: 3px solid #75715e;
51
+ margin: 0;
52
+ padding-left: 1.25rem;
53
+ color: #75715e;
54
+ }
55
+ hr { border-color: #3e3d32; }
56
+ img { max-width: 100%; }
57
+ table { border-collapse: collapse; width: 100%; }
58
+ th, td {
59
+ border: 1px solid #3e3d32;
60
+ padding: 0.5rem 0.75rem;
61
+ text-align: left;
62
+ }
63
+ th { background: #3e3d32; color: #a6e22e; }
64
+ /* footnotes */
65
+ .footnotes { margin-top: 3rem; border-top: 1px solid #3e3d32; padding-top: 1rem; font-size: 0.9em; color: #75715e; }
66
+ .footnotes a { color: #75715e; }
67
+ sup a { color: #ae81ff; text-decoration: none; }
68
+ /* callout blocks */
69
+ .markdown-alert {
70
+ padding: 0.75rem 1rem;
71
+ border-left: 4px solid;
72
+ border-radius: 0 6px 6px 0;
73
+ margin: 1.5rem 0;
74
+ }
75
+ .markdown-alert p { margin: 0; }
76
+ .markdown-alert-title {
77
+ font-weight: bold;
78
+ font-family: system-ui, sans-serif;
79
+ font-size: 0.9em;
80
+ margin-bottom: 0.4rem;
81
+ display: flex;
82
+ align-items: center;
83
+ gap: 0.4rem;
84
+ }
85
+ .markdown-alert-title svg { fill: currentColor; }
86
+ .markdown-alert-note { border-color: #66d9ef; background: #1a2a2e; }
87
+ .markdown-alert-note .markdown-alert-title { color: #66d9ef; }
88
+ .markdown-alert-tip { border-color: #a6e22e; background: #1e2a1a; }
89
+ .markdown-alert-tip .markdown-alert-title { color: #a6e22e; }
90
+ .markdown-alert-important { border-color: #ae81ff; background: #1e1a2e; }
91
+ .markdown-alert-important .markdown-alert-title { color: #ae81ff; }
92
+ .markdown-alert-warning { border-color: #e6db74; background: #2a2a1a; }
93
+ .markdown-alert-warning .markdown-alert-title { color: #e6db74; }
94
+ .markdown-alert-caution { border-color: #f92672; background: #2a1a1e; }
95
+ .markdown-alert-caution .markdown-alert-title { color: #f92672; }
96
+ /* mermaid diagrams */
97
+ .mermaid { margin: 1.5rem 0; text-align: center; }
98
+ @media print {
99
+ body { max-width: 100%; margin: 0; background: white; color: black; }
100
+ a { color: inherit; text-decoration: none; }
101
+ }
102
+ </style>
103
+ </head>
104
+ <body>
105
+ <div id="content"></div>
106
+ <script src="https://cdn.jsdelivr.net/npm/marked@15/marked.min.js"></script>
107
+ <script src="https://cdn.jsdelivr.net/npm/marked-footnote/dist/index.umd.min.js"></script>
108
+ <script src="https://cdn.jsdelivr.net/npm/marked-alert/dist/index.umd.min.js"></script>
109
+ <script src="https://cdn.jsdelivr.net/npm/katex@0.16/dist/katex.min.js"></script>
110
+ <script src="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/highlight.min.js"></script>
111
+ <script>
112
+ function extractMath(md) {
113
+ const blocks = [];
114
+ md = md.replace(/\$\$([\s\S]+?)\$\$/g, (_, content) => {
115
+ return "TOSS_MATH_" + (blocks.push({ content, display: true }) - 1) + "_END";
116
+ });
117
+ md = md.replace(/\$([^\n$]+?)\$/g, (_, content) => {
118
+ return "TOSS_MATH_" + (blocks.push({ content, display: false }) - 1) + "_END";
119
+ });
120
+ return { md, blocks };
121
+ }
122
+
123
+ function restoreMath(el, blocks) {
124
+ const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
125
+ const nodes = [];
126
+ while (walker.nextNode()) nodes.push(walker.currentNode);
127
+ for (const node of nodes) {
128
+ if (!node.textContent.includes("TOSS_MATH_")) continue;
129
+ const span = document.createElement("span");
130
+ span.innerHTML = node.textContent.replace(/TOSS_MATH_(\d+)_END/g, (_, i) => {
131
+ const { content, display } = blocks[i];
132
+ return katex.renderToString(content, { displayMode: display, throwOnError: false });
133
+ });
134
+ node.parentNode.replaceChild(span, node);
135
+ }
136
+ }
137
+
138
+ marked.use(markedFootnote());
139
+ marked.use(markedAlert());
140
+ marked.use({
141
+ renderer: {
142
+ code(token) {
143
+ if (token.lang === "mermaid") {
144
+ return `<pre class="mermaid">${token.text}</pre>`;
145
+ }
146
+ return false;
147
+ }
148
+ }
149
+ });
150
+
151
+ const MARKDOWN = TOSS_CONTENT;
152
+ const { md, blocks } = extractMath(MARKDOWN);
153
+ document.getElementById("content").innerHTML = marked.parse(md);
154
+ restoreMath(document.getElementById("content"), blocks);
155
+ document.querySelectorAll("pre code").forEach(block => hljs.highlightElement(block));
156
+ </script>
157
+ <script type="module">
158
+ import mermaid from "https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs";
159
+ mermaid.initialize({ startOnLoad: false, theme: "dark" });
160
+ mermaid.run();
161
+ </script>
162
+ </body>
163
+ </html>
@@ -0,0 +1,152 @@
1
+ Metadata-Version: 2.4
2
+ Name: toss-cli
3
+ Version: 1.0.0
4
+ Summary: Minimal CLI to deploy and share static sites, HTML, and Markdown from your own server.
5
+ Project-URL: Homepage, https://github.com/brayevalerien/toss
6
+ Project-URL: Repository, https://github.com/brayevalerien/toss
7
+ Author-email: Valérien <contact@brayevalerien.com>
8
+ License: MIT License
9
+
10
+ Copyright (c) 2026 Valérien
11
+
12
+ Permission is hereby granted, free of charge, to any person obtaining a copy
13
+ of this software and associated documentation files (the "Software"), to deal
14
+ in the Software without restriction, including without limitation the rights
15
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
16
+ copies of the Software, and to permit persons to whom the Software is
17
+ furnished to do so, subject to the following conditions:
18
+
19
+ The above copyright notice and this permission notice shall be included in all
20
+ copies or substantial portions of the Software.
21
+
22
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
23
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
24
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
25
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
26
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
27
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
28
+ SOFTWARE.
29
+ License-File: LICENSE
30
+ Keywords: caddy,cli,deploy,file-sharing,latex,markdown,rsync,self-hosted,static-site
31
+ Classifier: Environment :: Console
32
+ Classifier: License :: OSI Approved :: MIT License
33
+ Classifier: Programming Language :: Python :: 3
34
+ Requires-Python: >=3.10
35
+ Description-Content-Type: text/markdown
36
+
37
+ <div align="center">
38
+ <img src="assets/logo-circle.svg" alt="toss logo" width="96" />
39
+ </div>
40
+
41
+ # Toss
42
+ Toss is a minimal CLI to deploy and share static sites, HTML, and Markdown from your own server. Fast configuration and easy to setup so you can use it from about everywhere and share your work with private URLs.
43
+
44
+ ## Features
45
+ - Deploy a Markdown file, an HTML file, or a full directory with a single command
46
+ - Markdown pages render in the browser with LaTeX (KaTeX), syntax highlighting, footnotes, callouts, and Mermaid diagrams
47
+ - Random slugs by default, custom slugs with `--slug`
48
+ - Build-and-deploy in one step with `--build`
49
+ - List, hide, unhide, and permanently delete deployments
50
+ - Zero server-side dependencies beyond Caddy and SSH access
51
+
52
+ ## Installation and setup
53
+ > [!NOTE]
54
+ > The following prerequisites are needed before installing toss.
55
+ > - [Python](https://www.python.org/) 3.10+
56
+ > - [uv](https://docs.astral.sh/uv/)
57
+ > - [git](https://git-scm.com/)
58
+ > - `rsync` and `ssh` available in PATH (client-side)
59
+ > - SSH access to a server running [Caddy](https://caddyserver.com/)
60
+
61
+ ### Server-side setup
62
+ You'll first need to configure your server to serve files through SSH and [Caddy](https://caddyserver.com/).
63
+ This section assumes you are running the commands on your server.
64
+
65
+ Add a DNS A record: `share` (or anything you like) pointing to your server IP.
66
+
67
+ Create the sites directory and give your SSH user write access:
68
+ ```sh
69
+ sudo mkdir -p /srv/sites
70
+ sudo chown youruser:youruser /srv/sites
71
+ ```
72
+
73
+ Add a block to your Caddyfile for the share subdomain:
74
+ ```
75
+ share.yourdomain.com {
76
+ root * /srv/sites
77
+ file_server
78
+ header X-Robots-Tag "noindex, nofollow"
79
+ handle_errors {
80
+ rewrite * /404.html
81
+ file_server
82
+ }
83
+ }
84
+ ```
85
+
86
+ If running Caddy in Docker, also mount `/srv/sites` in your Caddy container's volumes:
87
+ ```yaml
88
+ volumes:
89
+ - /srv/sites:/srv/sites:ro
90
+ ```
91
+
92
+ Copy the 404 page to your server. Feel free to edit `assets/404.html` to add your own contact info, and edit `toss_cli/templates/markdown_page.html` to customize the Markdown rendering theme before installing:
93
+ ```sh
94
+ scp assets/404.html user@your-server:/srv/sites/404.html
95
+ ```
96
+
97
+ Then restart Caddy:
98
+ ```sh
99
+ # assuming systemd
100
+ sudo systemctl reload caddy
101
+ # or if using Docker
102
+ docker compose up -d caddy
103
+ ```
104
+
105
+ ### Local CLI
106
+ Once the server is configured, clone the repo and install toss locally:
107
+ ```sh
108
+ git clone https://github.com/brayevalerien/toss
109
+ cd toss
110
+ uv tool install .
111
+ ```
112
+
113
+ Once toss is installed, run the interactive setup wizard once:
114
+ ```sh
115
+ toss init
116
+ ```
117
+ This will prompt you for your server details, validate SSH connectivity, and save a config file at `~/.config/toss/config.toml`.
118
+
119
+ > [!NOTE]
120
+ > Re-run `toss init` at any time to update your configuration. If you prefer to edit it manually, it uses the following format:
121
+ > ```toml
122
+ > host = "user@my-server"
123
+ > domain = "share.mydomain.com"
124
+ > remote_path = "/srv/sites"
125
+ > slug_length = 6
126
+ > ```
127
+
128
+ ## Usage
129
+
130
+ ```sh
131
+ # deploy a Markdown file, an HTML file, or a directory
132
+ toss deploy path/to/file.md
133
+ toss deploy path/to/page.html
134
+ toss deploy path/to/site/
135
+
136
+ # custom slug
137
+ toss deploy report.md --slug my-report
138
+
139
+ # build then deploy
140
+ toss deploy . --build "npm run build"
141
+ toss deploy . --build "npm run build" --out dist
142
+
143
+ # list all deployments
144
+ toss list
145
+
146
+ # hide (makes URL return 404) and unhide
147
+ toss hide <slug>
148
+ toss unhide <slug>
149
+
150
+ # permanently delete
151
+ toss undeploy <slug>
152
+ ```
@@ -0,0 +1,14 @@
1
+ toss_cli/__init__.py,sha256=J-j-u0itpEFT6irdmWmixQqYMadNl1X91TxUmoiLHMI,22
2
+ toss_cli/__main__.py,sha256=kPJckXHdzH6_ruoA3omAhzNubgq1RD2sETYUCGsaYmw,69
3
+ toss_cli/cli.py,sha256=gaT71KV2J1j4OQuv1XlKkH6lpIp39X5of3_B_doqUb4,3334
4
+ toss_cli/config.py,sha256=fP4IhXFMJsa5X21DrWBZJ-AJzyEfLMMWkBxwfG2RTqE,3457
5
+ toss_cli/deploy.py,sha256=tac8nasLvp-w-RSmCQndyp9wvxX4J61VdILwavQRkxw,3208
6
+ toss_cli/remote.py,sha256=UKg9qfDdq0SrOMM9PL1Bh2RCzvFRPeGaWJK4zJp8Glg,3615
7
+ toss_cli/ssh.py,sha256=7GA1ahUaCN-MFzFI1-0ipSkbzr9bzNSmTUsZUEae3OE,490
8
+ toss_cli/templates/__init__.py,sha256=XSYp7OvLRlK-2nN5RzudBdASiwJlThCWp8cmNT7jNf0,311
9
+ toss_cli/templates/markdown_page.html,sha256=CobFzF78wVdkUQ8UCPFmpiBpEkCpPBcyyV3SW4-G8ZU,5876
10
+ toss_cli-1.0.0.dist-info/METADATA,sha256=iHrCCXb835M36jHNNObjgn-bWUuWX4hT8gYrXQoo2p8,5278
11
+ toss_cli-1.0.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
12
+ toss_cli-1.0.0.dist-info/entry_points.txt,sha256=uhwYQXeA8qZ5urUTQWQsfN9YNGruyMz6pNqLvCTKYzs,43
13
+ toss_cli-1.0.0.dist-info/licenses/LICENSE,sha256=GAhgWHA2OR0YL7Q-Ueu1YDiuPC-SRIipdyalTWdX0z8,1066
14
+ toss_cli-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ toss = toss_cli.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Valérien
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.