bearcli 1.0.1__tar.gz → 1.1.0__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: bearcli
3
- Version: 1.0.1
3
+ Version: 1.1.0
4
4
  Summary: The missing CLI for Bear notes — read, search, export, and manage your notes from the terminal
5
5
  Keywords: bear,notes,cli,markdown,macos
6
6
  Author: Michel Tricot
@@ -12,6 +12,7 @@ Classifier: Environment :: Console
12
12
  Classifier: Operating System :: MacOS
13
13
  Classifier: Programming Language :: Python :: 3.13
14
14
  Classifier: Topic :: Utilities
15
+ Requires-Dist: detect-secrets>=1.5.0
15
16
  Requires-Dist: rapidfuzz>=3.14.5
16
17
  Requires-Dist: typer>=0.27.0
17
18
  Requires-Python: >=3.13
@@ -117,9 +118,19 @@ Every note becomes a self-contained directory — `<slug>/README.md` plus its
117
118
  attachments — with a generated index, so GitHub renders the whole export as a
118
119
  browsable tree.
119
120
 
121
+ Before anything is written, the notes are scanned for potential secrets
122
+ (token formats, key blocks, credential assignments); findings block the export
123
+ with a list of the affected notes (`--allow-secrets` overrides).
124
+
120
125
  ```sh
121
126
  bearcli export ~/bear-backup
122
127
  bearcli export ~/bear-backup --sync # only rewrite notes that changed
128
+
129
+ # Mirror to a git repository (clone it first; use a *private* repo — these are
130
+ # your notes). Bear is the source of truth: remote or manual edits are kept in
131
+ # git history but overwritten in HEAD. Never force-pushes, never gets stuck.
132
+ git clone git@github.com:you/bear-notes.git ~/bear-notes
133
+ bearcli export ~/bear-notes --sync --push
123
134
  ```
124
135
 
125
136
  ## Scripting
@@ -95,9 +95,19 @@ Every note becomes a self-contained directory — `<slug>/README.md` plus its
95
95
  attachments — with a generated index, so GitHub renders the whole export as a
96
96
  browsable tree.
97
97
 
98
+ Before anything is written, the notes are scanned for potential secrets
99
+ (token formats, key blocks, credential assignments); findings block the export
100
+ with a list of the affected notes (`--allow-secrets` overrides).
101
+
98
102
  ```sh
99
103
  bearcli export ~/bear-backup
100
104
  bearcli export ~/bear-backup --sync # only rewrite notes that changed
105
+
106
+ # Mirror to a git repository (clone it first; use a *private* repo — these are
107
+ # your notes). Bear is the source of truth: remote or manual edits are kept in
108
+ # git history but overwritten in HEAD. Never force-pushes, never gets stuck.
109
+ git clone git@github.com:you/bear-notes.git ~/bear-notes
110
+ bearcli export ~/bear-notes --sync --push
101
111
  ```
102
112
 
103
113
  ## Scripting
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "bearcli"
3
- version = "1.0.1"
3
+ version = "1.1.0"
4
4
  description = "The missing CLI for Bear notes — read, search, export, and manage your notes from the terminal"
5
5
  readme = "README.md"
6
6
  keywords = ["bear", "notes", "cli", "markdown", "macos"]
@@ -18,6 +18,7 @@ license = "MIT"
18
18
  license-files = ["LICENSE"]
19
19
  requires-python = ">=3.13"
20
20
  dependencies = [
21
+ "detect-secrets>=1.5.0",
21
22
  "rapidfuzz>=3.14.5",
22
23
  "typer>=0.27.0",
23
24
  ]
@@ -22,7 +22,9 @@ from rich.table import Table
22
22
  from bearcli import actions
23
23
  from bearcli.db import DEFAULT_DB_PATH, BearDB, Note
24
24
  from bearcli.export import export_notes
25
+ from bearcli.gitsync import GitError, export_and_push
25
26
  from bearcli.search import naive_search, search_notes
27
+ from bearcli.secrets import SecretFinding, redaction_map, scan_notes
26
28
 
27
29
  app = typer.Typer(help="Read notes from the Bear note app.", no_args_is_help=True, add_completion=False)
28
30
  note_app = typer.Typer(help="Create, read, and modify notes.", no_args_is_help=True)
