hoist-cli 0.1.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 (58) hide show
  1. hoist_cli-0.1.0/LICENSE +21 -0
  2. hoist_cli-0.1.0/PKG-INFO +524 -0
  3. hoist_cli-0.1.0/README.md +500 -0
  4. hoist_cli-0.1.0/hoist/__init__.py +4 -0
  5. hoist_cli-0.1.0/hoist/__main__.py +4 -0
  6. hoist_cli-0.1.0/hoist/cli.py +1672 -0
  7. hoist_cli-0.1.0/hoist/config.py +131 -0
  8. hoist_cli-0.1.0/hoist/containers.py +781 -0
  9. hoist_cli-0.1.0/hoist/dash/__init__.py +0 -0
  10. hoist_cli-0.1.0/hoist/dash/collect.py +497 -0
  11. hoist_cli-0.1.0/hoist/dash/fleet.py +140 -0
  12. hoist_cli-0.1.0/hoist/dash/jobs.py +111 -0
  13. hoist_cli-0.1.0/hoist/dash/server.py +979 -0
  14. hoist_cli-0.1.0/hoist/dash/static/app.css +595 -0
  15. hoist_cli-0.1.0/hoist/dash/static/app.js +437 -0
  16. hoist_cli-0.1.0/hoist/dash/static/app.ui.js +501 -0
  17. hoist_cli-0.1.0/hoist/dash/static/app.views.js +1057 -0
  18. hoist_cli-0.1.0/hoist/dash/static/favicon.svg +1 -0
  19. hoist_cli-0.1.0/hoist/dash/static/fonts/IBMPlexMono-400.woff2 +0 -0
  20. hoist_cli-0.1.0/hoist/dash/static/fonts/IBMPlexMono-500.woff2 +0 -0
  21. hoist_cli-0.1.0/hoist/dash/static/fonts/IBMPlexSans.woff2 +0 -0
  22. hoist_cli-0.1.0/hoist/dash/static/fonts/OFL.txt +93 -0
  23. hoist_cli-0.1.0/hoist/dash/static/fonts/fonts.css +3 -0
  24. hoist_cli-0.1.0/hoist/dash/static/index.html +41 -0
  25. hoist_cli-0.1.0/hoist/dash/static/login.html +41 -0
  26. hoist_cli-0.1.0/hoist/dash/static/vendor/XTERM-LICENSE +21 -0
  27. hoist_cli-0.1.0/hoist/dash/static/vendor/addon-fit.js +2 -0
  28. hoist_cli-0.1.0/hoist/dash/static/vendor/xterm.css +218 -0
  29. hoist_cli-0.1.0/hoist/dash/static/vendor/xterm.js +2 -0
  30. hoist_cli-0.1.0/hoist/dash/team.py +241 -0
  31. hoist_cli-0.1.0/hoist/dash/terminal.py +164 -0
  32. hoist_cli-0.1.0/hoist/detect.py +159 -0
  33. hoist_cli-0.1.0/hoist/front.py +457 -0
  34. hoist_cli-0.1.0/hoist/notify.py +111 -0
  35. hoist_cli-0.1.0/hoist/qr.py +417 -0
  36. hoist_cli-0.1.0/hoist/service.py +210 -0
  37. hoist_cli-0.1.0/hoist/source.py +410 -0
  38. hoist_cli-0.1.0/hoist/tunnel.py +376 -0
  39. hoist_cli-0.1.0/hoist/ui.py +101 -0
  40. hoist_cli-0.1.0/hoist_cli.egg-info/PKG-INFO +524 -0
  41. hoist_cli-0.1.0/hoist_cli.egg-info/SOURCES.txt +56 -0
  42. hoist_cli-0.1.0/hoist_cli.egg-info/dependency_links.txt +1 -0
  43. hoist_cli-0.1.0/hoist_cli.egg-info/entry_points.txt +2 -0
  44. hoist_cli-0.1.0/hoist_cli.egg-info/requires.txt +3 -0
  45. hoist_cli-0.1.0/hoist_cli.egg-info/top_level.txt +1 -0
  46. hoist_cli-0.1.0/pyproject.toml +43 -0
  47. hoist_cli-0.1.0/setup.cfg +4 -0
  48. hoist_cli-0.1.0/tests/test_config.py +63 -0
  49. hoist_cli-0.1.0/tests/test_containers.py +193 -0
  50. hoist_cli-0.1.0/tests/test_dash.py +523 -0
  51. hoist_cli-0.1.0/tests/test_detect_service.py +188 -0
  52. hoist_cli-0.1.0/tests/test_front.py +340 -0
  53. hoist_cli-0.1.0/tests/test_ls.py +60 -0
  54. hoist_cli-0.1.0/tests/test_notify.py +80 -0
  55. hoist_cli-0.1.0/tests/test_qr.py +133 -0
  56. hoist_cli-0.1.0/tests/test_source.py +275 -0
  57. hoist_cli-0.1.0/tests/test_team_previews.py +156 -0
  58. hoist_cli-0.1.0/tests/test_tunnel.py +266 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 nikhilcherry
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,524 @@
1
+ Metadata-Version: 2.4
2
+ Name: hoist-cli
3
+ Version: 0.1.0
4
+ Summary: Put a local app on a public HTTPS URL, with one command.
5
+ Author: nikhilcherry
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/nikhilcherry/hoist
8
+ Project-URL: Issues, https://github.com/nikhilcherry/hoist/issues
9
+ Keywords: cloudflare,tunnel,systemd,hackathon,deploy,qr
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Operating System :: POSIX :: Linux
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Topic :: Internet :: WWW/HTTP
17
+ Classifier: Topic :: System :: Systems Administration
18
+ Requires-Python: >=3.9
19
+ Description-Content-Type: text/markdown
20
+ License-File: LICENSE
21
+ Provides-Extra: dev
22
+ Requires-Dist: pytest>=7; extra == "dev"
23
+ Dynamic: license-file
24
+
25
+ <img src="docs/hero.svg" alt="hoist — put a local app on a public HTTPS URL, with one command" width="100%">
26
+
27
+ <p align="center">
28
+ <img src="https://img.shields.io/github/actions/workflow/status/nikhilcherry/hoist/ci.yml?branch=main&amp;label=ci&amp;style=flat-square" alt="CI status">
29
+ <img src="https://img.shields.io/badge/python-3.9%2B-blue?style=flat-square" alt="Python 3.9+">
30
+ <img src="https://img.shields.io/badge/dependencies-none-3fb950?style=flat-square" alt="No dependencies">
31
+ <img src="https://img.shields.io/badge/license-MIT-lightgrey?style=flat-square" alt="MIT license">
32
+ </p>
33
+
34
+ <p align="center">
35
+ <img src="docs/demo-up.svg" width="584" alt="hoist up publishing a directory: it picks a free port, writes a systemd unit, adds an ingress rule, routes DNS, reloads cloudflared, verifies the public URL, then prints it with a scannable QR code">
36
+ </p>
37
+
38
+ <p align="center">
39
+ <img src="docs/demo-dashboard.gif" width="820" alt="Push to deploy and rollback: the running app shows v3, the Deployments page lists three deploys, one click on Roll back confirms and returns to v2 in 0.4 seconds, and the app now shows v2">
40
+ </p>
41
+
42
+ That's it. Your app is running as a managed service, it survives crashes and
43
+ reboots, it has a real certificate, and the QR code on screen puts it on a
44
+ judge's phone in about two seconds.
45
+
46
+ hoist started as that one command and has grown into a small self-hosting
47
+ platform for one machine or a team: push-to-deploy from git, a web dashboard,
48
+ databases and 46 one-click apps, backups, branch previews, per-person
49
+ permissions, and zero-downtime deploys. It is still **one standard-library
50
+ Python package with no dependencies**, and it was measured against Dokploy on
51
+ the same laptop (see [Measured](#measured)).
52
+
53
+ > Every screenshot here is a real run, rendered from the terminal output by
54
+ > [`scripts/make_docs_images.sh`](scripts/make_docs_images.sh). The QR codes in
55
+ > them scan.
56
+
57
+ ## Why
58
+
59
+ Getting a local project onto a shareable URL is five fiddly steps that are
60
+ easy to get wrong under time pressure:
61
+
62
+ 1. pick a port nothing else is using
63
+ 2. write a systemd unit so it keeps running
64
+ 3. add an ingress rule to your Cloudflare Tunnel config
65
+ 4. create the DNS record
66
+ 5. restart the tunnel, then find the URL again to share it
67
+
68
+ `hoist up` is those five steps. `hoist down` is their exact inverse.
69
+
70
+ ## Install
71
+
72
+ ```bash
73
+ pipx install hoist-cli # or: pip install --user hoist-cli
74
+ # latest from git: pipx install git+https://github.com/nikhilcherry/hoist
75
+ ```
76
+
77
+ Or straight from source, no packaging step:
78
+
79
+ ```bash
80
+ git clone https://github.com/nikhilcherry/hoist
81
+ cd hoist && pip install -e .
82
+ ```
83
+
84
+ **Zero runtime dependencies** — standard library only, including the QR
85
+ encoder. It works on a conference laptop with no internet.
86
+
87
+ Check your setup any time:
88
+
89
+ ```bash
90
+ hoist doctor
91
+ ```
92
+
93
+ <img src="docs/demo-doctor.svg" width="100%" alt="hoist doctor checking systemd, lingering, cloudflared, the tunnel config and id, ingress hostnames, the default domain, and tracked apps">
94
+
95
+ `doctor` never changes anything. When something is off it prints the exact
96
+ command that fixes it.
97
+
98
+ ## One-time setup: no password prompts
99
+
100
+ cloudflared does not watch its config file, so new ingress rules only take
101
+ effect on restart -- which is the only reason root is involved. If your
102
+ config is root-owned, sudo will prompt, and `hoist up` then has to run in a
103
+ real terminal rather than any wrapper that lacks a TTY.
104
+
105
+ To remove the prompt for good, run these once (`hoist doctor` prints them
106
+ with your paths filled in):
107
+
108
+ ```bash
109
+ sudo chown $USER /etc/cloudflared/config.yml
110
+ echo "$USER ALL=(root) NOPASSWD: /usr/bin/systemctl restart cloudflared" | sudo tee /etc/sudoers.d/hoist
111
+ sudo chmod 440 /etc/sudoers.d/hoist
112
+ ```
113
+
114
+ The sudoers rule is scoped to exactly one command -- restarting cloudflared --
115
+ not blanket root.
116
+
117
+ ## A note on wildcard DNS
118
+
119
+ If your zone has a wildcard record (`*.example.com`), every hostname resolves
120
+ and returns *something*, so a half-finished publish looks like it worked --
121
+ you get someone else's 404 instead of an error. After reloading the tunnel,
122
+ hoist fetches the public URL and compares it against what your app serves
123
+ locally, and tells you when a different server answered.
124
+
125
+ ## Requirements
126
+
127
+ - Linux with `systemd --user`
128
+ - [`cloudflared`](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/)
129
+ with a tunnel already created, and a domain on Cloudflare
130
+ - Python 3.9+
131
+ - Docker, *only* for the Docker features: Dockerfile and Compose repos,
132
+ `hoist db`, `hoist template` and buildpacks (optional). Plain apps don't need it.
133
+
134
+ Only want a LAN URL? Skip cloudflared entirely and use `--local`.
135
+
136
+ ## Commands
137
+
138
+ | Command | What it does |
139
+ | --- | --- |
140
+ | `hoist up [dir]` | Run a project and publish it. Detects the start command. |
141
+ | `hoist up <git-url>` | Clone, build and publish a repo, then redeploy on every push. |
142
+ | `hoist sync <name>` | Redeploy a repo app now if its branch moved (the timer runs this). |
143
+ | `hoist share [dir]` | Serve a folder of files publicly. No project needed. |
144
+ | `hoist ls` | Every hoisted app, its port, health and URL. |
145
+ | `hoist down <name>` | Stop it and remove its ingress rule. |
146
+ | `hoist logs <name> [-f]` | Tail the app's journald logs. `--deploys` shows the push-to-deploy log. |
147
+ | `hoist restart <name>` | Restart the service. |
148
+ | `hoist url <name>` | Print the URL (pipe it into `xdg-open`, Slack, etc). |
149
+ | `hoist qr <name>` | Reprint the QR code. |
150
+ | `hoist adopt <name> --port N` | Publish something already running (Docker, `npm run dev`). |
151
+ | `hoist dash` | Open the web dashboard (below). `--service` keeps it running. |
152
+ | `hoist pause / resume <name>` | Stop or restart redeploying a repo app on push. |
153
+ | `hoist stop / start <name>` | Stop an app's process, keeping its URL and config. |
154
+ | `hoist sync <name> --to <sha>` | Roll back to an earlier commit (pauses auto-deploy). |
155
+ | `hoist limit <name> --memory 512M --cpu 50%` | Cap an app's resources. |
156
+ | `hoist db postgres --name votes` | A database in Docker: PostgreSQL, MySQL, MariaDB, Redis or MongoDB. |
157
+ | `hoist link <db> <app>` | Put the database's URL in the app's environment and restart it. |
158
+ | `hoist backup <db> [--daily] [--restore F] [--offsite gdrive:hoist]` | Back up, schedule, restore, copy off-site. |
159
+ | `hoist template <id>` | 46 one-click apps; `hoist template add <Compose file or URL> --name x` adds your own. |
160
+ | `hoist stage <name> staging` | Label an app production, staging, development or preview; permissions can follow the label. |
161
+ | `hoist previews <name> on` | Every branch gets its own preview app, removed when the branch is deleted. |
162
+ | `hoist exec <name> -- <command>` | Run one command in the app's folder, container or stack. |
163
+ | `hoist webhook <name>` | Deploy within a second of a push (GitHub or GitLab webhook). |
164
+ | `hoist backup <stack>` | Back up a Docker stack's volumes too, not just databases. |
165
+ | `hoist notify <webhook>` | Deploy, failed-build and downtime alerts to Discord, Slack, ntfy, Telegram or email (`smtps://…`). |
166
+ | `hoist doctor` | Check systemd, cloudflared, tunnel, domain, permissions. |
167
+
168
+ <img src="docs/demo-ls.svg" width="557" alt="hoist ls listing two apps with their port, state and URL — one published through the tunnel, one on the LAN">
169
+
170
+ ### Useful flags
171
+
172
+ ```bash
173
+ hoist up ./api --name api --port 8000 # pin the name and port
174
+ hoist up ./api --cmd 'uvicorn main:app --port $PORT'
175
+ hoist up ./api --env DATABASE_URL=postgres://localhost/dev
176
+ hoist up ./site --local # LAN URL + QR, no tunnel, no internet
177
+ hoist up ./site --domain example.com # remembered for next time
178
+ ```
179
+
180
+ ## Push to deploy
181
+
182
+ Point `hoist up` at a repo instead of a folder and your teammates deploy by
183
+ pushing. They need no access to your machine, no account and no CLI:
184
+
185
+ ```bash
186
+ hoist up github.com/team/project --name team
187
+ # ✓ cloned main @ a9f8649 mate: first page
188
+ # ✓ built
189
+ # ✓ watching main — every push redeploys (checks every 20s)
190
+ # https://team.example.com
191
+ ```
192
+
193
+ hoist clones into its own checkout, runs the install/build the project needs
194
+ (`npm ci` + `npm run build`, a `.venv` from `requirements.txt`, …), starts
195
+ it, and installs a systemd timer that fetches the branch every 20 seconds.
196
+ A new commit is checked out, built, and the service restarted.
197
+
198
+ - **A broken push never takes the demo down.** If the build fails, the app
199
+ keeps serving the last good commit, `hoist ls` shows the failure, and that
200
+ commit is not retried until someone pushes again.
201
+ - **Offline is fine.** A failed fetch is skipped; the next tick tries again.
202
+ - **Polling, not webhooks**, so nothing has to accept inbound connections and
203
+ it works on venue Wi-Fi that blocks them.
204
+ - Private repos use whatever git credentials this machine already has
205
+ (`gh auth login`, an SSH key).
206
+
207
+ ```bash
208
+ hoist up git@github.com:team/api.git --branch demo # track another branch
209
+ hoist up github.com/team/web --build 'npm ci && npm run build:prod'
210
+ hoist up github.com/team/web --no-sync # deploy once, don't watch
211
+ hoist logs team --deploys -f # watch deploys happen
212
+ hoist sync team --force # rebuild right now
213
+ ```
214
+
215
+ `hoist down team` stops the watcher and deletes the checkout as well.
216
+
217
+ ## The dashboard
218
+
219
+ ```bash
220
+ hoist dash --service # runs on 127.0.0.1:7457, survives reboots; prints a sign-in link once
221
+ hoist dash --public dash # optional: also at https://dash.<your-domain>, behind the same token
222
+ ```
223
+
224
+ <img src="docs/dashboard.png" width="100%" alt="The hoist dashboard overview: three live apps, public-check column, and CPU, memory and disk for the machine">
225
+
226
+ <table>
227
+ <tr>
228
+ <td width="50%"><img src="docs/dashboard-app.png" alt="An app page: the route strip from source to build to process to port to network, uptime and response cards, resources, and a QR code to open it on a phone"><br><sub><b>An app</b> — the route strip shows which hop is broken, plus a QR code for the phone.</sub></td>
229
+ <td width="50%"><img src="docs/dashboard-machine.png" alt="The Machine page: CPU, memory, swap, disk and tunnel status with live charts for CPU, memory and network"><br><sub><b>The machine</b> — CPU, memory, disk and tunnel health, with the <code>hoist doctor</code> checks below.</sub></td>
230
+ </tr>
231
+ </table>
232
+
233
+ Everything the CLI does, in one place, for the whole team:
234
+
235
+ - **Overview** that answers "can a judge open every app right now?" — each app's
236
+ public URL is checked every 30 s, and anything not answering says why
237
+ (*nothing is listening on port 8420*, *its public URL answers 502*).
238
+ - **The route strip** on every app: source → build → process → port → public URL,
239
+ each hop live, so you see which one broke.
240
+ - **Deployments** with the commit, author, trigger and build log of every push;
241
+ one-click **rollback** (instant when the release is still on disk) and redeploy;
242
+ with a webhook a push is live in about 0.3 s for a plain app and about 2 s for a
243
+ Dockerfile app (measured, with Dokploy on the same laptop, in [docs/benchmark.md](docs/benchmark.md)).
244
+ - **Live logs** streamed from the journal, with search, follow and download —
245
+ the app's output or the deploy log.
246
+ - **Environment variables** editor (paste a `.env`), **resource limits**, stop/start,
247
+ pause auto-deploy, take down.
248
+ - **New app** from a git URL or a folder, with the build streaming as it runs.
249
+ - **Routes** (every hostname on the tunnel, hand-written ones too, with a one-hour
250
+ uptime strip), **Machine** (CPU, memory, disk, the `hoist doctor` checks with
251
+ copyable fixes), **notifications** and a QR code for every app.
252
+ - **Databases** with a connection card, one-click linking, daily backups,
253
+ restore and download; **ready-made apps** from the new-app drawer;
254
+ **branch previews**; a **console** that runs one command in the app.
255
+ - **Team**: everyone gets their own sign-in link (`hoist dash --add-user shreyas`; `--role viewer` for read-only, or
256
+ `--role custom --can operate,backups --apps api --stages staging` to pick exactly what they can do and where), and an audit log records
257
+ who deployed, rolled back or changed what.
258
+ - **Terminal**: an interactive shell in the app, its database or its stack.
259
+ - **Machines**: link other machines running hoist and manage them all from one dashboard.
260
+ - Command palette on <kbd>Ctrl</kbd> <kbd>K</kbd>, <kbd>N</kbd> for a new app, light and dark.
261
+
262
+ How it stacks up against Dokploy, feature by feature and measured on the same
263
+ laptop: [docs/vs-dokploy.md](docs/vs-dokploy.md); the measured side-by-side is
264
+ [docs/benchmark.md](docs/benchmark.md).
265
+
266
+ Zero dependencies like the rest of hoist: a standard-library server and one page
267
+ of plain JavaScript, fonts bundled so it works with no internet. A token guards
268
+ it (HttpOnly cookie plus a header every write must carry), because behind a
269
+ tunnel every request arrives from 127.0.0.1.
270
+
271
+ ### How a deploy can never take the demo down
272
+
273
+ Dockerfile and buildpack apps also redeploy with **no downtime at all**: the new
274
+ container starts beside the old one behind a small supervisor, traffic moves when
275
+ it answers, and a version that never answers is discarded.
276
+
277
+ Every deploy is exported from git into its own release directory, built there,
278
+ and only then made live by swapping a `current` symlink and restarting. The
279
+ running process keeps serving its own release the whole time, so a push that
280
+ fails to build — or deletes files halfway — never touches it. The last four
281
+ releases are kept, which is why rolling back to one of them needs no rebuild.
282
+
283
+ ### Docker apps
284
+
285
+ A repo with a `compose.yml` runs as a Compose stack and one with only a
286
+ `Dockerfile` is wrapped in one; both are built before they go live, like
287
+ everything else. Their CPU and memory are read from the containers' own
288
+ cgroups, and their limits go to Docker. Databases (`hoist db`) and templates
289
+ (`hoist template`) are Docker apps too, so they get logs, restart, limits and
290
+ the dashboard for free.
291
+
292
+ ## Measured
293
+
294
+ Same laptop, same Dockerfile app, same push webhook, a 50 ms poller watching the
295
+ public URL; full method and raw logs in [docs/benchmark.md](docs/benchmark.md),
296
+ feature comparison in [docs/vs-dokploy.md](docs/vs-dokploy.md).
297
+
298
+ | | hoist | Dokploy v0.31.0 |
299
+ | --- | --: | --: |
300
+ | Push → live, median of 6 deploys | **1.54 s** | 2.53 s |
301
+ | Downtime during the swap | **0.00 s** | ≈ 0.10 s |
302
+ | Broken build | old version keeps serving | old version keeps serving |
303
+ | Dashboard API latency | 3.5 ms median | 24 ms |
304
+
305
+ Benchmarking found real bugs (a Nagle/delayed-ACK stall that made the dashboard's
306
+ API take 45 ms, a stale-container swap race, a supervisor that polled Docker too
307
+ often), each now pinned by a test. Re-run it with `scripts/bench_deploy.sh`.
308
+
309
+ ## What it detects
310
+
311
+ | Found in the directory | Start command |
312
+ | --- | --- |
313
+ | `Procfile` with a `web:` line | that line |
314
+ | `package.json` | `npm start`, else `npm run dev` / `serve` / `preview` |
315
+ | `manage.py` | `python3 manage.py runserver 127.0.0.1:$PORT` |
316
+ | `app.py` / `main.py` / `server.py` / `wsgi.py` | `python3 <file>` |
317
+ | `Cargo.toml` | `cargo run --release` |
318
+ | `go.mod` | `go run .` |
319
+ | `index.html` or any `*.html` | `python3 -m http.server $PORT` |
320
+
321
+ Anything else: tell hoist how to start it with `--cmd`.
322
+
323
+ ## Telling hoist how to start your app
324
+
325
+ `--cmd` is for when the table above has no row for your project, or the guess
326
+ it makes is wrong:
327
+
328
+ ```bash
329
+ hoist up ./api --cmd 'uvicorn main:app --port $PORT'
330
+ ```
331
+
332
+ ### Rule 1: listen on the port hoist gives you
333
+
334
+ hoist picks a free port, then publishes *that* port. If your app ignores it
335
+ and listens on its own hardcoded port instead, hoist waits, then tells you
336
+ `nothing listening on port 43581` — your app is running perfectly well, just
337
+ somewhere nobody is looking. This is the one thing that has to be right.
338
+
339
+ There are two ways to get the number, and they are equivalent — use whichever
340
+ suits your app:
341
+
342
+ - **Write `$PORT` in the command.** hoist substitutes the real number before
343
+ the app ever starts, so `--port $PORT` becomes `--port 43581`.
344
+ - **Read `PORT` from the environment.** hoist always sets it, so
345
+ `process.env.PORT` in Node or `os.environ["PORT"]` in Python just works,
346
+ and you can leave `$PORT` out of the command entirely.
347
+
348
+ ### Rule 2: wrap it in single quotes
349
+
350
+ ```bash
351
+ hoist up ./api --cmd 'uvicorn main:app --port $PORT' # right
352
+ hoist up ./api --cmd "uvicorn main:app --port $PORT" # wrong
353
+ ```
354
+
355
+ With double quotes **your shell** expands `$PORT` before hoist ever sees it.
356
+ It is almost never set in your shell, so hoist receives `--port` followed by
357
+ nothing and the app dies with a confusing error. Single quotes pass the text
358
+ through untouched, which is what you want.
359
+
360
+ ### Rule 3: don't background it
361
+
362
+ The command must run in the foreground and stay there — that is how systemd
363
+ knows your app is alive. Anything that forks and returns immediately makes
364
+ systemd think it exited:
365
+
366
+ ```bash
367
+ --cmd 'npm start &' # no: the & backgrounds it
368
+ --cmd 'gunicorn app:app --daemon' # no: --daemon forks
369
+ --cmd 'pm2 start server.js' # no: pm2 is its own supervisor
370
+ ```
371
+
372
+ Drop the `&`, the `--daemon`, the `pm2`. hoist is the supervisor.
373
+
374
+ ### Recipes
375
+
376
+ | Stack | `--cmd` |
377
+ | --- | --- |
378
+ | Node / Express | `'node server.js'` — read `process.env.PORT` |
379
+ | Next.js | `'npx next dev -p $PORT'` |
380
+ | Vite | `'npm run dev -- --port $PORT'` |
381
+ | FastAPI | `'uvicorn main:app --port $PORT'` |
382
+ | Flask | `'flask run --port $PORT'` |
383
+ | Django | `'python3 manage.py runserver 127.0.0.1:$PORT'` |
384
+ | Gunicorn | `'gunicorn app:app --bind 127.0.0.1:$PORT'` |
385
+ | Streamlit | `'streamlit run app.py --server.port $PORT'` |
386
+ | Go | `'go run . --port $PORT'` |
387
+ | Rust | `'cargo run --release -- --port $PORT'` |
388
+ | A folder of files | `'python3 -m http.server $PORT'` |
389
+
390
+ ### What else you get
391
+
392
+ - **It runs in the directory you hoisted**, so relative paths in the command
393
+ and in your code behave as they do when you run it by hand.
394
+ - **Shell syntax works.** If the command contains `&&`, a pipe, a redirect or
395
+ a glob, hoist runs it through `sh -lc` instead of executing it directly, so
396
+ `--cmd 'npm run build && npm start'` is fine.
397
+ - **Version managers work.** If the binary isn't on your `PATH` when you run
398
+ `hoist up`, the command is resolved by a login shell at start time instead,
399
+ so things installed through nvm, pyenv or rustup are still found.
400
+ - **Extra environment variables** go in with `--env`, repeated as needed:
401
+ `--env DATABASE_URL=postgres://localhost/dev --env DEBUG=1`.
402
+
403
+ ### When it doesn't come up
404
+
405
+ You don't have to go looking. `hoist up` waits for the port to open, and if it
406
+ doesn't, it says which of the three ways it went wrong and prints the last few
407
+ lines the app logged before dying:
408
+
409
+ | What you see | What it usually means |
410
+ | --- | --- |
411
+ | `keeps crashing on startup` | the app exits as fast as it starts — a mistyped binary, a missing dependency, an import error. The tail says which. |
412
+ | `failed to start` | systemd couldn't run the command at all |
413
+ | `nothing listening on port N` | the app is running, on a *different* port. Rule 1. |
414
+
415
+ For more than the tail, `hoist logs <name> -f` follows the app's journald
416
+ output live. Nine times out of ten it is one of the three rules above: wrong
417
+ port, double quotes, or a command that backgrounded itself.
418
+
419
+ Already have the app running and only want it public? Skip `--cmd` entirely
420
+ and adopt the port it is already on:
421
+
422
+ ```bash
423
+ hoist adopt api --port 8000
424
+ ```
425
+
426
+ ## Limitations
427
+
428
+ Honest list, so you can decide quickly:
429
+
430
+ - **Linux with `systemd --user` only.** No macOS or Windows host support.
431
+ - **Cloudflare Tunnel is the only public-URL backend** (plus `--local` for a LAN
432
+ URL). No Caddy, Traefik or ngrok mode yet.
433
+ - **One machine.** `Machines` in the dashboard links other hoist hosts, but there
434
+ is no scheduler, clustering or failover.
435
+ - **Zero-downtime rollover is for Dockerfile and buildpack apps only.** Plain
436
+ apps restart in well under a second; Compose stacks, templates and databases
437
+ stop and start on purpose, since two copies of a database could corrupt it.
438
+ - Benchmarked on one laptop against one competitor ([details](docs/benchmark.md));
439
+ treat the numbers as a method, not a universal claim.
440
+ - Docker features are tested on Docker Engine only (not Podman).
441
+
442
+ ## Safety around your tunnel config
443
+
444
+ The cloudflared config is usually a live file with hand-written rules in it,
445
+ so hoist is deliberately careful:
446
+
447
+ - Every rule it adds is tagged with a `# hoist:<name>` marker, and it will
448
+ only ever remove rules carrying its own marker.
449
+ - It **refuses** to touch a hostname you configured by hand.
450
+ - The config is edited as text, so your comments, ordering and indentation
451
+ survive. `hoist down` restores the file byte-for-byte.
452
+ - Every write makes a timestamped backup first.
453
+ - After writing it runs `cloudflared tunnel ingress validate`, and rolls back
454
+ to the backup if the config is rejected.
455
+ - The catch-all rule is always kept last, where cloudflared requires it.
456
+
457
+ `hoist down` intentionally leaves the DNS record in place: deleting DNS is
458
+ slow to undo, and reusing the same name later just works.
459
+
460
+ ## Hackathon notes
461
+
462
+ - **Offline?** `--local` gives you a LAN URL and a QR code with no internet
463
+ and no cloudflared. Everyone on the venue wifi can reach your demo.
464
+
465
+ <img src="docs/demo-local.svg" width="566" alt="hoist up --local serving a directory on the LAN IP with a QR code, no tunnel and no internet involved">
466
+
467
+ - **Demo day.** `hoist qr <name>` fills the terminal with a code judges can
468
+ scan while you talk. No "let me just find the link".
469
+ - **Webhooks.** Stripe, Twilio, GitHub and friends need a real HTTPS URL. A
470
+ hoisted app gives you a stable one that survives laptop sleep.
471
+ - **Your laptop lid.** Services are `Restart=on-failure` and enabled, so they
472
+ come back after a reboot. Run `sudo loginctl enable-linger $USER` once so
473
+ they also survive logout — `hoist doctor` reminds you.
474
+
475
+ ## How it works
476
+
477
+ <img src="docs/architecture.svg" width="100%" alt="Four stages: detect.py picks a free port and start command, service.py writes and starts a user systemd unit, tunnel.py adds an ingress rule and routes DNS, qr.py renders the URL as a scannable code">
478
+
479
+ State lives in `~/.config/hoist/apps.json`, written atomically and under a lock
480
+ so concurrent deploys can't corrupt it. Nothing is hidden anywhere else: hoist
481
+ only ever touches that file, its own systemd units and timers, its release and
482
+ checkout directories, and the ingress rules it marked.
483
+
484
+ Beyond the four stages above: `source.py` clones, builds and releases repos,
485
+ `containers.py` runs Docker apps, databases and templates, `front.py` is the
486
+ small supervisor that gives Docker apps their zero-downtime rollover, `notify.py`
487
+ sends alerts, and `dash/` is the dashboard (standard-library server plus one
488
+ page of plain JavaScript).
489
+
490
+ ## Development
491
+
492
+ ```bash
493
+ pip install -e ".[dev]"
494
+ pytest -q
495
+ ```
496
+
497
+ Deploy performance is checked by `scripts/bench_deploy.sh` (needs Docker);
498
+ results are committed under `docs/bench-results/`.
499
+
500
+ The QR encoder is written from scratch against the spec so the tool has no
501
+ dependencies. It is tested two ways: a golden matrix in the unit tests, and
502
+ `scripts/qr_conformance.py`, which renders codes to PNG and decodes them with
503
+ `zbarimg` in CI.
504
+
505
+ The screenshots in `docs/` are captured from real runs rather than drawn by
506
+ hand, so they cannot drift from what the tool actually prints:
507
+
508
+ ```bash
509
+ scripts/make_docs_images.sh # the --local screenshots
510
+ scripts/make_docs_images.sh --public NAME # also re-shoot the publish flow
511
+ ```
512
+
513
+ `--public` performs a real publish, so pass a hostname you already use for
514
+ testing — it rewrites your cloudflared config and restarts the tunnel. The
515
+ script re-decodes every QR it renders with `zbarimg` at the end, and redacts
516
+ the tunnel id out of the `doctor` shot.
517
+
518
+ ## Contributing and security
519
+
520
+ See [CONTRIBUTING.md](CONTRIBUTING.md), [SECURITY.md](SECURITY.md) and the [CHANGELOG](CHANGELOG.md).
521
+
522
+ ## License
523
+
524
+ MIT