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.
- hoist_cli-0.1.0/LICENSE +21 -0
- hoist_cli-0.1.0/PKG-INFO +524 -0
- hoist_cli-0.1.0/README.md +500 -0
- hoist_cli-0.1.0/hoist/__init__.py +4 -0
- hoist_cli-0.1.0/hoist/__main__.py +4 -0
- hoist_cli-0.1.0/hoist/cli.py +1672 -0
- hoist_cli-0.1.0/hoist/config.py +131 -0
- hoist_cli-0.1.0/hoist/containers.py +781 -0
- hoist_cli-0.1.0/hoist/dash/__init__.py +0 -0
- hoist_cli-0.1.0/hoist/dash/collect.py +497 -0
- hoist_cli-0.1.0/hoist/dash/fleet.py +140 -0
- hoist_cli-0.1.0/hoist/dash/jobs.py +111 -0
- hoist_cli-0.1.0/hoist/dash/server.py +979 -0
- hoist_cli-0.1.0/hoist/dash/static/app.css +595 -0
- hoist_cli-0.1.0/hoist/dash/static/app.js +437 -0
- hoist_cli-0.1.0/hoist/dash/static/app.ui.js +501 -0
- hoist_cli-0.1.0/hoist/dash/static/app.views.js +1057 -0
- hoist_cli-0.1.0/hoist/dash/static/favicon.svg +1 -0
- hoist_cli-0.1.0/hoist/dash/static/fonts/IBMPlexMono-400.woff2 +0 -0
- hoist_cli-0.1.0/hoist/dash/static/fonts/IBMPlexMono-500.woff2 +0 -0
- hoist_cli-0.1.0/hoist/dash/static/fonts/IBMPlexSans.woff2 +0 -0
- hoist_cli-0.1.0/hoist/dash/static/fonts/OFL.txt +93 -0
- hoist_cli-0.1.0/hoist/dash/static/fonts/fonts.css +3 -0
- hoist_cli-0.1.0/hoist/dash/static/index.html +41 -0
- hoist_cli-0.1.0/hoist/dash/static/login.html +41 -0
- hoist_cli-0.1.0/hoist/dash/static/vendor/XTERM-LICENSE +21 -0
- hoist_cli-0.1.0/hoist/dash/static/vendor/addon-fit.js +2 -0
- hoist_cli-0.1.0/hoist/dash/static/vendor/xterm.css +218 -0
- hoist_cli-0.1.0/hoist/dash/static/vendor/xterm.js +2 -0
- hoist_cli-0.1.0/hoist/dash/team.py +241 -0
- hoist_cli-0.1.0/hoist/dash/terminal.py +164 -0
- hoist_cli-0.1.0/hoist/detect.py +159 -0
- hoist_cli-0.1.0/hoist/front.py +457 -0
- hoist_cli-0.1.0/hoist/notify.py +111 -0
- hoist_cli-0.1.0/hoist/qr.py +417 -0
- hoist_cli-0.1.0/hoist/service.py +210 -0
- hoist_cli-0.1.0/hoist/source.py +410 -0
- hoist_cli-0.1.0/hoist/tunnel.py +376 -0
- hoist_cli-0.1.0/hoist/ui.py +101 -0
- hoist_cli-0.1.0/hoist_cli.egg-info/PKG-INFO +524 -0
- hoist_cli-0.1.0/hoist_cli.egg-info/SOURCES.txt +56 -0
- hoist_cli-0.1.0/hoist_cli.egg-info/dependency_links.txt +1 -0
- hoist_cli-0.1.0/hoist_cli.egg-info/entry_points.txt +2 -0
- hoist_cli-0.1.0/hoist_cli.egg-info/requires.txt +3 -0
- hoist_cli-0.1.0/hoist_cli.egg-info/top_level.txt +1 -0
- hoist_cli-0.1.0/pyproject.toml +43 -0
- hoist_cli-0.1.0/setup.cfg +4 -0
- hoist_cli-0.1.0/tests/test_config.py +63 -0
- hoist_cli-0.1.0/tests/test_containers.py +193 -0
- hoist_cli-0.1.0/tests/test_dash.py +523 -0
- hoist_cli-0.1.0/tests/test_detect_service.py +188 -0
- hoist_cli-0.1.0/tests/test_front.py +340 -0
- hoist_cli-0.1.0/tests/test_ls.py +60 -0
- hoist_cli-0.1.0/tests/test_notify.py +80 -0
- hoist_cli-0.1.0/tests/test_qr.py +133 -0
- hoist_cli-0.1.0/tests/test_source.py +275 -0
- hoist_cli-0.1.0/tests/test_team_previews.py +156 -0
- hoist_cli-0.1.0/tests/test_tunnel.py +266 -0
hoist_cli-0.1.0/LICENSE
ADDED
|
@@ -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.
|
hoist_cli-0.1.0/PKG-INFO
ADDED
|
@@ -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&label=ci&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
|