@@ -288,6 +290,23 @@ def get(
288
290
  print(text)
289
291
 
290
292
 
293
+ def _report_secrets(findings: list[SecretFinding]) -> None:
294
+ table = Table(box=box.ROUNDED, header_style="bold")
295
+ table.add_column("Note", overflow="ellipsis", max_width=30)
296
+ table.add_column("ID", style="dim", no_wrap=True)
297
+ table.add_column("Rule", style="yellow")
298
+ table.add_column("Line", justify="right")
299
+ table.add_column("Match", style="red")
300
+ for f in findings:
301
+ table.add_row(f.note_title, f.note_id, f.rule, str(f.line), f.excerpt)
302
+ console.print(table)
303
+ notes = len({f.note_id for f in findings})
304
+ console.print(
305
+ f"[red]Export blocked:[/red] {len(findings)} potential secret(s) in {notes} note(s). "
306
+ "Move them somewhere safe (or into an encrypted note), or re-run with --allow-secrets."
307
+ )
308
+
309
+
291
310
  @app.command()
292
311
  def export(
293
312
  dest: Annotated[Path, typer.Argument(help="Destination directory for the markdown files.")],
@@ -298,17 +317,60 @@ def export(
298
317
  help="Only rewrite notes that changed since the last export instead of everything.",
299
318
  ),
300
319
  ] = False,
320
+ push: Annotated[
321
+ bool,
322
+ typer.Option(
323
+ "--push",
324
+ help="Treat DEST as a git clone: commit the export and push. Bear is the source of "
325
+ "truth — remote or manual edits are kept in history but overwritten in HEAD.",
326
+ ),
327
+ ] = False,
328
+ allow_secrets: Annotated[
329
+ bool,
330
+ typer.Option("--allow-secrets", help="Export even if the secret scan finds potential credentials."),
331
+ ] = False,
332
+ redact_secrets: Annotated[
333
+ bool,
334
+ typer.Option(
335
+ "--redact-secrets",
336
+ help="Export with detected secrets replaced by a [redacted: <rule>] placeholder "
337
+ "(notes in Bear are untouched).",
338
+ ),
339
+ ] = False,
301
340
  db_path: DbPathOption = DEFAULT_DB_PATH,
302
341
  ) -> None:
303
342
  """Export all notes as markdown files with frontmatter and attachments."""
343
+ if allow_secrets and redact_secrets:
344
+ console.print("[red]Error:[/red] --allow-secrets and --redact-secrets are mutually exclusive")
345
+ raise typer.Exit(2)
304
346
  db = _open_db(db_path)
305
347
  try:
348
+ redactions: dict[str, dict[str, str]] | None = None
349
+ if not allow_secrets:
350
+ with console.status("Scanning notes for secrets…", spinner="dots"):
351
+ candidates = db.list_notes(limit=None, include_archived=True, with_text=True)
352
+ findings = scan_notes(candidates)
353
+ if findings and not redact_secrets:
354
+ _report_secrets(findings)
355
+ raise typer.Exit(1)
356
+ if findings:
357
+ redactions = redaction_map(findings)
306
358
  with console.status("Exporting…", spinner="dots") as status:
307
- result = export_notes(db, dest, sync=sync, progress=lambda msg: status.update(rich_escape(msg)))
359
+ update = lambda msg: status.update(rich_escape(msg)) # noqa: E731
360
+ if push:
361
+ try:
362
+ result, outcome = export_and_push(db, dest, sync=sync, progress=update, redactions=redactions)
363
+ except GitError as exc:
364
+ console.print(f"[red]Error:[/red] {exc}")
365
+ raise typer.Exit(1) from None
366
+ else:
367
+ result = export_notes(db, dest, sync=sync, progress=update, redactions=redactions)
308
368
  finally:
309
369
  db.close()
310
370
 
311
371
  parts = [f"{result.written} written"]
372
+ if push:
373
+ parts.append(outcome)
312
374
  if sync:
313
375
  parts.append(f"{result.unchanged} unchanged")
314
376
  if result.removed:
