lemming-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.
Files changed (94) hide show
  1. lemming/__init__.py +1 -0
  2. lemming/api/__init__.py +5 -0
  3. lemming/api/auth.py +34 -0
  4. lemming/api/auth_test.py +40 -0
  5. lemming/api/config.py +85 -0
  6. lemming/api/config_test.py +84 -0
  7. lemming/api/conftest.py +204 -0
  8. lemming/api/context.py +39 -0
  9. lemming/api/context_test.py +62 -0
  10. lemming/api/directories.py +57 -0
  11. lemming/api/directories_test.py +94 -0
  12. lemming/api/files.py +116 -0
  13. lemming/api/files_test.py +163 -0
  14. lemming/api/hooks.py +15 -0
  15. lemming/api/hooks_test.py +33 -0
  16. lemming/api/logging.py +16 -0
  17. lemming/api/logging_test.py +59 -0
  18. lemming/api/loop.py +45 -0
  19. lemming/api/loop_test.py +58 -0
  20. lemming/api/main.py +53 -0
  21. lemming/api/main_test.py +30 -0
  22. lemming/api/tasks.py +170 -0
  23. lemming/api/tasks_test.py +426 -0
  24. lemming/api.py +5 -0
  25. lemming/api_test.py +7 -0
  26. lemming/cli/__init__.py +12 -0
  27. lemming/cli/config.py +67 -0
  28. lemming/cli/config_test.py +50 -0
  29. lemming/cli/context.py +40 -0
  30. lemming/cli/context_test.py +49 -0
  31. lemming/cli/hooks.py +147 -0
  32. lemming/cli/hooks_test.py +50 -0
  33. lemming/cli/main.py +42 -0
  34. lemming/cli/main_test.py +21 -0
  35. lemming/cli/operations.py +226 -0
  36. lemming/cli/operations_test.py +44 -0
  37. lemming/cli/progress.py +54 -0
  38. lemming/cli/progress_test.py +40 -0
  39. lemming/cli/readability_cli.py +28 -0
  40. lemming/cli/readability_cli_test.py +57 -0
  41. lemming/cli/tasks.py +529 -0
  42. lemming/cli/tasks_test.py +168 -0
  43. lemming/cli.py +5 -0
  44. lemming/cli_test.py +22 -0
  45. lemming/conftest.py +13 -0
  46. lemming/hooks.py +145 -0
  47. lemming/hooks_test.py +180 -0
  48. lemming/integration_test.py +299 -0
  49. lemming/main.py +6 -0
  50. lemming/main_test.py +44 -0
  51. lemming/models.py +88 -0
  52. lemming/models_test.py +91 -0
  53. lemming/orchestrator.py +407 -0
  54. lemming/orchestrator_test.py +468 -0
  55. lemming/paths.py +245 -0
  56. lemming/paths_test.py +186 -0
  57. lemming/persistence.py +179 -0
  58. lemming/persistence_test.py +150 -0
  59. lemming/prompts/hooks/readability.md +42 -0
  60. lemming/prompts/hooks/roadmap.md +61 -0
  61. lemming/prompts/hooks/testing.md +44 -0
  62. lemming/prompts/taskrunner.md +69 -0
  63. lemming/prompts.py +354 -0
  64. lemming/prompts_test.py +421 -0
  65. lemming/providers.py +190 -0
  66. lemming/providers_test.py +73 -0
  67. lemming/runner.py +327 -0
  68. lemming/runner_test.py +378 -0
  69. lemming/tasks/__init__.py +75 -0
  70. lemming/tasks/lifecycle.py +331 -0
  71. lemming/tasks/lifecycle_test.py +312 -0
  72. lemming/tasks/operations.py +258 -0
  73. lemming/tasks/operations_test.py +172 -0
  74. lemming/tasks/progress.py +29 -0
  75. lemming/tasks/progress_test.py +22 -0
  76. lemming/tasks/queries.py +128 -0
  77. lemming/tasks/queries_test.py +233 -0
  78. lemming/web/dashboard.spec.js +350 -0
  79. lemming/web/dashboard.test.js +998 -0
  80. lemming/web/favicon.js +40 -0
  81. lemming/web/favicon.spec.js +242 -0
  82. lemming/web/files.html +375 -0
  83. lemming/web/files.spec.js +97 -0
  84. lemming/web/index.html +983 -0
  85. lemming/web/index.js +753 -0
  86. lemming/web/logs.html +358 -0
  87. lemming/web/logs.test.js +195 -0
  88. lemming/web/mancha.js +58 -0
  89. lemming/web/screenshots.spec.js +328 -0
  90. lemming_cli-0.1.0.dist-info/METADATA +314 -0
  91. lemming_cli-0.1.0.dist-info/RECORD +94 -0
  92. lemming_cli-0.1.0.dist-info/WHEEL +4 -0
  93. lemming_cli-0.1.0.dist-info/entry_points.txt +2 -0
  94. lemming_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,421 @@
