claude-nb 0.3.0
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.
- package/LICENSE +38 -0
- package/Makefile +60 -0
- package/README.md +63 -0
- package/VERSION +1 -0
- package/bin/_pip_entry.py +25 -0
- package/bin/board +287 -0
- package/bin/cnb +150 -0
- package/bin/cnb.js +33 -0
- package/bin/dispatcher +151 -0
- package/bin/dispatcher-watchdog +57 -0
- package/bin/doctor +328 -0
- package/bin/init +316 -0
- package/bin/registry +347 -0
- package/bin/swarm +896 -0
- package/lib/__init__.py +1 -0
- package/lib/board_admin.py +128 -0
- package/lib/board_bbs.py +99 -0
- package/lib/board_bug.py +161 -0
- package/lib/board_db.py +262 -0
- package/lib/board_lock.py +113 -0
- package/lib/board_mailbox.py +145 -0
- package/lib/board_maintenance.py +237 -0
- package/lib/board_msg.py +230 -0
- package/lib/board_task.py +200 -0
- package/lib/board_view.py +366 -0
- package/lib/board_vote.py +164 -0
- package/lib/build_lock.py +221 -0
- package/lib/cli.py +34 -0
- package/lib/common.py +285 -0
- package/lib/concerns/__init__.py +42 -0
- package/lib/concerns/adaptive_throttle.py +26 -0
- package/lib/concerns/base.py +25 -0
- package/lib/concerns/bug_sla_checker.py +32 -0
- package/lib/concerns/config.py +22 -0
- package/lib/concerns/coral_manager.py +61 -0
- package/lib/concerns/coral_poker.py +57 -0
- package/lib/concerns/file_watcher.py +127 -0
- package/lib/concerns/health_checker.py +72 -0
- package/lib/concerns/helpers.py +152 -0
- package/lib/concerns/idle_detector.py +56 -0
- package/lib/concerns/idle_killer.py +41 -0
- package/lib/concerns/idle_nudger.py +38 -0
- package/lib/concerns/inbox_nudger.py +34 -0
- package/lib/concerns/resource_monitor.py +47 -0
- package/lib/concerns/session_keepalive.py +23 -0
- package/lib/concerns/time_announcer.py +34 -0
- package/lib/crypto.py +92 -0
- package/lib/health.py +187 -0
- package/lib/inject.py +164 -0
- package/lib/migrate.py +109 -0
- package/lib/monitor.py +373 -0
- package/lib/panel.py +137 -0
- package/lib/resources.py +341 -0
- package/migrations/001_foreign_keys.sql +77 -0
- package/migrations/002_session_persona.sql +1 -0
- package/migrations/003_mailbox.sql +9 -0
- package/package.json +28 -0
- package/pyproject.toml +71 -0
- package/registry/0001-meridian.json +12 -0
- package/registry/0002-forge.json +12 -0
- package/registry/0003-lead.json +12 -0
- package/registry/0004-ms-encrypted-mailbox-live.json +12 -0
- package/registry/GENESIS.json +9 -0
- package/registry/pubkeys.json +5 -0
- package/schema.sql +138 -0
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""board_lock — git lock coordination: git-lock / git-unlock / git-lock-status."""
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
import time
|
|
5
|
+
|
|
6
|
+
from lib.board_db import BoardDB, ts
|
|
7
|
+
|
|
8
|
+
GIT_LOCK_TTL = 60
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _cleanup_stale(db: BoardDB) -> None:
|
|
12
|
+
now_epoch = int(time.time())
|
|
13
|
+
db.execute("DELETE FROM git_locks WHERE expires_at < ?", (now_epoch,))
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def cmd_git_lock(db: BoardDB, identity: str, args: list[str]) -> None:
|
|
17
|
+
name = identity.lower()
|
|
18
|
+
reason = " ".join(args) if args else "git operation"
|
|
19
|
+
|
|
20
|
+
_cleanup_stale(db)
|
|
21
|
+
|
|
22
|
+
expires = int(time.time()) + GIT_LOCK_TTL
|
|
23
|
+
did_insert = db.execute_changes(
|
|
24
|
+
"INSERT OR IGNORE INTO git_locks(id, session, reason, expires_at) VALUES (1, ?, ?, ?)",
|
|
25
|
+
(name, reason, expires),
|
|
26
|
+
)
|
|
27
|
+
if did_insert:
|
|
28
|
+
print(f"OK git-lock acquired by {name} (expires in {GIT_LOCK_TTL}s)")
|
|
29
|
+
return
|
|
30
|
+
|
|
31
|
+
holder = db.scalar("SELECT session FROM git_locks WHERE id=1")
|
|
32
|
+
if holder == name:
|
|
33
|
+
new_expires = int(time.time()) + GIT_LOCK_TTL
|
|
34
|
+
db.execute(
|
|
35
|
+
"UPDATE git_locks SET expires_at=?, reason=?, "
|
|
36
|
+
"acquired_at=strftime('%Y-%m-%d %H:%M:%S', 'now', 'localtime') WHERE id=1",
|
|
37
|
+
(new_expires, reason),
|
|
38
|
+
)
|
|
39
|
+
print(f"OK git-lock extended (held by you, expires in {GIT_LOCK_TTL}s)")
|
|
40
|
+
return
|
|
41
|
+
|
|
42
|
+
expires_at = db.scalar("SELECT expires_at FROM git_locks WHERE id=1") or 0
|
|
43
|
+
remaining = expires_at - int(time.time())
|
|
44
|
+
lock_reason = db.scalar("SELECT reason FROM git_locks WHERE id=1") or ""
|
|
45
|
+
print(
|
|
46
|
+
f"BLOCKED: git lock held by '{holder}' ({lock_reason}, expires in {remaining}s)",
|
|
47
|
+
)
|
|
48
|
+
print(f" Wait and retry, or force with: ./board --as {identity} git-unlock --force")
|
|
49
|
+
raise SystemExit(1)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def cmd_git_unlock(db: BoardDB, identity: str, args: list[str]) -> None:
|
|
53
|
+
name = identity.lower()
|
|
54
|
+
force = "--force" in args
|
|
55
|
+
|
|
56
|
+
_cleanup_stale(db)
|
|
57
|
+
|
|
58
|
+
holder = db.scalar("SELECT session FROM git_locks WHERE id=1")
|
|
59
|
+
if not holder:
|
|
60
|
+
print("OK git lock is already free")
|
|
61
|
+
return
|
|
62
|
+
|
|
63
|
+
if holder != name and not force:
|
|
64
|
+
print(f"ERROR: git lock held by '{holder}', not you. Use --force to override.")
|
|
65
|
+
raise SystemExit(1)
|
|
66
|
+
|
|
67
|
+
if holder != name and force:
|
|
68
|
+
print(f"WARN: force-releasing git lock held by '{holder}'")
|
|
69
|
+
now = ts()
|
|
70
|
+
msg_id = db.execute(
|
|
71
|
+
"INSERT INTO messages(ts, sender, recipient, body) VALUES (?, 'SYSTEM', ?, ?)",
|
|
72
|
+
(now, holder, f"[GIT-LOCK] {name} force-released your git lock"),
|
|
73
|
+
)
|
|
74
|
+
db.execute("INSERT INTO inbox(session, message_id) VALUES (?, ?)", (holder, msg_id))
|
|
75
|
+
db.sync_inbox_to_file(holder)
|
|
76
|
+
|
|
77
|
+
db.execute("DELETE FROM git_locks WHERE id=1")
|
|
78
|
+
print("OK git-lock released")
|
|
79
|
+
|
|
80
|
+
index_lock = db.env.project_root / ".git" / "index.lock"
|
|
81
|
+
if index_lock.exists():
|
|
82
|
+
r = subprocess.run(
|
|
83
|
+
["pgrep", "-f", f"git.*{db.env.project_root}"],
|
|
84
|
+
capture_output=True,
|
|
85
|
+
)
|
|
86
|
+
if r.returncode != 0:
|
|
87
|
+
index_lock.unlink()
|
|
88
|
+
print(" Also removed stale .git/index.lock")
|
|
89
|
+
else:
|
|
90
|
+
print(" WARN: .git/index.lock exists and a git process is running")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def cmd_git_lock_status(db: BoardDB) -> None:
|
|
94
|
+
_cleanup_stale(db)
|
|
95
|
+
|
|
96
|
+
row = db.query_one("SELECT session, reason, acquired_at, expires_at FROM git_locks WHERE id=1")
|
|
97
|
+
if not row:
|
|
98
|
+
print("FREE: no session holds the git lock")
|
|
99
|
+
else:
|
|
100
|
+
holder, reason, acquired, expires_at = row
|
|
101
|
+
remaining = expires_at - int(time.time())
|
|
102
|
+
print(f"LOCKED by {holder}")
|
|
103
|
+
print(f" Reason: {reason}")
|
|
104
|
+
print(f" Acquired: {acquired}")
|
|
105
|
+
print(f" Expires in: {remaining}s")
|
|
106
|
+
|
|
107
|
+
index_lock = db.env.project_root / ".git" / "index.lock"
|
|
108
|
+
if index_lock.exists():
|
|
109
|
+
try:
|
|
110
|
+
lock_age = int(time.time()) - int(index_lock.stat().st_mtime)
|
|
111
|
+
except OSError:
|
|
112
|
+
lock_age = 0
|
|
113
|
+
print(f" .git/index.lock exists (age: {lock_age}s)")
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""board_mailbox — encrypted async messaging between registered agents."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from lib.board_db import BoardDB, ts
|
|
7
|
+
from lib.crypto import (
|
|
8
|
+
generate_keypair,
|
|
9
|
+
load_private_key,
|
|
10
|
+
public_key_from_hex,
|
|
11
|
+
public_key_to_hex,
|
|
12
|
+
save_keypair,
|
|
13
|
+
seal_b64,
|
|
14
|
+
unseal_b64,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
REGISTRY_DIR = Path(__file__).resolve().parent.parent / "registry"
|
|
18
|
+
PUBKEYS_FILE = REGISTRY_DIR / "pubkeys.json"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _keys_dir(db: BoardDB) -> Path:
|
|
22
|
+
return db.env.claudes_dir / "keys"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _load_pubkeys() -> dict[str, str]:
|
|
26
|
+
if PUBKEYS_FILE.exists():
|
|
27
|
+
return json.loads(PUBKEYS_FILE.read_text())
|
|
28
|
+
return {}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _save_pubkeys(data: dict[str, str]) -> None:
|
|
32
|
+
PUBKEYS_FILE.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _find_pubkey(name: str) -> str | None:
|
|
36
|
+
"""Look up public_key from pubkeys.json (separate from immutable chain blocks)."""
|
|
37
|
+
return _load_pubkeys().get(name)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def cmd_keygen(db: BoardDB, identity: str) -> None:
|
|
41
|
+
name = identity.lower()
|
|
42
|
+
kd = _keys_dir(db)
|
|
43
|
+
|
|
44
|
+
if (kd / f"{name}.pem").exists():
|
|
45
|
+
print(f"ERROR: 密钥已存在 ({kd / f'{name}.pem'}),如需重新生成请先删除旧密钥")
|
|
46
|
+
raise SystemExit(1)
|
|
47
|
+
|
|
48
|
+
private, public = generate_keypair()
|
|
49
|
+
save_keypair(kd, name, private, public)
|
|
50
|
+
pubkey_hex = public_key_to_hex(public)
|
|
51
|
+
|
|
52
|
+
pubkeys = _load_pubkeys()
|
|
53
|
+
pubkeys[name] = pubkey_hex
|
|
54
|
+
_save_pubkeys(pubkeys)
|
|
55
|
+
|
|
56
|
+
print("OK 密钥已生成")
|
|
57
|
+
print(f" 私钥: {kd / f'{name}.pem'} (勿泄露)")
|
|
58
|
+
print(f" 公钥: {pubkey_hex[:16]}...")
|
|
59
|
+
print(f" 已写入 {PUBKEYS_FILE.relative_to(REGISTRY_DIR.parent)}")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def cmd_seal(db: BoardDB, identity: str, args: list[str]) -> None:
|
|
63
|
+
name = identity.lower()
|
|
64
|
+
if len(args) < 2:
|
|
65
|
+
print("Usage: ./board --as <name> seal <recipient> <message>")
|
|
66
|
+
raise SystemExit(1)
|
|
67
|
+
|
|
68
|
+
recipient = args[0].lower()
|
|
69
|
+
plaintext = " ".join(args[1:])
|
|
70
|
+
|
|
71
|
+
recipient_pubkey_hex = _find_pubkey(recipient)
|
|
72
|
+
if not recipient_pubkey_hex:
|
|
73
|
+
print(f"ERROR: {recipient} 未注册公钥 (需先运行 keygen)")
|
|
74
|
+
raise SystemExit(1)
|
|
75
|
+
|
|
76
|
+
recipient_pub = public_key_from_hex(recipient_pubkey_hex)
|
|
77
|
+
encrypted = seal_b64(plaintext, recipient_pub)
|
|
78
|
+
|
|
79
|
+
now = ts()
|
|
80
|
+
db.execute(
|
|
81
|
+
"INSERT INTO mailbox(ts, sender, recipient, encrypted_body) VALUES (?, ?, ?, ?)",
|
|
82
|
+
(now, name, recipient, encrypted),
|
|
83
|
+
)
|
|
84
|
+
print(f"OK 加密消息已发送给 {recipient} ({len(encrypted)} bytes)")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def cmd_unseal(db: BoardDB, identity: str) -> None:
|
|
88
|
+
name = identity.lower()
|
|
89
|
+
kd = _keys_dir(db)
|
|
90
|
+
|
|
91
|
+
try:
|
|
92
|
+
private = load_private_key(kd, name)
|
|
93
|
+
except FileNotFoundError as e:
|
|
94
|
+
print(f"ERROR: {e}")
|
|
95
|
+
raise SystemExit(1)
|
|
96
|
+
|
|
97
|
+
rows = db.query(
|
|
98
|
+
"SELECT id, ts, sender, encrypted_body FROM mailbox WHERE recipient=? AND read=0 ORDER BY ts",
|
|
99
|
+
(name,),
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
if not rows:
|
|
103
|
+
print("加密信箱为空")
|
|
104
|
+
return
|
|
105
|
+
|
|
106
|
+
print(f"你有 {len(rows)} 条加密消息:\n")
|
|
107
|
+
decrypted_ids = []
|
|
108
|
+
for msg_id, msg_ts, sender, encrypted_body in rows:
|
|
109
|
+
try:
|
|
110
|
+
plaintext = unseal_b64(encrypted_body, private)
|
|
111
|
+
print(f" [{msg_ts}] **{sender}**: {plaintext}")
|
|
112
|
+
decrypted_ids.append(msg_id)
|
|
113
|
+
except Exception:
|
|
114
|
+
print(f" [{msg_ts}] **{sender}**: [解密失败 — 密钥不匹配或消息损坏]")
|
|
115
|
+
|
|
116
|
+
if decrypted_ids:
|
|
117
|
+
placeholders = ",".join("?" * len(decrypted_ids))
|
|
118
|
+
db.execute(f"UPDATE mailbox SET read=1 WHERE id IN ({placeholders})", tuple(decrypted_ids))
|
|
119
|
+
print(f"\n已标记 {len(decrypted_ids)} 条为已读")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def cmd_mailbox_log(db: BoardDB, identity: str) -> None:
|
|
123
|
+
name = identity.lower()
|
|
124
|
+
kd = _keys_dir(db)
|
|
125
|
+
|
|
126
|
+
try:
|
|
127
|
+
private = load_private_key(kd, name)
|
|
128
|
+
except FileNotFoundError as e:
|
|
129
|
+
print(f"ERROR: {e}")
|
|
130
|
+
raise SystemExit(1)
|
|
131
|
+
|
|
132
|
+
rows = db.query(
|
|
133
|
+
"SELECT ts, sender, encrypted_body FROM mailbox WHERE recipient=? ORDER BY ts DESC LIMIT 20",
|
|
134
|
+
(name,),
|
|
135
|
+
)
|
|
136
|
+
if not rows:
|
|
137
|
+
print("无加密消息记录")
|
|
138
|
+
return
|
|
139
|
+
|
|
140
|
+
for msg_ts, sender, encrypted_body in reversed(rows):
|
|
141
|
+
try:
|
|
142
|
+
plaintext = unseal_b64(encrypted_body, private)
|
|
143
|
+
print(f" [{msg_ts}] {sender}: {plaintext}")
|
|
144
|
+
except Exception:
|
|
145
|
+
print(f" [{msg_ts}] {sender}: [无法解密]")
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""board_maintenance — data maintenance: prune, backup, restore."""
|
|
2
|
+
|
|
3
|
+
import shutil
|
|
4
|
+
import time
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from lib.board_db import BoardDB
|
|
8
|
+
|
|
9
|
+
# ---------------------------------------------------------------------------
|
|
10
|
+
# prune
|
|
11
|
+
# ---------------------------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def cmd_prune(db: BoardDB, args: list[str]) -> None:
|
|
15
|
+
"""Prune old messages and inbox entries.
|
|
16
|
+
|
|
17
|
+
Usage: board --as <name> prune [--before YYYY-MM-DD] [--keep N] [--dry-run]
|
|
18
|
+
"""
|
|
19
|
+
usage = "Usage: board --as <name> prune [--before YYYY-MM-DD] [--keep N] [--dry-run]"
|
|
20
|
+
|
|
21
|
+
before_days = 90 # default: keep 90 days
|
|
22
|
+
dry_run = False
|
|
23
|
+
positional: list[str] = []
|
|
24
|
+
|
|
25
|
+
i = 0
|
|
26
|
+
while i < len(args):
|
|
27
|
+
if args[i] == "--before" and i + 1 < len(args):
|
|
28
|
+
before_days = _parse_days(args[i + 1])
|
|
29
|
+
i += 2
|
|
30
|
+
elif args[i] == "--keep" and i + 1 < len(args):
|
|
31
|
+
try:
|
|
32
|
+
int(args[i + 1])
|
|
33
|
+
except ValueError:
|
|
34
|
+
print(f"ERROR: --keep requires a number, got '{args[i + 1]}'")
|
|
35
|
+
raise SystemExit(1)
|
|
36
|
+
i += 2
|
|
37
|
+
elif args[i] == "--dry-run":
|
|
38
|
+
dry_run = True
|
|
39
|
+
i += 1
|
|
40
|
+
else:
|
|
41
|
+
positional.append(args[i])
|
|
42
|
+
i += 1
|
|
43
|
+
|
|
44
|
+
if positional:
|
|
45
|
+
print(usage)
|
|
46
|
+
raise SystemExit(1)
|
|
47
|
+
|
|
48
|
+
cutoff = _days_ago_ts(before_days)
|
|
49
|
+
|
|
50
|
+
# Count what would be deleted
|
|
51
|
+
old_inbox = db.scalar(
|
|
52
|
+
"SELECT COUNT(*) FROM inbox WHERE message_id IN "
|
|
53
|
+
"(SELECT id FROM messages WHERE ts < ?)",
|
|
54
|
+
(cutoff,),
|
|
55
|
+
) or 0
|
|
56
|
+
old_messages = db.scalar(
|
|
57
|
+
"SELECT COUNT(*) FROM messages WHERE ts < ?",
|
|
58
|
+
(cutoff,),
|
|
59
|
+
) or 0
|
|
60
|
+
|
|
61
|
+
# Also count old read inbox entries (not just those linked to old messages)
|
|
62
|
+
old_read_inbox = db.scalar(
|
|
63
|
+
"SELECT COUNT(*) FROM inbox WHERE read=1 AND "
|
|
64
|
+
"delivered_at < ?",
|
|
65
|
+
(cutoff,),
|
|
66
|
+
) or 0
|
|
67
|
+
|
|
68
|
+
if dry_run:
|
|
69
|
+
print("=== DRY RUN: would delete ===")
|
|
70
|
+
print(f" {old_messages} messages older than {before_days} days")
|
|
71
|
+
print(f" {old_inbox} inbox entries referencing old messages")
|
|
72
|
+
print(f" {old_read_inbox} already-read inbox entries")
|
|
73
|
+
total = old_messages + old_inbox + old_read_inbox
|
|
74
|
+
if total == 0:
|
|
75
|
+
print(" Nothing to prune.")
|
|
76
|
+
else:
|
|
77
|
+
print(f" Total: {total} rows")
|
|
78
|
+
return
|
|
79
|
+
|
|
80
|
+
total_deleted = 0
|
|
81
|
+
|
|
82
|
+
# Delete old messages (cascades to inbox via FK)
|
|
83
|
+
if old_messages > 0:
|
|
84
|
+
db.execute("DELETE FROM messages WHERE ts < ?", (cutoff,))
|
|
85
|
+
total_deleted += old_messages
|
|
86
|
+
|
|
87
|
+
# Delete old read inbox entries (not covered by message FK cascade)
|
|
88
|
+
if old_read_inbox > 0:
|
|
89
|
+
db.execute("DELETE FROM inbox WHERE read=1 AND delivered_at < ?", (cutoff,))
|
|
90
|
+
total_deleted += old_read_inbox
|
|
91
|
+
|
|
92
|
+
if total_deleted == 0:
|
|
93
|
+
print("OK nothing to prune")
|
|
94
|
+
else:
|
|
95
|
+
print(f"OK pruned {total_deleted} rows (messages older than {before_days} days)")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _parse_days(arg: str) -> int:
|
|
99
|
+
"""Parse a date or day-count argument.
|
|
100
|
+
|
|
101
|
+
Accepts: 'YYYY-MM-DD' (exact date), or integer (days ago).
|
|
102
|
+
"""
|
|
103
|
+
from datetime import datetime
|
|
104
|
+
|
|
105
|
+
try:
|
|
106
|
+
target = datetime.strptime(arg, "%Y-%m-%d")
|
|
107
|
+
now = datetime.now()
|
|
108
|
+
return (now - target).days
|
|
109
|
+
except ValueError:
|
|
110
|
+
pass
|
|
111
|
+
try:
|
|
112
|
+
return int(arg)
|
|
113
|
+
except ValueError:
|
|
114
|
+
print(f"ERROR: invalid date/days: '{arg}'. Use YYYY-MM-DD or a number.")
|
|
115
|
+
raise SystemExit(1)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _days_ago_ts(days: int) -> str:
|
|
119
|
+
"""Return a timestamp string for *days* ago."""
|
|
120
|
+
from datetime import datetime, timedelta
|
|
121
|
+
|
|
122
|
+
return (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d %H:%M:%S")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# ---------------------------------------------------------------------------
|
|
126
|
+
# backup
|
|
127
|
+
# ---------------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def cmd_backup(db: BoardDB, args: list[str]) -> None:
|
|
131
|
+
"""Backup board.db to a timestamped file.
|
|
132
|
+
|
|
133
|
+
Usage: board backup [--output <path>]
|
|
134
|
+
"""
|
|
135
|
+
import shutil as _shutil
|
|
136
|
+
|
|
137
|
+
output: Path | None = None
|
|
138
|
+
for arg in args:
|
|
139
|
+
if arg.startswith("--output="):
|
|
140
|
+
output = Path(arg.split("=", 1)[1])
|
|
141
|
+
elif arg == "--output" and len(args) > args.index(arg) + 1:
|
|
142
|
+
output = Path(args[args.index(arg) + 1])
|
|
143
|
+
elif arg == "--help":
|
|
144
|
+
print("Usage: board backup [--output <path>]")
|
|
145
|
+
return
|
|
146
|
+
|
|
147
|
+
if output is None:
|
|
148
|
+
stamp = time.strftime("%Y%m%d-%H%M%S")
|
|
149
|
+
output = db.db_path.parent / f"backup-{stamp}.db"
|
|
150
|
+
|
|
151
|
+
_shutil.copy2(db.db_path, output)
|
|
152
|
+
|
|
153
|
+
# Verify the backup
|
|
154
|
+
import sqlite3
|
|
155
|
+
|
|
156
|
+
try:
|
|
157
|
+
conn = sqlite3.connect(str(output))
|
|
158
|
+
conn.execute("SELECT COUNT(*) FROM meta")
|
|
159
|
+
conn.close()
|
|
160
|
+
except sqlite3.Error as e:
|
|
161
|
+
print(f"ERROR: backup verification failed: {e}")
|
|
162
|
+
if output.exists():
|
|
163
|
+
output.unlink()
|
|
164
|
+
raise SystemExit(1)
|
|
165
|
+
|
|
166
|
+
size = output.stat().st_size
|
|
167
|
+
print(f"OK backup saved: {output}")
|
|
168
|
+
print(f" Size: {size} bytes")
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
# ---------------------------------------------------------------------------
|
|
172
|
+
# restore
|
|
173
|
+
# ---------------------------------------------------------------------------
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def cmd_restore(db: BoardDB, args: list[str]) -> None:
|
|
177
|
+
"""Restore board.db from a backup file.
|
|
178
|
+
|
|
179
|
+
Usage: board restore <backup-file> [--force]
|
|
180
|
+
"""
|
|
181
|
+
force = False
|
|
182
|
+
source: Path | None = None
|
|
183
|
+
for arg in args:
|
|
184
|
+
if arg in ("--force", "-f"):
|
|
185
|
+
force = True
|
|
186
|
+
elif arg in ("--help", "-h"):
|
|
187
|
+
print("Usage: board restore <backup-file> [--force]")
|
|
188
|
+
print()
|
|
189
|
+
print(" --force Skip confirmation prompt")
|
|
190
|
+
return
|
|
191
|
+
else:
|
|
192
|
+
source = Path(arg)
|
|
193
|
+
|
|
194
|
+
if source is None:
|
|
195
|
+
print("Usage: board restore <backup-file> [--force]")
|
|
196
|
+
raise SystemExit(1)
|
|
197
|
+
if not source.exists():
|
|
198
|
+
print(f"ERROR: backup file not found: {source}")
|
|
199
|
+
raise SystemExit(1)
|
|
200
|
+
|
|
201
|
+
# Verify the backup
|
|
202
|
+
import sqlite3
|
|
203
|
+
|
|
204
|
+
try:
|
|
205
|
+
conn = sqlite3.connect(str(source))
|
|
206
|
+
conn.execute("PRAGMA integrity_check")
|
|
207
|
+
tables = conn.execute(
|
|
208
|
+
"SELECT name FROM sqlite_master WHERE type='table'"
|
|
209
|
+
).fetchall()
|
|
210
|
+
conn.close()
|
|
211
|
+
if not tables:
|
|
212
|
+
print("ERROR: backup file contains no tables")
|
|
213
|
+
raise SystemExit(1)
|
|
214
|
+
except sqlite3.Error as e:
|
|
215
|
+
print(f"ERROR: invalid backup file: {e}")
|
|
216
|
+
raise SystemExit(1)
|
|
217
|
+
|
|
218
|
+
if not force:
|
|
219
|
+
print("About to restore board.db from:")
|
|
220
|
+
print(f" {source}")
|
|
221
|
+
print("This will OVERWRITE the current database.")
|
|
222
|
+
try:
|
|
223
|
+
answer = input("Continue? [y/N] ").strip().lower()
|
|
224
|
+
except (EOFError, KeyboardInterrupt):
|
|
225
|
+
print("\nCancelled.")
|
|
226
|
+
return
|
|
227
|
+
if answer not in ("y", "yes"):
|
|
228
|
+
print("Cancelled.")
|
|
229
|
+
return
|
|
230
|
+
|
|
231
|
+
# Atomic restore: copy to temp, rename
|
|
232
|
+
tmp = db.db_path.with_suffix(".db.restore-tmp")
|
|
233
|
+
shutil.copy2(source, tmp)
|
|
234
|
+
tmp.replace(db.db_path)
|
|
235
|
+
|
|
236
|
+
print(f"OK restored from {source}")
|
|
237
|
+
print(" Sessions may need to restart to pick up changes.")
|