small-software 0.6.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 (46) hide show
  1. small_software-0.6.0/LICENSE +21 -0
  2. small_software-0.6.0/PKG-INFO +559 -0
  3. small_software-0.6.0/README.md +531 -0
  4. small_software-0.6.0/pyproject.toml +47 -0
  5. small_software-0.6.0/setup.cfg +4 -0
  6. small_software-0.6.0/small_cli/__init__.py +1 -0
  7. small_software-0.6.0/small_cli/__main__.py +3 -0
  8. small_software-0.6.0/small_cli/access.py +114 -0
  9. small_software-0.6.0/small_cli/archive.py +127 -0
  10. small_software-0.6.0/small_cli/cli.py +76 -0
  11. small_software-0.6.0/small_cli/commands/__init__.py +8 -0
  12. small_software-0.6.0/small_cli/commands/access.py +167 -0
  13. small_software-0.6.0/small_cli/commands/dashboard.py +49 -0
  14. small_software-0.6.0/small_cli/commands/db.py +154 -0
  15. small_software-0.6.0/small_cli/commands/deploy.py +248 -0
  16. small_software-0.6.0/small_cli/commands/destroy.py +63 -0
  17. small_software-0.6.0/small_cli/commands/domain.py +123 -0
  18. small_software-0.6.0/small_cli/commands/env.py +151 -0
  19. small_software-0.6.0/small_cli/commands/init.py +86 -0
  20. small_software-0.6.0/small_cli/commands/link.py +53 -0
  21. small_software-0.6.0/small_cli/commands/logs.py +59 -0
  22. small_software-0.6.0/small_cli/commands/mail.py +203 -0
  23. small_software-0.6.0/small_cli/commands/notify.py +147 -0
  24. small_software-0.6.0/small_cli/commands/sleep.py +61 -0
  25. small_software-0.6.0/small_cli/common.py +92 -0
  26. small_software-0.6.0/small_cli/config.py +82 -0
  27. small_software-0.6.0/small_cli/dashboard.py +648 -0
  28. small_software-0.6.0/small_cli/i18n.py +55 -0
  29. small_software-0.6.0/small_cli/railway.py +369 -0
  30. small_software-0.6.0/small_cli/scaffold.py +81 -0
  31. small_software-0.6.0/small_cli/templates/fastapi/magic_link.py +675 -0
  32. small_software-0.6.0/small_cli/templates/fastapi/main.py +67 -0
  33. small_software-0.6.0/small_cli/templates/fastapi/requirements.txt +2 -0
  34. small_software-0.6.0/small_cli/templates/node-http/magic-link.js +509 -0
  35. small_software-0.6.0/small_cli/templates/node-http/package.json +11 -0
  36. small_software-0.6.0/small_cli/templates/node-http/server.js +33 -0
  37. small_software-0.6.0/small_cli/templates/python-http/app.py +47 -0
  38. small_software-0.6.0/small_cli/templates/python-http/magic_link.py +675 -0
  39. small_software-0.6.0/small_cli/templates/streamlit/app.py +181 -0
  40. small_software-0.6.0/small_cli/templates/streamlit/requirements.txt +1 -0
  41. small_software-0.6.0/small_software.egg-info/PKG-INFO +559 -0
  42. small_software-0.6.0/small_software.egg-info/SOURCES.txt +44 -0
  43. small_software-0.6.0/small_software.egg-info/dependency_links.txt +1 -0
  44. small_software-0.6.0/small_software.egg-info/entry_points.txt +2 -0
  45. small_software-0.6.0/small_software.egg-info/top_level.txt +1 -0
  46. small_software-0.6.0/tests/test_small.py +2134 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 RuslanKazyradzi13
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,559 @@
1
+ Metadata-Version: 2.4
2
+ Name: small-software
3
+ Version: 0.6.0
4
+ Summary: A cloud for small software: deploy team apps to Railway in one command, private by default, shared by work email
5
+ Author: RuslanKazyradzi13
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/RuslanKazyradzi13/small-software
8
+ Project-URL: Documentation, https://github.com/RuslanKazyradzi13/small-software/blob/main/small-cli/README.md
9
+ Project-URL: Changelog, https://github.com/RuslanKazyradzi13/small-software/blob/main/small-cli/CHANGELOG.md
10
+ Project-URL: Issues, https://github.com/RuslanKazyradzi13/small-software/issues
11
+ Project-URL: Source, https://github.com/RuslanKazyradzi13/small-software
12
+ Keywords: railway,deploy,internal-tools,magic-link,email-login,passwordless,cli
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Environment :: Console
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: Natural Language :: English
17
+ Classifier: Natural Language :: Russian
18
+ Classifier: Operating System :: OS Independent
19
+ Classifier: Programming Language :: Python :: 3
20
+ Classifier: Programming Language :: Python :: 3 :: Only
21
+ Classifier: Topic :: Internet :: WWW/HTTP
22
+ Classifier: Topic :: Software Development :: Build Tools
23
+ Classifier: Topic :: System :: Software Distribution
24
+ Requires-Python: >=3.9
25
+ Description-Content-Type: text/markdown
26
+ License-File: LICENSE
27
+ Dynamic: license-file
28
+
29
+ # small: deploy to Railway in one command
30
+
31
+ [Русская версия](https://github.com/RuslanKazyradzi13/small-software/blob/main/small-cli/README.ru.md)
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.
35
+
36
+ ```
37
+ small init my-service # skeleton: invite-link protected server, small.json, .gitignore
38
+ cd my-service
39
+ small deploy # → https://my-service-3f9a.up.railway.app
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
43
+ small access add bob # or a personal link for bob (small access revoke bob to revoke it)
44
+ small notify telegram # access requests to Telegram
45
+ small env set DEBUG=1 # environment variable in Railway and small.json
46
+ small db add postgres # database, DATABASE_URL in the app
47
+ small domain add app.example.com # custom domain
48
+ small sleep on # sleep when idle (saves money)
49
+ small logs -f # app logs
50
+ small dashboard # all apps in the browser: status, cost, logs
51
+ small destroy # remove from Railway together with databases
52
+ ```
53
+
54
+ You can also deploy an existing code folder (`app.py`, `main.py`, `package.json` and so on): `small init` is
55
+ optional.
56
+
57
+ Python 3.9+, no dependencies.
58
+
59
+ ## Install
60
+
61
+ ```bash
62
+ pip install small-software
63
+ ```
64
+
65
+ The command is `small`. With [pipx](https://pipx.pypa.io): `pipx install small-software`.
66
+
67
+ From source, in a clone of the repository:
68
+
69
+ ```bash
70
+ pip install ./small-cli
71
+ ```
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:
75
+
76
+ - run small through Python, which works everywhere: `python -m small_cli deploy` (any command works this way);
77
+ - or add the `small-cli` folder of a clone to `PATH`, and `small.cmd` will serve the `small` command. Run this in
78
+ the root of the cloned repository (`$PWD` expands to its path), then open a new terminal:
79
+
80
+ ```powershell
81
+ [Environment]::SetEnvironmentVariable("Path", $env:Path + ";$PWD\small-cli", "User")
82
+ ```
83
+
84
+ ## Language
85
+
86
+ small speaks English and Russian. It uses Russian when the system language is Russian (`LC_ALL`, `LC_MESSAGES`,
87
+ `LANG`, `LANGUAGE`, or the Windows display language) and English otherwise. To choose explicitly, set `SMALL_LANG`
88
+ to `en` or `ru`:
89
+
90
+ ```bash
91
+ export SMALL_LANG=en
92
+ ```
93
+
94
+ ```powershell
95
+ $env:SMALL_LANG = "en"
96
+ ```
97
+
98
+ Deployed apps pick their language separately:
99
+
100
+ - **Sign-in pages and emails** follow each visitor's browser language. `ACCESS_LANG=ru` or `ACCESS_LANG=en` forces
101
+ one language.
102
+ - **App log lines and Telegram notifications** are for the owner and follow `OWNER_LANG=en|ru`. `small deploy` sets
103
+ `OWNER_LANG` to small's own language unless `small.json` sets it. It does this only when `small.json` has at
104
+ least one variable in `env` (apps created by `small init` always do); an app with an empty `env` gets no
105
+ variables from `small deploy` at all.
106
+
107
+ ## Token
108
+
109
+ You need an **account token** or a **workspace token** (Railway → Account Settings → Tokens). A project token will
110
+ not work: it cannot create projects.
111
+
112
+ Save the token to the `RAILWAY_API_TOKEN` variable, replacing `YOUR_TOKEN` entirely. Only the token itself, in the
113
+ form `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`, should remain inside the quotes:
114
+
115
+ ```powershell
116
+ [Environment]::SetEnvironmentVariable("RAILWAY_API_TOKEN", "YOUR_TOKEN", "User") # permanently
117
+ $env:RAILWAY_API_TOKEN = "YOUR_TOKEN" # this terminal only
118
+ ```
119
+
120
+ On macOS / Linux:
121
+
122
+ ```bash
123
+ export RAILWAY_API_TOKEN="YOUR_TOKEN" # add this line to ~/.bashrc or ~/.zshrc to keep it
124
+ ```
125
+
126
+ A variable saved "permanently" is visible to new terminal windows. Terminals and apps that are already open do not
127
+ see it; restart them. `small` strips spaces, quotes and angle brackets around the token by itself.
128
+
129
+ Fallback variable name: `RAILWAY_TOKEN`.
130
+
131
+ ## `small init`: app skeleton
132
+
133
+ ```
134
+ small init [folder] [--template python-http|node-http|fastapi|streamlit] [--name NAME] [--force]
135
+ ```
136
+
137
+ Creates a ready-to-deploy micro-app in the folder (the current one by default; created if it does not exist).
138
+ `--template` (short: `-t`) picks the template, `python-http` by default:
139
+
140
+ | File | What it is |
141
+ |---|---|
142
+ | `app.py` + `magic_link.py` (`python-http`, default) | web server on the standard library (`wsgiref`); all your code goes in `app()` |
143
+ | `server.js` + `magic-link.js` + `package.json` (`node-http`) | web server on the `http` module, Node 18+; all your code goes in `handler()` |
144
+ | `main.py` + `magic_link.py` + `requirements.txt` (`fastapi`) | FastAPI + uvicorn API: a page, `/api/hello`, docs at `/docs`; protected by `MagicLinkASGI`, the token is hidden in uvicorn logs. Railway runs `uvicorn main:app` by itself |
145
+ | `app.py` + `requirements.txt` (`streamlit`) | interactive [Streamlit](https://streamlit.io) app: sidebar with settings, metrics, a chart, a table with CSV download; invite-link sign-in is built into `app.py` |
146
+ | `small.json` | unique project name and a generated `ACCESS_TOKEN` in `env` |
147
+ | `.gitignore` | `small.json`, `.small.json`, `.env` and language clutter (`__pycache__/`, `node_modules/`…) |
148
+ | `.railwayignore` | what belongs in git but not in Railway: `*.md`, `tests/`, IDE settings |
149
+
150
+ The sample apps are in English: both the page text and the code comments.
151
+
152
+ - **Invite-link protection.** Templates are protected from the start, based on
153
+ [magic-link](https://github.com/RuslanKazyradzi13/small-software/blob/main/magic-link/README.md).
154
+ `small deploy` passes `ACCESS_TOKEN` from `small.json` to Railway. The app opens at
155
+ `https://<domain>/?token=<ACCESS_TOKEN>`; without the token visitors get a 403 page with a **Request access**
156
+ 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.
163
+ Railway runs `python app.py`, and `app.py` restarts itself through `streamlit run` on `$HOST:$PORT`.
164
+ Run it locally the same way: `python app.py`.
165
+ - **Name.** By default, the folder name plus a random suffix, for example `my-service-3f9a`. An explicit name in
166
+ `small.json` binds deploys to the Railway project with the same name, so the name must not accidentally match
167
+ someone else's project. The suffix also makes it more likely that the `<name>.up.railway.app` subdomain is free.
168
+ `--name` sets a name without the suffix.
169
+ - **`small.json`** is added to `.gitignore` because it holds the token. Do not publish it.
170
+
171
+ **Existing files are not overwritten.** If the folder already has files the template creates:
172
+ - in a terminal, `small init` lists them and asks `Overwrite? [y/N]`;
173
+ - without a terminal (script, CI), the command fails. Either way, if you decline, nothing in the folder changes.
174
+
175
+ `--force` overwrites without asking, including `small.json`: you get a new name and a new `ACCESS_TOKEN`, and old
176
+ invite links stop working after the next deploy. `.gitignore` and `.railwayignore` are never overwritten: only
177
+ missing lines are appended, so running `init` again does not duplicate anything.
178
+
179
+ ## `small dashboard`: all apps in the browser
180
+
181
+ ```
182
+ small dashboard [folder] [--port N] [--no-browser]
183
+ ```
184
+
185
+ Starts a local server and opens a browser with all micro-apps in a folder: the current one by default, or any
186
+ other, for example `small dashboard ..`. An app is a folder with `small.json` and/or `.small.json`.
187
+
188
+ For each app the dashboard shows:
189
+ - name: from `small.json`, otherwise from `.small.json`, otherwise the folder name;
190
+ - status: **Linked to Railway** (`.small.json` has a service ID) or **Not deployed**;
191
+ - **live status of the latest deploy** from Railway (`SUCCESS`, `BUILDING`, `FAILED`, `CRASHED`…), if a token is
192
+ set;
193
+ - **auto-sleep**: sleep mode is on (`small sleep on`);
194
+ - **cost estimate** in $/month at the current load (see below), with the total for all apps in the summary line;
195
+ - the public URL and a link to the service in the Railway console, if the app has been deployed;
196
+ - the folder path;
197
+ - names of the variables from `small.json`, without values;
198
+ - errors in `small.json` / `.small.json`: the same checks as `small deploy` runs.
199
+
200
+ The **Refresh** button rescans the folders without a restart. Stop the server with `Ctrl+C`.
201
+
202
+ Buttons on the card of a deployed app:
203
+ - **Open via invite link** opens the app right away with the owner's token, skipping the "invite only" screen.
204
+ The token only goes to the browser's address bar on navigation; it is not in the dashboard page data.
205
+ - **Logs** opens a window with the last 200 lines of the app or build log (tabs **App** / **Build**).
206
+ Requires `RAILWAY_API_TOKEN`; without it the button is disabled.
207
+
208
+ - **Port** 3000 by default; if it is busy, any free port is used. The URL is printed to stdout.
209
+ - **The server listens on `127.0.0.1` only** and answers 403 to requests with a foreign `Host` (DNS rebinding
210
+ protection). Values of the variables from `small.json` are never served: they include `ACCESS_TOKEN`.
211
+ - **Search** goes up to 6 levels deep. `.git`, `node_modules`, `__pycache__`, `.venv` and `venv` are skipped, and
212
+ the scanner does not descend into an app it has found.
213
+ - Styles are loaded from the Tailwind CDN; without internet access the page works, just unstyled.
214
+
215
+ ### Live deploy statuses
216
+
217
+ If `RAILWAY_API_TOKEN` (or `RAILWAY_TOKEN`) is set at startup, on every refresh the dashboard asks Railway for the
218
+ latest deploy of each linked app with the `serviceInstance(serviceId, environmentId) { latestDeployment }` query.
219
+ The status is shown as a colored badge:
220
+
221
+ | Color | Statuses |
222
+ |---|---|
223
+ | green | `SUCCESS` |
224
+ | light blue | `SLEEPING` |
225
+ | pulsing yellow | `QUEUED`, `WAITING`, `INITIALIZING`, `BUILDING`, `DEPLOYING` |
226
+ | red | `FAILED`, `CRASHED` |
227
+ | gray | everything else |
228
+
229
+ The badge tooltip shows the deploy time.
230
+
231
+ - **Without a token** the dashboard works offline from local files and never calls Railway.
232
+ - **Errors** (no network, the 5 s timeout, a wrong token, a deleted service) do not crash the server: those cards
233
+ simply stay without a live status, and the summary line notes that Railway didn't respond.
234
+ - **Speed.** Requests run in parallel, so even a slow Railway delays a refresh by no more than the timeout.
235
+ - **Security.** The Railway token stays in the dashboard process and is never sent to the browser.
236
+
237
+ ### Cost estimate
238
+
239
+ The dashboard takes the project's load measurements for the past week from Railway (the `metrics` query: average
240
+ CPU cores, GB of memory and GB of volumes per hour, including the app's databases) and multiplies the average load
241
+ by the monthly prices from the [Railway docs](https://docs.railway.com/reference/pricing/plans): $20 per vCPU,
242
+ $10 per GB of memory, $0.15 per GB of volume. The result reads "≈ $X/mo at current load".
243
+ - **Sleep and new apps.** Hours when the app was asleep (`small sleep`) count as zero. For a new app the average
244
+ starts at its first measurement, so the estimate is not understated.
245
+ - **Why not `usage`.** The accumulated `usage` arrives with a delay. In a live check it differed from the actual
246
+ measurements by 7–50×, and differently for CPU and memory, so an honest total cannot be computed from it.
247
+ - **This is an estimate, not a bill.** Egress ($0.05/GB), the plan's free allowance ($5 on Hobby, $20 on Pro) and
248
+ the subscription are not included. The exact amount is in Railway → Usage.
249
+
250
+ ## `small deploy`
251
+
252
+ ```
253
+ small deploy [folder] [--name NAME] [--workspace ID] [--port N] [-m MESSAGE] [--new] [--no-wait] [--timeout SEC]
254
+ ```
255
+
256
+ | Flag | What it does |
257
+ |---|---|
258
+ | `--name` | project, service and subdomain name; overrides `name` from `small.json` (default: the folder name normalized to `a-z0-9-`) |
259
+ | `--workspace` | workspace ID if the account has several (or `RAILWAY_WORKSPACE_ID`) |
260
+ | `--port` | port for the public domain if the app does not listen on `$PORT` |
261
+ | `-m`, `--message` | deploy message shown in Railway |
262
+ | `--new` | create a new project even if the folder has been deployed before |
263
+ | `--no-wait` | print the URL right away without waiting for the build |
264
+ | `--timeout` | how many seconds to wait for the build (default 900) |
265
+
266
+ Progress goes to stderr and the final URL to stdout: `URL=$(small deploy)`.
267
+ Exit codes: `0` success, `1` error or failed build (the last log lines are printed), `130` interrupted.
268
+
269
+ ## After deploy
270
+
271
+ These commands work in the folder of a deployed app, that is, where `.small.json` is. `link`, `open`, `logs` and
272
+ `destroy` take the path as an argument; the others take it as `-C folder`.
273
+
274
+ ```
275
+ small link [folder] [--as NAME] # print the invite link
276
+ small open [folder] [--as NAME] # the same + open it in the browser
277
+ small access [list | add EMAIL|@DOMAIN|NAME | revoke EMAIL|NAME] # who can sign in
278
+ small mail [resend | smtp [--from FROM] [--to TO] | log | off] # emails for sign-in by email
279
+ small notify [telegram [--chat-id ID] | off] # access requests to Telegram
280
+ small env [list [--values] | set K=V ... | unset K ...] [--no-deploy]
281
+ small db [list | add postgres | remove postgres [--yes]]
282
+ small domain [list | add DOMAIN [--port N] | remove DOMAIN]
283
+ small sleep [on | off] # sleep mode; no argument shows the status
284
+ small logs [folder] [--build] [-n N] [-f] # log of the latest deploy
285
+ small destroy [folder] [--yes] # remove from Railway
286
+ ```
287
+
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
290
+ then takes effect on the next deploy.
291
+
292
+ **`small link` / `small open`** build `https://<domain>/?token=<token>`.
293
+ - **Where the token comes from.** From `small.json`, or, if it is not there (for example, the token was set in the
294
+ Railway dashboard), from the service variables in Railway; that requires `RAILWAY_API_TOKEN`. Without `--as` the
295
+ first token (the owner's) is used; `--as bob` gives bob's personal token.
296
+ - **If there is no token anywhere**, the address is printed without a token.
297
+ - **Output.** The link goes to stdout, so you can pass it on: `small link | clip`. It works like a password, so
298
+ send it only to people who need access.
299
+
300
+ **`small access`** controls who can open the app. There are two ways, and you can combine them.
301
+
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.
307
+ - **`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`.
311
+ - `small` passes the address for links in emails to the app itself, in `PUBLIC_URL` (in Railway only): after the
312
+ domain is renamed to `<name>.up.railway.app`, Railway keeps the old address in `RAILWAY_PUBLIC_DOMAIN`, and the
313
+ links would lead to a 404. If you want links on your own domain, set `PUBLIC_URL` in `small.json`; `small` does
314
+ not touch it.
315
+ - Someone who is not on the list and enters their email gets the same "Check your email" response, and the owner
316
+ receives an access request (in the log, and in Telegram if connected) with a ready-made
317
+ `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.
320
+
321
+ *By personal invite link*:
322
+ - **`add bob`** creates a token, adds it to `ACCESS_TOKEN` as `bob:token`, restarts the app and prints the link.
323
+ The link works like a password: anyone it is forwarded to can get in.
324
+ - **`revoke bob`**: after the restart neither bob's link nor the cookie he already has works; everyone else keeps
325
+ their access.
326
+
327
+ - **`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.
332
+
333
+ **`small mail`** sets how the app sends sign-in emails.
334
+ - **`resend`** sends through [Resend](https://resend.com) over HTTPS. `small` takes the key from `RESEND_API_KEY`
335
+ or asks for it with hidden input. Railway's Hobby plan blocks SMTP ports, so on Hobby this is the main option
336
+ ([Railway docs](https://docs.railway.com/networking/outbound-networking)). Without a domain verified in Resend,
337
+ emails go only to your Resend account's address; for colleagues, verify a domain and pass
338
+ `--from "Reports <noreply@company.com>"`.
339
+ - **`smtp`** uses your own mail server: `SMTP_URL` in the form `smtp://user:password@server:587` (or
340
+ `smtps://…:465`), taken from the environment or asked for with hidden input. On Railway it works from the Pro
341
+ plan.
342
+ - Before saving, `resend` and `smtp` send a test email (`--to address`) with the same code the app uses. If it
343
+ does not go through, nothing is saved and the reason is printed. `--from` and `--to` work with both.
344
+ - **`log`** writes the sign-in link to the app log instead of sending an email. For testing only: a link from the
345
+ log lets you into the app.
346
+ - **`off`** turns email off; with no subcommand you get the status (`resend`, `smtp`, `log` or `off`).
347
+
348
+ **`small notify`** sends access requests to the owner in Telegram.
349
+ - **`telegram`** connects a bot. Create one with [@BotFather](https://t.me/BotFather) (`/newbot`). `small` takes
350
+ the bot token from `TELEGRAM_BOT_TOKEN` or asks for it with hidden input, so it does not end up in your terminal
351
+ history. Then `small` asks you to send the bot any message, finds the chat ID itself, sends a test notification
352
+ and writes `TELEGRAM_BOT_TOKEN` and `TELEGRAM_CHAT_ID` to Railway and `small.json`. You can pass the chat ID right
353
+ away with `--chat-id`.
354
+ - **What a notification looks like.** When someone clicks **Request access** and leaves a contact, you get a
355
+ message with the app's address, the contact, the IP and a hint: `small access add NAME`. Works in all templates
356
+ (`python-http`, `fastapi`, `node-http`, `streamlit`) and in `magic-link` in general. The request is still written
357
+ to the log as well.
358
+ - **`off`** disconnects the bot; with no subcommand you get the status.
359
+
360
+ **`small db`** adds a database in one step.
361
+ - **`add postgres`** deploys Postgres from the Railway template (like `railway add --database postgres`) into the
362
+ app's project and passes `DATABASE_URL` to the app as the Railway reference `${{Postgres.DATABASE_URL}}`. The
363
+ password is stored only in Railway; `small.json` gets just the reference. The app reaches the database over
364
+ Railway's private network.
365
+ - **Startup.** The database takes about a minute to start: if the app connects to it at startup, the first start
366
+ may happen before the database is ready.
367
+ - **`list`** shows the app's databases and their status.
368
+ - **`remove postgres`** deletes the database together with its data (confirm by typing the name, or pass `--yes`)
369
+ and removes `DATABASE_URL`.
370
+ - **`small destroy`** deletes the databases together with the app.
371
+
372
+ **`small domain`** sets up a custom domain instead of `*.up.railway.app`.
373
+ - **`add app.example.com`** registers the domain in Railway and prints the DNS records for your registrar: a CNAME
374
+ pointing to the Railway address and, if Railway asks for it, a TXT record that verifies ownership.
375
+ - **Certificate.** Once the records are live (from minutes to a day), Railway issues the HTTPS certificate by
376
+ itself.
377
+ - **Invite links** work on the custom domain too: `https://app.example.com/?token=…`.
378
+ - **`list`** shows the domains and their state (waiting for DNS records / DNS configured) and the certificate
379
+ status.
380
+ - **`remove`** removes the domain.
381
+
382
+ **`small sleep`** controls Railway's sleep mode (serverless).
383
+ - **`on`**: with no incoming requests the app goes to sleep and uses no CPU or memory; the first request wakes it
384
+ up in a few seconds. Handy for apps that are opened a couple of times a day.
385
+ - **`off`**: the app always runs. With no argument you get the current mode.
386
+ - **Restart.** The setting takes effect after a restart, which `small sleep` does by itself (`--no-deploy`
387
+ postpones it until the next deploy).
388
+
389
+ **`small env`** manages the app's environment variables.
390
+ - **`list`** shows the variables and where they are set: `small.json + Railway`, Railway only, or
391
+ `Railway ≠ small.json`. Values are hidden; `--values` shows them. Internal `RAILWAY_*` variables are not listed.
392
+ - **`set` / `unset`** change variables in both Railway and `small.json` at once; otherwise the next `small deploy`
393
+ would bring back the old values. After a change Railway restarts the app once; `--no-deploy` turns that off. If
394
+ the app has not been deployed yet, only `small.json` changes.
395
+ - **Removed variables.** If you removed a variable from `small.json` by hand, delete it with `small env unset` so
396
+ that it disappears from Railway too.
397
+
398
+ **`small logs`** shows the log of the latest deploy.
399
+ - **Which log.** The app log by default, the build log with `--build`. `-n` sets the number of lines (default 100).
400
+ - **Follow mode.** `-f` appends new lines every 2 seconds; `Ctrl+C` exits.
401
+ - **Format.** Every line is printed with its local time. Requests from the **Request access** button show up as
402
+ `[access-request] ...`, and they are easy to filter: `small logs | findstr access-request`.
403
+ - **Limitation.** `-f` follows the deploy that was the latest when the command started; after a new deploy, run
404
+ the command again.
405
+
406
+ **`small destroy`** removes the app from Railway.
407
+ - **What gets deleted.** If the project contains only this app's services (the service itself and the databases
408
+ from `small db`), the whole project is deleted: services, domains, variables, deploys, database data. If
409
+ `small deploy` once linked the folder to a project that also has other services, only the app's services are
410
+ deleted and the rest stay.
411
+ - **Confirmation.** In a terminal you have to type the project name. Without a terminal (script, CI) the app is
412
+ deleted only with the `--yes` flag. If you decline, nothing is deleted.
413
+ - **Local files.** After the deletion `.small.json` is removed, while `small.json` stays, so the next
414
+ `small deploy` creates a new project with the same settings.
415
+
416
+ ## Settings: `small.json`
417
+
418
+ An optional file in the root of the app folder:
419
+
420
+ ```json
421
+ {
422
+ "name": "my-custom-app-name",
423
+ "env": {
424
+ "OPENAI_API_KEY": "sk-...",
425
+ "DEBUG": "true"
426
+ }
427
+ }
428
+ ```
429
+
430
+ | Field | What it does |
431
+ |---|---|
432
+ | `name` | Project and service name in Railway, and the desired subdomain `<name>.up.railway.app`. Normalized to `a-z0-9-`. The `--name` flag takes precedence. |
433
+ | `env` | The service's environment variables. Names: Latin letters, digits and `_`, not starting with a digit. Values: strings, numbers or `true`/`false` (sent to Railway as strings: `"true"`, `"2"`). |
434
+
435
+ Both fields are optional; any other field is an error (this catches typos like `"envs"`). An error in the file
436
+ stops the deploy before any request to Railway.
437
+
438
+ **Name.** While the folder is not linked to a service (there is no `.small.json`), an explicitly set name (from
439
+ `small.json` or `--name`) works as an identifier: if Railway already has a project with that name, for example
440
+ after a deploy from another computer, `small` connects to it instead of creating a duplicate. If there is no such
441
+ project, it is created. If there are several projects with that name, the deploy stops with an error. `--new`
442
+ always creates a new project. If the folder is already linked, a new name is not applied, and `small` warns you
443
+ about it. To link the folder to another project, delete `.small.json`.
444
+
445
+ **Variables** are written on every `small deploy`, before the code is uploaded, so the new build sees them right
446
+ away. small uses `variableCollectionUpsert`, the batch form of `variableUpsert`, with `replace: false`: variables
447
+ from `env` are added or updated, and the service's other variables (for example, an `ACCESS_TOKEN` set in the
448
+ dashboard) are left alone. A variable removed from `env` **stays** in Railway: delete it with `small env unset`
449
+ or in the dashboard (service → Variables). Only variable names are printed, never values.
450
+
451
+ Unless `env` sets them itself, `small deploy` also passes the app `PUBLIC_URL` (when sign-in by email is on, see
452
+ `small access`) and `OWNER_LANG` (small's language, see "Language" above). With an empty `env`, `small deploy`
453
+ writes no variables at all, `OWNER_LANG` included.
454
+
455
+ **Secrets.** The root `small.json` is not uploaded to Railway with the code. If it holds keys, add it to
456
+ `.gitignore` so that it does not end up in git.
457
+
458
+ `small.json` and `.small.json` are different files. You write the first one yourself (settings). `small` creates
459
+ the second one: it holds the project and service IDs and the domain.
460
+
461
+ ## Under the hood
462
+
463
+ 1. `small.json` is read and validated, if it exists.
464
+ 2. The folder's code is packed into a `.tar.gz`. The root `.gitignore` and `.railwayignore` are respected;
465
+ `.git`, `node_modules`, `__pycache__`, `.venv`, `venv`, as well as `small.json` and `.small.json` in the root,
466
+ are always excluded.
467
+ 3. GraphQL at `https://backboard.railway.com/graphql/v2`: `me.workspaces` (picking the workspace). If the name is
468
+ set explicitly, `projects` looks for a project with that name. Then `projectCreate` and `serviceCreate`, if
469
+ there is no matching project.
470
+ 4. `serviceDomainCreate` creates a `*.up.railway.app` domain. If `<name>.up.railway.app` is free
471
+ (`serviceDomainAvailable`), the domain is renamed to it (`serviceDomainUpdate`). If the name is taken, the
472
+ domain Railway assigned stays. If the service was found by name and already has a domain, the existing one is
473
+ used. The domain is created before the upload because the very first build needs its address (links in sign-in
474
+ emails).
475
+ 5. `variableCollectionUpsert` writes the variables from `small.json`, plus `PUBLIC_URL` and `OWNER_LANG` (see
476
+ above). With an empty `env` this step is skipped.
477
+ 6. The archive goes to `POST https://backboard.railway.com/project/{projectId}/environment/{environmentId}/up?serviceId=...`.
478
+ This is the same endpoint the official `railway up` uses. It starts the build and returns a `deploymentId`.
479
+ 7. `deployment(id) { status }` is polled every 3 s until `SUCCESS` (or `SLEEPING`) or a failure status:
480
+ `FAILED`, `CRASHED`, `REMOVED`, `REMOVING`, `SKIPPED`, `NEEDS_APPROVAL`. On `FAILED` the `buildLogs` are
481
+ printed, on any other failure the `deploymentLogs`.
482
+ 8. `https://<domain>` is printed.
483
+
484
+ The project and service IDs and the domain are saved to `.small.json` in the app folder. The next `small deploy`
485
+ ships a new version to the same service and does not create a new project. The file is written right after the
486
+ project is created or linked, so a failed build does not spawn extra projects when you retry.
487
+
488
+ The build is done by Railpack, which detects the language by itself. For Python, `app.py` or `main.py` is enough
489
+ (plus `requirements.txt` for dependencies), and the start command will be `python app.py`. The app must listen on
490
+ `0.0.0.0:$PORT`.
491
+
492
+ ## Tests
493
+
494
+ In the `small-cli` folder of a clone:
495
+
496
+ ```bash
497
+ python -m unittest discover -s tests
498
+ ```
499
+
500
+ The tests start a local mock of the Railway API (GraphQL + the upload endpoint) and run the full scenario: first
501
+ deploy, repeat deploy, failed build, crash, taken domain, several workspaces, wrong token, ignore rules.
502
+
503
+ 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`.
506
+ - 2 `fastapi` tests: `TestClient` and `uvicorn main:app` the way Railway runs it, plus a check that the token is
507
+ hidden in the access log.
508
+
509
+ `small env` and `small access` are tested against the Railway mock:
510
+ - variable sources and hidden values;
511
+ - `set` / `unset` together with `small.json`, a single restart, `--no-deploy`;
512
+ - before the first deploy;
513
+ - a Railway failure that leaves `small.json` unchanged;
514
+ - granting, revoking and `link --as`, revoking the last token, a token that exists only in Railway.
515
+
516
+ Dashboard buttons: the "Open via invite link" redirect and its protection against foreign paths, "Logs" without a
517
+ token, with Railway and with Railway unavailable. Sleep mode and the cost estimate in the dashboard, Railway errors
518
+ while computing the cost.
519
+
520
+ `small sleep`, `db`, `domain` and `notify` are tested against mocks of Railway and the Telegram Bot API:
521
+ - turning sleep on and off, turning it on again without requests, `--no-deploy`;
522
+ - deploying the Postgres template with default values, the `DATABASE_URL` reference, a workflow error, removing
523
+ the database, `destroy` together with the database;
524
+ - DNS records for a custom domain, address normalization, repeat and removal;
525
+ - finding the bot's chat, the test message, a wrong token, "nobody messaged the bot", a missing token.
526
+
527
+ `small init` is tested like this (the `streamlit` template too):
528
+ - the `python-http` and `node-http` templates and unique names and tokens (`fastapi` and `streamlit` have their
529
+ own tests, see above);
530
+ - protection of existing files: declining, `--force`, the terminal prompt;
531
+ - appending to `.gitignore` without duplicates;
532
+ - the `init` → `deploy` chain;
533
+ - `python-http` and `node-http` run as real servers (403 → link → cookie → 200);
534
+ - the copies of `magic_link.py` and `magic-link.js` in the templates match the sources in the repository's
535
+ `magic-link/` folder.
536
+
537
+ `small dashboard` is tested like this (live statuses against the Railway mock: success, partial failure, no
538
+ network, timeout, wrong token, garbage status, no token in the response):
539
+ - scanning: nested folders, service folders, folders inside an app, the depth limit, an app at the root;
540
+ - app descriptions and no variable values in the output;
541
+ - broken files and unsafe links;
542
+ - the page and the API, including a rescan;
543
+ - 403 for a foreign `Host`;
544
+ - the fallback port;
545
+ - the command itself: URL, browser, `--no-browser`.
546
+
547
+ `small.json` is tested separately:
548
+ - name and variables, the "variables before upload" order;
549
+ - excluding the file from the archive;
550
+ - linking to an existing project and an ambiguous name;
551
+ - `--new` and the precedence of `--name`;
552
+ - an already linked folder and an invalid file.
553
+
554
+ `SMALL_API_URL` overrides the API address.
555
+
556
+ ## Limitations
557
+
558
+ - Only the root `.gitignore`/`.railwayignore` are read; nested ones are ignored.
559
+ - One service is created, in the `production` environment. Databases: Postgres only (`small db`).