small-software 0.6.0__tar.gz → 0.8.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 (64) hide show
  1. {small_software-0.6.0 → small_software-0.8.0}/PKG-INFO +142 -31
  2. {small_software-0.6.0 → small_software-0.8.0}/README.md +141 -30
  3. {small_software-0.6.0 → small_software-0.8.0}/pyproject.toml +1 -1
  4. small_software-0.8.0/small_cli/__init__.py +1 -0
  5. small_software-0.8.0/small_cli/access.py +202 -0
  6. {small_software-0.6.0 → small_software-0.8.0}/small_cli/archive.py +6 -6
  7. {small_software-0.6.0 → small_software-0.8.0}/small_cli/cli.py +10 -9
  8. small_software-0.8.0/small_cli/commands/__init__.py +9 -0
  9. small_software-0.8.0/small_cli/commands/access.py +352 -0
  10. {small_software-0.6.0 → small_software-0.8.0}/small_cli/commands/dashboard.py +1 -1
  11. {small_software-0.6.0 → small_software-0.8.0}/small_cli/commands/db.py +8 -8
  12. {small_software-0.6.0 → small_software-0.8.0}/small_cli/commands/deploy.py +18 -16
  13. {small_software-0.6.0 → small_software-0.8.0}/small_cli/commands/destroy.py +2 -2
  14. {small_software-0.6.0 → small_software-0.8.0}/small_cli/commands/domain.py +1 -1
  15. {small_software-0.6.0 → small_software-0.8.0}/small_cli/commands/env.py +8 -8
  16. {small_software-0.6.0 → small_software-0.8.0}/small_cli/commands/init.py +5 -5
  17. {small_software-0.6.0 → small_software-0.8.0}/small_cli/commands/link.py +1 -1
  18. {small_software-0.6.0 → small_software-0.8.0}/small_cli/commands/logs.py +1 -1
  19. {small_software-0.6.0 → small_software-0.8.0}/small_cli/commands/mail.py +8 -8
  20. small_software-0.8.0/small_cli/commands/mcp.py +23 -0
  21. {small_software-0.6.0 → small_software-0.8.0}/small_cli/commands/notify.py +5 -5
  22. {small_software-0.6.0 → small_software-0.8.0}/small_cli/commands/sleep.py +2 -2
  23. small_software-0.8.0/small_cli/commands/sso.py +268 -0
  24. small_software-0.8.0/small_cli/commands/volume.py +89 -0
  25. {small_software-0.6.0 → small_software-0.8.0}/small_cli/common.py +4 -4
  26. {small_software-0.6.0 → small_software-0.8.0}/small_cli/config.py +6 -6
  27. small_software-0.8.0/small_cli/dashboard.py +1112 -0
  28. {small_software-0.6.0 → small_software-0.8.0}/small_cli/i18n.py +10 -9
  29. small_software-0.8.0/small_cli/mcp.py +389 -0
  30. {small_software-0.6.0 → small_software-0.8.0}/small_cli/railway.py +66 -25
  31. {small_software-0.6.0 → small_software-0.8.0}/small_cli/scaffold.py +11 -11
  32. small_software-0.8.0/small_cli/templates/fastapi/magic_link.py +1397 -0
  33. {small_software-0.6.0 → small_software-0.8.0}/small_cli/templates/fastapi/main.py +3 -3
  34. small_software-0.8.0/small_cli/templates/node-http/magic-link.js +988 -0
  35. {small_software-0.6.0 → small_software-0.8.0}/small_cli/templates/python-http/app.py +3 -2
  36. small_software-0.8.0/small_cli/templates/python-http/magic_link.py +1397 -0
  37. small_software-0.8.0/small_cli/templates/streamlit/app.py +314 -0
  38. small_software-0.8.0/small_cli/templates/streamlit/magic_link.py +1397 -0
  39. {small_software-0.6.0 → small_software-0.8.0}/small_software.egg-info/PKG-INFO +142 -31
  40. {small_software-0.6.0 → small_software-0.8.0}/small_software.egg-info/SOURCES.txt +10 -1
  41. small_software-0.8.0/tests/test_dashboard_share.py +355 -0
  42. small_software-0.8.0/tests/test_mcp.py +229 -0
  43. {small_software-0.6.0 → small_software-0.8.0}/tests/test_small.py +129 -124
  44. small_software-0.8.0/tests/test_sso_volume.py +392 -0
  45. small_software-0.8.0/tests/test_streamlit_email.py +562 -0
  46. small_software-0.6.0/small_cli/__init__.py +0 -1
  47. small_software-0.6.0/small_cli/access.py +0 -114
  48. small_software-0.6.0/small_cli/commands/__init__.py +0 -8
  49. small_software-0.6.0/small_cli/commands/access.py +0 -167
  50. small_software-0.6.0/small_cli/dashboard.py +0 -648
  51. small_software-0.6.0/small_cli/templates/fastapi/magic_link.py +0 -675
  52. small_software-0.6.0/small_cli/templates/node-http/magic-link.js +0 -509
  53. small_software-0.6.0/small_cli/templates/python-http/magic_link.py +0 -675
  54. small_software-0.6.0/small_cli/templates/streamlit/app.py +0 -181
  55. {small_software-0.6.0 → small_software-0.8.0}/LICENSE +0 -0
  56. {small_software-0.6.0 → small_software-0.8.0}/setup.cfg +0 -0
  57. {small_software-0.6.0 → small_software-0.8.0}/small_cli/__main__.py +0 -0
  58. {small_software-0.6.0 → small_software-0.8.0}/small_cli/templates/fastapi/requirements.txt +0 -0
  59. {small_software-0.6.0 → small_software-0.8.0}/small_cli/templates/node-http/package.json +0 -0
  60. {small_software-0.6.0 → small_software-0.8.0}/small_cli/templates/node-http/server.js +0 -0
  61. {small_software-0.6.0 → small_software-0.8.0}/small_cli/templates/streamlit/requirements.txt +0 -0
  62. {small_software-0.6.0 → small_software-0.8.0}/small_software.egg-info/dependency_links.txt +0 -0
  63. {small_software-0.6.0 → small_software-0.8.0}/small_software.egg-info/entry_points.txt +0 -0
  64. {small_software-0.6.0 → small_software-0.8.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.8.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
@@ -30,20 +30,23 @@ Dynamic: license-file
30
30
 
31
31
  [Русская версия](https://github.com/RuslanKazyradzi13/small-software/blob/main/small-cli/README.ru.md)
32
32
 
33
- `small` deploys small team apps to Railway in one command and protects them with sign-in by work email or
34
- personal invite links.
33
+ `small` deploys small team apps to Railway in one command and protects them with sign-in by work account (Google,
34
+ Microsoft), work email or personal invite links.
35
35
 
36
36
  ```
37
37
  small init my-service # skeleton: invite-link protected server, small.json, .gitignore
38
38
  cd my-service
39
39
  small deploy # → https://my-service-3f9a.up.railway.app
40
40
  small open # open with the invite link
41
- small access add ivan@company.com # sign-in by work email, like sharing a Google Doc
42
- small mail resend # how sign-in emails are sent
41
+ small access add ivan@company.com # who may sign in, like sharing a Google Doc
42
+ small sso google # "Sign in with Google" (or small sso microsoft)
43
+ small mail resend # or sign-in links by email
44
+ small access log # who signed in, when and how
43
45
  small access add bob # or a personal link for bob (small access revoke bob to revoke it)
44
46
  small notify telegram # access requests to Telegram
45
47
  small env set DEBUG=1 # environment variable in Railway and small.json
46
48
  small db add postgres # database, DATABASE_URL in the app
49
+ small volume add # persistent disk: files and sign-in state survive deploys
47
50
  small domain add app.example.com # custom domain
48
51
  small sleep on # sleep when idle (saves money)
49
52
  small logs -f # app logs
@@ -70,8 +73,25 @@ From source, in a clone of the repository:
70
73
  pip install ./small-cli
71
74
  ```
72
75
 
73
- **Windows.** `pip install` creates `small.exe`, which Smart App Control / Application Control may block because it
74
- is unsigned. If that happens, either:
76
+ **Windows (recommended): one command in PowerShell.**
77
+
78
+ ```powershell
79
+ irm https://raw.githubusercontent.com/RuslanKazyradzi13/small-software/main/install.ps1 | iex
80
+ ```
81
+
82
+ It installs small into its own Python environment in `%LOCALAPPDATA%\Programs\small` and adds a `small`
83
+ command that Smart App Control does not block (there is no unsigned `small.exe`). Needs Python 3.9+, no admin
84
+ rights; run it again to upgrade. Uninstall:
85
+
86
+ ```powershell
87
+ & ([scriptblock]::Create((irm https://raw.githubusercontent.com/RuslanKazyradzi13/small-software/main/install.ps1))) -Uninstall
88
+ ```
89
+
90
+ Values with `&` or `|` (URLs, for example) need PowerShell's stop-parsing token:
91
+ `small --% env set "URL=https://x?a=1&b=2"`.
92
+
93
+ **Windows with pip.** `pip install` creates `small.exe`, which Smart App Control / Application Control may
94
+ block because it is unsigned. If that happens, either:
75
95
 
76
96
  - run small through Python, which works everywhere: `python -m small_cli deploy` (any command works this way);
77
97
  - or add the `small-cli` folder of a clone to `PATH`, and `small.cmd` will serve the `small` command. Run this in
@@ -154,12 +174,15 @@ The sample apps are in English: both the page text and the code comments.
154
174
  `small deploy` passes `ACCESS_TOKEN` from `small.json` to Railway. The app opens at
155
175
  `https://<domain>/?token=<ACCESS_TOKEN>`; without the token visitors get a 403 page with a **Request access**
156
176
  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.
177
+ - **The `streamlit` template.** Streamlit can't be wrapped in middleware, so `require_access()` in `app.py` uses
178
+ `MagicLinkGate` from `magic_link.py`: the same variables (`ACCESS_EMAILS`, `ACCESS_TOKEN`, `SESSION_SECRET`,
179
+ `ACCESS_REQUEST_URL`...), one-time links, limits and logs as the other templates, and the access screen follows the
180
+ visitor's browser language. Differences:
181
+ - Sign-in by email: the link leads to `https://<domain>/?t=...` and shows a **Sign in** button; only the click
182
+ signs in. The 30-day session is a signed cookie written by a tiny same-origin frame, so unlike in the other
183
+ templates it is **not HttpOnly**: don't render untrusted HTML in a protected app. **Sign out** is in the sidebar.
184
+ - Personal links keep the token in the address bar (`?token=`), which is how they survive a page reload.
185
+ - Streamlit is a single-page app, so the "no access" screen is served with HTTP 200, not 403.
163
186
  Railway runs `python app.py`, and `app.py` restarts itself through `streamlit run` on `$HOST:$PORT`.
164
187
  Run it locally the same way: `python app.py`.
165
188
  - **Name.** By default, the folder name plus a random suffix, for example `my-service-3f9a`. An explicit name in
@@ -204,6 +227,12 @@ Buttons on the card of a deployed app:
204
227
  The token only goes to the browser's address bar on navigation; it is not in the dashboard page data.
205
228
  - **Logs** opens a window with the last 200 lines of the app or build log (tabs **App** / **Build**).
206
229
  Requires `RAILWAY_API_TOKEN`; without it the button is disabled.
230
+ - **Share** opens who has access: work emails, whole domains and personal-link names (never the links
231
+ themselves). Type `ivan@company.com`, `@company.com` or a name and click **Add**, or click **Revoke** next to
232
+ anyone. It does exactly what `small access add` / `revoke` does: updates small.json and Railway and restarts
233
+ the app. A new personal link is shown only once, with a Copy button, and works like a password. Requires
234
+ `RAILWAY_API_TOKEN`. The page and its requests are protected: a per-run key, `Host`/`Origin` checks, no
235
+ framing, JSON only.
207
236
 
208
237
  - **Port** 3000 by default; if it is busy, any free port is used. The URL is printed to stdout.
209
238
  - **The server listens on `127.0.0.1` only** and answers 403 to requests with a foreign `Host` (DNS rebinding
@@ -247,6 +276,46 @@ $10 per GB of memory, $0.15 per GB of volume. The result reads "≈ $X/mo at cur
247
276
  - **This is an estimate, not a bill.** Egress ($0.05/GB), the plan's free allowance ($5 on Hobby, $20 on Pro) and
248
277
  the subscription are not included. The exact amount is in Railway → Usage.
249
278
 
279
+ ## `small mcp`: AI agents (Claude Code, Cursor...)
280
+
281
+ ```
282
+ small mcp
283
+ ```
284
+
285
+ Runs an [MCP](https://modelcontextprotocol.io) server on stdin/stdout, so an AI coding agent can create, deploy and
286
+ share apps itself: *"Deploy this folder and give ivan@company.com access."*
287
+
288
+ **Connect it:**
289
+ - **Claude Code:** `claude mcp add small -- small mcp` (Windows: `claude mcp add small -- cmd /c small mcp`).
290
+ - **Cursor** (`~/.cursor/mcp.json`), **Claude Desktop**, **Windsurf** and other clients:
291
+
292
+ ```json
293
+ {"mcpServers": {"small": {"command": "small", "args": ["mcp"]}}}
294
+ ```
295
+
296
+ On Windows use `"command": "cmd", "args": ["/c", "small", "mcp"]`, or `"command": "python", "args": ["-m",
297
+ "small_cli", "mcp"]` if small was installed with pip.
298
+ - `RAILWAY_API_TOKEN` must be in the environment the agent starts from. After setting it, restart the agent.
299
+
300
+ **Tools:**
301
+
302
+ | Tool | What it does |
303
+ |---|---|
304
+ | `init_app` | `small init`: a ready app from a template |
305
+ | `deploy_app` | `small deploy`: returns the URL; progress is streamed while it builds |
306
+ | `share_app` | `small access add`: an email, `@domain` or a personal-link name |
307
+ | `revoke_access` | `small access revoke` |
308
+ | `list_access` | `small access list` |
309
+ | `list_apps` | apps under a folder with URL and latest deploy status |
310
+ | `app_logs` | `small logs`: app or build log |
311
+ | `set_env` | `small env set` / `unset` |
312
+
313
+ - **No delete tool:** `small destroy` stays a terminal command.
314
+ - **Prefer emails.** A personal link (a name instead of an email) is returned to the agent, so it passes through the
315
+ chat like any other text; with email sign-in nothing secret does.
316
+ - The tools run the same code as the commands and answer in small's language (`SMALL_LANG`). Your MCP client
317
+ normally asks you before each tool call.
318
+
250
319
  ## `small deploy`
251
320
 
252
321
  ```
@@ -275,18 +344,22 @@ These commands work in the folder of a deployed app, that is, where `.small.json
275
344
  small link [folder] [--as NAME] # print the invite link
276
345
  small open [folder] [--as NAME] # the same + open it in the browser
277
346
  small access [list | add EMAIL|@DOMAIN|NAME | revoke EMAIL|NAME] # who can sign in
347
+ small access log [-n N] [--json] # who signed in, when and how
348
+ small access signout EMAIL|@DOMAIN|--all # end their sessions on all devices
349
+ small sso [google [--client-id ID] | microsoft [--client-id ID] [--tenant T] | off [PROVIDER]]
278
350
  small mail [resend | smtp [--from FROM] [--to TO] | log | off] # emails for sign-in by email
279
351
  small notify [telegram [--chat-id ID] | off] # access requests to Telegram
280
352
  small env [list [--values] | set K=V ... | unset K ...] [--no-deploy]
281
353
  small db [list | add postgres | remove postgres [--yes]]
354
+ small volume [add [--mount /data]] # persistent disk; no argument shows the status
282
355
  small domain [list | add DOMAIN [--port N] | remove DOMAIN]
283
356
  small sleep [on | off] # sleep mode; no argument shows the status
284
357
  small logs [folder] [--build] [-n N] [-f] # log of the latest deploy
285
358
  small destroy [folder] [--yes] # remove from Railway
286
359
  ```
287
360
 
288
- Commands that change the app's variables (`access add` / `revoke`, `mail`, `notify`, `env set` / `unset`,
289
- `db add` / `remove`) and `sleep on` / `off` restart the app once. Add `--no-deploy` to skip the restart; the change
361
+ Commands that change the app's variables (`access add` / `revoke` / `signout`, `sso`, `mail`, `notify`,
362
+ `env set` / `unset`, `db add` / `remove`), `volume add` and `sleep on` / `off` restart the app once. Add `--no-deploy` to skip the restart; the change
290
363
  then takes effect on the next deploy.
291
364
 
292
365
  **`small link` / `small open`** build `https://<domain>/?token=<token>`.
@@ -299,15 +372,22 @@ then takes effect on the next deploy.
299
372
 
300
373
  **`small access`** controls who can open the app. There are two ways, and you can combine them.
301
374
 
302
- *By work email*, like sharing a Google Doc. This is the recommended way:
303
- - **`add ivan@company.com`** adds the address to `ACCESS_EMAILS`. Ivan opens the app, enters his email and gets a
304
- message with a sign-in link: it works for 15 minutes and only once, and the browser remembers the sign-in for
305
- 30 days. Only someone who reads that mailbox can sign in, so passing the link on to someone else gives them
306
- nothing.
375
+ *By address*, like sharing a Google Doc. This is the recommended way:
376
+ - **`add ivan@company.com`** adds the address to `ACCESS_EMAILS`. Ivan opens the app and proves the address is his:
377
+ with **Sign in with Google / Microsoft** (`small sso`), or by entering it and getting a sign-in link by email
378
+ (`small mail`) that works for 15 minutes and only once. The browser remembers the sign-in for 30 days. Passing a
379
+ link on gives nobody anything: only the owner of the mailbox or account gets in.
307
380
  - **`add @company.com`** lets in everyone with an address in that domain.
308
- - **`revoke ivan@company.com`** closes sign-in right after the restart, even in a browser that is already open.
309
- - The first `add` creates `SESSION_SECRET`, the key that signs links and sessions. Email sending must be set up:
310
- `small mail`.
381
+ - **`revoke ivan@company.com`** closes sign-in right after the restart, even in a browser that is already open. The
382
+ time also goes to `SESSIONS_REVOKED`, so if you add the address back later, the old sessions stay invalid.
383
+ - **`signout ivan@company.com`** (or `@company.com`, or `--all`) ends the sessions on every device without taking
384
+ access away: for a lost laptop. People can do it themselves with **Sign out on all devices** if the app has a
385
+ volume (`small volume add`).
386
+ - **`log`** prints who signed in, when, how (Google, Microsoft, email link, invite link) and from which IP, plus
387
+ refused sign-ins and access requests, across deploys: `magic_link` writes them as JSON log lines, and Railway
388
+ keeps them as long as your plan keeps logs. `--json` gives one object per line.
389
+ - The first `add` creates `SESSION_SECRET`, the key that signs links and sessions. One way to sign in must be set up:
390
+ `small sso` or `small mail`.
311
391
  - `small` passes the address for links in emails to the app itself, in `PUBLIC_URL` (in Railway only): after the
312
392
  domain is renamed to `<name>.up.railway.app`, Railway keeps the old address in `RAILWAY_PUBLIC_DOMAIN`, and the
313
393
  links would lead to a 404. If you want links on your own domain, set `PUBLIC_URL` in `small.json`; `small` does
@@ -315,8 +395,7 @@ then takes effect on the next deploy.
315
395
  - Someone who is not on the list and enters their email gets the same "Check your email" response, and the owner
316
396
  receives an access request (in the log, and in Telegram if connected) with a ready-made
317
397
  `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.
398
+ - Works in all templates: `python-http`, `fastapi`, `node-http` and `streamlit` (see the `streamlit` notes above).
320
399
 
321
400
  *By personal invite link*:
322
401
  - **`add bob`** creates a token, adds it to `ACCESS_TOKEN` as `bob:token`, restarts the app and prints the link.
@@ -325,10 +404,26 @@ then takes effect on the next deploy.
325
404
  their access.
326
405
 
327
406
  - **`list`** shows who has access: addresses and names (tokens are hidden).
328
- - **Sign-in log.** Sign-ins show up in the log: `small logs | Select-String "signed in by"` (PowerShell; on
329
- macOS/Linux use `grep`) → `signed in by email (ivan@company.com)` or `signed in by link (bob)`. With
330
- `OWNER_LANG=ru` these lines are in Russian; search them with `Select-String`, not `findstr`, which reads the
331
- argument and the text in different encodings and does not find Cyrillic.
407
+ - **Sign-in log.** `small access log` (above). In the raw log (`small logs`) each sign-in is one JSON line, for
408
+ example `{"message": "[magic-link] signed in with Google: ivan@company.com, ip 203.0.113.7", "magic_link":
409
+ "sign_in", "method": "google", ...}`; Railway's log explorer filters them with `@magic_link:sign_in`.
410
+
411
+ **`small sso`** sets up sign-in with a work account. Who may sign in is still the access list.
412
+ - **`google`**: "Sign in with Google" for Google Workspace accounts and Gmail addresses. `small` prints what to do
413
+ in Google Cloud (about 5 minutes: an OAuth client of type Web application) and the exact redirect URI to register,
414
+ then asks for the client ID and secret (the secret from `GOOGLE_CLIENT_SECRET` or with hidden input).
415
+ - **`microsoft`**: "Sign in with Microsoft" for the members of one Microsoft Entra organization. `small` prints the
416
+ steps (an app registration, single tenant, a client secret) and the redirect URI; `--tenant` takes the Directory
417
+ (tenant) ID or your domain (`company.com`), which `small` resolves to the ID.
418
+ - **Checked before saving.** `small` shows the provider the client ID and secret; a wrong pair is refused and
419
+ nothing is saved.
420
+ - **Which accounts count.** Google: verified Gmail addresses, or accounts of a Google Workspace organization; a
421
+ personal Google account registered with a company address is refused. Microsoft: members of your tenant only, not
422
+ guests and not other organizations. Details: [SECURITY.md](https://github.com/RuslanKazyradzi13/small-software/blob/main/SECURITY.md#threat-model).
423
+ - **Redirect URI**: `https://<app>/_access/sso/callback`, or `https://<app>/` for `streamlit`. With your own domain,
424
+ set `PUBLIC_URL` in `small.json` first, and register that address.
425
+ - If email sign-in isn't set up, the sign-in page shows only the provider buttons; with both, it shows both.
426
+ - **`off [google|microsoft]`** turns one or both off; with no subcommand you get the status and the redirect URI.
332
427
 
333
428
  **`small mail`** sets how the app sends sign-in emails.
334
429
  - **`resend`** sends through [Resend](https://resend.com) over HTTPS. `small` takes the key from `RESEND_API_KEY`
@@ -379,6 +474,16 @@ then takes effect on the next deploy.
379
474
  status.
380
475
  - **`remove`** removes the domain.
381
476
 
477
+ **`small volume`** gives the app a persistent disk (a Railway volume).
478
+ - **`add`** creates one at `/data` (`--mount` for another path, outside `/app`) and restarts the app. The app can
479
+ keep files there; the path is in `RAILWAY_VOLUME_MOUNT_PATH`. With no subcommand you get the path and how much is
480
+ used.
481
+ - **Sign-in state.** `magic_link` keeps it in `.magic-link.db` (SQLite) on the volume: used sign-in links stay used
482
+ after a restart, rate limits are shared by worker processes, and **Sign out on all devices** appears. Without a
483
+ volume this state lives in memory and a restart forgets it.
484
+ - **Trade-off.** With a volume each deploy has a few seconds of downtime: two copies of the app can't share the disk.
485
+ A service has one volume.
486
+
382
487
  **`small sleep`** controls Railway's sleep mode (serverless).
383
488
  - **`on`**: with no incoming requests the app goes to sleep and uses no CPU or memory; the first request wakes it
384
489
  up in a few seconds. Handy for apps that are opened a couple of times a day.
@@ -501,8 +606,9 @@ The tests start a local mock of the Railway API (GraphQL + the upload endpoint)
501
606
  deploy, repeat deploy, failed build, crash, taken domain, several workspaces, wrong token, ignore rules.
502
607
 
503
608
  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`.
609
+ - `streamlit` tests: the access gate through `streamlit.testing.v1.AppTest` (personal tokens, sign-in by email:
610
+ form, one-time link, session cookie, revocation, sign out; sign-in with Google against a local provider, refusals,
611
+ sign out on all devices) and the server through `python app.py`.
506
612
  - 2 `fastapi` tests: `TestClient` and `uvicorn main:app` the way Railway runs it, plus a check that the token is
507
613
  hidden in the access log.
508
614
 
@@ -517,6 +623,11 @@ Dashboard buttons: the "Open via invite link" redirect and its protection agains
517
623
  token, with Railway and with Railway unavailable. Sleep mode and the cost estimate in the dashboard, Railway errors
518
624
  while computing the cost.
519
625
 
626
+ `small sso`, `volume`, `access log` and `access signout` are tested against mocks of Railway, Google and Microsoft:
627
+ the credential check, a wrong secret that saves nothing, resolving a domain to a tenant, the Streamlit and custom
628
+ domain redirect URIs, old `magic_link` code, the volume and its mount path, the access log table, `--json` and the MCP
629
+ tool, sign-outs by address, domain and `--all`, forgetting old sign-outs.
630
+
520
631
  `small sleep`, `db`, `domain` and `notify` are tested against mocks of Railway and the Telegram Bot API:
521
632
  - turning sleep on and off, turning it on again without requests, `--no-deploy`;
522
633
  - deploying the Postgres template with default values, the `DATABASE_URL` reference, a workflow error, removing
@@ -2,20 +2,23 @@
2
2
 
3
3
  [Русская версия](https://github.com/RuslanKazyradzi13/small-software/blob/main/small-cli/README.ru.md)
4
4
 
5
- `small` deploys small team apps to Railway in one command and protects them with sign-in by work email or
6
- personal invite links.
5
+ `small` deploys small team apps to Railway in one command and protects them with sign-in by work account (Google,
6
+ Microsoft), work email or personal invite links.
7
7
 
8
8
  ```
9
9
  small init my-service # skeleton: invite-link protected server, small.json, .gitignore
10
10
  cd my-service
11
11
  small deploy # → https://my-service-3f9a.up.railway.app
12
12
  small open # open with the invite link
13
- small access add ivan@company.com # sign-in by work email, like sharing a Google Doc
14
- small mail resend # how sign-in emails are sent
13
+ small access add ivan@company.com # who may sign in, like sharing a Google Doc
14
+ small sso google # "Sign in with Google" (or small sso microsoft)
15
+ small mail resend # or sign-in links by email
16
+ small access log # who signed in, when and how
15
17
  small access add bob # or a personal link for bob (small access revoke bob to revoke it)
16
18
  small notify telegram # access requests to Telegram
17
19
  small env set DEBUG=1 # environment variable in Railway and small.json
18
20
  small db add postgres # database, DATABASE_URL in the app
21
+ small volume add # persistent disk: files and sign-in state survive deploys
19
22
  small domain add app.example.com # custom domain
20
23
  small sleep on # sleep when idle (saves money)
21
24
  small logs -f # app logs
@@ -42,8 +45,25 @@ From source, in a clone of the repository:
42
45
  pip install ./small-cli
43
46
  ```
44
47
 
45
- **Windows.** `pip install` creates `small.exe`, which Smart App Control / Application Control may block because it
46
- is unsigned. If that happens, either:
48
+ **Windows (recommended): one command in PowerShell.**
49
+
50
+ ```powershell
51
+ irm https://raw.githubusercontent.com/RuslanKazyradzi13/small-software/main/install.ps1 | iex
52
+ ```
53
+
54
+ It installs small into its own Python environment in `%LOCALAPPDATA%\Programs\small` and adds a `small`
55
+ command that Smart App Control does not block (there is no unsigned `small.exe`). Needs Python 3.9+, no admin
56
+ rights; run it again to upgrade. Uninstall:
57
+
58
+ ```powershell
59
+ & ([scriptblock]::Create((irm https://raw.githubusercontent.com/RuslanKazyradzi13/small-software/main/install.ps1))) -Uninstall
60
+ ```
61
+
62
+ Values with `&` or `|` (URLs, for example) need PowerShell's stop-parsing token:
63
+ `small --% env set "URL=https://x?a=1&b=2"`.
64
+
65
+ **Windows with pip.** `pip install` creates `small.exe`, which Smart App Control / Application Control may
66
+ block because it is unsigned. If that happens, either:
47
67
 
48
68
  - run small through Python, which works everywhere: `python -m small_cli deploy` (any command works this way);
49
69
  - or add the `small-cli` folder of a clone to `PATH`, and `small.cmd` will serve the `small` command. Run this in
@@ -126,12 +146,15 @@ The sample apps are in English: both the page text and the code comments.
126
146
  `small deploy` passes `ACCESS_TOKEN` from `small.json` to Railway. The app opens at
127
147
  `https://<domain>/?token=<ACCESS_TOKEN>`; without the token visitors get a 403 page with a **Request access**
128
148
  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.
149
+ - **The `streamlit` template.** Streamlit can't be wrapped in middleware, so `require_access()` in `app.py` uses
150
+ `MagicLinkGate` from `magic_link.py`: the same variables (`ACCESS_EMAILS`, `ACCESS_TOKEN`, `SESSION_SECRET`,
151
+ `ACCESS_REQUEST_URL`...), one-time links, limits and logs as the other templates, and the access screen follows the
152
+ visitor's browser language. Differences:
153
+ - Sign-in by email: the link leads to `https://<domain>/?t=...` and shows a **Sign in** button; only the click
154
+ signs in. The 30-day session is a signed cookie written by a tiny same-origin frame, so unlike in the other
155
+ templates it is **not HttpOnly**: don't render untrusted HTML in a protected app. **Sign out** is in the sidebar.
156
+ - Personal links keep the token in the address bar (`?token=`), which is how they survive a page reload.
157
+ - Streamlit is a single-page app, so the "no access" screen is served with HTTP 200, not 403.
135
158
  Railway runs `python app.py`, and `app.py` restarts itself through `streamlit run` on `$HOST:$PORT`.
136
159
  Run it locally the same way: `python app.py`.
137
160
  - **Name.** By default, the folder name plus a random suffix, for example `my-service-3f9a`. An explicit name in
@@ -176,6 +199,12 @@ Buttons on the card of a deployed app:
176
199
  The token only goes to the browser's address bar on navigation; it is not in the dashboard page data.
177
200
  - **Logs** opens a window with the last 200 lines of the app or build log (tabs **App** / **Build**).
178
201
  Requires `RAILWAY_API_TOKEN`; without it the button is disabled.
202
+ - **Share** opens who has access: work emails, whole domains and personal-link names (never the links
203
+ themselves). Type `ivan@company.com`, `@company.com` or a name and click **Add**, or click **Revoke** next to
204
+ anyone. It does exactly what `small access add` / `revoke` does: updates small.json and Railway and restarts
205
+ the app. A new personal link is shown only once, with a Copy button, and works like a password. Requires
206
+ `RAILWAY_API_TOKEN`. The page and its requests are protected: a per-run key, `Host`/`Origin` checks, no
207
+ framing, JSON only.
179
208
 
180
209
  - **Port** 3000 by default; if it is busy, any free port is used. The URL is printed to stdout.
181
210
  - **The server listens on `127.0.0.1` only** and answers 403 to requests with a foreign `Host` (DNS rebinding
@@ -219,6 +248,46 @@ $10 per GB of memory, $0.15 per GB of volume. The result reads "≈ $X/mo at cur
219
248
  - **This is an estimate, not a bill.** Egress ($0.05/GB), the plan's free allowance ($5 on Hobby, $20 on Pro) and
220
249
  the subscription are not included. The exact amount is in Railway → Usage.
221
250
 
251
+ ## `small mcp`: AI agents (Claude Code, Cursor...)
252
+
253
+ ```
254
+ small mcp
255
+ ```
256
+
257
+ Runs an [MCP](https://modelcontextprotocol.io) server on stdin/stdout, so an AI coding agent can create, deploy and
258
+ share apps itself: *"Deploy this folder and give ivan@company.com access."*
259
+
260
+ **Connect it:**
261
+ - **Claude Code:** `claude mcp add small -- small mcp` (Windows: `claude mcp add small -- cmd /c small mcp`).
262
+ - **Cursor** (`~/.cursor/mcp.json`), **Claude Desktop**, **Windsurf** and other clients:
263
+
264
+ ```json
265
+ {"mcpServers": {"small": {"command": "small", "args": ["mcp"]}}}
266
+ ```
267
+
268
+ On Windows use `"command": "cmd", "args": ["/c", "small", "mcp"]`, or `"command": "python", "args": ["-m",
269
+ "small_cli", "mcp"]` if small was installed with pip.
270
+ - `RAILWAY_API_TOKEN` must be in the environment the agent starts from. After setting it, restart the agent.
271
+
272
+ **Tools:**
273
+
274
+ | Tool | What it does |
275
+ |---|---|
276
+ | `init_app` | `small init`: a ready app from a template |
277
+ | `deploy_app` | `small deploy`: returns the URL; progress is streamed while it builds |
278
+ | `share_app` | `small access add`: an email, `@domain` or a personal-link name |
279
+ | `revoke_access` | `small access revoke` |
280
+ | `list_access` | `small access list` |
281
+ | `list_apps` | apps under a folder with URL and latest deploy status |
282
+ | `app_logs` | `small logs`: app or build log |
283
+ | `set_env` | `small env set` / `unset` |
284
+
285
+ - **No delete tool:** `small destroy` stays a terminal command.
286
+ - **Prefer emails.** A personal link (a name instead of an email) is returned to the agent, so it passes through the
287
+ chat like any other text; with email sign-in nothing secret does.
288
+ - The tools run the same code as the commands and answer in small's language (`SMALL_LANG`). Your MCP client
289
+ normally asks you before each tool call.
290
+
222
291
  ## `small deploy`
223
292
 
224
293
  ```
@@ -247,18 +316,22 @@ These commands work in the folder of a deployed app, that is, where `.small.json
247
316
  small link [folder] [--as NAME] # print the invite link
248
317
  small open [folder] [--as NAME] # the same + open it in the browser
249
318
  small access [list | add EMAIL|@DOMAIN|NAME | revoke EMAIL|NAME] # who can sign in
319
+ small access log [-n N] [--json] # who signed in, when and how
320
+ small access signout EMAIL|@DOMAIN|--all # end their sessions on all devices
321
+ small sso [google [--client-id ID] | microsoft [--client-id ID] [--tenant T] | off [PROVIDER]]
250
322
  small mail [resend | smtp [--from FROM] [--to TO] | log | off] # emails for sign-in by email
251
323
  small notify [telegram [--chat-id ID] | off] # access requests to Telegram
252
324
  small env [list [--values] | set K=V ... | unset K ...] [--no-deploy]
253
325
  small db [list | add postgres | remove postgres [--yes]]
326
+ small volume [add [--mount /data]] # persistent disk; no argument shows the status
254
327
  small domain [list | add DOMAIN [--port N] | remove DOMAIN]
255
328
  small sleep [on | off] # sleep mode; no argument shows the status
256
329
  small logs [folder] [--build] [-n N] [-f] # log of the latest deploy
257
330
  small destroy [folder] [--yes] # remove from Railway
258
331
  ```
259
332
 
260
- Commands that change the app's variables (`access add` / `revoke`, `mail`, `notify`, `env set` / `unset`,
261
- `db add` / `remove`) and `sleep on` / `off` restart the app once. Add `--no-deploy` to skip the restart; the change
333
+ Commands that change the app's variables (`access add` / `revoke` / `signout`, `sso`, `mail`, `notify`,
334
+ `env set` / `unset`, `db add` / `remove`), `volume add` and `sleep on` / `off` restart the app once. Add `--no-deploy` to skip the restart; the change
262
335
  then takes effect on the next deploy.
263
336
 
264
337
  **`small link` / `small open`** build `https://<domain>/?token=<token>`.
@@ -271,15 +344,22 @@ then takes effect on the next deploy.
271
344
 
272
345
  **`small access`** controls who can open the app. There are two ways, and you can combine them.
273
346
 
274
- *By work email*, like sharing a Google Doc. This is the recommended way:
275
- - **`add ivan@company.com`** adds the address to `ACCESS_EMAILS`. Ivan opens the app, enters his email and gets a
276
- message with a sign-in link: it works for 15 minutes and only once, and the browser remembers the sign-in for
277
- 30 days. Only someone who reads that mailbox can sign in, so passing the link on to someone else gives them
278
- nothing.
347
+ *By address*, like sharing a Google Doc. This is the recommended way:
348
+ - **`add ivan@company.com`** adds the address to `ACCESS_EMAILS`. Ivan opens the app and proves the address is his:
349
+ with **Sign in with Google / Microsoft** (`small sso`), or by entering it and getting a sign-in link by email
350
+ (`small mail`) that works for 15 minutes and only once. The browser remembers the sign-in for 30 days. Passing a
351
+ link on gives nobody anything: only the owner of the mailbox or account gets in.
279
352
  - **`add @company.com`** lets in everyone with an address in that domain.
280
- - **`revoke ivan@company.com`** closes sign-in right after the restart, even in a browser that is already open.
281
- - The first `add` creates `SESSION_SECRET`, the key that signs links and sessions. Email sending must be set up:
282
- `small mail`.
353
+ - **`revoke ivan@company.com`** closes sign-in right after the restart, even in a browser that is already open. The
354
+ time also goes to `SESSIONS_REVOKED`, so if you add the address back later, the old sessions stay invalid.
355
+ - **`signout ivan@company.com`** (or `@company.com`, or `--all`) ends the sessions on every device without taking
356
+ access away: for a lost laptop. People can do it themselves with **Sign out on all devices** if the app has a
357
+ volume (`small volume add`).
358
+ - **`log`** prints who signed in, when, how (Google, Microsoft, email link, invite link) and from which IP, plus
359
+ refused sign-ins and access requests, across deploys: `magic_link` writes them as JSON log lines, and Railway
360
+ keeps them as long as your plan keeps logs. `--json` gives one object per line.
361
+ - The first `add` creates `SESSION_SECRET`, the key that signs links and sessions. One way to sign in must be set up:
362
+ `small sso` or `small mail`.
283
363
  - `small` passes the address for links in emails to the app itself, in `PUBLIC_URL` (in Railway only): after the
284
364
  domain is renamed to `<name>.up.railway.app`, Railway keeps the old address in `RAILWAY_PUBLIC_DOMAIN`, and the
285
365
  links would lead to a 404. If you want links on your own domain, set `PUBLIC_URL` in `small.json`; `small` does
@@ -287,8 +367,7 @@ then takes effect on the next deploy.
287
367
  - Someone who is not on the list and enters their email gets the same "Check your email" response, and the owner
288
368
  receives an access request (in the log, and in Telegram if connected) with a ready-made
289
369
  `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.
370
+ - Works in all templates: `python-http`, `fastapi`, `node-http` and `streamlit` (see the `streamlit` notes above).
292
371
 
293
372
  *By personal invite link*:
294
373
  - **`add bob`** creates a token, adds it to `ACCESS_TOKEN` as `bob:token`, restarts the app and prints the link.
@@ -297,10 +376,26 @@ then takes effect on the next deploy.
297
376
  their access.
298
377
 
299
378
  - **`list`** shows who has access: addresses and names (tokens are hidden).
300
- - **Sign-in log.** Sign-ins show up in the log: `small logs | Select-String "signed in by"` (PowerShell; on
301
- macOS/Linux use `grep`) → `signed in by email (ivan@company.com)` or `signed in by link (bob)`. With
302
- `OWNER_LANG=ru` these lines are in Russian; search them with `Select-String`, not `findstr`, which reads the
303
- argument and the text in different encodings and does not find Cyrillic.
379
+ - **Sign-in log.** `small access log` (above). In the raw log (`small logs`) each sign-in is one JSON line, for
380
+ example `{"message": "[magic-link] signed in with Google: ivan@company.com, ip 203.0.113.7", "magic_link":
381
+ "sign_in", "method": "google", ...}`; Railway's log explorer filters them with `@magic_link:sign_in`.
382
+
383
+ **`small sso`** sets up sign-in with a work account. Who may sign in is still the access list.
384
+ - **`google`**: "Sign in with Google" for Google Workspace accounts and Gmail addresses. `small` prints what to do
385
+ in Google Cloud (about 5 minutes: an OAuth client of type Web application) and the exact redirect URI to register,
386
+ then asks for the client ID and secret (the secret from `GOOGLE_CLIENT_SECRET` or with hidden input).
387
+ - **`microsoft`**: "Sign in with Microsoft" for the members of one Microsoft Entra organization. `small` prints the
388
+ steps (an app registration, single tenant, a client secret) and the redirect URI; `--tenant` takes the Directory
389
+ (tenant) ID or your domain (`company.com`), which `small` resolves to the ID.
390
+ - **Checked before saving.** `small` shows the provider the client ID and secret; a wrong pair is refused and
391
+ nothing is saved.
392
+ - **Which accounts count.** Google: verified Gmail addresses, or accounts of a Google Workspace organization; a
393
+ personal Google account registered with a company address is refused. Microsoft: members of your tenant only, not
394
+ guests and not other organizations. Details: [SECURITY.md](https://github.com/RuslanKazyradzi13/small-software/blob/main/SECURITY.md#threat-model).
395
+ - **Redirect URI**: `https://<app>/_access/sso/callback`, or `https://<app>/` for `streamlit`. With your own domain,
396
+ set `PUBLIC_URL` in `small.json` first, and register that address.
397
+ - If email sign-in isn't set up, the sign-in page shows only the provider buttons; with both, it shows both.
398
+ - **`off [google|microsoft]`** turns one or both off; with no subcommand you get the status and the redirect URI.
304
399
 
305
400
  **`small mail`** sets how the app sends sign-in emails.
306
401
  - **`resend`** sends through [Resend](https://resend.com) over HTTPS. `small` takes the key from `RESEND_API_KEY`
@@ -351,6 +446,16 @@ then takes effect on the next deploy.
351
446
  status.
352
447
  - **`remove`** removes the domain.
353
448
 
449
+ **`small volume`** gives the app a persistent disk (a Railway volume).
450
+ - **`add`** creates one at `/data` (`--mount` for another path, outside `/app`) and restarts the app. The app can
451
+ keep files there; the path is in `RAILWAY_VOLUME_MOUNT_PATH`. With no subcommand you get the path and how much is
452
+ used.
453
+ - **Sign-in state.** `magic_link` keeps it in `.magic-link.db` (SQLite) on the volume: used sign-in links stay used
454
+ after a restart, rate limits are shared by worker processes, and **Sign out on all devices** appears. Without a
455
+ volume this state lives in memory and a restart forgets it.
456
+ - **Trade-off.** With a volume each deploy has a few seconds of downtime: two copies of the app can't share the disk.
457
+ A service has one volume.
458
+
354
459
  **`small sleep`** controls Railway's sleep mode (serverless).
355
460
  - **`on`**: with no incoming requests the app goes to sleep and uses no CPU or memory; the first request wakes it
356
461
  up in a few seconds. Handy for apps that are opened a couple of times a day.
@@ -473,8 +578,9 @@ The tests start a local mock of the Railway API (GraphQL + the upload endpoint)
473
578
  deploy, repeat deploy, failed build, crash, taken domain, several workspaces, wrong token, ignore rules.
474
579
 
475
580
  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`.
581
+ - `streamlit` tests: the access gate through `streamlit.testing.v1.AppTest` (personal tokens, sign-in by email:
582
+ form, one-time link, session cookie, revocation, sign out; sign-in with Google against a local provider, refusals,
583
+ sign out on all devices) and the server through `python app.py`.
478
584
  - 2 `fastapi` tests: `TestClient` and `uvicorn main:app` the way Railway runs it, plus a check that the token is
479
585
  hidden in the access log.
480
586
 
@@ -489,6 +595,11 @@ Dashboard buttons: the "Open via invite link" redirect and its protection agains
489
595
  token, with Railway and with Railway unavailable. Sleep mode and the cost estimate in the dashboard, Railway errors
490
596
  while computing the cost.
491
597
 
598
+ `small sso`, `volume`, `access log` and `access signout` are tested against mocks of Railway, Google and Microsoft:
599
+ the credential check, a wrong secret that saves nothing, resolving a domain to a tenant, the Streamlit and custom
600
+ domain redirect URIs, old `magic_link` code, the volume and its mount path, the access log table, `--json` and the MCP
601
+ tool, sign-outs by address, domain and `--all`, forgetting old sign-outs.
602
+
492
603
  `small sleep`, `db`, `domain` and `notify` are tested against mocks of Railway and the Telegram Bot API:
493
604
  - turning sleep on and off, turning it on again without requests, `--no-deploy`;
494
605
  - deploying the Postgres template with default values, the `DATABASE_URL` reference, a workflow error, removing
@@ -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.8.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.8.0"