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 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 &lt;file&gt; "&lt;message&gt;"</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> &lt;file&gt; "message"</div>
147
+ <div class="cmd"><span>commit</span> &lt;file&gt; "message"</div>
148
+ <div class="cmd"><span>verify</span> &lt;file&gt;</div>
149
+ <div class="cmd"><span>log</span> &lt;file&gt;</div>
150
+ <div class="cmd"><span>tag</span> &lt;file&gt; v2 "label"</div>
151
+ <div class="cmd"><span>diff</span> &lt;file&gt; v1 v2</div>
152
+ <div class="cmd"><span>checkout</span> &lt;file&gt; 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()