1
+ from lemming import models, paths, prompts, tasks
2
+
3
+
4
+ def test_load_prompt():
5
+ prompt = prompts.load_prompt("taskrunner")
6
+ assert "roadmap" in prompt
7
+ assert "description" in prompt
8
+
9
+
10
+ def test_prepare_prompt(tmp_path):
11
+ tasks_file = tmp_path / "tasks.yml"
12
+ data = tasks.Roadmap(
13
+ context="My context",
14
+ tasks=[
15
+ tasks.Task(
16
+ id="1",
17
+ description="T1",
18
+ status=tasks.TaskStatus.COMPLETED,
19
+ progress=["O1"],
20
+ ),
21
+ tasks.Task(
22
+ id="2", description="T2", status=tasks.TaskStatus.PENDING
23
+ ),
24
+ ],
25
+ )
26
+ task = data.tasks[1]
27
+ prompt = prompts.prepare_prompt(data, task, tasks_file)
28
+ assert "My context" in prompt
29
+ assert "T2" in prompt
30
+ assert "T1" in prompt
31
+ assert "O1" in prompt
32
+
33
+
34
+ def test_prepare_prompt_with_parent_context(tmp_path):
35
+
36
+ root_tasks_file = tmp_path / "root_tasks.yml"
37
+ sub_tasks_file = tmp_path / "sub_tasks.yml"
38
+
39
+ # Setup parent task
40
+ parent_task = tasks.Task(
41
+ id="parent123",
42
+ description="Parent Task Description",
43
+ progress=["Parent Outcome 1"],
44
+ )
45
+ root_data = tasks.Roadmap(tasks=[parent_task])
46
+ tasks.save_tasks(root_tasks_file, root_data)
47
+
48
+ # Setup child task referencing parent
49
+ child_task = tasks.Task(
50
+ id="child456",
51
+ description="Child Task",
52
+ parent="parent123",
53
+ parent_tasks_file=str(root_tasks_file),
54
+ )
55
+ sub_data = tasks.Roadmap(tasks=[child_task])
56
+
57
+ prompt = prompts.prepare_prompt(sub_data, child_task, sub_tasks_file)
58
+
59
+ assert "Parent Task Context" in prompt
60
+ assert "Parent Task Description" in prompt
61
+ assert "Parent Outcome 1" in prompt
62
+
63
+
64
+ def test_load_prompt_discovery(tmp_path, monkeypatch):
65
+ """Tests the discovery of hook prompts across different layers."""
66
+ # Setup global hooks dir
67
+ lemming_home = tmp_path / "lemming_home"
68
+ global_hooks_dir = lemming_home / "hooks"
69
+ global_hooks_dir.mkdir(parents=True)
70
+ monkeypatch.setenv("LEMMING_HOME", str(lemming_home))
71
+
72
+ # Setup project dir
73
+ project_dir = tmp_path / "project"
74
+ project_dir.mkdir()
75
+ local_hooks_dir = project_dir / ".lemming" / "hooks"
76
+ local_hooks_dir.mkdir(parents=True)
77
+ tasks_file = project_dir / "tasks.yml"
78
+ tasks_file.write_text("tasks: []")
79
+
80
+ # 1. Test local hook
81
+ (local_hooks_dir / "myhook.md").write_text("local content")
82
+ assert prompts.load_prompt("myhook", tasks_file) == "local content"
83
+
84
+ # 2. Test global hook (when local doesn't exist)
85
+ (global_hooks_dir / "globalhook.md").write_text("global content")
86
+ assert prompts.load_prompt("globalhook", tasks_file) == "global content"
87
+
88
+ # 3. Test precedence: local > global
89
+ (global_hooks_dir / "myhook.md").write_text("global content")
90
+ assert prompts.load_prompt("myhook", tasks_file) == "local content"
91
+
92
+ # 4. Test built-in (fallback)
93
+ assert "You are a roadmap orchestrator" in prompts.load_prompt(
94
+ "roadmap", tasks_file
95
+ )
96
+
97
+
98
+ def test_prepare_hook_prompt_substitution(tmp_path, monkeypatch):
99
+ # Setup paths to avoid polluting real global/home
100
+ lemming_home = tmp_path / "lemming_home"
101
+ monkeypatch.setenv("LEMMING_HOME", str(lemming_home))
102
+
103
+ tasks_file = tmp_path / "tasks.yml"
104
+ data = tasks.Roadmap(
105
+ context="Project Context",
106
+ tasks=[
107
+ tasks.Task(
108
+ id="task1",
109
+ description="Task 1",
110
+ status=tasks.TaskStatus.COMPLETED,
111
+ progress=["Done"],
112
+ ),
113
+ tasks.Task(
114
+ id="task2",
115
+ description="Task 2",
116
+ status=tasks.TaskStatus.IN_PROGRESS,
117
+ ),
118
+ ],
119
+ )
120
+ finished_task = data.tasks[0]
121
+
122
+ # Create a mock hook prompt with all placeholders
123
+ local_hooks_dir = tmp_path / ".lemming" / "hooks"
124
+ local_hooks_dir.mkdir(parents=True)
125
+ hook_prompt_file = local_hooks_dir / "test-hook.md"
126
+ hook_prompt_file.write_text("""
127
+ Roadmap: {{roadmap}}
128
+ Finished Task: {{finished_task}}
129
+ ID: {{finished_task_id}}
130
+ File Name: {{tasks_file_name}}
131
+ File Path: {{tasks_file_path}}
132
+ """)
133
+
134
+ # Mock log file
135
+ log_file = paths.get_log_file(tasks_file, finished_task.id)
136
+ log_file.parent.mkdir(parents=True, exist_ok=True)
137
+ log_file.write_text("line 1\nline 2\nline 3")
138
+
139
+ prompt = prompts.prepare_hook_prompt(
140
+ "test-hook", data, finished_task, tasks_file
141
+ )
142
+
143
+ # Verify roadmap substitution
144
+ assert "Roadmap: ## Project Context" in prompt
145
+ assert "**[COMPLETED] (task1) Task 1**" in prompt
146
+ assert " - Done" in prompt
147
+ assert "- [IN PROGRESS] (task2) Task 2" in prompt
148
+
149
+ # Verify finished task substitution
150
+ assert "Finished Task: Task ID: task1" in prompt
151
+ assert "Description: Task 1" in prompt
152
+ assert "Result: completed" in prompt
153
+ assert "Progress recorded during this attempt:\n- Done" in prompt
154
+
155
+ # Verify log inclusion
156
+ assert "Execution log of THIS task" in prompt
157
+ assert "line 1\nline 2\nline 3" in prompt
158
+
159
+
160
+ def test_prepare_prompt_local_override(tmp_path):
161
+ tasks_file = tmp_path / "tasks.yml"
162
+ project_dir = tmp_path
163
+ local_hooks_dir = project_dir / ".lemming" / "hooks"
164
+ local_hooks_dir.mkdir(parents=True)
165
+ (local_hooks_dir / "taskrunner.md").write_text(
166
+ "LOCAL OVERRIDE {{description}}"
167
+ )
168
+
169
+ data = tasks.Roadmap(
170
+ tasks=[
171
+ tasks.Task(id="1", description="My Task"),
172
+ ],
173
+ )
174
+ task = data.tasks[0]
175
+ prompt = prompts.prepare_prompt(data, task, tasks_file)
176
+ assert "LOCAL OVERRIDE My Task" in prompt
177
+
178
+
179
+ def test_prepare_hook_prompt_filters_command_noise(tmp_path, monkeypatch):
180
+ # Setup paths to avoid polluting real global/home
181
+ lemming_home = tmp_path / "lemming_home"
182
+ monkeypatch.setenv("LEMMING_HOME", str(lemming_home))
183
+
184
+ tasks_file = tmp_path / "tasks.yml"
185
+ data = tasks.Roadmap(
186
+ context="Project Context",
187
+ tasks=[
188
+ tasks.Task(
189
+ id="task1",
190
+ description="Task 1",
191
+ status=tasks.TaskStatus.COMPLETED,
192
+ ),
193
+ ],
194
+ )
195
+ finished_task = data.tasks[0]
196
+
197
+ # Create a mock hook prompt with all placeholders
198
+ local_hooks_dir = tmp_path / ".lemming" / "hooks"
199
+ local_hooks_dir.mkdir(parents=True)
200
+ hook_prompt_file = local_hooks_dir / "test-hook.md"
201
+ hook_prompt_file.write_text("Log: {{finished_task}}")
202
+
203
+ # Mock log file with a long Command: line
204
+ log_file = paths.get_log_file(tasks_file, finished_task.id)
205
+ log_file.parent.mkdir(parents=True, exist_ok=True)
206
+ log_file.write_text(
207
+ "Command: agy --prompt \"HUGE PROMPT WITH 'QUOTES'\" ...\n"
208
+ "Real output from AI\n"
209
+ )
210
+
211
+ prompt = prompts.prepare_hook_prompt(
212
+ "test-hook", data, finished_task, tasks_file
213
+ )
214
+
215
+ # Verify Command: line is filtered out
216
+ assert "HUGE PROMPT" not in prompt
217
+ assert "Real output from AI" in prompt
218
+ assert "Execution log of THIS task" in prompt
219
+
220
+
221
+ def test_ensure_hooks_symlinked(tmp_path, monkeypatch):
222
+ # Setup mock lemming home
223
+ lemming_home = tmp_path / "lemming_home"
224
+ monkeypatch.setenv("LEMMING_HOME", str(lemming_home))
225
+
226
+ global_hooks_dir = paths.get_global_hooks_dir()
227
+ assert not global_hooks_dir.exists()
228
+
229
+ # Run ensure_hooks_symlinked
230
+ prompts.ensure_hooks_symlinked()
231
+
232
+ assert global_hooks_dir.exists()
233
+ assert (global_hooks_dir / "roadmap.md").is_symlink()
234
+ assert (global_hooks_dir / "readability.md").is_symlink()
235
+
236
+ # Check if we can load it
237
+ content = prompts.load_prompt("roadmap")
238
+ assert "roadmap orchestrator" in content.lower()
239
+
240
+ content = prompts.load_prompt("readability")
241
+ assert "google style guide" in content.lower()
242
+
243
+
244
+ def test_list_hooks_includes_all(tmp_path, monkeypatch):
245
+ # Setup mock lemming home
246
+ lemming_home = tmp_path / "lemming_home"
247
+ monkeypatch.setenv("LEMMING_HOME", str(lemming_home))
248
+
249
+ # Project hooks
250
+ project_dir = tmp_path / "project"
251
+ project_dir.mkdir()
252
+ local_hooks_dir = project_dir / ".lemming" / "hooks"
253
+ local_hooks_dir.mkdir(parents=True)
254
+ (local_hooks_dir / "custom_hook.md").write_text("custom", encoding="utf-8")
255
+
256
+ tasks_file = project_dir / "tasks.yml"
257
+ tasks_file.touch()
258
+
259
+ # Run list_hooks
260
+ hooks = prompts.list_hooks(tasks_file)
261
+
262
+ assert "roadmap" in hooks
263
+ assert "readability" in hooks
264
+ assert "custom_hook" in hooks
265
+
266
+
267
+ def test_hook_override_precedence(tmp_path, monkeypatch):
268
+ # Setup mock lemming home
269
+ lemming_home = tmp_path / "lemming_home"
270
+ monkeypatch.setenv("LEMMING_HOME", str(lemming_home))
271
+
272
+ # Global override
273
+ global_hooks_dir = paths.get_global_hooks_dir()
274
+ global_hooks_dir.mkdir(parents=True)
275
+ (global_hooks_dir / "roadmap.md").write_text(
276
+ "global roadmap", encoding="utf-8"
277
+ )
278
+
279
+ # Project override
280
+ project_dir = tmp_path / "project"
281
+ project_dir.mkdir()
282
+ local_hooks_dir = project_dir / ".lemming" / "hooks"
283
+ local_hooks_dir.mkdir(parents=True)
284
+ (local_hooks_dir / "roadmap.md").write_text(
285
+ "project roadmap", encoding="utf-8"
286
+ )
287
+
288
+ tasks_file = project_dir / "tasks.yml"
289
+ tasks_file.touch()
290
+
291
+ # Check precedence
292
+ content = prompts.load_prompt("roadmap", tasks_file)
293
+ assert content == "project roadmap"
294
+
295
+ # Remove project override
296
+ (local_hooks_dir / "roadmap.md").unlink()
297
+ content = prompts.load_prompt("roadmap", tasks_file)
298
+ assert content == "global roadmap"
299
+
300
+
301
+ def test_prepare_prompt_time_limit_section(tmp_path):
302
+ """Verifies the time limit section is injected when time_limit > 0."""
303
+ tasks_file = tmp_path / "tasks.yml"
304
+ data = tasks.Roadmap(
305
+ tasks=[tasks.Task(id="1", description="T1")],
306
+ )
307
+ task = data.tasks[0]
308
+
309
+ # With time limit (60 minutes)
310
+ prompt = prompts.prepare_prompt(data, task, tasks_file, time_limit=60)
311
+ assert "## Time Limit" in prompt
312
+ assert "60 minutes" in prompt
313
+ assert "Record progress early" in prompt
314
+ assert "subagents" in prompt
315
+
316
+ # Without time limit
317
+ prompt_no_limit = prompts.prepare_prompt(
318
+ data, task, tasks_file, time_limit=0
319
+ )
320
+ assert "## Time Limit" not in prompt_no_limit
321
+
322
+
323
+ def test_prepare_prompt_time_limit_custom(tmp_path):
324
+ """Verifies the time limit section uses the correct minute value."""
325
+ tasks_file = tmp_path / "tasks.yml"
326
+ data = tasks.Roadmap(
327
+ tasks=[tasks.Task(id="1", description="T1")],
328
+ )
329
+ task = data.tasks[0]
330
+
331
+ prompt = prompts.prepare_prompt(data, task, tasks_file, time_limit=30)
332
+ assert "30 minutes" in prompt
333
+
334
+
335
+ def test_prepare_hook_prompt_shows_failed_for_exhausted_task(
336
+ tmp_path, monkeypatch
337
+ ):
338
+ """A task with requested_status=FAILED (during hook execution) should show
339
+ Result: failed and [FAILED] in the roadmap, not in_progress."""
340
+ lemming_home = tmp_path / "lemming_home"
341
+ monkeypatch.setenv("LEMMING_HOME", str(lemming_home))
342
+
343
+ tasks_file = tmp_path / "tasks.yml"
344
+ # Simulate the state during hook execution after retry exhaustion:
345
+ # status=IN_PROGRESS, requested_status=FAILED
346
+ failed_task = tasks.Task(
347
+ id="task1",
348
+ description="Flaky task",
349
+ status=tasks.TaskStatus.IN_PROGRESS,
350
+ requested_status=tasks.TaskStatus.FAILED,
351
+ attempts=3,
352
+ progress=["Task killed: time limit of 60 minutes reached."],
353
+ )
354
+ data = tasks.Roadmap(
355
+ context="Test",
356
+ tasks=[
357
+ failed_task,
358
+ tasks.Task(id="task2", description="Next task"),
359
+ ],
360
+ )
361
+
362
+ local_hooks_dir = tmp_path / ".lemming" / "hooks"
363
+ local_hooks_dir.mkdir(parents=True)
364
+ (local_hooks_dir / "test-hook.md").write_text(
365
+ "Roadmap: {{roadmap}}\nFinished: {{finished_task}}"
366
+ )
367
+
368
+ prompt = prompts.prepare_hook_prompt(
369
+ "test-hook", data, failed_task, tasks_file
370
+ )
371
+
372
+ # The finished task section must show "failed", not "in_progress"
373
+ assert "Result: failed" in prompt
374
+ # The roadmap overview must show [FAILED], not [IN PROGRESS]
375
+ assert "[FAILED - 3/3 attempt(s)]" in prompt
376
+ assert "[PENDING]" in prompt
377
+
378
+
379
+ def test_list_hooks_roadmap_is_last(tmp_path):
380
+ tasks_file = tmp_path / "tasks.yml"
381
+ tasks_file.touch()
382
+
383
+ # Create project hooks that would alphabetically come before and after
384
+ # 'roadmap'
385
+ local_hooks_dir = tmp_path / ".lemming" / "hooks"
386
+ local_hooks_dir.mkdir(parents=True)
387
+ (local_hooks_dir / "z_hook.md").write_text("z", encoding="utf-8")
388
+ (local_hooks_dir / "a_hook.md").write_text("a", encoding="utf-8")
389
+
390
+ hooks = prompts.list_hooks(tasks_file)
391
+
392
+ assert "roadmap" in hooks
393
+ assert "z_hook" in hooks
394
+ assert "a_hook" in hooks
395
+ # Even though z_hook is alphabetically last, roadmap should be moved to
396
+ # the end
397
+ assert hooks[-1] == "roadmap"
398
+
399
+
400
+ def test_format_roadmap_with_finalizing_task():
401
+ data = models.Roadmap(
402
+ tasks=[
403
+ models.Task(
404
+ id="1",
405
+ description="Done",
406
+ status=models.TaskStatus.COMPLETED,
407
+ progress=["a"],
408
+ ),
409
+ models.Task(
410
+ id="2",
411
+ description="Finalizing",
412
+ status=models.TaskStatus.IN_PROGRESS,
413
+ requested_status=models.TaskStatus.COMPLETED,
414
+ progress=["b"],
415
+ ),
416
+ ]
417
+ )
418
+
419
+ output = prompts._format_roadmap(data)
420
+ assert "[COMPLETED] (2)" in output
421
+ assert "- b" in output
lemming/providers.py ADDED
@@ -0,0 +1,190 @@
1
+ """Tunnel providers that expose a local port through a public URL."""
2
+
3
+ import abc
4
+ import json
5
+ import re
6
+ import subprocess
7
+ import time
8
+
9
+
10
+ class TunnelProvider(abc.ABC):
11
+ """Abstract interface for a tunnel exposing a local port publicly."""
12
+
13
+ @abc.abstractmethod
14
+ def start(self, local_port: int) -> str:
15
+ """Starts the tunnel and returns the public URL.
16
+
17
+ Raises an exception on failure.
18
+ """
19
+ pass
20
+
21
+ @abc.abstractmethod
22
+ def stop(self):
23
+ """Tears down the tunnel."""
24
+ pass
25
+
26
+
27
+ class CloudflareProvider(TunnelProvider):
28
+ """Tunnel provider backed by Cloudflare quick tunnels (cloudflared)."""
29
+
30
+ def __init__(self):
31
+ """Initializes the provider with no running tunnel process."""
32
+ self.process: subprocess.Popen | None = None
33
+
34
+ def start(self, local_port: int) -> str:
35
+ """Starts a Cloudflare quick tunnel to the given local port.
36
+
37
+ Args:
38
+ local_port: Local port to expose through the tunnel.
39
+
40
+ Returns:
41
+ The public tunnel URL.
42
+
43
+ Raises:
44
+ RuntimeError: If cloudflared is missing or no URL is obtained.
45
+ """
46
+ if (
47
+ subprocess.run(
48
+ ["which", "cloudflared"], capture_output=True, check=False
49
+ ).returncode
50
+ != 0
51
+ ):
52
+ raise RuntimeError(
53
+ "cloudflared not found in PATH. Please install it via "
54
+ "'brew install cloudflare/cloudflare/cloudflared' "
55
+ "or visit https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/"
56
+ )
57
+
58
+ cmd = [
59
+ "cloudflared",
60
+ "tunnel",
61
+ "--url",
62
+ f"http://127.0.0.1:{local_port}",
63
+ ]
64
+ self.process = subprocess.Popen(
65
+ cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True
66
+ )
67
+
68
+ url_pattern = re.compile(r"(https://[a-zA-Z0-9-]+\.trycloudflare\.com)")
69
+
70
+ start_time = time.time()
71
+ while time.time() - start_time < 15:
72
+ if self.process.stdout is None:
73
+ break
74
+ line = self.process.stdout.readline()
75
+ if not line:
76
+ # Process might have died
77
+ if self.process.poll() is not None:
78
+ break
79
+ continue
80
+ match = url_pattern.search(line)
81
+ if (
82
+ match
83
+ and "api.trycloudflare.com" not in match.group(1)
84
+ and "update.trycloudflare.com" not in match.group(1)
85
+ ):
86
+ return match.group(1)
87
+
88
+ self.stop()
89
+ raise RuntimeError(
90
+ "Failed to obtain Cloudflare tunnel URL within 15 seconds."
91
+ )
92
+
93
+ def stop(self):
94
+ """Terminates the cloudflared process, if one is running."""
95
+ if self.process:
96
+ self.process.terminate()
97
+ try:
98
+ self.process.wait(timeout=5)
99
+ except subprocess.TimeoutExpired:
100
+ self.process.kill()
101
+ if self.process.stdout:
102
+ self.process.stdout.close()
103
+ self.process = None
104
+
105
+
106
+ class TailscaleProvider(TunnelProvider):
107
+ """Tunnel provider backed by Tailscale serve and funnel."""
108
+
109
+ def __init__(self):
110
+ """Initializes the provider with no running tunnel process."""
111
+ self.process: subprocess.Popen | None = None
112
+
113
+ def start(self, local_port: int) -> str:
114
+ """Exposes the given local port via Tailscale serve and funnel.
115
+
116
+ Args:
117
+ local_port: Local port to expose through the tunnel.
118
+
119
+ Returns:
120
+ The public URL derived from the node's Tailscale domain.
121
+
122
+ Raises:
123
+ RuntimeError: If tailscale is missing or setup fails.
124
+ """
125
+ if (
126
+ subprocess.run(
127
+ ["which", "tailscale"], capture_output=True, check=False
128
+ ).returncode
129
+ != 0
130
+ ):
131
+ raise RuntimeError(
132
+ "tailscale not found in PATH. Please install it from https://tailscale.com/download"
133
+ )
134
+
135
+ cmd = [
136
+ "tailscale",
137
+ "serve",
138
+ "https",
139
+ "/",
140
+ f"http://127.0.0.1:{local_port}",
141
+ ]
142
+ try:
143
+ subprocess.run(cmd, capture_output=True, check=True)
144
+ # funnel is enabled separately: tailscale funnel 8999
145
+ subprocess.run(
146
+ ["tailscale", "funnel", str(local_port), "on"],
147
+ capture_output=True,
148
+ check=True,
149
+ )
150
+ except subprocess.CalledProcessError as e:
151
+ raise RuntimeError(
152
+ "Failed to start tailscale serve/funnel: "
153
+ f"{e.stderr.decode() if e.stderr else str(e)}"
154
+ )
155
+
156
+ status = subprocess.run(
157
+ ["tailscale", "status", "--json"],
158
+ capture_output=True,
159
+ text=True,
160
+ check=False,
161
+ )
162
+ if status.returncode != 0:
163
+ self.stop()
164
+ raise RuntimeError(
165
+ "Failed to run 'tailscale status'. Is tailscaled running?"
166
+ )
167
+
168
+ try:
169
+ data = json.loads(status.stdout)
170
+ domain = data.get("Self", {}).get("DNSName", "").strip(".")
171
+ if not domain:
172
+ raise ValueError("No DNSName found in tailscale status.")
173
+ return f"https://{domain}:{local_port}"
174
+ except Exception as e:
175
+ self.stop()
176
+ raise RuntimeError(f"Failed to determine tailscale domain: {e}")
177
+
178
+ def stop(self):
179
+ """Turns off Tailscale funnel and serve for this node."""
180
+ # We don't have a long-running process to kill, but we can turn off
181
+ # funnel and serve. It's cleaner to turn them off.
182
+ subprocess.run(
183
+ ["tailscale", "funnel", "off"], capture_output=True, check=False
184
+ )
185
+ subprocess.run(
186
+ ["tailscale", "serve", "reset"], capture_output=True, check=False
187
+ )
188
+ if self.process:
189
+ self.process.terminate()
190
+ self.process = None
@@ -0,0 +1,73 @@
1
+ import unittest.mock
2
+
3
+ import pytest
4
+
5
+ from lemming import providers
6
+
7
+
8
+ @unittest.mock.patch("subprocess.run")
9
+ def test_cloudflare_provider_binary_missing(mock_run):
10
+ mock_run.return_value.returncode = 1
11
+ provider = providers.CloudflareProvider()
12
+ with pytest.raises(RuntimeError, match="cloudflared not found in PATH"):
13
+ provider.start(8999)
14
+
15
+
16
+ @unittest.mock.patch("subprocess.run")
17
+ @unittest.mock.patch("subprocess.Popen")
18
+ @unittest.mock.patch("time.time")
19
+ def test_cloudflare_provider_success(mock_time, mock_popen, mock_run):
20
+ mock_run.return_value.returncode = 0
21
+ mock_time.side_effect = [0, 1, 2]
22
+
23
+ mock_process = unittest.mock.MagicMock()
24
+ mock_process.stdout.readline.side_effect = [
25
+ "Starting tunnel...",
26
+ "https://mocked.trycloudflare.com",
27
+ "",
28
+ ]
29
+ mock_popen.return_value = mock_process
30
+
31
+ provider = providers.CloudflareProvider()
32
+ url = provider.start(8999)
33
+
34
+ assert url == "https://mocked.trycloudflare.com"
35
+ assert provider.process is not None
36
+ provider.stop()
37
+ assert provider.process is None
38
+
39
+
40
+ @unittest.mock.patch("subprocess.run")
41
+ def test_tailscale_provider_binary_missing(mock_run):
42
+ mock_run.return_value.returncode = 1
43
+ provider = providers.TailscaleProvider()
44
+ with pytest.raises(RuntimeError, match="tailscale not found in PATH"):
45
+ provider.start(8999)
46
+
47
+
48
+ @unittest.mock.patch("subprocess.run")
49
+ def test_tailscale_provider_success(mock_run):
50
+ # Mock which tailscale
51
+ # Mock tailscale serve
52
+ # Mock tailscale funnel
53
+ # Mock tailscale status
54
+ mock_run.side_effect = [
55
+ unittest.mock.MagicMock(returncode=0), # which
56
+ unittest.mock.MagicMock(returncode=0), # serve
57
+ unittest.mock.MagicMock(returncode=0), # funnel
58
+ unittest.mock.MagicMock(
59
+ returncode=0,
60
+ stdout='{"Self": {"DNSName": "my-node.tail-scale.net."}}',
61
+ ), # status
62
+ unittest.mock.MagicMock(returncode=0), # stop: funnel off
63
+ unittest.mock.MagicMock(returncode=0), # stop: serve reset
64
+ ]
65
+
66
+ provider = providers.TailscaleProvider()
67
+ url = provider.start(8999)
68
+
69
+ assert url == "https://my-node.tail-scale.net:8999"
70
+
71
+ # Test stop
72
+ provider.stop()
73
+ assert mock_run.call_count == 6 # 4 in start + 2 in stop