ossnap 0.1.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.
- ossnap/__init__.py +1 -0
- ossnap/cli.py +541 -0
- ossnap/config.py +47 -0
- ossnap/crypto.py +85 -0
- ossnap/exceptions.py +20 -0
- ossnap/git.py +115 -0
- ossnap/github.py +85 -0
- ossnap/install.py +58 -0
- ossnap/repos.py +127 -0
- ossnap/ssh.py +147 -0
- ossnap/ui.py +94 -0
- ossnap-0.1.0.dist-info/METADATA +161 -0
- ossnap-0.1.0.dist-info/RECORD +16 -0
- ossnap-0.1.0.dist-info/WHEEL +5 -0
- ossnap-0.1.0.dist-info/entry_points.txt +2 -0
- ossnap-0.1.0.dist-info/top_level.txt +1 -0
ossnap/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
ossnap/cli.py
ADDED
|
@@ -0,0 +1,541 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
import shutil
|
|
3
|
+
import sys
|
|
4
|
+
import tempfile
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
import questionary
|
|
9
|
+
|
|
10
|
+
from . import config, crypto, git, github, install, repos, ssh, ui
|
|
11
|
+
from .exceptions import (
|
|
12
|
+
ConfigNotFoundError,
|
|
13
|
+
DecryptionError,
|
|
14
|
+
GhAuthError,
|
|
15
|
+
GitError,
|
|
16
|
+
NetworkError,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class AliasedGroup(click.Group):
|
|
21
|
+
ALIASES = {"i": "init", "s": "snapshot", "p": "pull", "l": "list"}
|
|
22
|
+
_REVERSE = {v: k for k, v in ALIASES.items()}
|
|
23
|
+
|
|
24
|
+
def get_command(self, ctx, cmd_name):
|
|
25
|
+
return super().get_command(ctx, self.ALIASES.get(cmd_name, cmd_name))
|
|
26
|
+
|
|
27
|
+
def format_commands(self, ctx, formatter):
|
|
28
|
+
rows = []
|
|
29
|
+
for name in self.list_commands(ctx):
|
|
30
|
+
cmd = self.get_command(ctx, name)
|
|
31
|
+
if cmd is None or cmd.hidden:
|
|
32
|
+
continue
|
|
33
|
+
alias = self._REVERSE.get(name, "")
|
|
34
|
+
label = f"{name} ({alias})" if alias else name
|
|
35
|
+
rows.append((label, cmd.get_short_help_str(limit=formatter.width)))
|
|
36
|
+
if rows:
|
|
37
|
+
with formatter.section("Commands"):
|
|
38
|
+
formatter.write_dl(rows)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@click.group(cls=AliasedGroup, context_settings={"help_option_names": ["-h", "--help"]})
|
|
42
|
+
def main():
|
|
43
|
+
"""ossnap — snapshot and restore your macOS dev environment."""
|
|
44
|
+
pass
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
HELP_OPTS = {"help_option_names": ["-h", "--help"]}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@main.command(context_settings=HELP_OPTS)
|
|
51
|
+
def init():
|
|
52
|
+
"""Interactive setup wizard."""
|
|
53
|
+
ui.banner("init")
|
|
54
|
+
|
|
55
|
+
# Load existing config as defaults (if re-running init)
|
|
56
|
+
try:
|
|
57
|
+
existing_cfg = config.load_config()
|
|
58
|
+
ui.info("Existing config found — pre-filling values.")
|
|
59
|
+
except ConfigNotFoundError:
|
|
60
|
+
existing_cfg = {}
|
|
61
|
+
|
|
62
|
+
# 1. Check gh CLI
|
|
63
|
+
if not install.ensure_gh():
|
|
64
|
+
sys.exit(1)
|
|
65
|
+
|
|
66
|
+
# 2. Authenticate
|
|
67
|
+
with ui.status_spinner("Checking GitHub authentication..."):
|
|
68
|
+
username = github.check_authenticated()
|
|
69
|
+
|
|
70
|
+
if username:
|
|
71
|
+
ui.success(f"Logged in as: {username}")
|
|
72
|
+
else:
|
|
73
|
+
try:
|
|
74
|
+
github.login()
|
|
75
|
+
with ui.status_spinner("Verifying authentication..."):
|
|
76
|
+
username = github.check_authenticated() or ""
|
|
77
|
+
ui.success(f"Logged in as: {username}")
|
|
78
|
+
except (GhAuthError, NetworkError) as e:
|
|
79
|
+
ui.error(f"Authentication failed: {e}")
|
|
80
|
+
sys.exit(1)
|
|
81
|
+
|
|
82
|
+
# 3. Select or create snapshot repo
|
|
83
|
+
ui.header("Snapshot Repository")
|
|
84
|
+
try:
|
|
85
|
+
with ui.status_spinner("Fetching your repositories..."):
|
|
86
|
+
repo_list = github.list_repos()
|
|
87
|
+
except NetworkError as e:
|
|
88
|
+
ui.error(f"Failed to list repos: {e}")
|
|
89
|
+
sys.exit(1)
|
|
90
|
+
|
|
91
|
+
repo_map = {r["name"]: r["url"] for r in repo_list}
|
|
92
|
+
CREATE_NEW = "+ Create new private repo"
|
|
93
|
+
# Pre-select existing repo from config
|
|
94
|
+
existing_url = existing_cfg.get("github_repo_url", "")
|
|
95
|
+
existing_repo_name = existing_url.rstrip("/").split("/")[-1] if existing_url else None
|
|
96
|
+
repo_default = existing_repo_name if existing_repo_name in repo_map else CREATE_NEW
|
|
97
|
+
selected = questionary.select(
|
|
98
|
+
"Snapshot repository:",
|
|
99
|
+
choices=[CREATE_NEW] + list(repo_map.keys()),
|
|
100
|
+
default=repo_default,
|
|
101
|
+
).ask()
|
|
102
|
+
if selected is None:
|
|
103
|
+
sys.exit(0)
|
|
104
|
+
|
|
105
|
+
if selected == CREATE_NEW:
|
|
106
|
+
repo_name = questionary.text(
|
|
107
|
+
"Repository name:", default="macos_setup"
|
|
108
|
+
).ask()
|
|
109
|
+
if not repo_name or not repo_name.strip():
|
|
110
|
+
sys.exit(0)
|
|
111
|
+
try:
|
|
112
|
+
with ui.status_spinner(f"Creating {repo_name.strip()}..."):
|
|
113
|
+
repo_url = github.create_private_repo(repo_name.strip())
|
|
114
|
+
ui.success(f"Created: {repo_url}")
|
|
115
|
+
except NetworkError as e:
|
|
116
|
+
ui.error(f"Failed to create repo: {e}")
|
|
117
|
+
sys.exit(1)
|
|
118
|
+
else:
|
|
119
|
+
repo_url = repo_map[selected]
|
|
120
|
+
ui.success(f"Using: {repo_url}")
|
|
121
|
+
|
|
122
|
+
# 4. SSH directory
|
|
123
|
+
ui.header("SSH Directory")
|
|
124
|
+
existing_ssh = existing_cfg.get("ssh_dir", "~/.ssh")
|
|
125
|
+
existing_ssh = existing_ssh.replace(str(Path.home()), "~")
|
|
126
|
+
ssh_dir_input = questionary.path(
|
|
127
|
+
"SSH directory:",
|
|
128
|
+
default=existing_ssh,
|
|
129
|
+
only_directories=True,
|
|
130
|
+
).ask()
|
|
131
|
+
if ssh_dir_input is None:
|
|
132
|
+
sys.exit(0)
|
|
133
|
+
ssh_dir = str(Path(ssh_dir_input).expanduser())
|
|
134
|
+
ui.success(f"SSH dir: {ssh_dir}")
|
|
135
|
+
|
|
136
|
+
# 5. Scan directories
|
|
137
|
+
ui.header("Repo Scan Directories")
|
|
138
|
+
home = Path.home()
|
|
139
|
+
common_names = ["Documents", "Projects", "Developer", "Desktop", "workspace", "code", "repos", "work", "dev"]
|
|
140
|
+
default_names = {"Documents", "Projects"}
|
|
141
|
+
existing_scan = {str(Path(d).expanduser()) for d in existing_cfg.get("scan_dirs", [])}
|
|
142
|
+
dir_choices = [
|
|
143
|
+
questionary.Choice(
|
|
144
|
+
str(home / name),
|
|
145
|
+
checked=(str(home / name) in existing_scan if existing_scan else name in default_names),
|
|
146
|
+
)
|
|
147
|
+
for name in common_names
|
|
148
|
+
if (home / name).exists()
|
|
149
|
+
]
|
|
150
|
+
# Add custom dirs from existing config that aren't in common list
|
|
151
|
+
for d in existing_scan:
|
|
152
|
+
if not any(str(home / n) == d for n in common_names) and Path(d).exists():
|
|
153
|
+
dir_choices.append(questionary.Choice(d, checked=True))
|
|
154
|
+
scan_dirs = questionary.checkbox(
|
|
155
|
+
"Select directories to scan for git repos:",
|
|
156
|
+
choices=dir_choices,
|
|
157
|
+
instruction="(space: select, a: all, i: invert)",
|
|
158
|
+
).ask()
|
|
159
|
+
if scan_dirs is None:
|
|
160
|
+
sys.exit(0)
|
|
161
|
+
|
|
162
|
+
while True:
|
|
163
|
+
d = questionary.path(
|
|
164
|
+
"Add custom directory (leave blank to finish):",
|
|
165
|
+
default="",
|
|
166
|
+
only_directories=True,
|
|
167
|
+
).ask()
|
|
168
|
+
if not d or not d.strip():
|
|
169
|
+
break
|
|
170
|
+
expanded = str(Path(d).expanduser())
|
|
171
|
+
if expanded not in scan_dirs:
|
|
172
|
+
scan_dirs.append(expanded)
|
|
173
|
+
|
|
174
|
+
if not scan_dirs:
|
|
175
|
+
ui.warn("No scan directories configured. You can edit ~/.ossnap/config.json later.")
|
|
176
|
+
else:
|
|
177
|
+
ui.success(f"Will scan: {', '.join(scan_dirs)}")
|
|
178
|
+
|
|
179
|
+
# 6. Env file patterns
|
|
180
|
+
ui.header("Env File Patterns")
|
|
181
|
+
default_patterns = {".env", ".env.local", ".env.development", ".env.production"}
|
|
182
|
+
existing_patterns = set(existing_cfg.get("env_patterns", [])) or default_patterns
|
|
183
|
+
known = [".env", ".env.local", ".env.development", ".env.production", ".env.staging", ".env.test"]
|
|
184
|
+
all_choices = [
|
|
185
|
+
questionary.Choice(p, checked=(p in existing_patterns))
|
|
186
|
+
for p in known
|
|
187
|
+
]
|
|
188
|
+
# Add custom patterns from existing config not in known list
|
|
189
|
+
for p in existing_patterns:
|
|
190
|
+
if p not in known:
|
|
191
|
+
all_choices.append(questionary.Choice(p, checked=True))
|
|
192
|
+
env_patterns = questionary.checkbox(
|
|
193
|
+
"Select env file patterns to snapshot:",
|
|
194
|
+
choices=all_choices,
|
|
195
|
+
instruction="(space: select, a: all, i: invert)",
|
|
196
|
+
).ask()
|
|
197
|
+
|
|
198
|
+
if env_patterns is None:
|
|
199
|
+
sys.exit(0)
|
|
200
|
+
|
|
201
|
+
custom = questionary.text(
|
|
202
|
+
"Additional patterns? (comma-separated, leave blank to skip):", default=""
|
|
203
|
+
).ask()
|
|
204
|
+
if custom:
|
|
205
|
+
extras = [p.strip() for p in custom.split(",") if p.strip()]
|
|
206
|
+
env_patterns.extend(e for e in extras if e not in env_patterns)
|
|
207
|
+
|
|
208
|
+
ui.success(f"Env patterns: {', '.join(env_patterns)}")
|
|
209
|
+
|
|
210
|
+
# 7. Encryption password
|
|
211
|
+
ui.header("Encryption")
|
|
212
|
+
existing_pw = crypto.get_password_if_exists()
|
|
213
|
+
if existing_pw:
|
|
214
|
+
change = questionary.confirm("Encryption password already set. Change it?", default=False).ask()
|
|
215
|
+
if not change:
|
|
216
|
+
pw = existing_pw
|
|
217
|
+
ui.success("Keeping existing password.")
|
|
218
|
+
else:
|
|
219
|
+
existing_pw = None
|
|
220
|
+
|
|
221
|
+
if not existing_pw:
|
|
222
|
+
ui.info("Set a password to encrypt your SSH keys and .env files.")
|
|
223
|
+
while True:
|
|
224
|
+
pw = questionary.password("Password:").ask()
|
|
225
|
+
if not pw:
|
|
226
|
+
ui.error("Password cannot be empty.")
|
|
227
|
+
continue
|
|
228
|
+
pw2 = questionary.password("Confirm password:").ask()
|
|
229
|
+
if pw != pw2:
|
|
230
|
+
ui.error("Passwords do not match. Try again.")
|
|
231
|
+
continue
|
|
232
|
+
break
|
|
233
|
+
crypto.set_password(pw)
|
|
234
|
+
ui.success("Password saved to macOS Keychain")
|
|
235
|
+
|
|
236
|
+
# 8. Save config
|
|
237
|
+
cfg = config.default_config()
|
|
238
|
+
cfg["github_repo_url"] = repo_url
|
|
239
|
+
cfg["ssh_dir"] = ssh_dir_input.replace(str(Path.home()), "~")
|
|
240
|
+
cfg["scan_dirs"] = [str(d).replace(str(Path.home()), "~") for d in scan_dirs]
|
|
241
|
+
cfg["env_patterns"] = env_patterns
|
|
242
|
+
config.save_config(cfg)
|
|
243
|
+
ui.success("Config saved to ~/.ossnap/config.json")
|
|
244
|
+
|
|
245
|
+
# 9. Verify connection
|
|
246
|
+
ui.header("Verifying connection")
|
|
247
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
248
|
+
tmp = Path(tmpdir) / "verify"
|
|
249
|
+
try:
|
|
250
|
+
with ui.status_spinner("Connecting to snapshot repo..."):
|
|
251
|
+
git.clone_or_pull(repo_url, tmp)
|
|
252
|
+
ui.success("Connection verified")
|
|
253
|
+
except GitError as e:
|
|
254
|
+
ui.warn(f"Could not verify connection: {e}")
|
|
255
|
+
ui.info("You can still snapshot/pull later.")
|
|
256
|
+
|
|
257
|
+
# 10. Preview what will be backed up
|
|
258
|
+
ui.header("Preview")
|
|
259
|
+
ssh_result = ssh.scan_ssh(Path(ssh_dir).expanduser())
|
|
260
|
+
with ui.status_spinner("Scanning repos..."):
|
|
261
|
+
repo_list = repos.collect_repos(
|
|
262
|
+
[str(Path(d).expanduser()) for d in scan_dirs],
|
|
263
|
+
cfg.get("exclude_dirs", config.DEFAULT_CONFIG["exclude_dirs"]),
|
|
264
|
+
)
|
|
265
|
+
repo_results = [
|
|
266
|
+
(entry["path"], repos.scan_envs(Path.home() / entry["path"], env_patterns))
|
|
267
|
+
for entry in repo_list
|
|
268
|
+
]
|
|
269
|
+
ui.print_snapshot_tree(repo_results, ssh_result)
|
|
270
|
+
|
|
271
|
+
ui.header("Done! Run `ossnap snapshot` to create your first snapshot.")
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
@main.command(name="list", context_settings=HELP_OPTS)
|
|
275
|
+
def list_snapshots():
|
|
276
|
+
"""List all snapshots."""
|
|
277
|
+
ui.banner("list")
|
|
278
|
+
|
|
279
|
+
try:
|
|
280
|
+
cfg = config.load_config()
|
|
281
|
+
except ConfigNotFoundError as e:
|
|
282
|
+
ui.error(str(e))
|
|
283
|
+
sys.exit(1)
|
|
284
|
+
|
|
285
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
286
|
+
tmp = Path(tmpdir) / "snapshot_repo"
|
|
287
|
+
try:
|
|
288
|
+
with ui.status_spinner("Fetching snapshot history..."):
|
|
289
|
+
git.clone_or_pull(cfg["github_repo_url"], tmp)
|
|
290
|
+
except GitError as e:
|
|
291
|
+
ui.error(f"Failed to access snapshot repo: {e}")
|
|
292
|
+
sys.exit(1)
|
|
293
|
+
|
|
294
|
+
commits = git.list_commits(tmp, limit=50)
|
|
295
|
+
|
|
296
|
+
if not commits:
|
|
297
|
+
ui.warn("No snapshots found.")
|
|
298
|
+
return
|
|
299
|
+
|
|
300
|
+
ui.print_table(
|
|
301
|
+
title="Snapshot History",
|
|
302
|
+
headers=["#", "Commit", "Date", "Name"],
|
|
303
|
+
rows=[
|
|
304
|
+
(
|
|
305
|
+
str(i + 1),
|
|
306
|
+
c["short"],
|
|
307
|
+
c["date"],
|
|
308
|
+
c["message"],
|
|
309
|
+
)
|
|
310
|
+
for i, c in enumerate(commits)
|
|
311
|
+
],
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
@main.command(context_settings=HELP_OPTS)
|
|
316
|
+
@click.option("-n", "--name", default=None, help="Custom snapshot name.")
|
|
317
|
+
def snapshot(name: str | None):
|
|
318
|
+
"""Save a snapshot of your environment to GitHub."""
|
|
319
|
+
ui.banner("snapshot")
|
|
320
|
+
|
|
321
|
+
try:
|
|
322
|
+
cfg = config.load_config()
|
|
323
|
+
except ConfigNotFoundError as e:
|
|
324
|
+
ui.error(str(e))
|
|
325
|
+
sys.exit(1)
|
|
326
|
+
|
|
327
|
+
try:
|
|
328
|
+
password = crypto.get_or_create_password()
|
|
329
|
+
except Exception as e:
|
|
330
|
+
ui.error(f"Could not get encryption password: {e}")
|
|
331
|
+
sys.exit(1)
|
|
332
|
+
|
|
333
|
+
repo_url = cfg["github_repo_url"]
|
|
334
|
+
ssh_dir = Path(cfg["ssh_dir"]).expanduser()
|
|
335
|
+
scan_dirs = cfg["scan_dirs"]
|
|
336
|
+
env_patterns = cfg.get("env_patterns", [".env", ".env.local"])
|
|
337
|
+
exclude_dirs = cfg.get("exclude_dirs", [])
|
|
338
|
+
|
|
339
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
340
|
+
tmp = Path(tmpdir) / "snapshot_repo"
|
|
341
|
+
try:
|
|
342
|
+
with ui.status_spinner("Cloning snapshot repo..."):
|
|
343
|
+
git.clone_or_pull(repo_url, tmp)
|
|
344
|
+
except GitError as e:
|
|
345
|
+
ui.error(f"Failed to access snapshot repo: {e}")
|
|
346
|
+
sys.exit(1)
|
|
347
|
+
|
|
348
|
+
# SSH
|
|
349
|
+
ssh_result = {}
|
|
350
|
+
try:
|
|
351
|
+
with ui.status_spinner("Backing up SSH..."):
|
|
352
|
+
ssh_result = ssh.snapshot_ssh(tmp, ssh_dir, password)
|
|
353
|
+
except Exception as e:
|
|
354
|
+
ui.warn(f"SSH snapshot failed: {e}")
|
|
355
|
+
|
|
356
|
+
# Repos
|
|
357
|
+
with ui.status_spinner("Discovering git repos..."):
|
|
358
|
+
repo_list = repos.collect_repos(scan_dirs, exclude_dirs)
|
|
359
|
+
|
|
360
|
+
# Clear entire repos/ dir so stale entries don't accumulate
|
|
361
|
+
repos_dir = tmp / "repos"
|
|
362
|
+
if repos_dir.exists():
|
|
363
|
+
shutil.rmtree(repos_dir)
|
|
364
|
+
|
|
365
|
+
repos.write_repos_json(repo_list, tmp)
|
|
366
|
+
|
|
367
|
+
env_base = tmp / "repos" / "envs"
|
|
368
|
+
snapshot_results = []
|
|
369
|
+
for entry in repo_list:
|
|
370
|
+
repo_path = Path.home() / entry["path"]
|
|
371
|
+
env_files = repos.snapshot_envs(repo_path, env_base, password, env_patterns)
|
|
372
|
+
snapshot_results.append((entry["path"], env_files))
|
|
373
|
+
|
|
374
|
+
ui.print_snapshot_tree(snapshot_results, ssh_result)
|
|
375
|
+
|
|
376
|
+
# Meta
|
|
377
|
+
import json, platform
|
|
378
|
+
meta = {
|
|
379
|
+
"tool_version": "0.1.0",
|
|
380
|
+
"snapshot_date": datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d %H:%M UTC"),
|
|
381
|
+
"hostname": platform.uname().node,
|
|
382
|
+
}
|
|
383
|
+
(tmp / "meta.json").write_text(json.dumps(meta, indent=2))
|
|
384
|
+
|
|
385
|
+
# Commit + push
|
|
386
|
+
now_str = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
|
|
387
|
+
msg = name.strip() if name else f"Snapshot at {now_str}"
|
|
388
|
+
try:
|
|
389
|
+
with ui.status_spinner("Saving snapshot..."):
|
|
390
|
+
changed = git.git_add_commit_push(tmp, msg)
|
|
391
|
+
if changed:
|
|
392
|
+
ui.success(f"Snapshot complete — {msg}")
|
|
393
|
+
else:
|
|
394
|
+
ui.info("Nothing changed since last snapshot.")
|
|
395
|
+
except GitError as e:
|
|
396
|
+
ui.error(f"Snapshot failed: {e}")
|
|
397
|
+
sys.exit(1)
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
@main.command(context_settings=HELP_OPTS)
|
|
401
|
+
@click.option("--ssh-dir", "ssh_dir_override", default=None, metavar="DIR",
|
|
402
|
+
help="Directory to restore SSH keys into.")
|
|
403
|
+
@click.option("--repos-dir", "repos_dir_override", default=None, metavar="DIR",
|
|
404
|
+
help="Base directory to clone repos into.")
|
|
405
|
+
def pull(ssh_dir_override: str | None, repos_dir_override: str | None):
|
|
406
|
+
"""Restore your environment from GitHub."""
|
|
407
|
+
ui.banner("pull")
|
|
408
|
+
|
|
409
|
+
try:
|
|
410
|
+
cfg = config.load_config()
|
|
411
|
+
except ConfigNotFoundError as e:
|
|
412
|
+
ui.error(str(e))
|
|
413
|
+
sys.exit(1)
|
|
414
|
+
|
|
415
|
+
try:
|
|
416
|
+
password = crypto.get_or_create_password()
|
|
417
|
+
except Exception as e:
|
|
418
|
+
ui.error(f"Could not get encryption password: {e}")
|
|
419
|
+
sys.exit(1)
|
|
420
|
+
|
|
421
|
+
repo_url = cfg["github_repo_url"]
|
|
422
|
+
ssh_dir = Path(ssh_dir_override).expanduser() if ssh_dir_override else Path(cfg.get("ssh_dir", "~/.ssh")).expanduser()
|
|
423
|
+
repos_base = Path(repos_dir_override).expanduser() if repos_dir_override else None
|
|
424
|
+
|
|
425
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
426
|
+
tmp = Path(tmpdir) / "snapshot_repo"
|
|
427
|
+
try:
|
|
428
|
+
with ui.status_spinner("Pulling from GitHub..."):
|
|
429
|
+
git.clone_or_pull(repo_url, tmp)
|
|
430
|
+
except GitError as e:
|
|
431
|
+
ui.error(f"Failed to access snapshot repo: {e}")
|
|
432
|
+
sys.exit(1)
|
|
433
|
+
|
|
434
|
+
# Version selection
|
|
435
|
+
commits = git.list_commits(tmp)
|
|
436
|
+
if len(commits) > 1:
|
|
437
|
+
ui.header("Version")
|
|
438
|
+
def _fmt(c: dict, suffix: str = "") -> str:
|
|
439
|
+
return f"{c['short']} {c['date']} {c['message']}{suffix}"
|
|
440
|
+
|
|
441
|
+
LATEST = _fmt(commits[0], " (latest)")
|
|
442
|
+
choices = [LATEST] + [_fmt(c) for c in commits[1:]]
|
|
443
|
+
selected_version = questionary.select(
|
|
444
|
+
"Select snapshot to restore:",
|
|
445
|
+
choices=choices,
|
|
446
|
+
).ask()
|
|
447
|
+
if selected_version is None:
|
|
448
|
+
sys.exit(0)
|
|
449
|
+
if selected_version != LATEST:
|
|
450
|
+
idx = choices.index(selected_version)
|
|
451
|
+
git.checkout_commit(tmp, commits[idx]["hash"])
|
|
452
|
+
|
|
453
|
+
# SSH — let user select what to restore
|
|
454
|
+
ssh_items = ssh.list_snapshot_items(tmp)
|
|
455
|
+
if ssh_items:
|
|
456
|
+
ui.header("SSH")
|
|
457
|
+
ui.info("Existing files will be skipped automatically.")
|
|
458
|
+
selected_ssh = questionary.checkbox(
|
|
459
|
+
"Select SSH items to restore:",
|
|
460
|
+
choices=[questionary.Choice(item, checked=True) for item in ssh_items],
|
|
461
|
+
instruction="(space: select, a: all, i: invert)",
|
|
462
|
+
).ask()
|
|
463
|
+
if selected_ssh is None:
|
|
464
|
+
sys.exit(0)
|
|
465
|
+
ssh_selection = set(selected_ssh) if selected_ssh else None
|
|
466
|
+
else:
|
|
467
|
+
ssh_selection = None
|
|
468
|
+
|
|
469
|
+
if ssh_selection:
|
|
470
|
+
try:
|
|
471
|
+
ssh.restore_ssh(tmp, ssh_dir, password, ssh_selection)
|
|
472
|
+
except DecryptionError:
|
|
473
|
+
ui.error("Wrong encryption password. Aborting SSH restore.")
|
|
474
|
+
sys.exit(1)
|
|
475
|
+
except Exception as e:
|
|
476
|
+
ui.warn(f"SSH restore failed: {e}")
|
|
477
|
+
|
|
478
|
+
env_base = tmp / "repos" / "envs"
|
|
479
|
+
|
|
480
|
+
# Repos — select which to clone
|
|
481
|
+
repo_list = repos.read_repos_json(tmp)
|
|
482
|
+
selected_repo_paths: set[str] = set()
|
|
483
|
+
if repo_list:
|
|
484
|
+
ui.header("Repos")
|
|
485
|
+
selected_repos = questionary.checkbox(
|
|
486
|
+
"Select repos to clone:",
|
|
487
|
+
choices=[questionary.Choice(e["path"], checked=True) for e in repo_list],
|
|
488
|
+
instruction="(space: select, a: all, i: invert)",
|
|
489
|
+
).ask()
|
|
490
|
+
if selected_repos is None:
|
|
491
|
+
sys.exit(0)
|
|
492
|
+
selected_repo_paths = set(selected_repos)
|
|
493
|
+
|
|
494
|
+
cloned = 0
|
|
495
|
+
for entry in [e for e in repo_list if e["path"] in selected_repo_paths]:
|
|
496
|
+
local_path = repos_base / entry["path"] if repos_base else Path.home() / entry["path"]
|
|
497
|
+
if not local_path.exists():
|
|
498
|
+
ui.info(f"Cloning {entry['remote']} → {local_path}")
|
|
499
|
+
try:
|
|
500
|
+
git.clone_repo(entry["remote"], local_path)
|
|
501
|
+
cloned += 1
|
|
502
|
+
except GitError as e:
|
|
503
|
+
ui.warn(f"Could not clone {entry['remote']}: {e}")
|
|
504
|
+
else:
|
|
505
|
+
ui.info(f"Already exists: {local_path}")
|
|
506
|
+
if cloned:
|
|
507
|
+
ui.success(f"Cloned {cloned} repo(s)")
|
|
508
|
+
|
|
509
|
+
# Env files — select which repos' env files to restore
|
|
510
|
+
snapshot_envs = repos.list_snapshot_envs(env_base, [e["path"] for e in repo_list])
|
|
511
|
+
if snapshot_envs:
|
|
512
|
+
ui.header("Env Files")
|
|
513
|
+
selected_env = questionary.checkbox(
|
|
514
|
+
"Select repos to restore env files for:",
|
|
515
|
+
choices=[
|
|
516
|
+
questionary.Choice(
|
|
517
|
+
f"{e['path']} ({', '.join(e['files'])})",
|
|
518
|
+
value=e["path"],
|
|
519
|
+
checked=True,
|
|
520
|
+
)
|
|
521
|
+
for e in snapshot_envs
|
|
522
|
+
],
|
|
523
|
+
instruction="(space: select, a: all, i: invert)",
|
|
524
|
+
).ask()
|
|
525
|
+
if selected_env is None:
|
|
526
|
+
sys.exit(0)
|
|
527
|
+
|
|
528
|
+
env_restored = 0
|
|
529
|
+
env_skipped = 0
|
|
530
|
+
for snap_rel in selected_env:
|
|
531
|
+
if repos_base:
|
|
532
|
+
dest_path = repos_base / snap_rel
|
|
533
|
+
else:
|
|
534
|
+
dest_path = Path.home() / snap_rel
|
|
535
|
+
r, s = repos.restore_envs(env_base, snap_rel, dest_path, password)
|
|
536
|
+
env_restored += r
|
|
537
|
+
env_skipped += s
|
|
538
|
+
if env_restored:
|
|
539
|
+
ui.success(f"Restored {env_restored} env file(s) ({env_skipped} skipped)")
|
|
540
|
+
|
|
541
|
+
ui.header("Pull complete.")
|
ossnap/config.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import tempfile
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from .exceptions import ConfigNotFoundError
|
|
7
|
+
|
|
8
|
+
CONFIG_DIR = Path.home() / ".ossnap"
|
|
9
|
+
CONFIG_FILE = CONFIG_DIR / "config.json"
|
|
10
|
+
|
|
11
|
+
DEFAULT_CONFIG = {
|
|
12
|
+
"version": 1,
|
|
13
|
+
"github_repo_url": "",
|
|
14
|
+
"ssh_dir": "~/.ssh",
|
|
15
|
+
"scan_dirs": ["~/Documents", "~/Projects"],
|
|
16
|
+
"env_patterns": [".env", ".env.local", ".env.development", ".env.production"],
|
|
17
|
+
"exclude_dirs": ["node_modules", ".git", "venv", "__pycache__", ".venv"],
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def load_config() -> dict:
|
|
22
|
+
if not CONFIG_FILE.exists():
|
|
23
|
+
raise ConfigNotFoundError("Config not found. Run `ossnap init` first.")
|
|
24
|
+
with open(CONFIG_FILE) as f:
|
|
25
|
+
data = json.load(f)
|
|
26
|
+
# Expand ~ in path fields
|
|
27
|
+
if data.get("ssh_dir"):
|
|
28
|
+
data["ssh_dir"] = str(Path(data["ssh_dir"]).expanduser())
|
|
29
|
+
data["scan_dirs"] = [str(Path(d).expanduser()) for d in data.get("scan_dirs", [])]
|
|
30
|
+
return data
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def save_config(data: dict) -> None:
|
|
34
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
35
|
+
# Write atomically via temp file
|
|
36
|
+
fd, tmp_path = tempfile.mkstemp(dir=CONFIG_DIR, suffix=".json.tmp")
|
|
37
|
+
try:
|
|
38
|
+
with os.fdopen(fd, "w") as f:
|
|
39
|
+
json.dump(data, f, indent=2)
|
|
40
|
+
os.replace(tmp_path, CONFIG_FILE)
|
|
41
|
+
except Exception:
|
|
42
|
+
os.unlink(tmp_path)
|
|
43
|
+
raise
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def default_config() -> dict:
|
|
47
|
+
return DEFAULT_CONFIG.copy()
|
ossnap/crypto.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import getpass
|
|
2
|
+
import os
|
|
3
|
+
|
|
4
|
+
from cryptography.fernet import Fernet, InvalidToken
|
|
5
|
+
from cryptography.hazmat.primitives import hashes
|
|
6
|
+
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
|
7
|
+
import base64
|
|
8
|
+
|
|
9
|
+
try:
|
|
10
|
+
import keyring
|
|
11
|
+
_KEYRING_AVAILABLE = True
|
|
12
|
+
except Exception:
|
|
13
|
+
_KEYRING_AVAILABLE = False
|
|
14
|
+
|
|
15
|
+
from .exceptions import DecryptionError
|
|
16
|
+
|
|
17
|
+
KEYCHAIN_SERVICE = "ossnap"
|
|
18
|
+
KEYCHAIN_ACCOUNT = "encryption-password"
|
|
19
|
+
SALT_SIZE = 16
|
|
20
|
+
PBKDF2_ITERATIONS = 600_000
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def get_password_if_exists() -> str | None:
|
|
24
|
+
if _KEYRING_AVAILABLE:
|
|
25
|
+
return keyring.get_password(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT)
|
|
26
|
+
return None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def get_or_create_password() -> str:
|
|
30
|
+
if _KEYRING_AVAILABLE:
|
|
31
|
+
pw = keyring.get_password(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT)
|
|
32
|
+
if pw:
|
|
33
|
+
return pw
|
|
34
|
+
pw = getpass.getpass("Encryption password: ")
|
|
35
|
+
if _KEYRING_AVAILABLE:
|
|
36
|
+
keyring.set_password(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT, pw)
|
|
37
|
+
return pw
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def set_password(pw: str) -> None:
|
|
41
|
+
if _KEYRING_AVAILABLE:
|
|
42
|
+
keyring.set_password(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT, pw)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _derive_key(password: str, salt: bytes) -> bytes:
|
|
46
|
+
kdf = PBKDF2HMAC(
|
|
47
|
+
algorithm=hashes.SHA256(),
|
|
48
|
+
length=32,
|
|
49
|
+
salt=salt,
|
|
50
|
+
iterations=PBKDF2_ITERATIONS,
|
|
51
|
+
)
|
|
52
|
+
return base64.urlsafe_b64encode(kdf.derive(password.encode()))
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def encrypt_bytes(data: bytes, password: str) -> bytes:
|
|
56
|
+
salt = os.urandom(SALT_SIZE)
|
|
57
|
+
key = _derive_key(password, salt)
|
|
58
|
+
token = Fernet(key).encrypt(data)
|
|
59
|
+
return salt + token
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def decrypt_bytes(data: bytes, password: str) -> bytes:
|
|
63
|
+
salt = data[:SALT_SIZE]
|
|
64
|
+
token = data[SALT_SIZE:]
|
|
65
|
+
key = _derive_key(password, salt)
|
|
66
|
+
try:
|
|
67
|
+
return Fernet(key).decrypt(token)
|
|
68
|
+
except InvalidToken:
|
|
69
|
+
raise DecryptionError("Wrong password or corrupted file.")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def encrypt_file(src, dst, password: str) -> None:
|
|
73
|
+
from pathlib import Path
|
|
74
|
+
data = Path(src).read_bytes()
|
|
75
|
+
encrypted = encrypt_bytes(data, password)
|
|
76
|
+
Path(dst).parent.mkdir(parents=True, exist_ok=True)
|
|
77
|
+
Path(dst).write_bytes(encrypted)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def decrypt_file(src, dst, password: str) -> None:
|
|
81
|
+
from pathlib import Path
|
|
82
|
+
data = Path(src).read_bytes()
|
|
83
|
+
decrypted = decrypt_bytes(data, password)
|
|
84
|
+
Path(dst).parent.mkdir(parents=True, exist_ok=True)
|
|
85
|
+
Path(dst).write_bytes(decrypted)
|
ossnap/exceptions.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
class OSSnapError(Exception):
|
|
2
|
+
pass
|
|
3
|
+
|
|
4
|
+
class ConfigNotFoundError(OSSnapError):
|
|
5
|
+
pass
|
|
6
|
+
|
|
7
|
+
class GitError(OSSnapError):
|
|
8
|
+
pass
|
|
9
|
+
|
|
10
|
+
class DecryptionError(OSSnapError):
|
|
11
|
+
pass
|
|
12
|
+
|
|
13
|
+
class NetworkError(OSSnapError):
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
class GhNotInstalledError(OSSnapError):
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
class GhAuthError(OSSnapError):
|
|
20
|
+
pass
|