lnp-devopscli 1.0.0__tar.gz → 1.1.0__tar.gz

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.
Files changed (36) hide show
  1. {lnp_devopscli-1.0.0 → lnp_devopscli-1.1.0}/PKG-INFO +5 -1
  2. {lnp_devopscli-1.0.0 → lnp_devopscli-1.1.0}/pyproject.toml +10 -3
  3. {lnp_devopscli-1.0.0 → lnp_devopscli-1.1.0}/scripts/dc_cli/__init__.py +1 -1
  4. lnp_devopscli-1.1.0/scripts/dc_cli/bw_secrets/__init__.py +22 -0
  5. lnp_devopscli-1.1.0/scripts/dc_cli/bw_secrets/client.py +134 -0
  6. lnp_devopscli-1.1.0/scripts/dc_cli/bw_secrets/mapping.py +74 -0
  7. lnp_devopscli-1.1.0/scripts/dc_cli/bw_secrets/sync.py +236 -0
  8. {lnp_devopscli-1.0.0 → lnp_devopscli-1.1.0}/scripts/dc_cli/groups/bw.py +176 -0
  9. lnp_devopscli-1.1.0/scripts/dc_cli/groups/test.py +581 -0
  10. lnp_devopscli-1.1.0/scripts/dc_cli/groups/ui.py +537 -0
  11. lnp_devopscli-1.1.0/scripts/dc_cli/ui/__init__.py +18 -0
  12. lnp_devopscli-1.1.0/scripts/dc_cli/ui/dashboard.py +371 -0
  13. lnp_devopscli-1.1.0/scripts/dc_cli/ui/events.py +88 -0
  14. lnp_devopscli-1.1.0/scripts/dc_cli/ui/themes.py +22 -0
  15. lnp_devopscli-1.1.0/scripts/dc_cli/web/__init__.py +5 -0
  16. lnp_devopscli-1.1.0/scripts/dc_cli/web/server.py +196 -0
  17. lnp_devopscli-1.1.0/scripts/dc_cli/web/static/app.js +417 -0
  18. lnp_devopscli-1.1.0/scripts/dc_cli/web/static/index.html +109 -0
  19. lnp_devopscli-1.1.0/scripts/dc_cli/web/static/style.css +304 -0
  20. {lnp_devopscli-1.0.0 → lnp_devopscli-1.1.0}/scripts/lnp_devopscli.egg-info/PKG-INFO +5 -1
  21. lnp_devopscli-1.1.0/scripts/lnp_devopscli.egg-info/SOURCES.txt +32 -0
  22. lnp_devopscli-1.1.0/scripts/lnp_devopscli.egg-info/requires.txt +7 -0
  23. lnp_devopscli-1.0.0/scripts/lnp_devopscli.egg-info/SOURCES.txt +0 -17
  24. lnp_devopscli-1.0.0/scripts/lnp_devopscli.egg-info/requires.txt +0 -3
  25. {lnp_devopscli-1.0.0 → lnp_devopscli-1.1.0}/README.md +0 -0
  26. {lnp_devopscli-1.0.0 → lnp_devopscli-1.1.0}/scripts/dc_cli/groups/__init__.py +0 -0
  27. {lnp_devopscli-1.0.0 → lnp_devopscli-1.1.0}/scripts/dc_cli/groups/gl.py +0 -0
  28. {lnp_devopscli-1.0.0 → lnp_devopscli-1.1.0}/scripts/dc_cli/groups/ws.py +0 -0
  29. {lnp_devopscli-1.0.0 → lnp_devopscli-1.1.0}/scripts/dc_cli/main.py +0 -0
  30. {lnp_devopscli-1.0.0 → lnp_devopscli-1.1.0}/scripts/dc_cli/utils/__init__.py +0 -0
  31. {lnp_devopscli-1.0.0 → lnp_devopscli-1.1.0}/scripts/dc_cli/utils/config.py +0 -0
  32. {lnp_devopscli-1.0.0 → lnp_devopscli-1.1.0}/scripts/dc_cli/utils/gitlab.py +0 -0
  33. {lnp_devopscli-1.0.0 → lnp_devopscli-1.1.0}/scripts/lnp_devopscli.egg-info/dependency_links.txt +0 -0
  34. {lnp_devopscli-1.0.0 → lnp_devopscli-1.1.0}/scripts/lnp_devopscli.egg-info/entry_points.txt +0 -0
  35. {lnp_devopscli-1.0.0 → lnp_devopscli-1.1.0}/scripts/lnp_devopscli.egg-info/top_level.txt +0 -0
  36. {lnp_devopscli-1.0.0 → lnp_devopscli-1.1.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lnp-devopscli
3
- Version: 1.0.0
3
+ Version: 1.1.0
4
4
  Summary: DevOps CLI — workspace sync (GDrive, git, GPG, systemd timers) + GitLab/Bitwarden tooling
5
5
  Author-email: Lucas Neves Pires <npires.lucas@gmail.com>
6
6
  License: MIT
@@ -24,6 +24,10 @@ Description-Content-Type: text/markdown
24
24
  Requires-Dist: click>=8.0
25
25
  Requires-Dist: pyyaml>=6.0
26
26
  Requires-Dist: requests>=2.28
27
+ Requires-Dist: rich>=13.0
28
+ Requires-Dist: fastapi>=0.115
29
+ Requires-Dist: uvicorn[standard]>=0.32
30
+ Requires-Dist: sse-starlette>=2.1
27
31
 
28
32
  # lnp-devopscli
29
33
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "lnp-devopscli"
7
- version = "1.0.0"
7
+ version = "1.1.0"
8
8
  description = "DevOps CLI — workspace sync (GDrive, git, GPG, systemd timers) + GitLab/Bitwarden tooling"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -30,8 +30,17 @@ dependencies = [
30
30
  "click>=8.0",
31
31
  "pyyaml>=6.0",
32
32
  "requests>=2.28",
33
+ "rich>=13.0",
34
+ "fastapi>=0.115",
35
+ "uvicorn[standard]>=0.32",
36
+ "sse-starlette>=2.1",
33
37
  ]
34
38
 
39
+ [tool.setuptools.package-data]
40
+ "dc_cli" = ["*.toml", "*.yaml", "*.yml"]
41
+ "dc_cli.installers.scripts" = ["*.sh"]
42
+ "dc_cli.web.static" = ["*.html", "*.css", "*.js"]
43
+
35
44
  [project.urls]
36
45
  Homepage = "https://gitlab.com/lnp-consulting-ti/devops/devops-cli"
37
46
  Repository = "https://gitlab.com/lnp-consulting-ti/devops/devops-cli"
@@ -49,5 +58,3 @@ where = ["scripts"]
49
58
  include = ["dc_cli*"]
50
59
  exclude = ["tests*", "*.tests*"]
51
60
 
52
- [tool.setuptools.package-data]
53
- "dc_cli" = ["*.toml", "*.yaml", "*.yml"]
@@ -1,3 +1,3 @@
1
1
  """DevOps CLI - LNP Consulting TI."""
2
2
 
3
- __version__ = "1.0.0"
3
+ __version__ = "1.1.0"
@@ -0,0 +1,22 @@
1
+ """Bitwarden Secrets Manager integration for machine secrets.
2
+
3
+ Provides bidirectional sync between local files (~/.ssh, ~/.config/rclone, etc)
4
+ and BWS project `devopscli-machine-secrets`. Replaces leaking via GDrive sync.
5
+ """
6
+
7
+ from .mapping import SECRETS, SecretSpec
8
+ from .client import BwsClient, BwsError, BwsSecret
9
+ from .sync import sync_all, migrate_all, sync_one, SyncAction, SyncResult
10
+
11
+ __all__ = [
12
+ "SECRETS",
13
+ "SecretSpec",
14
+ "BwsClient",
15
+ "BwsError",
16
+ "BwsSecret",
17
+ "sync_all",
18
+ "sync_one",
19
+ "migrate_all",
20
+ "SyncAction",
21
+ "SyncResult",
22
+ ]
@@ -0,0 +1,134 @@
1
+ """Thin wrapper around the `bws` CLI for project/secret CRUD."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import shutil
8
+ import subprocess
9
+ from dataclasses import dataclass
10
+ from typing import Any
11
+
12
+
13
+ class BwsError(RuntimeError):
14
+ pass
15
+
16
+
17
+ @dataclass
18
+ class BwsSecret:
19
+ id: str
20
+ key: str
21
+ value: str
22
+ note: str
23
+ project_id: str
24
+ revision_date: str
25
+
26
+ @classmethod
27
+ def from_api(cls, data: dict[str, Any]) -> "BwsSecret":
28
+ return cls(
29
+ id=data.get("id", ""),
30
+ key=data.get("key", ""),
31
+ value=data.get("value", ""),
32
+ note=data.get("note") or "",
33
+ project_id=data.get("projectId", ""),
34
+ revision_date=data.get("revisionDate", ""),
35
+ )
36
+
37
+
38
+ class BwsClient:
39
+ """Wrap the bws binary. Auto-detects BWS_ACCESS_TOKEN from env."""
40
+
41
+ def __init__(self, access_token: str | None = None):
42
+ self.access_token = access_token or os.environ.get(
43
+ "BWS_ACCESS_TOKEN"
44
+ ) or os.environ.get("BW_ACCESS_TOKEN")
45
+ if not self.access_token:
46
+ raise BwsError(
47
+ "BWS_ACCESS_TOKEN nao definido. Exporte em ~/.zshrc."
48
+ )
49
+ if not shutil.which("bws"):
50
+ raise BwsError(
51
+ "bws CLI nao instalado. Veja: "
52
+ "https://bitwarden.com/help/secrets-manager-cli/"
53
+ )
54
+
55
+ def _run(self, args: list[str], timeout: int = 30) -> dict:
56
+ cmd = ["bws", "--access-token", self.access_token, "--output", "json"] + args
57
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
58
+ if result.returncode != 0:
59
+ raise BwsError(
60
+ f"bws {' '.join(args)} falhou: {result.stderr.strip()[:200]}"
61
+ )
62
+ if not result.stdout.strip():
63
+ return {}
64
+ try:
65
+ return json.loads(result.stdout)
66
+ except json.JSONDecodeError as e:
67
+ raise BwsError(f"resposta inválida do bws: {e}") from None
68
+
69
+ # ─── Projects ──────────────────────────────────────────────────
70
+
71
+ def list_projects(self) -> list[dict]:
72
+ return self._run(["project", "list"]) # type: ignore[return-value]
73
+
74
+ def get_project_by_name(self, name: str) -> dict | None:
75
+ for p in self.list_projects():
76
+ if p.get("name") == name:
77
+ return p
78
+ return None
79
+
80
+ def ensure_project(self, name: str) -> str:
81
+ """Get or create project, return its ID."""
82
+ existing = self.get_project_by_name(name)
83
+ if existing:
84
+ return existing["id"]
85
+ created = self._run(["project", "create", name])
86
+ return created["id"]
87
+
88
+ # ─── Secrets ───────────────────────────────────────────────────
89
+
90
+ def list_secrets(self, project_id: str | None = None) -> list[BwsSecret]:
91
+ raw = self._run(["secret", "list"])
92
+ if not isinstance(raw, list):
93
+ return []
94
+ secrets = [BwsSecret.from_api(s) for s in raw]
95
+ if project_id:
96
+ secrets = [s for s in secrets if s.project_id == project_id]
97
+ return secrets
98
+
99
+ def find_secret(self, key: str, project_id: str) -> BwsSecret | None:
100
+ for s in self.list_secrets(project_id):
101
+ if s.key == key:
102
+ return s
103
+ return None
104
+
105
+ def create_secret(
106
+ self, key: str, value: str, project_id: str, note: str = ""
107
+ ) -> BwsSecret:
108
+ args = ["secret", "create", key, value, project_id]
109
+ if note:
110
+ args += ["--note", note]
111
+ return BwsSecret.from_api(self._run(args))
112
+
113
+ def update_secret(
114
+ self,
115
+ secret_id: str,
116
+ project_id: str,
117
+ key: str | None = None,
118
+ value: str | None = None,
119
+ note: str | None = None,
120
+ ) -> BwsSecret:
121
+ args = ["secret", "edit", secret_id]
122
+ # bws edit aceita --key, --value, --note, --project-id
123
+ if key is not None:
124
+ args += ["--key", key]
125
+ if value is not None:
126
+ args += ["--value", value]
127
+ if note is not None:
128
+ args += ["--note", note]
129
+ if project_id:
130
+ args += ["--project-id", project_id]
131
+ return BwsSecret.from_api(self._run(args))
132
+
133
+ def delete_secret(self, secret_id: str) -> None:
134
+ self._run(["secret", "delete", secret_id])
@@ -0,0 +1,74 @@
1
+ """Specification of machine secrets stored in BWS."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+
9
+
10
+ DEFAULT_PROJECT_NAME = "devopscli-machine-secrets"
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class SecretSpec:
15
+ """One machine secret managed by devopscli bw."""
16
+
17
+ name: str
18
+ path: str
19
+ file_mode: int = 0o600
20
+ dir_mode: int = 0o700
21
+ optional: bool = True
22
+ category: str = "misc"
23
+ binary: bool = False
24
+
25
+ def local_path(self) -> Path:
26
+ return Path(os.path.expanduser(self.path))
27
+
28
+ @property
29
+ def parent(self) -> Path:
30
+ return self.local_path().parent
31
+
32
+
33
+ SECRETS: tuple[SecretSpec, ...] = (
34
+ SecretSpec(name="rclone_conf", path="~/.config/rclone/rclone.conf",
35
+ file_mode=0o600, dir_mode=0o700, category="rclone"),
36
+ SecretSpec(name="ssh_config", path="~/.ssh/config",
37
+ file_mode=0o600, category="ssh"),
38
+ SecretSpec(name="ssh_known_hosts", path="~/.ssh/known_hosts",
39
+ file_mode=0o644, category="ssh"),
40
+ SecretSpec(name="ssh_id_rsa", path="~/.ssh/id_rsa",
41
+ file_mode=0o600, category="ssh"),
42
+ SecretSpec(name="ssh_id_rsa_pub", path="~/.ssh/id_rsa.pub",
43
+ file_mode=0o644, category="ssh"),
44
+ SecretSpec(name="ssh_id_rsa_builders", path="~/.ssh/id_rsa_builders",
45
+ file_mode=0o600, category="ssh"),
46
+ SecretSpec(name="ssh_id_rsa_builders_pub", path="~/.ssh/id_rsa_builders.pub",
47
+ file_mode=0o644, category="ssh"),
48
+ SecretSpec(name="ssh_id_rsa_git_hml", path="~/.ssh/id_rsa_git_hml",
49
+ file_mode=0o600, category="ssh"),
50
+ SecretSpec(name="ssh_id_rsa_git_hml_pub", path="~/.ssh/id_rsa_git_hml.pub",
51
+ file_mode=0o644, category="ssh"),
52
+ SecretSpec(name="ssh_id_ed25519", path="~/.ssh/id_ed25519",
53
+ file_mode=0o600, category="ssh"),
54
+ SecretSpec(name="ssh_id_ed25519_pub", path="~/.ssh/id_ed25519.pub",
55
+ file_mode=0o644, category="ssh"),
56
+ SecretSpec(name="ssh_desktop_key", path="~/.ssh/desktop_key",
57
+ file_mode=0o600, category="ssh"),
58
+ SecretSpec(name="ssh_desktop_key_pub", path="~/.ssh/desktop_key.pub",
59
+ file_mode=0o644, category="ssh"),
60
+ )
61
+
62
+
63
+ def secrets_by_category() -> dict[str, list[SecretSpec]]:
64
+ result: dict[str, list[SecretSpec]] = {}
65
+ for s in SECRETS:
66
+ result.setdefault(s.category, []).append(s)
67
+ return result
68
+
69
+
70
+ def find_secret(name: str) -> SecretSpec | None:
71
+ for s in SECRETS:
72
+ if s.name == name:
73
+ return s
74
+ return None
@@ -0,0 +1,236 @@
1
+ """Bidirectional sync logic with hash-based idempotency."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import hashlib
7
+ import json
8
+ from dataclasses import dataclass
9
+ from enum import Enum
10
+ from pathlib import Path
11
+
12
+ from .client import BwsClient, BwsError, BwsSecret
13
+ from .mapping import SECRETS, SecretSpec
14
+
15
+
16
+ class SyncAction(str, Enum):
17
+ UNCHANGED = "unchanged"
18
+ PULLED = "pulled"
19
+ PUSHED = "pushed"
20
+ SKIPPED_LOCAL_MISSING = "skipped_local_missing"
21
+ SKIPPED_REMOTE_MISSING = "skipped_remote_missing"
22
+ CREATED_REMOTE = "created_remote"
23
+ CONFLICT = "conflict"
24
+ ERROR = "error"
25
+
26
+
27
+ @dataclass
28
+ class SyncResult:
29
+ spec: SecretSpec
30
+ action: SyncAction
31
+ detail: str = ""
32
+
33
+
34
+ def _sha256_text(s: str) -> str:
35
+ return hashlib.sha256(s.encode("utf-8")).hexdigest()
36
+
37
+
38
+ def _read_local(spec: SecretSpec) -> str | None:
39
+ path = spec.local_path()
40
+ if not path.exists():
41
+ return None
42
+ try:
43
+ if spec.binary:
44
+ return base64.b64encode(path.read_bytes()).decode("ascii")
45
+ return path.read_text()
46
+ except OSError:
47
+ return None
48
+
49
+
50
+ def _parse_note(note: str) -> dict:
51
+ if not note:
52
+ return {}
53
+ try:
54
+ return json.loads(note)
55
+ except json.JSONDecodeError:
56
+ return {}
57
+
58
+
59
+ def _make_note(local_path: str, content_hash: str) -> str:
60
+ return json.dumps(
61
+ {"path": local_path, "sha256": content_hash, "managed_by": "devopscli"},
62
+ separators=(",", ":"),
63
+ )
64
+
65
+
66
+ def _apply_permissions(spec: SecretSpec) -> None:
67
+ """chmod the file and ensure parent dir has secure mode."""
68
+ parent = spec.parent
69
+ if parent.exists() and parent.is_dir():
70
+ try:
71
+ parent.chmod(spec.dir_mode)
72
+ except OSError:
73
+ pass
74
+ path = spec.local_path()
75
+ if path.exists():
76
+ try:
77
+ path.chmod(spec.file_mode)
78
+ except OSError:
79
+ pass
80
+
81
+
82
+ def _write_local(spec: SecretSpec, content: str) -> None:
83
+ path = spec.local_path()
84
+ path.parent.mkdir(parents=True, exist_ok=True)
85
+ try:
86
+ path.parent.chmod(spec.dir_mode)
87
+ except OSError:
88
+ pass
89
+ if spec.binary:
90
+ path.write_bytes(base64.b64decode(content.encode("ascii")))
91
+ else:
92
+ path.write_text(content)
93
+ _apply_permissions(spec)
94
+
95
+
96
+ def sync_one(
97
+ client: BwsClient,
98
+ spec: SecretSpec,
99
+ project_id: str,
100
+ direction: str = "auto",
101
+ dry_run: bool = False,
102
+ ) -> SyncResult:
103
+ """Sync a single secret.
104
+
105
+ direction:
106
+ - "auto": if both exist, compare hashes; if remote is newer, pull;
107
+ if local is newer, push; if equal, unchanged
108
+ - "push": only upload local → BWS
109
+ - "pull": only download BWS → local
110
+ """
111
+ local_content = _read_local(spec)
112
+ remote_secret = client.find_secret(spec.name, project_id)
113
+
114
+ # ── Caso 1: nada local, nada remoto ─────────────────────────
115
+ if local_content is None and remote_secret is None:
116
+ if spec.optional:
117
+ return SyncResult(spec, SyncAction.SKIPPED_LOCAL_MISSING,
118
+ "nem local nem remoto existem")
119
+ return SyncResult(spec, SyncAction.ERROR, "obrigatorio mas faltando dos dois lados")
120
+
121
+ # ── Caso 2: só remoto existe (pull) ─────────────────────────
122
+ if local_content is None and remote_secret is not None:
123
+ if direction == "push":
124
+ return SyncResult(spec, SyncAction.SKIPPED_LOCAL_MISSING,
125
+ "push solicitado mas arquivo local nao existe")
126
+ if dry_run:
127
+ return SyncResult(spec, SyncAction.PULLED,
128
+ f"[dry-run] restauraria {spec.local_path()}")
129
+ _write_local(spec, remote_secret.value)
130
+ return SyncResult(spec, SyncAction.PULLED,
131
+ f"restaurado em {spec.local_path()}")
132
+
133
+ # ── Caso 3: só local existe (push/create) ───────────────────
134
+ if local_content is not None and remote_secret is None:
135
+ if direction == "pull":
136
+ return SyncResult(spec, SyncAction.SKIPPED_REMOTE_MISSING,
137
+ "pull solicitado mas secret remoto nao existe")
138
+ if dry_run:
139
+ return SyncResult(spec, SyncAction.CREATED_REMOTE,
140
+ f"[dry-run] criaria secret {spec.name}")
141
+ h = _sha256_text(local_content)
142
+ note = _make_note(spec.path, h)
143
+ client.create_secret(spec.name, local_content, project_id, note=note)
144
+ return SyncResult(spec, SyncAction.CREATED_REMOTE,
145
+ f"criado no BWS (hash {h[:8]})")
146
+
147
+ # ── Caso 4: ambos existem ───────────────────────────────────
148
+ assert local_content is not None and remote_secret is not None
149
+ local_hash = _sha256_text(local_content)
150
+ remote_meta = _parse_note(remote_secret.note)
151
+ remote_hash = remote_meta.get("sha256", "")
152
+
153
+ # Se hashes batem, nada a fazer
154
+ if local_hash == remote_hash and local_content == remote_secret.value:
155
+ return SyncResult(spec, SyncAction.UNCHANGED, f"hash {local_hash[:8]}")
156
+
157
+ # Conteúdo difere — decide direção
158
+ if direction == "push":
159
+ target = "push"
160
+ elif direction == "pull":
161
+ target = "pull"
162
+ else:
163
+ # auto: prefere remoto se hash do BWS bate com conteúdo (significa
164
+ # foi alguem com revisao mais nova). Se hashes nao batem na nota,
165
+ # local ganha (fonte de verdade do dispositivo atual).
166
+ if remote_secret.value != local_content:
167
+ # Diferenca real. Local ganha por padrao (push)
168
+ target = "push"
169
+ else:
170
+ target = "pull"
171
+
172
+ if target == "push":
173
+ if dry_run:
174
+ return SyncResult(spec, SyncAction.PUSHED,
175
+ f"[dry-run] sobrescreveria remoto (local hash {local_hash[:8]})")
176
+ note = _make_note(spec.path, local_hash)
177
+ client.update_secret(
178
+ remote_secret.id,
179
+ project_id=project_id,
180
+ value=local_content,
181
+ note=note,
182
+ )
183
+ return SyncResult(spec, SyncAction.PUSHED,
184
+ f"local → BWS (hash {local_hash[:8]})")
185
+ else:
186
+ if dry_run:
187
+ return SyncResult(spec, SyncAction.PULLED,
188
+ f"[dry-run] sobrescreveria local")
189
+ _write_local(spec, remote_secret.value)
190
+ return SyncResult(spec, SyncAction.PULLED,
191
+ f"BWS → local (hash {remote_hash[:8]})")
192
+
193
+
194
+ def sync_all(
195
+ client: BwsClient,
196
+ project_id: str,
197
+ direction: str = "auto",
198
+ dry_run: bool = False,
199
+ only: list[str] | None = None,
200
+ category: str | None = None,
201
+ on_result=None,
202
+ ) -> list[SyncResult]:
203
+ """Sync all secrets matching filters.
204
+
205
+ on_result: optional callback(result) for live progress.
206
+ """
207
+ results = []
208
+ for spec in SECRETS:
209
+ if only and spec.name not in only:
210
+ continue
211
+ if category and spec.category != category:
212
+ continue
213
+ try:
214
+ r = sync_one(client, spec, project_id, direction=direction, dry_run=dry_run)
215
+ except (BwsError, OSError) as e:
216
+ r = SyncResult(spec, SyncAction.ERROR, str(e)[:150])
217
+ results.append(r)
218
+ if on_result:
219
+ on_result(r)
220
+ return results
221
+
222
+
223
+ def migrate_all(
224
+ client: BwsClient,
225
+ project_id: str,
226
+ dry_run: bool = False,
227
+ on_result=None,
228
+ ) -> list[SyncResult]:
229
+ """Upload current state of all local files to BWS (first-time migration)."""
230
+ return sync_all(
231
+ client,
232
+ project_id,
233
+ direction="push",
234
+ dry_run=dry_run,
235
+ on_result=on_result,
236
+ )