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.
Files changed (41) hide show
  1. small_cli/__init__.py +1 -0
  2. small_cli/__main__.py +3 -0
  3. small_cli/access.py +114 -0
  4. small_cli/archive.py +127 -0
  5. small_cli/cli.py +76 -0
  6. small_cli/commands/__init__.py +8 -0
  7. small_cli/commands/access.py +167 -0
  8. small_cli/commands/dashboard.py +49 -0
  9. small_cli/commands/db.py +154 -0
  10. small_cli/commands/deploy.py +248 -0
  11. small_cli/commands/destroy.py +63 -0
  12. small_cli/commands/domain.py +123 -0
  13. small_cli/commands/env.py +151 -0
  14. small_cli/commands/init.py +86 -0
  15. small_cli/commands/link.py +53 -0
  16. small_cli/commands/logs.py +59 -0
  17. small_cli/commands/mail.py +203 -0
  18. small_cli/commands/notify.py +147 -0
  19. small_cli/commands/sleep.py +61 -0
  20. small_cli/common.py +92 -0
  21. small_cli/config.py +82 -0
  22. small_cli/dashboard.py +648 -0
  23. small_cli/i18n.py +55 -0
  24. small_cli/railway.py +369 -0
  25. small_cli/scaffold.py +81 -0
  26. small_cli/templates/fastapi/magic_link.py +675 -0
  27. small_cli/templates/fastapi/main.py +67 -0
  28. small_cli/templates/fastapi/requirements.txt +2 -0
  29. small_cli/templates/node-http/magic-link.js +509 -0
  30. small_cli/templates/node-http/package.json +11 -0
  31. small_cli/templates/node-http/server.js +33 -0
  32. small_cli/templates/python-http/app.py +47 -0
  33. small_cli/templates/python-http/magic_link.py +675 -0
  34. small_cli/templates/streamlit/app.py +181 -0
  35. small_cli/templates/streamlit/requirements.txt +1 -0
  36. small_software-0.6.0.dist-info/METADATA +559 -0
  37. small_software-0.6.0.dist-info/RECORD +41 -0
  38. small_software-0.6.0.dist-info/WHEEL +5 -0
  39. small_software-0.6.0.dist-info/entry_points.txt +2 -0
  40. small_software-0.6.0.dist-info/licenses/LICENSE +21 -0
  41. small_software-0.6.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,151 @@
