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.
- freemyid/__init__.py +2 -0
- freemyid/_data/brokers/LICENSE-DATA.md +28 -0
- freemyid/_data/brokers/registry.sample.yaml +674 -0
- freemyid/_data/brokers/state_laws.yaml +448 -0
- freemyid/_data/schema/registry.schema.json +668 -0
- freemyid/_data/schema/setup-config.schema.json +538 -0
- freemyid/_data/schema/state_laws.schema.json +222 -0
- freemyid/cli.py +725 -0
- freemyid/cli_json.py +198 -0
- freemyid/config.py +43 -0
- freemyid/consent.py +82 -0
- freemyid/crypto.py +197 -0
- freemyid/doctor.py +374 -0
- freemyid/engine/__init__.py +0 -0
- freemyid/engine/_dev/__init__.py +101 -0
- freemyid/engine/_dev/devrun.py +101 -0
- freemyid/engine/_dev/inbox/devauto.eml +7 -0
- freemyid/engine/_dev/registry.yaml +106 -0
- freemyid/engine/_dev/site/devauto.example/confirm.html +1 -0
- freemyid/engine/_dev/site/devauto.example/name/jane-testperson/il.html +17 -0
- freemyid/engine/_dev/site/devauto.example/name/jane-testperson/nc.html +4 -0
- freemyid/engine/_dev/site/devauto.example/name/jane-testperson/oh.html +4 -0
- freemyid/engine/_dev/site/devauto.example/optout/done.html +1 -0
- freemyid/engine/_dev/site/devauto.example/optout.html +6 -0
- freemyid/engine/_dev/site/devblocked.example/name/jane-testperson/il.html +3 -0
- freemyid/engine/_dev/site/devblocked.example/optout.html +3 -0
- freemyid/engine/_dev/site/devfill.example/ajax.html +20 -0
- freemyid/engine/_dev/site/devfill.example/aria.html +16 -0
- freemyid/engine/_dev/site/devfill.example/consent.html +20 -0
- freemyid/engine/_dev/site/devfill.example/done.html +3 -0
- freemyid/engine/_dev/site/devfill.example/empty.html +1 -0
- freemyid/engine/_dev/site/devfill.example/fullname.html +12 -0
- freemyid/engine/_dev/site/devfill.example/labels.html +32 -0
- freemyid/engine/_dev/site/devfill.example/late.html +22 -0
- freemyid/engine/_dev/site/devfind.example/hostile.html +12 -0
- freemyid/engine/_dev/site/devfind.example/p/jane-testperson-shelbyville-il.html +6 -0
- freemyid/engine/_dev/site/devfind.example/p/jane-testperson-springfield-il.html +6 -0
- freemyid/engine/_dev/site/devfind.example/results.html +11 -0
- freemyid/engine/_dev/site/devfind.example/search.html +16 -0
- freemyid/engine/_dev/site/devweb.example/remove.html +1 -0
- freemyid/engine/browser.py +430 -0
- freemyid/engine/fill_for_me.py +630 -0
- freemyid/engine/filler.py +523 -0
- freemyid/engine/handoff.py +237 -0
- freemyid/engine/letters.py +125 -0
- freemyid/engine/listing_finder.py +538 -0
- freemyid/engine/mail.py +406 -0
- freemyid/engine/mail_tls.py +198 -0
- freemyid/engine/matcher.py +208 -0
- freemyid/engine/outbox.py +421 -0
- freemyid/engine/plugins.py +68 -0
- freemyid/engine/runner.py +791 -0
- freemyid/engine/tiers.py +107 -0
- freemyid/interfaces.py +164 -0
- freemyid/logging_setup.py +152 -0
- freemyid/models.py +700 -0
- freemyid/netpolicy.py +145 -0
- freemyid/notify.py +58 -0
- freemyid/paths.py +83 -0
- freemyid/registry.py +275 -0
- freemyid/scheduler/__init__.py +203 -0
- freemyid/scheduler/render.py +91 -0
- freemyid/secret_backends.py +562 -0
- freemyid/secrets.py +25 -0
- freemyid/setup_config.py +311 -0
- freemyid/state.py +151 -0
- freemyid/storage/__init__.py +3 -0
- freemyid/storage/db.py +394 -0
- freemyid/submitters/__init__.py +0 -0
- freemyid/submitters/assisted.py +171 -0
- freemyid/submitters/base.py +119 -0
- freemyid/submitters/email.py +40 -0
- freemyid/submitters/web_form.py +276 -0
- freemyid/templates/_identity.txt.j2 +19 -0
- freemyid/templates/_signature.txt.j2 +7 -0
- freemyid/templates/ccpa.txt.j2 +16 -0
- freemyid/templates/follow_up.txt.j2 +15 -0
- freemyid/templates/generic_removal.txt.j2 +12 -0
- freemyid/templates/legal_demand.txt.j2 +20 -0
- freemyid/templates/phone_script.txt.j2 +25 -0
- freemyid/templates/postal_letter.txt.j2 +20 -0
- freemyid/templates/state_privacy.txt.j2 +17 -0
- freemyid/wizard/__init__.py +0 -0
- freemyid/wizard/dashboard.py +401 -0
- freemyid/wizard/desktop.py +188 -0
- freemyid/wizard/draft.py +627 -0
- freemyid/wizard/firstrun.py +42 -0
- freemyid/wizard/flows.py +283 -0
- freemyid/wizard/report.py +86 -0
- freemyid/wizard/server.py +834 -0
- freemyid/wizard/static/__init__.py +0 -0
- freemyid/wizard/static/app.js +98 -0
- freemyid/wizard/static/freemyid.png +0 -0
- freemyid/wizard/static/style.css +85 -0
- freemyid/wizard/ui.py +343 -0
- freemyid-0.2.0.dev1.dist-info/METADATA +396 -0
- freemyid-0.2.0.dev1.dist-info/RECORD +100 -0
- freemyid-0.2.0.dev1.dist-info/WHEEL +4 -0
- freemyid-0.2.0.dev1.dist-info/entry_points.txt +2 -0
- 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()
|