hoist-cli 0.1.0__py3-none-any.whl

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.py ADDED
@@ -0,0 +1,1672 @@
1
+ """hoist -- put a local app on a public HTTPS URL, with one command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import fcntl
7
+ import os
8
+ import re
9
+ import sys
10
+ import time
11
+ import urllib.request
12
+ from pathlib import Path
13
+
14
+ import shlex
15
+ import shutil
16
+ import subprocess
17
+
18
+ from . import config, containers, detect, front, notify, qr, service, source, tunnel, ui
19
+
20
+ __version__ = "0.1.0"
21
+
22
+ HEALTH_TIMEOUT = 20.0
23
+ CRASH_LOOP_RESTARTS = 2
24
+
25
+
26
+ class UserError(Exception):
27
+ """Something the user can fix; printed without a traceback."""
28
+
29
+
30
+ # --- helpers ------------------------------------------------------------------
31
+
32
+
33
+ def _wait_for_healthy(name: str, port: int, timeout: float = HEALTH_TIMEOUT, check=None) -> str:
34
+ """Wait for the port to open. Returns "up", "failed" or "timeout".
35
+
36
+ The unit state is watched alongside the port so a crashed app is reported
37
+ in a second or two instead of after the full timeout.
38
+ """
39
+ deadline = time.monotonic() + timeout
40
+ while time.monotonic() < deadline:
41
+ if (check or detect.port_in_use)(port):
42
+ return "up"
43
+ if service.status(name) == "failed":
44
+ return "failed"
45
+ if service.n_restarts(name) >= CRASH_LOOP_RESTARTS:
46
+ return "crashing"
47
+ time.sleep(0.25)
48
+ return "timeout"
49
+
50
+
51
+ def _report_health(name: str, port: int, timeout: float = HEALTH_TIMEOUT, check=None) -> str:
52
+ health = _wait_for_healthy(name, port, timeout, check)
53
+ if health == "up":
54
+ ui.ok(f"listening on 127.0.0.1:{port}")
55
+ return health
56
+ if health == "crashing":
57
+ ui.fail(f"{name} keeps crashing on startup")
58
+ elif health == "failed":
59
+ ui.fail(f"{name} failed to start")
60
+ else:
61
+ ui.warn(f"nothing listening on port {port} after {int(timeout)}s")
62
+ tail = service.recent_logs(name)
63
+ for line in tail.splitlines()[-8:]:
64
+ ui.step(line)
65
+ ui.step(f"full logs: hoist logs {name}")
66
+ return health
67
+
68
+
69
+ def _parse_env(pairs: list[str] | None) -> dict[str, str]:
70
+ env: dict[str, str] = {}
71
+ for pair in pairs or []:
72
+ if "=" not in pair:
73
+ raise UserError(f"--env expects KEY=VALUE, got {pair!r}")
74
+ key, value = pair.split("=", 1)
75
+ env[key.strip()] = value
76
+ return env
77
+
78
+
79
+ def _resolve_domain(state: dict, explicit: str | None, cf_text: str | None) -> str:
80
+ if explicit:
81
+ return explicit.lstrip(".")
82
+ if state.get("domain"):
83
+ return str(state["domain"])
84
+ if cf_text:
85
+ guess = tunnel.default_domain(cf_text)
86
+ if guess:
87
+ return guess
88
+ raise UserError(
89
+ "could not work out which domain to use -- pass --domain example.com "
90
+ "(it is remembered for next time)"
91
+ )
92
+
93
+
94
+ def _show_qr(url: str, ascii_mode: bool = False) -> None:
95
+ try:
96
+ print(qr.render_ascii(url) if ascii_mode else qr.render(url))
97
+ except ValueError as exc:
98
+ ui.warn(f"no QR code: {exc}")
99
+
100
+
101
+ def _app_url(app: dict) -> str:
102
+ if app.get("kind") == "db":
103
+ return app["db"]["url"]
104
+ if app.get("tunnel"):
105
+ return f"https://{app['hostname']}"
106
+ return f"http://{app.get('lan_ip', '127.0.0.1')}:{app['port']}"
107
+
108
+
109
+ def _pick_port(args: argparse.Namespace, existing: dict | None) -> int:
110
+ """Reuse our own port across restarts; never steal someone else's."""
111
+ wanted = args.port or (existing or {}).get("port")
112
+ if wanted is None:
113
+ return detect.free_port()
114
+ if detect.port_is_free(wanted):
115
+ return int(wanted)
116
+ if existing and existing.get("port") == wanted:
117
+ return int(wanted) # it is our own service still holding it
118
+ raise UserError(f"port {wanted} is already in use")
119
+
120
+
121
+ def _apply_ingress(
122
+ cf_path: Path, cf_text: str, name: str, hostname: str, port: int
123
+ ) -> str:
124
+ """Write, validate and roll back the ingress edit. Returns the new text."""
125
+ try:
126
+ updated = tunnel.upsert(cf_text, name, hostname, port)
127
+ except tunnel.TunnelError as exc:
128
+ raise UserError(str(exc)) from exc
129
+ if updated == cf_text:
130
+ return updated
131
+ if tunnel.needs_sudo(cf_path):
132
+ ui.step(f"sudo needed to edit {cf_path}")
133
+ backup = tunnel.write_config(cf_path, updated)
134
+ good, message = tunnel.validate(cf_path)
135
+ if not good:
136
+ tunnel.restore(cf_path, backup)
137
+ raise UserError(f"ingress config rejected, rolled back: {message}")
138
+ ui.ok(f"ingress rule added {ui.dim(str(cf_path))}")
139
+ return updated
140
+
141
+
142
+ def _local_body(port: int, limit: int = 256) -> bytes:
143
+ """Grab a sample of what the app serves, to compare against the public URL."""
144
+ try:
145
+ with urllib.request.urlopen(f"http://127.0.0.1:{port}/", timeout=5) as response:
146
+ return response.read(limit)
147
+ except Exception:
148
+ return b""
149
+
150
+
151
+ def _verify_public(url: str, port: int, attempts: int = 5) -> None:
152
+ """Confirm the public URL really reaches this app, not something else.
153
+
154
+ On a zone with a wildcard DNS record any hostname resolves and returns a
155
+ page, so a half-finished publish looks like it worked. Comparing against
156
+ the local response is what actually catches that.
157
+ """
158
+ expected = _local_body(port)
159
+ for attempt in range(attempts):
160
+ good, detail = tunnel.reaches_tunnel(url, expected)
161
+ if good:
162
+ ui.ok(f"public URL verified ({detail})")
163
+ return
164
+ if attempt < attempts - 1:
165
+ time.sleep(2.0)
166
+ ui.warn(f"{url} did not serve this app: {detail}")
167
+ ui.step("DNS may still be propagating — re-check in a minute")
168
+ ui.step("if a wildcard record covers this zone, it can shadow the hostname")
169
+
170
+
171
+ def _publish(text: str, hostname: str) -> None:
172
+ tid = tunnel.tunnel_id(text)
173
+ if tid:
174
+ routed, message = tunnel.route_dns(tid, hostname)
175
+ if routed:
176
+ ui.ok(f"DNS → {hostname}")
177
+ else:
178
+ ui.warn(f"DNS not set automatically: {message}")
179
+ else:
180
+ ui.warn("no `tunnel:` id in config, skipped DNS")
181
+ reloaded, detail = tunnel.reload()
182
+ if reloaded:
183
+ ui.ok("cloudflared reloaded")
184
+ else:
185
+ ui.warn(f"could not reload cloudflared: {detail}")
186
+
187
+
188
+ # --- commands -----------------------------------------------------------------
189
+
190
+
191
+ def cmd_up(args: argparse.Namespace) -> int:
192
+ state = config.load()
193
+ repo: dict | None = None
194
+
195
+ if source.is_repo_url(args.target):
196
+ url = source.normalize_url(args.target)
197
+ name = detect.slug(args.name or source.repo_name(url))
198
+ cache = source.checkout_dir(name)
199
+ ui.step(f"cloning {url}")
200
+ try:
201
+ branch = source.clone(url, cache, getattr(args, "branch", None))
202
+ sha = source.head(cache)
203
+ target = source.prepare_release(name, cache, sha)
204
+ except source.SourceError as exc:
205
+ raise UserError(str(exc)) from exc
206
+ repo = {"url": url, "branch": branch}
207
+ ui.ok(f"cloned {branch} @ {source.subject(cache)}")
208
+ else:
209
+ target = Path(args.target).expanduser().resolve() if args.target else Path.cwd()
210
+ if not target.is_dir():
211
+ raise UserError(f"not a directory: {target}")
212
+ name = detect.slug(args.name or target.name)
213
+
214
+ wants_buildpack = getattr(args, "buildpack", None)
215
+ if args.cmd:
216
+ command, how = args.cmd, "custom command"
217
+ elif wants_buildpack:
218
+ command, how = detect.BUILDPACK, "buildpack"
219
+ else:
220
+ try:
221
+ command, how = detect.detect_command(target)
222
+ except detect.DetectionError as exc:
223
+ # Nothing hoist knows how to run directly: a buildpack can still
224
+ # containerise it, if the user already has one installed.
225
+ if not containers.buildpack_tool():
226
+ raise detect.DetectionError(
227
+ f"{exc}. For other languages, hoist can use a Dockerfile, or Nixpacks / pack if you "
228
+ "install one (hoist never installs build tools itself)") from exc
229
+ command, how = detect.BUILDPACK, "buildpack"
230
+
231
+ kind = None
232
+ compose_path = None
233
+ generated = False
234
+ front_cfg = None
235
+ buildpack = None
236
+ if command == detect.BUILDPACK:
237
+ buildpack = containers.buildpack_tool(None if wants_buildpack in (None, "auto") else wants_buildpack)
238
+ if buildpack is None:
239
+ raise UserError("no buildpack tool found. Install Nixpacks (nixpacks.com) or pack "
240
+ "(buildpacks.io) yourself — hoist never installs build tools — or add a Dockerfile")
241
+ if not containers.available():
242
+ raise UserError("buildpacks build Docker images, and hoist needs a running Docker for it")
243
+ compose_path = containers.ensure_buildpack_compose(target, name, _parse_env(args.env))
244
+ generated = True
245
+ how = f"{buildpack} buildpack (port {containers.BUILDPACK_INNER_PORT} inside)"
246
+ command = detect.COMPOSE
247
+ if command == detect.DOCKERFILE:
248
+ if not containers.available():
249
+ raise UserError("this project has a Dockerfile, and hoist needs a running Docker for it")
250
+ compose_path = containers.ensure_dockerfile_compose(target, _parse_env(args.env))
251
+ generated = True
252
+ how = f"Dockerfile (port {containers.dockerfile_port(target)} inside)"
253
+ command = detect.COMPOSE
254
+ if command == detect.COMPOSE:
255
+ if not containers.available():
256
+ raise UserError("this project is a Compose stack, and hoist needs a running Docker for it")
257
+ compose_path = compose_path or containers.compose_file(target)
258
+ kind = "compose"
259
+ if not generated:
260
+ how = f"Compose stack ({compose_path.name})"
261
+ command = containers.compose_up_command(name, generated) + ("" if repo is not None else " --build")
262
+ if generated:
263
+ # hoist owns the public port and forwards to the container: seamless redeploys.
264
+ inner = containers.BUILDPACK_INNER_PORT if buildpack else containers.dockerfile_port(target)
265
+ front_cfg = {"inner": inner, "bind": "0.0.0.0" if args.local else "127.0.0.1"}
266
+ command = front.front_command(name)
267
+ elif not args.port and not containers.compose_uses_port_var(compose_path):
268
+ published = containers.compose_published_port(compose_path)
269
+ if published is None:
270
+ raise UserError("the compose file publishes no port — add `ports: [\"${PORT}:<container port>\"]`"
271
+ " to the web service, or pass --port")
272
+ args.port = published
273
+
274
+ if repo is not None:
275
+ build = getattr(args, "build", None)
276
+ if buildpack:
277
+ steps = [build or containers.buildpack_build_step(buildpack, name)]
278
+ elif kind == "compose" and not build:
279
+ steps = [containers.compose_build_step(name, generated)]
280
+ else:
281
+ steps = [build] if build else source.detect_build(target, command)
282
+ ok, detail = source.run_build(target, steps, log=ui.step)
283
+ if not ok:
284
+ shutil.rmtree(target, ignore_errors=True)
285
+ raise UserError(f"build failed: {detail}")
286
+ if steps:
287
+ ui.ok("built")
288
+ source.mark_built(target)
289
+ source.activate(name, target)
290
+ source.prune_releases(name)
291
+ target = source.current_link(name)
292
+ if not args.cmd:
293
+ command = source.use_venv(target, command)
294
+ repo.update(sha=sha, build=steps, deployed=config.now(),
295
+ commit=source.subject(cache, sha))
296
+ previous = config.get_app(state, name)
297
+ if previous and (previous.get("repo") or {}).get("history"):
298
+ repo["history"] = previous["repo"]["history"]
299
+ source.record_deploy(repo, {
300
+ "sha": repo["sha"], "commit": repo["commit"], "at": config.now(),
301
+ "trigger": "initial", "status": "ok", "seconds": 0, "log": [],
302
+ })
303
+
304
+ existing = config.get_app(state, name)
305
+ port = _pick_port(args, existing)
306
+
307
+ use_tunnel = not args.local
308
+ cf_path: Path | None = None
309
+ cf_text: str | None = None
310
+ domain: str | None = None
311
+ hostname: str | None = None
312
+
313
+ if use_tunnel:
314
+ if not tunnel.have_cloudflared():
315
+ raise UserError(
316
+ "cloudflared is not installed -- install it, or use --local "
317
+ "for a LAN-only URL"
318
+ )
319
+ cf_path = tunnel.find_config()
320
+ cf_text = cf_path.read_text()
321
+ domain = _resolve_domain(state, args.domain, cf_text)
322
+ hostname = args.hostname or f"{name}.{domain}"
323
+
324
+ env = _parse_env(args.env)
325
+ if args.local:
326
+ env.setdefault("HOST", "0.0.0.0")
327
+ command = command.replace("--bind 127.0.0.1", "--bind 0.0.0.0")
328
+
329
+ app = {
330
+ "name": name,
331
+ "port": port,
332
+ "workdir": str(target),
333
+ "cmd": command,
334
+ "env": env,
335
+ "tunnel": use_tunnel,
336
+ "hostname": hostname,
337
+ "created": (existing or {}).get("created") or config.now(),
338
+ "updated": config.now(),
339
+ }
340
+ if generated and repo is None:
341
+ # A local folder has no deploy step to build in, so build the image now.
342
+ step = getattr(args, "build", None) or (containers.buildpack_build_step(buildpack, name) if buildpack
343
+ else containers.compose_build_step(name, True))
344
+ ok, detail = source.run_build(target, [step], log=ui.step)
345
+ if not ok:
346
+ raise UserError(f"build failed: {detail}")
347
+ ui.ok("built")
348
+ if repo is not None:
349
+ app["repo"] = repo
350
+ if kind == "compose":
351
+ app["kind"] = "compose"
352
+ if front_cfg:
353
+ app["front"] = front_cfg
354
+ if generated and buildpack:
355
+ app["buildpack"] = buildpack
356
+ elif generated:
357
+ app["dockerfile"] = True
358
+ if getattr(args, "preview_of", None):
359
+ app["preview_of"] = args.preview_of
360
+ if getattr(args, "stage", None):
361
+ from .dash import team as _team
362
+ if args.stage not in _team.STAGES:
363
+ raise UserError(f"stage must be one of {', '.join(_team.STAGES)}")
364
+ app["stage"] = args.stage
365
+ elif existing and existing.get("stage"):
366
+ app["stage"] = existing["stage"]
367
+
368
+ ui.info(f"{ui.bold(name)} {ui.dim('·')} {how} {ui.dim('·')} port {port}")
369
+ ui.step(f"$ {command.replace('$PORT', str(port))}")
370
+
371
+ if not service.have_systemd():
372
+ raise UserError("systemd --user is unavailable; hoist needs it to run apps")
373
+
374
+ service.write_unit(app)
375
+ if app.get("front"):
376
+ # `hoist front` reads its own configuration when it starts, so the app must be
377
+ # on record before its unit is.
378
+ with config.transaction() as _state:
379
+ config.put_app(_state, app)
380
+ try:
381
+ if existing:
382
+ service.restart(name)
383
+ else:
384
+ service.start(name)
385
+ except service.ServiceError as exc:
386
+ raise UserError(f"failed to start service: {exc}") from exc
387
+
388
+ health = _report_health(name, port, timeout=240 if kind == "compose" else HEALTH_TIMEOUT,
389
+ check=detect.http_answers if kind == "compose" else None)
390
+
391
+ # The app is running from here on, so record it before anything that can
392
+ # fail -- otherwise a failed tunnel step leaves a live service that
393
+ # `hoist ls` cannot see and `hoist down` cannot stop.
394
+ config.put_app(state, app)
395
+ config.save(state)
396
+
397
+ if use_tunnel:
398
+ assert cf_path is not None and cf_text is not None and hostname is not None
399
+ updated = _apply_ingress(cf_path, cf_text, name, hostname, port)
400
+ _publish(updated, hostname)
401
+ state["domain"] = domain
402
+ config.save(state)
403
+ if health == "up":
404
+ _verify_public(f"https://{hostname}", port)
405
+ else:
406
+ app["lan_ip"] = detect.lan_ip()
407
+ config.put_app(state, app)
408
+ config.save(state)
409
+
410
+ if repo is not None and not getattr(args, "no_sync", False):
411
+ try:
412
+ source.install_sync_timer(name, service.UNIT_DIR, service.systemctl)
413
+ ui.ok(f"watching {repo['branch']} — every push redeploys (checks every {source.SYNC_INTERVAL}s)")
414
+ except source.SourceError as exc:
415
+ ui.warn(f"auto-deploy not enabled: {exc}")
416
+
417
+ url = _app_url(app)
418
+ ui.banner(url, f"hoist logs {name} · hoist down {name}")
419
+ if not args.no_qr and health == "up":
420
+ _show_qr(url, args.ascii)
421
+ return 0 if health == "up" else 1
422
+
423
+
424
+ def cmd_share(args: argparse.Namespace) -> int:
425
+ """Serve a folder of files publicly, no project scaffolding required."""
426
+ directory = Path(args.path).expanduser().resolve()
427
+ if not directory.is_dir():
428
+ raise UserError(f"not a directory: {directory}")
429
+ python = sys.executable or "python3"
430
+ bind = "0.0.0.0" if args.local else "127.0.0.1"
431
+ args.target = str(directory)
432
+ args.cmd = f"{python} -m http.server $PORT --bind {bind} --directory {directory}"
433
+ args.name = args.name or directory.name
434
+ return cmd_up(args)
435
+
436
+
437
+ def cmd_ls(args: argparse.Namespace) -> int:
438
+ state = config.load()
439
+ apps = state["apps"]
440
+ if not apps:
441
+ ui.info("nothing hoisted yet — try: hoist up .")
442
+ return 0
443
+
444
+ rows = []
445
+ for name in sorted(apps):
446
+ app = apps[name]
447
+ unit_state = "adopted" if app.get("adopted") else service.status(name)
448
+ if app.get("stopped"):
449
+ unit_state = "stopped"
450
+ listening = detect.port_in_use(app["port"])
451
+ if listening and unit_state in ("active", "adopted"):
452
+ health = ui.green("up")
453
+ elif unit_state == "adopted":
454
+ # There is no unit to ask about an adopted process, so the port is
455
+ # the only evidence there is -- and a dead one still has a DNS
456
+ # record and an ingress rule pointing at it, so the public URL
457
+ # answers 502. Printing "adopted" here read like an ordinary state
458
+ # and hid exactly that.
459
+ health = ui.red("down")
460
+ elif unit_state == "active":
461
+ health = ui.yellow("starting")
462
+ elif unit_state == "failed":
463
+ health = ui.red("failed")
464
+ else:
465
+ health = ui.dim(unit_state)
466
+ repo = app.get("repo")
467
+ src = (f"{repo['branch']}@{repo.get('sha', '')[:7]}" if repo
468
+ else app["db"]["engine"] if app.get("kind") == "db" else ui.dim("local"))
469
+ if repo and repo.get("paused"):
470
+ src += " " + ui.yellow("(paused)")
471
+ if repo and repo.get("failed_sha"):
472
+ src += " " + ui.red(f"(build of {repo['failed_sha'][:7]} failed)")
473
+ shown = _app_url(app)
474
+ if app.get("kind") == "db": # never print a password in a listing
475
+ shown = re.sub(r"(://[^:/@]*:)[^@]+@", r"\1•••@", shown)
476
+ rows.append([name, str(app["port"]), health, src, shown])
477
+ ui.table(rows, ["NAME", "PORT", "STATE", "SOURCE", "URL"])
478
+ return 0
479
+
480
+
481
+ def cmd_down(args: argparse.Namespace) -> int:
482
+ state = config.load()
483
+ app = config.get_app(state, args.name)
484
+ if app is None:
485
+ raise UserError(f"no hoisted app called {args.name!r}")
486
+
487
+ if app.get("repo"):
488
+ source.remove_sync_timer(args.name, service.UNIT_DIR, service.systemctl)
489
+ source.remove_all(args.name)
490
+ ui.ok("stopped watching the repo, checkout removed")
491
+
492
+ if not app.get("adopted"):
493
+ service.stop_and_remove(args.name)
494
+ ui.ok("service stopped and removed")
495
+ if app.get("kind") == "compose":
496
+ kept = containers.compose_down(args.name, purge=getattr(args, "purge", False))
497
+ ui.ok("compose stack removed" + (" with its volumes" if getattr(args, "purge", False) else ""))
498
+ if kept:
499
+ ui.step(f"data kept in {', '.join(kept)} — `hoist down {args.name} --purge` deletes it")
500
+ if app.get("kind") == "db":
501
+ containers.run("rm", "-f", containers.container_name(args.name), timeout=60)
502
+ base = containers.backup_unit_base(args.name)
503
+ service.systemctl("disable", "--now", f"{base}.timer")
504
+ for suffix in (".timer", ".service"):
505
+ (service.UNIT_DIR / f"{base}{suffix}").unlink(missing_ok=True)
506
+ if getattr(args, "purge", False):
507
+ containers.remove_volume(args.name)
508
+ ui.ok(f"data volume {app['db']['volume']} deleted")
509
+ else:
510
+ ui.step(f"data kept in volume {app['db']['volume']} — `hoist down {args.name} --purge` deletes it")
511
+
512
+ if app.get("tunnel"):
513
+ try:
514
+ cf_path = tunnel.find_config()
515
+ updated, changed = tunnel.remove(cf_path.read_text(), args.name)
516
+ if changed:
517
+ if tunnel.needs_sudo(cf_path):
518
+ ui.step(f"sudo needed to edit {cf_path}")
519
+ backup = tunnel.write_config(cf_path, updated)
520
+ good, message = tunnel.validate(cf_path)
521
+ if good:
522
+ ui.ok("ingress rule removed")
523
+ reloaded, detail = tunnel.reload()
524
+ if not reloaded:
525
+ ui.warn(f"could not reload cloudflared: {detail}")
526
+ else:
527
+ tunnel.restore(cf_path, backup)
528
+ ui.warn(f"ingress edit rolled back: {message}")
529
+ except tunnel.TunnelError as exc:
530
+ ui.warn(f"left ingress alone: {exc}")
531
+ if app.get("hostname"):
532
+ ui.step(f"DNS record for {app['hostname']} was left in place")
533
+
534
+ config.drop_app(state, args.name)
535
+ config.save(state)
536
+ ui.ok(f"{args.name} is down")
537
+ return 0
538
+
539
+
540
+ def cmd_sync(args: argparse.Namespace) -> int:
541
+ """Redeploy a git-backed app if its branch has moved. Run by the timer.
542
+
543
+ A failed fetch or build never takes the app down: the running process
544
+ keeps serving the last good commit, and a failed commit is not retried
545
+ until something newer is pushed. `--to <sha>` rolls back to an earlier
546
+ commit and pauses auto-deploy, so the next tick does not undo it.
547
+ """
548
+ state = config.load()
549
+ app = config.get_app(state, args.name)
550
+ if app is None or not app.get("repo"):
551
+ raise UserError(f"{args.name!r} is not a git-backed app")
552
+ repo = app["repo"]
553
+ target = source.checkout_dir(args.name)
554
+ to = getattr(args, "to", None)
555
+ manual = bool(args.force or to)
556
+
557
+ if not manual and (repo.get("paused") or app.get("stopped")):
558
+ return 0
559
+
560
+ # One deploy per app at a time: the timer and a dashboard redeploy can
561
+ # otherwise build the same app concurrently.
562
+ lock_path = source.releases_dir(args.name)
563
+ lock_path.mkdir(parents=True, exist_ok=True)
564
+ lock = open(lock_path / ".deploy-lock", "a+")
565
+ try:
566
+ fcntl.flock(lock, fcntl.LOCK_EX | (0 if manual or getattr(args, "wait", False) else fcntl.LOCK_NB))
567
+ except BlockingIOError:
568
+ return 0 # a deploy is already running; the next tick re-checks
569
+
570
+ try:
571
+ wanted = source.fetch_sha(target, to) if to else source.remote_head(target, repo["branch"])
572
+ except source.SourceError as exc:
573
+ if manual:
574
+ raise UserError(str(exc)) from exc
575
+ print(f"fetch skipped: {exc}")
576
+ return 0 # offline or rate-limited; the next tick tries again
577
+ if not manual and repo.get("previews") and not app.get("preview_of"):
578
+ _reconcile_previews(args.name, app)
579
+ if wanted in (repo.get("sha"), repo.get("failed_sha")) and not manual:
580
+ return 0
581
+
582
+ started = time.monotonic()
583
+ commit = source.subject(target, wanted)
584
+ trigger = "rollback" if to else ("manual" if args.force else "push")
585
+ print(f"deploying {commit} ({trigger})")
586
+ transcript: list[str] = []
587
+ release = source.find_release(args.name, wanted) if to else None
588
+ env_keys = list((app.get("env") or {}).keys())
589
+ if release is not None:
590
+ ok, detail = True, ""
591
+ transcript.append(f"reusing the release built earlier: {release.name}")
592
+ print(transcript[-1])
593
+ if app.get("dockerfile"): # an old release may carry the pre-rollover compose file
594
+ containers.ensure_dockerfile_compose(release, env_keys)
595
+ elif app.get("buildpack"):
596
+ containers.ensure_buildpack_compose(release, args.name, env_keys)
597
+ else:
598
+ try:
599
+ release = source.prepare_release(args.name, target, wanted)
600
+ except source.SourceError as exc:
601
+ if manual:
602
+ raise UserError(str(exc)) from exc
603
+ print(f"skipped: {exc}")
604
+ return 0
605
+ if app.get("dockerfile"):
606
+ containers.ensure_dockerfile_compose(release, env_keys)
607
+ elif app.get("buildpack"):
608
+ containers.ensure_buildpack_compose(release, args.name, env_keys)
609
+ ok, detail = source.run_build(release, repo.get("build", []), transcript=transcript)
610
+
611
+ # Seamless path: start the new container beside the old and let `hoist front`
612
+ # switch to it once it answers. A new version that never answers fails the
613
+ # deploy here, with the old one untouched and still serving.
614
+ rolled = False
615
+ if ok and app.get("front") and not app.get("stopped"):
616
+ swapped, why = front.rollover(args.name, release, app.get("env"))
617
+ if swapped is False:
618
+ ok, detail = False, why
619
+ transcript.append(why)
620
+ elif swapped:
621
+ rolled = True
622
+ transcript.append("started beside the old version; traffic switched with no gap")
623
+
624
+ entry = {"sha": wanted, "commit": commit, "at": config.now(), "trigger": trigger,
625
+ "seconds": round(time.monotonic() - started, 1)}
626
+ if os.environ.get("HOIST_ACTOR"):
627
+ entry["by"] = os.environ["HOIST_ACTOR"]
628
+ if not ok:
629
+ shutil.rmtree(release, ignore_errors=True) # never live, never needed
630
+ entry.update(status="failed", log=transcript[-60:], error=detail.splitlines()[0])
631
+ with config.transaction() as state: # re-read: the build may have run for minutes
632
+ app = config.get_app(state, args.name)
633
+ if app is None:
634
+ return 0 # taken down while we were building
635
+ app["repo"]["failed_sha"] = wanted
636
+ source.record_deploy(app["repo"], entry)
637
+ print(f"build failed, still serving {repo.get('sha', '')[:7]}: {detail}")
638
+ notify.send(f"{args.name}: push {wanted[:7]} failed to build",
639
+ f"{commit}\n{detail.splitlines()[0]}\nStill serving {repo.get('sha', '')[:7]}.",
640
+ level="bad", app=args.name)
641
+ return 1 if manual else 0
642
+
643
+ with config.transaction() as state:
644
+ app = config.get_app(state, args.name)
645
+ if app is None:
646
+ shutil.rmtree(release, ignore_errors=True)
647
+ return 0
648
+ source.mark_built(release)
649
+ source.activate(args.name, release)
650
+ source.prune_releases(args.name)
651
+ link = str(source.current_link(args.name))
652
+ if app.get("workdir") != link:
653
+ # An app deployed before releases existed ran straight from the git
654
+ # cache; point its unit at the release link from now on.
655
+ app["workdir"] = link
656
+ app["cmd"] = app.get("cmd", "").replace(str(target), link)
657
+ service.write_unit(app)
658
+ if (app.get("dockerfile") or app.get("buildpack")) and not app.get("front"):
659
+ # Deployed before seamless rollover existed: switch it over now. This one
660
+ # redeploy still restarts the container; every later one does not.
661
+ inner = (containers.BUILDPACK_INNER_PORT if app.get("buildpack")
662
+ else containers.dockerfile_port(release))
663
+ app["front"] = {"inner": inner, "bind": "127.0.0.1" if app.get("tunnel") else "0.0.0.0"}
664
+ app["cmd"] = front.front_command(args.name)
665
+ service.write_unit(app)
666
+ app["repo"].update(sha=wanted, commit=commit, deployed=config.now())
667
+ app["repo"].pop("failed_sha", None)
668
+ if to:
669
+ app["repo"]["paused"] = True
670
+ stopped, port, kind = bool(app.get("stopped")), int(app["port"]), app.get("kind")
671
+ hostname = app.get("hostname") if app.get("tunnel") else None
672
+
673
+ # Restart and wait outside the lock: health checks take seconds.
674
+ if rolled:
675
+ health = "up"
676
+ elif not stopped:
677
+ service.restart(args.name)
678
+ health = _wait_for_healthy(args.name, port, 240 if kind == "compose" else HEALTH_TIMEOUT,
679
+ detect.http_answers if kind == "compose" else None)
680
+ else:
681
+ health = "stopped"
682
+ entry.update(status="ok" if health in ("up", "stopped") else "unhealthy",
683
+ seconds=round(time.monotonic() - started, 1), log=transcript[-60:])
684
+ with config.transaction() as state:
685
+ app = config.get_app(state, args.name)
686
+ if app is not None:
687
+ source.record_deploy(app["repo"], entry)
688
+ print(f"deployed {commit} ({health})")
689
+ notify.send(f"{args.name} deployed {wanted[:7]}" + (" (rollback)" if to else ""),
690
+ f"{commit}\n{'https://' + hostname if hostname else ''}".strip(),
691
+ level="ok" if entry["status"] == "ok" else "warn", app=args.name)
692
+ if to:
693
+ print("auto-deploy paused after rollback — resume with: hoist resume " + args.name)
694
+ return 0
695
+
696
+
697
+ PREVIEW_LIMIT = 5
698
+
699
+
700
+ def preview_name(app: str, branch: str) -> str:
701
+ return detect.slug(f"{app}-{branch}")[:40].strip("-")
702
+
703
+
704
+ def _reconcile_previews(name: str, app: dict) -> None:
705
+ """One preview app per branch: create it on a new branch, remove it when the
706
+ branch is deleted. At most one new preview per tick, so a burst of branches
707
+ never stalls the main app's deploys."""
708
+ repo = app["repo"]
709
+ try:
710
+ heads = source.remote_branches(source.checkout_dir(name))
711
+ except source.SourceError as exc:
712
+ print(f"previews skipped: {exc}")
713
+ return
714
+ state = config.load()
715
+ existing = {a["repo"]["branch"]: n for n, a in state["apps"].items()
716
+ if a.get("preview_of") == name and a.get("repo")}
717
+ hoist = [sys.executable, "-m", "hoist"]
718
+ for branch, pname in existing.items():
719
+ if branch not in heads:
720
+ print(f"branch {branch} is gone, removing preview {pname}")
721
+ subprocess.run([*hoist, "down", pname], capture_output=True, text=True, timeout=300)
722
+ notify.send(f"Preview {pname} removed", f"Branch {branch} was deleted.", level="info", app=pname)
723
+ failed = dict(repo.get("preview_failed") or {})
724
+ for branch in sorted(heads):
725
+ if branch == repo["branch"] or branch in existing:
726
+ continue
727
+ if failed.get(branch) == heads[branch]:
728
+ continue # this commit already failed; wait for a new push to the branch
729
+ if len(existing) >= PREVIEW_LIMIT:
730
+ print(f"preview limit ({PREVIEW_LIMIT}) reached; {branch} gets no preview")
731
+ break
732
+ pname = preview_name(name, branch)
733
+ if config.get_app(state, pname):
734
+ continue
735
+ args = [*hoist, "up", repo["url"], "--branch", branch, "--name", pname, "--no-qr",
736
+ "--preview-of", name]
737
+ if not app.get("tunnel"):
738
+ args.append("--local")
739
+ for key, value in (app.get("env") or {}).items():
740
+ args += ["--env", f"{key}={value}"]
741
+ print(f"new branch {branch}: deploying preview {pname}")
742
+ result = subprocess.run(args, capture_output=True, text=True, timeout=1200)
743
+ print((result.stdout + result.stderr).strip()[-2000:])
744
+ with config.transaction() as fresh_state:
745
+ main = config.get_app(fresh_state, name)
746
+ if main and main.get("repo"):
747
+ record = main["repo"].setdefault("preview_failed", {})
748
+ if result.returncode == 0:
749
+ record.pop(branch, None)
750
+ else:
751
+ record[branch] = heads[branch]
752
+ for gone in [b for b in record if b not in heads]:
753
+ record.pop(gone)
754
+ if result.returncode == 0:
755
+ fresh = config.get_app(config.load(), pname) or {}
756
+ url = _app_url(fresh) if fresh else ""
757
+ notify.send(f"Preview of {branch} is live", f"{pname}: {url}", level="ok", app=pname)
758
+ else:
759
+ notify.send(f"Preview of {branch} failed to build", f"{pname} — fix the branch and push again.",
760
+ level="bad", app=name)
761
+ break # one new preview per tick
762
+
763
+
764
+ def cmd_webhook(args: argparse.Namespace) -> int:
765
+ import secrets as _secrets
766
+ with config.transaction() as state:
767
+ app = config.get_app(state, args.name)
768
+ if app is None or not app.get("repo"):
769
+ raise UserError(f"{args.name!r} is not deployed from git")
770
+ if args.off:
771
+ app["repo"].pop("webhook_secret", None)
772
+ secret = None
773
+ else:
774
+ secret = _secrets.token_hex(20)
775
+ app["repo"]["webhook_secret"] = secret
776
+ base = (state.get("dash_url") or "https://<your published dashboard>").rstrip("/")
777
+ if secret is None:
778
+ ui.ok("webhook removed; pushes are picked up by the 20 s check")
779
+ return 0
780
+ ui.ok("add this webhook to the repo (GitHub: Settings → Webhooks; content type application/json):")
781
+ ui.step(f"Payload URL {base}/hooks/{args.name}")
782
+ ui.step(f"Secret {secret}")
783
+ ui.step("pushes then deploy within seconds; the 20 s check stays on as a fallback")
784
+ if "<" in base:
785
+ ui.step("the dashboard must be reachable by GitHub: hoist dash --public dash")
786
+ return 0
787
+
788
+
789
+ def cmd_front(args: argparse.Namespace) -> int:
790
+ """Run by the app's systemd unit: own the public port and forward to the container."""
791
+ import asyncio
792
+ app = config.get_app(config.load(), args.name)
793
+ if app is None or not app.get("front"):
794
+ raise UserError(f"{args.name!r} is not a seamless-rollover app")
795
+ cfg = app["front"]
796
+ workdir = Path(app["workdir"])
797
+ docker = front.Docker(containers.compose_project(args.name), lambda: workdir.resolve(),
798
+ env=dict(app.get("env") or {}))
799
+ return asyncio.run(front.serve(args.name, docker, cfg["bind"], int(app["port"]), int(cfg["inner"])))
800
+
801
+
802
+ def cmd_stage(args: argparse.Namespace) -> int:
803
+ from .dash import team
804
+ if args.stage not in team.STAGES:
805
+ raise UserError(f"stage must be one of {', '.join(team.STAGES)}")
806
+ with config.transaction() as state:
807
+ app = config.get_app(state, args.name)
808
+ if app is None:
809
+ raise UserError(f"no hoisted app called {args.name!r}")
810
+ app["stage"] = args.stage
811
+ ui.ok(f"{args.name} is now in {args.stage}")
812
+ return 0
813
+
814
+
815
+ def cmd_previews(args: argparse.Namespace) -> int:
816
+ with config.transaction() as state:
817
+ app = config.get_app(state, args.name)
818
+ if app is None or not app.get("repo"):
819
+ raise UserError(f"{args.name!r} is not deployed from git")
820
+ if app.get("preview_of"):
821
+ raise UserError(f"{args.name} is itself a preview")
822
+ if args.state == "on":
823
+ app["repo"]["previews"] = True
824
+ else:
825
+ app["repo"].pop("previews", None)
826
+ if args.state == "on":
827
+ ui.ok(f"every branch pushed to {app['repo']['url']} gets its own preview app "
828
+ f"(up to {PREVIEW_LIMIT}), removed when the branch is deleted")
829
+ else:
830
+ ui.ok("branch previews off — existing previews stay until you take them down")
831
+ return 0
832
+
833
+
834
+ def _set_flag(name: str, key: str, value: bool, *, repo: bool = False) -> dict:
835
+ with config.transaction() as state:
836
+ app = config.get_app(state, name)
837
+ if app is None:
838
+ raise UserError(f"no hoisted app called {name!r}")
839
+ holder = app.get("repo") if repo else app
840
+ if repo and holder is None:
841
+ raise UserError(f"{name} is not deployed from git")
842
+ if value:
843
+ holder[key] = True
844
+ else:
845
+ holder.pop(key, None)
846
+ return app
847
+
848
+
849
+ def cmd_pause(args: argparse.Namespace) -> int:
850
+ _set_flag(args.name, "paused", True, repo=True)
851
+ ui.ok(f"auto-deploy paused for {args.name} — pushes are ignored until: hoist resume {args.name}")
852
+ return 0
853
+
854
+
855
+ def cmd_resume(args: argparse.Namespace) -> int:
856
+ _set_flag(args.name, "paused", False, repo=True)
857
+ ui.ok(f"auto-deploy resumed for {args.name}")
858
+ return 0
859
+
860
+
861
+ def cmd_stop(args: argparse.Namespace) -> int:
862
+ app = _set_flag(args.name, "stopped", True)
863
+ if app.get("adopted"):
864
+ raise UserError(f"{args.name} was adopted; hoist does not manage its process")
865
+ service.systemctl("stop", service.unit_name(args.name))
866
+ ui.ok(f"{args.name} stopped (its URL now answers 502) — start it with: hoist start {args.name}")
867
+ return 0
868
+
869
+
870
+ def cmd_start(args: argparse.Namespace) -> int:
871
+ app = _set_flag(args.name, "stopped", False)
872
+ if app.get("adopted"):
873
+ raise UserError(f"{args.name} was adopted; hoist does not manage its process")
874
+ service.start(args.name)
875
+ _report_health(args.name, app["port"])
876
+ return 0
877
+
878
+
879
+ def cmd_logs(args: argparse.Namespace) -> int:
880
+ state = config.load()
881
+ app = config.get_app(state, args.name)
882
+ if app is None:
883
+ raise UserError(f"no hoisted app called {args.name!r}")
884
+ if app.get("adopted"):
885
+ raise UserError(f"{args.name} was adopted; hoist does not manage its logs")
886
+ if getattr(args, "deploys", False):
887
+ if not app.get("repo"):
888
+ raise UserError(f"{args.name} is not deployed from git")
889
+ return service.logs(args.name, lines=args.lines, follow=args.follow,
890
+ unit=f"{source.sync_unit_base(args.name)}.service")
891
+ return service.logs(args.name, lines=args.lines, follow=args.follow)
892
+
893
+
894
+ def cmd_restart(args: argparse.Namespace) -> int:
895
+ state = config.load()
896
+ app = config.get_app(state, args.name)
897
+ if app is None:
898
+ raise UserError(f"no hoisted app called {args.name!r}")
899
+ if app.get("adopted"):
900
+ raise UserError(f"{args.name} was adopted; hoist does not manage its process")
901
+ service.restart(args.name)
902
+ ui.ok(f"{args.name} restarted")
903
+ return 0
904
+
905
+
906
+ def cmd_url(args: argparse.Namespace) -> int:
907
+ state = config.load()
908
+ app = config.get_app(state, args.name)
909
+ if app is None:
910
+ raise UserError(f"no hoisted app called {args.name!r}")
911
+ print(_app_url(app))
912
+ return 0
913
+
914
+
915
+ def cmd_qr(args: argparse.Namespace) -> int:
916
+ state = config.load()
917
+ app = config.get_app(state, args.name)
918
+ if app is None:
919
+ raise UserError(f"no hoisted app called {args.name!r}")
920
+ url = _app_url(app)
921
+ ui.banner(url)
922
+ _show_qr(url, args.ascii)
923
+ return 0
924
+
925
+
926
+ def cmd_adopt(args: argparse.Namespace) -> int:
927
+ """Expose something already running on a port, without managing its process."""
928
+ state = config.load()
929
+ name = detect.slug(args.name)
930
+ if not detect.port_in_use(args.port):
931
+ ui.warn(f"nothing is listening on port {args.port} yet")
932
+
933
+ cf_path = tunnel.find_config()
934
+ cf_text = cf_path.read_text()
935
+ domain = _resolve_domain(state, args.domain, cf_text)
936
+ hostname = args.hostname or f"{name}.{domain}"
937
+
938
+ updated = _apply_ingress(cf_path, cf_text, name, hostname, args.port)
939
+ _publish(updated, hostname)
940
+
941
+ app = {
942
+ "name": name,
943
+ "port": args.port,
944
+ "workdir": str(Path.cwd()),
945
+ "cmd": "",
946
+ "env": {},
947
+ "tunnel": True,
948
+ "hostname": hostname,
949
+ "adopted": True,
950
+ "created": config.now(),
951
+ "updated": config.now(),
952
+ }
953
+ config.put_app(state, app)
954
+ state["domain"] = domain
955
+ config.save(state)
956
+
957
+ url = _app_url(app)
958
+ ui.banner(url, "adopted — hoist does not manage this process")
959
+ if not args.no_qr:
960
+ _show_qr(url, args.ascii)
961
+ return 0
962
+
963
+
964
+ def doctor_checks() -> list[dict]:
965
+ """Every setup check as data: {level: ok|warn|fail, text, fix: [commands]}.
966
+
967
+ `hoist doctor` prints these; the dashboard's System page renders them.
968
+ """
969
+ checks: list[dict] = []
970
+
971
+ def add(level: str, text: str, fix: list[str] | None = None) -> None:
972
+ checks.append({"level": level, "text": text, "fix": fix or []})
973
+
974
+ if service.have_systemd():
975
+ add("ok", "systemd --user available")
976
+ else:
977
+ add("fail", "systemd --user unavailable — hoist cannot manage services")
978
+
979
+ if service.lingering_enabled():
980
+ add("ok", "lingering enabled (services survive logout)")
981
+ else:
982
+ add("warn", "lingering off — services stop when you log out",
983
+ [f"sudo loginctl enable-linger {os.environ.get('USER', '$USER')}"])
984
+
985
+ if tunnel.have_cloudflared():
986
+ add("ok", "cloudflared installed")
987
+ else:
988
+ add("warn", "cloudflared not installed — only --local URLs will work")
989
+
990
+ try:
991
+ cf_path = tunnel.find_config()
992
+ text = cf_path.read_text()
993
+ add("ok", f"tunnel config: {cf_path}")
994
+ tid = tunnel.tunnel_id(text)
995
+ if tid:
996
+ add("ok", f"tunnel id: {tid}")
997
+ else:
998
+ add("warn", "no `tunnel:` id in config")
999
+ hosts = tunnel.hostnames(text)
1000
+ add("ok", f"{len(hosts)} ingress hostname(s): {', '.join(hosts) or '—'}")
1001
+ domain = tunnel.default_domain(text)
1002
+ if domain:
1003
+ add("ok", f"default domain: {domain}")
1004
+ else:
1005
+ add("warn", "no domain inferred — pass --domain once")
1006
+ if tunnel.needs_sudo(cf_path) and not tunnel.sudo_is_passwordless():
1007
+ user = os.environ.get("USER") or Path.home().name
1008
+ add("warn", f"{cf_path} needs root, and sudo will prompt for a password — "
1009
+ "publishing a new URL must then run in a real terminal",
1010
+ tunnel.passwordless_setup(cf_path, user))
1011
+ elif tunnel.needs_sudo(cf_path):
1012
+ add("ok", "sudo available without a password prompt")
1013
+ else:
1014
+ add("ok", "tunnel config is writable without sudo")
1015
+ except tunnel.TunnelError as exc:
1016
+ add("warn", str(exc))
1017
+
1018
+ running = tunnel.daemon_unit()
1019
+ if running:
1020
+ _unit, is_system = running
1021
+ add("ok", f"cloudflared running as {'system' if is_system else 'user'} unit")
1022
+ else:
1023
+ add("warn", "cloudflared is not running as a service")
1024
+
1025
+ state = config.load()
1026
+ add("ok", f"{len(state['apps'])} app(s) tracked in {config.STATE_FILE}")
1027
+ return checks
1028
+
1029
+
1030
+ def cmd_doctor(args: argparse.Namespace) -> int:
1031
+ problems = 0
1032
+ for check in doctor_checks():
1033
+ {"ok": ui.ok, "warn": ui.warn, "fail": ui.fail}[check["level"]](check["text"])
1034
+ if check["fix"]:
1035
+ if len(check["fix"]) > 1:
1036
+ ui.step("to fix it, run these once:")
1037
+ for line in check["fix"]:
1038
+ ui.step(f" {line}" if len(check["fix"]) > 1 else f"fix: {line}")
1039
+ problems += check["level"] == "fail"
1040
+ return 1 if problems else 0
1041
+
1042
+
1043
+ def cmd_db(args: argparse.Namespace) -> int:
1044
+ """A database in one command: Docker, a private volume, a generated password."""
1045
+ try:
1046
+ containers.docker()
1047
+ except containers.ContainerError as exc:
1048
+ raise UserError(str(exc)) from exc
1049
+ name = detect.slug(args.name or args.engine)
1050
+ state = config.load()
1051
+ if config.get_app(state, name):
1052
+ raise UserError(f"{name} already exists — pick another --name")
1053
+ port = args.port or detect.free_port()
1054
+ if not detect.port_is_free(port):
1055
+ raise UserError(f"port {port} is already in use")
1056
+ db = containers.database_spec(args.engine, name, port, args.image)
1057
+ meta = containers.ENGINES[args.engine]
1058
+ ui.info(f"{ui.bold(name)} {ui.dim('·')} {meta['label']} {ui.dim('·')} 127.0.0.1:{port}")
1059
+ try:
1060
+ containers.pull(db["image"], log=ui.step)
1061
+ except containers.ContainerError as exc:
1062
+ raise UserError(str(exc)) from exc
1063
+ app = {
1064
+ "name": name, "port": port, "workdir": str(Path.home()),
1065
+ "cmd": containers.database_command(name, db), "env": {}, "tunnel": False,
1066
+ "hostname": None, "kind": "db", "db": db, "pre": containers.pre_start(name),
1067
+ "created": config.now(), "updated": config.now(),
1068
+ }
1069
+ service.write_unit(app)
1070
+ service.start(name)
1071
+ with config.transaction() as state:
1072
+ config.put_app(state, app)
1073
+ health = _report_health(name, port, timeout=180, check=lambda _p: containers.ready(name, db))
1074
+ ui.banner(db["url"], f"hoist link {name} <app> · hoist down {name}")
1075
+ ui.step("reachable from this machine only; data lives in the volume " + db["volume"])
1076
+ return 0 if health == "up" else 1
1077
+
1078
+
1079
+ def cmd_exec(args: argparse.Namespace) -> int:
1080
+ """Run one command in an app's own environment: its folder and env for a
1081
+ process, its container for a database, its first service for a stack."""
1082
+ app = config.get_app(config.load(), args.name)
1083
+ if app is None:
1084
+ raise UserError(f"no hoisted app called {args.name!r}")
1085
+ if app.get("adopted"):
1086
+ raise UserError(f"{args.name} was adopted; hoist does not know where it runs")
1087
+ command = " ".join(args.command)
1088
+ if not command.strip():
1089
+ raise UserError("nothing to run")
1090
+ if app.get("kind") == "db":
1091
+ argv = [*shlex.split(containers.docker()), "exec", containers.container_name(args.name),
1092
+ "sh", "-c", command]
1093
+ env, cwd = None, None
1094
+ elif app.get("kind") == "compose":
1095
+ base = [*shlex.split(containers.docker()), "compose", "-p", containers.compose_project(args.name)]
1096
+ services = subprocess.run([*base, "ps", "--services"], capture_output=True, text=True,
1097
+ cwd=app["workdir"]).stdout.split()
1098
+ if not services:
1099
+ raise UserError(f"{args.name} has no running containers")
1100
+ target = args.service or next((s for s in ("web", "app", "api", "server") if s in services), services[0])
1101
+ argv = [*base, "exec", "-T", target, "sh", "-c", command]
1102
+ env, cwd = None, app["workdir"]
1103
+ else:
1104
+ argv = ["/bin/sh", "-lc", command]
1105
+ env = dict(os.environ, PORT=str(app["port"]), HOIST_APP=args.name, **(app.get("env") or {}))
1106
+ cwd = app["workdir"]
1107
+ print(f"$ {command}", flush=True)
1108
+ try:
1109
+ return subprocess.run(argv, env=env, cwd=cwd, timeout=args.timeout).returncode
1110
+ except subprocess.TimeoutExpired:
1111
+ ui.fail(f"stopped after {args.timeout}s")
1112
+ return 124
1113
+
1114
+
1115
+ def cmd_template(args: argparse.Namespace) -> int:
1116
+ if args.id == "add":
1117
+ if not args.source or not args.name:
1118
+ raise UserError("usage: hoist template add <compose file or URL> --name <id> [--label L] [--about A]")
1119
+ try:
1120
+ info = containers.add_user_template(args.name, args.source, args.label, args.about)
1121
+ except containers.ContainerError as exc:
1122
+ raise UserError(str(exc)) from exc
1123
+ ui.ok(f"saved as template {info['id']}; it will run these images: {', '.join(info['images']) or 'none found'}")
1124
+ ui.step("a Compose file runs whatever its images do — read it before you deploy")
1125
+ ui.step(f"deploy it with: hoist template {info['id']}")
1126
+ return 0
1127
+ if args.id == "remove":
1128
+ if not args.source or not containers.remove_user_template(args.source):
1129
+ raise UserError("no such template of yours — see: hoist template list")
1130
+ ui.ok(f"template {args.source} removed (apps already deployed from it keep running)")
1131
+ return 0
1132
+ catalogue = containers.catalogue()
1133
+ if args.id in (None, "list"):
1134
+ ui.table([[k, v["label"] + (" ★" if v.get("user") else ""), v["about"]] for k, v in catalogue.items()],
1135
+ ["ID", "APP", "WHAT IT IS"])
1136
+ if any(v.get("user") for v in catalogue.values()):
1137
+ ui.step("★ = a template you added")
1138
+ return 0
1139
+ if args.id not in catalogue:
1140
+ raise UserError(f"no template {args.id!r} — see: hoist template list")
1141
+ try:
1142
+ containers.docker()
1143
+ except containers.ContainerError as exc:
1144
+ raise UserError(str(exc)) from exc
1145
+ spec = catalogue[args.id]
1146
+ name = detect.slug(args.name or args.id)
1147
+ if config.get_app(config.load(), name):
1148
+ raise UserError(f"{name} already exists — pick another --name")
1149
+ folder = containers.template_dir(name)
1150
+ folder.mkdir(parents=True, exist_ok=True)
1151
+ values = containers.template_secrets(args.id)
1152
+ compose = folder / "compose.yml"
1153
+ fd = os.open(compose, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
1154
+ with os.fdopen(fd, "w") as fh:
1155
+ fh.write(containers.template_compose(args.id, values))
1156
+ needs_password = any("{password}" in v for v in (spec.get("env") or {}).values())
1157
+ ui.info(f"{ui.bold(name)} {ui.dim('·')} {spec['label']} {ui.dim('·')} {spec['image']}")
1158
+ try:
1159
+ containers.pull(spec["image"], log=ui.step)
1160
+ except containers.ContainerError as exc:
1161
+ raise UserError(str(exc)) from exc
1162
+ up_args = argparse.Namespace(
1163
+ target=str(folder), name=name, port=None, cmd=None, domain=args.domain, hostname=None,
1164
+ env=None, local=args.local, no_qr=args.no_qr, ascii=False, branch=None, build=None,
1165
+ no_sync=True, preview_of=None)
1166
+ code = cmd_up(up_args)
1167
+ with config.transaction() as state:
1168
+ if name in state["apps"]:
1169
+ state["apps"][name]["template"] = args.id
1170
+ if needs_password:
1171
+ ui.ok(f"password for {spec['label']}: {values['password']} (shown once; it is in {compose})")
1172
+ return code
1173
+
1174
+
1175
+ def _db_app(name: str) -> dict:
1176
+ app = config.get_app(config.load(), name)
1177
+ if app is None or app.get("kind") not in ("db", "compose"):
1178
+ raise UserError(f"{name!r} is not a database or a Docker stack, so it has nothing hoist can back up")
1179
+ return app
1180
+
1181
+
1182
+ def cmd_backup(args: argparse.Namespace) -> int:
1183
+ app = _db_app(args.name)
1184
+ base = containers.backup_unit_base(args.name)
1185
+ if args.offsite:
1186
+ with config.transaction() as state:
1187
+ db = state["apps"][args.name].setdefault("db", {}) if app.get("kind") == "db" \
1188
+ else state["apps"][args.name].setdefault("backup", {})
1189
+ if args.offsite == "off":
1190
+ db.pop("offsite", None)
1191
+ else:
1192
+ db["offsite"] = args.offsite
1193
+ ui.ok("off-site copies off" if args.offsite == "off" else f"every backup is also copied to {args.offsite}")
1194
+ return 0
1195
+ if args.daily or args.no_schedule:
1196
+ if args.daily:
1197
+ svc, timer = containers.render_backup_units(
1198
+ args.name, sys.executable, str(Path(__file__).resolve().parent.parent),
1199
+ os.environ.get("HOIST_HOME"))
1200
+ service.UNIT_DIR.mkdir(parents=True, exist_ok=True)
1201
+ (service.UNIT_DIR / f"{base}.service").write_text(svc)
1202
+ (service.UNIT_DIR / f"{base}.timer").write_text(timer)
1203
+ service.systemctl("daemon-reload")
1204
+ service.systemctl("enable", "--now", f"{base}.timer")
1205
+ with config.transaction() as state:
1206
+ holder = "db" if app.get("kind") == "db" else "backup"
1207
+ state["apps"][args.name].setdefault(holder, {})["daily_backup"] = True
1208
+ ui.ok(f"{args.name} is backed up every day; the newest {containers.BACKUP_KEEP} are kept")
1209
+ else:
1210
+ service.systemctl("disable", "--now", f"{base}.timer")
1211
+ for suffix in (".timer", ".service"):
1212
+ (service.UNIT_DIR / f"{base}{suffix}").unlink(missing_ok=True)
1213
+ service.systemctl("daemon-reload")
1214
+ with config.transaction() as state:
1215
+ holder = "db" if app.get("kind") == "db" else "backup"
1216
+ state["apps"][args.name].setdefault(holder, {}).pop("daily_backup", None)
1217
+ ui.ok(f"daily backups of {args.name} turned off (existing files kept)")
1218
+ return 0
1219
+ if args.list:
1220
+ rows = [[b["file"], f"{b['size'] / 1024:.1f} KB", time.strftime("%Y-%m-%d %H:%M", time.localtime(b["at"]))]
1221
+ for b in containers.list_backups(args.name)]
1222
+ if rows:
1223
+ ui.table(rows, ["FILE", "SIZE", "TAKEN"])
1224
+ else:
1225
+ ui.info("no backups yet")
1226
+ return 0
1227
+ if args.restore:
1228
+ target = containers.backup_dir(args.name) / Path(args.restore).name
1229
+ if not target.is_file():
1230
+ raise UserError(f"no backup called {args.restore!r} — see hoist backup {args.name} --list")
1231
+ ui.step(f"restoring {target.name} into {args.name}")
1232
+ if app.get("kind") == "compose":
1233
+ containers.restore_volumes(args.name, target, service.systemctl)
1234
+ else:
1235
+ containers.restore(args.name, app["db"], target, service.systemctl)
1236
+ ui.ok(f"{args.name} restored from {target.name}")
1237
+ notify.send(f"{args.name} restored from a backup", target.name, level="warn", app=args.name)
1238
+ return 0
1239
+ try:
1240
+ if app.get("kind") == "compose":
1241
+ path = containers.backup_volumes(args.name, log=ui.step)
1242
+ offsite = (app.get("backup") or {}).get("offsite")
1243
+ if offsite:
1244
+ containers.copy_offsite(path, offsite, args.name)
1245
+ else:
1246
+ path = containers.backup(args.name, app["db"])
1247
+ except containers.ContainerError as exc:
1248
+ notify.send(f"Backup of {args.name} failed", str(exc), level="bad", app=args.name)
1249
+ raise UserError(str(exc)) from exc
1250
+ ui.ok(f"backed up {args.name} → {path} ({path.stat().st_size / 1024:.1f} KB)")
1251
+ return 0
1252
+
1253
+
1254
+ def cmd_link(args: argparse.Namespace) -> int:
1255
+ """Put a database's URL into an app's environment and restart the app."""
1256
+ with config.transaction() as state:
1257
+ db = config.get_app(state, args.db)
1258
+ app = config.get_app(state, args.app)
1259
+ if db is None or db.get("kind") != "db":
1260
+ raise UserError(f"{args.db!r} is not a hoist database")
1261
+ if app is None:
1262
+ raise UserError(f"no hoisted app called {args.app!r}")
1263
+ if app.get("adopted"):
1264
+ raise UserError(f"{args.app} was adopted; hoist does not manage its environment")
1265
+ var = args.var or db["db"]["env_var"]
1266
+ app.setdefault("env", {})[var] = db["db"]["url"]
1267
+ app["updated"] = config.now()
1268
+ service.write_unit(app)
1269
+ containers.refresh_generated_compose(app)
1270
+ if not app.get("stopped"):
1271
+ service.restart(args.app)
1272
+ ui.ok(f"{args.app} now has {var} pointing at {args.db} (restarted)")
1273
+ return 0
1274
+
1275
+
1276
+ def cmd_limit(args: argparse.Namespace) -> int:
1277
+ import re as _re
1278
+ with config.transaction() as state:
1279
+ app = config.get_app(state, args.name)
1280
+ if app is None:
1281
+ raise UserError(f"no hoisted app called {args.name!r}")
1282
+ if app.get("adopted"):
1283
+ raise UserError(f"{args.name} was adopted; hoist does not manage its process")
1284
+ if app.get("kind") == "compose":
1285
+ raise UserError("a Compose stack sets its limits per service in the compose file "
1286
+ "(deploy.resources.limits) — hoist cannot cap the containers from outside")
1287
+ limits = dict(app.get("limits") or {})
1288
+ for key, value, pattern, example in (("memory", args.memory, service.LIMIT_MEMORY, "512M"),
1289
+ ("cpu", args.cpu, service.LIMIT_CPU, "50%")):
1290
+ if value is None:
1291
+ continue
1292
+ value = value.strip().upper()
1293
+ if value in ("", "NONE", "OFF"):
1294
+ limits.pop(key, None)
1295
+ elif not _re.match(pattern, value):
1296
+ raise UserError(f"--{key} expects something like {example}, got {value!r}")
1297
+ else:
1298
+ limits[key] = value
1299
+ if limits:
1300
+ app["limits"] = limits
1301
+ else:
1302
+ app.pop("limits", None)
1303
+ if app.get("kind") == "db":
1304
+ app["cmd"] = containers.database_command(args.name, app["db"], limits)
1305
+ service.write_unit(app)
1306
+ if not app.get("stopped"):
1307
+ service.restart(args.name)
1308
+ shown = ", ".join(f"{k} {v}" for k, v in limits.items()) or "no limits"
1309
+ ui.ok(f"{args.name}: {shown} (restarted)")
1310
+ return 0
1311
+
1312
+
1313
+ def cmd_notify(args: argparse.Namespace) -> int:
1314
+ state = config.load()
1315
+ if args.off:
1316
+ state.pop("notify", None)
1317
+ config.save(state)
1318
+ ui.ok("notifications off")
1319
+ return 0
1320
+ if args.url:
1321
+ if not args.url.startswith(("https://", "http://", "smtp://", "smtps://")):
1322
+ raise UserError("expected a webhook URL (https://…) or an email URL (smtps://user:pass@host:465/?to=…)")
1323
+ state["notify"] = {"url": args.url}
1324
+ config.save(state)
1325
+ ui.ok(f"notifications go to a {notify.kind(args.url)} webhook")
1326
+ current = (state.get("notify") or {}).get("url")
1327
+ if not current:
1328
+ ui.info("no webhook set — hoist notify <discord|slack|ntfy|telegram|any URL>")
1329
+ return 0
1330
+ if args.test or args.url:
1331
+ good, detail = notify.send("hoist is connected",
1332
+ "You will hear about deploys, failed builds and apps that stop answering.",
1333
+ level="ok")
1334
+ (ui.ok if good else ui.fail)(f"test message: {detail}")
1335
+ return 0 if good else 1
1336
+ ui.info(f"notifications go to a {notify.kind(current)} webhook")
1337
+ return 0
1338
+
1339
+
1340
+ DASH_PORT = 7457
1341
+ DASH_TUNNEL_NAME = "_dashboard" # slug() can never produce "_", so no app collides
1342
+
1343
+
1344
+ def _dash_unit_text(port: int, host: str) -> str:
1345
+ pkg_root = Path(__file__).resolve().parent.parent
1346
+ env = f"Environment=PYTHONPATH={pkg_root}\n"
1347
+ if os.environ.get("HOIST_HOME"):
1348
+ env += f"Environment=HOIST_HOME={os.environ['HOIST_HOME']}\n"
1349
+ return f"""[Unit]
1350
+ Description=hoist dashboard
1351
+ After=network-online.target
1352
+
1353
+ [Service]
1354
+ Type=simple
1355
+ {env}ExecStart={sys.executable} -m hoist dash --port {port} --host {host} --foreground
1356
+ Restart=on-failure
1357
+ RestartSec=2
1358
+
1359
+ [Install]
1360
+ WantedBy=default.target
1361
+ """
1362
+
1363
+
1364
+ def cmd_dash(args: argparse.Namespace) -> int:
1365
+ from .dash import server
1366
+
1367
+ from .dash import team
1368
+
1369
+ token = server.load_token(reset=args.reset_token)
1370
+ port = args.port
1371
+ base = (config.load().get("dash_url") or f"http://127.0.0.1:{port}").rstrip("/")
1372
+ if args.add_user:
1373
+ try:
1374
+ member = team.add(args.add_user, args.role,
1375
+ [c for c in (args.can or "").split(",") if c],
1376
+ [a for a in (args.apps or "").split(",") if a],
1377
+ [s for s in (args.stages or "").split(",") if s])
1378
+ except team.TeamError as exc:
1379
+ raise UserError(str(exc)) from exc
1380
+ team.audit("owner", "add teammate", args.add_user.lower(), "from the CLI")
1381
+ ui.ok(f"{args.add_user} can sign in with this link (shown once):")
1382
+ ui.banner(f"{base}/?token={member}", f"hoist dash --remove-user {args.add_user} revokes it")
1383
+ return 0
1384
+ if args.remove_user:
1385
+ if not team.remove(args.remove_user):
1386
+ raise UserError(f"no teammate called {args.remove_user!r}")
1387
+ team.audit("owner", "remove teammate", args.remove_user, "from the CLI")
1388
+ ui.ok(f"{args.remove_user} is signed out and their link no longer works")
1389
+ return 0
1390
+ if args.users:
1391
+ people = team.users()
1392
+ def summary(i: dict) -> str:
1393
+ if i.get("role") != "custom":
1394
+ return i.get("role", "member")
1395
+ where = ','.join(i.get('apps', [])) or 'all apps'
1396
+ if i.get('stages'):
1397
+ where += f" in {','.join(i['stages'])}"
1398
+ return f"custom: {','.join(i.get('can', [])) or 'view'} on {where}"
1399
+ ui.table([["owner", "owner", "—"]] + [[n, summary(i), i.get("added", "")] for n, i in sorted(people.items())],
1400
+ ["NAME", "ACCESS", "ADDED"])
1401
+ return 0
1402
+
1403
+ if args.service or args.public:
1404
+ unit = service.UNIT_DIR / "hoist--dash.service"
1405
+ service.UNIT_DIR.mkdir(parents=True, exist_ok=True)
1406
+ unit.write_text(_dash_unit_text(port, args.host))
1407
+ service.systemctl("daemon-reload")
1408
+ result = service.systemctl("enable", "--now", "hoist--dash.service")
1409
+ if result.returncode != 0:
1410
+ raise UserError(result.stderr.strip() or "could not start the dashboard service")
1411
+ service.systemctl("restart", "hoist--dash.service")
1412
+ ui.ok(f"dashboard running as a service on 127.0.0.1:{port} (survives reboots)")
1413
+ url = f"http://127.0.0.1:{port}"
1414
+ if args.public:
1415
+ state = config.load()
1416
+ cf_path = tunnel.find_config()
1417
+ cf_text = cf_path.read_text()
1418
+ domain = _resolve_domain(state, None, cf_text)
1419
+ hostname = args.public if "." in args.public else f"{args.public}.{domain}"
1420
+ updated = _apply_ingress(cf_path, cf_text, DASH_TUNNEL_NAME, hostname, port)
1421
+ _publish(updated, hostname)
1422
+ url = f"https://{hostname}"
1423
+ with config.transaction() as st:
1424
+ st["dash_url"] = url
1425
+ ui.banner(f"{url}/?token={token}", "open this once; the browser remembers you")
1426
+ return 0
1427
+
1428
+ if args.stop:
1429
+ service.systemctl("disable", "--now", "hoist--dash.service")
1430
+ (service.UNIT_DIR / "hoist--dash.service").unlink(missing_ok=True)
1431
+ service.systemctl("daemon-reload")
1432
+ ui.ok("dashboard service stopped and removed")
1433
+ return 0
1434
+
1435
+ if not detect.port_is_free(port):
1436
+ if args.foreground:
1437
+ raise UserError(f"port {port} is already in use")
1438
+ ui.info(f"the dashboard is already running on port {port}")
1439
+ ui.banner(f"http://127.0.0.1:{port}/?token={token}")
1440
+ return 0
1441
+ httpd = server.serve(args.host, port, token)
1442
+ login = f"http://127.0.0.1:{port}/?token={token}"
1443
+ if not args.foreground:
1444
+ ui.banner(login, "Ctrl+C to stop · hoist dash --service keeps it running")
1445
+ if args.open:
1446
+ subprocess.Popen(["xdg-open", login], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
1447
+ try:
1448
+ httpd.serve_forever()
1449
+ except KeyboardInterrupt:
1450
+ pass
1451
+ return 0
1452
+
1453
+
1454
+ # --- argument parsing ---------------------------------------------------------
1455
+
1456
+
1457
+ def build_parser() -> argparse.ArgumentParser:
1458
+ parser = argparse.ArgumentParser(
1459
+ prog="hoist",
1460
+ description="Put a local app on a public HTTPS URL, with one command.",
1461
+ )
1462
+ parser.add_argument("--version", action="version", version=f"hoist {__version__}")
1463
+ sub = parser.add_subparsers(dest="command", required=True)
1464
+
1465
+ def add_qr_flags(p: argparse.ArgumentParser) -> None:
1466
+ p.add_argument("--no-qr", action="store_true", help="do not print a QR code")
1467
+ p.add_argument("--ascii", action="store_true", help="QR without ANSI colour")
1468
+
1469
+ up = sub.add_parser("up", help="hoist a directory or git repo onto a public URL")
1470
+ up.add_argument("target", nargs="?",
1471
+ help="project directory (default: cwd) or git URL, e.g. github.com/team/app")
1472
+ up.add_argument("--name", help="app name and subdomain label")
1473
+ up.add_argument("--port", type=int, help="port to run on (default: auto)")
1474
+ up.add_argument("--cmd", help="start command; $PORT is substituted")
1475
+ up.add_argument("--domain", help="zone to put the subdomain under")
1476
+ up.add_argument("--hostname", help="full hostname, overriding <name>.<domain>")
1477
+ up.add_argument("--env", action="append", metavar="K=V", help="extra env var")
1478
+ up.add_argument("--local", action="store_true", help="LAN URL only, no tunnel")
1479
+ up.add_argument("--branch", help="git branch to deploy (repo URLs only)")
1480
+ up.add_argument("--build", help="build command for a repo, overriding detection")
1481
+ up.add_argument("--buildpack", nargs="?", const="auto", choices=["auto", "nixpacks", "pack"],
1482
+ help="build with a buildpack you already have installed (any language)")
1483
+ up.add_argument("--stage", help="production (default), staging, development or preview")
1484
+ up.add_argument("--preview-of", help=argparse.SUPPRESS)
1485
+ up.add_argument("--no-sync", action="store_true",
1486
+ help="deploy a repo once, without redeploying on push")
1487
+ add_qr_flags(up)
1488
+ up.set_defaults(func=cmd_up)
1489
+
1490
+ share = sub.add_parser("share", help="serve a folder of files publicly")
1491
+ share.add_argument("path", nargs="?", default=".", help="directory to serve")
1492
+ share.add_argument("--name", help="app name and subdomain label")
1493
+ share.add_argument("--port", type=int)
1494
+ share.add_argument("--domain")
1495
+ share.add_argument("--hostname")
1496
+ share.add_argument("--env", action="append", metavar="K=V")
1497
+ share.add_argument("--local", action="store_true")
1498
+ share.add_argument("--stage", help="production (default), staging, development or preview")
1499
+ add_qr_flags(share)
1500
+ share.set_defaults(func=cmd_share)
1501
+
1502
+ ls = sub.add_parser("ls", help="list hoisted apps")
1503
+ ls.set_defaults(func=cmd_ls)
1504
+
1505
+ down = sub.add_parser("down", help="stop an app and remove its ingress rule")
1506
+ down.add_argument("name")
1507
+ down.add_argument("--purge", action="store_true", help="also delete a database's or stack's data volumes")
1508
+ down.set_defaults(func=cmd_down)
1509
+
1510
+ sync = sub.add_parser("sync", help="redeploy a git-backed app if its branch moved")
1511
+ sync.add_argument("name")
1512
+ sync.add_argument("--force", action="store_true", help="rebuild even if up to date")
1513
+ sync.add_argument("--to", metavar="SHA", help="roll back to this commit (pauses auto-deploy)")
1514
+ sync.add_argument("--wait", action="store_true",
1515
+ help="if a deploy is running, wait for it instead of skipping (used by webhooks)")
1516
+ sync.set_defaults(func=cmd_sync)
1517
+
1518
+ for verb, func, text in (
1519
+ ("pause", cmd_pause, "stop redeploying a repo app on push"),
1520
+ ("resume", cmd_resume, "redeploy a repo app on push again"),
1521
+ ("stop", cmd_stop, "stop an app's process, keeping its config and URL"),
1522
+ ("start", cmd_start, "start a stopped app again"),
1523
+ ):
1524
+ p = sub.add_parser(verb, help=text)
1525
+ p.add_argument("name")
1526
+ p.set_defaults(func=func)
1527
+
1528
+ logs = sub.add_parser("logs", help="show an app's logs")
1529
+ logs.add_argument("name")
1530
+ logs.add_argument("-n", "--lines", type=int, default=50)
1531
+ logs.add_argument("-f", "--follow", action="store_true")
1532
+ logs.add_argument("--deploys", action="store_true",
1533
+ help="show the push-to-deploy log instead of the app's")
1534
+ logs.set_defaults(func=cmd_logs)
1535
+
1536
+ restart = sub.add_parser("restart", help="restart an app")
1537
+ restart.add_argument("name")
1538
+ restart.set_defaults(func=cmd_restart)
1539
+
1540
+ url = sub.add_parser("url", help="print an app's URL")
1541
+ url.add_argument("name")
1542
+ url.set_defaults(func=cmd_url)
1543
+
1544
+ qr_cmd = sub.add_parser("qr", help="print an app's QR code")
1545
+ qr_cmd.add_argument("name")
1546
+ qr_cmd.add_argument("--ascii", action="store_true")
1547
+ qr_cmd.set_defaults(func=cmd_qr)
1548
+
1549
+ adopt = sub.add_parser("adopt", help="expose an already-running port")
1550
+ adopt.add_argument("name")
1551
+ adopt.add_argument("--port", type=int, required=True)
1552
+ adopt.add_argument("--domain")
1553
+ adopt.add_argument("--hostname")
1554
+ add_qr_flags(adopt)
1555
+ adopt.set_defaults(func=cmd_adopt)
1556
+
1557
+ db = sub.add_parser("db", help="start a database: postgres, mysql, redis or mongo")
1558
+ db.add_argument("engine", choices=sorted(containers.ENGINES))
1559
+ db.add_argument("--name", help="app name (default: the engine)")
1560
+ db.add_argument("--port", type=int, help="host port on 127.0.0.1 (default: free)")
1561
+ db.add_argument("--image", help="override the image, e.g. postgres:17")
1562
+ db.set_defaults(func=cmd_db)
1563
+
1564
+ ex = sub.add_parser("exec", help="run one command in an app's environment")
1565
+ ex.add_argument("name")
1566
+ ex.add_argument("command", nargs=argparse.REMAINDER, help="the command (quote it, or put it after --)")
1567
+ ex.add_argument("--service", help="Compose service to run in (default: the first)")
1568
+ ex.add_argument("--timeout", type=int, default=600)
1569
+ ex.set_defaults(func=cmd_exec)
1570
+
1571
+ tpl = sub.add_parser("template", help="one-click apps: uptime-kuma, n8n, ntfy, excalidraw...")
1572
+ tpl.add_argument("id", nargs="?", help="template id, `list`, `add` or `remove`")
1573
+ tpl.add_argument("source", nargs="?", help="with add: a Compose file or URL; with remove: the template id")
1574
+ tpl.add_argument("--name", help="app name (default: the template id); with add: the new template's id")
1575
+ tpl.add_argument("--label", help="with add: a display name")
1576
+ tpl.add_argument("--about", help="with add: a one-line description")
1577
+ tpl.add_argument("--local", action="store_true", help="LAN URL only, no tunnel")
1578
+ tpl.add_argument("--domain")
1579
+ add_qr_flags(tpl)
1580
+ tpl.set_defaults(func=cmd_template)
1581
+
1582
+ bk = sub.add_parser("backup", help="back up, list or restore a database")
1583
+ bk.add_argument("name")
1584
+ bk.add_argument("--list", action="store_true", help="list backups")
1585
+ bk.add_argument("--restore", metavar="FILE", help="load this backup into the database")
1586
+ bk.add_argument("--daily", action="store_true", help="back up every day")
1587
+ bk.add_argument("--no-schedule", action="store_true", help="stop daily backups")
1588
+ bk.add_argument("--offsite", metavar="TARGET",
1589
+ help="also copy every backup to an rclone remote (gdrive:hoist) or a folder; 'off' stops it")
1590
+ bk.set_defaults(func=cmd_backup)
1591
+
1592
+ link = sub.add_parser("link", help="give an app a database's URL")
1593
+ link.add_argument("db")
1594
+ link.add_argument("app")
1595
+ link.add_argument("--var", help="variable name (default: DATABASE_URL, REDIS_URL...)")
1596
+ link.set_defaults(func=cmd_link)
1597
+
1598
+ hook = sub.add_parser("webhook", help="deploy within seconds of a push (GitHub/GitLab webhook)")
1599
+ hook.add_argument("name")
1600
+ hook.add_argument("--off", action="store_true")
1601
+ hook.set_defaults(func=cmd_webhook)
1602
+
1603
+ fr = sub.add_parser("front", help=argparse.SUPPRESS)
1604
+ fr.add_argument("name")
1605
+ fr.set_defaults(func=cmd_front)
1606
+
1607
+ stg = sub.add_parser("stage", help="label an app production, staging, development or preview")
1608
+ stg.add_argument("name")
1609
+ stg.add_argument("stage", metavar="STAGE", help="production, staging, development or preview")
1610
+ stg.set_defaults(func=cmd_stage)
1611
+
1612
+ prev = sub.add_parser("previews", help="give every branch its own preview app")
1613
+ prev.add_argument("name")
1614
+ prev.add_argument("state", choices=["on", "off"])
1615
+ prev.set_defaults(func=cmd_previews)
1616
+
1617
+ lim = sub.add_parser("limit", help="cap an app's memory or CPU")
1618
+ lim.add_argument("name")
1619
+ lim.add_argument("--memory", help="e.g. 512M or 2G; 'off' removes it")
1620
+ lim.add_argument("--cpu", help="e.g. 50%% of one core, 200%% for two; 'off' removes it")
1621
+ lim.set_defaults(func=cmd_limit)
1622
+
1623
+ note = sub.add_parser("notify", help="send deploy and downtime alerts to a webhook")
1624
+ note.add_argument("url", nargs="?", help="Discord, Slack, ntfy, Telegram or any webhook URL")
1625
+ note.add_argument("--test", action="store_true", help="send a test message")
1626
+ note.add_argument("--off", action="store_true", help="stop sending notifications")
1627
+ note.set_defaults(func=cmd_notify)
1628
+
1629
+ dash = sub.add_parser("dash", help="open the web dashboard")
1630
+ dash.add_argument("--port", type=int, default=DASH_PORT)
1631
+ dash.add_argument("--host", default="127.0.0.1", help="interface to bind (default 127.0.0.1)")
1632
+ dash.add_argument("--open", action="store_true", help="open it in the browser")
1633
+ dash.add_argument("--service", action="store_true", help="keep it running as a user service")
1634
+ dash.add_argument("--public", metavar="NAME", help="also publish it, e.g. --public dash")
1635
+ dash.add_argument("--stop", action="store_true", help="stop and remove the dashboard service")
1636
+ dash.add_argument("--reset-token", action="store_true", help="sign every browser out")
1637
+ dash.add_argument("--add-user", metavar="NAME", help="give a teammate their own sign-in link")
1638
+ dash.add_argument("--role", default="member", choices=["member", "viewer", "custom"],
1639
+ help="with --add-user: member (everything but people), viewer (read only) or custom")
1640
+ dash.add_argument("--can", metavar="LIST",
1641
+ help="with --role custom: comma list of create,deploy,operate,config,backups,delete,terminal,secrets")
1642
+ dash.add_argument("--apps", metavar="LIST", help="with --role custom: only these apps (default: all)")
1643
+ dash.add_argument("--stages", metavar="LIST",
1644
+ help="with --role custom: only apps in these environments (production,staging,development,preview)")
1645
+ dash.add_argument("--remove-user", metavar="NAME", help="revoke a teammate's link")
1646
+ dash.add_argument("--users", action="store_true", help="list who has access")
1647
+ dash.add_argument("--foreground", action="store_true", help=argparse.SUPPRESS)
1648
+ dash.set_defaults(func=cmd_dash)
1649
+
1650
+ doctor = sub.add_parser("doctor", help="check the local setup")
1651
+ doctor.set_defaults(func=cmd_doctor)
1652
+
1653
+ return parser
1654
+
1655
+
1656
+ def main(argv: list[str] | None = None) -> int:
1657
+ args = build_parser().parse_args(argv)
1658
+ try:
1659
+ return int(args.func(args))
1660
+ except UserError as exc:
1661
+ ui.fail(str(exc))
1662
+ return 1
1663
+ except (tunnel.TunnelError, service.ServiceError, detect.DetectionError,
1664
+ source.SourceError, config.StateError, containers.ContainerError) as exc:
1665
+ ui.fail(str(exc))
1666
+ return 1
1667
+ except KeyboardInterrupt:
1668
+ return 130
1669
+
1670
+
1671
+ if __name__ == "__main__":
1672
+ raise SystemExit(main())