remindrun 0.5.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,126 @@
1
+ # Reminder
2
+
3
+ Reminder is a minimal prototype for monitoring Linux command runs from an Android app.
4
+
5
+ The first version has two parts:
6
+
7
+ - `remindrun`: a Python command wrapper. Put `remindrun` before a command to record its status, output tail, exit code, and timestamps.
8
+ - 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.
9
+
10
+ ## Python monitor
11
+
12
+ Install the local package in a virtual environment:
13
+
14
+ ```bash
15
+ python3 -m venv .venv
16
+ .venv/bin/python -m pip install -e .
17
+ ```
18
+
19
+ Or install the built wheel:
20
+
21
+ ```bash
22
+ .venv/bin/python -m pip install dist/remindrun-0.5.0-py3-none-any.whl
23
+ ```
24
+
25
+ Start the status server on the Linux machine:
26
+
27
+ ```bash
28
+ remindrun server --host 0.0.0.0 --port 8765 --token change-this-token
29
+ ```
30
+
31
+ Run commands through Reminder:
32
+
33
+ ```bash
34
+ remindrun sleep 5
35
+ remindrun run -- bash -lc 'echo hello && sleep 2 && echo done'
36
+ remindrun status
37
+ ```
38
+
39
+ You can use the shorter `rrun` command for the same actions:
40
+
41
+ ```bash
42
+ rrun sleep 5
43
+ rrun ngrok --domain <YOUR_DEV_DOMAIN>.ngrok-free.dev
44
+ ```
45
+
46
+ The server exposes:
47
+
48
+ - `GET /health`
49
+ - `GET /api/runs?limit=50`
50
+ - `GET /api/runs/{id}`
51
+ - `GET /api/events?since=<updatedAt>`
52
+
53
+ By default the SQLite database lives at `~/.reminder/reminder.db`. Set `REMINDER_HOME=/path/to/dir` to change that location.
54
+
55
+ ## Public access
56
+
57
+ If you do not have a public IP, use a Cloudflare quick tunnel:
58
+
59
+ ```bash
60
+ remindrun public
61
+ ```
62
+
63
+ 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:
64
+
65
+ ```text
66
+ Server: https://example.trycloudflare.com
67
+ Token: generated-token
68
+ ```
69
+
70
+ If `cloudflared` is missing, install it first:
71
+
72
+ ```bash
73
+ brew install cloudflared
74
+ ```
75
+
76
+ 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:
77
+
78
+ ```bash
79
+ brew install ngrok/ngrok/ngrok
80
+ ngrok config add-authtoken <YOUR_NGROK_AUTHTOKEN>
81
+ remindrun ngrok --domain <YOUR_DEV_DOMAIN>.ngrok-free.dev
82
+ ```
83
+
84
+ In the Android app:
85
+
86
+ ```text
87
+ Server: https://<YOUR_DEV_DOMAIN>.ngrok-free.dev
88
+ Token: generated-token
89
+ ```
90
+
91
+ If the Android app needs to connect to a server that already has a public IP or a public domain:
92
+
93
+ ```bash
94
+ remindrun server --host 0.0.0.0 --port 8765 --token a-long-random-token
95
+ ```
96
+
97
+ Then open TCP port `8765` in the cloud security group or firewall.
98
+
99
+ In the Android app:
100
+
101
+ ```text
102
+ Server: http://<public-ip>:8765
103
+ Token: a-long-random-token
104
+ ```
105
+
106
+ 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.
107
+
108
+ ## Android app
109
+
110
+ Open the `android/` directory in Android Studio.
111
+
112
+ For an emulator, the default server URL is:
113
+
114
+ ```text
115
+ http://10.0.2.2:8765
116
+ ```
117
+
118
+ 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:
119
+
120
+ ```text
121
+ http://<linux-machine-ip>:8765
122
+ ```
123
+
124
+ If the server was started with `--token`, enter the same token in the app's Token field.
125
+
126
+ 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,19 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "remindrun"
7
+ version = "0.5.0"
8
+ description = "Run commands with Reminder monitoring and expose their status to the Android app."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ authors = [{ name = "Reminder" }]
12
+ dependencies = []
13
+
14
+ [project.scripts]
15
+ remindrun = "remindrun.cli:main"
16
+ rrun = "remindrun.cli:main"
17
+
18
+ [tool.setuptools.packages.find]
19
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1 @@
1
+ __version__ = "0.5.0"
@@ -0,0 +1,6 @@
1
+ from .cli import main
2
+
3
+
4
+ if __name__ == "__main__":
5
+ raise SystemExit(main())
6
+
@@ -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
+ )
@@ -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()
@@ -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,13 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/remindrun/__init__.py
4
+ src/remindrun/__main__.py
5
+ src/remindrun/cli.py
6
+ src/remindrun/server.py
7
+ src/remindrun/store.py
8
+ src/remindrun.egg-info/PKG-INFO
9
+ src/remindrun.egg-info/SOURCES.txt
10
+ src/remindrun.egg-info/dependency_links.txt
11
+ src/remindrun.egg-info/entry_points.txt
12
+ src/remindrun.egg-info/top_level.txt
13
+ tests/test_store.py
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ remindrun = remindrun.cli:main
3
+ rrun = remindrun.cli:main
@@ -0,0 +1 @@
1
+ remindrun
@@ -0,0 +1,41 @@
1
+ import tempfile
2
+ import unittest
3
+ from pathlib import Path
4
+
5
+ from remindrun.store import RunStore
6
+
7
+
8
+ class RunStoreTest(unittest.TestCase):
9
+ def test_run_lifecycle(self):
10
+ with tempfile.TemporaryDirectory() as tmp:
11
+ store = RunStore(Path(tmp) / "runs.db")
12
+
13
+ store.create_run("run-1", ["echo", "hello"], "/tmp")
14
+ store.mark_running("run-1", 123)
15
+ store.append_output("run-1", "stdout_tail", "hello\n")
16
+ store.finish_run("run-1", 0)
17
+
18
+ run = store.get_run("run-1")
19
+
20
+ self.assertIsNotNone(run)
21
+ self.assertEqual(run.status, "succeeded")
22
+ self.assertEqual(run.exit_code, 0)
23
+ self.assertEqual(run.stdout_tail, "hello\n")
24
+ self.assertEqual(run.commandText, "echo hello")
25
+
26
+ def test_failed_run(self):
27
+ with tempfile.TemporaryDirectory() as tmp:
28
+ store = RunStore(Path(tmp) / "runs.db")
29
+
30
+ store.create_run("run-2", ["false"], "/tmp")
31
+ store.finish_run("run-2", 1)
32
+
33
+ run = store.get_run("run-2")
34
+
35
+ self.assertIsNotNone(run)
36
+ self.assertEqual(run.status, "failed")
37
+ self.assertEqual(run.exit_code, 1)
38
+
39
+
40
+ if __name__ == "__main__":
41
+ unittest.main()