@@ -317,6 +379,9 @@ def export(
317
379
  parts.append(f"{result.skipped_encrypted} encrypted skipped")
318
380
  if result.index_updated:
319
381
  parts.append("index updated")
382
+ if redactions:
383
+ secrets_count = sum(len(v) for v in redactions.values())
384
+ parts.append(f"{secrets_count} secret(s) redacted in {len(redactions)} note(s)")
320
385
  console.print(f"Exported to {dest}: " + ", ".join(parts))
321
386
  if result.index_skipped:
322
387
  console.print("[yellow]Warning:[/yellow] README.md exists but was not generated by bearcli; left untouched")
@@ -44,7 +44,7 @@ def _dirnames(notes: list[Note]) -> dict[str, str]:
44
44
  return {n.id: short[n.id] if counts[short[n.id]] == 1 else f"{slugify(n.title)}-{n.id.lower()}" for n in notes}
45
45
 
46
46
 
47
- def _frontmatter(note: Note) -> str:
47
+ def _frontmatter(note: Note, redacted: bool = False) -> str:
48
48
  lines = [
49
49
  "---",
50
50
  f"id: {note.id}",
@@ -55,10 +55,19 @@ def _frontmatter(note: Note) -> str:
55
55
  lines.append(f"created: {note.created.isoformat()}")
56
56
  if note.modified:
57
57
  lines.append(f"modified: {note.modified.isoformat()}")
58
+ if redacted:
59
+ lines.append("redacted: true")
58
60
  lines.append("---")
59
61
  return "\n".join(lines)
60
62
 
61
63
 
64
+ def _apply_redactions(text: str, secrets: dict[str, str]) -> str:
65
+ # Longest first, in case one detected value contains another.
66
+ for value in sorted(secrets, key=len, reverse=True):
67
+ text = text.replace(value, f"[redacted: {secrets[value]}]")
68
+ return text
69
+
70
+
62
71
  def _parse_frontmatter(path: Path) -> dict[str, str]:
63
72
  fields: dict[str, str] = {}
64
73
  try:
@@ -149,6 +158,7 @@ def export_notes(
149
158
  dest: Path,
150
159
  sync: bool = False,
151
160
  progress: Callable[[str], None] | None = None,
161
+ redactions: dict[str, dict[str, str]] | None = None,
152
162
  ) -> ExportResult:
153
163
  """Write every non-trashed note as dest/<slug>/README.md plus attachments/.
154
164
 
@@ -197,10 +207,15 @@ def export_notes(
197
207
  note_dir = dest / slug
198
208
  note_path = note_dir / NOTE_FILENAME
199
209
 
210
+ note_secrets = (redactions or {}).get(summary.id, {})
200
211
  modified_iso = summary.modified.isoformat() if summary.modified else ""
201
212
  if sync and note_path.exists():
202
213
  existing = _parse_frontmatter(note_path)
203
- if existing.get("id") == summary.id and existing.get("modified") == modified_iso:
214
+ # A change in redaction state must rewrite the file even though the
215
+ # note itself is unchanged — otherwise a previously exported secret
216
+ # would survive a later --redact-secrets run (and vice versa).
217
+ same_redaction = (existing.get("redacted") == "true") == bool(note_secrets)
218
+ if existing.get("id") == summary.id and existing.get("modified") == modified_iso and same_redaction:
204
219
  result.unchanged += 1
205
220
  add_entry(summary, f"{slug}/", (note_dir / ATTACHMENTS_DIRNAME).exists())
206
221
  continue
@@ -211,8 +226,11 @@ def export_notes(
211
226
  add_entry(summary, None, False)
212
227
  continue
213
228
 
229
+ text = _rewrite_refs(note)
230
+ if note_secrets:
231
+ text = _apply_redactions(text, note_secrets)
214
232
  note_dir.mkdir(exist_ok=True)
215
- note_path.write_text(f"{_frontmatter(note)}\n{_rewrite_refs(note)}\n")
233
+ note_path.write_text(f"{_frontmatter(note, redacted=bool(note_secrets))}\n{text}\n")
216
234
 
217
235
  attach_dir = note_dir / ATTACHMENTS_DIRNAME
218
236
  if attach_dir.exists():
@@ -0,0 +1,106 @@
1
+ """Export into a git worktree and push, converging without manual intervention.
2
+
3
+ The repository is a one-way mirror owned by bearcli: Bear is the source of
4
+ truth, and HEAD always converges to Bear's state. Nothing is ever lost —
5
+ local or remote edits are committed/merged before being overwritten, so they
6
+ remain in history — and nothing is ever force-pushed. Every failure mode
7
+ either self-heals (merge preferring local, reset to origin as last resort,
8
+ bounded push retries) or raises a clear error; there is no state that
9
+ requires manual git surgery to resume.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import subprocess
15
+ from collections.abc import Callable
16
+ from pathlib import Path
17
+
18
+ from bearcli.db import BearDB
19
+ from bearcli.export import ExportResult, export_notes
20
+
21
+
22
+ class GitError(RuntimeError):
23
+ pass
24
+
25
+
26
+ def _git(dest: Path, *args: str) -> subprocess.CompletedProcess[str]:
27
+ return subprocess.run(["git", "-C", str(dest), *args], capture_output=True, text=True, check=False)
28
+
29
+
30
+ def _run(dest: Path, *args: str) -> str:
31
+ proc = _git(dest, *args)
32
+ if proc.returncode != 0:
33
+ raise GitError(f"git {' '.join(args)} failed: {proc.stderr.strip()}")
34
+ return proc.stdout.strip()
35
+
36
+
37
+ def _ok(dest: Path, *args: str) -> bool:
38
+ return _git(dest, *args).returncode == 0
39
+
40
+
41
+ def _commit_if_dirty(dest: Path, message: str) -> None:
42
+ _run(dest, "add", "-A")
43
+ if not _ok(dest, "diff", "--cached", "--quiet"):
44
+ _run(dest, "commit", "-m", message)
45
+
46
+
47
+ def export_and_push(
48
+ db: BearDB,
49
+ dest: Path,
50
+ sync: bool = True,
51
+ progress: Callable[[str], None] | None = None,
52
+ redactions: dict[str, dict[str, str]] | None = None,
53
+ attempts: int = 3,
54
+ ) -> tuple[ExportResult, str]:
55
+ def report(message: str) -> None:
56
+ if progress is not None:
57
+ progress(message)
58
+
59
+ if not _ok(dest, "rev-parse", "--is-inside-work-tree"):
60
+ raise GitError(f"{dest} is not a git repository — clone your notes repo (or `git init`) first")
61
+ branch = _run(dest, "symbolic-ref", "--short", "HEAD")
62
+ has_remote = _ok(dest, "remote", "get-url", "origin")
63
+
64
+ last_error = "unknown"
65
+ result = ExportResult()
66
+ for _ in range(attempts):
67
+ # Anything lying around (manual edits, a previous crashed run) gets
68
+ # committed first: merges start from a clean tree and nothing is lost.
69
+ _commit_if_dirty(dest, "bear export: local changes")
70
+
71
+ full = not sync
72
+ if has_remote:
73
+ report("Fetching origin…")
74
+ _run(dest, "fetch", "origin")
75
+ if (
76
+ _ok(dest, "rev-parse", "--verify", f"origin/{branch}")
77
+ and _run(dest, "rev-list", "--count", f"HEAD..origin/{branch}") != "0"
78
+ ):
79
+ report("Integrating remote changes…")
80
+ if not (
81
+ _ok(dest, "merge", "--ff-only", f"origin/{branch}")
82
+ or _ok(dest, "merge", "-X", "ours", "--no-edit", f"origin/{branch}")
83
+ ):
84
+ _git(dest, "merge", "--abort")
85
+ _run(dest, "reset", "--hard", f"origin/{branch}")
86
+ # Whatever was pulled, Bear's state must win in the worktree:
87
+ # a full export re-asserts every note, not just changed ones.
88
+ full = True
89
+
90
+ result = export_notes(db, dest, sync=not full, progress=progress, redactions=redactions)
91
+
92
+ report("Committing…")
93
+ parts = [f"{result.written} written"]
94
+ if result.removed:
95
+ parts.append(f"{result.removed} removed")
96
+ _commit_if_dirty(dest, "bear export: " + ", ".join(parts))
97
+
98
+ if not has_remote:
99
+ return result, "committed (no remote configured)"
100
+ report("Pushing…")
101
+ push = _git(dest, "push", "-u", "origin", branch)
102
+ if push.returncode == 0:
103
+ return result, "pushed"
104
+ last_error = push.stderr.strip() # racing push; refetch and retry
105
+
106
+ raise GitError(f"push failed after {attempts} attempts: {last_error}")
@@ -0,0 +1,105 @@
1
+ """Secret detection over note text, powered by detect-secrets (Yelp).
2
+
3
+ A first line of defense before notes leave the machine via export. Everything
4
+ runs offline — note content is never sent anywhere. Format detectors cover
5
+ known token shapes (AWS, GitHub, Slack, Stripe, OpenAI, private keys, JWTs,
6
+ keyword assignments, …) and the entropy detectors catch random-looking
7
+ strings with no known format. Secrets written as prose are undetectable; for
8
+ deeper auditing run gitleaks over an --allow-secrets export.
9
+
10
+ Implementation note: detect-secrets' `scan_line` helper is unsuitable — it
11
+ enables eager search, which makes entropy plugins report every token — so
12
+ plugins are instantiated and run directly, with the heuristic false-positive
13
+ filters applied by hand.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from dataclasses import dataclass
19
+
20
+ from detect_secrets.core.plugins.util import get_mapping_from_secret_type_to_class
21
+ from detect_secrets.filters import heuristic
22
+ from detect_secrets.plugins.base import BasePlugin
23
+
24
+ from bearcli.db import Note
25
+
26
+ # An IP address in a note is not a credential.
27
+ _EXCLUDED_PLUGINS = {"IPPublicDetector"}
28
+ _BASE64_ENTROPY_LIMIT = 4.5 # detect-secrets' default
29
+ _HEX_ENTROPY_LIMIT = 3.5 # above default (3.0): notes are full of UUID/hash fragments
30
+
31
+
32
+ def _build_plugins() -> list[BasePlugin]:
33
+ plugins: list[BasePlugin] = []
34
+ for cls in get_mapping_from_secret_type_to_class().values():
35
+ name = cls.__name__
36
+ if name in _EXCLUDED_PLUGINS:
37
+ continue
38
+ if name == "Base64HighEntropyString":
39
+ plugins.append(cls(_BASE64_ENTROPY_LIMIT))
40
+ elif name == "HexHighEntropyString":
41
+ plugins.append(cls(_HEX_ENTROPY_LIMIT))
42
+ else:
43
+ plugins.append(cls())
44
+ return plugins
45
+
46
+
47
+ def _is_false_positive(value: str, line: str) -> bool:
48
+ return (
49
+ heuristic.is_potential_uuid(value)
50
+ or heuristic.is_sequential_string(value)
51
+ or heuristic.is_templated_secret(value)
52
+ or heuristic.is_likely_id_string(value, line)
53
+ or heuristic.is_not_alphanumeric_string(value)
54
+ )
55
+
56
+
57
+ @dataclass
58
+ class SecretFinding:
59
+ note_id: str
60
+ note_title: str
61
+ rule: str
62
+ line: int
63
+ excerpt: str
64
+ secret: str # raw value, for redaction only — never print this
65
+
66
+
67
+ def _redact(value: str) -> str:
68
+ """Show enough to locate the secret in the note, never the secret itself."""
69
+ return f"{value[:8]}…" if len(value) > 12 else "…"
70
+
71
+
72
+ def scan_notes(notes: list[Note]) -> list[SecretFinding]:
73
+ plugins = _build_plugins()
74
+ findings = []
75
+ for note in notes:
76
+ seen: set[tuple[int, str]] = set()
77
+ for lineno, line in enumerate((note.text or "").splitlines(), start=1):
78
+ for plugin in plugins:
79
+ # The yaml filetype hint makes KeywordDetector accept unquoted
80
+ # `password: value` assignments, which is how notes write them.
81
+ for secret in plugin.analyze_line(filename="note.yaml", line=line, line_number=lineno):
82
+ value = secret.secret_value or ""
83
+ if _is_false_positive(value, line) or (lineno, value) in seen:
84
+ continue
85
+ seen.add((lineno, value))
86
+ findings.append(
87
+ SecretFinding(
88
+ note_id=note.id,
89
+ note_title=note.title,
90
+ rule=secret.type,
91
+ line=lineno,
92
+ excerpt=_redact(value),
93
+ secret=value,
94
+ )
95
+ )
96
+ return findings
97
+
98
+
99
+ def redaction_map(findings: list[SecretFinding]) -> dict[str, dict[str, str]]:
100
+ """Per note id, the secret values to replace and the rule that found them."""
101
+ by_note: dict[str, dict[str, str]] = {}
102
+ for finding in findings:
103
+ if finding.secret:
104
+ by_note.setdefault(finding.note_id, {}).setdefault(finding.secret, finding.rule)
105
+ return by_note
File without changes
File without changes
File without changes
File without changes
File without changes