remindrun 0.5.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.
- remindrun/__init__.py +1 -0
- remindrun/__main__.py +6 -0
- remindrun/cli.py +311 -0
- remindrun/server.py +115 -0
- remindrun/store.py +199 -0
- remindrun-0.5.0.dist-info/METADATA +134 -0
- remindrun-0.5.0.dist-info/RECORD +10 -0
- remindrun-0.5.0.dist-info/WHEEL +5 -0
- remindrun-0.5.0.dist-info/entry_points.txt +3 -0
- remindrun-0.5.0.dist-info/top_level.txt +1 -0
remindrun/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.5.0"
|
remindrun/__main__.py
ADDED
remindrun/cli.py
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import secrets
|
|
7
|
+
import shlex
|
|
8
|
+
import signal
|
|
9
|
+
import shutil
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
import threading
|
|
13
|
+
import time
|
|
14
|
+
import uuid
|
|
15
|
+
from collections.abc import Sequence
|
|
16
|
+
|
|
17
|
+
from .server import serve
|
|
18
|
+
from .store import RunStore
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
RESERVED_COMMANDS = {"run", "server", "status", "public", "ngrok"}
|
|
22
|
+
PUBLIC_URL_RE = re.compile(r"https://[a-zA-Z0-9.-]+\.trycloudflare\.com")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
26
|
+
args = list(sys.argv[1:] if argv is None else argv)
|
|
27
|
+
if not args:
|
|
28
|
+
print_help()
|
|
29
|
+
return 2
|
|
30
|
+
|
|
31
|
+
first = args[0]
|
|
32
|
+
if first in {"-h", "--help"}:
|
|
33
|
+
print_help()
|
|
34
|
+
return 0
|
|
35
|
+
if first == "server":
|
|
36
|
+
return server_command(args[1:])
|
|
37
|
+
if first == "public":
|
|
38
|
+
return public_command(args[1:])
|
|
39
|
+
if first == "ngrok":
|
|
40
|
+
return ngrok_command(args[1:])
|
|
41
|
+
if first == "status":
|
|
42
|
+
return status_command(args[1:])
|
|
43
|
+
if first == "run":
|
|
44
|
+
command = args[1:]
|
|
45
|
+
if command and command[0] == "--":
|
|
46
|
+
command = command[1:]
|
|
47
|
+
return run_command(command)
|
|
48
|
+
|
|
49
|
+
return run_command(args)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def server_command(argv: Sequence[str]) -> int:
|
|
53
|
+
parser = argparse.ArgumentParser(prog="remindrun server")
|
|
54
|
+
parser.add_argument("--host", default="127.0.0.1")
|
|
55
|
+
parser.add_argument("--port", type=int, default=8765)
|
|
56
|
+
parser.add_argument(
|
|
57
|
+
"--token",
|
|
58
|
+
default=None,
|
|
59
|
+
help="Bearer token required by /api/* endpoints. Can also use REMINDRUN_TOKEN.",
|
|
60
|
+
)
|
|
61
|
+
ns = parser.parse_args(argv)
|
|
62
|
+
serve(ns.host, ns.port, token=ns.token)
|
|
63
|
+
return 0
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def public_command(argv: Sequence[str]) -> int:
|
|
67
|
+
parser = argparse.ArgumentParser(prog="remindrun public")
|
|
68
|
+
parser.add_argument("--port", type=int, default=8765)
|
|
69
|
+
parser.add_argument("--host", default="127.0.0.1")
|
|
70
|
+
parser.add_argument("--token", default=None)
|
|
71
|
+
parser.add_argument("--cloudflared", default="cloudflared")
|
|
72
|
+
ns = parser.parse_args(argv)
|
|
73
|
+
|
|
74
|
+
cloudflared = shutil.which(ns.cloudflared) if ns.cloudflared == "cloudflared" else ns.cloudflared
|
|
75
|
+
if not cloudflared:
|
|
76
|
+
print(
|
|
77
|
+
"remindrun: cloudflared is required for public tunnels.\n"
|
|
78
|
+
"Install it with: brew install cloudflared\n"
|
|
79
|
+
"Linux: https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/",
|
|
80
|
+
file=sys.stderr,
|
|
81
|
+
)
|
|
82
|
+
return 127
|
|
83
|
+
|
|
84
|
+
token = ns.token or os.environ.get("REMINDRUN_TOKEN") or secrets.token_urlsafe(24)
|
|
85
|
+
local_url = f"http://{ns.host}:{ns.port}"
|
|
86
|
+
|
|
87
|
+
print("Starting Reminder local server...")
|
|
88
|
+
print(f"Local: {local_url}")
|
|
89
|
+
print(f"Token: {token}")
|
|
90
|
+
print("Starting Cloudflare quick tunnel...")
|
|
91
|
+
|
|
92
|
+
server_thread = threading.Thread(
|
|
93
|
+
target=serve,
|
|
94
|
+
args=(ns.host, ns.port),
|
|
95
|
+
kwargs={"token": token},
|
|
96
|
+
daemon=True,
|
|
97
|
+
)
|
|
98
|
+
server_thread.start()
|
|
99
|
+
time.sleep(0.5)
|
|
100
|
+
|
|
101
|
+
proc = subprocess.Popen(
|
|
102
|
+
[cloudflared, "tunnel", "--url", local_url],
|
|
103
|
+
stdout=subprocess.PIPE,
|
|
104
|
+
stderr=subprocess.STDOUT,
|
|
105
|
+
text=True,
|
|
106
|
+
bufsize=1,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
public_url_seen = threading.Event()
|
|
110
|
+
|
|
111
|
+
def stream_tunnel_output() -> None:
|
|
112
|
+
if proc.stdout is None:
|
|
113
|
+
return
|
|
114
|
+
for line in proc.stdout:
|
|
115
|
+
sys.stdout.write(line)
|
|
116
|
+
sys.stdout.flush()
|
|
117
|
+
match = PUBLIC_URL_RE.search(line)
|
|
118
|
+
if match and not public_url_seen.is_set():
|
|
119
|
+
public_url_seen.set()
|
|
120
|
+
print()
|
|
121
|
+
print("Use this in the Android app:")
|
|
122
|
+
print(f"Server: {match.group(0)}")
|
|
123
|
+
print(f"Token: {token}")
|
|
124
|
+
print()
|
|
125
|
+
|
|
126
|
+
output_thread = threading.Thread(target=stream_tunnel_output, daemon=True)
|
|
127
|
+
output_thread.start()
|
|
128
|
+
|
|
129
|
+
try:
|
|
130
|
+
return proc.wait()
|
|
131
|
+
except KeyboardInterrupt:
|
|
132
|
+
proc.terminate()
|
|
133
|
+
try:
|
|
134
|
+
proc.wait(timeout=5)
|
|
135
|
+
except subprocess.TimeoutExpired:
|
|
136
|
+
proc.kill()
|
|
137
|
+
return 130
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def ngrok_command(argv: Sequence[str]) -> int:
|
|
141
|
+
parser = argparse.ArgumentParser(prog="remindrun ngrok")
|
|
142
|
+
parser.add_argument("--domain", required=True, help="Your fixed ngrok dev domain, like abc.ngrok-free.dev.")
|
|
143
|
+
parser.add_argument("--port", type=int, default=8765)
|
|
144
|
+
parser.add_argument("--host", default="127.0.0.1")
|
|
145
|
+
parser.add_argument("--token", default=None)
|
|
146
|
+
parser.add_argument("--ngrok", default="ngrok")
|
|
147
|
+
ns = parser.parse_args(argv)
|
|
148
|
+
|
|
149
|
+
ngrok = shutil.which(ns.ngrok) if ns.ngrok == "ngrok" else ns.ngrok
|
|
150
|
+
if not ngrok:
|
|
151
|
+
print(
|
|
152
|
+
"remindrun: ngrok is required for fixed free dev domains.\n"
|
|
153
|
+
"Install it with: brew install ngrok/ngrok/ngrok\n"
|
|
154
|
+
"Then run: ngrok config add-authtoken <YOUR_NGROK_AUTHTOKEN>",
|
|
155
|
+
file=sys.stderr,
|
|
156
|
+
)
|
|
157
|
+
return 127
|
|
158
|
+
|
|
159
|
+
token = ns.token or os.environ.get("REMINDRUN_TOKEN") or secrets.token_urlsafe(24)
|
|
160
|
+
local_url = f"http://{ns.host}:{ns.port}"
|
|
161
|
+
public_url = f"https://{ns.domain.removeprefix('https://').removeprefix('http://').rstrip('/')}"
|
|
162
|
+
|
|
163
|
+
print("Starting Reminder local server...")
|
|
164
|
+
print(f"Local: {local_url}")
|
|
165
|
+
print(f"Server: {public_url}")
|
|
166
|
+
print(f"Token: {token}")
|
|
167
|
+
print("Starting ngrok fixed dev domain tunnel...")
|
|
168
|
+
|
|
169
|
+
server_thread = threading.Thread(
|
|
170
|
+
target=serve,
|
|
171
|
+
args=(ns.host, ns.port),
|
|
172
|
+
kwargs={"token": token},
|
|
173
|
+
daemon=True,
|
|
174
|
+
)
|
|
175
|
+
server_thread.start()
|
|
176
|
+
time.sleep(0.5)
|
|
177
|
+
|
|
178
|
+
proc = subprocess.Popen(
|
|
179
|
+
[ngrok, "http", str(ns.port), "--url", ns.domain.removeprefix("https://").removeprefix("http://").rstrip("/")],
|
|
180
|
+
stdout=subprocess.PIPE,
|
|
181
|
+
stderr=subprocess.STDOUT,
|
|
182
|
+
text=True,
|
|
183
|
+
bufsize=1,
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
print()
|
|
187
|
+
print("Use this in the Android app:")
|
|
188
|
+
print(f"Server: {public_url}")
|
|
189
|
+
print(f"Token: {token}")
|
|
190
|
+
print()
|
|
191
|
+
|
|
192
|
+
try:
|
|
193
|
+
if proc.stdout is not None:
|
|
194
|
+
for line in proc.stdout:
|
|
195
|
+
sys.stdout.write(line)
|
|
196
|
+
sys.stdout.flush()
|
|
197
|
+
return proc.wait()
|
|
198
|
+
except KeyboardInterrupt:
|
|
199
|
+
proc.terminate()
|
|
200
|
+
try:
|
|
201
|
+
proc.wait(timeout=5)
|
|
202
|
+
except subprocess.TimeoutExpired:
|
|
203
|
+
proc.kill()
|
|
204
|
+
return 130
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def status_command(argv: Sequence[str]) -> int:
|
|
208
|
+
parser = argparse.ArgumentParser(prog="remindrun status")
|
|
209
|
+
parser.add_argument("--limit", type=int, default=10)
|
|
210
|
+
ns = parser.parse_args(argv)
|
|
211
|
+
runs = RunStore().list_runs(limit=ns.limit)
|
|
212
|
+
if not runs:
|
|
213
|
+
print("No runs yet.")
|
|
214
|
+
return 0
|
|
215
|
+
for run in runs:
|
|
216
|
+
exit_code = "" if run.exit_code is None else f" exit={run.exit_code}"
|
|
217
|
+
print(f"{run.id[:8]} {run.status:9} {run.started_at} {run.commandText}{exit_code}")
|
|
218
|
+
return 0
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def run_command(command: Sequence[str]) -> int:
|
|
222
|
+
if not command:
|
|
223
|
+
print("remindrun: missing command to run", file=sys.stderr)
|
|
224
|
+
return 2
|
|
225
|
+
|
|
226
|
+
run_id = str(uuid.uuid4())
|
|
227
|
+
store = RunStore()
|
|
228
|
+
store.create_run(run_id=run_id, command=list(command), cwd=os.getcwd())
|
|
229
|
+
|
|
230
|
+
print(f"Reminder run: {run_id}")
|
|
231
|
+
print(f"Command: {shlex.join(command)}")
|
|
232
|
+
|
|
233
|
+
try:
|
|
234
|
+
proc = subprocess.Popen(
|
|
235
|
+
list(command),
|
|
236
|
+
stdout=subprocess.PIPE,
|
|
237
|
+
stderr=subprocess.PIPE,
|
|
238
|
+
text=True,
|
|
239
|
+
bufsize=1,
|
|
240
|
+
)
|
|
241
|
+
except FileNotFoundError:
|
|
242
|
+
store.append_output(run_id, "stderr_tail", f"command not found: {command[0]}\n")
|
|
243
|
+
store.finish_run(run_id, 127)
|
|
244
|
+
print(f"remindrun: command not found: {command[0]}", file=sys.stderr)
|
|
245
|
+
return 127
|
|
246
|
+
|
|
247
|
+
store.mark_running(run_id, proc.pid)
|
|
248
|
+
|
|
249
|
+
stop_forwarding = threading.Event()
|
|
250
|
+
|
|
251
|
+
def forward_signal(signum: int, _frame: object) -> None:
|
|
252
|
+
if proc.poll() is None:
|
|
253
|
+
proc.send_signal(signum)
|
|
254
|
+
|
|
255
|
+
previous_sigint = signal.signal(signal.SIGINT, forward_signal)
|
|
256
|
+
previous_sigterm = signal.signal(signal.SIGTERM, forward_signal)
|
|
257
|
+
|
|
258
|
+
try:
|
|
259
|
+
threads = [
|
|
260
|
+
threading.Thread(
|
|
261
|
+
target=stream_output,
|
|
262
|
+
args=(proc.stdout, sys.stdout, store, run_id, "stdout_tail", stop_forwarding),
|
|
263
|
+
daemon=True,
|
|
264
|
+
),
|
|
265
|
+
threading.Thread(
|
|
266
|
+
target=stream_output,
|
|
267
|
+
args=(proc.stderr, sys.stderr, store, run_id, "stderr_tail", stop_forwarding),
|
|
268
|
+
daemon=True,
|
|
269
|
+
),
|
|
270
|
+
]
|
|
271
|
+
for thread in threads:
|
|
272
|
+
thread.start()
|
|
273
|
+
|
|
274
|
+
exit_code = proc.wait()
|
|
275
|
+
stop_forwarding.set()
|
|
276
|
+
for thread in threads:
|
|
277
|
+
thread.join(timeout=2)
|
|
278
|
+
finally:
|
|
279
|
+
signal.signal(signal.SIGINT, previous_sigint)
|
|
280
|
+
signal.signal(signal.SIGTERM, previous_sigterm)
|
|
281
|
+
|
|
282
|
+
store.finish_run(run_id, exit_code)
|
|
283
|
+
print(f"Reminder finished: {run_id} exit={exit_code}")
|
|
284
|
+
return exit_code
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def stream_output(pipe, target, store: RunStore, run_id: str, stream_name: str, stop: threading.Event) -> None:
|
|
288
|
+
if pipe is None:
|
|
289
|
+
return
|
|
290
|
+
while not stop.is_set():
|
|
291
|
+
chunk = pipe.readline()
|
|
292
|
+
if chunk == "":
|
|
293
|
+
break
|
|
294
|
+
target.write(chunk)
|
|
295
|
+
target.flush()
|
|
296
|
+
store.append_output(run_id, stream_name, chunk)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def print_help() -> None:
|
|
300
|
+
print(
|
|
301
|
+
"""Reminder command runner
|
|
302
|
+
|
|
303
|
+
Usage:
|
|
304
|
+
remindrun server [--host 0.0.0.0] [--port 8765] [--token TOKEN]
|
|
305
|
+
remindrun public [--port 8765] [--token TOKEN]
|
|
306
|
+
remindrun ngrok --domain YOUR_DOMAIN.ngrok-free.dev [--token TOKEN]
|
|
307
|
+
remindrun status [--limit 10]
|
|
308
|
+
remindrun run -- <command> [args...]
|
|
309
|
+
remindrun <command> [args...]
|
|
310
|
+
"""
|
|
311
|
+
)
|
remindrun/server.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import secrets
|
|
6
|
+
from http import HTTPStatus
|
|
7
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
8
|
+
from urllib.parse import parse_qs, urlparse
|
|
9
|
+
|
|
10
|
+
from .store import RunStore
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ReminderServer(ThreadingHTTPServer):
|
|
14
|
+
def __init__(self, server_address: tuple[str, int], store: RunStore, token: str | None = None) -> None:
|
|
15
|
+
super().__init__(server_address, ReminderRequestHandler)
|
|
16
|
+
self.store = store
|
|
17
|
+
self.token = token
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ReminderRequestHandler(BaseHTTPRequestHandler):
|
|
21
|
+
server: ReminderServer
|
|
22
|
+
|
|
23
|
+
def do_GET(self) -> None:
|
|
24
|
+
parsed = urlparse(self.path)
|
|
25
|
+
if parsed.path == "/health":
|
|
26
|
+
self.send_json({"ok": True})
|
|
27
|
+
return
|
|
28
|
+
|
|
29
|
+
if parsed.path.startswith("/api/") and not self.is_authorized():
|
|
30
|
+
self.send_json({"error": "unauthorized"}, status=HTTPStatus.UNAUTHORIZED)
|
|
31
|
+
return
|
|
32
|
+
|
|
33
|
+
if parsed.path == "/api/runs":
|
|
34
|
+
query = parse_qs(parsed.query)
|
|
35
|
+
limit = parse_limit(query.get("limit", ["100"])[0])
|
|
36
|
+
runs = [run.to_dict() for run in self.server.store.list_runs(limit=limit)]
|
|
37
|
+
self.send_json({"runs": runs})
|
|
38
|
+
return
|
|
39
|
+
|
|
40
|
+
if parsed.path.startswith("/api/runs/"):
|
|
41
|
+
run_id = parsed.path.removeprefix("/api/runs/")
|
|
42
|
+
run = self.server.store.get_run(run_id)
|
|
43
|
+
if run is None:
|
|
44
|
+
self.send_json({"error": "run not found"}, status=HTTPStatus.NOT_FOUND)
|
|
45
|
+
return
|
|
46
|
+
self.send_json({"run": run.to_dict()})
|
|
47
|
+
return
|
|
48
|
+
|
|
49
|
+
if parsed.path == "/api/events":
|
|
50
|
+
query = parse_qs(parsed.query)
|
|
51
|
+
since = query.get("since", [None])[0]
|
|
52
|
+
limit = parse_limit(query.get("limit", ["100"])[0])
|
|
53
|
+
runs = [
|
|
54
|
+
run.to_dict()
|
|
55
|
+
for run in self.server.store.list_updated_since(since=since, limit=limit)
|
|
56
|
+
]
|
|
57
|
+
latest = max((run["updatedAt"] for run in runs), default=since)
|
|
58
|
+
self.send_json({"events": runs, "latest": latest})
|
|
59
|
+
return
|
|
60
|
+
|
|
61
|
+
self.send_json({"error": "not found"}, status=HTTPStatus.NOT_FOUND)
|
|
62
|
+
|
|
63
|
+
def do_OPTIONS(self) -> None:
|
|
64
|
+
self.send_response(HTTPStatus.NO_CONTENT)
|
|
65
|
+
self.send_cors_headers()
|
|
66
|
+
self.end_headers()
|
|
67
|
+
|
|
68
|
+
def send_json(self, payload: dict, status: HTTPStatus = HTTPStatus.OK) -> None:
|
|
69
|
+
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
|
70
|
+
self.send_response(status)
|
|
71
|
+
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
72
|
+
self.send_header("Content-Length", str(len(body)))
|
|
73
|
+
self.send_cors_headers()
|
|
74
|
+
self.end_headers()
|
|
75
|
+
self.wfile.write(body)
|
|
76
|
+
|
|
77
|
+
def send_cors_headers(self) -> None:
|
|
78
|
+
self.send_header("Access-Control-Allow-Origin", "*")
|
|
79
|
+
self.send_header("Access-Control-Allow-Methods", "GET, OPTIONS")
|
|
80
|
+
self.send_header("Access-Control-Allow-Headers", "Authorization, Content-Type, X-Reminder-Token")
|
|
81
|
+
|
|
82
|
+
def is_authorized(self) -> bool:
|
|
83
|
+
token = self.server.token
|
|
84
|
+
if not token:
|
|
85
|
+
return True
|
|
86
|
+
|
|
87
|
+
auth = self.headers.get("Authorization", "")
|
|
88
|
+
if auth.startswith("Bearer "):
|
|
89
|
+
return secrets.compare_digest(auth.removeprefix("Bearer ").strip(), token)
|
|
90
|
+
|
|
91
|
+
header_token = self.headers.get("X-Reminder-Token", "")
|
|
92
|
+
return bool(header_token) and secrets.compare_digest(header_token, token)
|
|
93
|
+
|
|
94
|
+
def log_message(self, fmt: str, *args: object) -> None:
|
|
95
|
+
print(f"[remindrun server] {self.address_string()} - {fmt % args}")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def parse_limit(raw: str) -> int:
|
|
99
|
+
try:
|
|
100
|
+
return max(1, min(int(raw), 500))
|
|
101
|
+
except ValueError:
|
|
102
|
+
return 100
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def serve(host: str, port: int, store: RunStore | None = None, token: str | None = None) -> None:
|
|
106
|
+
actual_store = store or RunStore()
|
|
107
|
+
actual_token = token or os.environ.get("REMINDRUN_TOKEN")
|
|
108
|
+
httpd = ReminderServer((host, port), actual_store, actual_token)
|
|
109
|
+
print(f"Reminder server listening on http://{host}:{port}")
|
|
110
|
+
print(f"Database: {actual_store.db_path}")
|
|
111
|
+
if actual_token:
|
|
112
|
+
print("Auth: enabled")
|
|
113
|
+
elif host in {"0.0.0.0", "::"}:
|
|
114
|
+
print("WARNING: server is listening publicly without a token.")
|
|
115
|
+
httpd.serve_forever()
|
remindrun/store.py
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import shlex
|
|
6
|
+
import sqlite3
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from datetime import UTC, datetime
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
TERMINAL_STATUSES = {"succeeded", "failed", "cancelled"}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def utc_now() -> str:
|
|
17
|
+
return datetime.now(UTC).isoformat(timespec="seconds")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def default_data_dir() -> Path:
|
|
21
|
+
configured = os.environ.get("REMINDER_HOME")
|
|
22
|
+
if configured:
|
|
23
|
+
return Path(configured).expanduser()
|
|
24
|
+
return Path.home() / ".reminder"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def default_db_path() -> Path:
|
|
28
|
+
return default_data_dir() / "reminder.db"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class RunRecord:
|
|
33
|
+
id: str
|
|
34
|
+
command: list[str]
|
|
35
|
+
cwd: str
|
|
36
|
+
status: str
|
|
37
|
+
pid: int | None
|
|
38
|
+
exit_code: int | None
|
|
39
|
+
started_at: str
|
|
40
|
+
ended_at: str | None
|
|
41
|
+
updated_at: str
|
|
42
|
+
stdout_tail: str
|
|
43
|
+
stderr_tail: str
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def commandText(self) -> str:
|
|
47
|
+
return shlex.join(self.command)
|
|
48
|
+
|
|
49
|
+
@classmethod
|
|
50
|
+
def from_row(cls, row: sqlite3.Row) -> "RunRecord":
|
|
51
|
+
return cls(
|
|
52
|
+
id=row["id"],
|
|
53
|
+
command=json.loads(row["command"]),
|
|
54
|
+
cwd=row["cwd"],
|
|
55
|
+
status=row["status"],
|
|
56
|
+
pid=row["pid"],
|
|
57
|
+
exit_code=row["exit_code"],
|
|
58
|
+
started_at=row["started_at"],
|
|
59
|
+
ended_at=row["ended_at"],
|
|
60
|
+
updated_at=row["updated_at"],
|
|
61
|
+
stdout_tail=row["stdout_tail"] or "",
|
|
62
|
+
stderr_tail=row["stderr_tail"] or "",
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
def to_dict(self) -> dict[str, Any]:
|
|
66
|
+
return {
|
|
67
|
+
"id": self.id,
|
|
68
|
+
"command": self.command,
|
|
69
|
+
"commandText": self.commandText,
|
|
70
|
+
"cwd": self.cwd,
|
|
71
|
+
"status": self.status,
|
|
72
|
+
"pid": self.pid,
|
|
73
|
+
"exitCode": self.exit_code,
|
|
74
|
+
"startedAt": self.started_at,
|
|
75
|
+
"endedAt": self.ended_at,
|
|
76
|
+
"updatedAt": self.updated_at,
|
|
77
|
+
"stdoutTail": self.stdout_tail,
|
|
78
|
+
"stderrTail": self.stderr_tail,
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class RunStore:
|
|
83
|
+
def __init__(self, db_path: Path | str | None = None) -> None:
|
|
84
|
+
self.db_path = Path(db_path) if db_path else default_db_path()
|
|
85
|
+
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
86
|
+
self.init_db()
|
|
87
|
+
|
|
88
|
+
def connect(self) -> sqlite3.Connection:
|
|
89
|
+
conn = sqlite3.connect(self.db_path)
|
|
90
|
+
conn.row_factory = sqlite3.Row
|
|
91
|
+
return conn
|
|
92
|
+
|
|
93
|
+
def init_db(self) -> None:
|
|
94
|
+
with self.connect() as conn:
|
|
95
|
+
conn.execute(
|
|
96
|
+
"""
|
|
97
|
+
CREATE TABLE IF NOT EXISTS runs (
|
|
98
|
+
id TEXT PRIMARY KEY,
|
|
99
|
+
command TEXT NOT NULL,
|
|
100
|
+
cwd TEXT NOT NULL,
|
|
101
|
+
status TEXT NOT NULL,
|
|
102
|
+
pid INTEGER,
|
|
103
|
+
exit_code INTEGER,
|
|
104
|
+
started_at TEXT NOT NULL,
|
|
105
|
+
ended_at TEXT,
|
|
106
|
+
updated_at TEXT NOT NULL,
|
|
107
|
+
stdout_tail TEXT NOT NULL DEFAULT '',
|
|
108
|
+
stderr_tail TEXT NOT NULL DEFAULT ''
|
|
109
|
+
)
|
|
110
|
+
"""
|
|
111
|
+
)
|
|
112
|
+
conn.execute(
|
|
113
|
+
"CREATE INDEX IF NOT EXISTS idx_runs_updated_at ON runs(updated_at DESC)"
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
def create_run(self, run_id: str, command: list[str], cwd: str) -> None:
|
|
117
|
+
now = utc_now()
|
|
118
|
+
with self.connect() as conn:
|
|
119
|
+
conn.execute(
|
|
120
|
+
"""
|
|
121
|
+
INSERT INTO runs (
|
|
122
|
+
id, command, cwd, status, pid, exit_code, started_at,
|
|
123
|
+
ended_at, updated_at, stdout_tail, stderr_tail
|
|
124
|
+
) VALUES (?, ?, ?, 'created', NULL, NULL, ?, NULL, ?, '', '')
|
|
125
|
+
""",
|
|
126
|
+
(run_id, json.dumps(command), cwd, now, now),
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
def mark_running(self, run_id: str, pid: int) -> None:
|
|
130
|
+
self._update(run_id, {"status": "running", "pid": pid})
|
|
131
|
+
|
|
132
|
+
def append_output(self, run_id: str, stream: str, text: str, max_chars: int = 12000) -> None:
|
|
133
|
+
if stream not in {"stdout_tail", "stderr_tail"}:
|
|
134
|
+
raise ValueError(f"unsupported stream: {stream}")
|
|
135
|
+
with self.connect() as conn:
|
|
136
|
+
row = conn.execute(
|
|
137
|
+
f"SELECT {stream} FROM runs WHERE id = ?", (run_id,)
|
|
138
|
+
).fetchone()
|
|
139
|
+
if row is None:
|
|
140
|
+
return
|
|
141
|
+
updated = ((row[stream] or "") + text)[-max_chars:]
|
|
142
|
+
conn.execute(
|
|
143
|
+
f"UPDATE runs SET {stream} = ?, updated_at = ? WHERE id = ?",
|
|
144
|
+
(updated, utc_now(), run_id),
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
def finish_run(self, run_id: str, exit_code: int) -> None:
|
|
148
|
+
status = "succeeded" if exit_code == 0 else "failed"
|
|
149
|
+
now = utc_now()
|
|
150
|
+
with self.connect() as conn:
|
|
151
|
+
conn.execute(
|
|
152
|
+
"""
|
|
153
|
+
UPDATE runs
|
|
154
|
+
SET status = ?, exit_code = ?, ended_at = ?, updated_at = ?
|
|
155
|
+
WHERE id = ?
|
|
156
|
+
""",
|
|
157
|
+
(status, exit_code, now, now, run_id),
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
def get_run(self, run_id: str) -> RunRecord | None:
|
|
161
|
+
with self.connect() as conn:
|
|
162
|
+
row = conn.execute("SELECT * FROM runs WHERE id = ?", (run_id,)).fetchone()
|
|
163
|
+
return RunRecord.from_row(row) if row else None
|
|
164
|
+
|
|
165
|
+
def list_runs(self, limit: int = 100) -> list[RunRecord]:
|
|
166
|
+
with self.connect() as conn:
|
|
167
|
+
rows = conn.execute(
|
|
168
|
+
"SELECT * FROM runs ORDER BY started_at DESC LIMIT ?",
|
|
169
|
+
(limit,),
|
|
170
|
+
).fetchall()
|
|
171
|
+
return [RunRecord.from_row(row) for row in rows]
|
|
172
|
+
|
|
173
|
+
def list_updated_since(self, since: str | None, limit: int = 100) -> list[RunRecord]:
|
|
174
|
+
with self.connect() as conn:
|
|
175
|
+
if since:
|
|
176
|
+
rows = conn.execute(
|
|
177
|
+
"""
|
|
178
|
+
SELECT * FROM runs
|
|
179
|
+
WHERE updated_at > ?
|
|
180
|
+
ORDER BY updated_at ASC
|
|
181
|
+
LIMIT ?
|
|
182
|
+
""",
|
|
183
|
+
(since, limit),
|
|
184
|
+
).fetchall()
|
|
185
|
+
else:
|
|
186
|
+
rows = conn.execute(
|
|
187
|
+
"SELECT * FROM runs ORDER BY updated_at ASC LIMIT ?",
|
|
188
|
+
(limit,),
|
|
189
|
+
).fetchall()
|
|
190
|
+
return [RunRecord.from_row(row) for row in rows]
|
|
191
|
+
|
|
192
|
+
def _update(self, run_id: str, fields: dict[str, Any]) -> None:
|
|
193
|
+
if not fields:
|
|
194
|
+
return
|
|
195
|
+
fields["updated_at"] = utc_now()
|
|
196
|
+
assignments = ", ".join(f"{name} = ?" for name in fields)
|
|
197
|
+
values = list(fields.values()) + [run_id]
|
|
198
|
+
with self.connect() as conn:
|
|
199
|
+
conn.execute(f"UPDATE runs SET {assignments} WHERE id = ?", values)
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: remindrun
|
|
3
|
+
Version: 0.5.0
|
|
4
|
+
Summary: Run commands with Reminder monitoring and expose their status to the Android app.
|
|
5
|
+
Author: Reminder
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
|
|
9
|
+
# Reminder
|
|
10
|
+
|
|
11
|
+
Reminder is a minimal prototype for monitoring Linux command runs from an Android app.
|
|
12
|
+
|
|
13
|
+
The first version has two parts:
|
|
14
|
+
|
|
15
|
+
- `remindrun`: a Python command wrapper. Put `remindrun` before a command to record its status, output tail, exit code, and timestamps.
|
|
16
|
+
- Android app: a small native app that polls the `remindrun` HTTP API, lists runs, and posts a local notification when a run changes from running to finished.
|
|
17
|
+
|
|
18
|
+
## Python monitor
|
|
19
|
+
|
|
20
|
+
Install the local package in a virtual environment:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
python3 -m venv .venv
|
|
24
|
+
.venv/bin/python -m pip install -e .
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Or install the built wheel:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
.venv/bin/python -m pip install dist/remindrun-0.5.0-py3-none-any.whl
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Start the status server on the Linux machine:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
remindrun server --host 0.0.0.0 --port 8765 --token change-this-token
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Run commands through Reminder:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
remindrun sleep 5
|
|
43
|
+
remindrun run -- bash -lc 'echo hello && sleep 2 && echo done'
|
|
44
|
+
remindrun status
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
You can use the shorter `rrun` command for the same actions:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
rrun sleep 5
|
|
51
|
+
rrun ngrok --domain <YOUR_DEV_DOMAIN>.ngrok-free.dev
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
The server exposes:
|
|
55
|
+
|
|
56
|
+
- `GET /health`
|
|
57
|
+
- `GET /api/runs?limit=50`
|
|
58
|
+
- `GET /api/runs/{id}`
|
|
59
|
+
- `GET /api/events?since=<updatedAt>`
|
|
60
|
+
|
|
61
|
+
By default the SQLite database lives at `~/.reminder/reminder.db`. Set `REMINDER_HOME=/path/to/dir` to change that location.
|
|
62
|
+
|
|
63
|
+
## Public access
|
|
64
|
+
|
|
65
|
+
If you do not have a public IP, use a Cloudflare quick tunnel:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
remindrun public
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
This starts the local server and runs `cloudflared tunnel --url http://127.0.0.1:8765`. It prints a public `https://*.trycloudflare.com` URL and a token. Enter both in the Android app:
|
|
72
|
+
|
|
73
|
+
```text
|
|
74
|
+
Server: https://example.trycloudflare.com
|
|
75
|
+
Token: generated-token
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
If `cloudflared` is missing, install it first:
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
brew install cloudflared
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Cloudflare quick tunnel URLs are random. If you need the same URL every time and do not own a domain, use ngrok's free Dev Domain:
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
brew install ngrok/ngrok/ngrok
|
|
88
|
+
ngrok config add-authtoken <YOUR_NGROK_AUTHTOKEN>
|
|
89
|
+
remindrun ngrok --domain <YOUR_DEV_DOMAIN>.ngrok-free.dev
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
In the Android app:
|
|
93
|
+
|
|
94
|
+
```text
|
|
95
|
+
Server: https://<YOUR_DEV_DOMAIN>.ngrok-free.dev
|
|
96
|
+
Token: generated-token
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
If the Android app needs to connect to a server that already has a public IP or a public domain:
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
remindrun server --host 0.0.0.0 --port 8765 --token a-long-random-token
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Then open TCP port `8765` in the cloud security group or firewall.
|
|
106
|
+
|
|
107
|
+
In the Android app:
|
|
108
|
+
|
|
109
|
+
```text
|
|
110
|
+
Server: http://<public-ip>:8765
|
|
111
|
+
Token: a-long-random-token
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
For real public use, prefer HTTPS through a reverse proxy or tunnel. Plain `http://<public-ip>:8765` works for testing, but the token can be observed on an untrusted network.
|
|
115
|
+
|
|
116
|
+
## Android app
|
|
117
|
+
|
|
118
|
+
Open the `android/` directory in Android Studio.
|
|
119
|
+
|
|
120
|
+
For an emulator, the default server URL is:
|
|
121
|
+
|
|
122
|
+
```text
|
|
123
|
+
http://10.0.2.2:8765
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
For a physical phone, start the server with `--host 0.0.0.0`, put the phone on the same network, then set the app server URL to:
|
|
127
|
+
|
|
128
|
+
```text
|
|
129
|
+
http://<linux-machine-ip>:8765
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
If the server was started with `--token`, enter the same token in the app's Token field.
|
|
133
|
+
|
|
134
|
+
This prototype polls every 5 seconds while the app process is alive. A later version should move polling into a foreground service or push channel if you want reliable notifications while the app is fully backgrounded.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
remindrun/__init__.py,sha256=LBK46heutvn3KmsCrKIYu8RQikbfnjZaj2xFrXaeCzQ,22
|
|
2
|
+
remindrun/__main__.py,sha256=LN0j1PXNbDs-TLaQrRbVYGN3Z7nKWyo88Sn8wKXryCs,81
|
|
3
|
+
remindrun/cli.py,sha256=oT5td9mVW_vLLPmFW2PZolkcpitSccRRdDsP6ZmVBbY,9577
|
|
4
|
+
remindrun/server.py,sha256=lTHxFvnrL8ja-rF0Ju8Nnx8AEV3z5Sd-g3gS6yooKPw,4319
|
|
5
|
+
remindrun/store.py,sha256=E2df2I5fjOUtCKgStp3iuQ50JvzyCzIrptdGkerokXo,6634
|
|
6
|
+
remindrun-0.5.0.dist-info/METADATA,sha256=-8MQ6w2OTSGwUabuhFaNWRR-kV8yQxinQQMDdUFJgHk,3581
|
|
7
|
+
remindrun-0.5.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
8
|
+
remindrun-0.5.0.dist-info/entry_points.txt,sha256=Aymtm8kvPohc6V9eblKgb4ZA9Jj0STWMRiPawwVmQV0,75
|
|
9
|
+
remindrun-0.5.0.dist-info/top_level.txt,sha256=pssUgdOhg2jQ7fYIEEnfcFnSzDISTHnXdZb71LKYnNU,10
|
|
10
|
+
remindrun-0.5.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
remindrun
|