collab-runtime 0.4.0__tar.gz → 0.4.2__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.
Files changed (27) hide show
  1. {collab_runtime-0.4.0/collab_runtime.egg-info → collab_runtime-0.4.2}/PKG-INFO +1 -1
  2. collab_runtime-0.4.2/collab/dashboard/dashboard-format.js +74 -0
  3. {collab_runtime-0.4.0 → collab_runtime-0.4.2}/collab/dashboard/index.html +26 -8
  4. collab_runtime-0.4.2/collab/dashboard_server.py +507 -0
  5. {collab_runtime-0.4.0 → collab_runtime-0.4.2}/collab/live_locks_watcher.py +22 -1
  6. {collab_runtime-0.4.0 → collab_runtime-0.4.2}/collab/lock_client.py +150 -10
  7. {collab_runtime-0.4.0 → collab_runtime-0.4.2}/collab/platform_probe.py +41 -1
  8. {collab_runtime-0.4.0 → collab_runtime-0.4.2}/collab/safe_subprocess.py +14 -5
  9. {collab_runtime-0.4.0 → collab_runtime-0.4.2/collab_runtime.egg-info}/PKG-INFO +1 -1
  10. {collab_runtime-0.4.0 → collab_runtime-0.4.2}/collab_runtime.egg-info/SOURCES.txt +1 -0
  11. {collab_runtime-0.4.0 → collab_runtime-0.4.2}/pyproject.toml +5 -2
  12. collab_runtime-0.4.0/collab/dashboard_server.py +0 -250
  13. {collab_runtime-0.4.0 → collab_runtime-0.4.2}/LICENSE +0 -0
  14. {collab_runtime-0.4.0 → collab_runtime-0.4.2}/README.md +0 -0
  15. {collab_runtime-0.4.0 → collab_runtime-0.4.2}/collab/__init__.py +0 -0
  16. {collab_runtime-0.4.0 → collab_runtime-0.4.2}/collab/__main__.py +0 -0
  17. {collab_runtime-0.4.0 → collab_runtime-0.4.2}/collab/agent_identity.py +0 -0
  18. {collab_runtime-0.4.0 → collab_runtime-0.4.2}/collab/errors.py +0 -0
  19. {collab_runtime-0.4.0 → collab_runtime-0.4.2}/collab/logging_config.py +0 -0
  20. {collab_runtime-0.4.0 → collab_runtime-0.4.2}/collab/main.py +0 -0
  21. {collab_runtime-0.4.0 → collab_runtime-0.4.2}/collab/subprocess_bridge.py +0 -0
  22. {collab_runtime-0.4.0 → collab_runtime-0.4.2}/collab_runtime.egg-info/dependency_links.txt +0 -0
  23. {collab_runtime-0.4.0 → collab_runtime-0.4.2}/collab_runtime.egg-info/entry_points.txt +0 -0
  24. {collab_runtime-0.4.0 → collab_runtime-0.4.2}/collab_runtime.egg-info/requires.txt +0 -0
  25. {collab_runtime-0.4.0 → collab_runtime-0.4.2}/collab_runtime.egg-info/top_level.txt +0 -0
  26. {collab_runtime-0.4.0 → collab_runtime-0.4.2}/docs/pypi/README.md +0 -0
  27. {collab_runtime-0.4.0 → collab_runtime-0.4.2}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: collab-runtime
3
- Version: 0.4.0
3
+ Version: 0.4.2
4
4
  Summary: Collaborative file locking runtime
5
5
  Author-email: KirilMT <kiril.mt95@gmail.com>
6
6
  License-Expression: MIT
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Pure formatting and routing helpers for the Collaborative Lock Dashboard.
3
+ * Loaded in the browser (global DashboardFormat) and in Jest (module.exports).
4
+ */
5
+ (function (root, factory) {
6
+ const api = factory();
7
+ if (typeof module === "object" && module.exports) {
8
+ module.exports = api;
9
+ } else {
10
+ root.DashboardFormat = api;
11
+ }
12
+ })(typeof globalThis !== "undefined" ? globalThis : this, function () {
13
+ function formatDateLong(dt) {
14
+ return dt.toLocaleDateString([], {
15
+ year: "numeric",
16
+ month: "long",
17
+ day: "numeric",
18
+ });
19
+ }
20
+
21
+ function formatTime24(dt) {
22
+ return dt.toLocaleTimeString([], {
23
+ hour: "2-digit",
24
+ minute: "2-digit",
25
+ hour12: false,
26
+ });
27
+ }
28
+
29
+ function formatDateTime24(dt) {
30
+ return formatDateLong(dt) + " " + formatTime24(dt);
31
+ }
32
+
33
+ function formatDurationMinutes(totalMinutes) {
34
+ const rounded = Math.max(0, Math.round(Number(totalMinutes) || 0));
35
+ if (!Number.isFinite(rounded) || rounded <= 0) {
36
+ return "0m";
37
+ }
38
+
39
+ const units = [
40
+ { label: "mo", minutes: 30 * 24 * 60 },
41
+ { label: "d", minutes: 24 * 60 },
42
+ { label: "h", minutes: 60 },
43
+ { label: "m", minutes: 1 },
44
+ ];
45
+
46
+ let remaining = rounded;
47
+ const parts = [];
48
+
49
+ units.forEach((unit) => {
50
+ if (remaining >= unit.minutes) {
51
+ const value = Math.floor(remaining / unit.minutes);
52
+ remaining -= value * unit.minutes;
53
+ parts.push(String(value) + unit.label);
54
+ }
55
+ });
56
+
57
+ return parts.length ? parts.join(" ") : "0m";
58
+ }
59
+
60
+ function routeFromHash(hashLike) {
61
+ const h = String(hashLike || "")
62
+ .replace("#", "")
63
+ .toLowerCase();
64
+ return h === "history" ? "history" : "locks";
65
+ }
66
+
67
+ return {
68
+ formatDateLong,
69
+ formatTime24,
70
+ formatDateTime24,
71
+ formatDurationMinutes,
72
+ routeFromHash,
73
+ };
74
+ });
@@ -807,19 +807,37 @@ SUPABASE_ANON_KEY=your_anon_key</pre>
807
807
 
