freemyid 0.2.0.dev1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. freemyid/__init__.py +2 -0
  2. freemyid/_data/brokers/LICENSE-DATA.md +28 -0
  3. freemyid/_data/brokers/registry.sample.yaml +674 -0
  4. freemyid/_data/brokers/state_laws.yaml +448 -0
  5. freemyid/_data/schema/registry.schema.json +668 -0
  6. freemyid/_data/schema/setup-config.schema.json +538 -0
  7. freemyid/_data/schema/state_laws.schema.json +222 -0
  8. freemyid/cli.py +725 -0
  9. freemyid/cli_json.py +198 -0
  10. freemyid/config.py +43 -0
  11. freemyid/consent.py +82 -0
  12. freemyid/crypto.py +197 -0
  13. freemyid/doctor.py +374 -0
  14. freemyid/engine/__init__.py +0 -0
  15. freemyid/engine/_dev/__init__.py +101 -0
  16. freemyid/engine/_dev/devrun.py +101 -0
  17. freemyid/engine/_dev/inbox/devauto.eml +7 -0
  18. freemyid/engine/_dev/registry.yaml +106 -0
  19. freemyid/engine/_dev/site/devauto.example/confirm.html +1 -0
  20. freemyid/engine/_dev/site/devauto.example/name/jane-testperson/il.html +17 -0
  21. freemyid/engine/_dev/site/devauto.example/name/jane-testperson/nc.html +4 -0
  22. freemyid/engine/_dev/site/devauto.example/name/jane-testperson/oh.html +4 -0
  23. freemyid/engine/_dev/site/devauto.example/optout/done.html +1 -0
  24. freemyid/engine/_dev/site/devauto.example/optout.html +6 -0
  25. freemyid/engine/_dev/site/devblocked.example/name/jane-testperson/il.html +3 -0
  26. freemyid/engine/_dev/site/devblocked.example/optout.html +3 -0
  27. freemyid/engine/_dev/site/devfill.example/ajax.html +20 -0
  28. freemyid/engine/_dev/site/devfill.example/aria.html +16 -0
  29. freemyid/engine/_dev/site/devfill.example/consent.html +20 -0
  30. freemyid/engine/_dev/site/devfill.example/done.html +3 -0
  31. freemyid/engine/_dev/site/devfill.example/empty.html +1 -0
  32. freemyid/engine/_dev/site/devfill.example/fullname.html +12 -0
  33. freemyid/engine/_dev/site/devfill.example/labels.html +32 -0
  34. freemyid/engine/_dev/site/devfill.example/late.html +22 -0
  35. freemyid/engine/_dev/site/devfind.example/hostile.html +12 -0
  36. freemyid/engine/_dev/site/devfind.example/p/jane-testperson-shelbyville-il.html +6 -0
  37. freemyid/engine/_dev/site/devfind.example/p/jane-testperson-springfield-il.html +6 -0
  38. freemyid/engine/_dev/site/devfind.example/results.html +11 -0
  39. freemyid/engine/_dev/site/devfind.example/search.html +16 -0
  40. freemyid/engine/_dev/site/devweb.example/remove.html +1 -0
  41. freemyid/engine/browser.py +430 -0
  42. freemyid/engine/fill_for_me.py +630 -0
  43. freemyid/engine/filler.py +523 -0
  44. freemyid/engine/handoff.py +237 -0
  45. freemyid/engine/letters.py +125 -0
  46. freemyid/engine/listing_finder.py +538 -0
  47. freemyid/engine/mail.py +406 -0
  48. freemyid/engine/mail_tls.py +198 -0
  49. freemyid/engine/matcher.py +208 -0
  50. freemyid/engine/outbox.py +421 -0
  51. freemyid/engine/plugins.py +68 -0
  52. freemyid/engine/runner.py +791 -0
  53. freemyid/engine/tiers.py +107 -0
  54. freemyid/interfaces.py +164 -0
  55. freemyid/logging_setup.py +152 -0
  56. freemyid/models.py +700 -0
  57. freemyid/netpolicy.py +145 -0
  58. freemyid/notify.py +58 -0
  59. freemyid/paths.py +83 -0
  60. freemyid/registry.py +275 -0
  61. freemyid/scheduler/__init__.py +203 -0
  62. freemyid/scheduler/render.py +91 -0
  63. freemyid/secret_backends.py +562 -0
  64. freemyid/secrets.py +25 -0
  65. freemyid/setup_config.py +311 -0
  66. freemyid/state.py +151 -0
  67. freemyid/storage/__init__.py +3 -0
  68. freemyid/storage/db.py +394 -0
  69. freemyid/submitters/__init__.py +0 -0
  70. freemyid/submitters/assisted.py +171 -0
  71. freemyid/submitters/base.py +119 -0
  72. freemyid/submitters/email.py +40 -0
  73. freemyid/submitters/web_form.py +276 -0
  74. freemyid/templates/_identity.txt.j2 +19 -0
  75. freemyid/templates/_signature.txt.j2 +7 -0
  76. freemyid/templates/ccpa.txt.j2 +16 -0
  77. freemyid/templates/follow_up.txt.j2 +15 -0
  78. freemyid/templates/generic_removal.txt.j2 +12 -0
  79. freemyid/templates/legal_demand.txt.j2 +20 -0
  80. freemyid/templates/phone_script.txt.j2 +25 -0
  81. freemyid/templates/postal_letter.txt.j2 +20 -0
  82. freemyid/templates/state_privacy.txt.j2 +17 -0
  83. freemyid/wizard/__init__.py +0 -0
  84. freemyid/wizard/dashboard.py +401 -0
  85. freemyid/wizard/desktop.py +188 -0
  86. freemyid/wizard/draft.py +627 -0
  87. freemyid/wizard/firstrun.py +42 -0
  88. freemyid/wizard/flows.py +283 -0
  89. freemyid/wizard/report.py +86 -0
  90. freemyid/wizard/server.py +834 -0
  91. freemyid/wizard/static/__init__.py +0 -0
  92. freemyid/wizard/static/app.js +98 -0
  93. freemyid/wizard/static/freemyid.png +0 -0
  94. freemyid/wizard/static/style.css +85 -0
  95. freemyid/wizard/ui.py +343 -0
  96. freemyid-0.2.0.dev1.dist-info/METADATA +396 -0
  97. freemyid-0.2.0.dev1.dist-info/RECORD +100 -0
  98. freemyid-0.2.0.dev1.dist-info/WHEEL +4 -0
  99. freemyid-0.2.0.dev1.dist-info/entry_points.txt +2 -0
  100. freemyid-0.2.0.dev1.dist-info/licenses/LICENSE +661 -0
