small-software 0.6.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.
- small_cli/__init__.py +1 -0
- small_cli/__main__.py +3 -0
- small_cli/access.py +114 -0
- small_cli/archive.py +127 -0
- small_cli/cli.py +76 -0
- small_cli/commands/__init__.py +8 -0
- small_cli/commands/access.py +167 -0
- small_cli/commands/dashboard.py +49 -0
- small_cli/commands/db.py +154 -0
- small_cli/commands/deploy.py +248 -0
- small_cli/commands/destroy.py +63 -0
- small_cli/commands/domain.py +123 -0
- small_cli/commands/env.py +151 -0
- small_cli/commands/init.py +86 -0
- small_cli/commands/link.py +53 -0
- small_cli/commands/logs.py +59 -0
- small_cli/commands/mail.py +203 -0
- small_cli/commands/notify.py +147 -0
- small_cli/commands/sleep.py +61 -0
- small_cli/common.py +92 -0
- small_cli/config.py +82 -0
- small_cli/dashboard.py +648 -0
- small_cli/i18n.py +55 -0
- small_cli/railway.py +369 -0
- small_cli/scaffold.py +81 -0
- small_cli/templates/fastapi/magic_link.py +675 -0
- small_cli/templates/fastapi/main.py +67 -0
- small_cli/templates/fastapi/requirements.txt +2 -0
- small_cli/templates/node-http/magic-link.js +509 -0
- small_cli/templates/node-http/package.json +11 -0
- small_cli/templates/node-http/server.js +33 -0
- small_cli/templates/python-http/app.py +47 -0
- small_cli/templates/python-http/magic_link.py +675 -0
- small_cli/templates/streamlit/app.py +181 -0
- small_cli/templates/streamlit/requirements.txt +1 -0
- small_software-0.6.0.dist-info/METADATA +559 -0
- small_software-0.6.0.dist-info/RECORD +41 -0
- small_software-0.6.0.dist-info/WHEEL +5 -0
- small_software-0.6.0.dist-info/entry_points.txt +2 -0
- small_software-0.6.0.dist-info/licenses/LICENSE +21 -0
- small_software-0.6.0.dist-info/top_level.txt +1 -0
small_cli/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.6.0"
|
small_cli/__main__.py
ADDED
small_cli/access.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Доступ к приложению: ACCESS_TOKEN (ссылки-приглашения "имя:токен,…") и ACCESS_EMAILS (вход по почте).
|
|
2
|
+
|
|
3
|
+
Тот же формат понимают magic_link.py, magic-link.js и шаблон streamlit: убрали чей-то токен — его ссылка
|
|
4
|
+
и куки перестают работать, у остальных всё остаётся. Убрали адрес из ACCESS_EMAILS — вход по нему закрыт сразу.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import re
|
|
8
|
+
import secrets
|
|
9
|
+
from urllib.parse import quote
|
|
10
|
+
|
|
11
|
+
from .config import CONFIG_FILE, load_config
|
|
12
|
+
from .i18n import tr
|
|
13
|
+
from .railway import Railway, RailwayError, token_from_env
|
|
14
|
+
from .scaffold import TEMPLATES_DIR
|
|
15
|
+
|
|
16
|
+
NAME_RE = re.compile(r"[A-Za-z0-9_.@-]{1,40}\Z")
|
|
17
|
+
EMAIL_RE = re.compile(r"[^@\s,;<>\"']+@[^@\s,;<>\"']+\.[^@\s,;<>\"']+\Z")
|
|
18
|
+
DOMAIN_RE = re.compile(r"@[^@\s,;<>\"']+\.[^@\s,;<>\"']+\Z")
|
|
19
|
+
MAIL_VARS = ("RESEND_API_KEY", "SMTP_URL", "LOGIN_LINKS_TO_LOG")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def parse(value):
|
|
23
|
+
""""alice:tok1, tok2" -> [("alice", "tok1"), ("", "tok2")]."""
|
|
24
|
+
entries = []
|
|
25
|
+
for item in (value or "").split(","):
|
|
26
|
+
name, _, token = item.strip().rpartition(":")
|
|
27
|
+
if token.strip():
|
|
28
|
+
entries.append((name.strip(), token.strip()))
|
|
29
|
+
return entries
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def serialize(entries):
|
|
33
|
+
return ",".join("%s:%s" % (name, token) if name else token for name, token in entries)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def new_token():
|
|
37
|
+
return secrets.token_urlsafe(32)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def is_email(value):
|
|
41
|
+
"""Адрес почты или целый домен "@company.com"."""
|
|
42
|
+
return bool(EMAIL_RE.match(value) or DOMAIN_RE.match(value))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def parse_emails(value):
|
|
46
|
+
""""Ivan@Company.com, @team.com" -> ["ivan@company.com", "@team.com"]: без регистра, повторов и мусора."""
|
|
47
|
+
found = []
|
|
48
|
+
for item in (value or "").split(","):
|
|
49
|
+
item = item.strip().lower()
|
|
50
|
+
if is_email(item) and item not in found:
|
|
51
|
+
found.append(item)
|
|
52
|
+
return found
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def app_env(root, state, warn=None):
|
|
56
|
+
"""(переменные приложения, откуда каждая): Railway, поверх — непустые значения из small.json."""
|
|
57
|
+
env, sources = {}, {}
|
|
58
|
+
linked = all(state.get(k) for k in ("projectId", "environmentId", "serviceId"))
|
|
59
|
+
if linked and token_from_env():
|
|
60
|
+
try:
|
|
61
|
+
env = dict(Railway(token_from_env()).service_variables(
|
|
62
|
+
state["projectId"], state["environmentId"], state["serviceId"]))
|
|
63
|
+
sources = dict.fromkeys(env, "Railway")
|
|
64
|
+
except RailwayError as e:
|
|
65
|
+
if warn:
|
|
66
|
+
warn(tr("Could not read the service variables from Railway: %s",
|
|
67
|
+
"Не удалось прочитать переменные сервиса в Railway: %s") % e)
|
|
68
|
+
for name, value in load_config(root / CONFIG_FILE)["env"].items():
|
|
69
|
+
if value:
|
|
70
|
+
env[name], sources[name] = value, CONFIG_FILE
|
|
71
|
+
return env, sources
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def current(root, state, warn=None):
|
|
75
|
+
"""(записи ACCESS_TOKEN, откуда): из small.json, иначе из переменных сервиса в Railway, иначе ([], None)."""
|
|
76
|
+
raw = load_config(root / CONFIG_FILE)["env"].get("ACCESS_TOKEN")
|
|
77
|
+
if raw:
|
|
78
|
+
return parse(raw), CONFIG_FILE
|
|
79
|
+
env, sources = app_env(root, state, warn)
|
|
80
|
+
if env.get("ACCESS_TOKEN"):
|
|
81
|
+
return parse(env["ACCESS_TOKEN"]), sources["ACCESS_TOKEN"]
|
|
82
|
+
return [], None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def pick(entries, name=None):
|
|
86
|
+
"""Токен человека name; без имени — первый (владельца). None, если такого нет."""
|
|
87
|
+
if name is None:
|
|
88
|
+
return entries[0][1] if entries else None
|
|
89
|
+
return next((token for who, token in entries if who == name), None)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def invite_link(domain, token):
|
|
93
|
+
url = "https://%s/" % domain
|
|
94
|
+
return url + "?token=" + quote(token, safe="") if token else url
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def email_login_problem(root):
|
|
98
|
+
"""None — код приложения умеет вход по почте; иначе — что мешает и как исправить."""
|
|
99
|
+
for name, template in (("magic_link.py", "python-http"), ("magic-link.js", "node-http")):
|
|
100
|
+
path = root / name
|
|
101
|
+
if path.is_file():
|
|
102
|
+
if "ACCESS_EMAILS" in path.read_text(encoding="utf-8", errors="replace"):
|
|
103
|
+
return None
|
|
104
|
+
return (tr("%s in the app is an old version and does not support sign-in by email. "
|
|
105
|
+
"Replace it with the new one: %s",
|
|
106
|
+
"%s в приложении старой версии и не знает входа по почте. Замените его новым: %s")
|
|
107
|
+
% (name, TEMPLATES_DIR / template / name))
|
|
108
|
+
app = root / "app.py"
|
|
109
|
+
if app.is_file() and "streamlit" in app.read_text(encoding="utf-8", errors="replace"):
|
|
110
|
+
return tr("the streamlit template does not support sign-in by email yet — "
|
|
111
|
+
"grant a personal link instead: small access add NAME",
|
|
112
|
+
"в шаблоне streamlit вход по почте пока не поддерживается — "
|
|
113
|
+
"выдайте личную ссылку: small access add ИМЯ")
|
|
114
|
+
return None
|
small_cli/archive.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Сбор файлов проекта с учётом .gitignore/.railwayignore и упаковка в .tar.gz для Railway."""
|
|
2
|
+
|
|
3
|
+
import io
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import tarfile
|
|
7
|
+
|
|
8
|
+
# Никогда не загружаются, даже без .gitignore.
|
|
9
|
+
ALWAYS_IGNORED = {".git", "node_modules", "__pycache__", ".venv", "venv"}
|
|
10
|
+
IGNORE_FILES = (".gitignore", ".railwayignore")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class IgnoreRules:
|
|
14
|
+
"""Подмножество синтаксиса .gitignore: *, **, ?, [...], !отрицание, /якорь, хвостовой / для папок.
|
|
15
|
+
|
|
16
|
+
Читаются только файлы в корне проекта; вложенные .gitignore не учитываются.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(self, lines):
|
|
20
|
+
self.rules = [] # (regex, negate, dir_only)
|
|
21
|
+
for raw in lines:
|
|
22
|
+
line = raw.rstrip("\r\n").rstrip()
|
|
23
|
+
if not line or line.startswith("#"):
|
|
24
|
+
continue
|
|
25
|
+
negate = line.startswith("!")
|
|
26
|
+
if negate:
|
|
27
|
+
line = line[1:]
|
|
28
|
+
if line.startswith("\\"):
|
|
29
|
+
line = line[1:]
|
|
30
|
+
dir_only = line.endswith("/")
|
|
31
|
+
line = line.rstrip("/")
|
|
32
|
+
anchored = "/" in line
|
|
33
|
+
line = line.lstrip("/")
|
|
34
|
+
if not line:
|
|
35
|
+
continue
|
|
36
|
+
pattern = _translate(line)
|
|
37
|
+
if not anchored:
|
|
38
|
+
pattern = "(?:.*/)?" + pattern
|
|
39
|
+
try:
|
|
40
|
+
self.rules.append((re.compile(pattern), negate, dir_only))
|
|
41
|
+
except re.error:
|
|
42
|
+
continue
|
|
43
|
+
|
|
44
|
+
@classmethod
|
|
45
|
+
def load(cls, root):
|
|
46
|
+
lines = []
|
|
47
|
+
for name in IGNORE_FILES:
|
|
48
|
+
path = os.path.join(root, name)
|
|
49
|
+
if os.path.isfile(path):
|
|
50
|
+
with open(path, encoding="utf-8", errors="replace") as f:
|
|
51
|
+
lines.extend(f)
|
|
52
|
+
return cls(lines)
|
|
53
|
+
|
|
54
|
+
def ignored(self, rel_path, is_dir):
|
|
55
|
+
result = False
|
|
56
|
+
for regex, negate, dir_only in self.rules:
|
|
57
|
+
if dir_only and not is_dir:
|
|
58
|
+
continue
|
|
59
|
+
if regex.fullmatch(rel_path):
|
|
60
|
+
result = not negate
|
|
61
|
+
return result
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _translate(pattern):
|
|
65
|
+
out, i = [], 0
|
|
66
|
+
while i < len(pattern):
|
|
67
|
+
if pattern.startswith("**/", i):
|
|
68
|
+
out.append("(?:.*/)?")
|
|
69
|
+
i += 3
|
|
70
|
+
elif pattern.startswith("**", i):
|
|
71
|
+
out.append(".*")
|
|
72
|
+
i += 2
|
|
73
|
+
elif pattern[i] == "*":
|
|
74
|
+
out.append("[^/]*")
|
|
75
|
+
i += 1
|
|
76
|
+
elif pattern[i] == "?":
|
|
77
|
+
out.append("[^/]")
|
|
78
|
+
i += 1
|
|
79
|
+
elif pattern[i] == "[" and "]" in pattern[i + 2:]:
|
|
80
|
+
end = pattern.index("]", i + 2)
|
|
81
|
+
body = pattern[i + 1:end]
|
|
82
|
+
if body.startswith("!"):
|
|
83
|
+
body = "^" + body[1:]
|
|
84
|
+
out.append("[" + body + "]")
|
|
85
|
+
i = end + 1
|
|
86
|
+
else:
|
|
87
|
+
out.append(re.escape(pattern[i]))
|
|
88
|
+
i += 1
|
|
89
|
+
return "".join(out)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def collect_files(root, skip_root_files=()):
|
|
93
|
+
"""Относительные пути (через /) всех файлов, которые уйдут в Railway.
|
|
94
|
+
|
|
95
|
+
skip_root_files — имена файлов, которые не загружаются, только если лежат в корне (служебные файлы CLI).
|
|
96
|
+
"""
|
|
97
|
+
rules = IgnoreRules.load(root)
|
|
98
|
+
files = []
|
|
99
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
100
|
+
rel_dir = os.path.relpath(dirpath, root).replace(os.sep, "/")
|
|
101
|
+
prefix = "" if rel_dir == "." else rel_dir + "/"
|
|
102
|
+
dirnames[:] = sorted(
|
|
103
|
+
d for d in dirnames
|
|
104
|
+
if d not in ALWAYS_IGNORED and not rules.ignored(prefix + d, is_dir=True)
|
|
105
|
+
)
|
|
106
|
+
for name in sorted(filenames):
|
|
107
|
+
rel = prefix + name
|
|
108
|
+
if name in ALWAYS_IGNORED or (not prefix and name in skip_root_files):
|
|
109
|
+
continue
|
|
110
|
+
if not rules.ignored(rel, is_dir=False):
|
|
111
|
+
files.append(rel)
|
|
112
|
+
return files
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def make_tarball(root, files):
|
|
116
|
+
buf = io.BytesIO()
|
|
117
|
+
with tarfile.open(fileobj=buf, mode="w:gz", dereference=True) as tar:
|
|
118
|
+
for rel in files:
|
|
119
|
+
tar.add(os.path.join(root, *rel.split("/")), arcname="./" + rel,
|
|
120
|
+
recursive=False, filter=_anonymize)
|
|
121
|
+
return buf.getvalue()
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _anonymize(info):
|
|
125
|
+
info.uid = info.gid = 0
|
|
126
|
+
info.uname = info.gname = ""
|
|
127
|
+
return info
|
small_cli/cli.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""small — микро-приложения на Railway одной командой.
|
|
2
|
+
|
|
3
|
+
Точка входа: разбор аргументов и обработка ошибок. Сами команды — в small_cli/commands/,
|
|
4
|
+
общие помощники — в small_cli/common.py.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import argparse
|
|
8
|
+
import codecs
|
|
9
|
+
import os
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
from . import __version__, commands
|
|
13
|
+
from .common import CliError
|
|
14
|
+
from .config import ConfigError
|
|
15
|
+
from .i18n import tr
|
|
16
|
+
from .railway import RailwayError
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def main(argv=None):
|
|
20
|
+
setup_streams()
|
|
21
|
+
args = build_parser().parse_args(argv)
|
|
22
|
+
try:
|
|
23
|
+
return args.func(args)
|
|
24
|
+
except (CliError, ConfigError, RailwayError) as e:
|
|
25
|
+
print(tr("Error: %s", "Ошибка: %s") % e, file=sys.stderr)
|
|
26
|
+
return 1
|
|
27
|
+
except KeyboardInterrupt:
|
|
28
|
+
print(tr("\nInterrupted. A build that has already started will keep running in Railway.",
|
|
29
|
+
"\nПрервано. Уже запущенная сборка продолжится в Railway."), file=sys.stderr)
|
|
30
|
+
return 130
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def setup_streams():
|
|
34
|
+
"""В терминал Python пишет Unicode сам. Вывод в канал или файл (`small logs | Select-String вход`) он кодирует
|
|
35
|
+
в ANSI (cp1251), а PowerShell читает его в кодировке консоли (866 или UTF-8): кириллица превращается в мусор,
|
|
36
|
+
и поиск по ней ничего не находит. Поэтому в канал пишем в кодировке, которой его прочитают."""
|
|
37
|
+
encoding = pipe_encoding()
|
|
38
|
+
for stream in (sys.stdout, sys.stderr):
|
|
39
|
+
if not hasattr(stream, "reconfigure"):
|
|
40
|
+
continue
|
|
41
|
+
if encoding and not stream.isatty():
|
|
42
|
+
stream.reconfigure(encoding=encoding, errors="replace")
|
|
43
|
+
else:
|
|
44
|
+
stream.reconfigure(errors="replace")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def pipe_encoding():
|
|
48
|
+
"""Кодировка для вывода в канал: PYTHONIOENCODING, если задана (small.cmd запускает `python -I`, и сам Python
|
|
49
|
+
её не читает), иначе на Windows — кодировка консоли. None — оставить как есть."""
|
|
50
|
+
explicit = os.environ.get("PYTHONIOENCODING", "").split(":")[0]
|
|
51
|
+
if explicit:
|
|
52
|
+
try:
|
|
53
|
+
return codecs.lookup(explicit).name
|
|
54
|
+
except LookupError:
|
|
55
|
+
return None
|
|
56
|
+
if sys.platform != "win32":
|
|
57
|
+
return None
|
|
58
|
+
codepage = _console_codepage() # 0 — у процесса нет консоли
|
|
59
|
+
return "utf-8" if codepage == 65001 else "cp%d" % codepage if codepage else None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _console_codepage():
|
|
63
|
+
import ctypes
|
|
64
|
+
|
|
65
|
+
return ctypes.windll.kernel32.GetConsoleOutputCP()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def build_parser():
|
|
69
|
+
parser = argparse.ArgumentParser(prog="small", description=tr("Micro-apps on Railway with one command.",
|
|
70
|
+
"Микро-приложения на Railway одной командой."))
|
|
71
|
+
parser.add_argument("--version", action="version", version="small " + __version__)
|
|
72
|
+
sub = parser.add_subparsers(dest="command", metavar=tr("<command>", "<команда>"))
|
|
73
|
+
sub.required = True
|
|
74
|
+
for module in commands.ALL:
|
|
75
|
+
module.register(sub)
|
|
76
|
+
return parser
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Команды small. Каждый модуль добавляет свои подкоманды функцией register(subparsers).
|
|
2
|
+
|
|
3
|
+
Порядок здесь — порядок в `small --help`: от создания приложения к его удалению.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from . import access, dashboard, db, deploy, destroy, domain, env, init, link, logs, mail, notify, sleep
|
|
7
|
+
|
|
8
|
+
ALL = (init, deploy, link, access, mail, notify, env, db, domain, sleep, logs, dashboard, destroy)
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""small access: кто может открыть приложение. По почте (ACCESS_EMAILS) — как доступ к Google Doc, или по личной
|
|
2
|
+
ссылке-приглашению (ACCESS_TOKEN). add выдаёт доступ, revoke отзывает, list показывает, у кого он есть."""
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
|
|
6
|
+
from .. import access as tokens
|
|
7
|
+
from ..common import CliError, app_dir, say
|
|
8
|
+
from ..config import CONFIG_FILE, STATE_FILE, load_config, load_state
|
|
9
|
+
from ..i18n import tr
|
|
10
|
+
from .env import add_no_deploy, change_env, report_env_change
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def register(sub):
|
|
14
|
+
a = sub.add_parser("access", help=tr("who can open the app: by email or by personal link",
|
|
15
|
+
"кто может открыть приложение: по почте или по личной ссылке"),
|
|
16
|
+
description=tr("add ivan@company.com: sign-in by work email (an email with a sign-in link); "
|
|
17
|
+
"add @company.com: the whole domain; add bob: a personal invite link. "
|
|
18
|
+
"revoke takes access away from one person, everyone else keeps theirs. "
|
|
19
|
+
"Emails are stored in ACCESS_EMAILS, links in ACCESS_TOKEN (in %s and in "
|
|
20
|
+
"Railway).",
|
|
21
|
+
"add ivan@company.com — вход по рабочей почте (письмо со ссылкой для входа); "
|
|
22
|
+
"add @company.com — весь домен; add bob — личная ссылка-приглашение. "
|
|
23
|
+
"revoke отзывает доступ одного человека, у остальных всё остаётся. "
|
|
24
|
+
"Адреса хранятся в ACCESS_EMAILS, ссылки — в ACCESS_TOKEN (в %s и в Railway).")
|
|
25
|
+
% CONFIG_FILE)
|
|
26
|
+
a.add_argument("-C", "--path", default=".", help=tr("app folder (default: current folder)",
|
|
27
|
+
"папка приложения (по умолчанию текущая)"))
|
|
28
|
+
a.set_defaults(func=cmd_access_list)
|
|
29
|
+
access_sub = a.add_subparsers(dest="access_command", metavar="<list|add|revoke>")
|
|
30
|
+
al = access_sub.add_parser("list", help=tr("who has access", "у кого есть доступ"))
|
|
31
|
+
al.set_defaults(func=cmd_access_list)
|
|
32
|
+
aa = access_sub.add_parser("add", help=tr("grant access: small access add ivan@company.com | @company.com | bob",
|
|
33
|
+
"выдать доступ: small access add ivan@company.com | @company.com | bob"))
|
|
34
|
+
aa.add_argument("name", metavar=tr("EMAIL|NAME", "ПОЧТА|ИМЯ"))
|
|
35
|
+
aa.set_defaults(func=cmd_access_add)
|
|
36
|
+
ar = access_sub.add_parser("revoke", help=tr("revoke access: small access revoke ivan@company.com | bob",
|
|
37
|
+
"отозвать доступ: small access revoke ivan@company.com | bob"))
|
|
38
|
+
ar.add_argument("name", metavar=tr("EMAIL|NAME", "ПОЧТА|ИМЯ"))
|
|
39
|
+
ar.set_defaults(func=cmd_access_revoke)
|
|
40
|
+
for parser in (al, aa, ar):
|
|
41
|
+
parser.add_argument("-C", "--path", default=argparse.SUPPRESS, help=tr("app folder", "папка приложения"))
|
|
42
|
+
for parser in (aa, ar):
|
|
43
|
+
add_no_deploy(parser)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def cmd_access_list(args):
|
|
47
|
+
root = app_dir(args.path)
|
|
48
|
+
env, sources = tokens.app_env(root, load_state(root / STATE_FILE), warn=say)
|
|
49
|
+
entries, emails = tokens.parse(env.get("ACCESS_TOKEN")), tokens.parse_emails(env.get("ACCESS_EMAILS"))
|
|
50
|
+
if not entries and not emails:
|
|
51
|
+
say(tr("Nobody has access: the app is closed to everyone. "
|
|
52
|
+
"Grant access: small access add ivan@company.com (by email) or small access add NAME (by link)",
|
|
53
|
+
"Доступа нет ни у кого — приложение закрыто для всех. "
|
|
54
|
+
"Выдать: small access add ivan@company.com (по почте) или small access add ИМЯ (по ссылке)"))
|
|
55
|
+
return 0
|
|
56
|
+
if emails:
|
|
57
|
+
say(tr("Sign-in by email (ACCESS_EMAILS from %s):", "Вход по почте (ACCESS_EMAILS из %s):")
|
|
58
|
+
% sources["ACCESS_EMAILS"])
|
|
59
|
+
for email in emails:
|
|
60
|
+
print(" %s" % (email + tr(" (whole domain)", " (весь домен)") if email.startswith("@") else email))
|
|
61
|
+
warn_mail(env)
|
|
62
|
+
if entries:
|
|
63
|
+
say(tr("Personal links (ACCESS_TOKEN from %s):", "Личные ссылки (ACCESS_TOKEN из %s):")
|
|
64
|
+
% sources["ACCESS_TOKEN"])
|
|
65
|
+
for name, token in entries:
|
|
66
|
+
print(" %-24s %s…" % (name or tr("(shared link)", "(общая ссылка)"), token[:4]))
|
|
67
|
+
say(tr("Link: small link --as NAME", "Ссылка: small link --as ИМЯ"))
|
|
68
|
+
say(tr("Revoke: small access revoke EMAIL|NAME", "Отозвать: small access revoke ПОЧТА|ИМЯ"))
|
|
69
|
+
return 0
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def cmd_access_add(args):
|
|
73
|
+
root = app_dir(args.path)
|
|
74
|
+
if tokens.is_email(args.name.strip().lower()):
|
|
75
|
+
return add_email(root, args.name.strip().lower(), args.no_deploy)
|
|
76
|
+
if not tokens.NAME_RE.match(args.name):
|
|
77
|
+
raise CliError(tr("specify an email address (ivan@company.com), a domain (@company.com) or a name: "
|
|
78
|
+
"up to 40 characters, Latin letters, digits, _ . -",
|
|
79
|
+
"укажите адрес почты (ivan@company.com), домен (@company.com) или имя — до 40 символов: "
|
|
80
|
+
"латиница, цифры, _ . -"))
|
|
81
|
+
state = load_state(root / STATE_FILE)
|
|
82
|
+
entries, _ = tokens.current(root, state, warn=say)
|
|
83
|
+
if any(name == args.name for name, _ in entries):
|
|
84
|
+
raise CliError(tr("%s already has a link: small link --as %s", "у %s уже есть ссылка: small link --as %s")
|
|
85
|
+
% (args.name, args.name))
|
|
86
|
+
token = tokens.new_token()
|
|
87
|
+
linked = change_env(root, {"ACCESS_TOKEN": tokens.serialize(entries + [(args.name, token)])},
|
|
88
|
+
deploy=not args.no_deploy)
|
|
89
|
+
report_env_change(linked, not args.no_deploy, tr("Access granted to %s", "Доступ для %s добавлен") % args.name)
|
|
90
|
+
if state.get("domain"):
|
|
91
|
+
say(tr("Personal link for %s (it works like a password, send it to this person only):",
|
|
92
|
+
"Личная ссылка для %s (работает как пароль — пересылайте только ему):") % args.name)
|
|
93
|
+
print(tokens.invite_link(state["domain"], token), flush=True)
|
|
94
|
+
else:
|
|
95
|
+
say(tr("The link will be available after small deploy: small link --as %s",
|
|
96
|
+
"Ссылка появится после small deploy: small link --as %s") % args.name)
|
|
97
|
+
return 0
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def add_email(root, email, no_deploy):
|
|
101
|
+
problem = tokens.email_login_problem(root)
|
|
102
|
+
if problem:
|
|
103
|
+
raise CliError(problem)
|
|
104
|
+
state = load_state(root / STATE_FILE)
|
|
105
|
+
env, _ = tokens.app_env(root, state, warn=say)
|
|
106
|
+
emails = tokens.parse_emails(env.get("ACCESS_EMAILS"))
|
|
107
|
+
if email in emails:
|
|
108
|
+
raise CliError(tr("%s is already on the access list", "%s уже есть в списке доступа") % email)
|
|
109
|
+
changes = {"ACCESS_EMAILS": ", ".join(emails + [email])}
|
|
110
|
+
if not env.get("SESSION_SECRET"):
|
|
111
|
+
changes["SESSION_SECRET"] = tokens.new_token() # ключ подписи ссылок для входа и сессий
|
|
112
|
+
# Настоящий адрес для ссылок в письмах: RAILWAY_PUBLIC_DOMAIN после переименования домена устаревает.
|
|
113
|
+
# Свой PUBLIC_URL в small.json (например, свой домен) не трогаем.
|
|
114
|
+
extra = {}
|
|
115
|
+
if state.get("domain") and not load_config(root / CONFIG_FILE)["env"].get("PUBLIC_URL"):
|
|
116
|
+
extra["PUBLIC_URL"] = "https://" + state["domain"]
|
|
117
|
+
linked = change_env(root, changes, deploy=not no_deploy, railway_only=extra)
|
|
118
|
+
who = tr("all %s addresses", "все адреса %s") % email if email.startswith("@") else email
|
|
119
|
+
report_env_change(linked, not no_deploy, tr("Email access granted: %s", "Доступ по почте: %s") % who)
|
|
120
|
+
url = ("https://%s/" % state["domain"] if state.get("domain")
|
|
121
|
+
else tr("the app URL (after small deploy)", "адрес приложения (после small deploy)"))
|
|
122
|
+
say(tr("How to sign in: open %s and enter a work email; an email with a sign-in link will arrive.",
|
|
123
|
+
"Как войти: открыть %s и ввести рабочую почту — придёт письмо со ссылкой для входа.") % url)
|
|
124
|
+
warn_mail(env)
|
|
125
|
+
return 0
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def warn_mail(env):
|
|
129
|
+
if not any(env.get(name) for name in tokens.MAIL_VARS):
|
|
130
|
+
say(tr("Warning: email is not set up, so sign-in emails are not sent. Set it up: small mail resend",
|
|
131
|
+
"Внимание: почта не настроена, письма для входа не уходят. Подключить: small mail resend"))
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def cmd_access_revoke(args):
|
|
135
|
+
root = app_dir(args.path)
|
|
136
|
+
state = load_state(root / STATE_FILE)
|
|
137
|
+
email = args.name.strip().lower()
|
|
138
|
+
if tokens.is_email(email):
|
|
139
|
+
env, _ = tokens.app_env(root, state, warn=say)
|
|
140
|
+
emails = tokens.parse_emails(env.get("ACCESS_EMAILS"))
|
|
141
|
+
if email not in emails:
|
|
142
|
+
raise CliError(tr("%s is not on the access list. On the list: %s", "%s нет в списке доступа. Есть: %s")
|
|
143
|
+
% (email, ", ".join(emails) or "—"))
|
|
144
|
+
remaining = [e for e in emails if e != email]
|
|
145
|
+
linked = change_env(root, {"ACCESS_EMAILS": ", ".join(remaining)} if remaining else None,
|
|
146
|
+
unset=() if remaining else ["ACCESS_EMAILS"], deploy=not args.no_deploy)
|
|
147
|
+
report_env_change(linked, not args.no_deploy,
|
|
148
|
+
tr("Access revoked for %s: sign-in stops working right after the restart, "
|
|
149
|
+
"even in a browser that is already open",
|
|
150
|
+
"Доступ %s отозван — вход закроется сразу после перезапуска, даже в открытом браузере")
|
|
151
|
+
% email)
|
|
152
|
+
return 0
|
|
153
|
+
|
|
154
|
+
entries, _ = tokens.current(root, state, warn=say)
|
|
155
|
+
remaining = [(name, token) for name, token in entries if name != args.name]
|
|
156
|
+
if len(remaining) == len(entries):
|
|
157
|
+
raise CliError(tr("%s has no personal link. Existing links: %s", "у %s нет личной ссылки. Есть: %s")
|
|
158
|
+
% (args.name, ", ".join(n or tr("(shared)", "(общая)") for n, _ in entries) or "—"))
|
|
159
|
+
if not remaining:
|
|
160
|
+
say(tr("This is the last token: once it is revoked, nobody can sign in by link.",
|
|
161
|
+
"Это последний токен: после отзыва войти по ссылке не сможет никто."))
|
|
162
|
+
linked = change_env(root, {"ACCESS_TOKEN": tokens.serialize(remaining)} if remaining else None,
|
|
163
|
+
unset=() if remaining else ["ACCESS_TOKEN"], deploy=not args.no_deploy)
|
|
164
|
+
report_env_change(linked, not args.no_deploy,
|
|
165
|
+
tr("Access revoked for %s: their link and cookies stop working after the restart",
|
|
166
|
+
"Доступ %s отозван — его ссылка и куки перестанут действовать после перезапуска") % args.name)
|
|
167
|
+
return 0
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""small dashboard: локальная страница со списком приложений (сервер — в small_cli/dashboard.py)."""
|
|
2
|
+
|
|
3
|
+
import webbrowser
|
|
4
|
+
|
|
5
|
+
from .. import dashboard as server_module
|
|
6
|
+
from ..common import app_dir, say
|
|
7
|
+
from ..i18n import tr
|
|
8
|
+
from ..railway import token_from_env
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def register(sub):
|
|
12
|
+
b = sub.add_parser("dashboard", help=tr("local page that lists your apps",
|
|
13
|
+
"локальная страница со списком приложений"),
|
|
14
|
+
description=tr("Find apps in a folder (small.json / .small.json, up to %d levels deep) "
|
|
15
|
+
"and show them in the browser: name, status, links, path.",
|
|
16
|
+
"Найти в папке приложения (small.json / .small.json, до %d уровней вглубь) "
|
|
17
|
+
"и показать их в браузере: имя, статус, ссылки, путь.") % server_module.MAX_DEPTH)
|
|
18
|
+
b.add_argument("path", nargs="?", default=".", help=tr("where to look (default: current folder; e.g. ..)",
|
|
19
|
+
"где искать (по умолчанию текущая папка; например, ..)"))
|
|
20
|
+
b.add_argument("--port", type=int, default=server_module.DEFAULT_PORT,
|
|
21
|
+
help=tr("port (default %d; if it's busy, any free one)",
|
|
22
|
+
"порт (по умолчанию %d; если занят — любой свободный)") % server_module.DEFAULT_PORT)
|
|
23
|
+
b.add_argument("--no-browser", action="store_true", help=tr("don't open the browser", "не открывать браузер"))
|
|
24
|
+
b.set_defaults(func=cmd_dashboard)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def cmd_dashboard(args):
|
|
28
|
+
root = app_dir(args.path)
|
|
29
|
+
token = token_from_env()
|
|
30
|
+
server = server_module.start_server(root, args.port, token)
|
|
31
|
+
port = server.server_address[1]
|
|
32
|
+
if args.port and port != args.port:
|
|
33
|
+
say(tr("Port %d is busy, using %d", "Порт %d занят, использую %d") % (args.port, port))
|
|
34
|
+
found = len(server_module.scan_apps(root))
|
|
35
|
+
say(tr("small dashboard: %s, apps found: %d", "small dashboard: %s, приложений найдено: %d") % (root, found))
|
|
36
|
+
say(tr("Deploy statuses from Railway: %s", "Статусы деплоя из Railway: %s")
|
|
37
|
+
% (tr("on", "включены") if token else tr("off (no RAILWAY_API_TOKEN), offline mode",
|
|
38
|
+
"выключены (нет RAILWAY_API_TOKEN), офлайн-режим")))
|
|
39
|
+
say(tr("Ctrl+C to stop", "Ctrl+C — остановить"))
|
|
40
|
+
print(server.url, flush=True)
|
|
41
|
+
if not args.no_browser:
|
|
42
|
+
webbrowser.open(server.url)
|
|
43
|
+
try:
|
|
44
|
+
server.serve_forever()
|
|
45
|
+
except KeyboardInterrupt:
|
|
46
|
+
say(tr("Dashboard stopped", "Дашборд остановлен"))
|
|
47
|
+
finally:
|
|
48
|
+
server.server_close()
|
|
49
|
+
return 0
|