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 +1 -0
- toss_cli/__main__.py +4 -0
- toss_cli/cli.py +96 -0
- toss_cli/config.py +101 -0
- toss_cli/deploy.py +106 -0
- toss_cli/remote.py +92 -0
- toss_cli/ssh.py +17 -0
- toss_cli/templates/__init__.py +8 -0
- toss_cli/templates/markdown_page.html +163 -0
- toss_cli-1.0.0.dist-info/METADATA +152 -0
- toss_cli-1.0.0.dist-info/RECORD +14 -0
- toss_cli-1.0.0.dist-info/WHEEL +4 -0
- toss_cli-1.0.0.dist-info/entry_points.txt +2 -0
- toss_cli-1.0.0.dist-info/licenses/LICENSE +21 -0
toss_cli/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "1.0.0"
|
toss_cli/__main__.py
ADDED
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,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.
|