basic-password-manager 0.2.1__tar.gz → 0.2.3__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.
- {basic_password_manager-0.2.1/basic_password_manager.egg-info → basic_password_manager-0.2.3}/PKG-INFO +22 -9
- basic_password_manager-0.2.1/PKG-INFO → basic_password_manager-0.2.3/README.md +19 -21
- basic_password_manager-0.2.1/README.md → basic_password_manager-0.2.3/basic_password_manager.egg-info/PKG-INFO +34 -7
- {basic_password_manager-0.2.1 → basic_password_manager-0.2.3}/basic_password_manager.egg-info/SOURCES.txt +1 -0
- {basic_password_manager-0.2.1 → basic_password_manager-0.2.3}/pw_manager/cli.py +39 -12
- basic_password_manager-0.2.3/pw_manager/logo.py +88 -0
- {basic_password_manager-0.2.1 → basic_password_manager-0.2.3}/pw_manager/tui.py +113 -21
- {basic_password_manager-0.2.1 → basic_password_manager-0.2.3}/pyproject.toml +6 -2
- {basic_password_manager-0.2.1 → basic_password_manager-0.2.3}/LICENSE +0 -0
- {basic_password_manager-0.2.1 → basic_password_manager-0.2.3}/basic_password_manager.egg-info/dependency_links.txt +0 -0
- {basic_password_manager-0.2.1 → basic_password_manager-0.2.3}/basic_password_manager.egg-info/entry_points.txt +0 -0
- {basic_password_manager-0.2.1 → basic_password_manager-0.2.3}/basic_password_manager.egg-info/requires.txt +0 -0
- {basic_password_manager-0.2.1 → basic_password_manager-0.2.3}/basic_password_manager.egg-info/top_level.txt +0 -0
- {basic_password_manager-0.2.1 → basic_password_manager-0.2.3}/pw_manager/__init__.py +0 -0
- {basic_password_manager-0.2.1 → basic_password_manager-0.2.3}/pw_manager/crypto.py +0 -0
- {basic_password_manager-0.2.1 → basic_password_manager-0.2.3}/pw_manager/vault.py +0 -0
- {basic_password_manager-0.2.1 → basic_password_manager-0.2.3}/setup.cfg +0 -0
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: basic-password-manager
|
|
3
|
-
Version: 0.2.
|
|
4
|
-
Summary:
|
|
3
|
+
Version: 0.2.3
|
|
4
|
+
Summary: The Paladin — a local encrypted password manager that lives in your terminal
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
Project-URL: Repository, https://github.com/Daisentaur/password-manager
|
|
7
|
+
Project-URL: Changelog, https://github.com/Daisentaur/password-manager/blob/main/CHANGELOG.md
|
|
7
8
|
Requires-Python: >=3.10
|
|
8
9
|
Description-Content-Type: text/markdown
|
|
9
10
|
License-File: LICENSE
|
|
@@ -12,11 +13,17 @@ Requires-Dist: cryptography
|
|
|
12
13
|
Requires-Dist: textual
|
|
13
14
|
Dynamic: license-file
|
|
14
15
|
|
|
15
|
-
|
|
16
|
+
<p align="center">
|
|
17
|
+
<img src="assets/logo.png" width="160" alt="The Paladin — a pixel-art knight" />
|
|
18
|
+
</p>
|
|
19
|
+
|
|
20
|
+
<h1 align="center">The Paladin</h1>
|
|
21
|
+
|
|
22
|
+
<p align="center"><em>Your passwords, guarded locally. No browser, no cloud, no mercy.</em></p>
|
|
16
23
|
|
|
17
24
|
A local, encrypted password manager that lives in your terminal. No browser,
|
|
18
25
|
no cloud, no daemon — one encrypted file on disk and a small Python CLI in
|
|
19
|
-
front of it.
|
|
26
|
+
front of it. (Installs as `basic-password-manager`; answers to `pw`.)
|
|
20
27
|
|
|
21
28
|
```
|
|
22
29
|
$ python pw.py get github
|
|
@@ -48,8 +55,12 @@ For development: `python3 -m venv venv && ./venv/bin/pip install -e .`
|
|
|
48
55
|
Run `pw` with no arguments for the interactive TUI: unlock, then `/` to
|
|
49
56
|
filter, Enter to copy the selected password, `n` to add, `e` to edit,
|
|
50
57
|
`d` to delete, `t` to switch themes (persisted across runs), `q` to quit.
|
|
51
|
-
|
|
52
|
-
|
|
58
|
+
The search box matches every word of your query anywhere in an entry —
|
|
59
|
+
name, username, notes, and the password itself — with matches highlighted
|
|
60
|
+
in the theme's accent color (password matches show the row but highlight
|
|
61
|
+
nothing, since passwords are never displayed). Rarer operations live in the
|
|
62
|
+
command palette (`ctrl+p`): importing a browser CSV, changing the master
|
|
63
|
+
password.
|
|
53
64
|
The bundled themes — muted-slate (default), dawn, matrix — are borrowed with
|
|
54
65
|
admiration from [tuxedo](https://github.com/webstonehq/tuxedo); Textual's
|
|
55
66
|
built-in themes are in the picker too.
|
|
@@ -66,9 +77,10 @@ Or use the subcommands:
|
|
|
66
77
|
| `pw passwd` | change the master password |
|
|
67
78
|
| `pw ls` | list entry names |
|
|
68
79
|
| `pw rm github` | delete an entry |
|
|
69
|
-
| `pw find bank` | search
|
|
80
|
+
| `pw find "dt bank"` | search names, usernames, notes — every word must match, any order |
|
|
70
81
|
| `pw gen -l 32` | just print a random password |
|
|
71
82
|
| `pw import passwords.csv` | import a browser CSV export (see below) |
|
|
83
|
+
| `pw about` | meet the knight |
|
|
72
84
|
|
|
73
85
|
The vault lives at `~/.local/share/pw-manager/vault`. Set the `PW_VAULT`
|
|
74
86
|
environment variable to put it somewhere else.
|
|
@@ -131,9 +143,10 @@ memory for the session; the CLI re-derives the key per command.
|
|
|
131
143
|
|
|
132
144
|
argparse subcommands over the two modules above. Passwords are copied to the
|
|
133
145
|
clipboard (`wl-copy`/`xclip`/`xsel`, whichever exists) rather than printed,
|
|
134
|
-
so they don't sit in your terminal scrollback. `find` searches names,
|
|
146
|
+
so they don't sit in your terminal scrollback. CLI `find` searches names,
|
|
135
147
|
usernames, and notes — deliberately *not* passwords, so fragments of secrets
|
|
136
|
-
never land in your shell history
|
|
148
|
+
never land in your shell history; the TUI search box does cover passwords,
|
|
149
|
+
because nothing typed there is logged anywhere.
|
|
137
150
|
|
|
138
151
|
## Backups
|
|
139
152
|
|
|
@@ -1,22 +1,14 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
Description-Content-Type: text/markdown
|
|
9
|
-
License-File: LICENSE
|
|
10
|
-
Requires-Dist: argon2-cffi
|
|
11
|
-
Requires-Dist: cryptography
|
|
12
|
-
Requires-Dist: textual
|
|
13
|
-
Dynamic: license-file
|
|
14
|
-
|
|
15
|
-
# pw-manager
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="assets/logo.png" width="160" alt="The Paladin — a pixel-art knight" />
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
<h1 align="center">The Paladin</h1>
|
|
6
|
+
|
|
7
|
+
<p align="center"><em>Your passwords, guarded locally. No browser, no cloud, no mercy.</em></p>
|
|
16
8
|
|
|
17
9
|
A local, encrypted password manager that lives in your terminal. No browser,
|
|
18
10
|
no cloud, no daemon — one encrypted file on disk and a small Python CLI in
|
|
19
|
-
front of it.
|
|
11
|
+
front of it. (Installs as `basic-password-manager`; answers to `pw`.)
|
|
20
12
|
|
|
21
13
|
```
|
|
22
14
|
$ python pw.py get github
|
|
@@ -48,8 +40,12 @@ For development: `python3 -m venv venv && ./venv/bin/pip install -e .`
|
|
|
48
40
|
Run `pw` with no arguments for the interactive TUI: unlock, then `/` to
|
|
49
41
|
filter, Enter to copy the selected password, `n` to add, `e` to edit,
|
|
50
42
|
`d` to delete, `t` to switch themes (persisted across runs), `q` to quit.
|
|
51
|
-
|
|
52
|
-
|
|
43
|
+
The search box matches every word of your query anywhere in an entry —
|
|
44
|
+
name, username, notes, and the password itself — with matches highlighted
|
|
45
|
+
in the theme's accent color (password matches show the row but highlight
|
|
46
|
+
nothing, since passwords are never displayed). Rarer operations live in the
|
|
47
|
+
command palette (`ctrl+p`): importing a browser CSV, changing the master
|
|
48
|
+
password.
|
|
53
49
|
The bundled themes — muted-slate (default), dawn, matrix — are borrowed with
|
|
54
50
|
admiration from [tuxedo](https://github.com/webstonehq/tuxedo); Textual's
|
|
55
51
|
built-in themes are in the picker too.
|
|
@@ -66,9 +62,10 @@ Or use the subcommands:
|
|
|
66
62
|
| `pw passwd` | change the master password |
|
|
67
63
|
| `pw ls` | list entry names |
|
|
68
64
|
| `pw rm github` | delete an entry |
|
|
69
|
-
| `pw find bank` | search
|
|
65
|
+
| `pw find "dt bank"` | search names, usernames, notes — every word must match, any order |
|
|
70
66
|
| `pw gen -l 32` | just print a random password |
|
|
71
67
|
| `pw import passwords.csv` | import a browser CSV export (see below) |
|
|
68
|
+
| `pw about` | meet the knight |
|
|
72
69
|
|
|
73
70
|
The vault lives at `~/.local/share/pw-manager/vault`. Set the `PW_VAULT`
|
|
74
71
|
environment variable to put it somewhere else.
|
|
@@ -131,9 +128,10 @@ memory for the session; the CLI re-derives the key per command.
|
|
|
131
128
|
|
|
132
129
|
argparse subcommands over the two modules above. Passwords are copied to the
|
|
133
130
|
clipboard (`wl-copy`/`xclip`/`xsel`, whichever exists) rather than printed,
|
|
134
|
-
so they don't sit in your terminal scrollback. `find` searches names,
|
|
131
|
+
so they don't sit in your terminal scrollback. CLI `find` searches names,
|
|
135
132
|
usernames, and notes — deliberately *not* passwords, so fragments of secrets
|
|
136
|
-
never land in your shell history
|
|
133
|
+
never land in your shell history; the TUI search box does cover passwords,
|
|
134
|
+
because nothing typed there is logged anywhere.
|
|
137
135
|
|
|
138
136
|
## Backups
|
|
139
137
|
|
|
@@ -1,8 +1,29 @@
|
|
|
1
|
-
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: basic-password-manager
|
|
3
|
+
Version: 0.2.3
|
|
4
|
+
Summary: The Paladin — a local encrypted password manager that lives in your terminal
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Project-URL: Repository, https://github.com/Daisentaur/password-manager
|
|
7
|
+
Project-URL: Changelog, https://github.com/Daisentaur/password-manager/blob/main/CHANGELOG.md
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Requires-Dist: argon2-cffi
|
|
12
|
+
Requires-Dist: cryptography
|
|
13
|
+
Requires-Dist: textual
|
|
14
|
+
Dynamic: license-file
|
|
15
|
+
|
|
16
|
+
<p align="center">
|
|
17
|
+
<img src="assets/logo.png" width="160" alt="The Paladin — a pixel-art knight" />
|
|
18
|
+
</p>
|
|
19
|
+
|
|
20
|
+
<h1 align="center">The Paladin</h1>
|
|
21
|
+
|
|
22
|
+
<p align="center"><em>Your passwords, guarded locally. No browser, no cloud, no mercy.</em></p>
|
|
2
23
|
|
|
3
24
|
A local, encrypted password manager that lives in your terminal. No browser,
|
|
4
25
|
no cloud, no daemon — one encrypted file on disk and a small Python CLI in
|
|
5
|
-
front of it.
|
|
26
|
+
front of it. (Installs as `basic-password-manager`; answers to `pw`.)
|
|
6
27
|
|
|
7
28
|
```
|
|
8
29
|
$ python pw.py get github
|
|
@@ -34,8 +55,12 @@ For development: `python3 -m venv venv && ./venv/bin/pip install -e .`
|
|
|
34
55
|
Run `pw` with no arguments for the interactive TUI: unlock, then `/` to
|
|
35
56
|
filter, Enter to copy the selected password, `n` to add, `e` to edit,
|
|
36
57
|
`d` to delete, `t` to switch themes (persisted across runs), `q` to quit.
|
|
37
|
-
|
|
38
|
-
|
|
58
|
+
The search box matches every word of your query anywhere in an entry —
|
|
59
|
+
name, username, notes, and the password itself — with matches highlighted
|
|
60
|
+
in the theme's accent color (password matches show the row but highlight
|
|
61
|
+
nothing, since passwords are never displayed). Rarer operations live in the
|
|
62
|
+
command palette (`ctrl+p`): importing a browser CSV, changing the master
|
|
63
|
+
password.
|
|
39
64
|
The bundled themes — muted-slate (default), dawn, matrix — are borrowed with
|
|
40
65
|
admiration from [tuxedo](https://github.com/webstonehq/tuxedo); Textual's
|
|
41
66
|
built-in themes are in the picker too.
|
|
@@ -52,9 +77,10 @@ Or use the subcommands:
|
|
|
52
77
|
| `pw passwd` | change the master password |
|
|
53
78
|
| `pw ls` | list entry names |
|
|
54
79
|
| `pw rm github` | delete an entry |
|
|
55
|
-
| `pw find bank` | search
|
|
80
|
+
| `pw find "dt bank"` | search names, usernames, notes — every word must match, any order |
|
|
56
81
|
| `pw gen -l 32` | just print a random password |
|
|
57
82
|
| `pw import passwords.csv` | import a browser CSV export (see below) |
|
|
83
|
+
| `pw about` | meet the knight |
|
|
58
84
|
|
|
59
85
|
The vault lives at `~/.local/share/pw-manager/vault`. Set the `PW_VAULT`
|
|
60
86
|
environment variable to put it somewhere else.
|
|
@@ -117,9 +143,10 @@ memory for the session; the CLI re-derives the key per command.
|
|
|
117
143
|
|
|
118
144
|
argparse subcommands over the two modules above. Passwords are copied to the
|
|
119
145
|
clipboard (`wl-copy`/`xclip`/`xsel`, whichever exists) rather than printed,
|
|
120
|
-
so they don't sit in your terminal scrollback. `find` searches names,
|
|
146
|
+
so they don't sit in your terminal scrollback. CLI `find` searches names,
|
|
121
147
|
usernames, and notes — deliberately *not* passwords, so fragments of secrets
|
|
122
|
-
never land in your shell history
|
|
148
|
+
never land in your shell history; the TUI search box does cover passwords,
|
|
149
|
+
because nothing typed there is logged anywhere.
|
|
123
150
|
|
|
124
151
|
## Backups
|
|
125
152
|
|
|
@@ -47,7 +47,28 @@ def cmd_init(args):
|
|
|
47
47
|
sys.exit(f"vault already exists at {VAULT_PATH}")
|
|
48
48
|
os.makedirs(os.path.dirname(VAULT_PATH), exist_ok=True)
|
|
49
49
|
vault.save(VAULT_PATH, ask_master(confirm=True), {})
|
|
50
|
-
|
|
50
|
+
if sys.stdout.isatty():
|
|
51
|
+
from . import logo
|
|
52
|
+
|
|
53
|
+
print(logo.says(f"empty vault created at {VAULT_PATH}"))
|
|
54
|
+
else:
|
|
55
|
+
print(f"empty vault created at {VAULT_PATH}")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def cmd_about(args):
|
|
59
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
v = version("basic-password-manager")
|
|
63
|
+
except PackageNotFoundError:
|
|
64
|
+
v = "dev" # running from source without an install
|
|
65
|
+
if sys.stdout.isatty():
|
|
66
|
+
from . import logo
|
|
67
|
+
|
|
68
|
+
print(logo.ansi())
|
|
69
|
+
print(f"The Paladin ♞ v{v}")
|
|
70
|
+
print("your passwords, guarded locally — no browser, no cloud, no mercy")
|
|
71
|
+
print("https://github.com/Daisentaur/password-manager")
|
|
51
72
|
|
|
52
73
|
|
|
53
74
|
def cmd_add(args):
|
|
@@ -135,16 +156,15 @@ def cmd_gen(args):
|
|
|
135
156
|
|
|
136
157
|
|
|
137
158
|
def cmd_find(args):
|
|
138
|
-
"""
|
|
139
|
-
passwords: a password fragment typed as
|
|
159
|
+
"""Every word of the query must appear somewhere in name/username/notes.
|
|
160
|
+
Deliberately not over passwords: a password fragment typed as a CLI
|
|
161
|
+
argument lands in shell history (the TUI search does cover passwords)."""
|
|
140
162
|
entries = vault.load(VAULT_PATH, ask_master())
|
|
141
|
-
|
|
163
|
+
words = args.term.lower().split()
|
|
142
164
|
hits = {
|
|
143
165
|
name: e
|
|
144
166
|
for name, e in sorted(entries.items())
|
|
145
|
-
if
|
|
146
|
-
or term in e["user"].lower()
|
|
147
|
-
or term in e.get("notes", "").lower()
|
|
167
|
+
if all(w in f"{name} {e['user']} {e.get('notes', '')}".lower() for w in words)
|
|
148
168
|
}
|
|
149
169
|
for name, e in hits.items():
|
|
150
170
|
print(f"{name} ({e['user']})")
|
|
@@ -152,12 +172,11 @@ def cmd_find(args):
|
|
|
152
172
|
sys.exit(f"nothing matches '{args.term}'")
|
|
153
173
|
|
|
154
174
|
|
|
155
|
-
def
|
|
156
|
-
"""
|
|
157
|
-
|
|
158
|
-
entries = vault.load(VAULT_PATH, master)
|
|
175
|
+
def import_csv(csv_file: str, entries: dict) -> int:
|
|
176
|
+
"""Merge a browser CSV export (Chrome: name,url,username,password /
|
|
177
|
+
Firefox: url,...) into entries. Returns how many were added."""
|
|
159
178
|
added = 0
|
|
160
|
-
with open(
|
|
179
|
+
with open(csv_file, newline="") as f:
|
|
161
180
|
for row in csv.DictReader(f):
|
|
162
181
|
name = row.get("name") or urlparse(row.get("url", "")).netloc
|
|
163
182
|
pw = row.get("password")
|
|
@@ -165,6 +184,13 @@ def cmd_import(args):
|
|
|
165
184
|
continue
|
|
166
185
|
entries[name] = {"user": row.get("username", ""), "pw": pw, "notes": ""}
|
|
167
186
|
added += 1
|
|
187
|
+
return added
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def cmd_import(args):
|
|
191
|
+
master = ask_master()
|
|
192
|
+
entries = vault.load(VAULT_PATH, master)
|
|
193
|
+
added = import_csv(args.csv_file, entries)
|
|
168
194
|
vault.save(VAULT_PATH, master, entries)
|
|
169
195
|
print(f"imported {added} entries — now delete {args.csv_file} securely")
|
|
170
196
|
|
|
@@ -193,6 +219,7 @@ def main():
|
|
|
193
219
|
f.add_argument("term")
|
|
194
220
|
i = sub.add_parser("import", help="import a browser CSV export")
|
|
195
221
|
i.add_argument("csv_file")
|
|
222
|
+
sub.add_parser("about", help="meet the knight")
|
|
196
223
|
|
|
197
224
|
args = p.parse_args()
|
|
198
225
|
if args.command is None:
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""The Paladin, as terminal pixel art.
|
|
2
|
+
|
|
3
|
+
Extracted once from assets/logo.png (each letter below is one pixel; dev-time
|
|
4
|
+
script, no image libraries at runtime). Rendered with half-block characters:
|
|
5
|
+
one character cell shows two pixels — top half foreground, bottom half
|
|
6
|
+
background.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from rich.style import Style
|
|
10
|
+
from rich.text import Text
|
|
11
|
+
|
|
12
|
+
PALETTE = {
|
|
13
|
+
"k": (0, 0, 0), "s": (140, 139, 147), "w": (194, 191, 198),
|
|
14
|
+
"o": (243, 243, 245), "g": (86, 85, 91), "d": (32, 32, 32),
|
|
15
|
+
"l": (158, 157, 163), "b": (246, 120, 59), "r": (143, 64, 47),
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
ROWS = [
|
|
19
|
+
"......kkk.........",
|
|
20
|
+
".....kswwkk.......",
|
|
21
|
+
"....wwwwwwwk......",
|
|
22
|
+
"...kwwwwwwwok.....",
|
|
23
|
+
"...kowwwwwwwk.....",
|
|
24
|
+
"...kgdooodgwk.....",
|
|
25
|
+
"...kdddddddwk...k.",
|
|
26
|
+
"...kwwdddwwwk..kok",
|
|
27
|
+
"..kkwwwddwwwk.kgl.",
|
|
28
|
+
".ookoowddookk.glk.",
|
|
29
|
+
"kgskkkwookkkbblk..",
|
|
30
|
+
"kssswwkkksskkrk...",
|
|
31
|
+
".kkkssgggwwksk....",
|
|
32
|
+
"...ksswwwwwkk.....",
|
|
33
|
+
"...kgkkkkggk......",
|
|
34
|
+
"....k.....k.......",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _pixel_pairs():
|
|
39
|
+
"""Yield rows of (top_color, bottom_color) tuples, one per char cell."""
|
|
40
|
+
for top, bottom in zip(ROWS[::2], ROWS[1::2]):
|
|
41
|
+
yield [
|
|
42
|
+
(PALETTE.get(t), PALETTE.get(b))
|
|
43
|
+
for t, b in zip(top, bottom)
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def ansi(indent: str = "") -> str:
|
|
48
|
+
"""The knight as a raw ANSI truecolor string, for print()."""
|
|
49
|
+
out = []
|
|
50
|
+
for row in _pixel_pairs():
|
|
51
|
+
line = indent
|
|
52
|
+
for top, bottom in row:
|
|
53
|
+
if top is None and bottom is None:
|
|
54
|
+
line += "\x1b[0m "
|
|
55
|
+
elif bottom is None:
|
|
56
|
+
line += "\x1b[0m\x1b[38;2;%d;%d;%dm▀" % top
|
|
57
|
+
elif top is None:
|
|
58
|
+
line += "\x1b[0m\x1b[38;2;%d;%d;%dm▄" % bottom
|
|
59
|
+
else:
|
|
60
|
+
line += "\x1b[38;2;%d;%d;%dm\x1b[48;2;%d;%d;%dm▀" % (top + bottom)
|
|
61
|
+
out.append(line + "\x1b[0m")
|
|
62
|
+
return "\n".join(out)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def rich_text() -> Text:
|
|
66
|
+
"""The knight as a Rich Text, for Textual widgets."""
|
|
67
|
+
text = Text()
|
|
68
|
+
for row in _pixel_pairs():
|
|
69
|
+
for top, bottom in row:
|
|
70
|
+
if top is None and bottom is None:
|
|
71
|
+
text.append(" ")
|
|
72
|
+
elif bottom is None:
|
|
73
|
+
text.append("▀", Style(color="rgb(%d,%d,%d)" % top))
|
|
74
|
+
elif top is None:
|
|
75
|
+
text.append("▄", Style(color="rgb(%d,%d,%d)" % bottom))
|
|
76
|
+
else:
|
|
77
|
+
text.append("▀", Style(color="rgb(%d,%d,%d)" % top, bgcolor="rgb(%d,%d,%d)" % bottom))
|
|
78
|
+
text.append("\n")
|
|
79
|
+
return text
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def says(message: str) -> str:
|
|
83
|
+
"""Cowsay, but it's a knight."""
|
|
84
|
+
border = "_" * (len(message) + 2)
|
|
85
|
+
return (
|
|
86
|
+
f" {border}\n< {message} >\n {'-' * (len(message) + 2)}\n"
|
|
87
|
+
f" \\\n" + ansi(indent=" ")
|
|
88
|
+
)
|
|
@@ -5,21 +5,26 @@ src/theme.rs); "muted-slate" is the default. `t` opens the theme picker,
|
|
|
5
5
|
and Textual's own built-in themes are available there too.
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
|
+
import csv
|
|
8
9
|
import os
|
|
9
|
-
from typing import Iterable
|
|
10
10
|
|
|
11
|
-
from
|
|
11
|
+
from rich.text import Text
|
|
12
|
+
from textual.app import App, ComposeResult
|
|
12
13
|
from textual.binding import Binding
|
|
14
|
+
from textual.command import DiscoveryHit, Hit, Hits, Provider
|
|
13
15
|
from textual.containers import Vertical
|
|
14
16
|
from textual.screen import ModalScreen, Screen
|
|
15
17
|
from textual.theme import Theme
|
|
16
18
|
from textual.widgets import DataTable, Footer, Input, Label, Static
|
|
17
19
|
|
|
18
|
-
from . import vault
|
|
19
|
-
from .cli import VAULT_PATH, generate, to_clipboard
|
|
20
|
+
from . import logo, vault
|
|
21
|
+
from .cli import VAULT_PATH, generate, import_csv, to_clipboard
|
|
20
22
|
|
|
21
23
|
THEME_FILE = os.path.expanduser("~/.local/share/pw-manager/theme")
|
|
22
24
|
|
|
25
|
+
APP_NAME = "The Paladin"
|
|
26
|
+
APP_ICON = "♞"
|
|
27
|
+
|
|
23
28
|
TUXEDO_THEMES = [
|
|
24
29
|
Theme(
|
|
25
30
|
name="muted-slate",
|
|
@@ -71,7 +76,8 @@ class UnlockScreen(Screen):
|
|
|
71
76
|
|
|
72
77
|
def compose(self) -> ComposeResult:
|
|
73
78
|
with Vertical(id="unlock-box"):
|
|
74
|
-
yield
|
|
79
|
+
yield Static(logo.rich_text(), id="unlock-logo")
|
|
80
|
+
yield Label(f"{APP_ICON} {APP_NAME}", id="unlock-title")
|
|
75
81
|
yield Input(password=True, placeholder="master password", id="master")
|
|
76
82
|
yield Static("", id="unlock-error")
|
|
77
83
|
|
|
@@ -126,6 +132,22 @@ class EntryModal(ModalScreen):
|
|
|
126
132
|
)
|
|
127
133
|
|
|
128
134
|
|
|
135
|
+
class ImportModal(ModalScreen):
|
|
136
|
+
"""Ask for the path of a browser CSV export."""
|
|
137
|
+
|
|
138
|
+
BINDINGS = [Binding("escape", "dismiss", "cancel")]
|
|
139
|
+
|
|
140
|
+
def compose(self) -> ComposeResult:
|
|
141
|
+
with Vertical(classes="modal-box"):
|
|
142
|
+
yield Label("import browser CSV", classes="modal-title")
|
|
143
|
+
yield Input(placeholder="path to export, e.g. ~/Downloads/passwords.csv", id="i-path")
|
|
144
|
+
yield Static("enter imports · esc cancels", classes="modal-hint")
|
|
145
|
+
|
|
146
|
+
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
147
|
+
if event.value.strip():
|
|
148
|
+
self.dismiss(os.path.expanduser(event.value.strip()))
|
|
149
|
+
|
|
150
|
+
|
|
129
151
|
class PasswdModal(ModalScreen):
|
|
130
152
|
"""Change the master password. Asks for the current one again so a
|
|
131
153
|
walked-up-to unlocked session can't lock the real owner out."""
|
|
@@ -194,6 +216,7 @@ class MainScreen(Screen):
|
|
|
194
216
|
]
|
|
195
217
|
|
|
196
218
|
def compose(self) -> ComposeResult:
|
|
219
|
+
yield Static(f" {APP_ICON} {APP_NAME}", id="topbar")
|
|
197
220
|
yield Input(placeholder="/ to search", id="search")
|
|
198
221
|
yield DataTable(id="table", cursor_type="row")
|
|
199
222
|
yield Footer()
|
|
@@ -203,18 +226,40 @@ class MainScreen(Screen):
|
|
|
203
226
|
table.add_columns("name", "username", "notes")
|
|
204
227
|
self.refresh_table()
|
|
205
228
|
table.focus()
|
|
229
|
+
# match-highlight color comes from the theme, so repaint on switch
|
|
230
|
+
self.app.theme_changed_signal.subscribe(self, self._on_theme_change)
|
|
231
|
+
|
|
232
|
+
def _on_theme_change(self, _theme: Theme) -> None:
|
|
233
|
+
self.refresh_table()
|
|
234
|
+
|
|
235
|
+
@staticmethod
|
|
236
|
+
def _highlight(value: str, words: list[str], style: str) -> Text:
|
|
237
|
+
text = Text(value)
|
|
238
|
+
low = value.lower()
|
|
239
|
+
for w in words:
|
|
240
|
+
start = 0
|
|
241
|
+
while (i := low.find(w, start)) != -1:
|
|
242
|
+
text.stylize(style, i, i + len(w))
|
|
243
|
+
start = i + 1
|
|
244
|
+
return text
|
|
206
245
|
|
|
207
246
|
def refresh_table(self) -> None:
|
|
247
|
+
"""Every search word must appear somewhere in the entry — name,
|
|
248
|
+
username, notes, or the password itself. Passwords are searched but
|
|
249
|
+
never displayed, so matches there can't be highlighted (by design)."""
|
|
208
250
|
table = self.query_one(DataTable)
|
|
209
251
|
table.clear()
|
|
210
|
-
|
|
252
|
+
words = self.query_one("#search", Input).value.lower().split()
|
|
253
|
+
style = f"bold {self.app.current_theme.primary}"
|
|
211
254
|
for name, e in sorted(self.app.entries.items()):
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
255
|
+
hay = f"{name} {e['user']} {e.get('notes', '')} {e['pw']}".lower()
|
|
256
|
+
if all(w in hay for w in words):
|
|
257
|
+
table.add_row(
|
|
258
|
+
self._highlight(name, words, style),
|
|
259
|
+
self._highlight(e["user"], words, style),
|
|
260
|
+
self._highlight(e.get("notes", ""), words, style),
|
|
261
|
+
key=name,
|
|
262
|
+
)
|
|
218
263
|
|
|
219
264
|
def save(self) -> None:
|
|
220
265
|
vault.save(VAULT_PATH, self.app.master, self.app.entries)
|
|
@@ -295,13 +340,46 @@ class MainScreen(Screen):
|
|
|
295
340
|
self.query_one(DataTable).focus()
|
|
296
341
|
|
|
297
342
|
|
|
343
|
+
class VaultCommands(Provider):
|
|
344
|
+
"""Palette commands for the unlocked vault. Unlike Textual's built-in
|
|
345
|
+
provider, the query matches descriptions too, not just titles."""
|
|
346
|
+
|
|
347
|
+
@property
|
|
348
|
+
def _commands(self):
|
|
349
|
+
if not isinstance(self.screen, MainScreen):
|
|
350
|
+
return []
|
|
351
|
+
app = self.app
|
|
352
|
+
return [
|
|
353
|
+
("Import browser CSV", "add every entry from a Chrome/Firefox password export", app._import_csv),
|
|
354
|
+
("Change master password", "re-encrypt the vault under a new master password", app._change_master),
|
|
355
|
+
]
|
|
356
|
+
|
|
357
|
+
async def search(self, query: str) -> Hits:
|
|
358
|
+
matcher = self.matcher(query)
|
|
359
|
+
for title, help_text, callback in self._commands:
|
|
360
|
+
score = matcher.match(f"{title} {help_text}")
|
|
361
|
+
if score > 0:
|
|
362
|
+
yield Hit(score, matcher.highlight(title), callback, help=help_text)
|
|
363
|
+
|
|
364
|
+
async def discover(self) -> Hits:
|
|
365
|
+
for title, help_text, callback in self._commands:
|
|
366
|
+
yield DiscoveryHit(title, callback, help=help_text)
|
|
367
|
+
|
|
368
|
+
|
|
298
369
|
class PwApp(App):
|
|
299
|
-
TITLE =
|
|
370
|
+
TITLE = APP_NAME
|
|
371
|
+
COMMANDS = App.COMMANDS | {VaultCommands}
|
|
300
372
|
|
|
301
373
|
# Colors come from $theme-variables so every theme applies everywhere;
|
|
302
374
|
# widgets without rules here (table, footer, toasts) use Textual's own
|
|
303
375
|
# theme-aware defaults.
|
|
304
376
|
CSS = """
|
|
377
|
+
#topbar {
|
|
378
|
+
height: 1;
|
|
379
|
+
background: $panel;
|
|
380
|
+
color: $primary;
|
|
381
|
+
text-style: bold;
|
|
382
|
+
}
|
|
305
383
|
#unlock-box {
|
|
306
384
|
align: center middle;
|
|
307
385
|
width: 44;
|
|
@@ -310,7 +388,14 @@ class PwApp(App):
|
|
|
310
388
|
background: $surface;
|
|
311
389
|
border: round $panel;
|
|
312
390
|
}
|
|
391
|
+
#unlock-logo {
|
|
392
|
+
width: 100%;
|
|
393
|
+
text-align: center;
|
|
394
|
+
margin-bottom: 1;
|
|
395
|
+
}
|
|
313
396
|
#unlock-title {
|
|
397
|
+
width: 100%;
|
|
398
|
+
text-align: center;
|
|
314
399
|
color: $primary;
|
|
315
400
|
text-style: bold;
|
|
316
401
|
margin-bottom: 1;
|
|
@@ -372,14 +457,21 @@ class PwApp(App):
|
|
|
372
457
|
with open(THEME_FILE, "w") as f:
|
|
373
458
|
f.write(theme.name)
|
|
374
459
|
|
|
375
|
-
def
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
460
|
+
def _import_csv(self) -> None:
|
|
461
|
+
def done(path: str | None) -> None:
|
|
462
|
+
if not path:
|
|
463
|
+
return
|
|
464
|
+
try:
|
|
465
|
+
added = import_csv(path, self.entries)
|
|
466
|
+
except (OSError, csv.Error) as e:
|
|
467
|
+
self.notify(str(e), severity="error")
|
|
468
|
+
return
|
|
469
|
+
vault.save(VAULT_PATH, self.master, self.entries)
|
|
470
|
+
if isinstance(self.screen, MainScreen):
|
|
471
|
+
self.screen.refresh_table()
|
|
472
|
+
self.notify(f"imported {added} entries — delete the CSV securely")
|
|
473
|
+
|
|
474
|
+
self.push_screen(ImportModal(), done)
|
|
383
475
|
|
|
384
476
|
def _change_master(self) -> None:
|
|
385
477
|
def done(new: str | None) -> None:
|
|
@@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "basic-password-manager"
|
|
7
|
-
version = "0.2.
|
|
8
|
-
description = "
|
|
7
|
+
version = "0.2.3"
|
|
8
|
+
description = "The Paladin — a local encrypted password manager that lives in your terminal"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
11
11
|
requires-python = ">=3.10"
|
|
@@ -15,8 +15,12 @@ dependencies = [
|
|
|
15
15
|
"textual",
|
|
16
16
|
]
|
|
17
17
|
|
|
18
|
+
[tool.setuptools]
|
|
19
|
+
packages = ["pw_manager"]
|
|
20
|
+
|
|
18
21
|
[project.scripts]
|
|
19
22
|
pw = "pw_manager.cli:main"
|
|
20
23
|
|
|
21
24
|
[project.urls]
|
|
22
25
|
Repository = "https://github.com/Daisentaur/password-manager"
|
|
26
|
+
Changelog = "https://github.com/Daisentaur/password-manager/blob/main/CHANGELOG.md"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|