mooring 0.2.2__tar.gz → 0.2.4__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 (66) hide show
  1. {mooring-0.2.2 → mooring-0.2.4}/PKG-INFO +1 -1
  2. {mooring-0.2.2 → mooring-0.2.4}/docs/admins/build-and-distribute.md +4 -0
  3. {mooring-0.2.2 → mooring-0.2.4}/docs/admins/configuration.md +59 -0
  4. {mooring-0.2.2 → mooring-0.2.4}/pyproject.toml +1 -1
  5. {mooring-0.2.2 → mooring-0.2.4}/src/mooring/__init__.py +1 -1
  6. {mooring-0.2.2 → mooring-0.2.4}/src/mooring/auth.py +20 -0
  7. {mooring-0.2.2 → mooring-0.2.4}/src/mooring/cli.py +128 -26
  8. {mooring-0.2.2 → mooring-0.2.4}/src/mooring/config.py +5 -0
  9. {mooring-0.2.2 → mooring-0.2.4}/src/mooring/config_default.toml +10 -0
  10. {mooring-0.2.2 → mooring-0.2.4}/src/mooring/config_store.py +25 -0
  11. {mooring-0.2.2 → mooring-0.2.4}/src/mooring/hub/server.py +35 -9
  12. mooring-0.2.4/src/mooring/paths.py +64 -0
  13. {mooring-0.2.2 → mooring-0.2.4}/src/mooring/sync.py +100 -25
  14. mooring-0.2.4/src/mooring/telemetry.py +243 -0
  15. {mooring-0.2.2 → mooring-0.2.4}/tests/conftest.py +19 -0
  16. {mooring-0.2.2 → mooring-0.2.4}/tests/test_auth.py +29 -0
  17. mooring-0.2.4/tests/test_cli_repo.py +181 -0
  18. {mooring-0.2.2 → mooring-0.2.4}/tests/test_config.py +30 -0
  19. {mooring-0.2.2 → mooring-0.2.4}/tests/test_config_store.py +26 -0
  20. mooring-0.2.4/tests/test_paths.py +33 -0
  21. {mooring-0.2.2 → mooring-0.2.4}/tests/test_sync.py +119 -5
  22. mooring-0.2.4/tests/test_telemetry.py +183 -0
  23. {mooring-0.2.2 → mooring-0.2.4}/uv.lock +1 -1
  24. mooring-0.2.2/src/mooring/paths.py +0 -33
  25. mooring-0.2.2/tests/test_cli_repo.py +0 -86
  26. {mooring-0.2.2 → mooring-0.2.4}/.github/workflows/docs.yml +0 -0
  27. {mooring-0.2.2 → mooring-0.2.4}/.github/workflows/release.yml +0 -0
  28. {mooring-0.2.2 → mooring-0.2.4}/.gitignore +0 -0
  29. {mooring-0.2.2 → mooring-0.2.4}/README.md +0 -0
  30. {mooring-0.2.2 → mooring-0.2.4}/docs/admins/github-setup.md +0 -0
  31. {mooring-0.2.2 → mooring-0.2.4}/docs/admins/index.md +0 -0
  32. {mooring-0.2.2 → mooring-0.2.4}/docs/assets/images/anchor-mark.svg +0 -0
  33. {mooring-0.2.2 → mooring-0.2.4}/docs/assets/images/favicon.svg +0 -0
  34. {mooring-0.2.2 → mooring-0.2.4}/docs/assets/javascripts/landing.js +0 -0
  35. {mooring-0.2.2 → mooring-0.2.4}/docs/assets/stylesheets/landing.css +0 -0
  36. {mooring-0.2.2 → mooring-0.2.4}/docs/assets/stylesheets/oah-theme.css +0 -0
  37. {mooring-0.2.2 → mooring-0.2.4}/docs/developers/contributing.md +0 -0
  38. {mooring-0.2.2 → mooring-0.2.4}/docs/developers/index.md +0 -0
  39. {mooring-0.2.2 → mooring-0.2.4}/docs/index.md +0 -0
  40. {mooring-0.2.2 → mooring-0.2.4}/docs/users/cli.md +0 -0
  41. {mooring-0.2.2 → mooring-0.2.4}/docs/users/conflicts.md +0 -0
  42. {mooring-0.2.2 → mooring-0.2.4}/docs/users/daily-workflow.md +0 -0
  43. {mooring-0.2.2 → mooring-0.2.4}/docs/users/index.md +0 -0
  44. {mooring-0.2.2 → mooring-0.2.4}/docs/users/power-bi.md +0 -0
  45. {mooring-0.2.2 → mooring-0.2.4}/overrides/home.html +0 -0
  46. {mooring-0.2.2 → mooring-0.2.4}/scripts/release.ps1 +0 -0
  47. {mooring-0.2.2 → mooring-0.2.4}/src/mooring/editor.py +0 -0
  48. {mooring-0.2.2 → mooring-0.2.4}/src/mooring/githost.py +0 -0
  49. {mooring-0.2.2 → mooring-0.2.4}/src/mooring/github.py +0 -0
  50. {mooring-0.2.2 → mooring-0.2.4}/src/mooring/gitsha.py +0 -0
  51. {mooring-0.2.2 → mooring-0.2.4}/src/mooring/hub/__init__.py +0 -0
  52. {mooring-0.2.2 → mooring-0.2.4}/src/mooring/hub/static/app.js +0 -0
  53. {mooring-0.2.2 → mooring-0.2.4}/src/mooring/hub/static/index.html +0 -0
  54. {mooring-0.2.2 → mooring-0.2.4}/src/mooring/hub/static/style.css +0 -0
  55. {mooring-0.2.2 → mooring-0.2.4}/src/mooring/manifest.py +0 -0
  56. {mooring-0.2.2 → mooring-0.2.4}/src/mooring/notebook_template.py +0 -0
  57. {mooring-0.2.2 → mooring-0.2.4}/src/mooring/pbip.py +0 -0
  58. {mooring-0.2.2 → mooring-0.2.4}/tests/manual_editor_check.py +0 -0
  59. {mooring-0.2.2 → mooring-0.2.4}/tests/test_githost.py +0 -0
  60. {mooring-0.2.2 → mooring-0.2.4}/tests/test_github.py +0 -0
  61. {mooring-0.2.2 → mooring-0.2.4}/tests/test_gitsha.py +0 -0
  62. {mooring-0.2.2 → mooring-0.2.4}/tests/test_hub.py +0 -0
  63. {mooring-0.2.2 → mooring-0.2.4}/tests/test_manifest.py +0 -0
  64. {mooring-0.2.2 → mooring-0.2.4}/tests/test_pbip.py +0 -0
  65. {mooring-0.2.2 → mooring-0.2.4}/tests/test_truststore.py +0 -0
  66. {mooring-0.2.2 → mooring-0.2.4}/zensical.toml +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mooring
