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.
- {mooring-0.2.2 → mooring-0.2.4}/PKG-INFO +1 -1
- {mooring-0.2.2 → mooring-0.2.4}/docs/admins/build-and-distribute.md +4 -0
- {mooring-0.2.2 → mooring-0.2.4}/docs/admins/configuration.md +59 -0
- {mooring-0.2.2 → mooring-0.2.4}/pyproject.toml +1 -1
- {mooring-0.2.2 → mooring-0.2.4}/src/mooring/__init__.py +1 -1
- {mooring-0.2.2 → mooring-0.2.4}/src/mooring/auth.py +20 -0
- {mooring-0.2.2 → mooring-0.2.4}/src/mooring/cli.py +128 -26
- {mooring-0.2.2 → mooring-0.2.4}/src/mooring/config.py +5 -0
- {mooring-0.2.2 → mooring-0.2.4}/src/mooring/config_default.toml +10 -0
- {mooring-0.2.2 → mooring-0.2.4}/src/mooring/config_store.py +25 -0
- {mooring-0.2.2 → mooring-0.2.4}/src/mooring/hub/server.py +35 -9
- mooring-0.2.4/src/mooring/paths.py +64 -0
- {mooring-0.2.2 → mooring-0.2.4}/src/mooring/sync.py +100 -25
- mooring-0.2.4/src/mooring/telemetry.py +243 -0
- {mooring-0.2.2 → mooring-0.2.4}/tests/conftest.py +19 -0
- {mooring-0.2.2 → mooring-0.2.4}/tests/test_auth.py +29 -0
- mooring-0.2.4/tests/test_cli_repo.py +181 -0
- {mooring-0.2.2 → mooring-0.2.4}/tests/test_config.py +30 -0
- {mooring-0.2.2 → mooring-0.2.4}/tests/test_config_store.py +26 -0
- mooring-0.2.4/tests/test_paths.py +33 -0
- {mooring-0.2.2 → mooring-0.2.4}/tests/test_sync.py +119 -5
- mooring-0.2.4/tests/test_telemetry.py +183 -0
- {mooring-0.2.2 → mooring-0.2.4}/uv.lock +1 -1
- mooring-0.2.2/src/mooring/paths.py +0 -33
- mooring-0.2.2/tests/test_cli_repo.py +0 -86
- {mooring-0.2.2 → mooring-0.2.4}/.github/workflows/docs.yml +0 -0
- {mooring-0.2.2 → mooring-0.2.4}/.github/workflows/release.yml +0 -0
- {mooring-0.2.2 → mooring-0.2.4}/.gitignore +0 -0
- {mooring-0.2.2 → mooring-0.2.4}/README.md +0 -0
- {mooring-0.2.2 → mooring-0.2.4}/docs/admins/github-setup.md +0 -0
- {mooring-0.2.2 → mooring-0.2.4}/docs/admins/index.md +0 -0
- {mooring-0.2.2 → mooring-0.2.4}/docs/assets/images/anchor-mark.svg +0 -0
- {mooring-0.2.2 → mooring-0.2.4}/docs/assets/images/favicon.svg +0 -0
- {mooring-0.2.2 → mooring-0.2.4}/docs/assets/javascripts/landing.js +0 -0
- {mooring-0.2.2 → mooring-0.2.4}/docs/assets/stylesheets/landing.css +0 -0
- {mooring-0.2.2 → mooring-0.2.4}/docs/assets/stylesheets/oah-theme.css +0 -0
- {mooring-0.2.2 → mooring-0.2.4}/docs/developers/contributing.md +0 -0
- {mooring-0.2.2 → mooring-0.2.4}/docs/developers/index.md +0 -0
- {mooring-0.2.2 → mooring-0.2.4}/docs/index.md +0 -0
- {mooring-0.2.2 → mooring-0.2.4}/docs/users/cli.md +0 -0
- {mooring-0.2.2 → mooring-0.2.4}/docs/users/conflicts.md +0 -0
- {mooring-0.2.2 → mooring-0.2.4}/docs/users/daily-workflow.md +0 -0
- {mooring-0.2.2 → mooring-0.2.4}/docs/users/index.md +0 -0
- {mooring-0.2.2 → mooring-0.2.4}/docs/users/power-bi.md +0 -0
- {mooring-0.2.2 → mooring-0.2.4}/overrides/home.html +0 -0
- {mooring-0.2.2 → mooring-0.2.4}/scripts/release.ps1 +0 -0
- {mooring-0.2.2 → mooring-0.2.4}/src/mooring/editor.py +0 -0
- {mooring-0.2.2 → mooring-0.2.4}/src/mooring/githost.py +0 -0
- {mooring-0.2.2 → mooring-0.2.4}/src/mooring/github.py +0 -0
- {mooring-0.2.2 → mooring-0.2.4}/src/mooring/gitsha.py +0 -0
- {mooring-0.2.2 → mooring-0.2.4}/src/mooring/hub/__init__.py +0 -0
- {mooring-0.2.2 → mooring-0.2.4}/src/mooring/hub/static/app.js +0 -0
- {mooring-0.2.2 → mooring-0.2.4}/src/mooring/hub/static/index.html +0 -0
- {mooring-0.2.2 → mooring-0.2.4}/src/mooring/hub/static/style.css +0 -0
- {mooring-0.2.2 → mooring-0.2.4}/src/mooring/manifest.py +0 -0
- {mooring-0.2.2 → mooring-0.2.4}/src/mooring/notebook_template.py +0 -0
- {mooring-0.2.2 → mooring-0.2.4}/src/mooring/pbip.py +0 -0
- {mooring-0.2.2 → mooring-0.2.4}/tests/manual_editor_check.py +0 -0
- {mooring-0.2.2 → mooring-0.2.4}/tests/test_githost.py +0 -0
- {mooring-0.2.2 → mooring-0.2.4}/tests/test_github.py +0 -0
- {mooring-0.2.2 → mooring-0.2.4}/tests/test_gitsha.py +0 -0
- {mooring-0.2.2 → mooring-0.2.4}/tests/test_hub.py +0 -0
- {mooring-0.2.2 → mooring-0.2.4}/tests/test_manifest.py +0 -0
- {mooring-0.2.2 → mooring-0.2.4}/tests/test_pbip.py +0 -0
- {mooring-0.2.2 → mooring-0.2.4}/tests/test_truststore.py +0 -0
- {mooring-0.2.2 → mooring-0.2.4}/zensical.toml +0 -0
|
@@ -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
|
|
@@ -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
|
-
|
|
147
|
-
if
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
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:
|