lnp-devopscli 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.
dc_cli/groups/ws.py ADDED
@@ -0,0 +1,1249 @@
1
+ """Grupo: dc ws - Workspace sync utilities."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import shutil
7
+ import subprocess
8
+ import sys
9
+ import tempfile
10
+ from pathlib import Path
11
+
12
+ import click
13
+ import yaml
14
+
15
+
16
+ DEFAULT_REMOTE = "gdrive:workspace-sync"
17
+ DEFAULT_CONFIG_PATH = Path.home() / "bin" / "config.yaml"
18
+ DEVOPSCLI_GIT_URL = "git@gitlab.com:lnp-consulting-ti/devops/devops-cli.git"
19
+ DEVOPSCLI_GIT_URL_HTTPS = "https://gitlab.com/lnp-consulting-ti/devops/devops-cli.git"
20
+ BIN_EXCLUDES = [
21
+ "devops-cli/**",
22
+ "**/.git/**",
23
+ "**/.venv/**",
24
+ "**/__pycache__/**",
25
+ "**/*.pyc",
26
+ "**/.env",
27
+ "**/.env.*",
28
+ ]
29
+ LOCAL_BIN_EXCLUDES = [
30
+ # gerenciados por outros instaladores (re-instalados no bootstrap)
31
+ "mise",
32
+ "uv",
33
+ # ansible scripts dependem de venv especifica
34
+ "ansible",
35
+ "ansible-*",
36
+ # backups / temporarios
37
+ "*.bak",
38
+ "*.bak-*",
39
+ "*.tmp",
40
+ "*.old",
41
+ ]
42
+ SSH_EXCLUDES = [
43
+ "agent.*",
44
+ "sockets/**",
45
+ "S.*",
46
+ "*.sock",
47
+ ]
48
+ ZELLIJ_CACHE_EXCLUDES = [
49
+ "**/initial_contents_*",
50
+ ]
51
+
52
+
53
+ def _require_rclone() -> None:
54
+ if not shutil.which("rclone"):
55
+ click.echo("[ERRO] rclone nao encontrado no PATH.", err=True)
56
+ sys.exit(1)
57
+
58
+
59
+ def _run(cmd: list[str], check: bool = True) -> subprocess.CompletedProcess:
60
+ result = subprocess.run(cmd)
61
+ if check and result.returncode != 0:
62
+ sys.exit(result.returncode)
63
+ return result
64
+
65
+
66
+ def _copyto(src: Path | str, dst: str, dry_run: bool, check: bool = True) -> None:
67
+ cmd = ["rclone", "copyto", str(src), dst, "-v"]
68
+ if dry_run:
69
+ cmd.append("--dry-run")
70
+ _run(cmd, check=check)
71
+
72
+
73
+ def _copy(
74
+ src: Path | str, dst: str, dry_run: bool, excludes: list[str] | None = None
75
+ ) -> None:
76
+ cmd = ["rclone", "copy", str(src), dst, "-v"]
77
+ if excludes:
78
+ for pattern in excludes:
79
+ cmd.extend(["--exclude", pattern])
80
+ if dry_run:
81
+ cmd.append("--dry-run")
82
+ _run(cmd)
83
+
84
+
85
+ def _sync(
86
+ src: Path | str, dst: str, dry_run: bool, excludes: list[str] | None = None
87
+ ) -> None:
88
+ cmd = ["rclone", "sync", str(src), dst, "-v"]
89
+ if excludes:
90
+ for pattern in excludes:
91
+ cmd.extend(["--exclude", pattern])
92
+ if dry_run:
93
+ cmd.append("--dry-run")
94
+ _run(cmd)
95
+
96
+
97
+ def _snapshot_and_copy(
98
+ src_dir: Path,
99
+ dst: str,
100
+ dry_run: bool,
101
+ excludes: list[str] | None = None,
102
+ ) -> None:
103
+ """Snapshot a live directory locally, then rclone copy the snapshot.
104
+
105
+ Eliminates 'source file is being updated' race conditions when the
106
+ source has files mutated continuously (e.g. zellij session cache).
107
+ """
108
+ if dry_run:
109
+ _copy(src_dir, dst, dry_run, excludes=excludes)
110
+ return
111
+
112
+ snapshot_root = Path(tempfile.mkdtemp(prefix=f"ws-snap-{src_dir.name}-"))
113
+ snapshot_dir = snapshot_root / src_dir.name
114
+ try:
115
+ shutil.copytree(
116
+ src_dir,
117
+ snapshot_dir,
118
+ symlinks=False,
119
+ ignore_dangling_symlinks=True,
120
+ copy_function=shutil.copy2,
121
+ )
122
+ _copy(snapshot_dir, dst, dry_run, excludes=excludes)
123
+ finally:
124
+ shutil.rmtree(snapshot_root, ignore_errors=True)
125
+
126
+
127
+ def _load_workspace_config(path: Path) -> dict:
128
+ if not path.exists():
129
+ click.echo(f"[ERRO] config.yaml nao encontrado: {path}", err=True)
130
+ sys.exit(1)
131
+ return yaml.safe_load(path.read_text()) or {}
132
+
133
+
134
+ @click.group(name="ws")
135
+ def ws_group():
136
+ """Workspace sync utilities."""
137
+
138
+
139
+ @ws_group.command(name="push")
140
+ @click.option("--remote", default=DEFAULT_REMOTE, show_default=True)
141
+ @click.option("--dry-run", is_flag=True, default=False)
142
+ def push_cmd(remote: str, dry_run: bool) -> None:
143
+ """Sync local workspace configs to Google Drive."""
144
+ _require_rclone()
145
+
146
+ home = Path.home()
147
+ alacritty_dir = home / ".config" / "alacritty"
148
+ workspace_config = home / "bin" / "config.yaml"
149
+ ssh_dir = home / ".ssh"
150
+
151
+ click.echo("Starting workspace sync to Google Drive...")
152
+
153
+ zellij_dir = home / ".config" / "zellij"
154
+ zellij_cache = home / ".cache" / "zellij"
155
+
156
+ click.echo("[1/8] Syncing zellij config...")
157
+ if zellij_dir.exists():
158
+ _sync(zellij_dir, f"{remote}/zellij/config", dry_run)
159
+
160
+ click.echo("[2/8] Syncing zellij session data (snapshot, no scrollback)...")
161
+ if zellij_cache.exists():
162
+ _snapshot_and_copy(
163
+ zellij_cache,
164
+ f"{remote}/zellij/cache",
165
+ dry_run,
166
+ excludes=ZELLIJ_CACHE_EXCLUDES,
167
+ )
168
+
169
+ click.echo("[3/8] Syncing alacritty config...")
170
+ if alacritty_dir.exists():
171
+ _sync(alacritty_dir, f"{remote}/alacritty", dry_run)
172
+
173
+ click.echo("[4/8] Syncing shell configs...")
174
+ if (home / ".zshrc").exists():
175
+ _copy(home / ".zshrc", f"{remote}/shell", dry_run)
176
+ if (home / ".bashrc").exists():
177
+ _copy(home / ".bashrc", f"{remote}/shell", dry_run)
178
+
179
+ click.echo("[5/8] Syncing ~/bin scripts...")
180
+ _sync(home / "bin", f"{remote}/bin", dry_run, excludes=BIN_EXCLUDES)
181
+
182
+ click.echo("[6/8] Syncing ~/.ssh ...")
183
+ if ssh_dir.exists():
184
+ _sync(ssh_dir, f"{remote}/ssh", dry_run, excludes=SSH_EXCLUDES)
185
+
186
+ click.echo("[7/13] Syncing workspace config.yaml...")
187
+ if workspace_config.exists():
188
+ _copyto(workspace_config, f"{remote}/config.yaml", dry_run)
189
+
190
+ click.echo("[8/13] Syncing crontab...")
191
+ with tempfile.NamedTemporaryFile(prefix="crontab_backup_", delete=False) as tmp:
192
+ tmp_path = Path(tmp.name)
193
+ try:
194
+ result = subprocess.run(["crontab", "-l"], capture_output=True, text=True)
195
+ tmp_path.write_text(
196
+ result.stdout if result.returncode == 0 else "# Empty crontab\n"
197
+ )
198
+ _copyto(tmp_path, f"{remote}/crontab/crontab", dry_run)
199
+ finally:
200
+ tmp_path.unlink(missing_ok=True)
201
+
202
+ click.echo("[9/13] Syncing rclone config...")
203
+ rclone_conf = home / ".config" / "rclone" / "rclone.conf"
204
+ if rclone_conf.exists():
205
+ _copy(rclone_conf, f"{remote}/rclone", dry_run)
206
+
207
+ click.echo("[10/13] Syncing mise config...")
208
+ mise_conf = home / ".config" / "mise" / "config.toml"
209
+ if mise_conf.exists():
210
+ _copy(mise_conf, f"{remote}/mise", dry_run)
211
+
212
+ click.echo("[11/13] Syncing /usr/local/bin user binaries...")
213
+ local_bin_snapshot = (
214
+ home / ".local" / "state" / "workspace-snapshot" / "user-local-bin"
215
+ )
216
+ if not dry_run:
217
+ local_bin_snapshot.mkdir(parents=True, exist_ok=True)
218
+ uid = os.getuid()
219
+ usr_local_bin = Path("/usr/local/bin")
220
+ if usr_local_bin.exists():
221
+ for f in usr_local_bin.iterdir():
222
+ try:
223
+ st = f.stat()
224
+ except (OSError, PermissionError):
225
+ continue
226
+ if st.st_uid == uid and not f.is_symlink() and f.is_file():
227
+ try:
228
+ shutil.copy2(f, local_bin_snapshot / f.name)
229
+ except (OSError, PermissionError):
230
+ pass
231
+ _sync(local_bin_snapshot, f"{remote}/usr-local-bin", dry_run)
232
+
233
+ click.echo("[12/13] Syncing system snapshots (apt/snap/mise)...")
234
+ snap_dir = home / ".local" / "state" / "workspace-snapshot"
235
+ if not dry_run:
236
+ snap_dir.mkdir(parents=True, exist_ok=True)
237
+ _write_snapshot(
238
+ snap_dir / "apt-manual.txt", ["apt-mark", "showmanual"]
239
+ )
240
+ _write_snapshot(snap_dir / "snaps.txt", ["snap", "list", "--all"])
241
+ if shutil.which("mise"):
242
+ _write_snapshot(snap_dir / "mise-list.txt", ["mise", "list"])
243
+ _write_snapshot(
244
+ snap_dir / "dpkg-selections.txt", ["dpkg", "--get-selections"]
245
+ )
246
+ for fname in (
247
+ "apt-manual.txt",
248
+ "snaps.txt",
249
+ "mise-list.txt",
250
+ "dpkg-selections.txt",
251
+ ):
252
+ fpath = snap_dir / fname
253
+ if fpath.exists():
254
+ _copy(fpath, f"{remote}/snapshots", dry_run)
255
+
256
+ click.echo("[13/13] Syncing ~/.local/bin (zellij, trivy, dc, devopscli, etc)...")
257
+ local_bin = home / ".local" / "bin"
258
+ if local_bin.exists():
259
+ _sync(local_bin, f"{remote}/local-bin", dry_run, excludes=LOCAL_BIN_EXCLUDES)
260
+
261
+ click.echo("")
262
+ click.echo("=== Workspace push completed! ===")
263
+ click.echo(f"Remote: {remote}")
264
+
265
+
266
+ @ws_group.command(name="pull")
267
+ @click.option("--remote", default=DEFAULT_REMOTE, show_default=True)
268
+ @click.option("--dry-run", is_flag=True, default=False)
269
+ def pull_cmd(remote: str, dry_run: bool) -> None:
270
+ """Pull workspace configs from Google Drive."""
271
+ _require_rclone()
272
+
273
+ home = Path.home()
274
+ alacritty_dir = home / ".config" / "alacritty"
275
+ workspace_dir = home / "bin"
276
+ ssh_dir = home / ".ssh"
277
+
278
+ zellij_dir = home / ".config" / "zellij"
279
+ zellij_cache = home / ".cache" / "zellij"
280
+
281
+ click.echo("Starting workspace pull from Google Drive...")
282
+
283
+ click.echo("[1/13] Pulling zellij config...")
284
+ zellij_dir.mkdir(parents=True, exist_ok=True)
285
+ _sync(f"{remote}/zellij/config", str(zellij_dir), dry_run)
286
+
287
+ click.echo("[2/13] Pulling zellij session data...")
288
+ zellij_cache.mkdir(parents=True, exist_ok=True)
289
+ _copy(f"{remote}/zellij/cache", str(zellij_cache), dry_run)
290
+
291
+ click.echo("[3/13] Pulling alacritty config...")
292
+ alacritty_dir.mkdir(parents=True, exist_ok=True)
293
+ _sync(f"{remote}/alacritty", str(alacritty_dir), dry_run)
294
+
295
+ click.echo("[4/13] Pulling shell configs...")
296
+ _copy(f"{remote}/shell/.zshrc", str(home), dry_run)
297
+ _copy(f"{remote}/shell/.bashrc", str(home), dry_run)
298
+
299
+ click.echo("[5/13] Pulling ~/bin scripts...")
300
+ (home / "bin").mkdir(parents=True, exist_ok=True)
301
+ _sync(f"{remote}/bin", str(home / "bin"), dry_run, excludes=BIN_EXCLUDES)
302
+ if not dry_run:
303
+ for path in (home / "bin").glob("*"):
304
+ if path.is_file():
305
+ path.chmod(0o755)
306
+
307
+ _ensure_devopscli(home)
308
+
309
+ click.echo("[6/13] Pulling ~/.ssh ...")
310
+ ssh_dir.mkdir(parents=True, exist_ok=True)
311
+ if not dry_run:
312
+ ssh_dir.chmod(0o700)
313
+ _sync(f"{remote}/ssh", str(ssh_dir), dry_run, excludes=SSH_EXCLUDES)
314
+ if not dry_run:
315
+ for path in ssh_dir.iterdir():
316
+ if path.is_file():
317
+ path.chmod(0o600)
318
+
319
+ click.echo("[7/13] Pulling workspace config.yaml...")
320
+ workspace_dir.mkdir(parents=True, exist_ok=True)
321
+ _copy(f"{remote}/config.yaml", str(workspace_dir), dry_run)
322
+
323
+ click.echo("[8/13] Pulling crontab...")
324
+ crontab_target = Path("/tmp/crontab")
325
+ _copyto(f"{remote}/crontab/crontab", str(crontab_target), dry_run, check=False)
326
+ if not dry_run and crontab_target.exists():
327
+ subprocess.run(["crontab", str(crontab_target)])
328
+ crontab_target.unlink(missing_ok=True)
329
+
330
+ click.echo("[9/13] Pulling rclone config...")
331
+ rclone_dir = home / ".config" / "rclone"
332
+ if not dry_run:
333
+ rclone_dir.mkdir(parents=True, exist_ok=True)
334
+ _copy(f"{remote}/rclone", str(rclone_dir), dry_run)
335
+ if not dry_run:
336
+ rclone_conf = rclone_dir / "rclone.conf"
337
+ if rclone_conf.exists():
338
+ rclone_conf.chmod(0o600)
339
+
340
+ click.echo("[10/13] Pulling mise config...")
341
+ mise_dir = home / ".config" / "mise"
342
+ if not dry_run:
343
+ mise_dir.mkdir(parents=True, exist_ok=True)
344
+ _copy(f"{remote}/mise", str(mise_dir), dry_run)
345
+
346
+ click.echo("[11/13] Pulling /usr/local/bin user binaries (snapshot)...")
347
+ local_bin_snapshot = (
348
+ home / ".local" / "state" / "workspace-snapshot" / "user-local-bin"
349
+ )
350
+ if not dry_run:
351
+ local_bin_snapshot.mkdir(parents=True, exist_ok=True)
352
+ _sync(f"{remote}/usr-local-bin", str(local_bin_snapshot), dry_run)
353
+ if not dry_run:
354
+ for path in local_bin_snapshot.iterdir():
355
+ if path.is_file():
356
+ path.chmod(0o755)
357
+ click.echo(
358
+ f" → snapshot em {local_bin_snapshot}. "
359
+ "Use 'sudo cp ~/.local/state/workspace-snapshot/user-local-bin/* "
360
+ "/usr/local/bin/' para instalar."
361
+ )
362
+
363
+ click.echo("[12/13] Pulling system snapshots...")
364
+ snap_dir = home / ".local" / "state" / "workspace-snapshot"
365
+ if not dry_run:
366
+ snap_dir.mkdir(parents=True, exist_ok=True)
367
+ _copy(f"{remote}/snapshots", str(snap_dir), dry_run)
368
+
369
+ click.echo("[13/13] Pulling ~/.local/bin (zellij, trivy, etc)...")
370
+ local_bin = home / ".local" / "bin"
371
+ if not dry_run:
372
+ local_bin.mkdir(parents=True, exist_ok=True)
373
+ _sync(f"{remote}/local-bin", str(local_bin), dry_run, excludes=LOCAL_BIN_EXCLUDES)
374
+ if not dry_run:
375
+ for path in local_bin.iterdir():
376
+ if path.is_file():
377
+ path.chmod(0o755)
378
+
379
+ click.echo("")
380
+ click.echo("=== Workspace pull completed! ===")
381
+ click.echo(
382
+ "Note: Restart your shell or run 'source ~/.zshrc' to apply shell changes"
383
+ )
384
+ click.echo(
385
+ "Note: Use 'devopscli ws apply-snapshots' para instalar apt/snap/mise listados"
386
+ )
387
+
388
+
389
+ def _git_crypt_unlocked(path: Path) -> bool:
390
+ """Return True if repo has git-crypt and is currently unlocked.
391
+
392
+ If repo has no git-crypt at all, returns True (nothing to check).
393
+ If git-crypt files exist but content is still encrypted, returns False
394
+ to avoid committing encrypted blobs as plaintext.
395
+ """
396
+ gitattrs = path / ".gitattributes"
397
+ if not gitattrs.exists():
398
+ return True
399
+ if "git-crypt" not in gitattrs.read_text(errors="ignore"):
400
+ return True
401
+ if not shutil.which("git-crypt"):
402
+ click.echo("[WARN] git-crypt nao instalado; pulando auto-commit por seguranca")
403
+ return False
404
+ result = subprocess.run(
405
+ ["git-crypt", "status", "-e"],
406
+ cwd=str(path),
407
+ capture_output=True,
408
+ text=True,
409
+ )
410
+ # When locked, encrypted files appear as binary. We check via dump command
411
+ # which only works when unlocked.
412
+ check = subprocess.run(
413
+ ["git", "-C", str(path), "config", "--get", "filter.git-crypt.smudge"],
414
+ capture_output=True,
415
+ text=True,
416
+ )
417
+ return check.returncode == 0 and bool(check.stdout.strip())
418
+
419
+
420
+ def _write_snapshot(out_path: Path, cmd: list[str]) -> None:
421
+ """Run a command and write its stdout to out_path. Silent on failure."""
422
+ if not shutil.which(cmd[0]):
423
+ return
424
+ try:
425
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
426
+ if result.returncode == 0:
427
+ out_path.write_text(result.stdout)
428
+ except (subprocess.SubprocessError, OSError):
429
+ pass
430
+
431
+
432
+ def _notify_sync_issue(repo_name: str, summary: str) -> None:
433
+ """Send desktop notification when sync fails (cron-safe).
434
+
435
+ Sets DBUS_SESSION_BUS_ADDRESS and DISPLAY so notify-send works even when
436
+ called from cron (which has no desktop env by default). Silently swallows
437
+ any error.
438
+ """
439
+ if not shutil.which("notify-send"):
440
+ return
441
+ env = os.environ.copy()
442
+ uid = os.getuid()
443
+ env.setdefault("DBUS_SESSION_BUS_ADDRESS", f"unix:path=/run/user/{uid}/bus")
444
+ env.setdefault("DISPLAY", ":0")
445
+ env.setdefault("XDG_RUNTIME_DIR", f"/run/user/{uid}")
446
+ try:
447
+ subprocess.run(
448
+ [
449
+ "notify-send",
450
+ "--urgency=critical",
451
+ "--icon=dialog-warning",
452
+ "--app-name=devopscli ws",
453
+ f"[ws sync] {repo_name}: acao manual necessaria",
454
+ summary[:500],
455
+ ],
456
+ env=env,
457
+ check=False,
458
+ timeout=5,
459
+ )
460
+ except (subprocess.SubprocessError, OSError):
461
+ pass
462
+
463
+
464
+ def _auto_commit(path: Path, message: str | None) -> bool:
465
+ """Stage all changes and create a commit. Returns True if a commit was made.
466
+
467
+ Retries once if pre-commit hooks auto-fix files (common with ruff/prettier/
468
+ end-of-file-fixer). The hook re-formats the file and aborts the commit
469
+ expecting the user to re-stage; we do that automatically.
470
+ """
471
+ status = subprocess.run(
472
+ ["git", "-C", str(path), "status", "--porcelain"],
473
+ capture_output=True,
474
+ text=True,
475
+ )
476
+ if not status.stdout.strip():
477
+ return False
478
+
479
+ if not _git_crypt_unlocked(path):
480
+ click.echo(f"[skip] {path.name}: git-crypt locked, nao commitando")
481
+ return False
482
+
483
+ msg = message or "chore(auto-sync): commit automatico do ws ai-sync"
484
+
485
+ for attempt in (1, 2):
486
+ subprocess.run(["git", "-C", str(path), "add", "-A"])
487
+ staged = subprocess.run(
488
+ ["git", "-C", str(path), "diff", "--cached", "--quiet"],
489
+ )
490
+ if staged.returncode == 0:
491
+ return False
492
+ result = subprocess.run(
493
+ ["git", "-C", str(path), "commit", "-m", msg],
494
+ capture_output=True,
495
+ text=True,
496
+ )
497
+ if result.returncode == 0:
498
+ label = "[commit]" if attempt == 1 else "[commit/retry]"
499
+ click.echo(f"{label} {path.name}: {msg}")
500
+ return True
501
+
502
+ output = (result.stdout + result.stderr).lower()
503
+ hook_autofix = "files were modified by this hook" in output
504
+ if attempt == 1 and hook_autofix:
505
+ click.echo(
506
+ f"[retry] {path.name}: hooks auto-corrigiram arquivos, re-staging"
507
+ )
508
+ continue
509
+
510
+ err_summary = result.stderr.strip()[:300] or result.stdout.strip()[:300]
511
+ click.echo(f"[WARN] commit falhou em {path}: {err_summary}")
512
+ _notify_sync_issue(path.name, err_summary)
513
+ return False
514
+
515
+ return False
516
+
517
+
518
+ SYSTEMD_UNITS = {
519
+ "ws-push.service": """[Unit]
520
+ Description=Workspace push to GDrive (configs/dotfiles)
521
+ After=network-online.target
522
+ Wants=network-online.target
523
+
524
+ [Service]
525
+ Type=oneshot
526
+ ExecStart=%h/bin/devops-cli/devopscli ws push
527
+ Nice=10
528
+ """,
529
+ "ws-push.timer": """[Unit]
530
+ Description=Run ws push every 30 minutes
531
+
532
+ [Timer]
533
+ OnBootSec=5min
534
+ OnUnitActiveSec=30min
535
+ Persistent=true
536
+
537
+ [Install]
538
+ WantedBy=timers.target
539
+ """,
540
+ "ws-sync.service": """[Unit]
541
+ Description=Workspace full sync (pull + git + push)
542
+ After=network-online.target
543
+ Wants=network-online.target
544
+
545
+ [Service]
546
+ Type=oneshot
547
+ ExecStart=%h/bin/devops-cli/devopscli ws sync
548
+ Nice=10
549
+ """,
550
+ "ws-sync.timer": """[Unit]
551
+ Description=Run ws sync hourly
552
+
553
+ [Timer]
554
+ OnBootSec=10min
555
+ OnUnitActiveSec=1h
556
+ Persistent=true
557
+
558
+ [Install]
559
+ WantedBy=timers.target
560
+ """,
561
+ }
562
+
563
+
564
+ @ws_group.command(name="install-timers")
565
+ @click.option(
566
+ "--remove-cron",
567
+ is_flag=True,
568
+ default=True,
569
+ help="Remove old devopscli ws lines from crontab (default: true)",
570
+ )
571
+ def install_timers_cmd(remove_cron: bool) -> None:
572
+ """Install systemd user timers replacing the cron jobs."""
573
+ home = Path.home()
574
+ units_dir = home / ".config" / "systemd" / "user"
575
+ units_dir.mkdir(parents=True, exist_ok=True)
576
+
577
+ click.echo(f"▶ Writing units to {units_dir}/...")
578
+ for name, content in SYSTEMD_UNITS.items():
579
+ (units_dir / name).write_text(content)
580
+ click.echo(f" • {name}")
581
+
582
+ click.echo("\n▶ systemctl --user daemon-reload")
583
+ subprocess.run(["systemctl", "--user", "daemon-reload"], check=False)
584
+
585
+ click.echo("\n▶ Enabling timers...")
586
+ for timer in ("ws-push.timer", "ws-sync.timer"):
587
+ subprocess.run(
588
+ ["systemctl", "--user", "enable", "--now", timer], check=False
589
+ )
590
+ click.echo(f" ✓ {timer}")
591
+
592
+ if remove_cron:
593
+ click.echo("\n▶ Removing old devopscli ws lines from crontab...")
594
+ result = subprocess.run(
595
+ ["crontab", "-l"], capture_output=True, text=True
596
+ )
597
+ if result.returncode == 0:
598
+ kept = []
599
+ removed = []
600
+ for line in result.stdout.splitlines():
601
+ if "devopscli ws" in line and not line.lstrip().startswith("#"):
602
+ removed.append(line)
603
+ else:
604
+ kept.append(line)
605
+ if removed:
606
+ click.echo(f" Removendo {len(removed)} linhas:")
607
+ for line in removed:
608
+ click.echo(f" - {line[:80]}")
609
+ with tempfile.NamedTemporaryFile(
610
+ mode="w", suffix=".cron", delete=False
611
+ ) as tmp:
612
+ tmp.write("\n".join(kept) + "\n")
613
+ tmp_path = tmp.name
614
+ subprocess.run(["crontab", tmp_path], check=False)
615
+ Path(tmp_path).unlink(missing_ok=True)
616
+ else:
617
+ click.echo(" Nenhuma linha 'devopscli ws' encontrada no crontab.")
618
+
619
+ click.echo("\n▶ Status atual:")
620
+ subprocess.run(
621
+ ["systemctl", "--user", "list-timers", "ws-push.timer", "ws-sync.timer"],
622
+ check=False,
623
+ )
624
+
625
+ click.echo("\n=== Timers instalados! ===")
626
+ click.echo("Logs: journalctl --user -u ws-sync.service -f")
627
+ click.echo("Status: systemctl --user status ws-sync.timer")
628
+
629
+
630
+ @ws_group.command(name="uninstall-timers")
631
+ def uninstall_timers_cmd() -> None:
632
+ """Disable and remove systemd user timers (does NOT restore old cron)."""
633
+ home = Path.home()
634
+ units_dir = home / ".config" / "systemd" / "user"
635
+
636
+ click.echo("▶ Stopping and disabling timers...")
637
+ for timer in ("ws-push.timer", "ws-sync.timer"):
638
+ subprocess.run(
639
+ ["systemctl", "--user", "disable", "--now", timer], check=False
640
+ )
641
+
642
+ click.echo("▶ Removing unit files...")
643
+ for name in SYSTEMD_UNITS:
644
+ (units_dir / name).unlink(missing_ok=True)
645
+
646
+ subprocess.run(["systemctl", "--user", "daemon-reload"], check=False)
647
+ click.echo("=== Timers removed. ===")
648
+
649
+
650
+ @ws_group.command(name="doctor")
651
+ @click.option(
652
+ "--config",
653
+ "config_path",
654
+ default=str(DEFAULT_CONFIG_PATH),
655
+ show_default=True,
656
+ )
657
+ @click.option("--remote", default=DEFAULT_REMOTE, show_default=True)
658
+ @click.option(
659
+ "--skip-network",
660
+ is_flag=True,
661
+ help="Skip GDrive/network checks (offline mode)",
662
+ )
663
+ def doctor_cmd(config_path: str, remote: str, skip_network: bool) -> None:
664
+ """Full health check: deps, configs, timers, GDrive, repos, GPG."""
665
+ home = Path.home()
666
+ OK = click.style("✓", fg="green", bold=True)
667
+ WARN = click.style("⚠", fg="yellow", bold=True)
668
+ FAIL = click.style("✗", fg="red", bold=True)
669
+ DIM = lambda s: click.style(s, dim=True)
670
+
671
+ summary = {"ok": 0, "warn": 0, "fail": 0}
672
+
673
+ def status(level: str, msg: str, hint: str = "") -> None:
674
+ summary[level] += 1
675
+ mark = {"ok": OK, "warn": WARN, "fail": FAIL}[level]
676
+ click.echo(f" {mark} {msg}")
677
+ if hint:
678
+ click.echo(f" {DIM(hint)}")
679
+
680
+ def header(text: str) -> None:
681
+ click.echo()
682
+ click.echo(click.style(f"▶ {text}", fg="cyan", bold=True))
683
+ click.echo(click.style("─" * 60, dim=True))
684
+
685
+ # ─── Dependências obrigatórias ─────────────────────────────────
686
+ header("Dependências obrigatórias")
687
+ required_deps = {
688
+ "git": "git --version",
689
+ "rclone": "rclone --version",
690
+ "python3": "python3 --version",
691
+ "make": "make --version",
692
+ "gpg": "gpg --version",
693
+ "git-crypt": "git-crypt --version",
694
+ "systemctl": "systemctl --version",
695
+ }
696
+ for cmd, ver_cmd in required_deps.items():
697
+ if shutil.which(cmd):
698
+ try:
699
+ result = subprocess.run(
700
+ ver_cmd.split(), capture_output=True, text=True, timeout=3
701
+ )
702
+ version = result.stdout.splitlines()[0] if result.stdout else "?"
703
+ status("ok", f"{cmd:<15} {DIM(version[:50])}")
704
+ except (subprocess.SubprocessError, OSError):
705
+ status("ok", f"{cmd:<15} {DIM('instalado')}")
706
+ else:
707
+ status(
708
+ "fail",
709
+ f"{cmd:<15} NÃO instalado",
710
+ f"sudo apt install -y {cmd}",
711
+ )
712
+
713
+ # ─── Dependências opcionais ────────────────────────────────────
714
+ header("Dependências opcionais")
715
+ optional_deps = {
716
+ "mise": ("mise --version", "curl -fsSL https://mise.run | sh"),
717
+ "notify-send": ("notify-send --version", "sudo apt install -y libnotify-bin"),
718
+ "whiptail": ("whiptail --version", "sudo apt install -y whiptail"),
719
+ "jq": ("jq --version", "sudo apt install -y jq"),
720
+ "yq": ("yq --version", "sudo apt install -y yq"),
721
+ "curl": ("curl --version", "sudo apt install -y curl"),
722
+ "zellij": ("zellij --version", "ws-bootstrap.sh install_zellij"),
723
+ "alacritty": ("alacritty --version", "sudo apt install -y alacritty"),
724
+ "devopscli": ("devopscli --version", "make -C ~/bin/devops-cli setup"),
725
+ }
726
+ for cmd, (ver_cmd, hint) in optional_deps.items():
727
+ if shutil.which(cmd):
728
+ try:
729
+ result = subprocess.run(
730
+ ver_cmd.split(), capture_output=True, text=True, timeout=3
731
+ )
732
+ version = (result.stdout + result.stderr).splitlines()
733
+ version = version[0] if version else "?"
734
+ status("ok", f"{cmd:<15} {DIM(version[:50])}")
735
+ except (subprocess.SubprocessError, OSError):
736
+ status("ok", f"{cmd:<15} {DIM('instalado')}")
737
+ else:
738
+ status("warn", f"{cmd:<15} ausente", hint)
739
+
740
+ # ─── Python deps ───────────────────────────────────────────────
741
+ header("Python deps")
742
+ for module in ("yaml", "click"):
743
+ try:
744
+ __import__(module)
745
+ status("ok", f"python3 -m {module:<10} {DIM('importável')}")
746
+ except ImportError:
747
+ status(
748
+ "fail",
749
+ f"python3 -m {module:<10} NÃO importável",
750
+ f"pip install {module}" if module != "yaml" else "pip install pyyaml",
751
+ )
752
+
753
+ # ─── Schedule (systemd vs cron) ─────────────────────────────────
754
+ header("Schedule")
755
+ timers_active = False
756
+ for unit in ("ws-push.timer", "ws-sync.timer"):
757
+ active = subprocess.run(
758
+ ["systemctl", "--user", "is-active", unit],
759
+ capture_output=True, text=True,
760
+ )
761
+ enabled = subprocess.run(
762
+ ["systemctl", "--user", "is-enabled", unit],
763
+ capture_output=True, text=True,
764
+ )
765
+ a = active.stdout.strip()
766
+ e = enabled.stdout.strip()
767
+ if a == "active":
768
+ status("ok", f"{unit:<20} {DIM(f'(active, {e})')}")
769
+ timers_active = True
770
+ else:
771
+ status(
772
+ "fail",
773
+ f"{unit:<20} ({a}, {e})",
774
+ "rode: devopscli ws install-timers",
775
+ )
776
+
777
+ if timers_active:
778
+ timers = subprocess.run(
779
+ ["systemctl", "--user", "list-timers", "ws-*.timer", "--no-pager"],
780
+ capture_output=True, text=True,
781
+ )
782
+ for line in timers.stdout.splitlines()[1:3]:
783
+ if line.strip() and "ws-" in line:
784
+ click.echo(f" {DIM(line.strip())}")
785
+
786
+ # Crontab leftovers
787
+ cron = subprocess.run(["crontab", "-l"], capture_output=True, text=True)
788
+ active_cron_lines = [
789
+ line for line in cron.stdout.splitlines()
790
+ if "devopscli ws" in line and not line.lstrip().startswith("#")
791
+ ]
792
+ if active_cron_lines:
793
+ status(
794
+ "warn",
795
+ f"crontab tem {len(active_cron_lines)} linha(s) devopscli ativa(s)",
796
+ "rode: devopscli ws install-timers --remove-cron",
797
+ )
798
+ for line in active_cron_lines:
799
+ click.echo(f" {DIM(line[:80])}")
800
+ else:
801
+ status("ok", "crontab sem linhas devopscli ativas")
802
+
803
+ # ─── Last sync result ───────────────────────────────────────────
804
+ header("Última execução do sync")
805
+ journal = subprocess.run(
806
+ ["journalctl", "--user", "-u", "ws-sync.service",
807
+ "--since", "24 hours ago", "-o", "short-iso", "--no-pager"],
808
+ capture_output=True, text=True,
809
+ )
810
+ if not journal.stdout.strip():
811
+ click.echo(f" {DIM('sem logs nas últimas 24h')}")
812
+ else:
813
+ lines = journal.stdout.splitlines()
814
+ last_started = None
815
+ last_finished = None
816
+ had_error = False
817
+ for line in lines:
818
+ if "Started ws-sync.service" in line:
819
+ last_started = line.split()[0] if line.split() else None
820
+ elif "Finished ws-sync.service" in line:
821
+ last_finished = line.split()[0] if line.split() else None
822
+ elif "[WARN]" in line or "ERROR" in line:
823
+ had_error = True
824
+ if last_finished:
825
+ if had_error:
826
+ status(
827
+ "warn",
828
+ f"última execução: {last_finished}",
829
+ "warning/error nos logs — journalctl --user -u ws-sync.service",
830
+ )
831
+ else:
832
+ status("ok", f"última execução: {last_finished}")
833
+ elif last_started:
834
+ status("warn", f"ainda rodando? última started: {last_started}")
835
+ else:
836
+ click.echo(f" {DIM('sem execuções completas detectadas')}")
837
+
838
+ # ─── GDrive ─────────────────────────────────────────────────────
839
+ header("GDrive (rclone)")
840
+ if skip_network:
841
+ click.echo(f" {DIM('pulado (--skip-network)')}")
842
+ elif not shutil.which("rclone"):
843
+ status("fail", "rclone não instalado", "sudo apt install -y rclone")
844
+ else:
845
+ remotes = subprocess.run(
846
+ ["rclone", "listremotes"], capture_output=True, text=True
847
+ )
848
+ rclone_remote = remote.split(":")[0] + ":"
849
+ if rclone_remote not in remotes.stdout:
850
+ status(
851
+ "fail",
852
+ f"remote {rclone_remote} não configurado",
853
+ "rode: rclone config",
854
+ )
855
+ else:
856
+ status("ok", f"remote configurado: {rclone_remote}")
857
+ try:
858
+ ls = subprocess.run(
859
+ ["rclone", "lsf", remote, "--max-depth", "1"],
860
+ capture_output=True, text=True, timeout=30,
861
+ )
862
+ if ls.returncode == 0:
863
+ count = len([l for l in ls.stdout.splitlines() if l.strip()])
864
+ status("ok", f"acessível ({count} entradas em {remote}/)")
865
+ else:
866
+ status(
867
+ "fail",
868
+ f"erro ao listar: {ls.stderr.strip()[:80]}",
869
+ )
870
+ except subprocess.TimeoutExpired:
871
+ status(
872
+ "warn",
873
+ f"timeout (>30s) listando {remote}",
874
+ "verifique conexão com Google Drive",
875
+ )
876
+
877
+ # ─── GPG ──────────────────────────────────────────────────────
878
+ header("GPG")
879
+ # Secret key local
880
+ if shutil.which("gpg"):
881
+ result = subprocess.run(
882
+ ["gpg", "--list-secret-keys", "--with-colons"],
883
+ capture_output=True, text=True,
884
+ )
885
+ key_count = sum(1 for line in result.stdout.splitlines() if line.startswith("sec:"))
886
+ if key_count > 0:
887
+ status("ok", f"GPG secret keys importadas: {key_count}")
888
+ else:
889
+ status(
890
+ "warn",
891
+ "Nenhuma GPG secret key importada",
892
+ "rode: devopscli ws gpg-restore",
893
+ )
894
+ # Backup no GDrive
895
+ if not skip_network and shutil.which("rclone"):
896
+ try:
897
+ gpg_check = subprocess.run(
898
+ ["rclone", "lsf", f"{remote}/gpg/", "--max-depth", "1"],
899
+ capture_output=True, text=True, timeout=30,
900
+ )
901
+ if gpg_check.returncode == 0 and "gpg-backup.gpg" in gpg_check.stdout:
902
+ status("ok", f"gpg-backup.gpg existe em {remote}/gpg/")
903
+ else:
904
+ status(
905
+ "fail",
906
+ f"gpg-backup.gpg NÃO encontrado em {remote}/gpg/",
907
+ "devopscli ws gpg-backup (CRÍTICO para migração)",
908
+ )
909
+ except subprocess.TimeoutExpired:
910
+ status("warn", f"timeout checando {remote}/gpg/")
911
+
912
+ # ─── Git-crypt no workspace-personal ───────────────────────────
913
+ header("git-crypt (workspace-personal)")
914
+ wp = home / "workspace-personal"
915
+ if not (wp / ".git").exists():
916
+ status("warn", "workspace-personal não clonado")
917
+ elif not shutil.which("git-crypt"):
918
+ status("fail", "git-crypt não instalado", "sudo apt install -y git-crypt")
919
+ else:
920
+ result = subprocess.run(
921
+ ["git-crypt", "status", "-e"],
922
+ cwd=str(wp), capture_output=True, text=True,
923
+ )
924
+ # check filter setup
925
+ check = subprocess.run(
926
+ ["git", "-C", str(wp), "config", "--get", "filter.git-crypt.smudge"],
927
+ capture_output=True, text=True,
928
+ )
929
+ if check.returncode == 0 and check.stdout.strip():
930
+ # Check if .env is actually decrypted (binary check)
931
+ env_file = wp / ".env"
932
+ if env_file.exists():
933
+ first_bytes = env_file.read_bytes()[:10]
934
+ if first_bytes.startswith(b"\x00GITCRYPT"):
935
+ status(
936
+ "fail",
937
+ "git-crypt LOCKED (.env ainda criptografado)",
938
+ "cd ~/workspace-personal && git-crypt unlock",
939
+ )
940
+ else:
941
+ status("ok", "git-crypt unlocked (.env legível)")
942
+ else:
943
+ status("warn", ".env não existe no workspace-personal")
944
+ else:
945
+ status(
946
+ "fail",
947
+ "git-crypt filter não configurado",
948
+ "cd ~/workspace-personal && git-crypt unlock",
949
+ )
950
+
951
+ # ─── Repos sync ────────────────────────────────────────────────
952
+ header("Git repos sincronizados")
953
+ try:
954
+ cfg = _load_workspace_config(Path(config_path).expanduser())
955
+ except SystemExit:
956
+ cfg = {}
957
+ all_repos = (cfg.get("ai_workspaces") or []) + (cfg.get("repos_sync") or [])
958
+ if not all_repos:
959
+ click.echo(f" {DIM('nenhum repo configurado')}")
960
+ else:
961
+ for r in all_repos:
962
+ name = r.get("name", "?")
963
+ path = Path(str(r.get("path", ""))).expanduser()
964
+ branch = r.get("branch", "main")
965
+ if not (path / ".git").exists():
966
+ status(
967
+ "warn",
968
+ f"{name:<32} NÃO clonado",
969
+ f"git clone {r.get('clone_url', '?')} {path}",
970
+ )
971
+ continue
972
+ git_status = subprocess.run(
973
+ ["git", "-C", str(path), "status", "--porcelain"],
974
+ capture_output=True, text=True,
975
+ )
976
+ pending = len([l for l in git_status.stdout.splitlines() if l.strip()])
977
+ ab = subprocess.run(
978
+ ["git", "-C", str(path), "rev-list", "--left-right", "--count",
979
+ f"origin/{branch}...HEAD"],
980
+ capture_output=True, text=True,
981
+ )
982
+ ab_parts = ab.stdout.strip().split() if ab.returncode == 0 else ["?", "?"]
983
+ behind, ahead = (ab_parts + ["?", "?"])[:2]
984
+ if pending == 0 and behind == "0" and ahead == "0":
985
+ status("ok", f"{name:<32} {DIM(f'clean (branch: {branch})')}")
986
+ else:
987
+ marks = []
988
+ if pending: marks.append(f"{pending} mod")
989
+ if behind != "0": marks.append(f"{behind} behind")
990
+ if ahead != "0": marks.append(f"{ahead} ahead")
991
+ status("warn", f"{name:<32} ({', '.join(marks)})")
992
+
993
+ # ─── Configs críticos locais ────────────────────────────────────
994
+ header("Configs críticos locais")
995
+ checks = [
996
+ (home / ".config" / "rclone" / "rclone.conf", "rclone config", True),
997
+ (home / ".config" / "mise" / "config.toml", "mise config", False),
998
+ (home / ".zshrc", ".zshrc", True),
999
+ (home / ".bashrc", ".bashrc", False),
1000
+ (home / ".ssh" / "id_ed25519", "SSH key (id_ed25519)", True),
1001
+ (home / "bin" / "config.yaml", "workspace config.yaml", True),
1002
+ (home / ".config" / "alacritty" / "alacritty.toml", "alacritty.toml", False),
1003
+ (home / ".config" / "zellij" / "config.kdl", "zellij config.kdl", False),
1004
+ (home / ".gnupg", "GPG home (.gnupg)", True),
1005
+ ]
1006
+ for path, label, critical in checks:
1007
+ if path.exists():
1008
+ status("ok", f"{label:<25} {DIM(str(path))}")
1009
+ else:
1010
+ level = "fail" if critical else "warn"
1011
+ status(level, f"{label:<25} ausente em {path}")
1012
+
1013
+ # ─── Summary ───────────────────────────────────────────────────
1014
+ click.echo()
1015
+ click.echo(click.style("─" * 60, dim=True))
1016
+ total = summary["ok"] + summary["warn"] + summary["fail"]
1017
+ ok_s = click.style(f"{summary['ok']} OK", fg="green", bold=True)
1018
+ warn_s = click.style(f"{summary['warn']} warn", fg="yellow", bold=True)
1019
+ fail_s = click.style(f"{summary['fail']} fail", fg="red", bold=True)
1020
+ click.echo(f" Total: {total} | {ok_s} | {warn_s} | {fail_s}")
1021
+ if summary["fail"] > 0:
1022
+ click.echo(click.style("\n ✗ Setup tem problemas críticos. Veja itens com ✗ acima.", fg="red"))
1023
+ sys.exit(1)
1024
+ elif summary["warn"] > 0:
1025
+ click.echo(click.style("\n ⚠ Setup funcional com avisos. Veja itens com ⚠ acima.", fg="yellow"))
1026
+ else:
1027
+ click.echo(click.style("\n ✓ Setup 100% saudável!", fg="green", bold=True))
1028
+ click.echo()
1029
+
1030
+
1031
+ @ws_group.command(name="sync")
1032
+ @click.option("--remote", default=DEFAULT_REMOTE, show_default=True)
1033
+ @click.option(
1034
+ "--config",
1035
+ "config_path",
1036
+ default=str(DEFAULT_CONFIG_PATH),
1037
+ show_default=True,
1038
+ )
1039
+ @click.option("--skip-pull", is_flag=True, help="Skip the initial GDrive pull step")
1040
+ @click.option("--skip-push", is_flag=True, help="Skip the final GDrive push step")
1041
+ def sync_cmd(remote: str, config_path: str, skip_pull: bool, skip_push: bool) -> None:
1042
+ """Full bidirectional sync: pull → git-sync → push.
1043
+
1044
+ Ordem segura: pull traz mudancas do remoto, git-sync resolve repos,
1045
+ push envia estado local. Idempotente — pode rodar a qualquer momento.
1046
+ """
1047
+ click.echo("╔═══════════════════════════════════════════════════════╗")
1048
+ click.echo("║ ws sync — bidirectional workspace sync ║")
1049
+ click.echo("╚═══════════════════════════════════════════════════════╝")
1050
+
1051
+ if not skip_pull:
1052
+ click.echo("\n▶ Stage 1/4: pulling configs from GDrive...")
1053
+ ctx = click.get_current_context()
1054
+ ctx.invoke(pull_cmd, remote=remote, dry_run=False)
1055
+ else:
1056
+ click.echo("\n⊘ Stage 1/4: pull skipped (--skip-pull)")
1057
+
1058
+ config = _load_workspace_config(Path(config_path).expanduser())
1059
+
1060
+ click.echo("\n▶ Stage 2/4: syncing ai_workspaces (git pull/commit/push)...")
1061
+ _sync_repo_list(config.get("ai_workspaces", []), "ai_workspaces")
1062
+
1063
+ click.echo("\n▶ Stage 3/4: syncing repos_sync (git pull/commit/push)...")
1064
+ _sync_repo_list(config.get("repos_sync", []), "repos_sync")
1065
+
1066
+ if not skip_push:
1067
+ click.echo("\n▶ Stage 4/4: pushing configs to GDrive...")
1068
+ ctx = click.get_current_context()
1069
+ ctx.invoke(push_cmd, remote=remote, dry_run=False)
1070
+ else:
1071
+ click.echo("\n⊘ Stage 4/4: push skipped (--skip-push)")
1072
+
1073
+ click.echo("\n╔═══════════════════════════════════════════════════════╗")
1074
+ click.echo("║ ws sync completed ║")
1075
+ click.echo("╚═══════════════════════════════════════════════════════╝")
1076
+
1077
+
1078
+ @ws_group.command(name="gpg-backup")
1079
+ @click.option("--remote", default=DEFAULT_REMOTE, show_default=True)
1080
+ @click.option("--key-id", default=None, help="GPG key ID (default: first secret key)")
1081
+ def gpg_backup_cmd(remote: str, key_id: str | None) -> None:
1082
+ """Export GPG secret key, encrypt with passphrase, send to GDrive."""
1083
+ _require_rclone()
1084
+ if not shutil.which("gpg"):
1085
+ click.echo("[ERRO] gpg nao encontrado no PATH.", err=True)
1086
+ sys.exit(1)
1087
+
1088
+ if not key_id:
1089
+ result = subprocess.run(
1090
+ ["gpg", "--list-secret-keys", "--with-colons"],
1091
+ capture_output=True,
1092
+ text=True,
1093
+ )
1094
+ for line in result.stdout.splitlines():
1095
+ if line.startswith("fpr:"):
1096
+ key_id = line.split(":")[9]
1097
+ break
1098
+ if not key_id:
1099
+ click.echo("[ERRO] Nenhuma chave secreta GPG encontrada.", err=True)
1100
+ sys.exit(1)
1101
+ click.echo(f"[INFO] Usando key fingerprint: {key_id}")
1102
+
1103
+ with tempfile.TemporaryDirectory(prefix="gpg-backup-") as tmpdir:
1104
+ plain = Path(tmpdir) / "gpg-secret.asc"
1105
+ encrypted = Path(tmpdir) / "gpg-backup.gpg"
1106
+
1107
+ click.echo(f"[1/3] Exportando secret key {key_id}...")
1108
+ result = subprocess.run(
1109
+ ["gpg", "--armor", "--export-secret-keys", key_id],
1110
+ capture_output=True,
1111
+ text=True,
1112
+ )
1113
+ if result.returncode != 0:
1114
+ click.echo(f"[ERRO] gpg export falhou: {result.stderr}", err=True)
1115
+ sys.exit(1)
1116
+ plain.write_text(result.stdout)
1117
+
1118
+ click.echo("[2/3] Criptografando com passphrase (digite quando solicitado)...")
1119
+ result = subprocess.run(
1120
+ [
1121
+ "gpg",
1122
+ "--symmetric",
1123
+ "--cipher-algo",
1124
+ "AES256",
1125
+ "--output",
1126
+ str(encrypted),
1127
+ str(plain),
1128
+ ]
1129
+ )
1130
+ plain.unlink(missing_ok=True)
1131
+ if result.returncode != 0 or not encrypted.exists():
1132
+ click.echo("[ERRO] gpg symmetric encrypt falhou.", err=True)
1133
+ sys.exit(1)
1134
+
1135
+ click.echo(f"[3/3] Enviando para {remote}/gpg/...")
1136
+ _copyto(encrypted, f"{remote}/gpg/gpg-backup.gpg", dry_run=False)
1137
+
1138
+ click.echo("")
1139
+ click.echo("=== GPG backup completed! ===")
1140
+ click.echo(f"Restore com: devopscli ws gpg-restore --remote {remote}")
1141
+
1142
+
1143
+ @ws_group.command(name="gpg-restore")
1144
+ @click.option("--remote", default=DEFAULT_REMOTE, show_default=True)
1145
+ def gpg_restore_cmd(remote: str) -> None:
1146
+ """Download encrypted GPG key from GDrive and import into local keyring."""
1147
+ _require_rclone()
1148
+ if not shutil.which("gpg"):
1149
+ click.echo("[ERRO] gpg nao encontrado no PATH.", err=True)
1150
+ sys.exit(1)
1151
+
1152
+ with tempfile.TemporaryDirectory(prefix="gpg-restore-") as tmpdir:
1153
+ encrypted = Path(tmpdir) / "gpg-backup.gpg"
1154
+ decrypted = Path(tmpdir) / "gpg-secret.asc"
1155
+
1156
+ click.echo(f"[1/3] Baixando de {remote}/gpg/...")
1157
+ _copyto(f"{remote}/gpg/gpg-backup.gpg", str(encrypted), dry_run=False)
1158
+ if not encrypted.exists():
1159
+ click.echo("[ERRO] gpg-backup.gpg nao encontrado no remote.", err=True)
1160
+ sys.exit(1)
1161
+
1162
+ click.echo("[2/3] Descriptografando (digite a passphrase)...")
1163
+ result = subprocess.run(
1164
+ ["gpg", "--decrypt", "--output", str(decrypted), str(encrypted)]
1165
+ )
1166
+ if result.returncode != 0 or not decrypted.exists():
1167
+ click.echo("[ERRO] gpg decrypt falhou (passphrase errada?).", err=True)
1168
+ sys.exit(1)
1169
+
1170
+ click.echo("[3/3] Importando no keyring local...")
1171
+ result = subprocess.run(["gpg", "--import", str(decrypted)])
1172
+ if result.returncode != 0:
1173
+ click.echo("[ERRO] gpg import falhou.", err=True)
1174
+ sys.exit(1)
1175
+
1176
+ click.echo("")
1177
+ click.echo("=== GPG restore completed! ===")
1178
+ click.echo(
1179
+ "Note: para git-crypt unlock no workspace-personal: "
1180
+ "cd ~/workspace-personal && git-crypt unlock"
1181
+ )
1182
+
1183
+
1184
+ def _sync_repo_list(items: list[dict], label: str) -> None:
1185
+ if not items:
1186
+ click.echo(f"[INFO] Nenhum {label} definido no config.yaml")
1187
+ return
1188
+
1189
+ for item in items:
1190
+ name = item.get("name") or "(sem nome)"
1191
+ path = Path(str(item.get("path", ""))).expanduser()
1192
+ remote = item.get("remote", "origin")
1193
+ branch = item.get("branch", "main")
1194
+ auto_commit = item.get("auto_commit", False)
1195
+ commit_message = item.get("commit_message")
1196
+
1197
+ if not path.exists() or not (path / ".git").exists():
1198
+ click.echo(f"[skip] {name}: not a git repo at {path}")
1199
+ continue
1200
+
1201
+ click.echo(f"[sync] {name} ({path})")
1202
+ subprocess.run(["git", "-C", str(path), "fetch", remote, "--prune"])
1203
+ if auto_commit:
1204
+ _auto_commit(path, commit_message)
1205
+ subprocess.run(["git", "-C", str(path), "pull", "--rebase", remote, branch])
1206
+ subprocess.run(["git", "-C", str(path), "push", remote, branch])
1207
+
1208
+
1209
+ def _ensure_devopscli(home: Path) -> None:
1210
+ devops_cli_dir = home / "bin" / "devops-cli"
1211
+ if devops_cli_dir.exists():
1212
+ subprocess.run(["git", "-C", str(devops_cli_dir), "pull", "--rebase"])
1213
+ else:
1214
+ clone = subprocess.run(["git", "clone", DEVOPSCLI_GIT_URL, str(devops_cli_dir)])
1215
+ if clone.returncode != 0:
1216
+ click.echo("[WARN] clone SSH falhou, tentando HTTPS...")
1217
+ subprocess.run(
1218
+ ["git", "clone", DEVOPSCLI_GIT_URL_HTTPS, str(devops_cli_dir)]
1219
+ )
1220
+ if devops_cli_dir.exists():
1221
+ subprocess.run(["make", "-C", str(devops_cli_dir), "setup"])
1222
+
1223
+
1224
+ @ws_group.command(name="ai-sync")
1225
+ @click.option(
1226
+ "--config",
1227
+ "config_path",
1228
+ default=str(DEFAULT_CONFIG_PATH),
1229
+ show_default=True,
1230
+ help="Path to workspace config.yaml",
1231
+ )
1232
+ def ai_sync_cmd(config_path: str) -> None:
1233
+ """Sync AI workspaces listed in config.yaml."""
1234
+ config = _load_workspace_config(Path(config_path).expanduser())
1235
+ _sync_repo_list(config.get("ai_workspaces", []), "ai_workspaces")
1236
+
1237
+
1238
+ @ws_group.command(name="repos-sync")
1239
+ @click.option(
1240
+ "--config",
1241
+ "config_path",
1242
+ default=str(DEFAULT_CONFIG_PATH),
1243
+ show_default=True,
1244
+ help="Path to workspace config.yaml",
1245
+ )
1246
+ def repos_sync_cmd(config_path: str) -> None:
1247
+ """Sync repositories listed in config.yaml."""
1248
+ config = _load_workspace_config(Path(config_path).expanduser())
1249
+ _sync_repo_list(config.get("repos_sync", []), "repos_sync")