808
808
  function updateProjectInfo() {
809
809
  const el = document.getElementById("project-info");
810
- if (!SUPABASE_URL) {
810
+ const repoName = (runtimeCfg.projectName || "").trim();
811
+ let supabaseRef = "";
812
+ if (SUPABASE_URL) {
813
+ try {
814
+ supabaseRef = new URL(SUPABASE_URL).hostname.replace(
815
+ ".supabase.co",
816
+ ""
817
+ );
818
+ } catch (e) {
819
+ supabaseRef = "";
820
+ }
821
+ }
822
+ const display = repoName || supabaseRef;
823
+ if (!display) {
811
824
  el.classList.add("hidden");
812
825
  el.textContent = "";
813
826
  return;
814
827
  }
815
- try {
816
- const host = new URL(SUPABASE_URL).hostname.replace(".supabase.co", "");
817
- el.textContent = host;
818
- el.title = "Supabase project: " + SUPABASE_URL;
819
- el.classList.remove("hidden");
820
- } catch (e) {
821
- el.classList.add("hidden");
828
+ el.textContent = display;
829
+ const tip = [];
830
+ if (repoName) {
831
+ tip.push("Repository project: " + repoName);
832
+ }
833
+ if (supabaseRef) {
834
+ tip.push("Supabase project: " + supabaseRef);
835
+ }
836
+ if (SUPABASE_URL) {
837
+ tip.push(SUPABASE_URL);
822
838
  }
839
+ el.title = tip.join("\n");
840
+ el.classList.remove("hidden");
823
841
  }
824
842
 
825
843
  async function syncRuntimeConfig() {
@@ -0,0 +1,507 @@
1
+ """Local HTTP server for the collaborative dashboard static assets.
2
+
3
+ The dashboard HTML references sibling static files (e.g. ``dashboard-format.js``). The
4
+ injected config HTML must therefore be written *inside* ``collab/dashboard/`` and served
5
+ from that directory — not from a lone temp file in ``/tmp``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import atexit
11
+ import fnmatch
12
+ import http.server
13
+ import json
14
+ import logging
15
+ import os
16
+ import re
17
+ import tempfile
18
+ import threading
19
+ import time
20
+ import tomllib
21
+ import urllib.parse
22
+ import zipfile
23
+ from pathlib import Path
24
+ from typing import Any, Callable, Iterable, Optional, Sequence, Tuple
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+ DASHBOARD_TEMP_PREFIX = ".collab-dashboard-"
29
+ RUNTIME_CONFIG_PATH = "/collab-runtime-config.json"
30
+
31
+
32
+ def _repo_name_from_remote_url(url: str) -> Optional[str]:
33
+ """Extract repo folder name from HTTPS or SCP-style git remote URLs."""
34
+ cleaned = (url or "").strip().rstrip("/")
35
+ if not cleaned:
36
+ return None
37
+ if cleaned.endswith(".git"):
38
+ cleaned = cleaned[:-4]
39
+ if ":" in cleaned and "@" in cleaned.split(":", 1)[0]:
40
+ cleaned = cleaned.rsplit(":", 1)[-1]
41
+ tail = cleaned.rsplit("/", 1)[-1].strip()
42
+ return tail or None
43
+
44
+
45
+ def _name_from_pyproject(project_root: str) -> Optional[str]:
46
+ """Return ``[project].name`` from ``pyproject.toml`` when present."""
47
+ path = os.path.join(project_root, "pyproject.toml")
48
+ if not os.path.isfile(path):
49
+ return None
50
+ try:
51
+ with open(path, "rb") as fh:
52
+ data = tomllib.load(fh)
53
+ name = data.get("project", {}).get("name")
54
+ if isinstance(name, str) and name.strip():
55
+ return name.strip()
56
+ except (OSError, ValueError, TypeError, tomllib.TOMLDecodeError) as exc:
57
+ logger.debug("Could not read pyproject.toml project name: %s", exc)
58
+ return None
59
+
60
+
61
+ def _name_from_package_json(project_root: str) -> Optional[str]:
62
+ """Return ``name`` from ``package.json`` when present."""
63
+ path = os.path.join(project_root, "package.json")
64
+ if not os.path.isfile(path):
65
+ return None
66
+ try:
67
+ with open(path, encoding="utf-8") as fh:
68
+ data = json.load(fh)
69
+ name = data.get("name")
70
+ if isinstance(name, str) and name.strip():
71
+ return name.strip()
72
+ except (OSError, ValueError, TypeError, json.JSONDecodeError) as exc:
73
+ logger.debug("Could not read package.json name: %s", exc)
74
+ return None
75
+
76
+
77
+ def _name_from_git_remote(project_root: str) -> Optional[str]:
78
+ """Return the repository folder name from ``remote.origin.url`` (git config)."""
79
+ from collab import safe_subprocess
80
+
81
+ captured = safe_subprocess.capture(
82
+ ["git", "config", "--get", "remote.origin.url"],
83
+ policy="git",
84
+ cwd=project_root,
85
+ timeout=5,
86
+ )
87
+ if not captured.ok or captured.timed_out:
88
+ return None
89
+ remote = safe_subprocess.decode_output(captured.stdout).strip()
90
+ return _repo_name_from_remote_url(remote)
91
+
92
+
93
+ def resolve_project_display_name(
94
+ project_root: str,
95
+ file_vals: Optional[dict[str, Any]] = None,
96
+ ) -> str:
97
+ """Return a human-friendly label for the repository Collab is serving.
98
+
99
+ Precedence (repository-first, as shown in the dashboard header):
100
+
101
+ 1. ``COLLAB_PROJECT_NAME`` (``.env`` or environment)
102
+ 2. Git ``origin`` remote repository name (e.g. ``collab`` from ``.../collab.git``)
103
+ 3. ``pyproject.toml`` ``[project].name``
104
+ 4. ``package.json`` ``name``
105
+ 5. Basename of *project_root*
106
+ """
107
+ vals = file_vals if file_vals is not None else {}
108
+ override = vals.get("COLLAB_PROJECT_NAME") or os.getenv("COLLAB_PROJECT_NAME") or ""
109
+ if isinstance(override, str) and override.strip():
110
+ return override.strip()
111
+
112
+ for resolver in (
113
+ lambda: _name_from_git_remote(project_root),
114
+ lambda: _name_from_pyproject(project_root),
115
+ lambda: _name_from_package_json(project_root),
116
+ ):
117
+ name = resolver()
118
+ if name:
119
+ return name
120
+
121
+ return os.path.basename(os.path.abspath(project_root)) or "project"
122
+
123
+
124
+ def load_runtime_supabase_config(project_root: str) -> dict[str, Any]:
125
+ """Read ``.env`` from *project_root* and return dashboard Supabase settings.
126
+
127
+ The local dashboard fetches this on each sync so credential changes take effect
128
+ without restarting the watcher or reopening a stale browser tab. The project
129
+ ``.env`` is read directly (not via ``load_dotenv``) so polling never mutates the
130
+ running watcher's process environment.
131
+ """
132
+ from dotenv import dotenv_values
133
+
134
+ env_path = os.path.join(project_root, ".env")
135
+ file_vals: dict[str, Any] = {}
136
+ if os.path.isfile(env_path):
137
+ try:
138
+ file_vals = dict(dotenv_values(env_path))
139
+ except OSError as exc:
140
+ logger.debug("Could not read %s for runtime config: %s", env_path, exc)
141
+
142
+ def pick(name: str) -> Optional[str]:
143
+ val = file_vals.get(name)
144
+ if not val:
145
+ val = os.getenv(name)
146
+ return val
147
+
148
+ url = pick("SUPABASE_URL") or ""
149
+ anon = pick("SUPABASE_ANON_KEY") or ""
150
+ service = pick("SUPABASE_SERVICE_ROLE_KEY") or None
151
+ user = (
152
+ pick("COLLAB_DEVELOPER_ID")
153
+ or pick("DEVELOPER_ID")
154
+ or os.getenv("USERNAME")
155
+ or os.getenv("USER")
156
+ or ""
157
+ )
158
+ from . import agent_identity
159
+
160
+ state_override = os.getenv("COLLAB_STATE_DIR", "").strip()
161
+ state_dir = state_override or os.path.join(project_root, ".collab")
162
+ agent_id = agent_identity.resolve_agent_id(state_dir)
163
+ agent_label = agent_identity.resolve_agent_label()
164
+ project_name = resolve_project_display_name(project_root, file_vals)
165
+ return {
166
+ "url": url,
167
+ "anonKey": anon,
168
+ "serviceKey": service,
169
+ "user": user,
170
+ "agentId": agent_id,
171
+ "agentLabel": agent_label,
172
+ "projectName": project_name,
173
+ }
174
+
175
+
176
+ def create_dashboard_handler(project_root: str, directory: str) -> type:
177
+ """Build a request handler that serves static assets and live runtime config."""
178
+
179
+ class DashboardHandler(http.server.SimpleHTTPRequestHandler):
180
+ """Serve dashboard static files without per-request stderr logging."""
181
+
182
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
183
+ super().__init__(*args, directory=directory, **kwargs)
184
+
185
+ def log_message(self, format: str, *args: Any) -> None:
186
+ """Suppress default SimpleHTTPRequestHandler request log lines."""
187
+
188
+ def do_GET(self) -> None:
189
+ parsed = urllib.parse.urlparse(self.path)
190
+ if parsed.path == RUNTIME_CONFIG_PATH:
191
+ self._serve_runtime_config()
192
+ return
193
+ super().do_GET()
194
+
195
+ def _serve_runtime_config(self) -> None:
196
+ payload = load_runtime_supabase_config(project_root)
197
+ body = json.dumps(payload).encode("utf-8")
198
+ self.send_response(200)
199
+ self.send_header("Content-Type", "application/json; charset=utf-8")
200
+ self.send_header("Content-Length", str(len(body)))
201
+ self.send_header("Cache-Control", "no-store")
202
+ self.end_headers()
203
+ self.wfile.write(body)
204
+
205
+ return DashboardHandler
206
+
207
+
208
+ def dashboard_directory(resource_root: str) -> str:
209
+ """Return the path to packaged dashboard static assets."""
210
+ return os.path.join(resource_root, "dashboard")
211
+
212
+
213
+ # --- Dashboard static-asset packaging guards -------------------------------
214
+ #
215
+ # index.html loads sibling assets (e.g. ``dashboard-format.js``). If those files
216
+ # are not shipped in the wheel ``[tool.setuptools.package-data]`` the browser gets
217
+ # a 404 and the dashboard renders blank. The helpers below let the runtime warn on
218
+ # a broken install and let tests prove every referenced/shipped asset is packaged.
219
+
220
+ _PACKAGE_DASHBOARD_PREFIX = "dashboard/"
221
+ _WHEEL_DASHBOARD_PREFIX = "collab/dashboard/"
222
+
223
+ _LOCAL_SCRIPT_SRC = re.compile(
224
+ r'<script[^>]+src=["\'](?!https?://)([^"\']+)["\']',
225
+ re.IGNORECASE,
226
+ )
227
+ _LOCAL_LINK_HREF = re.compile(
228
+ r'<link[^>]+href=["\'](?!https?://)([^"\']+)["\']',
229
+ re.IGNORECASE,
230
+ )
231
+
232
+
233
+ def _normalize_static_ref(ref: str) -> Optional[str]:
234
+ """Strip query/fragment and reject absolute or protocol-relative refs."""
235
+ path = ref.split("?", 1)[0].split("#", 1)[0].strip()
236
+ if not path or path.startswith("/"):
237
+ return None
238
+ return path.replace("\\", "/")
239
+
240
+
241
+ def local_static_refs_from_html(html: str) -> Tuple[str, ...]:
242
+ """Return sorted relative script/link paths referenced by dashboard HTML.
243
+
244
+ CDN (``https://``) and absolute (``/foo``) references are ignored; only assets
245
+ that must ship inside the package are returned.
246
+ """
247
+ refs: list[str] = []
248
+ for pattern in (_LOCAL_SCRIPT_SRC, _LOCAL_LINK_HREF):
249
+ for raw in pattern.findall(html):
250
+ normalized = _normalize_static_ref(raw)
251
+ if normalized:
252
+ refs.append(normalized)
253
+ return tuple(sorted(set(refs)))
254
+
255
+
256
+ def shipped_dashboard_relative_paths(resource_root: str) -> Tuple[str, ...]:
257
+ """Return dashboard-relative paths of files that must ship in the wheel.
258
+
259
+ Hidden files (e.g. injected ``.collab-dashboard-*`` temp HTML) are excluded.
260
+ """
261
+ dash_dir = Path(dashboard_directory(resource_root))
262
+ if not dash_dir.is_dir():
263
+ return ()
264
+ return tuple(
265
+ path.relative_to(dash_dir).as_posix()
266
+ for path in sorted(dash_dir.rglob("*"))
267
+ if path.is_file() and not path.name.startswith(".")
268
+ )
269
+
270
+
271
+ def missing_local_static_files(resource_root: str, html: str) -> Tuple[str, ...]:
272
+ """Return local refs in *html* that are absent on disk under the dashboard dir."""
273
+ dash_dir = Path(dashboard_directory(resource_root))
274
+ return tuple(
275
+ rel
276
+ for rel in local_static_refs_from_html(html)
277
+ if not (dash_dir / rel).is_file()
278
+ )
279
+
280
+
281
+ def verify_dashboard_static_assets(resource_root: str) -> Tuple[str, ...]:
282
+ """Log and return any local assets referenced by index.html but missing.
283
+
284
+ Called on each template read so a broken wheel (or partial dev tree) surfaces a
285
+ clear error instead of a silent blank dashboard.
286
+ """
287
+ dash_dir = Path(dashboard_directory(resource_root))
288
+ index_path = dash_dir / "index.html"
289
+ if not index_path.is_file():
290
+ logger.error("Dashboard template missing at %s", index_path)
291
+ return ("index.html",)
292
+ missing = missing_local_static_files(
293
+ resource_root, index_path.read_text(encoding="utf-8")
294
+ )
295
+ for rel in missing:
296
+ logger.error(
297
+ "Dashboard static asset missing at %s (broken wheel or dev tree)",
298
+ dash_dir / rel,
299
+ )
300
+ return missing
301
+
302
+
303
+ def read_package_data_patterns(pyproject_path: str) -> Tuple[str, ...]:
304
+ """Return ``[tool.setuptools.package-data].collab`` glob patterns."""
305
+ with open(pyproject_path, "rb") as fh:
306
+ data = tomllib.load(fh)
307
+ patterns = (
308
+ data.get("tool", {})
309
+ .get("setuptools", {})
310
+ .get("package-data", {})
311
+ .get("collab", [])
312
+ )
313
+ if isinstance(patterns, str):
314
+ return (patterns,)
315
+ if isinstance(patterns, list):
316
+ return tuple(str(p) for p in patterns)
317
+ return ()
318
+
319
+
320
+ def package_data_covers(relative_path: str, patterns: Sequence[str]) -> bool:
321
+ """Return True when a dashboard-relative path matches a package-data glob."""
322
+ for pattern in patterns:
323
+ if not pattern.startswith(_PACKAGE_DASHBOARD_PREFIX):
324
+ continue
325
+ suffix = pattern[len(_PACKAGE_DASHBOARD_PREFIX) :]
326
+ if suffix == "**":
327
+ return True
328
+ if fnmatch.fnmatch(relative_path, suffix):
329
+ return True
330
+ return False
331
+
332
+
333
+ def missing_package_data_coverage(
334
+ resource_root: str, patterns: Sequence[str]
335
+ ) -> Tuple[str, ...]:
336
+ """Return shipped dashboard files not matched by any package-data glob."""
337
+ return tuple(
338
+ rel
339
+ for rel in shipped_dashboard_relative_paths(resource_root)
340
+ if not package_data_covers(rel, patterns)
341
+ )
342
+
343
+
344
+ def wheel_dashboard_member_paths(wheel_path: str) -> Tuple[str, ...]:
345
+ """Return dashboard-relative paths contained in a built wheel archive."""
346
+ members: set[str] = set()
347
+ with zipfile.ZipFile(wheel_path) as archive:
348
+ for name in archive.namelist():
349
+ normalized = name.replace("\\", "/")
350
+ if normalized.startswith(_WHEEL_DASHBOARD_PREFIX) and not (
351
+ normalized.endswith("/")
352
+ ):
353
+ members.add(normalized[len(_WHEEL_DASHBOARD_PREFIX) :])
354
+ return tuple(sorted(members))
355
+
356
+
357
+ def missing_wheel_dashboard_files(
358
+ wheel_path: str, required: Iterable[str]
359
+ ) -> Tuple[str, ...]:
360
+ """Return required dashboard paths absent from a built wheel."""
361
+ present = set(wheel_dashboard_member_paths(wheel_path))
362
+ return tuple(sorted(set(required) - present))
363
+
364
+
365
+ def read_dashboard_template(resource_root: str) -> Optional[str]:
366
+ """Read ``index.html`` from the dashboard package directory."""
367
+ verify_dashboard_static_assets(resource_root)
368
+ html_path = os.path.join(dashboard_directory(resource_root), "index.html")
369
+ if not os.path.exists(html_path):
370
+ logger.error("Dashboard file not found at %s", html_path)
371
+ return None
372
+ try:
373
+ with open(html_path, "r", encoding="utf-8") as fh:
374
+ return fh.read()
375
+ except OSError as exc:
376
+ logger.error("Error reading dashboard template: %s", exc)
377
+ return None
378
+
379
+
380
+ def write_injected_dashboard_html(
381
+ resource_root: str, injected: dict[str, Any]
382
+ ) -> Optional[str]:
383
+ """Write config-injected HTML next to static assets; return path or None."""
384
+ content = read_dashboard_template(resource_root)
385
+ if content is None:
386
+ return None
387
+
388
+ dash_dir = dashboard_directory(resource_root)
389
+ inject_script = (
390
+ f"<script>window.__SUPABASE_CONFIG__ = {json.dumps(injected)};</script>\n"
391
+ )
392
+ try:
393
+ tmp = tempfile.NamedTemporaryFile(
394
+ mode="w",
395
+ delete=False,
396
+ suffix=".html",
397
+ prefix=DASHBOARD_TEMP_PREFIX,
398
+ dir=dash_dir,
399
+ encoding="utf-8",
400
+ )
401
+ tmp.write(inject_script)
402
+ tmp.write(content)
403
+ tmp.flush()
404
+ tmp.close()
405
+ return tmp.name
406
+ except OSError as exc:
407
+ logger.error("Error creating temp dashboard file: %s", exc)
408
+ return None
409
+
410
+
411
+ def _register_temp_html_cleanup(html_path: str) -> None:
412
+ """Remove generated dashboard HTML on process exit."""
413
+
414
+ def _unlink() -> None:
415
+ try:
416
+ os.unlink(html_path)
417
+ except OSError:
418
+ pass
419
+
420
+ atexit.register(_unlink)
421
+
422
+
423
+ def start_dashboard_http_server(
424
+ resource_root: str,
425
+ injected_html_path: str,
426
+ *,
427
+ project_root: Optional[str] = None,
428
+ log_error: Callable[[str, Any], None] = logger.error,
429
+ log_warning: Callable[[str, Any], None] = logger.warning,
430
+ ) -> Optional[str]:
431
+ """Serve ``collab/dashboard`` and return the URL to the injected HTML file."""
432
+ dash_dir = dashboard_directory(resource_root)
433
+ filename = os.path.basename(injected_html_path)
434
+ env_root = project_root or resource_root
435
+
436
+ try:
437
+ handler_cls = create_dashboard_handler(env_root, dash_dir)
438
+ server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), handler_cls)
439
+ port = server.server_address[1]
440
+
441
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
442
+ thread.start()
443
+
444
+ def _safe_shutdown() -> None:
445
+ try:
446
+ server.shutdown()
447
+ except BaseException:
448
+ pass
449
+ close = getattr(server, "server_close", None)
450
+ if callable(close):
451
+ try:
452
+ close()
453
+ except OSError:
454
+ pass
455
+
456
+ atexit.register(_safe_shutdown)
457
+ _register_temp_html_cleanup(injected_html_path)
458
+
459
+ url = f"http://127.0.0.1:{port}/{filename}"
460
+
461
+ import socket
462
+
463
+ for _ in range(20):
464
+ try:
465
+ with socket.create_connection(("127.0.0.1", port), timeout=0.3):
466
+ break
467
+ except OSError:
468
+ time.sleep(0.05)
469
+
470
+ return url
471
+ except OSError as exc:
472
+ log_error("Failed to start local dashboard server: %s", exc)
473
+ try:
474
+ os.unlink(injected_html_path)
475
+ except OSError as cleanup_exc:
476
+ log_warning("Dashboard temp-file cleanup failed: %s", cleanup_exc)
477
+ return None
478
+
479
+
480
+ def prepare_dashboard_server(
481
+ resource_root: str,
482
+ injected: dict[str, Any],
483
+ *,
484
+ project_root: Optional[str] = None,
485
+ log_error: Callable[[str, Any], None] = logger.error,
486
+ log_warning: Callable[[str, Any], None] = logger.warning,
487
+ ) -> Tuple[Optional[str], Optional[str]]:
488
+ """Write injected HTML, start server from dashboard dir; return (url, path)."""
489
+ env_root = project_root or resource_root
490
+ enriched = {
491
+ **injected,
492
+ "projectName": resolve_project_display_name(env_root),
493
+ }
494
+ html_path = write_injected_dashboard_html(resource_root, enriched)
495
+ if not html_path:
496
+ return None, None
497
+
498
+ url = start_dashboard_http_server(
499
+ resource_root,
500
+ html_path,
501
+ project_root=project_root,
502
+ log_error=log_error,
503
+ log_warning=log_warning,
504
+ )
505
+ if not url:
506
+ return None, None
507
+ return url, html_path
@@ -282,6 +282,27 @@ def _git_capture_text(argv: list[str], *, cwd: str | None = None) -> str:
282
282
  return ""
283
283
 
284
284
 
285
+ def _git_capture_status_porcelain() -> str:
286
+ """Return ``git status --porcelain`` stdout, preserving leading whitespace.
287
+
288
+ Unlike :func:`_git_capture_text`, this trims only surrounding newlines and never
289
+ performs a full ``.strip()``. Porcelain lines begin with a 2-column status field
290
+ (XY) whose first column is a space for worktree-only changes (e.g. ``" M path"``).
291
+ Stripping the whole blob would remove the leading space of the FIRST line, shifting
292
+ the fixed-width parse in :func:`_parse_git_status_path` and silently dropping the
293
+ first character of that path.
294
+ """
295
+ try:
296
+ captured = safe_subprocess.capture(
297
+ ["git", "status", "--porcelain"], policy="git", cwd=_PROJECT_ROOT
298
+ )
299
+ if captured.ok:
300
+ return safe_subprocess.decode_output(captured.stdout).strip("\r\n")
301
+ except Exception as exc:
302
+ logger.debug("git status --porcelain failed: %s", exc)
303
+ return ""
304
+
305
+
285
306
  def _get_developer_id() -> str:
286
307
  """Derive developer identity from git config or environment."""
287
308
  try:
@@ -795,7 +816,7 @@ def _get_modified_and_unpushed_files() -> set[str]:
795
816
 
796
817
  # Part 1: dirty/staged files
797
818
  try:
798
- out = _git_capture_text(["git", "status", "--porcelain"])
819
+ out = _git_capture_status_porcelain()
799
820
  if out:
800
821
  for line in out.splitlines():
801
822
  if len(line) > 3:
@@ -1720,9 +1720,11 @@ class LockClient:
1720
1720
  # processes for this workspace if the PID file is missing or stale.
1721
1721
  pid = self._read_pid()
1722
1722
  pids_to_stop: List[int] = []
1723
+ watcher_found = False
1723
1724
 
1724
1725
  if pid and self._is_process_alive(pid):
1725
1726
  pids_to_stop = [pid]
1727
+ watcher_found = True
1726
1728
  else:
1727
1729
  # Safety rail: during tests, never discover/stop external watcher
1728
1730
  # processes when the module is still using the production PID file.
@@ -1740,21 +1742,17 @@ class LockClient:
1740
1742
  self._remove_pid()
1741
1743
  return
1742
1744
 
1743
- # Attempt to discover live watcher processes related to this repo
1745
+ # Attempt to discover live watcher processes related to this repo.
1746
+ # Note: even when no Python watcher is found we still fall through
1747
+ # to the launcher-reaping step below, because an orphaned
1748
+ # ``collab.exe`` wrapper can outlive the watcher it spawned.
1744
1749
  try:
1745
1750
  found = self._discover_running_watchers()
1746
1751
  if found:
1747
1752
  pids_to_stop = found
1748
- else:
1749
- print("No running watcher found.")
1750
- logger.info("No running watcher found for this workspace")
1751
- self._remove_pid()
1752
- return
1753
+ watcher_found = True
1753
1754
  except Exception as e:
1754
1755
  logger.debug("Watcher discovery failed: %s", e)
1755
- print("No running watcher found.")
1756
- self._remove_pid()
1757
- return
1758
1756
 
1759
1757
  # Stop each discovered watcher PID (soft stop first, then force)
1760
1758
  for target_pid in pids_to_stop:
@@ -1864,6 +1862,25 @@ class LockClient:
1864
1862
  logger.info("Stopped watcher (PID: %d) (forced)", target_pid)
1865
1863
  print("✅ Stopped.")
1866
1864
 
1865
+ # Defense-in-depth (Windows): reap orphaned ``collab.exe`` /
1866
+ # ``collab-watcher.exe`` console-script wrappers in this namespace.
1867
+ # These keep the venv ``.exe`` image locked (EBUSY on delete) and can
1868
+ # outlive the Python watcher when started by an older IDE extension.
1869
+ # Give a well-behaved wrapper a brief moment to exit on its own first.
1870
+ if pids_to_stop:
1871
+ time.sleep(0.5)
1872
+ reaped = self._reap_collab_launchers()
1873
+ if reaped:
1874
+ logger.info("Reaped %d orphaned collab launcher wrapper(s)", reaped)
1875
+ print(
1876
+ f"✅ Cleaned up {reaped} leftover collab launcher "
1877
+ f"process(es) locking the virtualenv."
1878
+ )
1879
+
1880
+ if not watcher_found and not reaped:
1881
+ print("No running watcher found.")
1882
+ logger.info("No running watcher found for this workspace")
1883
+
1867
1884
  # Final cleanup: ensure canonical PID file removed
1868
1885
  try:
1869
1886
  self._remove_pid()
@@ -3335,7 +3352,13 @@ class LockClient:
3335
3352
  return ""
3336
3353
  if not captured.ok:
3337
3354
  return ""
3338
- return safe_subprocess.decode_output(captured.stdout).strip()
3355
+ # NOTE: Only trim surrounding newlines, never a full ``.strip()``.
3356
+ # ``git status --porcelain`` lines begin with a 2-column status field
3357
+ # (XY) whose first column is a space for worktree-only changes (e.g.
3358
+ # " M path"). A full strip would remove the leading space of the FIRST
3359
+ # line, shifting the fixed-width parse in ``_parse_git_status_path`` and
3360
+ # silently dropping the first character of that path.
3361
+ return safe_subprocess.decode_output(captured.stdout).strip("\r\n")
3339
3362
 
3340
3363
  @staticmethod
3341
3364
  def _git_ref_exists(ref: str) -> bool:
@@ -3950,6 +3973,123 @@ class LockClient:
3950
3973
  continue
3951
3974
  return found
3952
3975
 
3976
+ def _launcher_cmdline_in_namespace(self, cmdline: str) -> bool:
3977
+ """Return True for a watcher-launcher cmdline in this PID-file namespace.
3978
+
3979
+ Used to identify pip console-script wrappers (``collab.exe`` / ``collab-
3980
+ watcher.exe``) that launched *this* workspace's watcher. Requires a ``watch``
3981
+ invocation and a ``--pid-file`` matching the current namespace so launchers from
3982
+ unrelated workspaces are never targeted.
3983
+ """
3984
+ if not cmdline:
3985
+ return False
3986
+ if "watch" not in cmdline.lower():
3987
+ return False
3988
+ return self._cmdline_matches_current_pid_namespace(cmdline)
3989
+
3990
+ def _discover_collab_launcher_pids(self) -> List[int]:
3991
+ """Find running collab console-script launcher wrappers for this namespace.
3992
+
3993
+ Windows-only. The pip-generated ``collab.exe`` / ``collab-watcher.exe`` wrappers
3994
+ keep their own image file open for their entire lifetime, so an orphaned wrapper
3995
+ (e.g. spawned by an older IDE extension) blocks deletion of the virtualenv long
3996
+ after the underlying Python watcher has exited. Returns candidate launcher PIDs
3997
+ to reap (may be empty).
3998
+ """
3999
+ if sys.platform != "win32":
4000
+ return []
4001
+
4002
+ launcher_names = {"collab.exe", "collab-watcher.exe"}
4003
+ candidates: set[int] = set()
4004
+ self_pid = os.getpid()
4005
+
4006
+ # Fast path: psutil enumeration.
4007
+ try:
4008
+ import psutil
4009
+
4010
+ for p in psutil.process_iter(attrs=("pid", "name", "cmdline")):
4011
+ try:
4012
+ pid = int(p.info.get("pid") or 0)
4013
+ if pid <= 0 or pid == self_pid:
4014
+ continue
4015
+ name = (p.info.get("name") or "").lower()
4016
+ if name not in launcher_names:
4017
+ continue
4018
+ cmdline = p.info.get("cmdline")
4019
+ cmd_str = (
4020
+ " ".join(cmdline)
4021
+ if isinstance(cmdline, (list, tuple))
4022
+ else str(cmdline or "")
4023
+ )
4024
+ if self._launcher_cmdline_in_namespace(cmd_str):
4025
+ candidates.add(pid)
4026
+ except Exception:
4027
+ continue
4028
+ return sorted(candidates)
4029
+ except Exception as exc:
4030
+ logger.debug("psutil launcher discovery unavailable/failed: %s", exc)
4031
+
4032
+ # Fallback: tasklist enumeration + per-PID cmdline lookup.
4033
+ try:
4034
+ for pid in platform_probe.iter_collab_launcher_pids():
4035
+ if pid == self_pid:
4036
+ continue
4037
+ cmd = self._get_cmdline_for_pid(pid)
4038
+ if cmd and self._launcher_cmdline_in_namespace(cmd):
4039
+ candidates.add(pid)
4040
+ except Exception as exc:
4041
+ logger.debug("tasklist launcher discovery failed: %s", exc)
4042
+ return sorted(candidates)
4043
+
4044
+ def _reap_collab_launchers(self) -> int:
4045
+ """Force-terminate orphaned collab launcher wrappers in this namespace.
4046
+
4047
+ Windows-only defense-in-depth for ``daemon_stop``. The Python watcher is now
4048
+ launched via the interpreter, but older/already-deployed IDE extensions launched
4049
+ it through the ``collab.exe`` console-script wrapper. That wrapper can be left
4050
+ running (holding the venv ``.exe`` locked) even after the watcher PID is
4051
+ stopped. Reaping it makes the virtualenv deletable. Returns the number of
4052
+ wrappers terminated.
4053
+ """
4054
+ if sys.platform != "win32" or _is_test_mode():
4055
+ return 0
4056
+
4057
+ try:
4058
+ launchers = self._discover_collab_launcher_pids()
4059
+ except Exception as exc:
4060
+ logger.debug("collab launcher discovery failed: %s", exc)
4061
+ return 0
4062
+
4063
+ skip = {os.getpid()}
4064
+ try:
4065
+ skip.add(os.getppid())
4066
+ except Exception:
4067
+ pass
4068
+
4069
+ reaped = 0
4070
+ for lpid in launchers:
4071
+ if lpid in skip:
4072
+ continue
4073
+ if not self._is_process_alive(lpid):
4074
+ continue
4075
+ logger.info(
4076
+ "Reaping orphaned collab launcher wrapper (PID: %d) holding venv .exe",
4077
+ lpid,
4078
+ )
4079
+ platform_probe.taskkill_force(lpid, tree=True)
4080
+ # Confirm termination; log if it stubbornly survives.
4081
+ for _ in range(10):
4082
+ if not self._is_process_alive(lpid):
4083
+ break
4084
+ time.sleep(0.2)
4085
+ if self._is_process_alive(lpid):
4086
+ logger.warning(
4087
+ "Collab launcher wrapper (PID: %d) survived reap attempt", lpid
4088
+ )
4089
+ else:
4090
+ reaped += 1
4091
+ return reaped
4092
+
3953
4093
  def _read_pid_file(self) -> Optional[Dict[str, Any]]:
3954
4094
  """Read the PID file and return the metadata dictionary if available."""
3955
4095
  if not os.path.exists(PID_FILE):
@@ -20,6 +20,11 @@ logger = logging.getLogger("collab.platform_probe")
20
20
 
21
21
  _WIN_CREATION_FLAGS = 0x08000000
22
22
  _PYTHON_IMAGE_NAMES = frozenset({"python.exe", "pythonw.exe", "python3.exe"})
23
+ # pip-generated console-script wrappers for the collab runtime. On Windows these
24
+ # ``.exe`` launchers hold their own image file open for the life of the process,
25
+ # which can block deletion of the virtualenv when a watcher was started through
26
+ # the wrapper (older IDE extensions) and the wrapper is left orphaned.
27
+ _COLLAB_LAUNCHER_IMAGE_NAMES = frozenset({"collab.exe", "collab-watcher.exe"})
23
28
 
24
29
 
25
30
  def _require_pid(pid: int) -> int:
@@ -50,7 +55,7 @@ def _run_platform(
50
55
  "timeout": timeout,
51
56
  "text": text,
52
57
  }
53
- if sys.platform == "win32":
58
+ if os.name == "nt":
54
59
  kwargs["creationflags"] = _WIN_CREATION_FLAGS
55
60
  try:
56
61
  completed = sp.run(list(argv), capture_output=True, **kwargs)
@@ -283,6 +288,41 @@ def ps_aux() -> str:
283
288
  return _run_platform([exe, "aux"], timeout=60.0)
284
289
 
285
290
 
291
+ def iter_collab_launcher_pids() -> list[int]:
292
+ """Collect PIDs for collab console-script launcher images (Windows).
293
+
294
+ Enumerates ``collab.exe`` and ``collab-watcher.exe`` processes via tasklist so
295
+ callers can reap orphaned wrappers that keep the virtualenv ``.exe`` locked. Returns
296
+ an empty list off Windows or when tasklist is unavailable.
297
+ """
298
+ if sys.platform != "win32":
299
+ return []
300
+ pids: list[int] = []
301
+ seen: set[int] = set()
302
+ exe = _resolve("tasklist")
303
+ if not exe:
304
+ return []
305
+ for image in sorted(_COLLAB_LAUNCHER_IMAGE_NAMES):
306
+ out = _run_platform(
307
+ [exe, "/FI", f"IMAGENAME eq {image}", "/FO", "CSV", "/NH"],
308
+ timeout=30.0,
309
+ )
310
+ for line in out.splitlines():
311
+ line = line.strip()
312
+ if not line:
313
+ continue
314
+ parts = line.strip().strip('"').split('","')
315
+ if len(parts) >= 2:
316
+ try:
317
+ pid = int(parts[1])
318
+ if pid not in seen:
319
+ seen.add(pid)
320
+ pids.append(pid)
321
+ except (ValueError, IndexError):
322
+ continue
323
+ return pids
324
+
325
+
286
326
  def iter_tasklist_python_pids() -> list[int]:
287
327
  """Collect PIDs from tasklist for known Python image names."""
288
328
  pids: list[int] = []
@@ -22,6 +22,15 @@ logger = logging.getLogger("collab.safe_subprocess")
22
22
  DEFAULT_CAPTURE_TIMEOUT_S = 30.0
23
23
  DEFAULT_RUN_TIMEOUT_S = 60.0
24
24
 
25
+ # CREATE_NO_WINDOW — hide console for background git probes on Windows.
26
+ _WIN_CREATE_NO_WINDOW = 0x08000000
27
+
28
+
29
+ def _host_supports_creationflags() -> bool:
30
+ """True only on a real Windows host (not ``sys.platform`` test doubles)."""
31
+ return os.name == "nt"
32
+
33
+
25
34
  # Git subcommands used by lock_client / live_locks_watcher.
26
35
  _ALLOWED_GIT_SUBCOMMANDS = frozenset(
27
36
  {
@@ -229,8 +238,8 @@ def capture(
229
238
  kwargs["env"] = dict(env)
230
239
  if text:
231
240
  kwargs["text"] = True
232
- if sys.platform == "win32":
233
- kwargs["creationflags"] = 0x08000000
241
+ if _host_supports_creationflags():
242
+ kwargs["creationflags"] = _WIN_CREATE_NO_WINDOW
234
243
  try:
235
244
  out = sp.check_output(list(safe_argv), **kwargs)
236
245
  return CaptureResult(
@@ -271,8 +280,8 @@ def run(
271
280
  kwargs: dict[str, Any] = {"cwd": cwd, "timeout": timeout}
272
281
  if capture_output:
273
282
  kwargs["capture_output"] = True
274
- if sys.platform == "win32":
275
- kwargs["creationflags"] = 0x08000000
283
+ if _host_supports_creationflags():
284
+ kwargs["creationflags"] = _WIN_CREATE_NO_WINDOW
276
285
  try:
277
286
  completed = sp.run(list(safe_argv), **kwargs)
278
287
  return RunResult(
@@ -301,7 +310,7 @@ def spawn_background(
301
310
  "cwd": cwd,
302
311
  "close_fds": True,
303
312
  }
304
- if sys.platform == "win32":
313
+ if _host_supports_creationflags():
305
314
  popen_kwargs["creationflags"] = creationflags
306
315
  else:
307
316
  popen_kwargs["start_new_session"] = start_new_session
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: collab-runtime
3
- Version: 0.4.0
3
+ Version: 0.4.2
4
4
  Summary: Collaborative file locking runtime
5
5
  Author-email: KirilMT <kiril.mt95@gmail.com>
6
6
  License-Expression: MIT
@@ -13,6 +13,7 @@ collab/main.py
13
13
  collab/platform_probe.py
14
14
  collab/safe_subprocess.py
15
15
  collab/subprocess_bridge.py
16
+ collab/dashboard/dashboard-format.js
16
17
  collab/dashboard/index.html
17
18
  collab_runtime.egg-info/PKG-INFO
18
19
  collab_runtime.egg-info/SOURCES.txt
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "collab-runtime"
7
- version = "0.4.0"
7
+ version = "0.4.2"
8
8
  description = "Collaborative file locking runtime"
9
9
  readme = "docs/pypi/README.md"
10
10
  license = "MIT"
@@ -40,7 +40,10 @@ where = ["."]
40
40
  include = ["collab*"]
41
41
 
42
42
  [tool.setuptools.package-data]
43
- collab = ["dashboard/index.html"]
43
+ # Ship every dashboard static asset (HTML/JS/CSS) recursively so a wheel can never
44
+ # be missing a file referenced by index.html. A recursive glob means new assets are
45
+ # packaged automatically without re-listing each filename.
46
+ collab = ["dashboard/**"]
44
47
 
45
48
  [tool.black]
46
49
  line-length = 88
@@ -1,250 +0,0 @@
1
- """Local HTTP server for the collaborative dashboard static assets.
2
-
3
- The dashboard HTML references sibling static files (e.g. ``dashboard-format.js``). The
4
- injected config HTML must therefore be written *inside* ``collab/dashboard/`` and served
5
- from that directory — not from a lone temp file in ``/tmp``.
6
- """
7
-
8
- from __future__ import annotations
9
-
10
- import atexit
11
- import http.server
12
- import json
13
- import logging
14
- import os
15
- import tempfile
16
- import threading
17
- import time
18
- import urllib.parse
19
- from typing import Any, Callable, Optional, Tuple
20
-
21
- logger = logging.getLogger(__name__)
22
-
23
- DASHBOARD_TEMP_PREFIX = ".collab-dashboard-"
24
- RUNTIME_CONFIG_PATH = "/collab-runtime-config.json"
25
-
26
-
27
- def load_runtime_supabase_config(project_root: str) -> dict[str, Any]:
28
- """Read ``.env`` from *project_root* and return dashboard Supabase settings.
29
-
30
- The local dashboard fetches this on each sync so credential changes take effect
31
- without restarting the watcher or reopening a stale browser tab. The project
32
- ``.env`` is read directly (not via ``load_dotenv``) so polling never mutates the
33
- running watcher's process environment.
34
- """
35
- from dotenv import dotenv_values
36
-
37
- env_path = os.path.join(project_root, ".env")
38
- file_vals: dict[str, Any] = {}
39
- if os.path.isfile(env_path):
40
- try:
41
- file_vals = dict(dotenv_values(env_path))
42
- except OSError as exc:
43
- logger.debug("Could not read %s for runtime config: %s", env_path, exc)
44
-
45
- def pick(name: str) -> Optional[str]:
46
- val = file_vals.get(name)
47
- if not val:
48
- val = os.getenv(name)
49
- return val
50
-
51
- url = pick("SUPABASE_URL") or ""
52
- anon = pick("SUPABASE_ANON_KEY") or ""
53
- service = pick("SUPABASE_SERVICE_ROLE_KEY") or None
54
- user = (
55
- pick("COLLAB_DEVELOPER_ID")
56
- or pick("DEVELOPER_ID")
57
- or os.getenv("USERNAME")
58
- or os.getenv("USER")
59
- or ""
60
- )
61
- from . import agent_identity
62
-
63
- state_override = os.getenv("COLLAB_STATE_DIR", "").strip()
64
- state_dir = state_override or os.path.join(project_root, ".collab")
65
- agent_id = agent_identity.resolve_agent_id(state_dir)
66
- agent_label = agent_identity.resolve_agent_label()
67
- return {
68
- "url": url,
69
- "anonKey": anon,
70
- "serviceKey": service,
71
- "user": user,
72
- "agentId": agent_id,
73
- "agentLabel": agent_label,
74
- }
75
-
76
-
77
- def create_dashboard_handler(project_root: str, directory: str) -> type:
78
- """Build a request handler that serves static assets and live runtime config."""
79
-
80
- class DashboardHandler(http.server.SimpleHTTPRequestHandler):
81
- """Serve dashboard static files without per-request stderr logging."""
82
-
83
- def __init__(self, *args: Any, **kwargs: Any) -> None:
84
- super().__init__(*args, directory=directory, **kwargs)
85
-
86
- def log_message(self, format: str, *args: Any) -> None:
87
- """Suppress default SimpleHTTPRequestHandler request log lines."""
88
-
89
- def do_GET(self) -> None:
90
- parsed = urllib.parse.urlparse(self.path)
91
- if parsed.path == RUNTIME_CONFIG_PATH:
92
- self._serve_runtime_config()
93
- return
94
- super().do_GET()
95
-
96
- def _serve_runtime_config(self) -> None:
97
- payload = load_runtime_supabase_config(project_root)
98
- body = json.dumps(payload).encode("utf-8")
99
- self.send_response(200)
100
- self.send_header("Content-Type", "application/json; charset=utf-8")
101
- self.send_header("Content-Length", str(len(body)))
102
- self.send_header("Cache-Control", "no-store")
103
- self.end_headers()
104
- self.wfile.write(body)
105
-
106
- return DashboardHandler
107
-
108
-
109
- def dashboard_directory(resource_root: str) -> str:
110
- """Return the path to packaged dashboard static assets."""
111
- return os.path.join(resource_root, "dashboard")
112
-
113
-
114
- def read_dashboard_template(resource_root: str) -> Optional[str]:
115
- """Read ``index.html`` from the dashboard package directory."""
116
- html_path = os.path.join(dashboard_directory(resource_root), "index.html")
117
- if not os.path.exists(html_path):
118
- logger.error("Dashboard file not found at %s", html_path)
119
- return None
120
- try:
121
- with open(html_path, "r", encoding="utf-8") as fh:
122
- return fh.read()
123
- except OSError as exc:
124
- logger.error("Error reading dashboard template: %s", exc)
125
- return None
126
-
127
-
128
- def write_injected_dashboard_html(
129
- resource_root: str, injected: dict[str, Any]
130
- ) -> Optional[str]:
131
- """Write config-injected HTML next to static assets; return path or None."""
132
- content = read_dashboard_template(resource_root)
133
- if content is None:
134
- return None
135
-
136
- dash_dir = dashboard_directory(resource_root)
137
- inject_script = (
138
- f"<script>window.__SUPABASE_CONFIG__ = {json.dumps(injected)};</script>\n"
139
- )
140
- try:
141
- tmp = tempfile.NamedTemporaryFile(
142
- mode="w",
143
- delete=False,
144
- suffix=".html",
145
- prefix=DASHBOARD_TEMP_PREFIX,
146
- dir=dash_dir,
147
- encoding="utf-8",
148
- )
149
- tmp.write(inject_script)
150
- tmp.write(content)
151
- tmp.flush()
152
- tmp.close()
153
- return tmp.name
154
- except OSError as exc:
155
- logger.error("Error creating temp dashboard file: %s", exc)
156
- return None
157
-
158
-
159
- def _register_temp_html_cleanup(html_path: str) -> None:
160
- """Remove generated dashboard HTML on process exit."""
161
-
162
- def _unlink() -> None:
163
- try:
164
- os.unlink(html_path)
165
- except OSError:
166
- pass
167
-
168
- atexit.register(_unlink)
169
-
170
-
171
- def start_dashboard_http_server(
172
- resource_root: str,
173
- injected_html_path: str,
174
- *,
175
- project_root: Optional[str] = None,
176
- log_error: Callable[[str, Any], None] = logger.error,
177
- log_warning: Callable[[str, Any], None] = logger.warning,
178
- ) -> Optional[str]:
179
- """Serve ``collab/dashboard`` and return the URL to the injected HTML file."""
180
- dash_dir = dashboard_directory(resource_root)
181
- filename = os.path.basename(injected_html_path)
182
- env_root = project_root or resource_root
183
-
184
- try:
185
- handler_cls = create_dashboard_handler(env_root, dash_dir)
186
- server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), handler_cls)
187
- port = server.server_address[1]
188
-
189
- thread = threading.Thread(target=server.serve_forever, daemon=True)
190
- thread.start()
191
-
192
- def _safe_shutdown() -> None:
193
- try:
194
- server.shutdown()
195
- except BaseException:
196
- pass
197
- close = getattr(server, "server_close", None)
198
- if callable(close):
199
- try:
200
- close()
201
- except OSError:
202
- pass
203
-
204
- atexit.register(_safe_shutdown)
205
- _register_temp_html_cleanup(injected_html_path)
206
-
207
- url = f"http://127.0.0.1:{port}/{filename}"
208
-
209
- import socket
210
-
211
- for _ in range(20):
212
- try:
213
- with socket.create_connection(("127.0.0.1", port), timeout=0.3):
214
- break
215
- except OSError:
216
- time.sleep(0.05)
217
-
218
- return url
219
- except OSError as exc:
220
- log_error("Failed to start local dashboard server: %s", exc)
221
- try:
222
- os.unlink(injected_html_path)
223
- except OSError as cleanup_exc:
224
- log_warning("Dashboard temp-file cleanup failed: %s", cleanup_exc)
225
- return None
226
-
227
-
228
- def prepare_dashboard_server(
229
- resource_root: str,
230
- injected: dict[str, Any],
231
- *,
232
- project_root: Optional[str] = None,
233
- log_error: Callable[[str, Any], None] = logger.error,
234
- log_warning: Callable[[str, Any], None] = logger.warning,
235
- ) -> Tuple[Optional[str], Optional[str]]:
236
- """Write injected HTML, start server from dashboard dir; return (url, path)."""
237
- html_path = write_injected_dashboard_html(resource_root, injected)
238
- if not html_path:
239
- return None, None
240
-
241
- url = start_dashboard_http_server(
242
- resource_root,
243
- html_path,
244
- project_root=project_root,
245
- log_error=log_error,
246
- log_warning=log_warning,
247
- )
248
- if not url:
249
- return None, None
250
- return url, html_path
File without changes
File without changes
File without changes