dong-pass 0.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.
- dong_pass-0.1.0/LICENSE +21 -0
- dong_pass-0.1.0/PKG-INFO +85 -0
- dong_pass-0.1.0/README.md +69 -0
- dong_pass-0.1.0/agent/TOOLS.md +120 -0
- dong_pass-0.1.0/pyproject.toml +29 -0
- dong_pass-0.1.0/src/pass/__init__.py +3 -0
- dong_pass-0.1.0/src/pass/cli.py +61 -0
- dong_pass-0.1.0/src/pass/commands/__init__.py +19 -0
- dong_pass-0.1.0/src/pass/commands/add.py +58 -0
- dong_pass-0.1.0/src/pass/commands/change_master.py +56 -0
- dong_pass-0.1.0/src/pass/commands/delete.py +39 -0
- dong_pass-0.1.0/src/pass/commands/export.py +51 -0
- dong_pass-0.1.0/src/pass/commands/get.py +83 -0
- dong_pass-0.1.0/src/pass/commands/import_cmd.py +59 -0
- dong_pass-0.1.0/src/pass/commands/init.py +35 -0
- dong_pass-0.1.0/src/pass/commands/ls.py +86 -0
- dong_pass-0.1.0/src/pass/commands/remove_encrypt.py +58 -0
- dong_pass-0.1.0/src/pass/commands/search.py +73 -0
- dong_pass-0.1.0/src/pass/commands/set_master.py +33 -0
- dong_pass-0.1.0/src/pass/commands/stats.py +62 -0
- dong_pass-0.1.0/src/pass/commands/update.py +80 -0
- dong_pass-0.1.0/src/pass/db/connection.py +82 -0
- dong_pass-0.1.0/src/pass/db/schema.py +60 -0
dong_pass-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 dong-labs
|
|
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.
|
dong_pass-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: dong-pass
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: 密码咚 - 账号密码管理 CLI
|
|
5
|
+
Author: gudong
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Keywords: account,cli,password,security
|
|
8
|
+
Requires-Python: >=3.11
|
|
9
|
+
Requires-Dist: cryptography>=41.0.0
|
|
10
|
+
Requires-Dist: rich>=13.0.0
|
|
11
|
+
Requires-Dist: typer>=0.12.0
|
|
12
|
+
Provides-Extra: dev
|
|
13
|
+
Requires-Dist: pytest-cov>=4.0; extra == 'dev'
|
|
14
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# 密码咚(dong-pass)
|
|
18
|
+
|
|
19
|
+
账号密码管理 CLI - 帮你安全存储和管理账号密码
|
|
20
|
+
|
|
21
|
+
## 安装
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pipx install dong-pass
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## 快速开始
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
# 初始化
|
|
31
|
+
dong-pass init
|
|
32
|
+
|
|
33
|
+
# 添加账号
|
|
34
|
+
dong-pass add github --account gudongtongxue --password "Gudong123!@#" --category "个人"
|
|
35
|
+
|
|
36
|
+
# 查询账号
|
|
37
|
+
dong-pass get github
|
|
38
|
+
|
|
39
|
+
# 列出所有
|
|
40
|
+
dong-pass list
|
|
41
|
+
|
|
42
|
+
# 搜索
|
|
43
|
+
dong-pass search "github"
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## 命令
|
|
47
|
+
|
|
48
|
+
| 命令 | 说明 |
|
|
49
|
+
|------|------|
|
|
50
|
+
| `init` | 初始化数据库 |
|
|
51
|
+
| `add` | 添加账号 |
|
|
52
|
+
| `get` | 获取账号 |
|
|
53
|
+
| `list` | 列出所有 |
|
|
54
|
+
| `search` | 搜索 |
|
|
55
|
+
| `update` | 更新账号 |
|
|
56
|
+
| `delete` | 删除账号 |
|
|
57
|
+
| `stats` | 统计信息 |
|
|
58
|
+
| `export` | 导出为 JSON |
|
|
59
|
+
| `import` | 从 JSON 导入 |
|
|
60
|
+
| `set-master` | 设置主密码 |
|
|
61
|
+
| `change-master` | 更改主密码 |
|
|
62
|
+
| `remove-encrypt` | 移除加密 |
|
|
63
|
+
|
|
64
|
+
## 加密存储
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
# 设置主密码(加密模式)
|
|
68
|
+
dong-pass init --master-password "MySecretKey"
|
|
69
|
+
|
|
70
|
+
# 添加时加密
|
|
71
|
+
dong-pass add github --account gudong --password "123456" --encrypt
|
|
72
|
+
|
|
73
|
+
# 查询时需要输入主密码
|
|
74
|
+
dong-pass get github
|
|
75
|
+
# 输入主密码: MySecretKey
|
|
76
|
+
# 显示密码: 123456
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## 数据库
|
|
80
|
+
|
|
81
|
+
数据存储在 `~/.dong/accounts.db`
|
|
82
|
+
|
|
83
|
+
## License
|
|
84
|
+
|
|
85
|
+
MIT
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# 密码咚(dong-pass)
|
|
2
|
+
|
|
3
|
+
账号密码管理 CLI - 帮你安全存储和管理账号密码
|
|
4
|
+
|
|
5
|
+
## 安装
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pipx install dong-pass
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## 快速开始
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
# 初始化
|
|
15
|
+
dong-pass init
|
|
16
|
+
|
|
17
|
+
# 添加账号
|
|
18
|
+
dong-pass add github --account gudongtongxue --password "Gudong123!@#" --category "个人"
|
|
19
|
+
|
|
20
|
+
# 查询账号
|
|
21
|
+
dong-pass get github
|
|
22
|
+
|
|
23
|
+
# 列出所有
|
|
24
|
+
dong-pass list
|
|
25
|
+
|
|
26
|
+
# 搜索
|
|
27
|
+
dong-pass search "github"
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## 命令
|
|
31
|
+
|
|
32
|
+
| 命令 | 说明 |
|
|
33
|
+
|------|------|
|
|
34
|
+
| `init` | 初始化数据库 |
|
|
35
|
+
| `add` | 添加账号 |
|
|
36
|
+
| `get` | 获取账号 |
|
|
37
|
+
| `list` | 列出所有 |
|
|
38
|
+
| `search` | 搜索 |
|
|
39
|
+
| `update` | 更新账号 |
|
|
40
|
+
| `delete` | 删除账号 |
|
|
41
|
+
| `stats` | 统计信息 |
|
|
42
|
+
| `export` | 导出为 JSON |
|
|
43
|
+
| `import` | 从 JSON 导入 |
|
|
44
|
+
| `set-master` | 设置主密码 |
|
|
45
|
+
| `change-master` | 更改主密码 |
|
|
46
|
+
| `remove-encrypt` | 移除加密 |
|
|
47
|
+
|
|
48
|
+
## 加密存储
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
# 设置主密码(加密模式)
|
|
52
|
+
dong-pass init --master-password "MySecretKey"
|
|
53
|
+
|
|
54
|
+
# 添加时加密
|
|
55
|
+
dong-pass add github --account gudong --password "123456" --encrypt
|
|
56
|
+
|
|
57
|
+
# 查询时需要输入主密码
|
|
58
|
+
dong-pass get github
|
|
59
|
+
# 输入主密码: MySecretKey
|
|
60
|
+
# 显示密码: 123456
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## 数据库
|
|
64
|
+
|
|
65
|
+
数据存储在 `~/.dong/accounts.db`
|
|
66
|
+
|
|
67
|
+
## License
|
|
68
|
+
|
|
69
|
+
MIT
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# TOOLS.md - 工具箱
|
|
2
|
+
|
|
3
|
+
我的核心工具是 `dong-pass` CLI。
|
|
4
|
+
|
|
5
|
+
## 安装
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pipx install dong-pass
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## 命令列表
|
|
12
|
+
|
|
13
|
+
### 初始化
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
dong-pass init
|
|
17
|
+
dong-pass init --master-password "MySecretKey" # 加密模式
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
### 添加账号
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
# 添加 GitHub 账号
|
|
24
|
+
dong-pass add github --account gudongtongxue --password "Gudong123!@#" --category "个人"
|
|
25
|
+
|
|
26
|
+
# 添加阿里云账号(加密)
|
|
27
|
+
dong-pass add aliyun --account gudong@aliyun.com --password "Aliyun456$" --category "工作" --encrypt
|
|
28
|
+
|
|
29
|
+
# 添加微博账号(带备注)
|
|
30
|
+
dong-pass add weibo --account gudongtongxue --password "Weibo789#" --nickname "工作微博" --category "社交"
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
### 查询账号
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
dong-pass get github
|
|
37
|
+
|
|
38
|
+
# 输出:
|
|
39
|
+
# 网站:github.com
|
|
40
|
+
# 账号:gudongtongxue
|
|
41
|
+
# 密码:Gudong123!@#
|
|
42
|
+
# 分类:个人
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
### 列出所有
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
# 列出所有
|
|
49
|
+
dong-pass list
|
|
50
|
+
|
|
51
|
+
# 列出工作类
|
|
52
|
+
dong-pass list --category 工作
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### 搜索
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
# 搜索包含 "aliyun" 的账号
|
|
59
|
+
dong-pass search aliyun
|
|
60
|
+
|
|
61
|
+
# 搜索所有个人类账号
|
|
62
|
+
dong-pass search --category 个人
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### 更新账号
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
# 更新密码
|
|
69
|
+
dong-pass update github --password "NewPassword123!"
|
|
70
|
+
|
|
71
|
+
# 更新分类
|
|
72
|
+
dong-pass update weibo --category "个人"
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### 删除账号
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
# 删除账号(需要确认)
|
|
79
|
+
dong-pass delete github
|
|
80
|
+
|
|
81
|
+
# 强制删除(不确认)
|
|
82
|
+
dong-pass delete github --force
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### 统计信息
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
dong-pass stats
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### 导出/导入
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
# 导出为 JSON
|
|
95
|
+
dong-pass export --output accounts.json
|
|
96
|
+
|
|
97
|
+
# 从 JSON 导入
|
|
98
|
+
dong-pass import --input accounts.json
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### 加密管理
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
# 设置主密码(首次设置)
|
|
105
|
+
dong-pass set-master
|
|
106
|
+
|
|
107
|
+
# 更改主密码
|
|
108
|
+
dong-pass change-master
|
|
109
|
+
|
|
110
|
+
# 移除加密(明文化)
|
|
111
|
+
dong-pass remove-encrypt
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## 数据库
|
|
115
|
+
|
|
116
|
+
数据存储在 `~/.dong/accounts.db`
|
|
117
|
+
|
|
118
|
+
---
|
|
119
|
+
|
|
120
|
+
*🔒 安全存储,随时访问*
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "dong-pass"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "密码咚 - 账号密码管理 CLI"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
authors = [{name = "gudong"}]
|
|
12
|
+
keywords = ["cli", "password", "account", "security"]
|
|
13
|
+
dependencies = [
|
|
14
|
+
"typer>=0.12.0",
|
|
15
|
+
"rich>=13.0.0",
|
|
16
|
+
"cryptography>=41.0.0",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
[project.optional-dependencies]
|
|
20
|
+
dev = [
|
|
21
|
+
"pytest>=7.0",
|
|
22
|
+
"pytest-cov>=4.0",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[project.scripts]
|
|
26
|
+
dong-pass = "pass.cli:app"
|
|
27
|
+
|
|
28
|
+
[tool.hatch.build.targets.wheel]
|
|
29
|
+
packages = ["src/pass"]
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""密码咚 CLI 主入口"""
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
from rich.console import Console
|
|
5
|
+
from dong import json_output
|
|
6
|
+
from . import __version__
|
|
7
|
+
|
|
8
|
+
console = Console()
|
|
9
|
+
|
|
10
|
+
app = typer.Typer(
|
|
11
|
+
name="dong-pass",
|
|
12
|
+
help="密码咚 - 账号密码管理 CLI",
|
|
13
|
+
no_args_is_help=True,
|
|
14
|
+
add_completion=False,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
# 导入命令
|
|
18
|
+
from .commands import (
|
|
19
|
+
init,
|
|
20
|
+
add,
|
|
21
|
+
get,
|
|
22
|
+
ls,
|
|
23
|
+
search,
|
|
24
|
+
update,
|
|
25
|
+
delete,
|
|
26
|
+
stats,
|
|
27
|
+
export,
|
|
28
|
+
import_cmd,
|
|
29
|
+
set_master,
|
|
30
|
+
change_master,
|
|
31
|
+
remove_encrypt,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
# 注册命令
|
|
35
|
+
app.command()(init.init)
|
|
36
|
+
app.command()(add.add)
|
|
37
|
+
app.command()(get.get)
|
|
38
|
+
app.command(name="list")(ls.list_accounts)
|
|
39
|
+
app.command()(search.search)
|
|
40
|
+
app.command()(update.update)
|
|
41
|
+
app.command()(delete.delete)
|
|
42
|
+
app.command()(stats.stats)
|
|
43
|
+
app.command()(export.export)
|
|
44
|
+
app.command()(import_cmd.import_cmd)
|
|
45
|
+
app.command()(set_master.set_master)
|
|
46
|
+
app.command()(change_master.change_master)
|
|
47
|
+
app.command()(remove_encrypt.remove_encrypt)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@app.callback()
|
|
51
|
+
def main(
|
|
52
|
+
version: bool = typer.Option(False, "--version", "-v", help="显示版本"),
|
|
53
|
+
):
|
|
54
|
+
"""密码咚 - 账号密码管理 CLI"""
|
|
55
|
+
if version:
|
|
56
|
+
console.print(f"dong-pass {__version__}")
|
|
57
|
+
raise typer.Exit()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
if __name__ == "__main__":
|
|
61
|
+
app()
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""命令模块"""
|
|
2
|
+
|
|
3
|
+
from . import init, add, get, ls, search, update, delete, stats, export, import_cmd, set_master, change_master, remove_encrypt
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"init",
|
|
7
|
+
"add",
|
|
8
|
+
"get",
|
|
9
|
+
"ls",
|
|
10
|
+
"search",
|
|
11
|
+
"update",
|
|
12
|
+
"delete",
|
|
13
|
+
"stats",
|
|
14
|
+
"export",
|
|
15
|
+
"import_cmd",
|
|
16
|
+
"set_master",
|
|
17
|
+
"change_master",
|
|
18
|
+
"remove_encrypt",
|
|
19
|
+
]
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""add 命令"""
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
from ..db import PassDatabase, get_cursor
|
|
5
|
+
from dong import json_output, DongError
|
|
6
|
+
|
|
7
|
+
console = typer.Console()
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@json_output
|
|
11
|
+
def add(
|
|
12
|
+
site: str = typer.Argument(..., help="网站名称"),
|
|
13
|
+
account: str = typer.Option(..., "--account", "-a", help="账号"),
|
|
14
|
+
password: str = typer.Option(..., "--password", "-p", help="密码"),
|
|
15
|
+
nickname: str = typer.Option(None, "--nickname", help="昵称"),
|
|
16
|
+
category: str = typer.Option(None, "--category", "-c", help="分类(工作/个人/金融/社交)"),
|
|
17
|
+
note: str = typer.Option(None, "--note", help="备注"),
|
|
18
|
+
encrypt: bool = typer.Option(False, "--encrypt", help="加密存储"),
|
|
19
|
+
):
|
|
20
|
+
"""添加账号密码"""
|
|
21
|
+
from ..db import is_initialized
|
|
22
|
+
|
|
23
|
+
if not is_initialized():
|
|
24
|
+
raise DongError("NOT_INITIALIZED", "请先运行 dong-pass init")
|
|
25
|
+
|
|
26
|
+
# 判断是否需要主密码
|
|
27
|
+
need_master = encrypt
|
|
28
|
+
master_password = None
|
|
29
|
+
if need_master:
|
|
30
|
+
master_password = typer.prompt("输入主密码以加密", password=True)
|
|
31
|
+
if not master_password:
|
|
32
|
+
raise DongError("NO_MASTER_PASSWORD", "加密存储需要主密码")
|
|
33
|
+
|
|
34
|
+
# 加密密码
|
|
35
|
+
db = PassDatabase(master_password)
|
|
36
|
+
if encrypt:
|
|
37
|
+
encrypted_pwd = db.encrypt(password)
|
|
38
|
+
else:
|
|
39
|
+
encrypted_pwd = password
|
|
40
|
+
|
|
41
|
+
with get_cursor(master_password) as cur:
|
|
42
|
+
try:
|
|
43
|
+
cur.execute("""
|
|
44
|
+
INSERT INTO accounts (site, account, password, nickname, category, note, encrypted)
|
|
45
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
46
|
+
""", (site.lower(), account, encrypted_pwd, nickname, category, note, encrypt))
|
|
47
|
+
except sqlite3.IntegrityError:
|
|
48
|
+
raise DongError("DUPLICATE_SITE", f"网站 '{site}' 已存在,请使用 update 命令更新")
|
|
49
|
+
|
|
50
|
+
console.print(f"[green]✅ 已添加账号:{site}[/green]")
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
"site": site.lower(),
|
|
54
|
+
"account": account,
|
|
55
|
+
"encrypted": encrypt,
|
|
56
|
+
"nickname": nickname,
|
|
57
|
+
"category": category,
|
|
58
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""change_master 命令"""
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
from ..db import PassDatabase, get_cursor, is_initialized
|
|
5
|
+
from dong import json_output, DongError
|
|
6
|
+
|
|
7
|
+
console = typer.Console()
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@json_output
|
|
11
|
+
def change_master():
|
|
12
|
+
"""更改主密码"""
|
|
13
|
+
if not is_initialized():
|
|
14
|
+
raise DongError("NOT_INITIALIZED", "请先运行 dong-pass init")
|
|
15
|
+
|
|
16
|
+
# 检查是否已加密
|
|
17
|
+
with get_cursor() as cur:
|
|
18
|
+
cur.execute("SELECT encrypted FROM accounts LIMIT 1")
|
|
19
|
+
row = cur.fetchone()
|
|
20
|
+
|
|
21
|
+
if not row or not row["encrypted"]:
|
|
22
|
+
raise DongError("NOT_ENCRYPTED", "数据库不是加密模式")
|
|
23
|
+
|
|
24
|
+
# 输入旧主密码
|
|
25
|
+
old_password = typer.prompt("输入当前主密码", password=True)
|
|
26
|
+
|
|
27
|
+
if not old_password:
|
|
28
|
+
raise DongError("INVALID_PASSWORD", "主密码不能为空")
|
|
29
|
+
|
|
30
|
+
# 输入新主密码
|
|
31
|
+
new_password = typer.prompt("输入新主密码", password=True, confirmation_prompt=True)
|
|
32
|
+
|
|
33
|
+
if not new_password:
|
|
34
|
+
raise DongError("INVALID_PASSWORD", "新主密码不能为空")
|
|
35
|
+
|
|
36
|
+
# 获取数据库
|
|
37
|
+
db = PassDatabase(old_password)
|
|
38
|
+
|
|
39
|
+
# 重新加密所有密码
|
|
40
|
+
with get_cursor(old_password) as cur:
|
|
41
|
+
cur.execute("SELECT id, account, password FROM accounts")
|
|
42
|
+
rows = cur.fetchall()
|
|
43
|
+
|
|
44
|
+
for row in rows:
|
|
45
|
+
account_id = row["id"]
|
|
46
|
+
encrypted_pwd = row["password"]
|
|
47
|
+
# 重新加密
|
|
48
|
+
new_encrypted_pwd = db.encrypt(encrypted_pwd)
|
|
49
|
+
cur.execute(
|
|
50
|
+
"UPDATE accounts SET password = ? WHERE id = ?",
|
|
51
|
+
(new_encrypted_pwd, account_id)
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
console.print("[green]✅ 主密码已更改[/green]")
|
|
55
|
+
|
|
56
|
+
return {"message": "主密码更改成功"}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""delete 命令"""
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
from ..db import get_cursor, is_initialized
|
|
5
|
+
from dong import json_output, DongError
|
|
6
|
+
|
|
7
|
+
console = typer.Console()
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@json_output
|
|
11
|
+
def delete(
|
|
12
|
+
site: str = typer.Option(..., help="网站名称"),
|
|
13
|
+
force: bool = typer.Option(False, "--force", "-f", help="强制删除,不确认"),
|
|
14
|
+
):
|
|
15
|
+
"""删除账号"""
|
|
16
|
+
from ..db import PassDatabase, get_db_path
|
|
17
|
+
|
|
18
|
+
if not is_initialized():
|
|
19
|
+
raise DongError("NOT_INITIALIZED", "请先运行 dong-pass init")
|
|
20
|
+
|
|
21
|
+
with get_cursor() as cur:
|
|
22
|
+
cur.execute("SELECT site FROM accounts WHERE site = ?", (site.lower(),))
|
|
23
|
+
row = cur.fetchone()
|
|
24
|
+
|
|
25
|
+
if not row:
|
|
26
|
+
raise DongError("NOT_FOUND", f"未找到网站:{site}")
|
|
27
|
+
|
|
28
|
+
site_name = row["site"]
|
|
29
|
+
|
|
30
|
+
if not force:
|
|
31
|
+
if not typer.confirm(f"确定要删除网站 '{site_name}' 吗?"):
|
|
32
|
+
return {"cancelled": True}
|
|
33
|
+
|
|
34
|
+
with get_cursor() as cur:
|
|
35
|
+
cur.execute("DELETE FROM accounts WHERE site = ?", (site.lower(),))
|
|
36
|
+
|
|
37
|
+
console.print(f"[green]✅ 已删除账号:{site_name}[/green]")
|
|
38
|
+
|
|
39
|
+
return {"site": site_name, "deleted": True}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""export 命令"""
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
import json
|
|
5
|
+
from rich.console import Console
|
|
6
|
+
from ..db import get_cursor, is_initialized
|
|
7
|
+
from dong import json_output, DongError
|
|
8
|
+
|
|
9
|
+
console = Console()
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@json_output
|
|
13
|
+
def export(
|
|
14
|
+
output: str = typer.Option(None, "--output", "-o", help="输出文件路径"),
|
|
15
|
+
):
|
|
16
|
+
"""导出账号到 JSON"""
|
|
17
|
+
from ..db import get_db_path
|
|
18
|
+
|
|
19
|
+
if not is_initialized():
|
|
20
|
+
raise DongError("NOT_INITIALIZED", "请先运行 dong-pass init")
|
|
21
|
+
|
|
22
|
+
db_path = get_db_path()
|
|
23
|
+
if not db_path.exists():
|
|
24
|
+
raise DongError("NOT_INITIALIZED", "数据库不存在,请运行 dong-pass init")
|
|
25
|
+
|
|
26
|
+
with get_cursor() as cur:
|
|
27
|
+
cur.execute("SELECT * FROM accounts")
|
|
28
|
+
rows = cur.fetchall()
|
|
29
|
+
|
|
30
|
+
if not rows:
|
|
31
|
+
console.print("暂无账号记录")
|
|
32
|
+
return {"message": "无数据导出", "accounts": []}
|
|
33
|
+
|
|
34
|
+
# 转换为字典
|
|
35
|
+
accounts = []
|
|
36
|
+
for row in rows:
|
|
37
|
+
account = dict(row)
|
|
38
|
+
# 不导出密码(除非用户有特殊需求)
|
|
39
|
+
# password_encrypted = account["password"]
|
|
40
|
+
accounts.append(account)
|
|
41
|
+
|
|
42
|
+
data = {"accounts": accounts, "exported_at": "2026-03-19"}
|
|
43
|
+
|
|
44
|
+
if output:
|
|
45
|
+
with open(output, "w", encoding="utf-8") as f:
|
|
46
|
+
json.dump(data, f, ensure_ascii=False, indent=2)
|
|
47
|
+
console.print(f"[green]✅ 已导出到:{output}[/green]")
|
|
48
|
+
else:
|
|
49
|
+
console.print(json.dumps(data, ensure_ascii=False, indent=2))
|
|
50
|
+
|
|
51
|
+
return {"message": "导出成功", "count": len(accounts)}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""get 命令"""
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
from rich.console import Console
|
|
5
|
+
from rich.table import Table
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from ..db import PassDatabase, get_cursor, is_initialized
|
|
8
|
+
from dong import json_output, DongError
|
|
9
|
+
|
|
10
|
+
console = Console()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@json_output
|
|
14
|
+
def get(
|
|
15
|
+
site: str = typer.Argument(..., help="网站名称"),
|
|
16
|
+
):
|
|
17
|
+
"""获取账号密码"""
|
|
18
|
+
from ..db import get_db_path
|
|
19
|
+
|
|
20
|
+
if not is_initialized():
|
|
21
|
+
raise DongError("NOT_INITIALIZED", "请先运行 dong-pass init")
|
|
22
|
+
|
|
23
|
+
db_path = get_db_path()
|
|
24
|
+
if not db_path.exists():
|
|
25
|
+
raise DongError("NOT_INITIALIZED", "数据库不存在,请运行 dong-pass init")
|
|
26
|
+
|
|
27
|
+
# 判断是否加密
|
|
28
|
+
db = PassDatabase()
|
|
29
|
+
with get_cursor() as cur:
|
|
30
|
+
cur.execute("SELECT * FROM accounts WHERE site = ?", (site.lower(),))
|
|
31
|
+
row = cur.fetchone()
|
|
32
|
+
|
|
33
|
+
if not row:
|
|
34
|
+
raise DongError("NOT_FOUND", f"未找到网站:{site}")
|
|
35
|
+
|
|
36
|
+
site_name = row["site"]
|
|
37
|
+
account = row["account"]
|
|
38
|
+
password_encrypted = row["password"]
|
|
39
|
+
nickname = row["nickname"] or "-"
|
|
40
|
+
category = row["category"] or "-"
|
|
41
|
+
encrypted = row["encrypted"]
|
|
42
|
+
last_used_at = row["last_used_at"]
|
|
43
|
+
created_at = row["created_at"]
|
|
44
|
+
|
|
45
|
+
# 解密密码
|
|
46
|
+
if encrypted:
|
|
47
|
+
master_password = typer.prompt(f"请输入主密码以解密 {site_name} 的密码", password=True)
|
|
48
|
+
db = PassDatabase(master_password)
|
|
49
|
+
password = db.decrypt(password_encrypted)
|
|
50
|
+
else:
|
|
51
|
+
password = password_encrypted
|
|
52
|
+
|
|
53
|
+
# 更新最后使用时间
|
|
54
|
+
with get_cursor() as cur:
|
|
55
|
+
cur.execute("UPDATE accounts SET last_used_at = CURRENT_TIMESTAMP WHERE site = ?", (site.lower(),))
|
|
56
|
+
|
|
57
|
+
# 渲染输出
|
|
58
|
+
table = Table(title=f"账号信息:{site_name}")
|
|
59
|
+
table.add_column("项目", style="cyan")
|
|
60
|
+
table.add_column("内容", style="green")
|
|
61
|
+
|
|
62
|
+
table.add_row("网站", site_name)
|
|
63
|
+
table.add_row("账号", account)
|
|
64
|
+
table.add_row("密码", password)
|
|
65
|
+
if nickname != "-":
|
|
66
|
+
table.add_row("昵称", nickname)
|
|
67
|
+
if category != "-":
|
|
68
|
+
table.add_row("分类", category)
|
|
69
|
+
if last_used_at:
|
|
70
|
+
table.add_row("最后使用", last_used_at)
|
|
71
|
+
table.add_row("创建时间", created_at)
|
|
72
|
+
|
|
73
|
+
console.print(table)
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
"site": site_name,
|
|
77
|
+
"account": account,
|
|
78
|
+
"password": password,
|
|
79
|
+
"encrypted": encrypted,
|
|
80
|
+
"nickname": nickname,
|
|
81
|
+
"category": category,
|
|
82
|
+
"last_used_at": last_used_at,
|
|
83
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""import 命令"""
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
import json
|
|
5
|
+
from ..db import get_cursor, is_initialized
|
|
6
|
+
from dong import json_output, DongError
|
|
7
|
+
|
|
8
|
+
console = typer.Console()
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@json_output
|
|
12
|
+
def import_cmd(
|
|
13
|
+
input: str = typer.Option(..., "--input", "-i", help="输入文件路径"),
|
|
14
|
+
):
|
|
15
|
+
"""从 JSON 导入账号"""
|
|
16
|
+
from ..db import is_initialized
|
|
17
|
+
|
|
18
|
+
if not is_initialized():
|
|
19
|
+
raise DongError("NOT_INITIALIZED", "请先运行 dong-pass init")
|
|
20
|
+
|
|
21
|
+
try:
|
|
22
|
+
with open(input, "r", encoding="utf-8") as f:
|
|
23
|
+
data = json.load(f)
|
|
24
|
+
except FileNotFoundError:
|
|
25
|
+
raise DongError("FILE_NOT_FOUND", f"文件不存在:{input}")
|
|
26
|
+
except json.JSONDecodeError as e:
|
|
27
|
+
raise DongError("INVALID_JSON", f"JSON 格式错误:{e}")
|
|
28
|
+
|
|
29
|
+
accounts = data.get("accounts", [])
|
|
30
|
+
if not accounts:
|
|
31
|
+
return {"message": "文件中没有账号数据", "imported": 0}
|
|
32
|
+
|
|
33
|
+
with get_cursor() as cur:
|
|
34
|
+
imported = 0
|
|
35
|
+
for account in accounts:
|
|
36
|
+
site = account.get("site")
|
|
37
|
+
account_field = account.get("account")
|
|
38
|
+
password_field = account.get("password")
|
|
39
|
+
nickname = account.get("nickname")
|
|
40
|
+
category = account.get("category")
|
|
41
|
+
note = account.get("note")
|
|
42
|
+
encrypted = account.get("encrypted", False)
|
|
43
|
+
|
|
44
|
+
if not site or not account_field:
|
|
45
|
+
continue
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
cur.execute("""
|
|
49
|
+
INSERT INTO accounts (site, account, password, nickname, category, note, encrypted)
|
|
50
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
51
|
+
""", (site.lower(), account_field, password_field, nickname, category, note, encrypted))
|
|
52
|
+
imported += 1
|
|
53
|
+
except sqlite3.IntegrityError:
|
|
54
|
+
# 跳过已存在的网站
|
|
55
|
+
pass
|
|
56
|
+
|
|
57
|
+
console.print(f"[green]✅ 已导入 {imported} 个账号[/green]")
|
|
58
|
+
|
|
59
|
+
return {"message": "导入成功", "imported": imported}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""init 命令"""
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
from rich.console import Console
|
|
5
|
+
from dong import json_output, DongError
|
|
6
|
+
|
|
7
|
+
console = Console()
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@json_output
|
|
11
|
+
def init(
|
|
12
|
+
master_password: str = typer.Option(None, "--master-password", help="主密码(用于加密)"),
|
|
13
|
+
):
|
|
14
|
+
"""初始化数据库"""
|
|
15
|
+
from ..db import PassDatabase, init_database, is_initialized
|
|
16
|
+
|
|
17
|
+
if is_initialized():
|
|
18
|
+
console.print("[yellow]数据库已初始化[/yellow]")
|
|
19
|
+
return {"message": "数据库已初始化"}
|
|
20
|
+
|
|
21
|
+
# 设置主密码
|
|
22
|
+
password = master_password
|
|
23
|
+
if not password:
|
|
24
|
+
password = typer.prompt("设置主密码(用于加密,留空则明文存储)", password=True)
|
|
25
|
+
|
|
26
|
+
# 初始化数据库
|
|
27
|
+
init_database()
|
|
28
|
+
|
|
29
|
+
if password:
|
|
30
|
+
console.print("[green]✅ 数据库初始化成功(加密模式)[/green]")
|
|
31
|
+
console.print("[yellow]⚠️ 请妥善保管主密码!忘记后密码将无法恢复。[/yellow]")
|
|
32
|
+
else:
|
|
33
|
+
console.print("[green]✅ 数据库初始化成功(明文模式)[/green]")
|
|
34
|
+
|
|
35
|
+
return {"message": "数据库初始化成功", "encrypted": bool(password)}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""ls 命令"""
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
from rich.console import Console
|
|
5
|
+
from rich.table import Table
|
|
6
|
+
from ..db import PassDatabase, get_cursor, is_initialized
|
|
7
|
+
from dong import json_output, DongError
|
|
8
|
+
|
|
9
|
+
console = Console()
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@json_output
|
|
13
|
+
def list_accounts(
|
|
14
|
+
category: str = typer.Option(None, "--category", "-c", help="按分类筛选"),
|
|
15
|
+
limit: int = typer.Option(50, "--limit", "-l", help="限制数量"),
|
|
16
|
+
):
|
|
17
|
+
"""列出所有账号"""
|
|
18
|
+
from ..db import get_db_path
|
|
19
|
+
|
|
20
|
+
if not is_initialized():
|
|
21
|
+
raise DongError("NOT_INITIALIZED", "请先运行 dong-pass init")
|
|
22
|
+
|
|
23
|
+
db_path = get_db_path()
|
|
24
|
+
if not db_path.exists():
|
|
25
|
+
raise DongError("NOT_INITIALIZED", "数据库不存在,请运行 dong-pass init")
|
|
26
|
+
|
|
27
|
+
with get_cursor() as cur:
|
|
28
|
+
if category:
|
|
29
|
+
cur.execute("""
|
|
30
|
+
SELECT * FROM accounts WHERE category = ?
|
|
31
|
+
ORDER BY last_used_at DESC
|
|
32
|
+
LIMIT ?
|
|
33
|
+
""", (category, limit))
|
|
34
|
+
else:
|
|
35
|
+
cur.execute("""
|
|
36
|
+
SELECT * FROM accounts
|
|
37
|
+
ORDER BY last_used_at DESC
|
|
38
|
+
LIMIT ?
|
|
39
|
+
""", (limit,))
|
|
40
|
+
|
|
41
|
+
rows = cur.fetchall()
|
|
42
|
+
|
|
43
|
+
if not rows:
|
|
44
|
+
console.print("暂无账号记录")
|
|
45
|
+
return {"items": [], "total": 0}
|
|
46
|
+
|
|
47
|
+
# 渲染表格
|
|
48
|
+
table = Table(title=f"账号列表 {f'(分类:{category})' if category else ''}")
|
|
49
|
+
table.add_column("ID", justify="right", style="cyan")
|
|
50
|
+
table.add_column("网站", style="green")
|
|
51
|
+
table.add_column("账号", style="yellow")
|
|
52
|
+
table.add_column("昵称", style="blue")
|
|
53
|
+
table.add_column("分类", style="magenta")
|
|
54
|
+
table.add_column("最后使用")
|
|
55
|
+
|
|
56
|
+
items = []
|
|
57
|
+
for row in rows:
|
|
58
|
+
expire_id, site, account, password, nickname, category, note, encrypted, created_at, updated_at, last_used_at = row
|
|
59
|
+
|
|
60
|
+
nickname_str = nickname or "-"
|
|
61
|
+
category_str = category or "-"
|
|
62
|
+
last_used_str = last_used_at[:10] if last_used_at else "-"
|
|
63
|
+
|
|
64
|
+
status = "🔒" if encrypted else "🔓"
|
|
65
|
+
|
|
66
|
+
table.add_row(
|
|
67
|
+
str(expire_id),
|
|
68
|
+
site,
|
|
69
|
+
account,
|
|
70
|
+
nickname_str,
|
|
71
|
+
category_str,
|
|
72
|
+
last_used_str,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
items.append({
|
|
76
|
+
"id": expire_id,
|
|
77
|
+
"site": site,
|
|
78
|
+
"account": account,
|
|
79
|
+
"nickname": nickname,
|
|
80
|
+
"category": category,
|
|
81
|
+
"encrypted": encrypted,
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
console.print(table)
|
|
85
|
+
|
|
86
|
+
return {"items": items, "total": len(items)}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""remove_encrypt 命令"""
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
from ..db import PassDatabase, get_cursor, is_initialized
|
|
5
|
+
from dong import json_output, DongError
|
|
6
|
+
|
|
7
|
+
console = typer.Console()
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@json_output
|
|
11
|
+
def remove_encrypt():
|
|
12
|
+
"""移除加密(明文化)"""
|
|
13
|
+
if not is_initialized():
|
|
14
|
+
raise DongError("NOT_INITIALIZED", "请先运行 dong-pass init")
|
|
15
|
+
|
|
16
|
+
# 检查是否已加密
|
|
17
|
+
with get_cursor() as cur:
|
|
18
|
+
cur.execute("SELECT encrypted FROM accounts LIMIT 1")
|
|
19
|
+
row = cur.fetchone()
|
|
20
|
+
|
|
21
|
+
if not row or not row["encrypted"]:
|
|
22
|
+
raise DongError("NOT_ENCRYPTED", "数据库不是加密模式")
|
|
23
|
+
|
|
24
|
+
# 输入主密码
|
|
25
|
+
master_password = typer.prompt("输入主密码以验证", password=True)
|
|
26
|
+
|
|
27
|
+
if not master_password:
|
|
28
|
+
raise DongError("INVALID_PASSWORD", "主密码不能为空")
|
|
29
|
+
|
|
30
|
+
# 验证密码
|
|
31
|
+
db = PassDatabase(master_password)
|
|
32
|
+
try:
|
|
33
|
+
# 测试解密一个密码
|
|
34
|
+
with get_cursor(master_password) as cur:
|
|
35
|
+
cur.execute("SELECT password FROM accounts LIMIT 1")
|
|
36
|
+
row = cur.fetchone()
|
|
37
|
+
db.decrypt(row["password"])
|
|
38
|
+
except Exception:
|
|
39
|
+
raise DongError("INVALID_PASSWORD", "主密码错误")
|
|
40
|
+
|
|
41
|
+
# 明文化所有密码
|
|
42
|
+
with get_cursor(master_password) as cur:
|
|
43
|
+
cur.execute("SELECT id, password FROM accounts")
|
|
44
|
+
rows = cur.fetchall()
|
|
45
|
+
|
|
46
|
+
for row in rows:
|
|
47
|
+
account_id = row["id"]
|
|
48
|
+
encrypted_pwd = row["password"]
|
|
49
|
+
# 转换为明文
|
|
50
|
+
plain_pwd = db.decrypt(encrypted_pwd)
|
|
51
|
+
cur.execute(
|
|
52
|
+
"UPDATE accounts SET password = ?, encrypted = 0 WHERE id = ?",
|
|
53
|
+
(plain_pwd, account_id)
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
console.print("[green]✅ 加密已移除,所有密码已明文化[/green]")
|
|
57
|
+
|
|
58
|
+
return {"message": "加密已移除"}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""search 命令"""
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
from rich.console import Console
|
|
5
|
+
from rich.table import Table
|
|
6
|
+
from ..db import PassDatabase, get_cursor, is_initialized
|
|
7
|
+
from dong import json_output, DongError
|
|
8
|
+
|
|
9
|
+
console = Console()
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@json_output
|
|
13
|
+
def search(
|
|
14
|
+
keyword: str = typer.Argument(..., help="搜索关键词"),
|
|
15
|
+
category: str = typer.Option(None, "--category", help="按分类筛选"),
|
|
16
|
+
):
|
|
17
|
+
"""搜索账号"""
|
|
18
|
+
if not is_initialized():
|
|
19
|
+
raise DongError("NOT_INITIALIZED", "请先运行 dong-pass init")
|
|
20
|
+
|
|
21
|
+
search_pattern = f"%{keyword}%"
|
|
22
|
+
with get_cursor() as cur:
|
|
23
|
+
if category:
|
|
24
|
+
cur.execute("""
|
|
25
|
+
SELECT * FROM accounts WHERE site LIKE ? OR account LIKE ? OR nickname LIKE ? OR category = ?
|
|
26
|
+
ORDER BY last_used_at DESC
|
|
27
|
+
""", (search_pattern, search_pattern, search_pattern, category))
|
|
28
|
+
else:
|
|
29
|
+
cur.execute("""
|
|
30
|
+
SELECT * FROM accounts WHERE site LIKE ? OR account LIKE ? OR nickname LIKE ?
|
|
31
|
+
ORDER BY last_used_at DESC
|
|
32
|
+
""", (search_pattern, search_pattern, search_pattern))
|
|
33
|
+
|
|
34
|
+
rows = cur.fetchall()
|
|
35
|
+
|
|
36
|
+
if not rows:
|
|
37
|
+
console.print(f"[yellow]未找到包含 '{keyword}' 的账号[/yellow]")
|
|
38
|
+
return {"items": [], "total": 0}
|
|
39
|
+
|
|
40
|
+
# 渲染表格
|
|
41
|
+
table = Table(title=f"搜索结果:{keyword}")
|
|
42
|
+
table.add_column("ID", justify="right", style="cyan")
|
|
43
|
+
table.add_column("网站", style="green")
|
|
44
|
+
table.add_column("账号", style="yellow")
|
|
45
|
+
table.add_column("昵称", style="blue")
|
|
46
|
+
table.add_column("分类", style="magenta")
|
|
47
|
+
|
|
48
|
+
items = []
|
|
49
|
+
for row in rows:
|
|
50
|
+
expire_id, site, account, password, nickname, category, note, encrypted, created_at, updated_at, last_used_at = row
|
|
51
|
+
|
|
52
|
+
nickname_str = nickname or "-"
|
|
53
|
+
category_str = category or "-"
|
|
54
|
+
|
|
55
|
+
table.add_row(
|
|
56
|
+
str(expire_id),
|
|
57
|
+
site,
|
|
58
|
+
account,
|
|
59
|
+
nickname_str,
|
|
60
|
+
category_str,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
items.append({
|
|
64
|
+
"id": expire_id,
|
|
65
|
+
"site": site,
|
|
66
|
+
"account": account,
|
|
67
|
+
"nickname": nickname,
|
|
68
|
+
"category": category,
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
console.print(table)
|
|
72
|
+
|
|
73
|
+
return {"items": items, "total": len(items)}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""set_master 命令"""
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
from ..db import PassDatabase, get_cursor, is_initialized
|
|
5
|
+
from dong import json_output, DongError
|
|
6
|
+
|
|
7
|
+
console = typer.Console()
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@json_output
|
|
11
|
+
def set_master():
|
|
12
|
+
"""设置主密码(首次设置)"""
|
|
13
|
+
if not is_initialized():
|
|
14
|
+
raise DongError("NOT_INITIALIZED", "请先运行 dong-pass init")
|
|
15
|
+
|
|
16
|
+
# 检查是否已有主密码
|
|
17
|
+
with get_cursor() as cur:
|
|
18
|
+
cur.execute("SELECT encrypted FROM accounts LIMIT 1")
|
|
19
|
+
row = cur.fetchone()
|
|
20
|
+
|
|
21
|
+
if row and row["encrypted"]:
|
|
22
|
+
raise DongError("ALREADY_ENCRYPTED", "数据库已经是加密模式,请使用 change-master 命令")
|
|
23
|
+
|
|
24
|
+
# 设置新主密码
|
|
25
|
+
master_password = typer.prompt("设置主密码(用于加密),不能为空", password=True, confirmation_prompt=True)
|
|
26
|
+
|
|
27
|
+
if not master_password:
|
|
28
|
+
raise DongError("INVALID_PASSWORD", "主密码不能为空")
|
|
29
|
+
|
|
30
|
+
console.print("[green]✅ 主密码设置成功[/green]")
|
|
31
|
+
console.print("[yellow]⚠️ 请妥善保管主密码!忘记后密码将无法恢复。[/yellow]")
|
|
32
|
+
|
|
33
|
+
return {"message": "主密码设置成功", "encrypted": True}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""stats 命令"""
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
from rich.console import Console
|
|
5
|
+
from rich.table import Table
|
|
6
|
+
from ..db import PassDatabase, get_cursor, is_initialized
|
|
7
|
+
from dong import json_output, DongError
|
|
8
|
+
|
|
9
|
+
console = Console()
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@json_output
|
|
13
|
+
def stats():
|
|
14
|
+
"""统计信息"""
|
|
15
|
+
from ..db import get_db_path
|
|
16
|
+
|
|
17
|
+
if not is_initialized():
|
|
18
|
+
raise DongError("NOT_INITIALIZED", "请先运行 dong-pass init")
|
|
19
|
+
|
|
20
|
+
db_path = get_db_path()
|
|
21
|
+
if not db_path.exists():
|
|
22
|
+
raise DongError("NOT_INITIALIZED", "数据库不存在,请运行 dong-pass init")
|
|
23
|
+
|
|
24
|
+
with get_cursor() as cur:
|
|
25
|
+
# 总数
|
|
26
|
+
cur.execute("SELECT COUNT(*) FROM accounts")
|
|
27
|
+
total = cur.fetchone()[0]
|
|
28
|
+
|
|
29
|
+
# 按分类统计
|
|
30
|
+
cur.execute("""
|
|
31
|
+
SELECT category, COUNT(*) as count
|
|
32
|
+
FROM accounts
|
|
33
|
+
GROUP BY category
|
|
34
|
+
""")
|
|
35
|
+
category_stats = cur.fetchall()
|
|
36
|
+
|
|
37
|
+
if total == 0:
|
|
38
|
+
console.print("暂无账号记录")
|
|
39
|
+
return {"total": 0, "categories": []}
|
|
40
|
+
|
|
41
|
+
# 渲染表格
|
|
42
|
+
table = Table(title="账号统计")
|
|
43
|
+
table.add_column("分类", style="blue")
|
|
44
|
+
table.add_column("数量", justify="right", style="cyan")
|
|
45
|
+
table.add_column("占比", justify="right", style="green")
|
|
46
|
+
|
|
47
|
+
categories = []
|
|
48
|
+
for row in category_stats:
|
|
49
|
+
category, count = row
|
|
50
|
+
category = category or "未分类"
|
|
51
|
+
percentage = (count / total * 100)
|
|
52
|
+
table.add_row(category, str(count), f"{percentage:.1f}%")
|
|
53
|
+
categories.append({
|
|
54
|
+
"category": category,
|
|
55
|
+
"count": count,
|
|
56
|
+
"percentage": percentage,
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
table.add_row("总计", str(total), "100%")
|
|
60
|
+
console.print(table)
|
|
61
|
+
|
|
62
|
+
return {"total": total, "categories": categories}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""update 命令"""
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
from ..db import PassDatabase, get_cursor
|
|
5
|
+
from dong import json_output, DongError
|
|
6
|
+
|
|
7
|
+
console = typer.Console()
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@json_output
|
|
11
|
+
def update(
|
|
12
|
+
site: str = typer.Argument(..., help="网站名称"),
|
|
13
|
+
account: str = typer.Option(None, "--account", help="账号"),
|
|
14
|
+
password: str = typer.Option(None, "--password", help="密码"),
|
|
15
|
+
nickname: str = typer.Option(None, "--nickname", help="昵称"),
|
|
16
|
+
category: str = typer.Option(None, "--category", help="分类"),
|
|
17
|
+
note: str = typer.Option(None, "--note", help="备注"),
|
|
18
|
+
):
|
|
19
|
+
"""更新账号"""
|
|
20
|
+
from ..db import is_initialized, PassDatabase
|
|
21
|
+
|
|
22
|
+
if not is_initialized():
|
|
23
|
+
raise DongError("NOT_INITIALIZED", "请先运行 dong-pass init")
|
|
24
|
+
|
|
25
|
+
# 判断是否需要主密码(如果修改密码且原记录已加密)
|
|
26
|
+
with get_cursor() as cur:
|
|
27
|
+
cur.execute("SELECT encrypted FROM accounts WHERE site = ?", (site.lower(),))
|
|
28
|
+
row = cur.fetchone()
|
|
29
|
+
|
|
30
|
+
if not row:
|
|
31
|
+
raise DongError("NOT_FOUND", f"未找到网站:{site}")
|
|
32
|
+
|
|
33
|
+
encrypted = row["encrypted"]
|
|
34
|
+
master_password = None
|
|
35
|
+
if password and encrypted:
|
|
36
|
+
master_password = typer.prompt("输入主密码以解密密码", password=True)
|
|
37
|
+
|
|
38
|
+
# 构建更新语句
|
|
39
|
+
updates = []
|
|
40
|
+
params = []
|
|
41
|
+
|
|
42
|
+
if account:
|
|
43
|
+
updates.append("account = ?")
|
|
44
|
+
params.append(account)
|
|
45
|
+
if password:
|
|
46
|
+
db = PassDatabase(master_password)
|
|
47
|
+
if encrypted:
|
|
48
|
+
encrypted_pwd = db.encrypt(password)
|
|
49
|
+
else:
|
|
50
|
+
encrypted_pwd = password
|
|
51
|
+
updates.append("password = ?")
|
|
52
|
+
params.append(encrypted_pwd)
|
|
53
|
+
if nickname:
|
|
54
|
+
updates.append("nickname = ?")
|
|
55
|
+
params.append(nickname)
|
|
56
|
+
if category:
|
|
57
|
+
updates.append("category = ?")
|
|
58
|
+
params.append(category)
|
|
59
|
+
if note:
|
|
60
|
+
updates.append("note = ?")
|
|
61
|
+
params.append(note)
|
|
62
|
+
|
|
63
|
+
if not updates:
|
|
64
|
+
raise DongError("NO_UPDATES", "没有指定要更新的字段")
|
|
65
|
+
|
|
66
|
+
updates.append("updated_at = CURRENT_TIMESTAMP")
|
|
67
|
+
params.append(site.lower())
|
|
68
|
+
|
|
69
|
+
with get_cursor(master_password) as cur:
|
|
70
|
+
cur.execute(
|
|
71
|
+
f"UPDATE accounts SET {', '.join(updates)} WHERE site = ?",
|
|
72
|
+
params
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
if cur.rowcount == 0:
|
|
76
|
+
raise DongError("NOT_FOUND", f"未找到网站:{site}")
|
|
77
|
+
|
|
78
|
+
console.print(f"[green]✅ 已更新账号:{site}[/green]")
|
|
79
|
+
|
|
80
|
+
return {"site": site.lower(), "updated": True}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""数据库连接管理"""
|
|
2
|
+
|
|
3
|
+
import sqlite3
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Iterator
|
|
6
|
+
from contextlib import contextmanager
|
|
7
|
+
from cryptography.fernet import Fernet
|
|
8
|
+
import getpass
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class PassDatabase:
|
|
12
|
+
"""密码咚数据库类"""
|
|
13
|
+
|
|
14
|
+
def __init__(self, master_password: str = None):
|
|
15
|
+
self.db_path = self.get_db_path()
|
|
16
|
+
self.master_password = master_password
|
|
17
|
+
self.cipher = None
|
|
18
|
+
if master_password:
|
|
19
|
+
self.cipher = self._get_cipher(master_password)
|
|
20
|
+
|
|
21
|
+
@classmethod
|
|
22
|
+
def get_db_path(cls) -> Path:
|
|
23
|
+
"""获取数据库路径"""
|
|
24
|
+
db_path = Path.home() / ".dong" / "accounts.db"
|
|
25
|
+
db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
26
|
+
return db_path
|
|
27
|
+
|
|
28
|
+
def _get_cipher(self, password: str) -> Fernet:
|
|
29
|
+
"""获取加密器"""
|
|
30
|
+
# 从密码生成密钥
|
|
31
|
+
key = Fernet.generate_key()
|
|
32
|
+
# 使用密码作为种子(实际应用中应该更安全的方式)
|
|
33
|
+
import hashlib
|
|
34
|
+
salt = b'dong-pass-salt-2026'
|
|
35
|
+
kdf_key = hashlib.pbkdf2_hmac(
|
|
36
|
+
'sha256',
|
|
37
|
+
password.encode(),
|
|
38
|
+
salt,
|
|
39
|
+
100000
|
|
40
|
+
)
|
|
41
|
+
# 转换为 Fernet key 格式
|
|
42
|
+
fernet_key = Fernet.generate_key(kdf_key)
|
|
43
|
+
return Fernet(fernet_key)
|
|
44
|
+
|
|
45
|
+
def encrypt(self, text: str) -> str:
|
|
46
|
+
"""加密文本"""
|
|
47
|
+
if not self.cipher:
|
|
48
|
+
return text
|
|
49
|
+
return self.cipher.encrypt(text.encode()).decode()
|
|
50
|
+
|
|
51
|
+
def decrypt(self, encrypted: str) -> str:
|
|
52
|
+
"""解密文本"""
|
|
53
|
+
if not self.cipher:
|
|
54
|
+
return encrypted
|
|
55
|
+
return self.cipher.decrypt(encrypted.encode()).decode()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@contextmanager
|
|
59
|
+
def get_cursor(master_password: str = None) -> Iterator[sqlite3.Cursor]:
|
|
60
|
+
"""获取数据库游标"""
|
|
61
|
+
db = PassDatabase(master_password)
|
|
62
|
+
conn = sqlite3.connect(str(db.db_path))
|
|
63
|
+
conn.row_factory = sqlite3.Row
|
|
64
|
+
cursor = conn.cursor()
|
|
65
|
+
try:
|
|
66
|
+
yield cursor
|
|
67
|
+
conn.commit()
|
|
68
|
+
except Exception:
|
|
69
|
+
conn.rollback()
|
|
70
|
+
raise
|
|
71
|
+
finally:
|
|
72
|
+
conn.close()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def get_db_path() -> Path:
|
|
76
|
+
"""获取数据库路径"""
|
|
77
|
+
return PassDatabase.get_db_path()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def close_connection():
|
|
81
|
+
"""关闭数据库连接"""
|
|
82
|
+
pass
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""数据库 Schema 管理"""
|
|
2
|
+
|
|
3
|
+
from .connection import PassDatabase
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class PassSchemaManager:
|
|
8
|
+
"""密码咚 Schema 管理器"""
|
|
9
|
+
|
|
10
|
+
def init_schema(self) -> None:
|
|
11
|
+
with PassDatabase.get_cursor() as cur:
|
|
12
|
+
self._create_accounts_table()
|
|
13
|
+
self._create_indexes()
|
|
14
|
+
|
|
15
|
+
def _create_accounts_table(self) -> None:
|
|
16
|
+
with PassDatabase.get_cursor() as cur:
|
|
17
|
+
cur.execute("""
|
|
18
|
+
CREATE TABLE IF NOT EXISTS accounts (
|
|
19
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
20
|
+
site TEXT NOT NULL UNIQUE,
|
|
21
|
+
account TEXT NOT NULL,
|
|
22
|
+
password TEXT NOT NULL,
|
|
23
|
+
nickname TEXT,
|
|
24
|
+
category TEXT,
|
|
25
|
+
note TEXT,
|
|
26
|
+
encrypted BOOLEAN DEFAULT 0,
|
|
27
|
+
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
|
28
|
+
updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
|
29
|
+
last_used_at TEXT
|
|
30
|
+
)
|
|
31
|
+
""")
|
|
32
|
+
|
|
33
|
+
def _create_indexes(self) -> None:
|
|
34
|
+
with PassDatabase.get_cursor() as cur:
|
|
35
|
+
cur.execute("CREATE INDEX IF NOT EXISTS idx_accounts_category ON accounts(category)")
|
|
36
|
+
cur.execute("CREATE INDEX IF NOT EXISTS idx_accounts_last_used ON accounts(last_used_at)")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def init_database() -> dict:
|
|
40
|
+
"""初始化数据库"""
|
|
41
|
+
manager = PassSchemaManager()
|
|
42
|
+
manager.init_schema()
|
|
43
|
+
return {"message": "数据库初始化成功"}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def is_initialized() -> bool:
|
|
47
|
+
"""检查数据库是否已初始化"""
|
|
48
|
+
db_path = PassDatabase.get_db_path()
|
|
49
|
+
if not db_path.exists():
|
|
50
|
+
return False
|
|
51
|
+
|
|
52
|
+
try:
|
|
53
|
+
conn = sqlite3.connect(str(db_path))
|
|
54
|
+
cur = conn.cursor()
|
|
55
|
+
cur.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='accounts'")
|
|
56
|
+
result = cur.fetchone() is not None
|
|
57
|
+
conn.close()
|
|
58
|
+
return result
|
|
59
|
+
except Exception:
|
|
60
|
+
return False
|