datavault-cli 1.0.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.
- chain.py +173 -0
- dashboard.py +211 -0
- datavault.py +214 -0
- datavault_cli-1.0.0.dist-info/METADATA +178 -0
- datavault_cli-1.0.0.dist-info/RECORD +13 -0
- datavault_cli-1.0.0.dist-info/WHEEL +5 -0
- datavault_cli-1.0.0.dist-info/entry_points.txt +2 -0
- datavault_cli-1.0.0.dist-info/top_level.txt +8 -0
- migrate.py +70 -0
- pi_bridge.py +214 -0
- row_tracker.py +201 -0
- signing.py +166 -0
- vault_core.py +424 -0
chain.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
HISTORY_FILE = ".datavault/history.json"
|
|
6
|
+
CHAIN_FILE = ".datavault/chain.json"
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def compute_chain_hash(file_hash, prev_chain_hash, version_id, timestamp, author, message):
|
|
10
|
+
chain_input = "|".join([file_hash, prev_chain_hash, version_id, timestamp, author, message])
|
|
11
|
+
return hashlib.sha256(chain_input.encode()).hexdigest()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def build_chain(filename):
|
|
15
|
+
if not os.path.exists(HISTORY_FILE):
|
|
16
|
+
return False, "No DataVault project found."
|
|
17
|
+
with open(HISTORY_FILE, "r", encoding="utf-8") as f:
|
|
18
|
+
history = json.load(f)
|
|
19
|
+
if filename not in history.get("files", {}):
|
|
20
|
+
return False, f"'{filename}' is not tracked."
|
|
21
|
+
|
|
22
|
+
versions = history["files"][filename]["versions"]
|
|
23
|
+
prev_chain_hash = "GENESIS"
|
|
24
|
+
chain = []
|
|
25
|
+
|
|
26
|
+
for v in versions:
|
|
27
|
+
chain_hash = compute_chain_hash(
|
|
28
|
+
v["hash"], prev_chain_hash, v["version_id"],
|
|
29
|
+
v["timestamp"], v.get("author", "unknown"), v["message"]
|
|
30
|
+
)
|
|
31
|
+
chain.append({
|
|
32
|
+
"version_id": v["version_id"],
|
|
33
|
+
"file_hash": v["hash"],
|
|
34
|
+
"prev_chain_hash": prev_chain_hash,
|
|
35
|
+
"chain_hash": chain_hash,
|
|
36
|
+
"timestamp": v["timestamp"],
|
|
37
|
+
"author": v.get("author", "unknown"),
|
|
38
|
+
"message": v["message"]
|
|
39
|
+
})
|
|
40
|
+
v["chain_hash"] = chain_hash
|
|
41
|
+
v["prev_chain_hash"] = prev_chain_hash
|
|
42
|
+
prev_chain_hash = chain_hash
|
|
43
|
+
|
|
44
|
+
with open(HISTORY_FILE, "w", encoding="utf-8") as f:
|
|
45
|
+
json.dump(history, f, indent=2)
|
|
46
|
+
|
|
47
|
+
chain_data = _load_chain()
|
|
48
|
+
chain_data["files"][filename] = chain
|
|
49
|
+
_save_chain(chain_data)
|
|
50
|
+
return True, chain
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def verify_chain(filename):
|
|
54
|
+
if not os.path.exists(HISTORY_FILE):
|
|
55
|
+
return False, "No DataVault project found."
|
|
56
|
+
with open(HISTORY_FILE, "r", encoding="utf-8") as f:
|
|
57
|
+
history = json.load(f)
|
|
58
|
+
if filename not in history.get("files", {}):
|
|
59
|
+
return False, f"'{filename}' is not tracked."
|
|
60
|
+
|
|
61
|
+
versions = history["files"][filename]["versions"]
|
|
62
|
+
if not any("chain_hash" in v for v in versions):
|
|
63
|
+
return False, f"No chain data for '{filename}'.\nRun: python datavault.py chainbuild {filename}"
|
|
64
|
+
|
|
65
|
+
prev_chain_hash = "GENESIS"
|
|
66
|
+
results = []
|
|
67
|
+
all_valid = True
|
|
68
|
+
|
|
69
|
+
for v in versions:
|
|
70
|
+
stored = v.get("chain_hash")
|
|
71
|
+
if not stored:
|
|
72
|
+
results.append({"version_id": v["version_id"], "valid": False, "reason": "no chain hash — run chainbuild"})
|
|
73
|
+
all_valid = False
|
|
74
|
+
continue
|
|
75
|
+
|
|
76
|
+
expected = compute_chain_hash(
|
|
77
|
+
v["hash"], prev_chain_hash, v["version_id"],
|
|
78
|
+
v["timestamp"], v.get("author", "unknown"), v["message"]
|
|
79
|
+
)
|
|
80
|
+
valid = (expected == stored)
|
|
81
|
+
if not valid:
|
|
82
|
+
all_valid = False
|
|
83
|
+
|
|
84
|
+
results.append({
|
|
85
|
+
"version_id": v["version_id"],
|
|
86
|
+
"valid": valid,
|
|
87
|
+
"stored": stored[:24],
|
|
88
|
+
"expected": expected[:24],
|
|
89
|
+
"prev": prev_chain_hash[:24] if prev_chain_hash != "GENESIS" else "GENESIS",
|
|
90
|
+
"timestamp": v["timestamp"][:19].replace("T", " "),
|
|
91
|
+
"message": v["message"]
|
|
92
|
+
})
|
|
93
|
+
prev_chain_hash = stored
|
|
94
|
+
|
|
95
|
+
return all_valid, results
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def format_chain_verification(filename, results):
|
|
99
|
+
lines = [f"\nChain verification: '{filename}'\n" + "─"*55]
|
|
100
|
+
for r in results:
|
|
101
|
+
if r["valid"]:
|
|
102
|
+
lines.append(
|
|
103
|
+
f"\n ✓ {r['version_id']} | {r.get('timestamp','')}\n"
|
|
104
|
+
f" chain: {r.get('stored','?')}…\n"
|
|
105
|
+
f" prev: {r.get('prev','?')}…\n"
|
|
106
|
+
f" \"{r.get('message','')}\""
|
|
107
|
+
)
|
|
108
|
+
else:
|
|
109
|
+
lines.append(
|
|
110
|
+
f"\n ✗ {r['version_id']} CHAIN BROKEN\n"
|
|
111
|
+
f" stored: {r.get('stored','?')}…\n"
|
|
112
|
+
f" expected: {r.get('expected','?')}…\n"
|
|
113
|
+
f" reason: {r.get('reason','hash mismatch')}"
|
|
114
|
+
)
|
|
115
|
+
lines.append(f"\n{'─'*55}")
|
|
116
|
+
if all(r["valid"] for r in results):
|
|
117
|
+
lines.append(
|
|
118
|
+
f" ✓ CHAIN INTACT — {len(results)} version(s) verified\n"
|
|
119
|
+
f" Complete history is authentic and unmodified.\n"
|
|
120
|
+
f" No versions were deleted, reordered, or tampered with."
|
|
121
|
+
)
|
|
122
|
+
else:
|
|
123
|
+
broken = [r["version_id"] for r in results if not r["valid"]]
|
|
124
|
+
lines.append(
|
|
125
|
+
f" ✗ CHAIN BROKEN at: {', '.join(broken)}\n"
|
|
126
|
+
f" History has been tampered with."
|
|
127
|
+
)
|
|
128
|
+
return "\n".join(lines)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def display_chain(filename):
|
|
132
|
+
if not os.path.exists(HISTORY_FILE):
|
|
133
|
+
return False, "No DataVault project found."
|
|
134
|
+
with open(HISTORY_FILE, "r", encoding="utf-8") as f:
|
|
135
|
+
history = json.load(f)
|
|
136
|
+
if filename not in history.get("files", {}):
|
|
137
|
+
return False, f"'{filename}' is not tracked."
|
|
138
|
+
|
|
139
|
+
versions = history["files"][filename]["versions"]
|
|
140
|
+
lines = [f"\nHash chain: '{filename}'\n" + "─"*55]
|
|
141
|
+
lines.append(" (Each block's hash includes the previous block's hash)")
|
|
142
|
+
lines.append(" (Changing any block invalidates all blocks after it)\n")
|
|
143
|
+
|
|
144
|
+
for i, v in enumerate(versions):
|
|
145
|
+
chain_hash = v.get("chain_hash", "not computed")[:20]
|
|
146
|
+
file_hash = v["hash"][:20]
|
|
147
|
+
prev_hash = v.get("prev_chain_hash", "GENESIS")
|
|
148
|
+
if prev_hash != "GENESIS":
|
|
149
|
+
prev_hash = prev_hash[:20]
|
|
150
|
+
lines.append(f" ┌─────────────────────────────────────┐")
|
|
151
|
+
lines.append(f" │ {v['version_id']} {v['timestamp'][:10]} │")
|
|
152
|
+
lines.append(f" │ file hash: {file_hash}… │")
|
|
153
|
+
lines.append(f" │ prev hash: {prev_hash}… │")
|
|
154
|
+
lines.append(f" │ chain hash: {chain_hash}… │")
|
|
155
|
+
lines.append(f" │ \"{v['message'][:30]}\"")
|
|
156
|
+
lines.append(f" └──────────────────┬──────────────────┘")
|
|
157
|
+
if i < len(versions) - 1:
|
|
158
|
+
lines.append(f" │")
|
|
159
|
+
lines.append(f" ▼")
|
|
160
|
+
|
|
161
|
+
return True, "\n".join(lines)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _load_chain():
|
|
165
|
+
if os.path.exists(CHAIN_FILE):
|
|
166
|
+
with open(CHAIN_FILE, "r", encoding="utf-8") as f:
|
|
167
|
+
return json.load(f)
|
|
168
|
+
return {"files": {}}
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _save_chain(data):
|
|
172
|
+
with open(CHAIN_FILE, "w", encoding="utf-8") as f:
|
|
173
|
+
json.dump(data, f, indent=2)
|
dashboard.py
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import http.server
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
import webbrowser
|
|
6
|
+
import threading
|
|
7
|
+
import hashlib
|
|
8
|
+
|
|
9
|
+
VAULT_DIR = ".datavault"
|
|
10
|
+
HISTORY_FILE = ".datavault/history.json"
|
|
11
|
+
META_FILE = ".datavault/meta.json"
|
|
12
|
+
PORT = 5000
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def build_dashboard_html():
|
|
16
|
+
if not os.path.exists(HISTORY_FILE):
|
|
17
|
+
return _error_page("No DataVault project found. Run 'python datavault.py init <name>' first.")
|
|
18
|
+
|
|
19
|
+
with open(HISTORY_FILE, "r", encoding="utf-8") as f:
|
|
20
|
+
history = json.load(f)
|
|
21
|
+
with open(META_FILE, "r", encoding="utf-8") as f:
|
|
22
|
+
meta = json.load(f)
|
|
23
|
+
|
|
24
|
+
proj_name = meta.get("project_name", "DataVault Project")
|
|
25
|
+
created_by = meta.get("created_by", "unknown")
|
|
26
|
+
file_cards = ""
|
|
27
|
+
|
|
28
|
+
for filename, data in history["files"].items():
|
|
29
|
+
versions = data["versions"]
|
|
30
|
+
latest = versions[-1]
|
|
31
|
+
if os.path.exists(filename):
|
|
32
|
+
status_html = '<span class="status ok">✓ up to date</span>' if _hash_file(filename) == latest["hash"] else '<span class="status warn">⚠ modified — run commit</span>'
|
|
33
|
+
else:
|
|
34
|
+
status_html = '<span class="status err">✗ file missing</span>'
|
|
35
|
+
|
|
36
|
+
timeline_items = ""
|
|
37
|
+
for i, v in enumerate(reversed(versions)):
|
|
38
|
+
ts = v["timestamp"][:19].replace("T", " ")
|
|
39
|
+
tags = v.get("tags", [])
|
|
40
|
+
tag_html = "".join(f'<span class="tag">{t["message"]}</span>' for t in tags)
|
|
41
|
+
is_latest = (i == 0)
|
|
42
|
+
timeline_items += f"""
|
|
43
|
+
<div class="version-item {'latest' if is_latest else ''}">
|
|
44
|
+
<div class="version-dot"></div>
|
|
45
|
+
<div class="version-body">
|
|
46
|
+
<div class="version-top">
|
|
47
|
+
<span class="vid">{v["version_id"]}</span>
|
|
48
|
+
<span class="vmsg">{v["message"]}</span>
|
|
49
|
+
{'<span class="latest-badge">latest</span>' if is_latest else ''}
|
|
50
|
+
</div>
|
|
51
|
+
<div class="version-meta">{ts} · {v.get("author","?")} · {_fmt_size(v["size_bytes"])}</div>
|
|
52
|
+
<div class="version-hash">{v["hash"][:32]}…</div>
|
|
53
|
+
{f'<div class="tags">{tag_html}</div>' if tags else ''}
|
|
54
|
+
</div>
|
|
55
|
+
</div>"""
|
|
56
|
+
|
|
57
|
+
file_cards += f"""
|
|
58
|
+
<div class="file-card">
|
|
59
|
+
<div class="file-card-header">
|
|
60
|
+
<div>
|
|
61
|
+
<div class="filename">{filename}</div>
|
|
62
|
+
<div class="file-meta">{len(versions)} version(s) · last: {latest["timestamp"][:10]}</div>
|
|
63
|
+
</div>
|
|
64
|
+
{status_html}
|
|
65
|
+
</div>
|
|
66
|
+
<div class="timeline">{timeline_items}</div>
|
|
67
|
+
</div>"""
|
|
68
|
+
|
|
69
|
+
if not file_cards:
|
|
70
|
+
file_cards = '<div class="empty">No files tracked yet. Use <code>datavault add <file> "<message>"</code> to start.</div>'
|
|
71
|
+
|
|
72
|
+
total_versions = sum(len(d["versions"]) for d in history["files"].values())
|
|
73
|
+
total_tags = sum(len(v.get("tags",[])) for d in history["files"].values() for v in d["versions"])
|
|
74
|
+
|
|
75
|
+
return f"""<!DOCTYPE html>
|
|
76
|
+
<html lang="en">
|
|
77
|
+
<head>
|
|
78
|
+
<meta charset="UTF-8">
|
|
79
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
80
|
+
<title>DataVault — {proj_name}</title>
|
|
81
|
+
<style>
|
|
82
|
+
*,*::before,*::after{{box-sizing:border-box;margin:0;padding:0}}
|
|
83
|
+
body{{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#f7f7f5;color:#1a1a1a;min-height:100vh}}
|
|
84
|
+
.topbar{{background:#1a1a1a;color:#fff;padding:0 32px;height:56px;display:flex;align-items:center;justify-content:space-between;position:sticky;top:0;z-index:10}}
|
|
85
|
+
.topbar-left{{display:flex;align-items:center;gap:12px}}
|
|
86
|
+
.logo{{font-size:15px;font-weight:600;letter-spacing:-.3px}}
|
|
87
|
+
.proj{{font-size:13px;color:#999}}
|
|
88
|
+
.topbar-right{{font-size:12px;color:#666}}
|
|
89
|
+
.stats-bar{{background:#fff;border-bottom:1px solid #e8e8e8;padding:16px 32px;display:flex;gap:32px}}
|
|
90
|
+
.stat{{display:flex;flex-direction:column;gap:2px}}
|
|
91
|
+
.stat-val{{font-size:20px;font-weight:600;color:#1a1a1a}}
|
|
92
|
+
.stat-lbl{{font-size:11px;color:#888;text-transform:uppercase;letter-spacing:.5px}}
|
|
93
|
+
.main{{max-width:860px;margin:32px auto;padding:0 24px}}
|
|
94
|
+
.section-title{{font-size:13px;font-weight:500;color:#888;text-transform:uppercase;letter-spacing:.5px;margin-bottom:16px}}
|
|
95
|
+
.file-card{{background:#fff;border:1px solid #e8e8e8;border-radius:10px;margin-bottom:20px;overflow:hidden}}
|
|
96
|
+
.file-card-header{{padding:16px 20px;border-bottom:1px solid #f0f0f0;display:flex;justify-content:space-between;align-items:flex-start}}
|
|
97
|
+
.filename{{font-size:15px;font-weight:600;margin-bottom:3px}}
|
|
98
|
+
.file-meta{{font-size:12px;color:#888}}
|
|
99
|
+
.status{{font-size:12px;padding:4px 10px;border-radius:20px;font-weight:500}}
|
|
100
|
+
.status.ok{{background:#e8f4e8;color:#2a6a2a}}
|
|
101
|
+
.status.warn{{background:#fff3e0;color:#8a4a00}}
|
|
102
|
+
.status.err{{background:#fdecea;color:#8a1a1a}}
|
|
103
|
+
.timeline{{padding:12px 20px 4px}}
|
|
104
|
+
.version-item{{display:flex;gap:12px;padding:10px 0;border-bottom:1px solid #f5f5f5}}
|
|
105
|
+
.version-item:last-child{{border-bottom:none}}
|
|
106
|
+
.version-dot{{width:10px;height:10px;border-radius:50%;background:#d0d0d0;margin-top:5px;flex-shrink:0}}
|
|
107
|
+
.version-item.latest .version-dot{{background:#2a6a2a}}
|
|
108
|
+
.version-body{{flex:1;min-width:0}}
|
|
109
|
+
.version-top{{display:flex;align-items:center;gap:8px;margin-bottom:3px;flex-wrap:wrap}}
|
|
110
|
+
.vid{{font-family:'Courier New',monospace;font-size:12px;font-weight:700;background:#f0f0f0;padding:1px 6px;border-radius:4px;color:#444}}
|
|
111
|
+
.vmsg{{font-size:13px;color:#1a1a1a}}
|
|
112
|
+
.latest-badge{{font-size:10px;background:#e8f4e8;color:#2a6a2a;padding:2px 7px;border-radius:10px;font-weight:500}}
|
|
113
|
+
.version-meta{{font-size:11px;color:#999;margin-bottom:3px}}
|
|
114
|
+
.version-hash{{font-family:'Courier New',monospace;font-size:10px;color:#bbb}}
|
|
115
|
+
.tags{{margin-top:5px}}
|
|
116
|
+
.tag{{display:inline-block;background:#e8f0fe;color:#1a56a8;padding:2px 8px;border-radius:10px;font-size:11px;margin:2px 3px 2px 0;border:1px solid #c2d4f5}}
|
|
117
|
+
.empty{{padding:32px;text-align:center;color:#999;font-size:14px}}
|
|
118
|
+
.empty code{{background:#f0f0f0;padding:2px 6px;border-radius:4px;font-size:13px;color:#444}}
|
|
119
|
+
.commands{{background:#1a1a1a;border-radius:10px;padding:20px 24px;margin-top:32px}}
|
|
120
|
+
.commands-title{{color:#888;font-size:11px;text-transform:uppercase;letter-spacing:.5px;margin-bottom:12px}}
|
|
121
|
+
.cmd-grid{{display:grid;grid-template-columns:1fr 1fr;gap:6px}}
|
|
122
|
+
.cmd{{font-family:'Courier New',monospace;font-size:12px;color:#ccc;padding:4px 0}}
|
|
123
|
+
.cmd span{{color:#7dd3a8}}
|
|
124
|
+
.refresh-note{{text-align:center;color:#aaa;font-size:12px;margin-top:24px;margin-bottom:8px}}
|
|
125
|
+
a.refresh-btn{{display:inline-block;color:#555;text-decoration:none;border:1px solid #ddd;padding:5px 14px;border-radius:6px;font-size:12px;margin-left:8px}}
|
|
126
|
+
a.refresh-btn:hover{{background:#f0f0f0}}
|
|
127
|
+
</style>
|
|
128
|
+
</head>
|
|
129
|
+
<body>
|
|
130
|
+
<div class="topbar">
|
|
131
|
+
<div class="topbar-left"><span class="logo">DataVault</span><span class="proj">/ {proj_name}</span></div>
|
|
132
|
+
<div class="topbar-right">by {created_by}</div>
|
|
133
|
+
</div>
|
|
134
|
+
<div class="stats-bar">
|
|
135
|
+
<div class="stat"><span class="stat-val">{len(history["files"])}</span><span class="stat-lbl">Files tracked</span></div>
|
|
136
|
+
<div class="stat"><span class="stat-val">{total_versions}</span><span class="stat-lbl">Total versions</span></div>
|
|
137
|
+
<div class="stat"><span class="stat-val">{total_tags}</span><span class="stat-lbl">Tags</span></div>
|
|
138
|
+
<div class="stat"><span class="stat-val">SHA-256</span><span class="stat-lbl">Hash algorithm</span></div>
|
|
139
|
+
</div>
|
|
140
|
+
<div class="main">
|
|
141
|
+
<div class="section-title">Tracked files</div>
|
|
142
|
+
{file_cards}
|
|
143
|
+
<div class="commands">
|
|
144
|
+
<div class="commands-title">Quick reference</div>
|
|
145
|
+
<div class="cmd-grid">
|
|
146
|
+
<div class="cmd"><span>add</span> <file> "message"</div>
|
|
147
|
+
<div class="cmd"><span>commit</span> <file> "message"</div>
|
|
148
|
+
<div class="cmd"><span>verify</span> <file></div>
|
|
149
|
+
<div class="cmd"><span>log</span> <file></div>
|
|
150
|
+
<div class="cmd"><span>tag</span> <file> v2 "label"</div>
|
|
151
|
+
<div class="cmd"><span>diff</span> <file> v1 v2</div>
|
|
152
|
+
<div class="cmd"><span>checkout</span> <file> v1</div>
|
|
153
|
+
<div class="cmd"><span>export</span></div>
|
|
154
|
+
</div>
|
|
155
|
+
</div>
|
|
156
|
+
<p class="refresh-note">Data updates when you refresh. <a class="refresh-btn" href="/">↻ Refresh</a></p>
|
|
157
|
+
</div>
|
|
158
|
+
</body>
|
|
159
|
+
</html>"""
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _error_page(message):
|
|
163
|
+
return f'<!DOCTYPE html><html><body style="font-family:sans-serif;padding:40px;color:#666"><h2 style="color:#1a1a1a">DataVault Dashboard</h2><p style="margin-top:12px">{message}</p></body></html>'
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _hash_file(filepath):
|
|
167
|
+
sha256 = hashlib.sha256()
|
|
168
|
+
with open(filepath, "rb") as f:
|
|
169
|
+
while chunk := f.read(65536):
|
|
170
|
+
sha256.update(chunk)
|
|
171
|
+
return sha256.hexdigest()
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _fmt_size(b):
|
|
175
|
+
if b < 1024: return f"{b} B"
|
|
176
|
+
if b < 1024*1024: return f"{b/1024:.1f} KB"
|
|
177
|
+
return f"{b/1024/1024:.1f} MB"
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class DashboardHandler(http.server.BaseHTTPRequestHandler):
|
|
181
|
+
def do_GET(self):
|
|
182
|
+
html = build_dashboard_html().encode("utf-8")
|
|
183
|
+
self.send_response(200)
|
|
184
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
185
|
+
self.send_header("Content-Length", str(len(html)))
|
|
186
|
+
self.end_headers()
|
|
187
|
+
self.wfile.write(html)
|
|
188
|
+
|
|
189
|
+
def log_message(self, format, *args):
|
|
190
|
+
pass
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def main():
|
|
194
|
+
if not os.path.exists(VAULT_DIR):
|
|
195
|
+
print("No DataVault project found. Run: python datavault.py init <name>")
|
|
196
|
+
sys.exit(1)
|
|
197
|
+
|
|
198
|
+
server = http.server.HTTPServer(("localhost", PORT), DashboardHandler)
|
|
199
|
+
print(f"\nDataVault Dashboard → http://localhost:{PORT}")
|
|
200
|
+
print("Refresh browser after any commit. Ctrl+C to stop.\n")
|
|
201
|
+
|
|
202
|
+
threading.Thread(target=lambda: (__import__('time').sleep(0.5), __import__('webbrowser').open(f"http://localhost:{PORT}")), daemon=True).start()
|
|
203
|
+
|
|
204
|
+
try:
|
|
205
|
+
server.serve_forever()
|
|
206
|
+
except KeyboardInterrupt:
|
|
207
|
+
print("\nDashboard stopped.")
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
if __name__ == "__main__":
|
|
211
|
+
main()
|
datavault.py
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
import sys
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
if sys.stdout.encoding != 'utf-8':
|
|
6
|
+
sys.stdout.reconfigure(encoding='utf-8', errors='replace')
|
|
7
|
+
|
|
8
|
+
import vault_core as core
|
|
9
|
+
import row_tracker
|
|
10
|
+
import chain as chain_module
|
|
11
|
+
import signing
|
|
12
|
+
|
|
13
|
+
GREEN = "\033[92m"
|
|
14
|
+
RED = "\033[91m"
|
|
15
|
+
BLUE = "\033[94m"
|
|
16
|
+
BOLD = "\033[1m"
|
|
17
|
+
RESET = "\033[0m"
|
|
18
|
+
|
|
19
|
+
def ok(message): print(f"{GREEN}✓{RESET} {message}")
|
|
20
|
+
def err(message): print(f"{RED}✗{RESET} {message}")
|
|
21
|
+
def info(message):print(f"{BLUE}ℹ{RESET} {message}")
|
|
22
|
+
|
|
23
|
+
HELP = f"""
|
|
24
|
+
{BOLD}DataVault{RESET} — version control for datasets
|
|
25
|
+
|
|
26
|
+
{BOLD}COMMANDS:{RESET}
|
|
27
|
+
{GREEN}init{RESET} <name> Start a new project
|
|
28
|
+
{GREEN}add{RESET} <file> "<message>" Start tracking a file
|
|
29
|
+
{GREEN}commit{RESET} <file> "<message>" Save a new version
|
|
30
|
+
{GREEN}log{RESET} <file> Show version history
|
|
31
|
+
{GREEN}status{RESET} Show all tracked files
|
|
32
|
+
{GREEN}verify{RESET} <file> Check for tampering
|
|
33
|
+
{GREEN}checkout{RESET} <file> <v> Restore a version
|
|
34
|
+
{GREEN}diff{RESET} <file> <v1> <v2> Show text diff
|
|
35
|
+
{GREEN}tag{RESET} <file> <v> "<msg>" Label a version
|
|
36
|
+
{GREEN}tags{RESET} <file> List all tags
|
|
37
|
+
{GREEN}export{RESET} Export HTML/PDF report
|
|
38
|
+
{GREEN}dashboard{RESET} Open web dashboard
|
|
39
|
+
|
|
40
|
+
{BOLD}ROW-LEVEL (CSV):{RESET}
|
|
41
|
+
{GREEN}rowlog{RESET} <file> Row change statistics
|
|
42
|
+
{GREEN}rowdiff{RESET} <file> <v1> <v2> Which rows changed
|
|
43
|
+
{GREEN}rowhistory{RESET} <file> <col> <val> History of one row
|
|
44
|
+
|
|
45
|
+
{BOLD}HASH CHAIN:{RESET}
|
|
46
|
+
{GREEN}chain{RESET} <file> Verify chain
|
|
47
|
+
{GREEN}chainshow{RESET} <file> Display chain visually
|
|
48
|
+
{GREEN}chainbuild{RESET} <file> Build/rebuild chain
|
|
49
|
+
|
|
50
|
+
{BOLD}SIGNATURES:{RESET}
|
|
51
|
+
{GREEN}sign{RESET} <file> <v> <name> [role] Sign a version
|
|
52
|
+
{GREEN}signatures{RESET} <file> [version] List or verify signatures
|
|
53
|
+
{GREEN}approve{RESET} <file> <version> Check approval status
|
|
54
|
+
{GREEN}help{RESET} Show this message
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
def main():
|
|
58
|
+
args = sys.argv[1:]
|
|
59
|
+
if not args or args[0] == "help":
|
|
60
|
+
print(HELP)
|
|
61
|
+
return
|
|
62
|
+
|
|
63
|
+
command = args[0].lower()
|
|
64
|
+
|
|
65
|
+
if command == "init":
|
|
66
|
+
if len(args) < 2:
|
|
67
|
+
err("Usage: python datavault.py init <project_name>"); return
|
|
68
|
+
success, message = core.init_project(args[1])
|
|
69
|
+
ok(message) if success else err(message)
|
|
70
|
+
|
|
71
|
+
elif command == "add":
|
|
72
|
+
if len(args) < 3:
|
|
73
|
+
err('Usage: python datavault.py add <file> "<message>"'); return
|
|
74
|
+
filepath, message = args[1], " ".join(args[2:])
|
|
75
|
+
success, result = core.add_file(filepath, message)
|
|
76
|
+
ok(result) if success else err(result)
|
|
77
|
+
if success:
|
|
78
|
+
if row_tracker.snapshot_on_commit(filepath, "v1"):
|
|
79
|
+
info("Row-level snapshot created")
|
|
80
|
+
chain_module.build_chain(os.path.basename(filepath))
|
|
81
|
+
info("Hash chain updated")
|
|
82
|
+
|
|
83
|
+
elif command == "commit":
|
|
84
|
+
if len(args) < 3:
|
|
85
|
+
err('Usage: python datavault.py commit <file> "<message>"'); return
|
|
86
|
+
filepath, message = args[1], " ".join(args[2:])
|
|
87
|
+
success, result = core.commit_file(filepath, message)
|
|
88
|
+
ok(result) if success else err(result)
|
|
89
|
+
if success:
|
|
90
|
+
import json
|
|
91
|
+
with open(core.HISTORY_FILE) as f:
|
|
92
|
+
hist = json.load(f)
|
|
93
|
+
fname = os.path.basename(filepath)
|
|
94
|
+
new_vid = hist["files"][fname]["versions"][-1]["version_id"]
|
|
95
|
+
if row_tracker.snapshot_on_commit(filepath, new_vid):
|
|
96
|
+
info("Row-level snapshot created")
|
|
97
|
+
chain_module.build_chain(fname)
|
|
98
|
+
info("Hash chain updated")
|
|
99
|
+
|
|
100
|
+
elif command == "log":
|
|
101
|
+
if len(args) < 2:
|
|
102
|
+
err("Usage: python datavault.py log <file>"); return
|
|
103
|
+
success, result = core.get_log(args[1])
|
|
104
|
+
print(result) if success else err(result)
|
|
105
|
+
|
|
106
|
+
elif command == "status":
|
|
107
|
+
success, result = core.list_files()
|
|
108
|
+
print(result) if success else err(result)
|
|
109
|
+
|
|
110
|
+
elif command == "verify":
|
|
111
|
+
if len(args) < 2:
|
|
112
|
+
err("Usage: python datavault.py verify <file>"); return
|
|
113
|
+
success, result = core.verify_file(args[1])
|
|
114
|
+
print(result) if success else err(result)
|
|
115
|
+
|
|
116
|
+
elif command == "checkout":
|
|
117
|
+
if len(args) < 3:
|
|
118
|
+
err("Usage: python datavault.py checkout <file> <version>"); return
|
|
119
|
+
success, result = core.checkout_version(args[1], args[2])
|
|
120
|
+
ok(result) if success else err(result)
|
|
121
|
+
|
|
122
|
+
elif command == "diff":
|
|
123
|
+
if len(args) < 4:
|
|
124
|
+
err("Usage: python datavault.py diff <file> <v1> <v2>"); return
|
|
125
|
+
success, result = core.diff_versions(args[1], args[2], args[3])
|
|
126
|
+
print(result) if success else err(result)
|
|
127
|
+
|
|
128
|
+
elif command == "rowlog":
|
|
129
|
+
if len(args) < 2:
|
|
130
|
+
err("Usage: python datavault.py rowlog <file>"); return
|
|
131
|
+
success, result = row_tracker.row_stats(args[1])
|
|
132
|
+
print(result) if success else err(result)
|
|
133
|
+
|
|
134
|
+
elif command == "rowdiff":
|
|
135
|
+
if len(args) < 4:
|
|
136
|
+
err("Usage: python datavault.py rowdiff <file> <v1> <v2>"); return
|
|
137
|
+
success, result = row_tracker.row_diff(args[1], args[2], args[3])
|
|
138
|
+
print(result) if success else err(result)
|
|
139
|
+
|
|
140
|
+
elif command == "rowhistory":
|
|
141
|
+
if len(args) < 4:
|
|
142
|
+
err("Usage: python datavault.py rowhistory <file> <column> <value>"); return
|
|
143
|
+
success, result = row_tracker.row_history(args[1], args[2], args[3])
|
|
144
|
+
print(result) if success else err(result)
|
|
145
|
+
|
|
146
|
+
elif command == "chain":
|
|
147
|
+
if len(args) < 2:
|
|
148
|
+
err("Usage: python datavault.py chain <file>"); return
|
|
149
|
+
filename = os.path.basename(args[1])
|
|
150
|
+
valid, results = chain_module.verify_chain(filename)
|
|
151
|
+
print(chain_module.format_chain_verification(filename, results)) if isinstance(results, list) else err(results)
|
|
152
|
+
|
|
153
|
+
elif command == "chainshow":
|
|
154
|
+
if len(args) < 2:
|
|
155
|
+
err("Usage: python datavault.py chainshow <file>"); return
|
|
156
|
+
success, result = chain_module.display_chain(os.path.basename(args[1]))
|
|
157
|
+
print(result) if success else err(result)
|
|
158
|
+
|
|
159
|
+
elif command == "chainbuild":
|
|
160
|
+
if len(args) < 2:
|
|
161
|
+
err("Usage: python datavault.py chainbuild <file>"); return
|
|
162
|
+
filename = os.path.basename(args[1])
|
|
163
|
+
success, result = chain_module.build_chain(filename)
|
|
164
|
+
ok(f"Chain built for '{filename}' ({len(result)} version(s))") if success else err(result)
|
|
165
|
+
|
|
166
|
+
elif command == "sign":
|
|
167
|
+
if len(args) < 4:
|
|
168
|
+
err("Usage: python datavault.py sign <file> <version> <signer> [role]"); return
|
|
169
|
+
role = args[4] if len(args) > 4 else "reviewer"
|
|
170
|
+
success, result = signing.sign_version(args[1], args[2], args[3], role)
|
|
171
|
+
ok(result) if success else err(result)
|
|
172
|
+
|
|
173
|
+
elif command == "signatures":
|
|
174
|
+
if len(args) < 2:
|
|
175
|
+
err("Usage: python datavault.py signatures <file> [version]"); return
|
|
176
|
+
if len(args) >= 3:
|
|
177
|
+
success, result = signing.verify_signatures(args[1], args[2])
|
|
178
|
+
else:
|
|
179
|
+
success, result = signing.list_all_signatures(args[1])
|
|
180
|
+
print(result) if success else err(result)
|
|
181
|
+
|
|
182
|
+
elif command == "approve":
|
|
183
|
+
if len(args) < 3:
|
|
184
|
+
err("Usage: python datavault.py approve <file> <version>"); return
|
|
185
|
+
_, result = signing.approval_status(args[1], args[2])
|
|
186
|
+
print(result)
|
|
187
|
+
|
|
188
|
+
elif command == "tag":
|
|
189
|
+
if len(args) < 4:
|
|
190
|
+
err('Usage: python datavault.py tag <file> <version> "<message>"'); return
|
|
191
|
+
success, result = core.tag_version(args[1], args[2], " ".join(args[3:]))
|
|
192
|
+
ok(result) if success else err(result)
|
|
193
|
+
|
|
194
|
+
elif command == "tags":
|
|
195
|
+
if len(args) < 2:
|
|
196
|
+
err("Usage: python datavault.py tags <file>"); return
|
|
197
|
+
success, result = core.list_tags(args[1])
|
|
198
|
+
print(result) if success else err(result)
|
|
199
|
+
|
|
200
|
+
elif command == "export":
|
|
201
|
+
output = args[1] if len(args) > 1 else "datavault_report.pdf"
|
|
202
|
+
success, result = core.export_pdf(output)
|
|
203
|
+
ok(result) if success else err(result)
|
|
204
|
+
|
|
205
|
+
elif command == "dashboard":
|
|
206
|
+
import dashboard as db_module
|
|
207
|
+
db_module.main()
|
|
208
|
+
|
|
209
|
+
else:
|
|
210
|
+
err(f"Unknown command: '{command}'")
|
|
211
|
+
info("Run 'python datavault.py help' to see all commands")
|
|
212
|
+
|
|
213
|
+
if __name__ == "__main__":
|
|
214
|
+
main()
|