op-env-sync 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -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,6 @@
|
|
|
1
|
+
op_env_sync.py,sha256=AymkQQ1tY3o43Alvft7z0vCuv3wa3CjmKK1c3XcC7x4,20974
|
|
2
|
+
op_env_sync-0.1.0.dist-info/METADATA,sha256=5H25IBlpnt2-gGIG9vCzailJ3-A-uRTv98nUyMT-1OM,7290
|
|
3
|
+
op_env_sync-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
4
|
+
op_env_sync-0.1.0.dist-info/entry_points.txt,sha256=4hfHevCyWAcIyzhACh89NeRHhWgKJHYAUR_6H7GPUdM,49
|
|
5
|
+
op_env_sync-0.1.0.dist-info/licenses/LICENSE,sha256=e9etw-Xgxx3DJr1mRrCeKKoq5XYONOFLhB6tkUI52-0,1067
|
|
6
|
+
op_env_sync-0.1.0.dist-info/RECORD,,
|
|
@@ -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.
|
op_env_sync.py
ADDED
|
@@ -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())
|