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/__init__.py +3 -0
- dc_cli/groups/__init__.py +0 -0
- dc_cli/groups/bw.py +430 -0
- dc_cli/groups/gl.py +300 -0
- dc_cli/groups/ws.py +1249 -0
- dc_cli/main.py +54 -0
- dc_cli/utils/__init__.py +0 -0
- dc_cli/utils/config.py +83 -0
- dc_cli/utils/gitlab.py +165 -0
- lnp_devopscli-1.0.0.dist-info/METADATA +136 -0
- lnp_devopscli-1.0.0.dist-info/RECORD +14 -0
- lnp_devopscli-1.0.0.dist-info/WHEEL +5 -0
- lnp_devopscli-1.0.0.dist-info/entry_points.txt +3 -0
- lnp_devopscli-1.0.0.dist-info/top_level.txt +1 -0
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")
|