pwdnote 0.1.0__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.
- pwdnote/__init__.py +3 -0
- pwdnote/cli.py +147 -0
- pwdnote/config.py +61 -0
- pwdnote/crypto.py +47 -0
- pwdnote/editor.py +54 -0
- pwdnote/notes.py +54 -0
- pwdnote/project.py +57 -0
- pwdnote-0.1.0.dist-info/METADATA +169 -0
- pwdnote-0.1.0.dist-info/RECORD +12 -0
- pwdnote-0.1.0.dist-info/WHEEL +4 -0
- pwdnote-0.1.0.dist-info/entry_points.txt +2 -0
- pwdnote-0.1.0.dist-info/licenses/LICENSE +21 -0
pwdnote/__init__.py
ADDED
pwdnote/cli.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""Command-line interface for pwdnote."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import NoReturn
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
|
|
11
|
+
from . import editor as editor_mod
|
|
12
|
+
from . import notes, project
|
|
13
|
+
from .config import load_or_create_key
|
|
14
|
+
from .crypto import DecryptionError
|
|
15
|
+
|
|
16
|
+
app = typer.Typer(
|
|
17
|
+
name="pwdnote",
|
|
18
|
+
help="Encrypted, project-local notes for your terminal.",
|
|
19
|
+
no_args_is_help=False,
|
|
20
|
+
add_completion=False,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
console = Console()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _fail(message: str) -> NoReturn:
|
|
27
|
+
console.print(message)
|
|
28
|
+
raise typer.Exit(code=1)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _no_note() -> NoReturn:
|
|
32
|
+
console.print("No project note found.")
|
|
33
|
+
console.print("Run:")
|
|
34
|
+
console.print(" pwdnote init")
|
|
35
|
+
raise typer.Exit(code=1)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _read_existing() -> tuple[Path, bytes, str]:
|
|
39
|
+
"""Locate the note, load the key, and decrypt — or fail with a message."""
|
|
40
|
+
note_path = project.find_existing_note(Path.cwd())
|
|
41
|
+
if note_path is None:
|
|
42
|
+
_no_note()
|
|
43
|
+
key = load_or_create_key()
|
|
44
|
+
try:
|
|
45
|
+
text = notes.read_note(note_path, key)
|
|
46
|
+
except DecryptionError:
|
|
47
|
+
_fail("Unable to decrypt project note.")
|
|
48
|
+
except PermissionError:
|
|
49
|
+
_fail("Unable to access note file.")
|
|
50
|
+
return note_path, key, text
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@app.callback(invoke_without_command=True)
|
|
54
|
+
def main(ctx: typer.Context) -> None:
|
|
55
|
+
"""Show the decrypted project note when no subcommand is given."""
|
|
56
|
+
if ctx.invoked_subcommand is not None:
|
|
57
|
+
return
|
|
58
|
+
_, _, text = _read_existing()
|
|
59
|
+
console.print(text, end="" if text.endswith("\n") else "\n", highlight=False)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@app.command()
|
|
63
|
+
def init() -> None:
|
|
64
|
+
"""Create an encrypted project note."""
|
|
65
|
+
root = project.resolve_project_root(Path.cwd())
|
|
66
|
+
note_path = project.note_path_for(root)
|
|
67
|
+
key = load_or_create_key()
|
|
68
|
+
try:
|
|
69
|
+
notes.init_note(note_path, key)
|
|
70
|
+
except notes.NoteExistsError:
|
|
71
|
+
_fail("Project note already exists.")
|
|
72
|
+
except PermissionError:
|
|
73
|
+
_fail("Unable to access note file.")
|
|
74
|
+
console.print(f"Created {note_path}")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@app.command()
|
|
78
|
+
def edit() -> None:
|
|
79
|
+
"""Edit the project note in your editor."""
|
|
80
|
+
note_path, key, text = _read_existing()
|
|
81
|
+
edited = editor_mod.edit_text(text, note_path.parent)
|
|
82
|
+
try:
|
|
83
|
+
notes.write_note(note_path, key, edited)
|
|
84
|
+
except PermissionError:
|
|
85
|
+
_fail("Unable to access note file.")
|
|
86
|
+
console.print("Note saved.")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@app.command()
|
|
90
|
+
def add(text: str = typer.Argument(..., help="Text to append as a bullet point.")) -> None:
|
|
91
|
+
"""Append a line to the project note without opening an editor."""
|
|
92
|
+
note_path, key, _ = _read_existing()
|
|
93
|
+
try:
|
|
94
|
+
notes.append_line(note_path, key, text)
|
|
95
|
+
except PermissionError:
|
|
96
|
+
_fail("Unable to access note file.")
|
|
97
|
+
console.print(f"Added: - {text}")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@app.command()
|
|
101
|
+
def status() -> None:
|
|
102
|
+
"""Show the project root, note file, and encryption status."""
|
|
103
|
+
start = Path.cwd()
|
|
104
|
+
note_path = project.find_existing_note(start)
|
|
105
|
+
if note_path is None:
|
|
106
|
+
root = project.resolve_project_root(start)
|
|
107
|
+
console.print("Project root:")
|
|
108
|
+
console.print(f" {root}")
|
|
109
|
+
console.print("Note file:")
|
|
110
|
+
console.print(" (none — run 'pwdnote init')")
|
|
111
|
+
console.print("Encrypted:")
|
|
112
|
+
console.print(" No note yet")
|
|
113
|
+
return
|
|
114
|
+
console.print("Project root:")
|
|
115
|
+
console.print(f" {note_path.parent}")
|
|
116
|
+
console.print("Note file:")
|
|
117
|
+
console.print(f" {note_path.name}")
|
|
118
|
+
console.print("Encrypted:")
|
|
119
|
+
console.print(" Yes")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@app.command()
|
|
123
|
+
def gitignore() -> None:
|
|
124
|
+
"""Add recommended pwdnote entries to the project's .gitignore."""
|
|
125
|
+
root = project.resolve_project_root(Path.cwd())
|
|
126
|
+
gitignore_path = root / ".gitignore"
|
|
127
|
+
recommended = [".pwdnote.tmp", ".pwdnote.cache"]
|
|
128
|
+
|
|
129
|
+
content = gitignore_path.read_text(encoding="utf-8") if gitignore_path.exists() else ""
|
|
130
|
+
existing = set(content.splitlines())
|
|
131
|
+
to_add = [entry for entry in recommended if entry not in existing]
|
|
132
|
+
|
|
133
|
+
if not to_add:
|
|
134
|
+
console.print("All recommended entries are already present.")
|
|
135
|
+
return
|
|
136
|
+
|
|
137
|
+
prefix = "" if content == "" or content.endswith("\n") else "\n"
|
|
138
|
+
with gitignore_path.open("a", encoding="utf-8") as handle:
|
|
139
|
+
handle.write(prefix + "".join(f"{entry}\n" for entry in to_add))
|
|
140
|
+
|
|
141
|
+
console.print(f"Added to {gitignore_path}:")
|
|
142
|
+
for entry in to_add:
|
|
143
|
+
console.print(f" {entry}")
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
if __name__ == "__main__": # pragma: no cover
|
|
147
|
+
app()
|
pwdnote/config.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Key management and configuration.
|
|
2
|
+
|
|
3
|
+
Version 1 stores a single auto-generated key on disk with restrictive
|
|
4
|
+
permissions. The lookup is structured so alternative backends (macOS Keychain,
|
|
5
|
+
1Password, age, GPG) can be added later behind ``load_or_create_key``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from .crypto import generate_key
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def get_config_dir() -> Path:
|
|
17
|
+
"""Return the directory that holds pwdnote configuration and the key.
|
|
18
|
+
|
|
19
|
+
Honours ``PWDNOTE_CONFIG_DIR`` and ``XDG_CONFIG_HOME`` overrides, falling
|
|
20
|
+
back to ``~/.config/pwdnote``.
|
|
21
|
+
"""
|
|
22
|
+
override = os.environ.get("PWDNOTE_CONFIG_DIR")
|
|
23
|
+
if override:
|
|
24
|
+
return Path(override)
|
|
25
|
+
xdg = os.environ.get("XDG_CONFIG_HOME")
|
|
26
|
+
if xdg:
|
|
27
|
+
return Path(xdg) / "pwdnote"
|
|
28
|
+
return Path.home() / ".config" / "pwdnote"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def get_key_path() -> Path:
|
|
32
|
+
"""Return the path to the encryption key file."""
|
|
33
|
+
override = os.environ.get("PWDNOTE_KEY_FILE")
|
|
34
|
+
if override:
|
|
35
|
+
return Path(override)
|
|
36
|
+
return get_config_dir() / "key"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def load_or_create_key() -> bytes:
|
|
40
|
+
"""Load the encryption key, creating it on first use.
|
|
41
|
+
|
|
42
|
+
The key file is created with ``0600`` permissions inside a ``0700``
|
|
43
|
+
directory so that other users on the system cannot read it.
|
|
44
|
+
"""
|
|
45
|
+
path = get_key_path()
|
|
46
|
+
if path.exists():
|
|
47
|
+
return path.read_bytes().strip()
|
|
48
|
+
|
|
49
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
50
|
+
try:
|
|
51
|
+
os.chmod(path.parent, 0o700)
|
|
52
|
+
except OSError:
|
|
53
|
+
# Best effort; not all filesystems support chmod.
|
|
54
|
+
pass
|
|
55
|
+
|
|
56
|
+
key = generate_key()
|
|
57
|
+
# O_EXCL guards against a concurrent writer; 0o600 keeps it private.
|
|
58
|
+
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
59
|
+
with os.fdopen(fd, "wb") as handle:
|
|
60
|
+
handle.write(key)
|
|
61
|
+
return key
|
pwdnote/crypto.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Crypto abstraction layer.
|
|
2
|
+
|
|
3
|
+
This module isolates all cryptographic details behind a tiny interface so the
|
|
4
|
+
backend can be swapped later without touching the rest of the codebase.
|
|
5
|
+
|
|
6
|
+
Current backend: Fernet (AES-128-CBC + HMAC-SHA256) from the ``cryptography``
|
|
7
|
+
library, which provides authenticated, integrity-protected encryption with a
|
|
8
|
+
versioned token format. We intentionally do NOT implement custom cryptography.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from cryptography.fernet import Fernet, InvalidToken
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class DecryptionError(Exception):
|
|
17
|
+
"""Raised when a note cannot be decrypted (wrong key or corrupted data)."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def generate_key() -> bytes:
|
|
21
|
+
"""Generate a fresh, URL-safe base64-encoded encryption key."""
|
|
22
|
+
return Fernet.generate_key()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _fernet(key: bytes) -> Fernet:
|
|
26
|
+
try:
|
|
27
|
+
return Fernet(key)
|
|
28
|
+
except (ValueError, TypeError) as exc:
|
|
29
|
+
raise DecryptionError("Invalid encryption key.") from exc
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def encrypt_text(plaintext: str, key: bytes) -> bytes:
|
|
33
|
+
"""Encrypt ``plaintext`` and return an opaque, integrity-protected token."""
|
|
34
|
+
return _fernet(key).encrypt(plaintext.encode("utf-8"))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def decrypt_text(token: bytes, key: bytes) -> str:
|
|
38
|
+
"""Decrypt and authenticate ``token``, returning the original text.
|
|
39
|
+
|
|
40
|
+
Raises:
|
|
41
|
+
DecryptionError: if the key is wrong, the key is malformed, or the
|
|
42
|
+
token has been tampered with / corrupted.
|
|
43
|
+
"""
|
|
44
|
+
try:
|
|
45
|
+
return _fernet(key).decrypt(token).decode("utf-8")
|
|
46
|
+
except InvalidToken as exc:
|
|
47
|
+
raise DecryptionError("Unable to decrypt project note.") from exc
|
pwdnote/editor.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Editor integration for ``pwdnote edit``.
|
|
2
|
+
|
|
3
|
+
Decrypted content is written to a temporary file with restrictive permissions,
|
|
4
|
+
opened in the user's editor, and deleted afterwards so that plaintext is never
|
|
5
|
+
left behind.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import shlex
|
|
12
|
+
import shutil
|
|
13
|
+
import subprocess
|
|
14
|
+
import tempfile
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
_FALLBACK_EDITORS = ("nano", "vi")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def resolve_editor() -> str:
|
|
21
|
+
"""Resolve the editor command using the standard precedence.
|
|
22
|
+
|
|
23
|
+
Order: ``$VISUAL``, ``$EDITOR``, ``nano``, ``vi``.
|
|
24
|
+
"""
|
|
25
|
+
for env_var in ("VISUAL", "EDITOR"):
|
|
26
|
+
value = os.environ.get(env_var)
|
|
27
|
+
if value:
|
|
28
|
+
return value
|
|
29
|
+
for candidate in _FALLBACK_EDITORS:
|
|
30
|
+
if shutil.which(candidate):
|
|
31
|
+
return candidate
|
|
32
|
+
return _FALLBACK_EDITORS[-1]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def edit_text(initial: str, directory: Path) -> str:
|
|
36
|
+
"""Open ``initial`` in an editor and return the edited result.
|
|
37
|
+
|
|
38
|
+
A temporary file is created in ``directory`` with ``0600`` permissions and
|
|
39
|
+
is always removed before returning, even if the editor fails.
|
|
40
|
+
"""
|
|
41
|
+
fd, tmp_name = tempfile.mkstemp(prefix=".pwdnote", suffix=".tmp", dir=str(directory))
|
|
42
|
+
tmp_path = Path(tmp_name)
|
|
43
|
+
try:
|
|
44
|
+
os.chmod(tmp_path, 0o600)
|
|
45
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
46
|
+
handle.write(initial)
|
|
47
|
+
editor_cmd = shlex.split(resolve_editor())
|
|
48
|
+
subprocess.run([*editor_cmd, str(tmp_path)], check=True)
|
|
49
|
+
return tmp_path.read_text(encoding="utf-8")
|
|
50
|
+
finally:
|
|
51
|
+
try:
|
|
52
|
+
tmp_path.unlink()
|
|
53
|
+
except OSError:
|
|
54
|
+
pass
|
pwdnote/notes.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Reading and writing encrypted project notes.
|
|
2
|
+
|
|
3
|
+
All persistence goes through the crypto layer, so plaintext notes are never
|
|
4
|
+
written to disk.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from .crypto import decrypt_text, encrypt_text
|
|
12
|
+
|
|
13
|
+
INITIAL_CONTENT = "# Project Notes\n"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class NoteNotFoundError(Exception):
|
|
17
|
+
"""Raised when a note is expected but does not exist."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class NoteExistsError(Exception):
|
|
21
|
+
"""Raised when initializing a note that already exists."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def note_exists(path: Path) -> bool:
|
|
25
|
+
return path.is_file()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def read_note(path: Path, key: bytes) -> str:
|
|
29
|
+
"""Decrypt and return the contents of the note at ``path``."""
|
|
30
|
+
if not path.is_file():
|
|
31
|
+
raise NoteNotFoundError(str(path))
|
|
32
|
+
return decrypt_text(path.read_bytes(), key)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def write_note(path: Path, key: bytes, text: str) -> None:
|
|
36
|
+
"""Encrypt ``text`` and write it to ``path``, overwriting any existing note."""
|
|
37
|
+
path.write_bytes(encrypt_text(text, key))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def init_note(path: Path, key: bytes) -> None:
|
|
41
|
+
"""Create a new note with the default starter content."""
|
|
42
|
+
if path.exists():
|
|
43
|
+
raise NoteExistsError(str(path))
|
|
44
|
+
write_note(path, key, INITIAL_CONTENT)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def append_line(path: Path, key: bytes, text: str) -> str:
|
|
48
|
+
"""Append ``text`` as a bullet point and return the updated note."""
|
|
49
|
+
current = read_note(path, key)
|
|
50
|
+
if current and not current.endswith("\n"):
|
|
51
|
+
current += "\n"
|
|
52
|
+
updated = f"{current}- {text}\n"
|
|
53
|
+
write_note(path, key, updated)
|
|
54
|
+
return updated
|
pwdnote/project.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Project root detection.
|
|
2
|
+
|
|
3
|
+
Starting from the current working directory we search upward:
|
|
4
|
+
|
|
5
|
+
1. If ``.pwdnote.enc`` exists, use that location.
|
|
6
|
+
2. Otherwise, if ``.git`` exists, treat that location as the project root.
|
|
7
|
+
3. Stop at the filesystem root.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Iterator, Optional
|
|
14
|
+
|
|
15
|
+
NOTE_FILENAME = ".pwdnote.enc"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def note_path_for(root: Path) -> Path:
|
|
19
|
+
"""Return the encrypted note path for a given project root."""
|
|
20
|
+
return root / NOTE_FILENAME
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _iter_up(start: Path) -> Iterator[Path]:
|
|
24
|
+
start = start.resolve()
|
|
25
|
+
yield start
|
|
26
|
+
yield from start.parents
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def find_existing_note(start: Path) -> Optional[Path]:
|
|
30
|
+
"""Walk upward from ``start`` and return the first ``.pwdnote.enc`` found."""
|
|
31
|
+
for directory in _iter_up(start):
|
|
32
|
+
candidate = directory / NOTE_FILENAME
|
|
33
|
+
if candidate.is_file():
|
|
34
|
+
return candidate
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def find_git_root(start: Path) -> Optional[Path]:
|
|
39
|
+
"""Walk upward from ``start`` and return the first directory with ``.git``."""
|
|
40
|
+
for directory in _iter_up(start):
|
|
41
|
+
if (directory / ".git").exists():
|
|
42
|
+
return directory
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def resolve_project_root(start: Path) -> Path:
|
|
47
|
+
"""Determine where a note should live for ``start``.
|
|
48
|
+
|
|
49
|
+
Prefers an existing note's directory, then the git root, then ``start``.
|
|
50
|
+
"""
|
|
51
|
+
note = find_existing_note(start)
|
|
52
|
+
if note is not None:
|
|
53
|
+
return note.parent
|
|
54
|
+
git_root = find_git_root(start)
|
|
55
|
+
if git_root is not None:
|
|
56
|
+
return git_root
|
|
57
|
+
return start.resolve()
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pwdnote
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Encrypted, project-local notes for your terminal.
|
|
5
|
+
Project-URL: Homepage, https://github.com/pwdnote/pwdnote
|
|
6
|
+
Project-URL: Repository, https://github.com/pwdnote/pwdnote
|
|
7
|
+
Project-URL: Issues, https://github.com/pwdnote/pwdnote/issues
|
|
8
|
+
Author: pwdnote maintainers
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: cli,encryption,notes,project,terminal
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Topic :: Utilities
|
|
19
|
+
Requires-Python: >=3.12
|
|
20
|
+
Requires-Dist: cryptography>=42.0
|
|
21
|
+
Requires-Dist: rich>=13.7
|
|
22
|
+
Requires-Dist: typer>=0.12
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# pwdnote
|
|
26
|
+
|
|
27
|
+
**Encrypted, project-local notes for your terminal.**
|
|
28
|
+
|
|
29
|
+
`pwdnote` keeps project-specific notes — TODOs, deployment notes, AWS account
|
|
30
|
+
details, session IDs, customer context, reminders — encrypted on disk, right
|
|
31
|
+
next to your code, without ever exposing plaintext inside the repository.
|
|
32
|
+
|
|
33
|
+
It is **local-first**, **encrypted-by-default**, **Git-friendly**, and
|
|
34
|
+
**terminal-native**. The single encrypted file (`.pwdnote.enc`) is safe to
|
|
35
|
+
commit; without your key it is just ciphertext.
|
|
36
|
+
|
|
37
|
+
`pwdnote` is *not* a cloud service, a note-taking app, a password manager, a
|
|
38
|
+
database, or a sync platform. It does one small thing well.
|
|
39
|
+
|
|
40
|
+
---
|
|
41
|
+
|
|
42
|
+
## Installation
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
uv tool install pwdnote
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
That's it — no further setup. The encryption key is generated automatically on
|
|
49
|
+
first use.
|
|
50
|
+
|
|
51
|
+
---
|
|
52
|
+
|
|
53
|
+
## Quick start
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
cd my-project
|
|
57
|
+
pwdnote init # create .pwdnote.enc
|
|
58
|
+
pwdnote edit # open it in your editor
|
|
59
|
+
pwdnote # print the decrypted note
|
|
60
|
+
pwdnote add "Remember to rotate AWS credentials"
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
---
|
|
64
|
+
|
|
65
|
+
## Commands
|
|
66
|
+
|
|
67
|
+
| Command | Description |
|
|
68
|
+
| --- | --- |
|
|
69
|
+
| `pwdnote` | Show the decrypted project note. |
|
|
70
|
+
| `pwdnote init` | Create an encrypted note (`# Project Notes`). |
|
|
71
|
+
| `pwdnote edit` | Decrypt, open in `$VISUAL`/`$EDITOR`, re-encrypt on save. |
|
|
72
|
+
| `pwdnote add "text"` | Append `- text` to the note without opening an editor. |
|
|
73
|
+
| `pwdnote status` | Show the project root, note file, and encryption status. |
|
|
74
|
+
| `pwdnote gitignore` | Add recommended ignore entries (`.pwdnote.tmp`, `.pwdnote.cache`). |
|
|
75
|
+
|
|
76
|
+
### Examples
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
$ pwdnote
|
|
80
|
+
TODO:
|
|
81
|
+
- rotate AWS keys
|
|
82
|
+
- update deployment docs
|
|
83
|
+
Notes:
|
|
84
|
+
Client requested staging environment.
|
|
85
|
+
|
|
86
|
+
$ pwdnote status
|
|
87
|
+
Project root:
|
|
88
|
+
~/projects/example
|
|
89
|
+
Note file:
|
|
90
|
+
.pwdnote.enc
|
|
91
|
+
Encrypted:
|
|
92
|
+
Yes
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
If no note exists yet:
|
|
96
|
+
|
|
97
|
+
```
|
|
98
|
+
No project note found.
|
|
99
|
+
Run:
|
|
100
|
+
pwdnote init
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
---
|
|
104
|
+
|
|
105
|
+
## Project root detection
|
|
106
|
+
|
|
107
|
+
`pwdnote` does not operate only on the current directory. Starting from your
|
|
108
|
+
working directory it searches **upward**:
|
|
109
|
+
|
|
110
|
+
1. If `.pwdnote.enc` exists, that location is used.
|
|
111
|
+
2. Otherwise, if `.git` exists, that location is treated as the project root.
|
|
112
|
+
3. The search stops at the filesystem root.
|
|
113
|
+
|
|
114
|
+
So from `project/backend/api`, running `pwdnote` finds
|
|
115
|
+
`project/.pwdnote.enc`.
|
|
116
|
+
|
|
117
|
+
---
|
|
118
|
+
|
|
119
|
+
## Security model
|
|
120
|
+
|
|
121
|
+
- **Authenticated encryption.** Notes are encrypted with
|
|
122
|
+
[Fernet](https://cryptography.io/en/latest/fernet/) (AES-128-CBC with an
|
|
123
|
+
HMAC-SHA256 authentication tag) from the well-maintained `cryptography`
|
|
124
|
+
library. We do not implement custom cryptography.
|
|
125
|
+
- **Integrity protection.** Tampered or corrupted files fail to decrypt rather
|
|
126
|
+
than returning garbage.
|
|
127
|
+
- **Key storage.** A single key is generated on first use and stored at
|
|
128
|
+
`~/.config/pwdnote/key` (honouring `XDG_CONFIG_HOME`) with `0600`
|
|
129
|
+
permissions inside a `0700` directory.
|
|
130
|
+
- **No plaintext on disk.** `pwdnote edit` writes to a temporary file with
|
|
131
|
+
restrictive permissions and always deletes it afterwards.
|
|
132
|
+
- **Commit-safe.** `.pwdnote.enc` is meant to be committed; it is ciphertext.
|
|
133
|
+
Do **not** ignore it. (The temporary/cache artifacts are ignored instead.)
|
|
134
|
+
|
|
135
|
+
The crypto backend lives behind a small abstraction (`encrypt_text` /
|
|
136
|
+
`decrypt_text`), so it can be replaced later — and future versions may add
|
|
137
|
+
macOS Keychain, 1Password, `age`, or GPG key backends.
|
|
138
|
+
|
|
139
|
+
---
|
|
140
|
+
|
|
141
|
+
## Limitations
|
|
142
|
+
|
|
143
|
+
- The key lives on your machine. If you lose `~/.config/pwdnote/key`, encrypted
|
|
144
|
+
notes cannot be recovered. Back the key up somewhere safe.
|
|
145
|
+
- There is no built-in sync. Sharing a note across machines means sharing the
|
|
146
|
+
same key (e.g. via a secrets manager).
|
|
147
|
+
- One note per project root. `pwdnote` is intentionally simple — no databases,
|
|
148
|
+
no cloud, no plugins, no AI features.
|
|
149
|
+
|
|
150
|
+
---
|
|
151
|
+
|
|
152
|
+
## Contributing
|
|
153
|
+
|
|
154
|
+
```bash
|
|
155
|
+
git clone https://github.com/pwdnote/pwdnote
|
|
156
|
+
cd pwdnote
|
|
157
|
+
uv sync # install deps + dev tools
|
|
158
|
+
uv run pytest # run the test suite
|
|
159
|
+
uv run pwdnote --help # try the CLI from source
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Issues and pull requests are welcome. Please keep the tool small and reliable —
|
|
163
|
+
new storage/key backends should slot in behind the existing abstractions.
|
|
164
|
+
|
|
165
|
+
---
|
|
166
|
+
|
|
167
|
+
## License
|
|
168
|
+
|
|
169
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
pwdnote/__init__.py,sha256=cgtxqbtXmQimQNKnWnSrMus11Eqds3wOSqwV_BADngU,91
|
|
2
|
+
pwdnote/cli.py,sha256=_DxinJ7ZzvIbXgPDovc2k90_rj6U8_daDsxrfnFzFlU,4446
|
|
3
|
+
pwdnote/config.py,sha256=iv3K-Ai9ef4R7_ut11KtddF6X4U8BYQZnKuobh5Yh9Y,1818
|
|
4
|
+
pwdnote/crypto.py,sha256=69pJ81rmgK-7kFjcdvv8VW6O1K_OHej6r7pRBdUmcxQ,1585
|
|
5
|
+
pwdnote/editor.py,sha256=nhLYdJGi_nNvXOWSWgh2-lvyNCC55X8ZJUAibwCW0hU,1610
|
|
6
|
+
pwdnote/notes.py,sha256=33HTAZ7043tBWdQF4Tww9sWNk-YYpxnNS-cIObOLcDw,1530
|
|
7
|
+
pwdnote/project.py,sha256=-8OwmvOhtnoF0xUFi9PxF4e5_74CvgI6cPZkgViZsxU,1601
|
|
8
|
+
pwdnote-0.1.0.dist-info/METADATA,sha256=dyIquFIWc-p3QIIEUoAL_IQpsY30T6GSCOtlMO2ABr8,5065
|
|
9
|
+
pwdnote-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
10
|
+
pwdnote-0.1.0.dist-info/entry_points.txt,sha256=KirbXytguyvMBrurbHmJrOLMEKmm3lmygMhFQFgRQOQ,44
|
|
11
|
+
pwdnote-0.1.0.dist-info/licenses/LICENSE,sha256=SsQsQQBPBbwnM7W1YKUyvz_MhSP8NcG789uKP8jT2Hg,1070
|
|
12
|
+
pwdnote-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Avi Bobrovsky
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|