1
+ """small env: переменные приложения. set/unset меняют их сразу в Railway и в small.json — иначе следующий
2
+ small deploy вернул бы старые значения из small.json."""
3
+
4
+ import argparse
5
+ import json
6
+
7
+ from ..common import CliError, app_dir, is_linked, require_token, say
8
+ from ..config import CONFIG_FILE, ENV_NAME_RE, STATE_FILE, load_config, load_state
9
+ from ..i18n import tr
10
+ from ..railway import Railway, token_from_env
11
+
12
+
13
+ def register(sub):
14
+ e = sub.add_parser("env", help=tr("app environment variables: list, set, unset",
15
+ "переменные приложения: список, задать, удалить"),
16
+ description=tr("App environment variables. set/unset change them in both Railway and %s "
17
+ "at once (otherwise the next small deploy would bring back the old values), "
18
+ "then Railway restarts the app. With no subcommand, lists them.",
19
+ "Переменные приложения. set/unset меняют их сразу и в Railway, и в %s "
20
+ "(иначе следующий small deploy вернул бы старые значения), затем Railway "
21
+ "перезапускает приложение. Без подкоманды — список.") % CONFIG_FILE)
22
+ e.add_argument("-C", "--path", default=".", help=tr("app folder (default: current folder)",
23
+ "папка приложения (по умолчанию текущая)"))
24
+ e.set_defaults(func=cmd_env_list, values=False)
25
+ env_sub = e.add_subparsers(dest="env_command", metavar="<list|set|unset>")
26
+ el = env_sub.add_parser("list", help=tr("list variables (values hidden)", "показать переменные (значения скрыты)"))
27
+ el.add_argument("--values", action="store_true", help=tr("show values", "показать значения"))
28
+ el.set_defaults(func=cmd_env_list)
29
+ es = env_sub.add_parser("set", help=tr("set variables: small env set KEY=VALUE ...",
30
+ "задать переменные: small env set KEY=VALUE ..."))
31
+ es.add_argument("pairs", nargs="+", metavar="KEY=VALUE")
32
+ es.set_defaults(func=cmd_env_set)
33
+ eu = env_sub.add_parser("unset", help=tr("remove variables: small env unset KEY ...",
34
+ "удалить переменные: small env unset KEY ..."))
35
+ eu.add_argument("names", nargs="+", metavar="KEY")
36
+ eu.set_defaults(func=cmd_env_unset)
37
+ for parser in (el, es, eu):
38
+ parser.add_argument("-C", "--path", default=argparse.SUPPRESS, help=tr("app folder", "папка приложения"))
39
+ for parser in (es, eu):
40
+ add_no_deploy(parser)
41
+
42
+
43
+ def add_no_deploy(parser):
44
+ parser.add_argument("--no-deploy", action="store_true",
45
+ help=tr("don't restart the app (changes take effect on the next deploy)",
46
+ "не перезапускать приложение (изменения вступят в силу при следующем деплое)"))
47
+
48
+
49
+ def change_env(root, set_vars=None, unset=(), deploy=True, railway_only=None):
50
+ """Меняет переменные в Railway (если приложение задеплоено) и в small.json — чтобы они не расходились.
51
+
52
+ Сначала Railway: если он откажет, small.json остаётся прежним. railway_only — переменные, которые small ведёт
53
+ сам и в small.json не пишет (например, PUBLIC_URL). Возвращает True, если трогали Railway.
54
+ """
55
+ set_vars, unset = dict(set_vars or {}), list(unset)
56
+ config_path, state = root / CONFIG_FILE, load_state(root / STATE_FILE)
57
+ load_config(config_path) # битый small.json — ошибка до любых изменений
58
+ raw = json.loads(config_path.read_text(encoding="utf-8-sig")) if config_path.is_file() else {}
59
+
60
+ if is_linked(state):
61
+ api = Railway(require_token())
62
+ ids = (state["projectId"], state["environmentId"], state["serviceId"])
63
+ existing = api.service_variables(*ids)
64
+ upsert = dict(set_vars, **(railway_only or {}))
65
+ if upsert:
66
+ api.upsert_variables(*ids, upsert)
67
+ for name in unset:
68
+ if name in existing:
69
+ api.delete_variable(*ids, name)
70
+ if deploy:
71
+ api.redeploy(state["serviceId"], state["environmentId"])
72
+
73
+ env = raw.setdefault("env", {})
74
+ env.update(set_vars)
75
+ for name in unset:
76
+ env.pop(name, None)
77
+ config_path.write_text(json.dumps(raw, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
78
+ return is_linked(state)
79
+
80
+
81
+ def report_env_change(linked, deploy, what):
82
+ if not linked:
83
+ say(tr("%s. Saved to %s; the app is not deployed yet, Railway gets it on small deploy.",
84
+ "%s. Записано в %s; приложение ещё не задеплоено — в Railway попадёт при small deploy.")
85
+ % (what, CONFIG_FILE))
86
+ elif deploy:
87
+ say(tr("%s. Updated in Railway and %s; Railway is restarting the app (follow with small logs -f).",
88
+ "%s. Обновлено в Railway и %s, Railway перезапускает приложение (small logs -f — следить).")
89
+ % (what, CONFIG_FILE))
90
+ else:
91
+ say(tr("%s. Updated in Railway and %s; takes effect on the next deploy.",
92
+ "%s. Обновлено в Railway и %s, вступит в силу при следующем деплое.") % (what, CONFIG_FILE))
93
+
94
+
95
+ def cmd_env_list(args):
96
+ root = app_dir(args.path)
97
+ config_env = load_config(root / CONFIG_FILE)["env"]
98
+ state = load_state(root / STATE_FILE)
99
+ railway_env = None
100
+ if is_linked(state):
101
+ if token_from_env():
102
+ # unrendered: ссылка ${{Postgres.DATABASE_URL}} из small.json совпадёт с записанной в Railway,
103
+ # а пароль базы не попадёт в терминал даже с --values.
104
+ railway_env = {k: v for k, v in Railway(token_from_env()).service_variables(
105
+ state["projectId"], state["environmentId"], state["serviceId"], unrendered=True).items()
106
+ if not k.startswith("RAILWAY_")}
107
+ else:
108
+ say(tr("No RAILWAY_API_TOKEN, showing %s only", "Нет RAILWAY_API_TOKEN — показываю только %s")
109
+ % CONFIG_FILE)
110
+ names = sorted(set(config_env) | set(railway_env or {}))
111
+ if not names:
112
+ say(tr("No variables. Set one: small env set KEY=VALUE", "Переменных нет. Задать: small env set KEY=VALUE"))
113
+ return 0
114
+ width = max(len(n) for n in names) + 2
115
+ for name in names:
116
+ in_config, in_railway = name in config_env, railway_env is not None and name in railway_env
117
+ value = railway_env[name] if in_railway else config_env[name]
118
+ if in_config and in_railway:
119
+ where = CONFIG_FILE + " + Railway" if config_env[name] == railway_env[name] else "Railway ≠ " + CONFIG_FILE
120
+ elif in_railway:
121
+ where = tr("Railway only", "только Railway")
122
+ else:
123
+ where = CONFIG_FILE + (tr(" (in Railway after small deploy)", " (в Railway — после small deploy)")
124
+ if railway_env is not None else "")
125
+ shown = value if getattr(args, "values", False) else "•" * min(len(value), 8) + " (%d)" % len(value)
126
+ print("%-*s%-24s %s" % (width, name, shown, where))
127
+ if not getattr(args, "values", False):
128
+ say(tr("Values hidden: small env list --values", "Значения скрыты: small env list --values"))
129
+ return 0
130
+
131
+
132
+ def cmd_env_set(args):
133
+ root = app_dir(args.path)
134
+ pairs = {}
135
+ for pair in args.pairs:
136
+ name, sep, value = pair.partition("=")
137
+ if not sep or not ENV_NAME_RE.match(name):
138
+ raise CliError(tr("expected KEY=VALUE (KEY: Latin letters, digits, _; must not start with a digit), "
139
+ "got: %s",
140
+ "ожидается KEY=VALUE (KEY — латиница, цифры, _; не с цифры), получено: %s") % pair)
141
+ pairs[name] = value
142
+ linked = change_env(root, set_vars=pairs, deploy=not args.no_deploy)
143
+ report_env_change(linked, not args.no_deploy, tr("Set: ", "Заданы: ") + ", ".join(sorted(pairs)))
144
+ return 0
145
+
146
+
147
+ def cmd_env_unset(args):
148
+ root = app_dir(args.path)
149
+ linked = change_env(root, unset=args.names, deploy=not args.no_deploy)
150
+ report_env_change(linked, not args.no_deploy, tr("Removed: ", "Удалены: ") + ", ".join(args.names))
151
+ return 0
@@ -0,0 +1,86 @@
1
+ """small init: заготовка приложения из шаблона — сервер с защитой по ссылке-приглашению,
2
+ small.json с уникальным именем и ACCESS_TOKEN, .gitignore и .railwayignore."""
3
+
4
+ import argparse
5
+ import json
6
+ import secrets
7
+ from pathlib import Path
8
+
9
+ from .. import scaffold
10
+ from ..common import CliError, confirm, say, slugify
11
+ from ..config import CONFIG_FILE
12
+ from ..i18n import tr
13
+
14
+
15
+ def register(sub):
16
+ templates = "\n".join(" %-12s %s" % (k, scaffold.description(k)) for k in scaffold.TEMPLATES)
17
+ i = sub.add_parser("init", help=tr("create a micro-app skeleton", "создать заготовку микро-приложения"),
18
+ description=tr("Create a micro-app skeleton: a server protected by an invite link, "
19
+ "%s, .gitignore and .railwayignore. Existing files are not overwritten "
20
+ "without confirmation.",
21
+ "Создать заготовку микро-приложения: сервер с защитой по ссылке-приглашению, "
22
+ "%s, .gitignore и .railwayignore. Существующие файлы не перезаписываются "
23
+ "без подтверждения.") % CONFIG_FILE,
24
+ epilog=tr("Templates:\n", "Шаблоны:\n") + templates,
25
+ formatter_class=argparse.RawDescriptionHelpFormatter)
26
+ i.add_argument("path", nargs="?", default=".",
27
+ help=tr("app folder (default: current folder)", "папка приложения (по умолчанию текущая)"))
28
+ i.add_argument("-t", "--template", choices=list(scaffold.TEMPLATES), default=scaffold.DEFAULT_TEMPLATE,
29
+ help=tr("template (default: %s)", "шаблон (по умолчанию %s)") % scaffold.DEFAULT_TEMPLATE)
30
+ i.add_argument("--name", help=tr("project name (default: folder name with a random suffix)",
31
+ "имя проекта (по умолчанию — имя папки со случайным суффиксом)"))
32
+ i.add_argument("--force", action="store_true",
33
+ help=tr("overwrite existing files without asking", "перезаписать существующие файлы без вопросов"))
34
+ i.set_defaults(func=cmd_init)
35
+
36
+
37
+ def cmd_init(args):
38
+ root = Path(args.path).resolve()
39
+ if root.exists() and not root.is_dir():
40
+ raise CliError(tr("%s is a file, not a folder", "%s — это файл, а не папка") % root)
41
+ name = slugify(args.name) if args.name else unique_name(root.name)
42
+ config = {"name": name, "env": {"ACCESS_TOKEN": secrets.token_urlsafe(32)}}
43
+ files = scaffold.template_files(args.template)
44
+ files[CONFIG_FILE] = (json.dumps(config, indent=2, ensure_ascii=False) + "\n").encode("utf-8")
45
+
46
+ # Ignore-файлы не перезаписываются, а дополняются, поэтому конфликтом не считаются.
47
+ conflicts = [rel for rel in files if (root / rel).exists()]
48
+ if conflicts and not args.force:
49
+ listing = ", ".join(conflicts)
50
+ if not confirm(tr("%s already contains: %s. Overwrite? [y/N] ",
51
+ "В %s уже есть: %s. Перезаписать? [y/N] ") % (root, listing)):
52
+ raise CliError(tr("%s already contains: %s. Nothing was changed. To overwrite, add --force",
53
+ "в %s уже есть: %s. Ничего не изменено. Чтобы перезаписать, добавьте --force")
54
+ % (root, listing))
55
+
56
+ root.mkdir(parents=True, exist_ok=True)
57
+ for rel, content in files.items():
58
+ (root / rel).write_bytes(content)
59
+ merged = {rel: scaffold.merge_ignore(root / rel, lines)
60
+ for rel, lines in scaffold.ignore_rules(args.template).items()}
61
+
62
+ labels = {"created": tr("created", "создан"), "updated": tr("updated", "дополнен"),
63
+ "unchanged": tr("unchanged", "без изменений")}
64
+ say(tr("Template %s in %s:", "Шаблон %s в %s:") % (args.template, root))
65
+ width = max(len(rel) for rel in list(files) + list(merged)) + 2
66
+ for rel in files:
67
+ say(" %-*s%s" % (width, rel, tr("overwritten", "перезаписан") if rel in conflicts
68
+ else tr("created", "создан")))
69
+ for rel, result in merged.items():
70
+ say(" %-*s%s" % (width, rel, labels[result]))
71
+ say("")
72
+ say(tr("Project name: %s. ACCESS_TOKEN generated in %s; the file is in .gitignore, do not publish it.",
73
+ "Имя проекта: %s. В %s сгенерирован ACCESS_TOKEN; файл добавлен в .gitignore, не публикуйте его.")
74
+ % (name, CONFIG_FILE))
75
+ say(tr("Next:", "Дальше:"))
76
+ if root != Path.cwd().resolve():
77
+ say(" cd \"%s\"" % root)
78
+ say(" small deploy")
79
+ say(tr(" small open # open with the invite link", " small open # открыть по ссылке-приглашению"))
80
+ return 0
81
+
82
+
83
+ def unique_name(folder_name):
84
+ """Имя папки + случайный суффикс: явное имя из small.json привязывает деплой к одноимённому проекту,
85
+ поэтому оно не должно совпасть с чужим проектом, а поддомен <name>.up.railway.app — с занятым."""
86
+ return "%s-%s" % (slugify(folder_name)[:35].strip("-") or "app", secrets.token_hex(2))
@@ -0,0 +1,53 @@
1
+ """small link / small open: ссылка-приглашение задеплоенного приложения (общая или личная, --as ИМЯ)."""
2
+
3
+ import webbrowser
4
+
5
+ from .. import access
6
+ from ..common import CliError, app_dir, linked_state, say
7
+ from ..config import CONFIG_FILE
8
+ from ..i18n import tr
9
+
10
+
11
+ def register(sub):
12
+ for name, opens, text in (("open", True, tr("open the deployed app via its invite link",
13
+ "открыть задеплоенное приложение по ссылке-приглашению")),
14
+ ("link", False, tr("print the invite link of the deployed app",
15
+ "напечатать ссылку-приглашение задеплоенного приложения"))):
16
+ k = sub.add_parser(name, help=text, description=text.capitalize() + tr(
17
+ ". ACCESS_TOKEN is taken from %s or, if it's not there, from the service variables in Railway "
18
+ "(requires RAILWAY_API_TOKEN).",
19
+ ". ACCESS_TOKEN берётся из %s, "
20
+ "а если его там нет — из переменных сервиса в Railway (нужен RAILWAY_API_TOKEN).")
21
+ % CONFIG_FILE)
22
+ k.add_argument("path", nargs="?", default=".", help=tr("app folder (default: current folder)",
23
+ "папка приложения (по умолчанию текущая)"))
24
+ k.add_argument("--as", dest="person", metavar=tr("NAME", "ИМЯ"),
25
+ help=tr("personal link of a person from small access", "личная ссылка человека из small access"))
26
+ k.set_defaults(func=cmd_link, open=opens)
27
+
28
+
29
+ def cmd_link(args):
30
+ root = app_dir(args.path)
31
+ state = linked_state(root)
32
+ if not state.get("domain"):
33
+ raise CliError(tr("the service has no public domain; run small deploy",
34
+ "у сервиса нет публичного домена — выполните small deploy"))
35
+ entries, source = access.current(root, state, warn=say)
36
+ token = access.pick(entries, args.person)
37
+ if args.person is not None and token is None:
38
+ raise CliError(tr("%s has no personal link. Existing links: %s. Grant one: small access add %s",
39
+ "у %s нет личной ссылки. Есть: %s. Выдать: small access add %s")
40
+ % (args.person, ", ".join(n or tr("(shared)", "(общая)") for n, _ in entries) or "—",
41
+ args.person))
42
+ link = access.invite_link(state["domain"], token)
43
+ if token:
44
+ say(tr("Invite link%s (ACCESS_TOKEN from %s). Anyone who gets it can sign in:",
45
+ "Ссылка-приглашение%s (ACCESS_TOKEN из %s). Кто её получит, тот войдёт:")
46
+ % (tr(" for ", " для ") + args.person if args.person else "", source))
47
+ else:
48
+ say(tr("ACCESS_TOKEN not found in %s or in Railway; URL without a token:",
49
+ "ACCESS_TOKEN не найден ни в %s, ни в Railway — адрес без токена:") % CONFIG_FILE)
50
+ print(link, flush=True)
51
+ if args.open:
52
+ webbrowser.open(link)
53
+ return 0
@@ -0,0 +1,59 @@
1
+ """small logs: лог последнего деплоя — приложения или сборки, с режимом слежения -f."""
2
+
3
+ import argparse
4
+ import time
5
+
6
+ from ..common import CliError, app_dir, linked_state, log_time, require_token, say
7
+ from ..i18n import tr
8
+ from ..railway import Railway, RailwayError
9
+
10
+
11
+ def register(sub):
12
+ g = sub.add_parser("logs", help=tr("app logs from Railway", "логи приложения из Railway"),
13
+ description=tr("Show the log of the latest deploy: the app log (default) or the build log.",
14
+ "Показать лог последнего деплоя: приложения (по умолчанию) или сборки."))
15
+ g.add_argument("path", nargs="?", default=".", help=tr("app folder (default: current folder)",
16
+ "папка приложения (по умолчанию текущая)"))
17
+ g.add_argument("--build", action="store_true", help=tr("build log instead of the app log",
18
+ "лог сборки вместо лога приложения"))
19
+ g.add_argument("-n", "--lines", type=int, default=100, help=tr("number of lines (default 100)",
20
+ "сколько строк (по умолчанию 100)"))
21
+ g.add_argument("-f", "--follow", action="store_true", help=tr("follow new lines (Ctrl+C to exit)",
22
+ "следить за новыми строками (Ctrl+C — выход)"))
23
+ g.add_argument("--interval", type=float, default=2.0, help=argparse.SUPPRESS)
24
+ g.set_defaults(func=cmd_logs)
25
+
26
+
27
+ def cmd_logs(args):
28
+ root = app_dir(args.path)
29
+ state = linked_state(root)
30
+ api = Railway(require_token())
31
+ deployment = api.latest_deployment(state["serviceId"], state["environmentId"])
32
+ if not deployment:
33
+ raise CliError(tr("service %s has no deploys yet", "у сервиса %s ещё нет деплоев") % state.get("name", ""))
34
+ say(tr("%s log · deploy %s · %s", "Лог %s · деплой %s · %s")
35
+ % (tr("Build", "сборки") if args.build else tr("App", "приложения"), deployment["id"], deployment["status"]))
36
+
37
+ seen, last = set(), {"ts": None}
38
+
39
+ def show(entries):
40
+ for entry in entries:
41
+ key = (entry["timestamp"], entry["message"])
42
+ if key not in seen:
43
+ seen.add(key)
44
+ print("%s %s" % (log_time(entry["timestamp"]), entry["message"]), flush=True)
45
+ last["ts"] = max(last["ts"] or "", entry["timestamp"])
46
+
47
+ show(api.log_entries(deployment["id"], build=args.build, limit=args.lines))
48
+ if not args.follow:
49
+ return 0
50
+ say(tr("Following new lines, Ctrl+C to exit", "Слежу за новыми строками, Ctrl+C — выход"))
51
+ try:
52
+ while True:
53
+ time.sleep(args.interval)
54
+ try:
55
+ show(api.log_entries(deployment["id"], build=args.build, limit=args.lines, start_date=last["ts"]))
56
+ except RailwayError as e:
57
+ say(tr(" retrying: %s", " повторяю запрос: %s") % e)
58
+ except KeyboardInterrupt:
59
+ return 0
@@ -0,0 +1,203 @@
1
+ """small mail: чем приложение отправляет письма для входа по почте (small access add ivan@company.com).
2
+
3
+ resend — Resend по HTTPS (работает на Railway Hobby); smtp — свой почтовый сервер (на Railway SMTP открыт только
4
+ с тарифа Pro); log — ссылки для входа пишутся в лог приложения (для проверки); off — выключить.
5
+ Перед сохранением resend и smtp отправляют пробное письмо тем же кодом, что и приложение.
6
+ """
7
+
8
+ import argparse
9
+ import getpass
10
+ import importlib.util
11
+ import os
12
+ import sys
13
+ from contextlib import contextmanager
14
+ from urllib.parse import urlsplit
15
+
16
+ from .. import access as tokens
17
+ from ..common import CliError, app_dir, ask, say
18
+ from ..config import CONFIG_FILE, STATE_FILE, load_config, load_state
19
+ from ..i18n import lang, tr
20
+ from ..scaffold import TEMPLATES_DIR
21
+ from .env import add_no_deploy, change_env, report_env_change
22
+
23
+ ALL_VARS = ("RESEND_API_KEY", "SMTP_URL", "LOGIN_LINKS_TO_LOG", "MAIL_FROM")
24
+
25
+
26
+ def register(sub):
27
+ m = sub.add_parser("mail", help=tr("sign-in emails: resend, smtp, log, off",
28
+ "письма для входа по почте: resend, smtp, log, off"),
29
+ description=tr("How to send emails with sign-in links. resend: key from RESEND_API_KEY "
30
+ "or hidden input; smtp: server URL from SMTP_URL or hidden input; "
31
+ "log: write links to the log (for testing); off: turn off; "
32
+ "with no subcommand, shows the status.",
33
+ "Чем отправлять письма со ссылкой для входа. resend — ключ из RESEND_API_KEY "
34
+ "или ввод без эха; smtp — адрес сервера из SMTP_URL или ввод без эха; "
35
+ "log — писать ссылки в лог (для проверки); off — выключить; "
36
+ "без подкоманды — статус."))
37
+ m.add_argument("-C", "--path", default=".", help=tr("app folder (default: current folder)",
38
+ "папка приложения (по умолчанию текущая)"))
39
+ m.set_defaults(func=cmd_mail_status)
40
+ mail_sub = m.add_subparsers(dest="mail_command", metavar="<resend|smtp|log|off>")
41
+ mr = mail_sub.add_parser("resend", help=tr("send via Resend (HTTPS)", "отправлять через Resend (HTTPS)"))
42
+ ms = mail_sub.add_parser("smtp", help=tr("send via your own SMTP server", "отправлять через свой SMTP-сервер"))
43
+ for parser in (mr, ms):
44
+ parser.add_argument("--from", dest="sender", help=tr('sender: "Reports <noreply@company.com>"',
45
+ 'отправитель: "Отчёты <noreply@company.com>"'))
46
+ parser.add_argument("--to", help=tr("where to send the test email (for Resend without your own domain, "
47
+ "use your Resend account email)",
48
+ "куда отправить пробное письмо (для Resend без своего домена — "
49
+ "адрес вашего аккаунта Resend)"))
50
+ mr.set_defaults(func=cmd_mail_resend)
51
+ ms.set_defaults(func=cmd_mail_smtp)
52
+ ml = mail_sub.add_parser("log", help=tr("write sign-in links to the app log (for testing only)",
53
+ "писать ссылки для входа в лог приложения (только для проверки)"))
54
+ ml.set_defaults(func=cmd_mail_log)
55
+ mo = mail_sub.add_parser("off", help=tr("stop sending emails", "выключить отправку писем"))
56
+ mo.set_defaults(func=cmd_mail_off)
57
+ for parser in (mr, ms, ml, mo):
58
+ parser.add_argument("-C", "--path", default=argparse.SUPPRESS, help=tr("app folder", "папка приложения"))
59
+ add_no_deploy(parser)
60
+
61
+
62
+ def secret_input(name, prompt):
63
+ value = os.environ.get(name, "").strip()
64
+ if value:
65
+ return value
66
+ if not sys.stdin or not sys.stdin.isatty():
67
+ raise CliError(tr("set %s or run the command in a terminal",
68
+ "задайте %s или запустите команду в терминале") % name)
69
+ say(prompt + tr(" Input is hidden.", " Ввод не отображается."))
70
+ value = getpass.getpass("%s: " % name).strip()
71
+ if not value:
72
+ raise CliError(tr("%s not entered", "%s не введён") % name)
73
+ return value
74
+
75
+
76
+ def mailer():
77
+ """magic_link.py из шаблона: письмо уходит тем же кодом, что и из приложения."""
78
+ path = TEMPLATES_DIR / "python-http" / "magic_link.py"
79
+ spec = importlib.util.spec_from_file_location("small_magic_link", path)
80
+ module = importlib.util.module_from_spec(spec)
81
+ spec.loader.exec_module(module)
82
+ return module
83
+
84
+
85
+ @contextmanager
86
+ def mail_env(values):
87
+ saved = {name: os.environ.get(name) for name in (*ALL_VARS, *values)}
88
+ try:
89
+ for name in ALL_VARS:
90
+ os.environ.pop(name, None)
91
+ os.environ.update({k: v for k, v in values.items() if v})
92
+ yield
93
+ finally:
94
+ for name, value in saved.items():
95
+ if value is None:
96
+ os.environ.pop(name, None)
97
+ else:
98
+ os.environ[name] = value
99
+
100
+
101
+ def test_letter(root, values, to):
102
+ to = (to or ask(tr("Send the test email to: ", "Куда отправить пробное письмо: ")) or "").strip()
103
+ if not tokens.EMAIL_RE.match(to):
104
+ raise CliError(tr("specify an address for the test email: --to ivan@company.com",
105
+ "укажите адрес для пробного письма: --to ivan@company.com"))
106
+ app = load_config(root / CONFIG_FILE)["name"] or load_state(root / STATE_FILE).get("name") or root.name
107
+ module = mailer()
108
+ with mail_env(dict(values, OWNER_LANG=lang())): # ошибки magic_link — на языке small
109
+ try:
110
+ module.send_mail(to, tr("small: email check for %s", "small: проверка почты для %s") % app,
111
+ tr("Sign-in emails for %s will arrive just like this one.\n",
112
+ "Письма для входа в %s будут приходить так же, как это.\n") % app,
113
+ tr("<p>Sign-in emails for <b>%s</b> will arrive just like this one.</p>",
114
+ "<p>Письма для входа в <b>%s</b> будут приходить так же, как это.</p>") % app)
115
+ except Exception as e: # noqa: BLE001 — показываем причину, но не ключ
116
+ hint = ""
117
+ if "RESEND_API_KEY" in values and "403" in str(getattr(e, "code", "")):
118
+ hint = tr(" Without a verified domain, Resend only sends to your account email; "
119
+ "verify a domain in Resend and pass --from.",
120
+ " Без подтверждённого домена Resend шлёт только на адрес вашего аккаунта; "
121
+ "подтвердите домен в Resend и укажите --from.")
122
+ raise CliError(tr("test email was not sent: %s.%s", "пробное письмо не ушло: %s.%s")
123
+ % (module._describe(e), hint)) from None
124
+ say(tr("Test email sent to %s. Check your inbox (and the Spam folder).",
125
+ "Пробное письмо отправлено на %s — проверьте почту (и папку «Спам»).") % to)
126
+
127
+
128
+ def cmd_mail_resend(args):
129
+ root = app_dir(args.path)
130
+ key = secret_input("RESEND_API_KEY", tr("API key from resend.com → API Keys.",
131
+ "Ключ API из resend.com → API Keys."))
132
+ values = {"RESEND_API_KEY": key, "MAIL_FROM": args.sender or ""}
133
+ test_letter(root, values, args.to)
134
+ linked = change_env(root, {k: v for k, v in values.items() if v},
135
+ unset=["SMTP_URL", "LOGIN_LINKS_TO_LOG"] + ([] if args.sender else ["MAIL_FROM"]),
136
+ deploy=not args.no_deploy)
137
+ report_env_change(linked, not args.no_deploy, tr("Sign-in emails are sent via Resend",
138
+ "Письма для входа отправляются через Resend"))
139
+ return 0
140
+
141
+
142
+ def cmd_mail_smtp(args):
143
+ root = app_dir(args.path)
144
+ url = secret_input("SMTP_URL", tr("Server URL: smtp://user:password@host:587 or smtps://…:465.",
145
+ "Адрес сервера: smtp://логин:пароль@сервер:587 или smtps://…:465."))
146
+ parts = urlsplit(url)
147
+ if parts.scheme not in ("smtp", "smtps") or not parts.hostname:
148
+ raise CliError(tr("SMTP_URL must look like smtp://user:password@host:587 (or smtps://…:465)",
149
+ "SMTP_URL должен выглядеть так: smtp://логин:пароль@сервер:587 (или smtps://…:465)"))
150
+ values = {"SMTP_URL": url, "MAIL_FROM": args.sender or ""}
151
+ test_letter(root, values, args.to)
152
+ linked = change_env(root, {k: v for k, v in values.items() if v},
153
+ unset=["RESEND_API_KEY", "LOGIN_LINKS_TO_LOG"] + ([] if args.sender else ["MAIL_FROM"]),
154
+ deploy=not args.no_deploy)
155
+ report_env_change(linked, not args.no_deploy, tr("Sign-in emails are sent via %s",
156
+ "Письма для входа отправляются через %s") % parts.hostname)
157
+ say(tr("On Railway, SMTP is open only on the Pro plan; on Hobby, use small mail resend.",
158
+ "На Railway SMTP открыт только с тарифа Pro; на Hobby используйте small mail resend."))
159
+ return 0
160
+
161
+
162
+ def cmd_mail_log(args):
163
+ root = app_dir(args.path)
164
+ linked = change_env(root, {"LOGIN_LINKS_TO_LOG": "1"}, deploy=not args.no_deploy)
165
+ report_env_change(linked, not args.no_deploy, tr("Sign-in links are written to the app log instead of emails",
166
+ "Ссылки для входа пишутся в лог приложения вместо писем"))
167
+ # Ищем строку middleware на языке владельца (OWNER_LANG = язык small при деплое).
168
+ search = ('Select-String "%s"' if sys.platform == "win32" else 'grep "%s"') % tr("sign-in link", "ссылка для входа")
169
+ say(tr("For testing only: a link from the log lets anyone into the app. View: small logs | %s",
170
+ "Только для проверки: ссылка из лога пускает в приложение. Смотреть: small logs | %s") % search)
171
+ return 0
172
+
173
+
174
+ def cmd_mail_off(args):
175
+ root = app_dir(args.path)
176
+ linked = change_env(root, unset=list(ALL_VARS), deploy=not args.no_deploy)
177
+ report_env_change(linked, not args.no_deploy, tr("Sign-in emails are turned off",
178
+ "Отправка писем для входа выключена"))
179
+ return 0
180
+
181
+
182
+ def cmd_mail_status(args):
183
+ root = app_dir(args.path)
184
+ env, _ = tokens.app_env(root, load_state(root / STATE_FILE), warn=say)
185
+ sender = env.get("MAIL_FROM") or ""
186
+ if env.get("LOGIN_LINKS_TO_LOG"):
187
+ say(tr("Emails are not sent: sign-in links are written to the app log (test mode).",
188
+ "Письма не отправляются: ссылки для входа пишутся в лог приложения (режим проверки)."))
189
+ print("log")
190
+ elif env.get("RESEND_API_KEY"):
191
+ say(tr("Sign-in emails: Resend, sender %s", "Письма для входа: Resend, отправитель %s")
192
+ % (sender or tr("onboarding@resend.dev (only to your account email)",
193
+ "onboarding@resend.dev (только на адрес аккаунта)")))
194
+ print("resend")
195
+ elif env.get("SMTP_URL"):
196
+ say(tr("Sign-in emails: SMTP %s%s", "Письма для входа: SMTP %s%s")
197
+ % (urlsplit(env["SMTP_URL"]).hostname, tr(", sender ", ", отправитель ") + sender if sender else ""))
198
+ print("smtp")
199
+ else:
200
+ say(tr("Email is not set up: sign-in by email does not work. Set it up: small mail resend",
201
+ "Почта не настроена: вход по почте не работает. Подключить: small mail resend"))
202
+ print("off")
203
+ return 0