op-env-sync 0.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.
@@ -0,0 +1,24 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ .venv/
5
+
6
+ # uv / packaging
7
+ dist/
8
+ build/
9
+ *.egg-info/
10
+
11
+ # Tools
12
+ .pytest_cache/
13
+ .ruff_cache/
14
+
15
+ # IDE / OS
16
+ .DS_Store
17
+ .idea/
18
+ .vscode/
19
+ *.swp
20
+
21
+ # Never commit real env files
22
+ .env
23
+ .env.*
24
+ !.env.op.toml
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sam Wilson
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,142 @@
1
+ Metadata-Version: 2.5
2
+ Name: op-env-sync
3
+ Version: 0.1.0
4
+ Summary: Sync .env files across machines through 1Password, with per-platform and per-host values
5
+ Project-URL: Homepage, https://github.com/swils23/op-env-sync
6
+ Project-URL: Repository, https://github.com/swils23/op-env-sync
7
+ Project-URL: Issues, https://github.com/swils23/op-env-sync/issues
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: 1password,dotenv,env,op,secrets
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3 :: Only
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Programming Language :: Python :: 3.14
20
+ Classifier: Topic :: Software Development :: Build Tools
21
+ Classifier: Topic :: Utilities
22
+ Requires-Python: >=3.11
23
+ Description-Content-Type: text/markdown
24
+
25
+ # op-env-sync
26
+
27
+ Sync a project's `.env` files across machines through 1Password.
28
+
29
+ A small tracked file, `.env.op.toml`, lists which variables travel and how. The values stay in your Git-ignored `.env` locally and in one 1Password item remotely. Values that differ per OS or per machine get their own slots in that item, so pushing from your Linux box never overwrites what your Mac uses.
30
+
31
+ ```console
32
+ $ op-env-sync
33
+ local: /home/sam/repos/swils (platform=wsl, host=sffpc)
34
+ 1Password: my.1password.com (sam@example.com) / default vault / 'swils .env'
35
+
36
+ SECRET_KEY shared in sync
37
+ SENTRY_AUTH_TOKEN shared not in 1Password -> push
38
+ LOCAL_LLM_TUNNEL_NETWORK_MODE wsl in sync [.env.local-llm]
39
+ ```
40
+
41
+ ## Requirements
42
+
43
+ - [`uv`](https://github.com/astral-sh/uv), which provides `uvx` and fetches Python 3.11+ for you
44
+ - The [1Password CLI](https://developer.1password.com/docs/cli/get-started/) (`op`), signed in. Turning on the desktop app integration is easiest. From WSL, op-env-sync also finds the Windows `op.exe` that WinGet installs.
45
+
46
+ op-env-sync has no dependencies beyond the Python standard library.
47
+
48
+ ## Run
49
+
50
+ ```bash
51
+ uvx op-env-sync init # write a starter .env.op.toml from your current .env
52
+ uvx op-env-sync # status: compare local values with 1Password
53
+ uvx op-env-sync push # copy local values up
54
+ uvx op-env-sync pull # copy values down; on a fresh clone this writes .env from scratch
55
+ ```
56
+
57
+ Or install it so `op-env-sync` is on your PATH: `uv tool install op-env-sync`.
58
+
59
+ To give a project a short command for it, add a [just](https://github.com/casey/just) recipe. Then `just env pull` works on a fresh clone:
60
+
61
+ ```just
62
+ # Sync .env files through 1Password per .env.op.toml: just env [status|push|pull] [--yes]
63
+ [positional-arguments]
64
+ @env *args:
65
+ uvx op-env-sync "$@"
66
+ ```
67
+
68
+ `push` and `pull` ask before they overwrite an existing value or create the 1Password item. Pass `--yes` to skip the question (required when nothing can answer it, as in scripts and agents). Values are never printed.
69
+
70
+ ## `.env.op.toml`
71
+
72
+ ```toml
73
+ [onepassword]
74
+ account = "my.1password.com" # optional: which signed-in account (see below)
75
+ vault = "Private" # optional: op's default vault when omitted
76
+ item = "swils .env" # optional: "<project dir> .env" when omitted
77
+
78
+ [variables]
79
+ SECRET_KEY = {} # shared by every machine
80
+ INTERNAL_IPS = { scope = "platform" } # one value per OS
81
+ COMPOSE_PROFILES = { scope = "host" } # one value per machine
82
+ DATABASE_URL = { scope = "local", default = "postgres://localhost/app" } # never synced
83
+ TUNNEL_MODE = { scope = "platform", file = ".env.local-llm", help_text = "bridge on WSL, host on Mac" }
84
+ ```
85
+
86
+ Commit it. It holds layout only, never values. op-env-sync looks for it in the current directory and then each parent, so it works from anywhere inside the project.
87
+
88
+ | Key | Meaning |
89
+ |---|---|
90
+ | `scope` | `shared` (default), `platform`, `host`, or `local` |
91
+ | `file` | The env file this variable lives in. Default `.env`. One item can hold several files. |
92
+ | `default` | Written by `pull` when the variable is missing locally and has no value in 1Password |
93
+ | `help_text` | Written as a comment above the variable when `pull` adds it |
94
+
95
+ `default` and `help_text` mean the same as in [epicenv](https://github.com/epicserve/epicenv). The file has its own name so the two tools never fight over `.env.toml`.
96
+
97
+ ### Scopes
98
+
99
+ All of a project's values live in one Secure Note, with one section per scope:
100
+
101
+ | Scope | Section | Use it for |
102
+ |---|---|---|
103
+ | `shared` | `shared` | Values that are the same everywhere (most secrets) |
104
+ | `platform` | `macos`, `wsl`, `linux`, `windows` | Things that differ by OS, like Docker networking or paths |
105
+ | `host` | `host-<hostname>` | Per-machine settings |
106
+ | `local` | — | Values that never leave the machine |
107
+
108
+ A machine only reads and writes `shared` plus its own platform and host sections. Each section holds concealed fields named after the variables, so you can also read them elsewhere with a secret reference like `op://Private/swils .env/shared/SECRET_KEY`.
109
+
110
+ ## Several 1Password accounts
111
+
112
+ When more than one account is signed in, as on a work PC with personal and work accounts, choose one in the order op-env-sync checks:
113
+
114
+ 1. `--account` (and `--vault`) on the command line, for one run
115
+ 2. `account` / `vault` under `[onepassword]` in `.env.op.toml`, for everyone using the project
116
+ 3. `op`'s own `OP_ACCOUNT` environment variable, or its default account
117
+
118
+ Use the account's sign-in address (`my.1password.com`, `acme.1password.com`) rather than a shorthand: the address names the same account on every machine and for every person. `op account list` shows it. `op-env-sync status` prints which account and vault it actually resolved to. Because a missing item is also what the wrong account looks like, `push` never creates the item without asking.
119
+
120
+ ## How syncing works
121
+
122
+ There is no merge base and no background sync. `status` shows what differs, and you choose a direction:
123
+
124
+ - **push** copies every tracked local value that differs into its section of the item. Keys that are in `.env` but not in `.env.op.toml` are reported and skipped.
125
+ - **pull** copies every value from this machine's sections into the right env file. It changes the value in place and keeps comments, ordering, `export` prefixes and quote style. New files are created with owner-only permissions (`0600`).
126
+
127
+ 1Password keeps every version of the item, so an overwritten value can be recovered from its item history. Your local `.env` has no such history, which is why `pull` asks first.
128
+
129
+ ## Development
130
+
131
+ ```bash
132
+ uv sync
133
+ uv run pytest
134
+ uv run ruff check . && uv run ruff format --check .
135
+ ./op_env_sync.py --help # the single file also runs directly with uv
136
+ ```
137
+
138
+ The tests run against `tests/fake_op.py`, a stand-in for `op` that stores items as JSON per account and vault.
139
+
140
+ ### Releasing
141
+
142
+ Bump `__version__` in `op_env_sync.py`, commit, then `git tag vX.Y.Z && git push origin vX.Y.Z`. The Release workflow publishes to PyPI through trusted publishing and attaches the wheel to a GitHub release. You can rehearse a release on TestPyPI from the Actions tab.
@@ -0,0 +1,118 @@
1
+ # op-env-sync
2
+
3
+ Sync a project's `.env` files across machines through 1Password.
4
+
5
+ A small tracked file, `.env.op.toml`, lists which variables travel and how. The values stay in your Git-ignored `.env` locally and in one 1Password item remotely. Values that differ per OS or per machine get their own slots in that item, so pushing from your Linux box never overwrites what your Mac uses.
6
+
7
+ ```console
8
+ $ op-env-sync
9
+ local: /home/sam/repos/swils (platform=wsl, host=sffpc)
10
+ 1Password: my.1password.com (sam@example.com) / default vault / 'swils .env'
11
+
12
+ SECRET_KEY shared in sync
13
+ SENTRY_AUTH_TOKEN shared not in 1Password -> push
14
+ LOCAL_LLM_TUNNEL_NETWORK_MODE wsl in sync [.env.local-llm]
15
+ ```
16
+
17
+ ## Requirements
18
+
19
+ - [`uv`](https://github.com/astral-sh/uv), which provides `uvx` and fetches Python 3.11+ for you
20
+ - The [1Password CLI](https://developer.1password.com/docs/cli/get-started/) (`op`), signed in. Turning on the desktop app integration is easiest. From WSL, op-env-sync also finds the Windows `op.exe` that WinGet installs.
21
+
22
+ op-env-sync has no dependencies beyond the Python standard library.
23
+
24
+ ## Run
25
+
26
+ ```bash
27
+ uvx op-env-sync init # write a starter .env.op.toml from your current .env
28
+ uvx op-env-sync # status: compare local values with 1Password
29
+ uvx op-env-sync push # copy local values up
30
+ uvx op-env-sync pull # copy values down; on a fresh clone this writes .env from scratch
31
+ ```
32
+
33
+ Or install it so `op-env-sync` is on your PATH: `uv tool install op-env-sync`.
34
+
35
+ To give a project a short command for it, add a [just](https://github.com/casey/just) recipe. Then `just env pull` works on a fresh clone:
36
+
37
+ ```just
38
+ # Sync .env files through 1Password per .env.op.toml: just env [status|push|pull] [--yes]
39
+ [positional-arguments]
40
+ @env *args:
41
+ uvx op-env-sync "$@"
42
+ ```
43
+
44
+ `push` and `pull` ask before they overwrite an existing value or create the 1Password item. Pass `--yes` to skip the question (required when nothing can answer it, as in scripts and agents). Values are never printed.
45
+
46
+ ## `.env.op.toml`
47
+
48
+ ```toml
49
+ [onepassword]
50
+ account = "my.1password.com" # optional: which signed-in account (see below)
51
+ vault = "Private" # optional: op's default vault when omitted
52
+ item = "swils .env" # optional: "<project dir> .env" when omitted
53
+
54
+ [variables]
55
+ SECRET_KEY = {} # shared by every machine
56
+ INTERNAL_IPS = { scope = "platform" } # one value per OS
57
+ COMPOSE_PROFILES = { scope = "host" } # one value per machine
58
+ DATABASE_URL = { scope = "local", default = "postgres://localhost/app" } # never synced
59
+ TUNNEL_MODE = { scope = "platform", file = ".env.local-llm", help_text = "bridge on WSL, host on Mac" }
60
+ ```
61
+
62
+ Commit it. It holds layout only, never values. op-env-sync looks for it in the current directory and then each parent, so it works from anywhere inside the project.
63
+
64
+ | Key | Meaning |
65
+ |---|---|
66
+ | `scope` | `shared` (default), `platform`, `host`, or `local` |
67
+ | `file` | The env file this variable lives in. Default `.env`. One item can hold several files. |
68
+ | `default` | Written by `pull` when the variable is missing locally and has no value in 1Password |
69
+ | `help_text` | Written as a comment above the variable when `pull` adds it |
70
+
71
+ `default` and `help_text` mean the same as in [epicenv](https://github.com/epicserve/epicenv). The file has its own name so the two tools never fight over `.env.toml`.
72
+
73
+ ### Scopes
74
+
75
+ All of a project's values live in one Secure Note, with one section per scope:
76
+
77
+ | Scope | Section | Use it for |
78
+ |---|---|---|
79
+ | `shared` | `shared` | Values that are the same everywhere (most secrets) |
80
+ | `platform` | `macos`, `wsl`, `linux`, `windows` | Things that differ by OS, like Docker networking or paths |
81
+ | `host` | `host-<hostname>` | Per-machine settings |
82
+ | `local` | — | Values that never leave the machine |
83
+
84
+ A machine only reads and writes `shared` plus its own platform and host sections. Each section holds concealed fields named after the variables, so you can also read them elsewhere with a secret reference like `op://Private/swils .env/shared/SECRET_KEY`.
85
+
86
+ ## Several 1Password accounts
87
+
88
+ When more than one account is signed in, as on a work PC with personal and work accounts, choose one in the order op-env-sync checks:
89
+
90
+ 1. `--account` (and `--vault`) on the command line, for one run
91
+ 2. `account` / `vault` under `[onepassword]` in `.env.op.toml`, for everyone using the project
92
+ 3. `op`'s own `OP_ACCOUNT` environment variable, or its default account
93
+
94
+ Use the account's sign-in address (`my.1password.com`, `acme.1password.com`) rather than a shorthand: the address names the same account on every machine and for every person. `op account list` shows it. `op-env-sync status` prints which account and vault it actually resolved to. Because a missing item is also what the wrong account looks like, `push` never creates the item without asking.
95
+
96
+ ## How syncing works
97
+
98
+ There is no merge base and no background sync. `status` shows what differs, and you choose a direction:
99
+
100
+ - **push** copies every tracked local value that differs into its section of the item. Keys that are in `.env` but not in `.env.op.toml` are reported and skipped.
101
+ - **pull** copies every value from this machine's sections into the right env file. It changes the value in place and keeps comments, ordering, `export` prefixes and quote style. New files are created with owner-only permissions (`0600`).
102
+
103
+ 1Password keeps every version of the item, so an overwritten value can be recovered from its item history. Your local `.env` has no such history, which is why `pull` asks first.
104
+
105
+ ## Development
106
+
107
+ ```bash
108
+ uv sync
109
+ uv run pytest
110
+ uv run ruff check . && uv run ruff format --check .
111
+ ./op_env_sync.py --help # the single file also runs directly with uv
112
+ ```
113
+
114
+ The tests run against `tests/fake_op.py`, a stand-in for `op` that stores items as JSON per account and vault.
115
+
116
+ ### Releasing
117
+
118
+ Bump `__version__` in `op_env_sync.py`, commit, then `git tag vX.Y.Z && git push origin vX.Y.Z`. The Release workflow publishes to PyPI through trusted publishing and attaches the wheel to a GitHub release. You can rehearse a release on TestPyPI from the Actions tab.
@@ -0,0 +1,568 @@
1
+ #!/usr/bin/env -S uv run --script
2
+ # /// script
3
+ # requires-python = ">=3.11"
4
+ # ///
5
+ """op-env-sync: sync a project's .env files with 1Password, so secrets travel between machines.
6
+
7
+ A tracked `.env.op.toml` next to the `.env` says which variables sync and how. It
8
+ holds layout, never values, so it is safe to commit:
9
+
10
+ [onepassword]
11
+ account = "my.1password.com" # optional; which signed-in account (sign-in address or ID)
12
+ vault = "Private" # optional; op's default vault when omitted
13
+ item = "swils .env" # optional; "<project dir> .env" when omitted
14
+
15
+ [variables]
16
+ SECRET_KEY = {} # shared by every machine
17
+ INTERNAL_IPS = { scope = "platform" } # one value per platform
18
+ COMPOSE_PROFILES = { scope = "host" } # one value per hostname
19
+ DATABASE_URL = { scope = "local", default = "postgres://..." } # never synced
20
+ TUNNEL_MODE = { scope = "platform", file = ".env.local-llm" } # another env file
21
+
22
+ Scopes map to sections of one Secure Note item: `shared` -> "shared",
23
+ `platform` -> "macos" / "wsl" / "linux" / "windows", `host` -> "host-<name>".
24
+ A machine only ever writes and reads its own platform/host sections, so a WSL
25
+ push can't clobber the Mac's value. `help_text` and `default` are honoured too
26
+ (the same keys epicenv uses), as comments and as the fallback written by `pull`.
27
+
28
+ With several 1Password accounts signed in (a work PC with personal and work
29
+ accounts), pin the account in the file, or per run with `--account`. A sign-in
30
+ address names the same account on every machine. Without either, op's own
31
+ OP_ACCOUNT variable or its default account applies. `--vault` overrides the vault.
32
+
33
+ There is no merge base, so sync is explicit: `status` compares, `push` copies
34
+ .env -> 1Password, `pull` copies 1Password -> .env. Overwriting an existing value,
35
+ or creating the item, asks first. Values are never printed.
36
+
37
+ Usage: op-env-sync [status|push|pull|init] [--yes] [--account ACCOUNT] [--vault VAULT]
38
+ """
39
+
40
+ from __future__ import annotations
41
+
42
+ import argparse
43
+ import glob
44
+ import json
45
+ import os
46
+ import re
47
+ import shutil
48
+ import socket
49
+ import subprocess
50
+ import sys
51
+ import tempfile
52
+ import tomllib
53
+ from dataclasses import dataclass, field
54
+ from pathlib import Path
55
+
56
+ __version__ = "0.1.0"
57
+
58
+ CONFIG_NAME = ".env.op.toml"
59
+ ENV_NAME = ".env"
60
+ SCOPES = ("shared", "platform", "host", "local")
61
+ NOTE = "Managed by op-env-sync from {project}/.env.op.toml. Sections: shared, one per platform, one per host."
62
+
63
+ # 1Password CLI installed on the Windows side, reachable from WSL without being on PATH.
64
+ WSL_OP_GLOB = "/mnt/c/Users/*/AppData/Local/Microsoft/WinGet/Packages/AgileBits.1Password.CLI_*/op.exe"
65
+
66
+
67
+ # --- Machine identity ---
68
+
69
+
70
+ def platform_name() -> str:
71
+ if sys.platform == "darwin":
72
+ return "macos"
73
+ if sys.platform == "win32":
74
+ return "windows"
75
+ try:
76
+ if "microsoft" in Path("/proc/version").read_text().lower():
77
+ return "wsl"
78
+ except OSError:
79
+ pass
80
+ return "linux"
81
+
82
+
83
+ def host_name() -> str:
84
+ return socket.gethostname().split(".")[0].lower()
85
+
86
+
87
+ def section_for(scope: str) -> str:
88
+ if scope == "platform":
89
+ return platform_name()
90
+ if scope == "host":
91
+ return f"host-{host_name()}"
92
+ return scope # "shared", or "local" (never sent to 1Password)
93
+
94
+
95
+ # --- .env.op.toml ---
96
+
97
+
98
+ @dataclass
99
+ class Var:
100
+ name: str
101
+ scope: str = "shared"
102
+ help_text: str = ""
103
+ default: str | None = None
104
+ file: str = ENV_NAME
105
+
106
+ @property
107
+ def section(self) -> str:
108
+ return section_for(self.scope)
109
+
110
+
111
+ @dataclass
112
+ class Config:
113
+ project: Path
114
+ account: str | None
115
+ vault: str | None
116
+ item: str
117
+ variables: dict[str, Var] = field(default_factory=dict)
118
+
119
+ @property
120
+ def files(self) -> list[str]:
121
+ return list(dict.fromkeys([ENV_NAME, *(v.file for v in self.variables.values())]))
122
+
123
+ @property
124
+ def synced(self) -> list[Var]:
125
+ return [v for v in self.variables.values() if v.scope != "local"]
126
+
127
+
128
+ class Locals:
129
+ """Every env file the config mentions, read once."""
130
+
131
+ def __init__(self, cfg: Config):
132
+ self.cfg = cfg
133
+ self.files = {name: EnvFile.read(cfg.project / name) for name in cfg.files}
134
+
135
+ def get(self, var: Var) -> str | None:
136
+ return self.files[var.file].values.get(var.name)
137
+
138
+ def untracked(self) -> list[str]:
139
+ tracked = {(v.file, v.name) for v in self.cfg.variables.values()}
140
+ return [
141
+ name if file == ENV_NAME else f"{file}:{name}"
142
+ for file, env in self.files.items()
143
+ for name in env.values
144
+ if (file, name) not in tracked
145
+ ]
146
+
147
+
148
+ def find_config(start: Path) -> Path | None:
149
+ for d in (start, *start.parents):
150
+ if (d / CONFIG_NAME).is_file():
151
+ return d / CONFIG_NAME
152
+ return None
153
+
154
+
155
+ def load_config(path: Path) -> Config:
156
+ data = tomllib.loads(path.read_text())
157
+ op = data.get("onepassword", {})
158
+ cfg = Config(
159
+ project=path.parent,
160
+ account=op.get("account"),
161
+ vault=op.get("vault"),
162
+ item=op.get("item") or f"{path.parent.name} .env",
163
+ )
164
+ for name, spec in data.get("variables", {}).items():
165
+ scope = spec.get("scope", "shared")
166
+ if scope not in SCOPES:
167
+ die(f"{path}: {name} has scope {scope!r}; expected one of {', '.join(SCOPES)}")
168
+ default = spec.get("default")
169
+ cfg.variables[name] = Var(
170
+ name,
171
+ scope,
172
+ spec.get("help_text", ""),
173
+ None if default is None else _toml_scalar(default),
174
+ spec.get("file", ENV_NAME),
175
+ )
176
+ return cfg
177
+
178
+
179
+ def _toml_scalar(value: object) -> str:
180
+ # TOML booleans come back as Python bools; .env wants the dotenv spelling.
181
+ if isinstance(value, bool):
182
+ return "true" if value else "false"
183
+ return str(value)
184
+
185
+
186
+ # --- .env parsing (line-preserving) ---
187
+
188
+ ASSIGN_RE = re.compile(r"^(\s*(?:export\s+)?)([A-Za-z_][A-Za-z0-9_]*)(\s*=\s*)")
189
+ BARE_SAFE_RE = re.compile(r"[A-Za-z0-9_./:@,+=%~-]*")
190
+
191
+
192
+ @dataclass
193
+ class EnvFile:
194
+ """A .env kept as its lines, so updates touch only the value and keep comments/order."""
195
+
196
+ lines: list[str]
197
+ # name -> (line index, value start, value end) of the last assignment (dotenv: last wins)
198
+ spans: dict[str, tuple[int, int, int]] = field(default_factory=dict)
199
+ values: dict[str, str] = field(default_factory=dict)
200
+
201
+ @classmethod
202
+ def read(cls, path: Path) -> EnvFile:
203
+ env = cls(path.read_text().splitlines() if path.exists() else [])
204
+ for i, line in enumerate(env.lines):
205
+ m = ASSIGN_RE.match(line)
206
+ if m:
207
+ value, end = _decode(line, m.end())
208
+ env.spans[m.group(2)] = (i, m.end(), end)
209
+ env.values[m.group(2)] = value
210
+ return env
211
+
212
+ def set(self, name: str, value: str, help_text: str = "") -> None:
213
+ if name in self.spans:
214
+ i, start, end = self.spans[name]
215
+ old = self.lines[i][start:end]
216
+ self.lines[i] = self.lines[i][:start] + _encode(value, old[:1]) + self.lines[i][end:]
217
+ else:
218
+ if help_text and self.lines and self.lines[-1].strip():
219
+ self.lines.append("")
220
+ self.lines.extend(f"# {ln}" for ln in help_text.splitlines())
221
+ self.lines.append(f"{name}={_encode(value, '')}")
222
+ self.values[name] = value
223
+
224
+ def write(self, path: Path) -> None:
225
+ mode = path.stat().st_mode & 0o777 if path.exists() else 0o600
226
+ fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=".env.op-env-sync-")
227
+ try:
228
+ with os.fdopen(fd, "w") as f:
229
+ f.write("\n".join(self.lines) + "\n")
230
+ os.chmod(tmp, mode)
231
+ os.replace(tmp, path)
232
+ except BaseException:
233
+ Path(tmp).unlink(missing_ok=True)
234
+ raise
235
+
236
+
237
+ def _decode(line: str, start: int) -> tuple[str, int]:
238
+ """Return (value, end offset) for the value starting at `start`; the rest is comment/whitespace."""
239
+ quote = line[start : start + 1]
240
+ if quote == "'":
241
+ end = line.find("'", start + 1)
242
+ if end != -1:
243
+ return line[start + 1 : end], end + 1
244
+ elif quote == '"':
245
+ out, i = [], start + 1
246
+ while i < len(line):
247
+ c = line[i]
248
+ if c == "\\" and i + 1 < len(line):
249
+ out.append({"n": "\n", "t": "\t"}.get(line[i + 1], line[i + 1]))
250
+ i += 2
251
+ elif c == '"':
252
+ return "".join(out), i + 1
253
+ else:
254
+ out.append(c)
255
+ i += 1
256
+ # Unquoted (or unterminated): runs to an inline " #" comment.
257
+ m = re.search(r"\s+#", line[start:])
258
+ end = start + m.start() if m else len(line.rstrip())
259
+ end = max(end, start)
260
+ return line[start:end].strip(), end
261
+
262
+
263
+ def _encode(value: str, prefer_quote: str) -> str:
264
+ if BARE_SAFE_RE.fullmatch(value) and prefer_quote not in ("'", '"'):
265
+ return value
266
+ if prefer_quote != '"' and "'" not in value and "\n" not in value:
267
+ return f"'{value}'"
268
+ # No `$` escaping: dotenv readers disagree on `\$` (python-dotenv keeps the backslash).
269
+ escaped = value.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n")
270
+ return f'"{escaped}"'
271
+
272
+
273
+ # --- 1Password CLI ---
274
+
275
+
276
+ def op_binary() -> str:
277
+ for name in ("op", "op.exe"):
278
+ if found := shutil.which(name):
279
+ return found
280
+ if found := sorted(glob.glob(WSL_OP_GLOB)):
281
+ return found[-1]
282
+ die("1Password CLI not found. Install it: https://developer.1password.com/docs/cli/get-started/")
283
+
284
+
285
+ def run_op(cfg: Config, *args: str, stdin: str | None = None) -> subprocess.CompletedProcess[str]:
286
+ cmd = [op_binary(), *args]
287
+ if cfg.account:
288
+ cmd += ["--account", cfg.account] # a global flag; valid on every op command
289
+ if cfg.vault and args[0] == "item":
290
+ cmd += ["--vault", cfg.vault]
291
+ return subprocess.run(cmd, input=stdin, capture_output=True, text=True, encoding="utf-8")
292
+
293
+
294
+ def op_failed(what: str, stderr: str) -> None:
295
+ msg = f"{what}:\n{stderr.strip()}"
296
+ if "account" in stderr.lower():
297
+ msg += (
298
+ f"\n\nWith several 1Password accounts signed in, set `account` under [onepassword] in {CONFIG_NAME}"
299
+ " or pass --account. `op account list` shows the sign-in addresses."
300
+ )
301
+ die(msg)
302
+
303
+
304
+ def op_json(cfg: Config, *args: str, stdin: str | None = None) -> dict:
305
+ proc = run_op(cfg, *args, "--format", "json", stdin=stdin)
306
+ if proc.returncode != 0:
307
+ op_failed(f"op {args[0]} {args[1]} failed", proc.stderr)
308
+ return json.loads(proc.stdout)
309
+
310
+
311
+ def get_item(cfg: Config) -> dict | None:
312
+ proc = run_op(cfg, "item", "get", cfg.item, "--format", "json")
313
+ if proc.returncode == 0:
314
+ return json.loads(proc.stdout)
315
+ if "isn't an item" in proc.stderr:
316
+ return None
317
+ op_failed(f"Couldn't read 1Password item {cfg.item!r}", proc.stderr)
318
+
319
+
320
+ def describe_target(cfg: Config) -> str:
321
+ """Where the item lives, naming the account op actually resolved to (best effort)."""
322
+ proc = run_op(cfg, "whoami", "--format", "json")
323
+ account = cfg.account or "default account"
324
+ if proc.returncode == 0:
325
+ me = json.loads(proc.stdout)
326
+ account = f"{me.get('url', account)} ({me.get('email', '?')})"
327
+ return f"{account} / {cfg.vault or 'default vault'} / {cfg.item!r}"
328
+
329
+
330
+ def remote_values(item: dict | None) -> dict[tuple[str, str], str]:
331
+ """(section label, field label) -> value for every custom field in a section."""
332
+ if not item:
333
+ return {}
334
+ return {
335
+ (f["section"]["label"], f["label"]): f.get("value", "")
336
+ for f in item.get("fields", [])
337
+ if f.get("section", {}).get("label")
338
+ }
339
+
340
+
341
+ def _slug(text: str) -> str:
342
+ return re.sub(r"[^a-z0-9]+", "_", text.lower()).strip("_")
343
+
344
+
345
+ def apply_to_item(cfg: Config, item: dict | None, updates: list[Var], values: dict[str, str]) -> None:
346
+ if item is None:
347
+ item = {
348
+ "title": cfg.item,
349
+ "category": "SECURE_NOTE",
350
+ "fields": [
351
+ {
352
+ "id": "notesPlain",
353
+ "type": "STRING",
354
+ "purpose": "NOTES",
355
+ "label": "notesPlain",
356
+ "value": NOTE.format(project=cfg.project.name),
357
+ }
358
+ ],
359
+ "sections": [],
360
+ }
361
+ sections = item.setdefault("sections", [])
362
+ fields = item.setdefault("fields", [])
363
+ for var in updates:
364
+ section = next((s for s in sections if s.get("label") == var.section), None)
365
+ if section is None:
366
+ section = {"id": f"op_env_sync_{_slug(var.section)}", "label": var.section}
367
+ sections.append(section)
368
+ existing = next(
369
+ (f for f in fields if f.get("label") == var.name and f.get("section", {}).get("label") == var.section),
370
+ None,
371
+ )
372
+ if existing:
373
+ existing["value"] = values[var.name]
374
+ else:
375
+ fields.append(
376
+ {
377
+ "id": f"{section['id']}_{_slug(var.name)}",
378
+ "type": "CONCEALED",
379
+ "label": var.name,
380
+ "value": values[var.name],
381
+ "section": {"id": section["id"], "label": section["label"]},
382
+ }
383
+ )
384
+ payload = json.dumps(item)
385
+ if "id" in item:
386
+ op_json(cfg, "item", "edit", item["id"], stdin=payload)
387
+ else:
388
+ op_json(cfg, "item", "create", "-", stdin=payload)
389
+
390
+
391
+ # --- Commands ---
392
+
393
+
394
+ def cmd_status(cfg: Config, _args: argparse.Namespace) -> int:
395
+ local = Locals(cfg)
396
+ item = get_item(cfg)
397
+ remote = remote_values(item)
398
+ print(f"local: {cfg.project} (platform={platform_name()}, host={host_name()})")
399
+ print(f"1Password: {describe_target(cfg)}" + ("" if item else " (item not created yet)") + "\n")
400
+
401
+ width = max((len(n) for n in cfg.variables), default=0)
402
+ for var in cfg.variables.values():
403
+ lv = local.get(var)
404
+ if var.scope == "local":
405
+ state = "local only" if lv is not None else ("missing (pull writes default)" if var.default else "missing")
406
+ else:
407
+ rv = remote.get((var.section, var.name))
408
+ if lv is None and rv is None:
409
+ state = "missing everywhere"
410
+ elif rv is None:
411
+ state = "not in 1Password -> push"
412
+ elif lv is None:
413
+ state = f"not in {var.file} -> pull"
414
+ elif lv != rv:
415
+ state = "DIFFERS"
416
+ else:
417
+ state = "in sync"
418
+ file = "" if var.file == ENV_NAME else f" [{var.file}]"
419
+ print(f" {var.name:<{width}} {var.section:<14} {state}{file}")
420
+
421
+ if untracked := local.untracked():
422
+ print(f"\nIn env files but not in {CONFIG_NAME} (not synced): {', '.join(untracked)}")
423
+ mine = {v.section for v in cfg.synced}
424
+ stale = sorted(f"{s}/{n}" for s, n in remote if s in mine and n not in cfg.variables)
425
+ if stale:
426
+ print(f"In 1Password but not in {CONFIG_NAME} (ignored): {', '.join(stale)}")
427
+ return 0
428
+
429
+
430
+ def cmd_push(cfg: Config, args: argparse.Namespace) -> int:
431
+ local = Locals(cfg)
432
+ item = get_item(cfg)
433
+ remote = remote_values(item)
434
+ updates, overwrites = [], []
435
+ for var in cfg.synced:
436
+ lv = local.get(var)
437
+ if lv is None:
438
+ continue
439
+ rv = remote.get((var.section, var.name))
440
+ if rv != lv:
441
+ updates.append(var)
442
+ if rv is not None:
443
+ overwrites.append(var)
444
+ if untracked := local.untracked():
445
+ print(f"Skipping keys not in {CONFIG_NAME}: {', '.join(untracked)}")
446
+ if not updates:
447
+ print("1Password is up to date.")
448
+ return 0
449
+ for var in updates:
450
+ print(f" {'~' if var in overwrites else '+'} {var.section}/{var.name}")
451
+ if item is None:
452
+ # A missing item is also what the wrong account or vault looks like, so creating one is never silent.
453
+ print(f"No item yet; this creates it in {describe_target(cfg)}.")
454
+ if not confirm("Create it?", args.yes):
455
+ return 1
456
+ elif overwrites and not confirm(f"Overwrite {len(overwrites)} value(s) in 1Password?", args.yes):
457
+ return 1
458
+ apply_to_item(cfg, item, updates, {v.name: local.get(v) or "" for v in updates})
459
+ print(f"Pushed {len(updates)} value(s) to {cfg.item!r}.")
460
+ return 0
461
+
462
+
463
+ def cmd_pull(cfg: Config, args: argparse.Namespace) -> int:
464
+ local = Locals(cfg)
465
+ item = get_item(cfg)
466
+ if item is None:
467
+ die(f"No 1Password item {cfg.item!r} yet. Run `push` on a machine that has the .env.")
468
+ remote = remote_values(item)
469
+ changes: list[tuple[Var, str, str]] = [] # (var, value, where it came from)
470
+ for var in cfg.variables.values():
471
+ lv = local.get(var)
472
+ rv = None if var.scope == "local" else remote.get((var.section, var.name))
473
+ source = var.section
474
+ if rv is None and lv is None and var.default is not None:
475
+ rv, source = var.default, "default"
476
+ if rv is not None and lv != rv:
477
+ changes.append((var, rv, source))
478
+ if not changes:
479
+ print("Env files are up to date.")
480
+ return 0
481
+ overwrites = [v for v, _, _ in changes if local.get(v) is not None]
482
+ for var, _, source in changes:
483
+ file = "" if var.file == ENV_NAME else f" [{var.file}]"
484
+ print(f" {'~' if var in overwrites else '+'} {var.name} ({source}){file}")
485
+ if overwrites and not confirm(f"Overwrite {len(overwrites)} local value(s)?", args.yes):
486
+ return 1
487
+ for var, value, _ in changes:
488
+ local.files[var.file].set(var.name, value, var.help_text)
489
+ for name in dict.fromkeys(v.file for v, _, _ in changes):
490
+ local.files[name].write(cfg.project / name)
491
+ print(f"Wrote {cfg.project / name}")
492
+ return 0
493
+
494
+
495
+ def cmd_init(_cfg: None, args: argparse.Namespace) -> int:
496
+ path = Path.cwd() / CONFIG_NAME
497
+ if path.exists():
498
+ die(f"{path} already exists.")
499
+ local = EnvFile.read(Path.cwd() / ENV_NAME)
500
+ lines = [
501
+ "# Which .env variables sync to 1Password, and how. Values never live here.",
502
+ "# Scopes: shared (default), platform (per OS), host (per machine), local (never synced).",
503
+ "# Sync with `op-env-sync status|push|pull` (https://github.com/swils23/op-env-sync).",
504
+ "",
505
+ "[onepassword]",
506
+ f'account = "{args.account}"'
507
+ if args.account
508
+ else '# account = "my.1password.com" # pin it when several accounts are signed in',
509
+ f'vault = "{args.vault}"' if args.vault else '# vault = "Private"',
510
+ f'item = "{Path.cwd().name} .env"',
511
+ "",
512
+ "[variables]",
513
+ *(f"{name} = {{}}" for name in local.values),
514
+ ]
515
+ path.write_text("\n".join(lines) + "\n")
516
+ print(f"Wrote {path} with {len(local.values)} variable(s), all shared. Mark per-machine ones, then push.")
517
+ return 0
518
+
519
+
520
+ # --- CLI plumbing ---
521
+
522
+
523
+ def die(msg: str) -> None:
524
+ print(f"op-env-sync: {msg}", file=sys.stderr)
525
+ raise SystemExit(2)
526
+
527
+
528
+ def confirm(question: str, yes: bool) -> bool:
529
+ if yes:
530
+ return True
531
+ if not sys.stdin.isatty():
532
+ print(f"{question} Re-run with --yes to confirm non-interactively.", file=sys.stderr)
533
+ return False
534
+ return input(f"{question} [y/N] ").strip().lower() in ("y", "yes")
535
+
536
+
537
+ def main(argv: list[str] | None = None) -> int:
538
+ parser = argparse.ArgumentParser(
539
+ prog="op-env-sync",
540
+ description="Sync a project's .env files with 1Password, so secrets travel between machines.",
541
+ )
542
+ parser.add_argument("command", nargs="?", default="status", choices=("status", "push", "pull", "init"))
543
+ parser.add_argument("-y", "--yes", action="store_true", help="overwrite or create without asking")
544
+ parser.add_argument("--account", help=f"1Password account (sign-in address or ID); overrides {CONFIG_NAME}")
545
+ parser.add_argument("--vault", help=f"1Password vault; overrides {CONFIG_NAME}")
546
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
547
+ args = parser.parse_args(argv)
548
+ if hasattr(sys.stdout, "reconfigure"):
549
+ sys.stdout.reconfigure(line_buffering=True) # keep the change list ahead of a stderr prompt
550
+
551
+ if args.command == "init":
552
+ return cmd_init(None, args)
553
+ path = find_config(Path.cwd())
554
+ if path is None:
555
+ die(f"No {CONFIG_NAME} here or in any parent. Run `init` in the project root to create one.")
556
+ cfg = load_config(path)
557
+ cfg.account = args.account or cfg.account
558
+ cfg.vault = args.vault or cfg.vault
559
+ try:
560
+ return {"status": cmd_status, "push": cmd_push, "pull": cmd_pull}[args.command](cfg, args)
561
+ except BrokenPipeError:
562
+ # Output piped into something like `head` that stopped reading: exit quietly, as other CLIs do.
563
+ os.dup2(os.open(os.devnull, os.O_WRONLY), sys.stdout.fileno())
564
+ return 1
565
+
566
+
567
+ if __name__ == "__main__":
568
+ sys.exit(main())
@@ -0,0 +1,61 @@
1
+ [project]
2
+ name = "op-env-sync"
3
+ dynamic = ["version"]
4
+ description = "Sync .env files across machines through 1Password, with per-platform and per-host values"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ requires-python = ">=3.11"
9
+ dependencies = []
10
+ keywords = ["1password", "dotenv", "env", "secrets", "op"]
11
+ classifiers = [
12
+ "Environment :: Console",
13
+ "Intended Audience :: Developers",
14
+ "Operating System :: OS Independent",
15
+ "Programming Language :: Python :: 3",
16
+ "Programming Language :: Python :: 3 :: Only",
17
+ "Programming Language :: Python :: 3.11",
18
+ "Programming Language :: Python :: 3.12",
19
+ "Programming Language :: Python :: 3.13",
20
+ "Programming Language :: Python :: 3.14",
21
+ "Topic :: Software Development :: Build Tools",
22
+ "Topic :: Utilities",
23
+ ]
24
+
25
+ [project.urls]
26
+ Homepage = "https://github.com/swils23/op-env-sync"
27
+ Repository = "https://github.com/swils23/op-env-sync"
28
+ Issues = "https://github.com/swils23/op-env-sync/issues"
29
+
30
+ [project.scripts]
31
+ op-env-sync = "op_env_sync:main"
32
+
33
+ [dependency-groups]
34
+ dev = ["pytest>=8", "ruff>=0.6"]
35
+
36
+ [build-system]
37
+ requires = ["hatchling>=1.27"]
38
+ build-backend = "hatchling.build"
39
+
40
+ [tool.hatch.version]
41
+ path = "op_env_sync.py"
42
+
43
+ [tool.hatch.build.targets.wheel]
44
+ only-include = ["op_env_sync.py"]
45
+
46
+ [tool.hatch.build.targets.sdist]
47
+ only-include = ["op_env_sync.py", "README.md", "LICENSE", "pyproject.toml"]
48
+
49
+ [tool.pytest.ini_options]
50
+ pythonpath = ["."]
51
+ testpaths = ["tests"]
52
+ filterwarnings = ["error"]
53
+
54
+ [tool.ruff]
55
+ line-length = 120
56
+
57
+ [tool.ruff.lint]
58
+ select = ["E", "F", "B", "SIM", "UP", "I", "S"]
59
+ # S603/S607: op-env-sync's job is running `op`; S101: pytest asserts.
60
+ ignore = ["S603", "S607"]
61
+ per-file-ignores = { "tests/*" = ["S101"] }