3
- Version: 0.2.2
3
+ Version: 0.2.4
4
4
  Summary: Git-free marimo notebook sharing via GitHub
5
5
  Requires-Python: <3.13,>=3.12
6
6
  Requires-Dist: altair
@@ -27,6 +27,10 @@ Edit `src/mooring/config_default.toml` with your `client_id`, `owner`, `repo`,
27
27
  and `branch` (and adjust `[sync]` limits if needed) so analysts get a
28
28
  ready-to-use app. See [Configuration](configuration.md) for every key.
29
29
 
30
+ To watch usage and field errors across your team, set `[logging] endpoint` to a
31
+ collector URL or a shared folder/UNC path — see
32
+ [Central logging](configuration.md#central-logging). Leave it empty to disable.
33
+
30
34
  ## 2. Build the artifacts
31
35
 
32
36
  ```bash
@@ -83,6 +83,13 @@ direction.
83
83
  |-----|---------|---------|
84
84
  | `path` | `""` | Override the workspace location (single-repo form; with `[repos]`, use the per-repo `workspace` key). Empty means `~/Documents/mooring/<owner>/<repo>`. Supports `~` expansion. |
85
85
 
86
+ ### `[logging]`
87
+
88
+ | Key | Default | Meaning |
89
+ |-----|---------|---------|
90
+ | `endpoint` | `""` | Where to send usage/error events. Empty disables logging. See [Central logging](#central-logging) for the auto-detected URL-vs-path behaviour. |
91
+ | `level` | `"info"` | `"info"` logs usage events **and** errors; `"error"` logs only errors. |
92
+
86
93
  ## The packaged default file
87
94
 
88
95
  `src/mooring/config_default.toml` is baked into every build. Edit it **before
@@ -102,6 +109,10 @@ max_file_mb = 45
102
109
 
103
110
  [workspace]
104
111
  path = "" # empty = ~/Documents/mooring/<owner>/<repo>
112
+
113
+ [logging]
114
+ endpoint = "" # optional; see Central logging below
115
+ level = "info"
105
116
  ```
106
117
 
107
118
  To bake **several** repos in, use the `[repos]` form shown above instead of
@@ -172,10 +183,58 @@ integration testing and CI, but work anywhere:
172
183
  | `MOORING_WORKSPACE` | The active repo's workspace path |
173
184
  | `MOORING_TOKEN` | The stored auth token — set this to skip device-flow login entirely (a personal access token works). |
174
185
  | `MOORING_TRUSTSTORE` | Set to `0` to disable [OS trust store TLS verification](#corporate-networks-tls) and fall back to the bundled CA list. |
186
+ | `MOORING_LOG_ENDPOINT` | `[logging] endpoint` — the central log destination (see [Central logging](#central-logging)). |
187
+ | `MOORING_LOG_LEVEL` | `[logging] level` — `info` or `error`. |
175
188
 
176
189
  See [Contributing](../developers/contributing.md#integration-testing) for using
177
190
  these to test against a scratch repo.
178
191
 
192
+ ## Central logging
193
+
194
+ Set `[logging] endpoint` (baked into the build, or in a user `config.toml`) to
195
+ collect a record of how the app is used and what fails, from every copy, in one
196
+ place. It is **off by default** — no endpoint, no logging. When an endpoint is
197
+ set, logging is always on for users (there is no per-user off switch).
198
+
199
+ The value is **auto-detected**:
200
+
201
+ - An `http://` / `https://` URL → each event is **POSTed as JSON** to that URL.
202
+ HTTPS uses the OS trust store like the rest of the app, so a corporate
203
+ proxy's root CA is honoured automatically.
204
+ - Anything else is treated as a **folder or UNC path** → events are appended to
205
+ a per-user file `<os-user>@<host>.jsonl` in that folder (e.g.
206
+ `\\fileserver\share\mooring-logs`). One file per user means no write
207
+ contention between teammates on a shared drive.
208
+
209
+ ```toml
210
+ [logging]
211
+ endpoint = "https://collector.example.com/mooring" # or \\server\share\mooring-logs
212
+ level = "info" # "info" = usage + errors; "error" = errors only
213
+ ```
214
+
215
+ ### What gets logged
216
+
217
+ Each event is one JSON object: a UTC timestamp, the event name, identity, and a
218
+ few event-specific fields. Identity is **OS username, hostname, app version, OS,
219
+ Python version, and the GitHub login** (added once the user has logged in).
220
+
221
+ ```json
222
+ {"ts":"2026-06-13T12:34:56.789Z","event":"push","version":"0.2.2",
223
+ "os_user":"jdoe","host":"FIN-LT-042","os":"Windows-11-10.0.26200",
224
+ "python":"3.12.4","user":"octocat","pushed":3,"conflicts":0,"lines":4}
225
+ ```
226
+
227
+ Events cover the app/command start, login/logout, repo add/switch/remove,
228
+ pull/push/propose (with counts), open/new, and errors (`event:"error"` with the
229
+ exception type and message). **No file contents, file paths, or full tracebacks
230
+ are ever sent** — only counts and coarse kinds. An error message may incidentally
231
+ contain a URL or path.
232
+
233
+ Logging is strictly best-effort: it runs on a background thread, never blocks a
234
+ command, and silently drops events if the destination is slow or unreachable
235
+ (the process still exits within a few seconds). `mooring selftest` prints a
236
+ `logging` line showing the active destination.
237
+
179
238
  ## Corporate networks & TLS
180
239
 
181
240
  Mooring verifies TLS connections against the **operating system's trust
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "mooring"
3
- version = "0.2.2"
3
+ version = "0.2.4"
4
4
  description = "Git-free marimo notebook sharing via GitHub"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12,<3.13"
@@ -1,3 +1,3 @@
1
1
  """Mooring: git-free marimo notebook sharing via GitHub."""
2
2
 
3
- __version__ = "0.2.2"
3
+ __version__ = "0.2.4"
@@ -40,6 +40,26 @@ class AuthError(Exception):
40
40
  pass
41
41
 
42
42
 
43
+ def device_flow_hint(host: str, exc: Exception) -> str:
44
+ """A friendly one-line explanation for a failed device-code request.
45
+
46
+ Names the host (and HTTP status, if any) so a misrouted login is obvious,
47
+ and only suggests setting a host when the request went to the default
48
+ github.com — a real GHE host that 404s has a different cause (device flow
49
+ disabled, or a client_id from the wrong instance).
50
+ """
51
+ status = getattr(getattr(exc, "response", None), "status_code", None)
52
+ head = f"Couldn't start GitHub login against {host}"
53
+ head += f" (HTTP {status})." if status else f": {exc}"
54
+ if host == githost.DEFAULT_HOST:
55
+ head += (
56
+ " If this repo is on GitHub Enterprise, set its host: run "
57
+ '`mooring login --host ghe.example.com`, or add `host = "ghe.example.com"` '
58
+ "under [github] in your config."
59
+ )
60
+ return head
61
+
62
+
43
63
  @dataclass
44
64
  class DeviceCode:
45
65
  device_code: str
@@ -9,7 +9,7 @@ import sys
9
9
  from collections.abc import Mapping
10
10
  from pathlib import Path
11
11
 
12
- from mooring import __version__, config, paths
12
+ from mooring import __version__, config, paths, telemetry
13
13
 
14
14
  SELFTEST_PACKAGES = (
15
15
  "marimo",
@@ -73,7 +73,13 @@ def _build_parser() -> argparse.ArgumentParser:
73
73
  hub.add_argument("--no-browser", action="store_true", help="don't open a browser tab")
74
74
  hub.add_argument("--port", type=int, default=None, help="fixed port for the hub server")
75
75
 
76
- sub.add_parser("login", help="log in to GitHub via device flow")
76
+ login = sub.add_parser("login", help="log in to GitHub via device flow")
77
+ login.add_argument(
78
+ "--host",
79
+ default=None,
80
+ help="GitHub host or URL for GitHub Enterprise (e.g. ghe.example.com); "
81
+ "saved as the global host before logging in",
82
+ )
77
83
  sub.add_parser("logout", help="forget the stored GitHub token")
78
84
  sub.add_parser("whoami", help="show the logged-in GitHub user")
79
85
  status = sub.add_parser("status", help="show sync status of workspace files")
@@ -98,7 +104,10 @@ def _build_parser() -> argparse.ArgumentParser:
98
104
  repo_use = repo_sub.add_parser("use", help="switch the active repo")
99
105
  repo_use.add_argument("alias")
100
106
  repo_rm = repo_sub.add_parser("remove", help="forget a repo (local files are kept)")
101
- repo_rm.add_argument("alias")
107
+ repo_rm.add_argument("alias", nargs="?", default=None, help="alias to remove (omit when using --all)")
108
+ repo_rm.add_argument(
109
+ "--all", dest="all_repos", action="store_true", help="remove every registered repo"
110
+ )
102
111
 
103
112
  pull = sub.add_parser("pull", help="download changes from the team repo")
104
113
  pull_grp = pull.add_mutually_exclusive_group()
@@ -143,8 +152,8 @@ def _print_paths(cfg: config.Config) -> None:
143
152
  print(f" config file : {paths.user_config_file()}")
144
153
  print(f" workspace : {cfg.workspace()}")
145
154
  print(f" logs : {paths.user_log_dir()}")
146
- hint = legacy_workspace_hint(cfg)
147
- if hint:
155
+ hints = (legacy_workspace_hint(cfg), paths.synced_folder_hint(cfg.workspace()))
156
+ for hint in (h for h in hints if h):
148
157
  print(f" note : {hint}")
149
158
 
150
159
 
@@ -162,7 +171,14 @@ def legacy_workspace_hint(cfg: config.Config) -> str:
162
171
  return ""
163
172
 
164
173
 
165
- def cmd_selftest(cfg: config.Config) -> int:
174
+ def workspace_hint(cfg: config.Config) -> str:
175
+ """Combined workspace warnings (legacy location + cloud-sync folder) for the
176
+ hub and selftest, joined into one line."""
177
+ hints = (legacy_workspace_hint(cfg), paths.synced_folder_hint(cfg.workspace()))
178
+ return " ".join(h for h in hints if h)
179
+
180
+
181
+ def cmd_selftest(app_cfg: config.AppConfig, cfg: config.Config) -> int:
166
182
  import importlib.metadata
167
183
 
168
184
  print(f"mooring {__version__} (python {sys.version.split()[0]}, {sys.executable})")
@@ -183,6 +199,12 @@ def cmd_selftest(cfg: config.Config) -> int:
183
199
  else "OS trust store (truststore)"
184
200
  )
185
201
  print(f" tls trust : {tls}")
202
+ log_dest = app_cfg.log_endpoint.strip()
203
+ if log_dest:
204
+ kind = "url" if log_dest.lower().startswith(("http://", "https://")) else "path"
205
+ print(f" logging : on -> {log_dest} ({kind})")
206
+ else:
207
+ print(" logging : off (no endpoint configured)")
186
208
  if cfg.is_configured:
187
209
  print(f" team repo : {cfg.repo_slug} (branch {cfg.branch}, host {cfg.host})")
188
210
  else:
@@ -214,15 +236,28 @@ def _client(cfg: config.Config):
214
236
  return GitHubClient(_require_token(cfg), cfg.owner, cfg.repo, host=cfg.host)
215
237
 
216
238
 
217
- def cmd_login(cfg: config.Config) -> int:
218
- from mooring import auth
239
+ def cmd_login(cfg: config.Config, host: str | None = None) -> int:
240
+ import requests
219
241
 
242
+ from mooring import auth, config_store
243
+
244
+ if host is not None:
245
+ try:
246
+ new_host = config_store.set_host(host)
247
+ except ValueError as exc:
248
+ sys.exit(str(exc))
249
+ print(f"Saved GitHub host: {new_host}")
250
+ cfg = config.load_config() # pick up the host just written
220
251
  if not cfg.client_id:
221
252
  sys.exit(
222
253
  "No OAuth client_id configured. Set [github] client_id in "
223
254
  f"{paths.user_config_file()}."
224
255
  )
225
- device = auth.start_device_flow(cfg.client_id, host=cfg.host)
256
+ print(f"Requesting device code from {cfg.host}…")
257
+ try:
258
+ device = auth.start_device_flow(cfg.client_id, host=cfg.host)
259
+ except (auth.AuthError, requests.RequestException) as exc:
260
+ sys.exit(auth.device_flow_hint(cfg.host, exc))
226
261
  print(f"Open {device.verification_uri} and enter code: {device.user_code}")
227
262
  print("Waiting for authorization...")
228
263
  token = auth.poll_for_token(cfg.client_id, device)
@@ -230,6 +265,8 @@ def cmd_login(cfg: config.Config) -> int:
230
265
  from mooring.github import GitHubClient
231
266
 
232
267
  user = GitHubClient(token, cfg.owner, cfg.repo, host=cfg.host).get_user()
268
+ telemetry.set_user(user["login"])
269
+ telemetry.log_event("login")
233
270
  print(f"Logged in as {user['login']}.")
234
271
  return 0
235
272
 
@@ -238,6 +275,7 @@ def cmd_logout(cfg: config.Config) -> int:
238
275
  from mooring import auth
239
276
 
240
277
  auth.delete_token(host=cfg.host)
278
+ telemetry.log_event("logout")
241
279
  print("Logged out.")
242
280
  return 0
243
281
 
@@ -246,6 +284,8 @@ def cmd_whoami(cfg: config.Config) -> int:
246
284
  from mooring.github import GitHubClient
247
285
 
248
286
  user = GitHubClient(_require_token(cfg), cfg.owner, cfg.repo, host=cfg.host).get_user()
287
+ telemetry.set_user(user["login"])
288
+ telemetry.log_event("whoami")
249
289
  print(user["login"])
250
290
  return 0
251
291
 
@@ -277,6 +317,13 @@ def cmd_pull(cfg: config.Config, theirs: bool, keep_both: bool) -> int:
277
317
  else sync.ConflictStrategy.SKIP
278
318
  )
279
319
  result = sync.pull(_client(cfg), cfg, strategy=strategy)
320
+ telemetry.log_event(
321
+ "pull",
322
+ pulled=result.pulled,
323
+ conflicts=len(result.skipped_conflicts),
324
+ lines=len(result.lines),
325
+ strategy=strategy.value,
326
+ )
280
327
  for line in result.lines:
281
328
  print(f" {line}")
282
329
  print(result.summary())
@@ -287,6 +334,12 @@ def cmd_push(cfg: config.Config, only_paths: list[str], message: str | None) ->
287
334
  from mooring import sync
288
335
 
289
336
  result = sync.push(_client(cfg), cfg, paths=only_paths or None, message=message)
337
+ telemetry.log_event(
338
+ "push",
339
+ pushed=result.pushed,
340
+ conflicts=len(result.blocked_conflicts),
341
+ lines=len(result.lines),
342
+ )
290
343
  for line in result.lines:
291
344
  print(f" {line}")
292
345
  print(result.summary())
@@ -297,6 +350,12 @@ def cmd_propose(cfg: config.Config, only_paths: list[str], message: str | None)
297
350
  from mooring import sync
298
351
 
299
352
  result = sync.propose(_client(cfg), cfg, paths=only_paths or None, message=message)
353
+ telemetry.log_event(
354
+ "propose",
355
+ proposed=result.proposed,
356
+ conflicts=len(result.blocked_conflicts),
357
+ review_branch=bool(result.review_branch),
358
+ )
300
359
  for line in result.lines:
301
360
  print(f" {line}")
302
361
  print(result.summary())
@@ -319,11 +378,13 @@ def cmd_open(cfg: config.Config, rel_path: str) -> int:
319
378
  pbip.launch(target)
320
379
  except pbip.PbipLaunchError as exc:
321
380
  sys.exit(str(exc))
381
+ telemetry.log_event("open", kind="pbip")
322
382
  print(f"Opened {rel_path} in Power BI Desktop.")
323
383
  return 0
324
384
  server = EditorServer(workspace)
325
385
  server.ensure_started()
326
386
  url = server.url_for(rel_path)
387
+ telemetry.log_event("open", kind="notebook")
327
388
  print(f"Editor running at {url} (Ctrl+C to stop)")
328
389
  webbrowser.open(url)
329
390
  try:
@@ -338,6 +399,7 @@ def cmd_new(cfg: config.Config, name: str) -> int:
338
399
 
339
400
  workspace = cfg.workspace()
340
401
  rel_path = notebook_template.create(workspace, name)
402
+ telemetry.log_event("new")
341
403
  print(f"Created {rel_path}")
342
404
  return cmd_open(cfg, rel_path)
343
405
 
@@ -368,6 +430,7 @@ def cmd_repo(app_cfg: config.AppConfig, args: argparse.Namespace) -> int:
368
430
  )
369
431
  except ValueError as exc:
370
432
  sys.exit(str(exc))
433
+ telemetry.log_event("repo_add", alias=alias)
371
434
  active = " (now active)" if not args.no_use else ""
372
435
  print(f"Registered {owner}/{repo} as {alias!r}{active}.")
373
436
  return 0
@@ -376,14 +439,30 @@ def cmd_repo(app_cfg: config.AppConfig, args: argparse.Namespace) -> int:
376
439
  config_store.set_active(args.alias)
377
440
  except KeyError:
378
441
  sys.exit(_unknown_alias(args.alias, app_cfg))
442
+ telemetry.log_event("repo_switch", alias=args.alias)
379
443
  print(f"Active repo is now {args.alias!r}.")
380
444
  return 0
381
445
  if args.repo_command == "remove":
446
+ if getattr(args, "all_repos", False):
447
+ aliases = list(app_cfg.aliases)
448
+ if not aliases:
449
+ print("No repos registered.")
450
+ return 0
451
+ config_store.remove_all_repos()
452
+ telemetry.log_event("repo_remove", alias="*")
453
+ print(
454
+ f"Removed all {len(aliases)} repo(s): {', '.join(aliases)}. "
455
+ "Workspace folders were kept; delete them manually."
456
+ )
457
+ return 0
458
+ if not args.alias:
459
+ sys.exit("Specify a repo alias to remove, or use --all.")
382
460
  try:
383
461
  ws = app_cfg.config_for(args.alias).workspace()
384
462
  config_store.remove_repo(args.alias)
385
463
  except KeyError:
386
464
  sys.exit(_unknown_alias(args.alias, app_cfg))
465
+ telemetry.log_event("repo_remove", alias=args.alias)
387
466
  print(f"Removed {args.alias!r}. Workspace folder {ws} was kept; delete it manually.")
388
467
  return 0
389
468
  return 2
@@ -394,28 +473,20 @@ def _unknown_alias(alias: str, app_cfg: config.AppConfig) -> str:
394
473
  return f"Unknown repo alias {alias!r}. Known: {known}"
395
474
 
396
475
 
397
- def main(argv: list[str] | None = None) -> int:
398
- _inject_truststore()
399
- _ensure_child_pythonpath()
400
- parser = _build_parser()
401
- args = parser.parse_args(argv)
402
- command = args.command or "hub"
403
- try:
404
- app_cfg = config.load_app_config()
405
- except ValueError as exc: # e.g. a malformed [github] host
406
- sys.exit(str(exc))
407
- try:
408
- cfg = app_cfg.config_for(getattr(args, "repo", None))
409
- except KeyError:
410
- sys.exit(_unknown_alias(args.repo, app_cfg))
411
-
476
+ def _dispatch(
477
+ parser: argparse.ArgumentParser,
478
+ command: str,
479
+ app_cfg: config.AppConfig,
480
+ cfg: config.Config,
481
+ args: argparse.Namespace,
482
+ ) -> int:
412
483
  if command == "version":
413
484
  print(f"mooring {__version__}")
414
485
  return 0
415
486
  if command == "repo":
416
487
  return cmd_repo(app_cfg, args)
417
488
  if command == "selftest":
418
- return cmd_selftest(cfg)
489
+ return cmd_selftest(app_cfg, cfg)
419
490
  if command == "hub":
420
491
  from mooring.hub.server import run_hub
421
492
 
@@ -423,7 +494,7 @@ def main(argv: list[str] | None = None) -> int:
423
494
  port = getattr(args, "port", None)
424
495
  return run_hub(app_cfg, open_browser=not no_browser, port=port)
425
496
  if command == "login":
426
- return cmd_login(cfg)
497
+ return cmd_login(cfg, getattr(args, "host", None))
427
498
  if command == "logout":
428
499
  return cmd_logout(cfg)
429
500
  if command == "whoami":
@@ -444,5 +515,36 @@ def main(argv: list[str] | None = None) -> int:
444
515
  return 2
445
516
 
446
517
 
518
+ def main(argv: list[str] | None = None) -> int:
519
+ _inject_truststore()
520
+ _ensure_child_pythonpath()
521
+ parser = _build_parser()
522
+ args = parser.parse_args(argv)
523
+ command = args.command or "hub"
524
+ try:
525
+ app_cfg = config.load_app_config()
526
+ except ValueError as exc: # e.g. a malformed [github] host
527
+ sys.exit(str(exc))
528
+ try:
529
+ cfg = app_cfg.config_for(getattr(args, "repo", None))
530
+ except KeyError:
531
+ sys.exit(_unknown_alias(args.repo, app_cfg))
532
+
533
+ telemetry.configure(
534
+ app_cfg.log_endpoint,
535
+ identity=telemetry.base_identity(),
536
+ level=app_cfg.log_level,
537
+ )
538
+ telemetry.log_event("app_start", command=command)
539
+
540
+ try:
541
+ return _dispatch(parser, command, app_cfg, cfg, args)
542
+ except SystemExit:
543
+ raise # user-facing errors (sys.exit / argparse) are not app failures
544
+ except BaseException as exc: # noqa: BLE001 - record genuine failures, then re-raise
545
+ telemetry.log_error(exc=exc, command=command)
546
+ raise
547
+
548
+
447
549
  if __name__ == "__main__":
448
550
  sys.exit(main())
@@ -66,6 +66,8 @@ class AppConfig:
66
66
  folders: tuple[str, ...] = ("notebooks", "data", "reports")
67
67
  warn_file_mb: int = 10
68
68
  max_file_mb: int = 45
69
+ log_endpoint: str = ""
70
+ log_level: str = "info"
69
71
 
70
72
  @property
71
73
  def aliases(self) -> list[str]:
@@ -174,6 +176,7 @@ def load_app_config(
174
176
  gh = data.get("github", {})
175
177
  sync = data.get("sync", {})
176
178
  ws = data.get("workspace", {})
179
+ log = data.get("logging", {})
177
180
 
178
181
  specs, active = repo_specs_from_data(data)
179
182
  if env.get("MOORING_ACTIVE_REPO") in {s.alias for s in specs}:
@@ -211,6 +214,8 @@ def load_app_config(
211
214
  folders=tuple(sync.get("folders", ("notebooks", "data", "reports"))),
212
215
  warn_file_mb=int(sync.get("warn_file_mb", 10)),
213
216
  max_file_mb=int(sync.get("max_file_mb", 45)),
217
+ log_endpoint=env.get("MOORING_LOG_ENDPOINT", str(log.get("endpoint", ""))),
218
+ log_level=env.get("MOORING_LOG_LEVEL", str(log.get("level", "info"))),
214
219
  )
215
220
 
216
221
 
@@ -30,3 +30,13 @@ max_file_mb = 45
30
30
 
31
31
  [workspace]
32
32
  path = "" # empty = ~/Documents/mooring/<owner>/<repo>
33
+
34
+ # Central logging. Set `endpoint` to collect usage events and errors from every
35
+ # copy of the app in one place. The value is auto-detected:
36
+ # http(s)://... -> each event is POSTed as JSON
37
+ # anything else -> treated as a folder/UNC path; events are appended as a
38
+ # per-user JSONL file (e.g. \\server\share\mooring-logs).
39
+ # Empty = disabled (the shipped default). When set, it is always on for users.
40
+ [logging]
41
+ endpoint = ""
42
+ level = "info" # "info" logs usage + errors; "error" logs only errors
@@ -83,6 +83,19 @@ def add_repo(
83
83
  write_user_data(data)
84
84
 
85
85
 
86
+ def set_host(host: str) -> str:
87
+ """Persist the global GitHub host; returns the normalized value.
88
+
89
+ Host is a single [github] setting shared by every repo, independent of the
90
+ [repos] registry, so this writes [github].host without materializing repos.
91
+ """
92
+ normalized = githost.normalize_host(host)
93
+ data = read_user_data()
94
+ data.setdefault("github", {})["host"] = normalized
95
+ write_user_data(data)
96
+ return normalized
97
+
98
+
86
99
  def remove_repo(alias: str) -> None:
87
100
  data = _materialized(read_user_data())
88
101
  if alias not in data["repos"] or alias in RESERVED_ALIASES:
@@ -97,6 +110,18 @@ def remove_repo(alias: str) -> None:
97
110
  write_user_data(data)
98
111
 
99
112
 
113
+ def remove_all_repos() -> None:
114
+ """Clear the entire repo registry. Workspaces and the saved token are kept.
115
+
116
+ An explicit empty [repos] is authoritative — it also overrides any
117
+ owner/repo baked into the packaged default (repo_specs_from_data treats a
118
+ present [repos] section as the whole truth).
119
+ """
120
+ data = read_user_data()
121
+ data["repos"] = {}
122
+ write_user_data(data)
123
+
124
+
100
125
  def set_active(alias: str) -> None:
101
126
  data = _materialized(read_user_data())
102
127
  if alias not in data["repos"] or alias in RESERVED_ALIASES: