small-software 0.6.0__tar.gz → 0.7.0__tar.gz

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 (56) hide show
  1. {small_software-0.6.0 → small_software-0.7.0}/PKG-INFO +78 -13
  2. {small_software-0.6.0 → small_software-0.7.0}/README.md +77 -12
  3. {small_software-0.6.0 → small_software-0.7.0}/pyproject.toml +1 -1
  4. small_software-0.7.0/small_cli/__init__.py +1 -0
  5. {small_software-0.6.0 → small_software-0.7.0}/small_cli/access.py +30 -17
  6. {small_software-0.6.0 → small_software-0.7.0}/small_cli/archive.py +6 -6
  7. {small_software-0.6.0 → small_software-0.7.0}/small_cli/cli.py +10 -9
  8. small_software-0.7.0/small_cli/commands/__init__.py +8 -0
  9. {small_software-0.6.0 → small_software-0.7.0}/small_cli/commands/access.py +119 -65
  10. {small_software-0.6.0 → small_software-0.7.0}/small_cli/commands/dashboard.py +1 -1
  11. {small_software-0.6.0 → small_software-0.7.0}/small_cli/commands/db.py +8 -8
  12. {small_software-0.6.0 → small_software-0.7.0}/small_cli/commands/deploy.py +15 -15
  13. {small_software-0.6.0 → small_software-0.7.0}/small_cli/commands/destroy.py +2 -2
  14. {small_software-0.6.0 → small_software-0.7.0}/small_cli/commands/domain.py +1 -1
  15. {small_software-0.6.0 → small_software-0.7.0}/small_cli/commands/env.py +8 -8
  16. {small_software-0.6.0 → small_software-0.7.0}/small_cli/commands/init.py +5 -5
  17. {small_software-0.6.0 → small_software-0.7.0}/small_cli/commands/link.py +1 -1
  18. {small_software-0.6.0 → small_software-0.7.0}/small_cli/commands/logs.py +1 -1
  19. {small_software-0.6.0 → small_software-0.7.0}/small_cli/commands/mail.py +8 -8
  20. small_software-0.7.0/small_cli/commands/mcp.py +23 -0
  21. {small_software-0.6.0 → small_software-0.7.0}/small_cli/commands/notify.py +5 -5
  22. {small_software-0.6.0 → small_software-0.7.0}/small_cli/commands/sleep.py +2 -2
  23. {small_software-0.6.0 → small_software-0.7.0}/small_cli/common.py +4 -4
  24. {small_software-0.6.0 → small_software-0.7.0}/small_cli/config.py +6 -6
  25. small_software-0.7.0/small_cli/dashboard.py +1108 -0
  26. {small_software-0.6.0 → small_software-0.7.0}/small_cli/i18n.py +10 -9
  27. small_software-0.7.0/small_cli/mcp.py +364 -0
  28. {small_software-0.6.0 → small_software-0.7.0}/small_cli/railway.py +25 -25
  29. {small_software-0.6.0 → small_software-0.7.0}/small_cli/scaffold.py +11 -11
  30. {small_software-0.6.0/small_cli/templates/python-http → small_software-0.7.0/small_cli/templates/fastapi}/magic_link.py +223 -90
  31. {small_software-0.6.0 → small_software-0.7.0}/small_cli/templates/node-http/magic-link.js +49 -45
  32. {small_software-0.6.0/small_cli/templates/fastapi → small_software-0.7.0/small_cli/templates/python-http}/magic_link.py +223 -90
  33. small_software-0.7.0/small_cli/templates/streamlit/app.py +249 -0
  34. small_software-0.7.0/small_cli/templates/streamlit/magic_link.py +808 -0
  35. {small_software-0.6.0 → small_software-0.7.0}/small_software.egg-info/PKG-INFO +78 -13
  36. {small_software-0.6.0 → small_software-0.7.0}/small_software.egg-info/SOURCES.txt +7 -1
  37. small_software-0.7.0/tests/test_dashboard_share.py +355 -0
  38. small_software-0.7.0/tests/test_mcp.py +229 -0
  39. {small_software-0.6.0 → small_software-0.7.0}/tests/test_small.py +124 -121
  40. small_software-0.7.0/tests/test_streamlit_email.py +434 -0
  41. small_software-0.6.0/small_cli/__init__.py +0 -1
  42. small_software-0.6.0/small_cli/commands/__init__.py +0 -8
  43. small_software-0.6.0/small_cli/dashboard.py +0 -648
  44. small_software-0.6.0/small_cli/templates/streamlit/app.py +0 -181
  45. {small_software-0.6.0 → small_software-0.7.0}/LICENSE +0 -0
  46. {small_software-0.6.0 → small_software-0.7.0}/setup.cfg +0 -0
  47. {small_software-0.6.0 → small_software-0.7.0}/small_cli/__main__.py +0 -0
  48. {small_software-0.6.0 → small_software-0.7.0}/small_cli/templates/fastapi/main.py +0 -0
  49. {small_software-0.6.0 → small_software-0.7.0}/small_cli/templates/fastapi/requirements.txt +0 -0
  50. {small_software-0.6.0 → small_software-0.7.0}/small_cli/templates/node-http/package.json +0 -0
  51. {small_software-0.6.0 → small_software-0.7.0}/small_cli/templates/node-http/server.js +0 -0
  52. {small_software-0.6.0 → small_software-0.7.0}/small_cli/templates/python-http/app.py +0 -0
  53. {small_software-0.6.0 → small_software-0.7.0}/small_cli/templates/streamlit/requirements.txt +0 -0
  54. {small_software-0.6.0 → small_software-0.7.0}/small_software.egg-info/dependency_links.txt +0 -0
  55. {small_software-0.6.0 → small_software-0.7.0}/small_software.egg-info/entry_points.txt +0 -0
  56. {small_software-0.6.0 → small_software-0.7.0}/small_software.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: small-software
3
- Version: 0.6.0
3
+ Version: 0.7.0
4
4
  Summary: A cloud for small software: deploy team apps to Railway in one command, private by default, shared by work email
5
5
  Author: RuslanKazyradzi13
6
6
  License-Expression: MIT
@@ -70,8 +70,25 @@ From source, in a clone of the repository:
70
70
  pip install ./small-cli
71
71
  ```
72
72
 
73
- **Windows.** `pip install` creates `small.exe`, which Smart App Control / Application Control may block because it
74
- is unsigned. If that happens, either:
73
+ **Windows (recommended): one command in PowerShell.**
74
+
75
+ ```powershell
76
+ irm https://raw.githubusercontent.com/RuslanKazyradzi13/small-software/main/install.ps1 | iex
77
+ ```
78
+
79
+ It installs small into its own Python environment in `%LOCALAPPDATA%\Programs\small` and adds a `small`
80
+ command that Smart App Control does not block (there is no unsigned `small.exe`). Needs Python 3.9+, no admin
81
+ rights; run it again to upgrade. Uninstall:
82
+
83
+ ```powershell
84
+ & ([scriptblock]::Create((irm https://raw.githubusercontent.com/RuslanKazyradzi13/small-software/main/install.ps1))) -Uninstall
85
+ ```
86
+
87
+ Values with `&` or `|` (URLs, for example) need PowerShell's stop-parsing token:
88
+ `small --% env set "URL=https://x?a=1&b=2"`.
89
+
90
+ **Windows with pip.** `pip install` creates `small.exe`, which Smart App Control / Application Control may
91
+ block because it is unsigned. If that happens, either:
75
92
 
76
93
  - run small through Python, which works everywhere: `python -m small_cli deploy` (any command works this way);
77
94
  - or add the `small-cli` folder of a clone to `PATH`, and `small.cmd` will serve the `small` command. Run this in
@@ -154,12 +171,15 @@ The sample apps are in English: both the page text and the code comments.
154
171
  `small deploy` passes `ACCESS_TOKEN` from `small.json` to Railway. The app opens at
155
172
  `https://<domain>/?token=<ACCESS_TOKEN>`; without the token visitors get a 403 page with a **Request access**
156
173
  button. The server listens on `$HOST:$PORT`, by default `0.0.0.0` and the `PORT` set by Railway.
157
- - **The `streamlit` template.** Streamlit is not a WSGI app, so `magic_link.py` cannot wrap it. The same logic is
158
- built into `app.py` as `require_access()`: the same `ACCESS_TOKEN` and `ACCESS_REQUEST_URL`, an "Invite only"
159
- screen and a **Request access** form that writes an `[access-request]` line to the log. Like magic-link, the
160
- access screen follows the visitor's browser language (`ACCESS_LANG` forces one). There are two differences.
161
- Streamlit cannot set cookies, so the token stays in the address bar, which is how access survives a page reload.
162
- And Streamlit is a single-page app, so the "no access" screen is served with HTTP 200, not 403.
174
+ - **The `streamlit` template.** Streamlit can't be wrapped in middleware, so `require_access()` in `app.py` uses
175
+ `MagicLinkGate` from `magic_link.py`: the same variables (`ACCESS_EMAILS`, `ACCESS_TOKEN`, `SESSION_SECRET`,
176
+ `ACCESS_REQUEST_URL`...), one-time links, limits and logs as the other templates, and the access screen follows the
177
+ visitor's browser language. Differences:
178
+ - Sign-in by email: the link leads to `https://<domain>/?t=...` and shows a **Sign in** button; only the click
179
+ signs in. The 30-day session is a signed cookie written by a tiny same-origin frame, so unlike in the other
180
+ templates it is **not HttpOnly**: don't render untrusted HTML in a protected app. **Sign out** is in the sidebar.
181
+ - Personal links keep the token in the address bar (`?token=`), which is how they survive a page reload.
182
+ - Streamlit is a single-page app, so the "no access" screen is served with HTTP 200, not 403.
163
183
  Railway runs `python app.py`, and `app.py` restarts itself through `streamlit run` on `$HOST:$PORT`.
164
184
  Run it locally the same way: `python app.py`.
165
185
  - **Name.** By default, the folder name plus a random suffix, for example `my-service-3f9a`. An explicit name in
@@ -204,6 +224,12 @@ Buttons on the card of a deployed app:
204
224
  The token only goes to the browser's address bar on navigation; it is not in the dashboard page data.
205
225
  - **Logs** opens a window with the last 200 lines of the app or build log (tabs **App** / **Build**).
206
226
  Requires `RAILWAY_API_TOKEN`; without it the button is disabled.
227
+ - **Share** opens who has access: work emails, whole domains and personal-link names (never the links
228
+ themselves). Type `ivan@company.com`, `@company.com` or a name and click **Add**, or click **Revoke** next to
229
+ anyone. It does exactly what `small access add` / `revoke` does: updates small.json and Railway and restarts
230
+ the app. A new personal link is shown only once, with a Copy button, and works like a password. Requires
231
+ `RAILWAY_API_TOKEN`. The page and its requests are protected: a per-run key, `Host`/`Origin` checks, no
232
+ framing, JSON only.
207
233
 
208
234
  - **Port** 3000 by default; if it is busy, any free port is used. The URL is printed to stdout.
209
235
  - **The server listens on `127.0.0.1` only** and answers 403 to requests with a foreign `Host` (DNS rebinding
@@ -247,6 +273,46 @@ $10 per GB of memory, $0.15 per GB of volume. The result reads "≈ $X/mo at cur
247
273
  - **This is an estimate, not a bill.** Egress ($0.05/GB), the plan's free allowance ($5 on Hobby, $20 on Pro) and
248
274
  the subscription are not included. The exact amount is in Railway → Usage.
249
275
 
276
+ ## `small mcp`: AI agents (Claude Code, Cursor...)
277
+
278
+ ```
279
+ small mcp
280
+ ```
281
+
282
+ Runs an [MCP](https://modelcontextprotocol.io) server on stdin/stdout, so an AI coding agent can create, deploy and
283
+ share apps itself: *"Deploy this folder and give ivan@company.com access."*
284
+
285
+ **Connect it:**
286
+ - **Claude Code:** `claude mcp add small -- small mcp` (Windows: `claude mcp add small -- cmd /c small mcp`).
287
+ - **Cursor** (`~/.cursor/mcp.json`), **Claude Desktop**, **Windsurf** and other clients:
288
+
289
+ ```json
290
+ {"mcpServers": {"small": {"command": "small", "args": ["mcp"]}}}
291
+ ```
292
+
293
+ On Windows use `"command": "cmd", "args": ["/c", "small", "mcp"]`, or `"command": "python", "args": ["-m",
294
+ "small_cli", "mcp"]` if small was installed with pip.
295
+ - `RAILWAY_API_TOKEN` must be in the environment the agent starts from. After setting it, restart the agent.
296
+
297
+ **Tools:**
298
+
299
+ | Tool | What it does |
300
+ |---|---|
301
+ | `init_app` | `small init`: a ready app from a template |
302
+ | `deploy_app` | `small deploy`: returns the URL; progress is streamed while it builds |
303
+ | `share_app` | `small access add`: an email, `@domain` or a personal-link name |
304
+ | `revoke_access` | `small access revoke` |
305
+ | `list_access` | `small access list` |
306
+ | `list_apps` | apps under a folder with URL and latest deploy status |
307
+ | `app_logs` | `small logs`: app or build log |
308
+ | `set_env` | `small env set` / `unset` |
309
+
310
+ - **No delete tool:** `small destroy` stays a terminal command.
311
+ - **Prefer emails.** A personal link (a name instead of an email) is returned to the agent, so it passes through the
312
+ chat like any other text; with email sign-in nothing secret does.
313
+ - The tools run the same code as the commands and answer in small's language (`SMALL_LANG`). Your MCP client
314
+ normally asks you before each tool call.
315
+
250
316
  ## `small deploy`
251
317
 
252
318
  ```
@@ -315,8 +381,7 @@ then takes effect on the next deploy.
315
381
  - Someone who is not on the list and enters their email gets the same "Check your email" response, and the owner
316
382
  receives an access request (in the log, and in Telegram if connected) with a ready-made
317
383
  `small access add their@address` command.
318
- - Works in the `python-http`, `fastapi` and `node-http` templates. `streamlit` supports personal links only for
319
- now.
384
+ - Works in all templates: `python-http`, `fastapi`, `node-http` and `streamlit` (see the `streamlit` notes above).
320
385
 
321
386
  *By personal invite link*:
322
387
  - **`add bob`** creates a token, adds it to `ACCESS_TOKEN` as `bob:token`, restarts the app and prints the link.
@@ -501,8 +566,8 @@ The tests start a local mock of the Railway API (GraphQL + the upload endpoint)
501
566
  deploy, repeat deploy, failed build, crash, taken domain, several workspaces, wrong token, ignore rules.
502
567
 
503
568
  Template tests on real frameworks are skipped if the framework is not installed; the rest pass.
504
- - 6 `streamlit` tests: the access gate through `streamlit.testing.v1.AppTest` (with personal tokens) and the server
505
- through `python app.py`.
569
+ - `streamlit` tests: the access gate through `streamlit.testing.v1.AppTest` (personal tokens and sign-in by email:
570
+ form, one-time link, session cookie, revocation, sign out) and the server through `python app.py`.
506
571
  - 2 `fastapi` tests: `TestClient` and `uvicorn main:app` the way Railway runs it, plus a check that the token is
507
572
  hidden in the access log.
508
573
 
@@ -42,8 +42,25 @@ From source, in a clone of the repository:
42
42
  pip install ./small-cli
43
43
  ```
44
44
 
45
- **Windows.** `pip install` creates `small.exe`, which Smart App Control / Application Control may block because it
46
- is unsigned. If that happens, either:
45
+ **Windows (recommended): one command in PowerShell.**
46
+
47
+ ```powershell
48
+ irm https://raw.githubusercontent.com/RuslanKazyradzi13/small-software/main/install.ps1 | iex
49
+ ```
50
+
51
+ It installs small into its own Python environment in `%LOCALAPPDATA%\Programs\small` and adds a `small`
52
+ command that Smart App Control does not block (there is no unsigned `small.exe`). Needs Python 3.9+, no admin
53
+ rights; run it again to upgrade. Uninstall:
54
+
55
+ ```powershell
56
+ & ([scriptblock]::Create((irm https://raw.githubusercontent.com/RuslanKazyradzi13/small-software/main/install.ps1))) -Uninstall
57
+ ```
58
+
59
+ Values with `&` or `|` (URLs, for example) need PowerShell's stop-parsing token:
60
+ `small --% env set "URL=https://x?a=1&b=2"`.
61
+
62
+ **Windows with pip.** `pip install` creates `small.exe`, which Smart App Control / Application Control may
63
+ block because it is unsigned. If that happens, either:
47
64
 
48
65
  - run small through Python, which works everywhere: `python -m small_cli deploy` (any command works this way);
49
66
  - or add the `small-cli` folder of a clone to `PATH`, and `small.cmd` will serve the `small` command. Run this in
@@ -126,12 +143,15 @@ The sample apps are in English: both the page text and the code comments.
126
143
  `small deploy` passes `ACCESS_TOKEN` from `small.json` to Railway. The app opens at
127
144
  `https://<domain>/?token=<ACCESS_TOKEN>`; without the token visitors get a 403 page with a **Request access**
128
145
  button. The server listens on `$HOST:$PORT`, by default `0.0.0.0` and the `PORT` set by Railway.
129
- - **The `streamlit` template.** Streamlit is not a WSGI app, so `magic_link.py` cannot wrap it. The same logic is
130
- built into `app.py` as `require_access()`: the same `ACCESS_TOKEN` and `ACCESS_REQUEST_URL`, an "Invite only"
131
- screen and a **Request access** form that writes an `[access-request]` line to the log. Like magic-link, the
132
- access screen follows the visitor's browser language (`ACCESS_LANG` forces one). There are two differences.
133
- Streamlit cannot set cookies, so the token stays in the address bar, which is how access survives a page reload.
134
- And Streamlit is a single-page app, so the "no access" screen is served with HTTP 200, not 403.
146
+ - **The `streamlit` template.** Streamlit can't be wrapped in middleware, so `require_access()` in `app.py` uses
147
+ `MagicLinkGate` from `magic_link.py`: the same variables (`ACCESS_EMAILS`, `ACCESS_TOKEN`, `SESSION_SECRET`,
148
+ `ACCESS_REQUEST_URL`...), one-time links, limits and logs as the other templates, and the access screen follows the
149
+ visitor's browser language. Differences:
150
+ - Sign-in by email: the link leads to `https://<domain>/?t=...` and shows a **Sign in** button; only the click
151
+ signs in. The 30-day session is a signed cookie written by a tiny same-origin frame, so unlike in the other
152
+ templates it is **not HttpOnly**: don't render untrusted HTML in a protected app. **Sign out** is in the sidebar.
153
+ - Personal links keep the token in the address bar (`?token=`), which is how they survive a page reload.
154
+ - Streamlit is a single-page app, so the "no access" screen is served with HTTP 200, not 403.
135
155
  Railway runs `python app.py`, and `app.py` restarts itself through `streamlit run` on `$HOST:$PORT`.
136
156
  Run it locally the same way: `python app.py`.
137
157
  - **Name.** By default, the folder name plus a random suffix, for example `my-service-3f9a`. An explicit name in
@@ -176,6 +196,12 @@ Buttons on the card of a deployed app:
176
196
  The token only goes to the browser's address bar on navigation; it is not in the dashboard page data.
177
197
  - **Logs** opens a window with the last 200 lines of the app or build log (tabs **App** / **Build**).
178
198
  Requires `RAILWAY_API_TOKEN`; without it the button is disabled.
199
+ - **Share** opens who has access: work emails, whole domains and personal-link names (never the links
200
+ themselves). Type `ivan@company.com`, `@company.com` or a name and click **Add**, or click **Revoke** next to
201
+ anyone. It does exactly what `small access add` / `revoke` does: updates small.json and Railway and restarts
202
+ the app. A new personal link is shown only once, with a Copy button, and works like a password. Requires
203
+ `RAILWAY_API_TOKEN`. The page and its requests are protected: a per-run key, `Host`/`Origin` checks, no
204
+ framing, JSON only.
179
205
 
180
206
  - **Port** 3000 by default; if it is busy, any free port is used. The URL is printed to stdout.
181
207
  - **The server listens on `127.0.0.1` only** and answers 403 to requests with a foreign `Host` (DNS rebinding
@@ -219,6 +245,46 @@ $10 per GB of memory, $0.15 per GB of volume. The result reads "≈ $X/mo at cur
219
245
  - **This is an estimate, not a bill.** Egress ($0.05/GB), the plan's free allowance ($5 on Hobby, $20 on Pro) and
220
246
  the subscription are not included. The exact amount is in Railway → Usage.
221
247
 
248
+ ## `small mcp`: AI agents (Claude Code, Cursor...)
249
+
250
+ ```
251
+ small mcp
252
+ ```
253
+
254
+ Runs an [MCP](https://modelcontextprotocol.io) server on stdin/stdout, so an AI coding agent can create, deploy and
255
+ share apps itself: *"Deploy this folder and give ivan@company.com access."*
256
+
257
+ **Connect it:**
258
+ - **Claude Code:** `claude mcp add small -- small mcp` (Windows: `claude mcp add small -- cmd /c small mcp`).
259
+ - **Cursor** (`~/.cursor/mcp.json`), **Claude Desktop**, **Windsurf** and other clients:
260
+
261
+ ```json
262
+ {"mcpServers": {"small": {"command": "small", "args": ["mcp"]}}}
263
+ ```
264
+
265
+ On Windows use `"command": "cmd", "args": ["/c", "small", "mcp"]`, or `"command": "python", "args": ["-m",
266
+ "small_cli", "mcp"]` if small was installed with pip.
267
+ - `RAILWAY_API_TOKEN` must be in the environment the agent starts from. After setting it, restart the agent.
268
+
269
+ **Tools:**
270
+
271
+ | Tool | What it does |
272
+ |---|---|
273
+ | `init_app` | `small init`: a ready app from a template |
274
+ | `deploy_app` | `small deploy`: returns the URL; progress is streamed while it builds |
275
+ | `share_app` | `small access add`: an email, `@domain` or a personal-link name |
276
+ | `revoke_access` | `small access revoke` |
277
+ | `list_access` | `small access list` |
278
+ | `list_apps` | apps under a folder with URL and latest deploy status |
279
+ | `app_logs` | `small logs`: app or build log |
280
+ | `set_env` | `small env set` / `unset` |
281
+
282
+ - **No delete tool:** `small destroy` stays a terminal command.
283
+ - **Prefer emails.** A personal link (a name instead of an email) is returned to the agent, so it passes through the
284
+ chat like any other text; with email sign-in nothing secret does.
285
+ - The tools run the same code as the commands and answer in small's language (`SMALL_LANG`). Your MCP client
286
+ normally asks you before each tool call.
287
+
222
288
  ## `small deploy`
223
289
 
224
290
  ```
@@ -287,8 +353,7 @@ then takes effect on the next deploy.
287
353
  - Someone who is not on the list and enters their email gets the same "Check your email" response, and the owner
288
354
  receives an access request (in the log, and in Telegram if connected) with a ready-made
289
355
  `small access add their@address` command.
290
- - Works in the `python-http`, `fastapi` and `node-http` templates. `streamlit` supports personal links only for
291
- now.
356
+ - Works in all templates: `python-http`, `fastapi`, `node-http` and `streamlit` (see the `streamlit` notes above).
292
357
 
293
358
  *By personal invite link*:
294
359
  - **`add bob`** creates a token, adds it to `ACCESS_TOKEN` as `bob:token`, restarts the app and prints the link.
@@ -473,8 +538,8 @@ The tests start a local mock of the Railway API (GraphQL + the upload endpoint)
473
538
  deploy, repeat deploy, failed build, crash, taken domain, several workspaces, wrong token, ignore rules.
474
539
 
475
540
  Template tests on real frameworks are skipped if the framework is not installed; the rest pass.
476
- - 6 `streamlit` tests: the access gate through `streamlit.testing.v1.AppTest` (with personal tokens) and the server
477
- through `python app.py`.
541
+ - `streamlit` tests: the access gate through `streamlit.testing.v1.AppTest` (personal tokens and sign-in by email:
542
+ form, one-time link, session cookie, revocation, sign out) and the server through `python app.py`.
478
543
  - 2 `fastapi` tests: `TestClient` and `uvicorn main:app` the way Railway runs it, plus a check that the token is
479
544
  hidden in the access log.
480
545
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "small-software"
7
- version = "0.6.0"
7
+ version = "0.7.0"
8
8
  description = "A cloud for small software: deploy team apps to Railway in one command, private by default, shared by work email"
9
9
  license = "MIT"
10
10
  license-files = ["LICENSE"]
@@ -0,0 +1 @@
1
+ __version__ = "0.7.0"
@@ -1,7 +1,8 @@
1
- """Доступ к приложению: ACCESS_TOKEN (ссылки-приглашения "имя:токен,…") и ACCESS_EMAILS (вход по почте).
1
+ """App access: ACCESS_TOKEN (invite links "name:token,…") and ACCESS_EMAILS (sign-in by email).
2
2
 
3
- Тот же формат понимают magic_link.py, magic-link.js и шаблон streamlit: убрали чей-то токен — его ссылка
4
- и куки перестают работать, у остальных всё остаётся. Убрали адрес из ACCESS_EMAILS — вход по нему закрыт сразу.
3
+ magic_link.py, magic-link.js and the streamlit template understand the same format: remove someone's token and
4
+ their link and cookie stop working, while everyone else keeps access. Remove an address from ACCESS_EMAILS and
5
+ sign-in with it is closed at once.
5
6
  """
6
7
 
7
8
  import re
@@ -38,12 +39,12 @@ def new_token():
38
39
 
39
40
 
40
41
  def is_email(value):
41
- """Адрес почты или целый домен "@company.com"."""
42
+ """An email address or a whole domain "@company.com"."""
42
43
  return bool(EMAIL_RE.match(value) or DOMAIN_RE.match(value))
43
44
 
44
45
 
45
46
  def parse_emails(value):
46
- """"Ivan@Company.com, @team.com" -> ["ivan@company.com", "@team.com"]: без регистра, повторов и мусора."""
47
+ """"Ivan@Company.com, @team.com" -> ["ivan@company.com", "@team.com"]: lowercased, without duplicates or junk."""
47
48
  found = []
48
49
  for item in (value or "").split(","):
49
50
  item = item.strip().lower()
@@ -53,7 +54,7 @@ def parse_emails(value):
53
54
 
54
55
 
55
56
  def app_env(root, state, warn=None):
56
- """(переменные приложения, откуда каждая): Railway, поверх — непустые значения из small.json."""
57
+ """(app variables, where each one comes from): Railway, overlaid with non-empty values from small.json."""
57
58
  env, sources = {}, {}
58
59
  linked = all(state.get(k) for k in ("projectId", "environmentId", "serviceId"))
59
60
  if linked and token_from_env():
@@ -72,7 +73,7 @@ def app_env(root, state, warn=None):
72
73
 
73
74
 
74
75
  def current(root, state, warn=None):
75
- """(записи ACCESS_TOKEN, откуда): из small.json, иначе из переменных сервиса в Railway, иначе ([], None)."""
76
+ """(ACCESS_TOKEN entries, source): from small.json, else from the service variables in Railway, else ([], None)."""
76
77
  raw = load_config(root / CONFIG_FILE)["env"].get("ACCESS_TOKEN")
77
78
  if raw:
78
79
  return parse(raw), CONFIG_FILE
@@ -83,7 +84,7 @@ def current(root, state, warn=None):
83
84
 
84
85
 
85
86
  def pick(entries, name=None):
86
- """Токен человека name; без имени — первый (владельца). None, если такого нет."""
87
+ """The token of the person `name`; without a name, the first one (the owner's). None if there is no such entry."""
87
88
  if name is None:
88
89
  return entries[0][1] if entries else None
89
90
  return next((token for who, token in entries if who == name), None)
@@ -95,20 +96,32 @@ def invite_link(domain, token):
95
96
 
96
97
 
97
98
  def email_login_problem(root):
98
- """None — код приложения умеет вход по почте; иначе — что мешает и как исправить."""
99
- for name, template in (("magic_link.py", "python-http"), ("magic-link.js", "node-http")):
99
+ """None if the app code supports sign-in by email; otherwise, what is in the way and how to fix it.
100
+
101
+ Streamlit cannot be wrapped in middleware: there sign-in by email is done by require_access() in app.py via
102
+ MagicLinkGate from magic_link.py, so new versions of both files are needed."""
103
+ app = root / "app.py"
104
+ source = app.read_text(encoding="utf-8", errors="replace") if app.is_file() else ""
105
+ streamlit = "streamlit" in source
106
+ if streamlit and "MagicLinkGate" not in source:
107
+ return (tr("app.py is from an old streamlit template without sign-in by email. Take require_access() from "
108
+ "the new app.py, and magic_link.py next to it: %s (or grant a personal link: small access add NAME)",
109
+ "app.py — из старого шаблона streamlit, без входа по почте. Возьмите require_access() из нового "
110
+ "app.py и magic_link.py рядом с ним: %s (или выдайте личную ссылку: small access add ИМЯ)")
111
+ % (TEMPLATES_DIR / "streamlit"))
112
+ for name, template in (("magic_link.py", "streamlit" if streamlit else "python-http"),
113
+ ("magic-link.js", "node-http")):
100
114
  path = root / name
101
115
  if path.is_file():
102
- if "ACCESS_EMAILS" in path.read_text(encoding="utf-8", errors="replace"):
116
+ text = path.read_text(encoding="utf-8", errors="replace")
117
+ if "ACCESS_EMAILS" in text and (not streamlit or "MagicLinkGate" in text):
103
118
  return None
104
119
  return (tr("%s in the app is an old version and does not support sign-in by email. "
105
120
  "Replace it with the new one: %s",
106
121
  "%s в приложении старой версии и не знает входа по почте. Замените его новым: %s")
107
122
  % (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 ИМЯ")
123
+ if streamlit:
124
+ return (tr("magic_link.py is missing next to app.py. Copy it from the template: %s",
125
+ "рядом с app.py нет magic_link.py. Скопируйте его из шаблона: %s")
126
+ % (TEMPLATES_DIR / "streamlit" / "magic_link.py"))
114
127
  return None
@@ -1,19 +1,19 @@
1
- """Сбор файлов проекта с учётом .gitignore/.railwayignore и упаковка в .tar.gz для Railway."""
1
+ """Collecting project files with .gitignore/.railwayignore applied, and packing them into a .tar.gz for Railway."""
2
2
 
3
3
  import io
4
4
  import os
5
5
  import re
6
6
  import tarfile
7
7
 
8
- # Никогда не загружаются, даже без .gitignore.
8
+ # Never uploaded, even without a .gitignore.
9
9
  ALWAYS_IGNORED = {".git", "node_modules", "__pycache__", ".venv", "venv"}
10
10
  IGNORE_FILES = (".gitignore", ".railwayignore")
11
11
 
12
12
 
13
13
  class IgnoreRules:
14
- """Подмножество синтаксиса .gitignore: *, **, ?, [...], !отрицание, /якорь, хвостовой / для папок.
14
+ """A subset of .gitignore syntax: *, **, ?, [...], !negation, /anchor, trailing / for folders.
15
15
 
16
- Читаются только файлы в корне проекта; вложенные .gitignore не учитываются.
16
+ Only the files in the project root are read; nested .gitignore files are not taken into account.
17
17
  """
18
18
 
19
19
  def __init__(self, lines):
@@ -90,9 +90,9 @@ def _translate(pattern):
90
90
 
91
91
 
92
92
  def collect_files(root, skip_root_files=()):
93
- """Относительные пути (через /) всех файлов, которые уйдут в Railway.
93
+ """Relative paths (with /) of all files that will go to Railway.
94
94
 
95
- skip_root_files — имена файлов, которые не загружаются, только если лежат в корне (служебные файлы CLI).
95
+ skip_root_files: names of files that are not uploaded, but only when they are in the root (the CLI's own files).
96
96
  """
97
97
  rules = IgnoreRules.load(root)
98
98
  files = []
@@ -1,7 +1,7 @@
1
- """small — микро-приложения на Railway одной командой.
1
+ """small: micro-apps on Railway with one command.
2
2
 
3
- Точка входа: разбор аргументов и обработка ошибок. Сами команды — в small_cli/commands/,
4
- общие помощники — в small_cli/common.py.
3
+ Entry point: argument parsing and error handling. The commands themselves live in small_cli/commands/,
4
+ shared helpers in small_cli/common.py.
5
5
  """
6
6
 
7
7
  import argparse
@@ -31,9 +31,10 @@ def main(argv=None):
31
31
 
32
32
 
33
33
  def setup_streams():
34
- """В терминал Python пишет Unicode сам. Вывод в канал или файл (`small logs | Select-String вход`) он кодирует
35
- в ANSI (cp1251), а PowerShell читает его в кодировке консоли (866 или UTF-8): кириллица превращается в мусор,
36
- и поиск по ней ничего не находит. Поэтому в канал пишем в кодировке, которой его прочитают."""
34
+ """Python writes Unicode to a terminal on its own. Output to a pipe or file
35
+ (`small logs | Select-String <Cyrillic word>`) it encodes as ANSI (cp1251), while PowerShell reads it in the
36
+ console encoding (866 or UTF-8): Cyrillic turns into garbage and searching for it finds nothing. So for a pipe
37
+ we write in the encoding it will be read with."""
37
38
  encoding = pipe_encoding()
38
39
  for stream in (sys.stdout, sys.stderr):
39
40
  if not hasattr(stream, "reconfigure"):
@@ -45,8 +46,8 @@ def setup_streams():
45
46
 
46
47
 
47
48
  def pipe_encoding():
48
- """Кодировка для вывода в канал: PYTHONIOENCODING, если задана (small.cmd запускает `python -I`, и сам Python
49
- её не читает), иначе на Windows — кодировка консоли. None — оставить как есть."""
49
+ """Encoding for output to a pipe: PYTHONIOENCODING if set (small.cmd runs `python -I`, so Python itself
50
+ does not read it), otherwise on Windows the console encoding. None means leave it as is."""
50
51
  explicit = os.environ.get("PYTHONIOENCODING", "").split(":")[0]
51
52
  if explicit:
52
53
  try:
@@ -55,7 +56,7 @@ def pipe_encoding():
55
56
  return None
56
57
  if sys.platform != "win32":
57
58
  return None
58
- codepage = _console_codepage() # 0 — у процесса нет консоли
59
+ codepage = _console_codepage() # 0: the process has no console
59
60
  return "utf-8" if codepage == 65001 else "cp%d" % codepage if codepage else None
60
61
 
61
62
 
@@ -0,0 +1,8 @@
1
+ """small commands. Each module adds its subcommands with a register(subparsers) function.
2
+
3
+ The order here is the order in `small --help`: from creating an app to deleting it.
4
+ """
5
+
6
+ from . import access, dashboard, db, deploy, destroy, domain, env, init, link, logs, mail, mcp, notify, sleep
7
+
8
+ ALL = (init, deploy, link, access, mail, notify, env, db, domain, sleep, logs, dashboard, mcp, destroy)