freemyid/cli.py ADDED
@@ -0,0 +1,725 @@
1
+ """`freemyid` command line. OWNER: orchestrator. Thin layer: all logic lives in modules."""
2
+ from __future__ import annotations
3
+
4
+ import logging
5
+ import os
6
+ import sys
7
+ from collections.abc import Callable, Sequence
8
+ from pathlib import Path
9
+ from typing import Annotated, NoReturn, TypeVar
10
+
11
+ import typer
12
+ from rich.console import Console
13
+ from rich.table import Table
14
+
15
+ from . import cli_json as cj
16
+ from . import config as cfgmod
17
+ from . import logging_setup, paths
18
+ from .consent import consent_ok
19
+ from .models import Profile, RequestStatus
20
+
21
+ app = typer.Typer(help="Free My ID: remove your info from data brokers, and keep it removed.",
22
+ no_args_is_help=True)
23
+ profile_app = typer.Typer(help="Manage people (profiles).", no_args_is_help=True)
24
+ schedule_app = typer.Typer(help="Install/remove the background re-check job.",
25
+ no_args_is_help=True)
26
+ registry_app = typer.Typer(help="Broker registry.", no_args_is_help=True)
27
+ todo_app = typer.Typer(help="Things that need a human.", no_args_is_help=True)
28
+ outbox_app = typer.Typer(help="Emails waiting for your approval.", no_args_is_help=True)
29
+ config_app = typer.Typer(help="Settings.", no_args_is_help=True)
30
+ secrets_app = typer.Typer(help="Where Free My ID keeps its encryption key and passwords.",
31
+ no_args_is_help=True)
32
+ for name, sub in (("profile", profile_app), ("schedule", schedule_app),
33
+ ("registry", registry_app), ("todo", todo_app), ("outbox", outbox_app),
34
+ ("config", config_app), ("secrets", secrets_app)):
35
+ app.add_typer(sub, name=name)
36
+
37
+ con = Console()
38
+ err = Console(stderr=True)
39
+ log = logging.getLogger("freemyid.cli")
40
+
41
+ #: Set by a command running with --json: human messages then go to stderr so stdout is
42
+ #: exactly one JSON document (docs/CLI.md).
43
+ _json_mode = False
44
+
45
+ JsonOpt = Annotated[bool, typer.Option("--json", help="Machine-readable output (docs/CLI.md)")]
46
+
47
+
48
+ def _ui() -> Console:
49
+ return err if _json_mode else con
50
+
51
+
52
+ def _set_json(on: bool) -> None:
53
+ global _json_mode
54
+ _json_mode = on
55
+
56
+
57
+ def _store():
58
+ from .storage import Store
59
+ return Store()
60
+
61
+
62
+ def _registry():
63
+ from .registry import load_registry
64
+ return load_registry()
65
+
66
+
67
+ T = TypeVar("T")
68
+
69
+
70
+ def _pick_one(items: Sequence[T], key: str, what: str, ident: Callable[[T], str],
71
+ describe: Callable[[T], str],
72
+ alias: Callable[[T], str] | None = None) -> T:
73
+ """Exactly one item whose id starts with `key` (or whose alias equals it).
74
+
75
+ A full id match wins. Otherwise zero or several matches list the candidates and exit
76
+ with code 2, so a short prefix never acts on the wrong record (SEC-12).
77
+ """
78
+ if not key:
79
+ raise typer.BadParameter(f"empty {what} id")
80
+ exact = [x for x in items if ident(x) == key]
81
+ if len(exact) == 1:
82
+ return exact[0]
83
+ k = key.lower()
84
+ match = [x for x in items if ident(x).startswith(key)
85
+ or (alias is not None and alias(x).lower() == k)]
86
+ if len(match) == 1:
87
+ return match[0]
88
+ if not match:
89
+ _ui().print(f"[red]No {what} matches {key!r}.[/]")
90
+ else:
91
+ _ui().print(f"[red]{key!r} matches {len(match)} {what}s; use more of the id:[/]")
92
+ for x in match:
93
+ _ui().print(f" {ident(x)[:12]} {describe(x)}")
94
+ raise typer.Exit(cj.EXIT_USAGE)
95
+
96
+
97
+ def _one_profile(store, key: str) -> Profile:
98
+ return _pick_one(store.list_profiles(), key, "profile", lambda p: p.id,
99
+ lambda p: p.label, alias=lambda p: p.label)
100
+
101
+
102
+ def _require_consent(store, p: Profile) -> Profile:
103
+ good, why = consent_ok(p, store)
104
+ if not good:
105
+ con.print(f"[red]{p.label}: {why}[/]")
106
+ raise typer.Exit(1)
107
+ return p
108
+
109
+
110
+ def _select(store, profile: str | None, skipped: list[dict] | None = None) -> list[Profile]:
111
+ ps = [_one_profile(store, profile)] if profile else store.list_profiles()
112
+ ok = []
113
+ for p in ps:
114
+ good, why = consent_ok(p, store)
115
+ if good:
116
+ ok.append(p)
117
+ else:
118
+ _ui().print(f"[yellow]Skipping {p.label}: {why}[/]")
119
+ if skipped is not None:
120
+ skipped.append(cj.skipped_entry(p, why))
121
+ return ok
122
+
123
+
124
+ def _harden_home() -> None:
125
+ """Private files by default (SEC-14): umask 077 and a 0700 data directory."""
126
+ os.umask(0o077)
127
+ if sys.platform == "win32":
128
+ return
129
+ try:
130
+ h = paths.home()
131
+ if h.stat().st_mode & 0o077:
132
+ h.chmod(0o700)
133
+ except OSError as e:
134
+ con.print(f"[yellow]Could not make the Free My ID data folder private: {e}[/]")
135
+
136
+
137
+ @app.callback()
138
+ def _main(verbose: Annotated[bool, typer.Option("--verbose", "-v")] = False) -> None:
139
+ _set_json(False)
140
+ _harden_home()
141
+ logging_setup.setup(verbose)
142
+
143
+
144
+ # --------------------------------------------------------------------------- top level
145
+
146
+
147
+ @app.command()
148
+ def setup(prefill: Annotated[Path | None,
149
+ typer.Option(help="Family bundle (.freemyid-profile)")] = None,
150
+ web: Annotated[bool, typer.Option(
151
+ help="Open the web wizard (else terminal prompts)")] = True,
152
+ from_file: Annotated[Path | None, typer.Option(
153
+ "--from", help="Set up from a YAML file, no wizard (docs/CLI.md)")] = None,
154
+ yes: Annotated[bool, typer.Option(
155
+ "--yes", help="With --from: confirm without asking")] = False,
156
+ check: Annotated[bool, typer.Option(
157
+ "--check", help="With --from: validate and show the plan; save nothing")] = False,
158
+ update: Annotated[bool, typer.Option(
159
+ "--update", help="With --from: replace the person with the same label")] = False):
160
+ """Walk through setup for a person (opens the local web wizard by default)."""
161
+ if from_file is not None:
162
+ _setup_from(from_file, yes=yes, check=check, update=update)
163
+ return
164
+ if web:
165
+ from .wizard.server import serve
166
+ serve(cfgmod.load().wizard_port, open_browser=True)
167
+ else:
168
+ from .wizard.flows import setup_cli
169
+ p = setup_cli(_store(), prefill=prefill)
170
+ con.print(f"[green]Saved profile {p.label} ({p.id[:8]})[/]")
171
+
172
+
173
+ @app.command()
174
+ def doctor(offline: Annotated[bool, typer.Option(
175
+ "--offline", help="Skip the mail server reachability check")] = False,
176
+ as_json: JsonOpt = False):
177
+ """Check this computer is ready: Python, data folder, secrets, browser, schedule, mail."""
178
+ from . import doctor as dmod
179
+ checks = dmod.run_checks(network=not offline)
180
+ if as_json:
181
+ cj.emit(dmod.doc(checks))
182
+ else:
183
+ style = {"ok": "green", "warn": "yellow", "fail": "red"}
184
+ for c in checks:
185
+ con.print(f"[{style[c.status]}]{c.status.upper():4}[/] {c.title}: ", end="")
186
+ con.print(c.detail, markup=False, highlight=False, soft_wrap=True)
187
+ if c.fix and c.status != "ok":
188
+ con.print(" fix: ", end="")
189
+ con.print(c.fix, markup=False, highlight=False, soft_wrap=True)
190
+ if any(c.status == "fail" for c in checks):
191
+ raise typer.Exit(cj.EXIT_ERROR)
192
+
193
+
194
+ def _setup_from(path: Path, *, yes: bool, check: bool, update: bool) -> None:
195
+ """Non-interactive setup from a versioned YAML file (setup_config.py)."""
196
+ from . import setup_config as sc
197
+ from .wizard.draft import SaveError, save_draft
198
+ try:
199
+ cfg = sc.load(path)
200
+ d = sc.to_draft(cfg, path.resolve().parent)
201
+ except sc.SetupFileError as e:
202
+ err.print(f"freemyid: {path.name} has {len(e.problems)} problem(s):", markup=False)
203
+ for line in e.problems:
204
+ err.print(f" - {line}", markup=False, highlight=False, soft_wrap=True)
205
+ raise typer.Exit(cj.EXIT_USAGE) from None
206
+ for line in sc.plan_lines(cfg):
207
+ con.print(line, markup=False, highlight=False, soft_wrap=True)
208
+ if check:
209
+ con.print("Check only: the file is valid; nothing was saved.")
210
+ return
211
+ store = _open_store_or_fail()
212
+ prior = sc.find_existing(store, d.label or d.first)
213
+ if prior is not None and not update:
214
+ _fail(cj.EXIT_USAGE, f"someone called {prior.label!r} is already set up; use --update "
215
+ "to replace them, or change person.label")
216
+ if prior is not None:
217
+ d.id, d.editing = prior.id, True
218
+ if not yes:
219
+ try:
220
+ ok = typer.confirm("Save this person and give this permission?", default=False)
221
+ except typer.Abort:
222
+ ok = False
223
+ if not ok:
224
+ _fail(cj.EXIT_USAGE, "not confirmed; nothing saved (add --yes to confirm "
225
+ "without a prompt)")
226
+ try:
227
+ prof = save_draft(store, d)
228
+ except SaveError as e:
229
+ _fail(cj.EXIT_ERROR, str(e))
230
+ con.print(f"[green]Saved {prof.label} ({prof.id[:8]}).[/] Next: `freemyid run --dry-run`.")
231
+ if cfg.schedule.install_job and cfg.schedule.weekly_recheck:
232
+ from . import scheduler
233
+ con.print(scheduler.install())
234
+
235
+
236
+ @app.command("app")
237
+ def app_window(no_window: Annotated[bool, typer.Option(
238
+ "--no-window", help="Use the web browser instead of an app window")] = False):
239
+ """Open Free My ID in its own window (wizard on first launch, dashboard after)."""
240
+ from .wizard.desktop import main as desktop_main
241
+ raise typer.Exit(desktop_main(window=not no_window))
242
+
243
+
244
+ @app.command()
245
+ def dashboard():
246
+ """Open the local dashboard (status, evidence, to-dos, approvals)."""
247
+ from .wizard.server import serve
248
+ serve(cfgmod.load().wizard_port, open_browser=True)
249
+
250
+
251
+ def _fail(code: int, reason: str) -> NoReturn:
252
+ """Stop with `code` and a one-line reason (stderr; also a JSON document with --json)."""
253
+ if _json_mode:
254
+ cj.emit(cj.failure_doc(code, reason))
255
+ err.print(f"freemyid: {reason}", markup=False, highlight=False, soft_wrap=True)
256
+ raise typer.Exit(code)
257
+
258
+
259
+ def _open_store_or_fail():
260
+ from .crypto import KeyUnavailable
261
+ try:
262
+ return _store()
263
+ except KeyUnavailable as e:
264
+ _fail(cj.EXIT_NOT_CONFIGURED,
265
+ f"not configured: the encryption key is unavailable ({e}); "
266
+ "run `freemyid doctor` for the fix")
267
+
268
+
269
+ def _do_run(profile, broker, scan_only, dry_run, scheduled, headless: bool = False,
270
+ as_json: bool = False):
271
+ """Shared body of `scan` and `run`.
272
+
273
+ Headless (cron/systemd/Docker): never prompts, never opens a window, and fails closed
274
+ with exit 4 (not configured) or 3 (needs a person) plus a one-line reason. After a
275
+ headless run the exit code says how it went (docs/CLI.md).
276
+ """
277
+ from .engine.runner import run as engine_run
278
+ _set_json(as_json)
279
+ cfg = cfgmod.load()
280
+ if scheduled and not cfg.real_submissions_enabled:
281
+ dry_run = True # practice mode: the daily task still runs, just as a dry run (SEC-22)
282
+ if not scan_only and not dry_run and not cfg.real_submissions_enabled:
283
+ if headless or as_json:
284
+ _fail(cj.EXIT_NOT_CONFIGURED if headless else cj.EXIT_USAGE,
285
+ "real submissions are not enabled (practice mode); use --dry-run, or run "
286
+ "`freemyid config enable-submissions` once")
287
+ con.print("[red]Real submissions are not enabled yet.[/] Run a dry run first, then "
288
+ "`freemyid config enable-submissions`.")
289
+ raise typer.Exit(2)
290
+ if headless and not paths.db_path().exists():
291
+ _fail(cj.EXIT_NOT_CONFIGURED, "not configured: nobody is set up yet; run "
292
+ "`freemyid setup` or `freemyid setup --from config.yaml`")
293
+ store = _open_store_or_fail()
294
+ skipped: list[dict] = []
295
+ people = _select(store, profile, skipped)
296
+ if headless and not people:
297
+ if not skipped:
298
+ _fail(cj.EXIT_NOT_CONFIGURED, "not configured: nobody is set up yet; run "
299
+ "`freemyid setup` or `freemyid setup --from config.yaml`")
300
+ _fail(cj.EXIT_NEEDS_HUMAN, f"needs a person: no profile has valid permission "
301
+ f"({len(skipped)} skipped); open Free My ID to give permission")
302
+ rep = engine_run(store, _registry(), people, scan_only=scan_only,
303
+ dry_run=dry_run, interactive=not (scheduled or headless),
304
+ broker_ids=broker or None, only_due=scheduled)
305
+ code = cj.EXIT_OK
306
+ if headless:
307
+ if rep.needs_human:
308
+ code = cj.EXIT_NEEDS_HUMAN
309
+ elif rep.errors:
310
+ code = cj.EXIT_ERROR
311
+ if as_json:
312
+ cj.emit(cj.run_doc(rep, scan_only=scan_only, headless=headless, exit_code=code,
313
+ skipped=skipped))
314
+ else:
315
+ for line in rep.summary_lines():
316
+ con.print(line)
317
+ if code:
318
+ raise typer.Exit(code)
319
+
320
+
321
+ @app.command()
322
+ def scan(profile: Annotated[str | None, typer.Option("--profile", "-p")] = None,
323
+ broker: Annotated[list[str] | None, typer.Option("--broker", "-b")] = None,
324
+ headless: Annotated[bool, typer.Option(
325
+ "--headless", help="No prompts or windows; fail closed (cron/Docker)")] = False,
326
+ as_json: JsonOpt = False):
327
+ """Search brokers for listings. Never submits anything."""
328
+ _do_run(profile, broker or [], scan_only=True, dry_run=True, scheduled=False,
329
+ headless=headless, as_json=as_json)
330
+
331
+
332
+ @app.command()
333
+ def run(profile: Annotated[str | None, typer.Option("--profile", "-p")] = None,
334
+ broker: Annotated[list[str] | None, typer.Option("--broker", "-b")] = None,
335
+ dry_run: Annotated[bool, typer.Option(
336
+ "--dry-run", help="Search + report, submit nothing")] = False,
337
+ headless: Annotated[bool, typer.Option(
338
+ "--headless", help="No prompts or windows; fail closed (cron/systemd/Docker)")
339
+ ] = False,
340
+ as_json: JsonOpt = False,
341
+ scheduled: Annotated[bool, typer.Option("--scheduled", hidden=True)] = False):
342
+ """Scan, submit opt-outs, check verifications and escalate ignored requests."""
343
+ _do_run(profile, broker or [], scan_only=False, dry_run=dry_run, scheduled=scheduled,
344
+ headless=headless, as_json=as_json)
345
+
346
+
347
+ @app.command()
348
+ def fill(broker: Annotated[str, typer.Argument(help="Broker id")],
349
+ profile: Annotated[str | None, typer.Option("--profile", "-p")] = None,
350
+ timeout: Annotated[int, typer.Option("--timeout", min=30, max=3600)] = 600):
351
+ """Open the broker's form in a visible window and fill it in; you tick the box and Submit."""
352
+ from .engine.fill_for_me import fill_for_me_sync
353
+ store = _store()
354
+ people = _select(store, profile)
355
+ if len(people) != 1:
356
+ con.print("[red]Choose one person with --profile.[/]")
357
+ raise typer.Exit(2)
358
+ res = fill_for_me_sync(store, people[0].id, broker, timeout_s=timeout)
359
+ con.print(f"{res.status}: {res.reason}")
360
+ if res.filled:
361
+ con.print("Filled: " + ", ".join(res.filled))
362
+ raise typer.Exit(0 if res.ok else 1)
363
+
364
+
365
+ @app.command()
366
+ def status(profile: Annotated[str | None, typer.Option("--profile", "-p")] = None,
367
+ as_json: JsonOpt = False):
368
+ """Per-broker status for each person."""
369
+ _set_json(as_json)
370
+ store, reg = _open_store_or_fail(), _registry()
371
+ names = {b.id: b.name for b in reg.brokers}
372
+ skipped: list[dict] = []
373
+ people = _select(store, profile, skipped)
374
+ if as_json:
375
+ cj.emit(cj.status_doc([(p, store.list_statuses(p.id), len(store.list_todos(p.id)))
376
+ for p in people], names, skipped))
377
+ return
378
+ for p in people:
379
+ t = Table(title=f"{p.label}")
380
+ for c in ("Broker", "State", "Updated", "Next check", "Escalation"):
381
+ t.add_column(c)
382
+ for st in sorted(store.list_statuses(p.id), key=lambda s: s.broker_id):
383
+ t.add_row(names.get(st.broker_id, st.broker_id), st.state.value,
384
+ st.updated_at.strftime("%Y-%m-%d"),
385
+ st.next_check_at.strftime("%Y-%m-%d") if st.next_check_at else "-",
386
+ str(st.escalation_level))
387
+ con.print(t)
388
+ todos = store.list_todos(p.id)
389
+ if todos:
390
+ con.print(f"[yellow]{len(todos)} item(s) need you: `freemyid todo list`[/]")
391
+ con.print("[dim]'removed' means removed from that broker, not from the whole internet. "
392
+ "Listings often reappear; Free My ID keeps checking.[/]")
393
+
394
+
395
+ @app.command()
396
+ def report(profile: Annotated[str | None, typer.Option("--profile", "-p")] = None,
397
+ fmt: Annotated[str, typer.Option("--format", help="html | pdf")] = "html",
398
+ as_json: JsonOpt = False):
399
+ """Export a report (HTML or PDF)."""
400
+ from .wizard.report import build_report
401
+ _set_json(as_json)
402
+ if fmt not in ("html", "pdf"):
403
+ raise typer.BadParameter("--format must be html or pdf")
404
+ store, reg = _open_store_or_fail(), _registry()
405
+ skipped: list[dict] = []
406
+ rows = []
407
+ for p in _select(store, profile, skipped):
408
+ out = build_report(store, reg, p.id, fmt)
409
+ rows.append((p, fmt, str(out)))
410
+ if not as_json:
411
+ con.print(f"Wrote {out}")
412
+ if as_json:
413
+ cj.emit(cj.reports_doc(rows, skipped))
414
+
415
+
416
+ # --------------------------------------------------------------------------- profiles
417
+
418
+
419
+ @profile_app.command("add")
420
+ def profile_add(prefill: Annotated[Path | None, typer.Option()] = None):
421
+ setup(prefill=prefill, web=prefill is None)
422
+
423
+
424
+ @profile_app.command("list")
425
+ def profile_list(as_json: JsonOpt = False):
426
+ """Everyone set up on this computer."""
427
+ _set_json(as_json)
428
+ store = _open_store_or_fail()
429
+ if as_json:
430
+ cj.emit(cj.profiles_doc([(p, consent_ok(p, store)[0]) for p in store.list_profiles()]))
431
+ return
432
+ t = Table()
433
+ for c in ("ID", "Label", "State", "Consent", "Mailbox"):
434
+ t.add_column(c)
435
+ for p in store.list_profiles():
436
+ t.add_row(p.id[:8], p.label, p.state_of_residence, p.consent.basis.value,
437
+ "yes" if p.mailbox else "no")
438
+ con.print(t)
439
+
440
+
441
+ @profile_app.command("edit")
442
+ def profile_edit(profile: str):
443
+ from .wizard.flows import edit_cli
444
+ store = _store()
445
+ p = _require_consent(store, _one_profile(store, profile))
446
+ edit_cli(store, p.id)
447
+
448
+
449
+ @profile_app.command("remove")
450
+ def profile_remove(profile: str, yes: Annotated[bool, typer.Option("--yes")] = False):
451
+ """Delete a person and all their stored data from this computer."""
452
+ store = _store()
453
+ p = _one_profile(store, profile)
454
+ if not yes and not typer.confirm(f"Permanently delete {p.label} ({p.id[:8]}) "
455
+ "and all evidence?"):
456
+ raise typer.Exit()
457
+ if p.mailbox:
458
+ from .secrets import delete_mailbox_password
459
+ delete_mailbox_password(p.id, p.mailbox)
460
+ store.delete_profile(p.id)
461
+ con.print("Deleted.")
462
+
463
+
464
+ @app.command()
465
+ def wipe(yes: Annotated[bool, typer.Option("--yes")] = False):
466
+ """Delete everything Free My ID saved on this computer: people, evidence, mailbox passwords,
467
+ the encryption key and the background task. Used by the Windows uninstaller."""
468
+ import shutil
469
+
470
+ from . import crypto
471
+ target = paths.home()
472
+ if not paths.is_safe_wipe_target(target):
473
+ con.print(f"[red]Refusing to wipe {target}:[/] it doesn't look like a Free My ID data folder.")
474
+ raise typer.Exit(2)
475
+ if not yes and not typer.confirm("Delete ALL Free My ID data on this computer?"):
476
+ raise typer.Exit()
477
+ try:
478
+ from .scheduler import remove as sched_remove
479
+ sched_remove()
480
+ except Exception: # noqa: BLE001 best effort; the task may not exist
481
+ log.warning("wipe: scheduler removal failed")
482
+ if paths.db_path().exists():
483
+ try:
484
+ from .secrets import delete_mailbox_password
485
+ store = _store()
486
+ for p in store.list_profiles():
487
+ if p.mailbox:
488
+ delete_mailbox_password(p.id, p.mailbox)
489
+ store.close()
490
+ except Exception: # noqa: BLE001 key may already be gone; still delete files
491
+ log.warning("wipe: could not read profiles to remove mailbox passwords")
492
+ crypto.delete_key()
493
+ # Close our own log files first: Windows refuses to delete a file that is still open.
494
+ for lg in (logging.getLogger(), logging.getLogger("freemyid")):
495
+ for h in list(lg.handlers):
496
+ if isinstance(h, logging.FileHandler):
497
+ h.close()
498
+ lg.removeHandler(h)
499
+ for name in paths.OWNED_ENTRIES: # only what Free My ID created; never rmtree the folder itself
500
+ e = target / name
501
+ if e.is_dir() and not e.is_symlink():
502
+ shutil.rmtree(e, ignore_errors=True)
503
+ elif e.exists() or e.is_symlink():
504
+ e.unlink(missing_ok=True)
505
+ try:
506
+ target.rmdir() # only succeeds if nothing else is left in it
507
+ except OSError:
508
+ pass
509
+ con.print("All Free My ID data deleted.")
510
+
511
+
512
+ @profile_app.command("export")
513
+ def profile_export(profile: str, dest: Path):
514
+ """Create a passphrase-protected pre-filled profile for a family installer."""
515
+ from .wizard.flows import export_family_bundle
516
+ store = _store()
517
+ p = _require_consent(store, _one_profile(store, profile))
518
+ pw = typer.prompt("Passphrase (tell them by phone, not email)", hide_input=True,
519
+ confirmation_prompt=True)
520
+ con.print(f"Wrote {export_family_bundle(store, p.id, dest, pw)}")
521
+
522
+
523
+ # --------------------------------------------------------------------------- schedule
524
+
525
+
526
+ @schedule_app.command("install")
527
+ def schedule_install():
528
+ from . import scheduler
529
+ con.print(scheduler.install())
530
+
531
+
532
+ @schedule_app.command("remove")
533
+ def schedule_remove():
534
+ from . import scheduler
535
+ con.print(scheduler.remove())
536
+
537
+
538
+ @schedule_app.command("status")
539
+ def schedule_status(as_json: JsonOpt = False):
540
+ from . import scheduler
541
+ detail = scheduler.status()
542
+ if as_json:
543
+ try:
544
+ plat: str | None = scheduler.platform_name()
545
+ except RuntimeError:
546
+ plat = None
547
+ cj.emit(cj.schedule_doc(plat, detail))
548
+ return
549
+ con.print(detail)
550
+
551
+
552
+ # --------------------------------------------------------------------------- registry
553
+
554
+
555
+ @registry_app.command("update")
556
+ def registry_update():
557
+ from .registry import update_registry
558
+ v, n = update_registry()
559
+ con.print(f"Registry {v}: {n} brokers")
560
+
561
+
562
+ @registry_app.command("validate")
563
+ def registry_validate(path: Annotated[Path | None, typer.Argument()] = None):
564
+ from .registry import load_registry
565
+ reg = load_registry(path)
566
+ unver = sum(1 for b in reg.brokers if b.verification_status.value != "verified")
567
+ con.print(f"OK: {len(reg.brokers)} brokers ({unver} unverified)")
568
+
569
+
570
+ @registry_app.command("source")
571
+ def registry_source_cmd(plain: Annotated[bool, typer.Option("--plain")] = False,
572
+ as_json: JsonOpt = False):
573
+ """Which broker list is in use: update, family, full or sample."""
574
+ from .registry import load_registry, load_warnings, registry_source
575
+ reg = load_registry()
576
+ if as_json:
577
+ cj.emit(cj.registry_source_doc(reg, registry_source(), load_warnings))
578
+ return
579
+ if plain:
580
+ print(registry_source())
581
+ return
582
+ con.print(f"{registry_source()}: {len(reg.brokers)} brokers (version {reg.version})")
583
+ for w in load_warnings:
584
+ con.print(f"[yellow]warning:[/] {w}")
585
+
586
+
587
+ @registry_app.command("show")
588
+ def registry_show(as_json: JsonOpt = False):
589
+ """Every broker in the registry in use, highest priority first."""
590
+ if as_json:
591
+ from .registry import load_warnings, registry_source
592
+ reg = _registry()
593
+ cj.emit(cj.registry_doc(reg, registry_source(), load_warnings))
594
+ return
595
+ t = Table()
596
+ for c in ("ID", "Name", "Method", "Verified", "Captcha", "Covered by"):
597
+ t.add_column(c)
598
+ for b in sorted(_registry().brokers, key=lambda b: -b.priority):
599
+ t.add_row(b.id, b.name, b.method.value, b.verification_status.value,
600
+ "yes" if b.has_captcha else "", b.covered_by or "")
601
+ con.print(t)
602
+
603
+
604
+ # --------------------------------------------------------------------------- todos / outbox
605
+
606
+
607
+ @todo_app.command("list")
608
+ def todo_list(profile: Annotated[str | None, typer.Option("--profile", "-p")] = None,
609
+ as_json: JsonOpt = False):
610
+ """Open to-dos (things that need a person)."""
611
+ _set_json(as_json)
612
+ store = _open_store_or_fail()
613
+ skipped: list[dict] = []
614
+ rows = [(p, t) for p in _select(store, profile, skipped) for t in store.list_todos(p.id)]
615
+ if as_json:
616
+ cj.emit(cj.todos_doc(rows, skipped))
617
+ return
618
+ for p, t in rows:
619
+ con.print(f"[{t.id[:8]}] {p.label} · {t.kind.value} · {t.title}", markup=False)
620
+
621
+
622
+ @todo_app.command("done")
623
+ def todo_done(todo_id: str):
624
+ store = _store()
625
+ t = _pick_one(store.list_todos(), todo_id, "open to-do", lambda t: t.id,
626
+ lambda t: f"{t.kind.value} · {t.title}")
627
+ store.complete_todo(t.id)
628
+ con.print("Marked done.")
629
+
630
+
631
+ @outbox_app.command("list")
632
+ def outbox_list(as_json: JsonOpt = False):
633
+ """Emails waiting for your approval."""
634
+ _set_json(as_json)
635
+ store = _open_store_or_fail()
636
+ queued = store.list_requests(status=RequestStatus.QUEUED_FOR_APPROVAL)
637
+ if as_json:
638
+ cj.emit(cj.outbox_doc(queued))
639
+ return
640
+ for r in queued:
641
+ con.print(f"[{r.id[:8]}] {r.broker_id} · {r.kind.value} · created {r.created_at:%Y-%m-%d}")
642
+
643
+
644
+ @outbox_app.command("approve")
645
+ def outbox_approve(request_id: str):
646
+ from .engine.outbox import approve_and_send
647
+ store = _store()
648
+ r = _pick_one(store.list_requests(status=RequestStatus.QUEUED_FOR_APPROVAL), request_id,
649
+ "queued email", lambda r: r.id, lambda r: f"{r.broker_id} · {r.kind.value}")
650
+ approve_and_send(store, r.id)
651
+ con.print("Sent.")
652
+
653
+
654
+ # --------------------------------------------------------------------------- secrets
655
+
656
+
657
+ @secrets_app.command("status")
658
+ def secrets_status(as_json: JsonOpt = False):
659
+ """Which backend holds the encryption key (keyring, passphrase or systemd-creds)."""
660
+ from . import crypto, secret_backends
661
+ _set_json(as_json)
662
+ try:
663
+ be = secret_backends.select()
664
+ except (ValueError, crypto.KeyUnavailable) as e:
665
+ _fail(cj.EXIT_NOT_CONFIGURED, f"no usable secrets backend: {e}")
666
+ if as_json:
667
+ cj.emit(cj.secrets_doc(be.name, secret_backends.configured_name(), be.usable(),
668
+ secret_backends.KeyringBackend().usable()))
669
+ return
670
+ con.print(be.name)
671
+
672
+
673
+ @secrets_app.command("migrate")
674
+ def secrets_migrate(to: Annotated[str, typer.Option(
675
+ "--to", help="passphrase | keyring | systemd-creds")]):
676
+ """Move the encryption key (and saved mailbox passwords) to another backend."""
677
+ from . import crypto, secret_backends
678
+ try:
679
+ r = secret_backends.migrate(to)
680
+ except (ValueError, crypto.KeyUnavailable) as e:
681
+ err.print(f"freemyid: {e}", markup=False, highlight=False, soft_wrap=True)
682
+ raise typer.Exit(cj.EXIT_USAGE) from None
683
+ if r.changed:
684
+ con.print(f"Now using {r.to_backend} (was {r.from_backend}); "
685
+ f"{r.secrets_moved} saved password(s) moved.")
686
+ else:
687
+ con.print("Nothing to change.")
688
+
689
+
690
+ # --------------------------------------------------------------------------- config
691
+
692
+
693
+ @config_app.command("set")
694
+ def config_set(key: str, value: str):
695
+ cfg = cfgmod.load()
696
+ if key not in type(cfg).model_fields or key == "real_submissions_enabled":
697
+ raise typer.BadParameter(f"unknown or protected key {key}")
698
+ field = type(cfg).model_fields[key]
699
+ cur = getattr(cfg, key)
700
+ if isinstance(cur, bool):
701
+ val: object = value.lower() in ("1", "true", "yes")
702
+ elif isinstance(cur, (int, float)):
703
+ val = type(cur)(value)
704
+ elif isinstance(cur, list):
705
+ val = [v.strip() for v in value.split(",") if v.strip()]
706
+ else:
707
+ val = value or None
708
+ del field
709
+ cfgmod.save(cfg.model_copy(update={key: val}))
710
+ con.print(f"{key} updated")
711
+
712
+
713
+ @config_app.command("enable-submissions")
714
+ def config_enable_submissions():
715
+ """One-time explicit opt-in to real (non-dry-run) opt-out submissions."""
716
+ con.print("Free My ID will submit real opt-out requests to data brokers using the "
717
+ "information in each profile (only what each broker already shows).")
718
+ if typer.confirm("Enable real submissions?"):
719
+ cfg = cfgmod.load()
720
+ cfgmod.save(cfg.model_copy(update={"real_submissions_enabled": True}))
721
+ con.print("[green]Enabled.[/]")
722
+
723
+
724
+ if __name__ == "__main__":
725
+ app()