envrcctl 0.2.1__tar.gz → 0.2.2__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 (25) hide show
  1. {envrcctl-0.2.1 → envrcctl-0.2.2}/PKG-INFO +38 -76
  2. {envrcctl-0.2.1 → envrcctl-0.2.2}/README.md +7 -49
  3. envrcctl-0.2.2/pyproject.toml +56 -0
  4. {envrcctl-0.2.1 → envrcctl-0.2.2}/src/envrcctl/cli.py +15 -44
  5. envrcctl-0.2.1/.gitignore +0 -11
  6. envrcctl-0.2.1/pyproject.toml +0 -31
  7. envrcctl-0.2.1/scripts/build_macos_auth_helper.sh +0 -43
  8. envrcctl-0.2.1/scripts/generate_completions.py +0 -35
  9. envrcctl-0.2.1/scripts/macos/envrcctl-macos-auth.swift +0 -340
  10. {envrcctl-0.2.1 → envrcctl-0.2.2}/LICENSE +0 -0
  11. {envrcctl-0.2.1 → envrcctl-0.2.2}/completions/envrcctl.bash +0 -0
  12. {envrcctl-0.2.1 → envrcctl-0.2.2}/completions/envrcctl.fish +0 -0
  13. {envrcctl-0.2.1 → envrcctl-0.2.2}/completions/envrcctl.zsh +0 -0
  14. {envrcctl-0.2.1 → envrcctl-0.2.2}/src/envrcctl/__init__.py +0 -0
  15. {envrcctl-0.2.1 → envrcctl-0.2.2}/src/envrcctl/audit.py +0 -0
  16. {envrcctl-0.2.1 → envrcctl-0.2.2}/src/envrcctl/auth.py +0 -0
  17. {envrcctl-0.2.1 → envrcctl-0.2.2}/src/envrcctl/command_runner.py +0 -0
  18. {envrcctl-0.2.1 → envrcctl-0.2.2}/src/envrcctl/envrc.py +0 -0
  19. {envrcctl-0.2.1 → envrcctl-0.2.2}/src/envrcctl/envrcctl-macos-auth +0 -0
  20. {envrcctl-0.2.1 → envrcctl-0.2.2}/src/envrcctl/errors.py +0 -0
  21. {envrcctl-0.2.1 → envrcctl-0.2.2}/src/envrcctl/keychain.py +0 -0
  22. {envrcctl-0.2.1 → envrcctl-0.2.2}/src/envrcctl/main.py +0 -0
  23. {envrcctl-0.2.1 → envrcctl-0.2.2}/src/envrcctl/managed_block.py +0 -0
  24. {envrcctl-0.2.1 → envrcctl-0.2.2}/src/envrcctl/secrets.py +0 -0
  25. {envrcctl-0.2.1 → envrcctl-0.2.2}/src/envrcctl/secretservice.py +0 -0
@@ -1,35 +1,39 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.3
2
2
  Name: envrcctl
3
- Version: 0.2.1
3
+ Version: 0.2.2
4
4
  Summary: Manage .envrc with managed blocks and OS-backed secrets.
5
+ Author: Rio Fujita
6
+ Author-email: Rio Fujita <rio_github@rio.st>
5
7
  License: MIT License
6
-
7
- Copyright (c) 2026 Rio Fujita
8
-
9
- Permission is hereby granted, free of charge, to any person obtaining a copy
10
- of this software and associated documentation files (the "Software"), to deal
11
- in the Software without restriction, including without limitation the rights
12
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
13
- copies of the Software, and to permit persons to whom the Software is
14
- furnished to do so, subject to the following conditions:
15
-
16
- The above copyright notice and this permission notice shall be included in all
17
- copies or substantial portions of the Software.
18
-
19
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
20
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
21
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
22
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
23
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
24
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
25
- SOFTWARE.
26
- License-File: LICENSE
27
- Requires-Python: >=3.14
8
+
9
+ Copyright (c) 2026 Rio Fujita
10
+
11
+ Permission is hereby granted, free of charge, to any person obtaining a copy
12
+ of this software and associated documentation files (the "Software"), to deal
13
+ in the Software without restriction, including without limitation the rights
14
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
15
+ copies of the Software, and to permit persons to whom the Software is
16
+ furnished to do so, subject to the following conditions:
17
+
18
+ The above copyright notice and this permission notice shall be included in all
19
+ copies or substantial portions of the Software.
20
+
21
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
22
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
23
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
24
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
25
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
26
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
27
+ SOFTWARE.
28
28
  Requires-Dist: typer>=0.24.1
29
+ Requires-Dist: pytest>=9 ; extra == 'test'
30
+ Requires-Dist: pytest-cov>=7 ; extra == 'test'
31
+ Requires-Dist: bandit>=1.7.10 ; extra == 'test'
32
+ Requires-Python: >=3.14
33
+ Project-URL: Homepage, https://github.com/rioriost/envrcctl
34
+ Project-URL: Issues, https://github.com/rioriost/envrcctl/issues
35
+ Project-URL: Repository, https://github.com/rioriost/envrcctl
29
36
  Provides-Extra: test
30
- Requires-Dist: bandit>=1.7.10; extra == 'test'
31
- Requires-Dist: pytest-cov>=7.0.0; extra == 'test'
32
- Requires-Dist: pytest>=9.0.2; extra == 'test'
33
37
  Description-Content-Type: text/markdown
34
38
 
35
39
  # envrcctl
@@ -68,23 +72,17 @@ It is designed for macOS first, with Linux support via SecretService.
68
72
  Tap and install:
69
73
 
70
74
  ```sh
71
- brew tap rioriost/envrcctl
75
+ brew tap rioriost/tap
72
76
  brew install envrcctl
73
77
  ```
74
78
 
75
- For the next patch release, the Homebrew formula is intended to install a
76
- prebuilt Apple Silicon macOS authentication helper from a GitHub release
77
- `tar.gz` asset instead of compiling it at install time.
78
-
79
- This Homebrew path is therefore intended for:
79
+ This Homebrew path is intended for:
80
80
 
81
81
  - Apple Silicon (`arm64`) Macs
82
82
  - macOS installs that should not require a full Xcode.app build dependency
83
83
 
84
84
  Intel Macs are not a target for this Homebrew distribution path.
85
85
 
86
- After release, Homebrew will download the release from GitHub.
87
-
88
86
  Install direnv with Homebrew:
89
87
 
90
88
  ```sh
@@ -103,42 +101,25 @@ pipx install envrcctl
103
101
  uv tool install envrcctl
104
102
  ```
105
103
 
106
- ### From source (macOS/Linux)
107
-
108
- ```sh
109
- git clone <REPO_URL>
110
- cd envrcctl
111
- uv sync
112
- uv run python -m envrcctl.main --help
113
- ```
114
-
115
- ### Build the macOS auth helper manually (macOS only)
104
+ ### About the macOS auth helper (Apple Silicon macOS only)
116
105
 
117
106
  The macOS device owner authentication flow requires a native helper named
118
107
  `envrcctl-macos-auth`.
119
108
 
120
- On Apple Silicon macOS, the Homebrew installation path for the next patch
121
- release is intended to install a prebuilt helper automatically from a GitHub
122
- release `tar.gz` asset, so you should not need to compile it yourself in the
123
- common case.
109
+ Homebrew on Apple Silicon is intended to install this helper automatically, so
110
+ you should not need to build it yourself in the common case.
124
111
 
125
112
  Manual helper installation is still useful when:
126
113
 
127
- - you are running from source
128
- - you are developing on this repository
129
114
  - you want to place the helper in a custom location
130
115
  - you are not using the Apple Silicon Homebrew distribution path
131
116
 
132
- The Homebrew release asset is expected to be named
133
- `envrcctl-macos-auth-arm64.tar.gz` and to contain an executable named
134
- `envrcctl-macos-auth`.
135
-
136
- If you are building the helper yourself, place the binary at either:
117
+ If you are building the helper yourself, use Apple Silicon (`arm64`) macOS and place the binary at either:
137
118
 
138
119
  - `src/envrcctl/envrcctl-macos-auth`
139
120
  - or a custom path set via `ENVRCCTL_MACOS_AUTH_HELPER`
140
121
 
141
- Example build flow:
122
+ Example build flow on Apple Silicon macOS:
142
123
 
143
124
  ```sh
144
125
  swiftc -O -framework LocalAuthentication -framework Security \
@@ -147,20 +128,6 @@ swiftc -O -framework LocalAuthentication -framework Security \
147
128
  chmod +x src/envrcctl/envrcctl-macos-auth
148
129
  ```
149
130
 
150
- You can also use the repository build script:
151
-
152
- ```sh
153
- sh scripts/build_macos_auth_helper.sh
154
- ```
155
-
156
- If you want to write the helper to a custom location, pass the source and output paths explicitly:
157
-
158
- ```sh
159
- sh scripts/build_macos_auth_helper.sh \
160
- scripts/macos/envrcctl-macos-auth.swift \
161
- /usr/local/bin/envrcctl-macos-auth
162
- ```
163
-
164
131
  If you install the helper elsewhere, set:
165
132
 
166
133
  ```sh
@@ -407,12 +374,7 @@ uv run python scripts/generate_completions.py
407
374
  - Audit integrity is based on a hash chain and can be checked with `envrcctl audit verify`
408
375
  - The tool refuses to write to world-writable `.envrc`
409
376
 
410
- ## Development
411
377
 
412
- ```sh
413
- uv sync
414
- .venv/bin/envrcctl --help
415
- ```
416
378
 
417
379
  ## Acknowledgements
418
380
 
@@ -34,23 +34,17 @@ It is designed for macOS first, with Linux support via SecretService.
34
34
  Tap and install:
35
35
 
36
36
  ```sh
37
- brew tap rioriost/envrcctl
37
+ brew tap rioriost/tap
38
38
  brew install envrcctl
39
39
  ```
40
40
 
41
- For the next patch release, the Homebrew formula is intended to install a
42
- prebuilt Apple Silicon macOS authentication helper from a GitHub release
43
- `tar.gz` asset instead of compiling it at install time.
44
-
45
- This Homebrew path is therefore intended for:
41
+ This Homebrew path is intended for:
46
42
 
47
43
  - Apple Silicon (`arm64`) Macs
48
44
  - macOS installs that should not require a full Xcode.app build dependency
49
45
 
50
46
  Intel Macs are not a target for this Homebrew distribution path.
51
47
 
52
- After release, Homebrew will download the release from GitHub.
53
-
54
48
  Install direnv with Homebrew:
55
49
 
56
50
  ```sh
@@ -69,42 +63,25 @@ pipx install envrcctl
69
63
  uv tool install envrcctl
70
64
  ```
71
65
 
72
- ### From source (macOS/Linux)
73
-
74
- ```sh
75
- git clone <REPO_URL>
76
- cd envrcctl
77
- uv sync
78
- uv run python -m envrcctl.main --help
79
- ```
80
-
81
- ### Build the macOS auth helper manually (macOS only)
66
+ ### About the macOS auth helper (Apple Silicon macOS only)
82
67
 
83
68
  The macOS device owner authentication flow requires a native helper named
84
69
  `envrcctl-macos-auth`.
85
70
 
86
- On Apple Silicon macOS, the Homebrew installation path for the next patch
87
- release is intended to install a prebuilt helper automatically from a GitHub
88
- release `tar.gz` asset, so you should not need to compile it yourself in the
89
- common case.
71
+ Homebrew on Apple Silicon is intended to install this helper automatically, so
72
+ you should not need to build it yourself in the common case.
90
73
 
91
74
  Manual helper installation is still useful when:
92
75
 
93
- - you are running from source
94
- - you are developing on this repository
95
76
  - you want to place the helper in a custom location
96
77
  - you are not using the Apple Silicon Homebrew distribution path
97
78
 
98
- The Homebrew release asset is expected to be named
99
- `envrcctl-macos-auth-arm64.tar.gz` and to contain an executable named
100
- `envrcctl-macos-auth`.
101
-
102
- If you are building the helper yourself, place the binary at either:
79
+ If you are building the helper yourself, use Apple Silicon (`arm64`) macOS and place the binary at either:
103
80
 
104
81
  - `src/envrcctl/envrcctl-macos-auth`
105
82
  - or a custom path set via `ENVRCCTL_MACOS_AUTH_HELPER`
106
83
 
107
- Example build flow:
84
+ Example build flow on Apple Silicon macOS:
108
85
 
109
86
  ```sh
110
87
  swiftc -O -framework LocalAuthentication -framework Security \
@@ -113,20 +90,6 @@ swiftc -O -framework LocalAuthentication -framework Security \
113
90
  chmod +x src/envrcctl/envrcctl-macos-auth
114
91
  ```
115
92
 
116
- You can also use the repository build script:
117
-
118
- ```sh
119
- sh scripts/build_macos_auth_helper.sh
120
- ```
121
-
122
- If you want to write the helper to a custom location, pass the source and output paths explicitly:
123
-
124
- ```sh
125
- sh scripts/build_macos_auth_helper.sh \
126
- scripts/macos/envrcctl-macos-auth.swift \
127
- /usr/local/bin/envrcctl-macos-auth
128
- ```
129
-
130
93
  If you install the helper elsewhere, set:
131
94
 
132
95
  ```sh
@@ -373,12 +336,7 @@ uv run python scripts/generate_completions.py
373
336
  - Audit integrity is based on a hash chain and can be checked with `envrcctl audit verify`
374
337
  - The tool refuses to write to world-writable `.envrc`
375
338
 
376
- ## Development
377
339
 
378
- ```sh
379
- uv sync
380
- .venv/bin/envrcctl --help
381
- ```
382
340
 
383
341
  ## Acknowledgements
384
342
 
@@ -0,0 +1,56 @@
1
+ [project]
2
+ name = "envrcctl"
3
+ version = "0.2.2"
4
+ description = "Manage .envrc with managed blocks and OS-backed secrets."
5
+ readme = { file = "README.md", content-type = "text/markdown" }
6
+ license = { file = "LICENSE" }
7
+ requires-python = ">=3.14"
8
+ authors = [
9
+ { name = "Rio Fujita", email = "rio_github@rio.st" },
10
+ ]
11
+ dependencies = [
12
+ "typer>=0.24.1",
13
+ ]
14
+
15
+ [tool.uv.build-backend]
16
+ module-root = "src"
17
+ source-include = [
18
+ "completions/**",
19
+ ]
20
+
21
+ [project.urls]
22
+ Homepage = "https://github.com/rioriost/envrcctl"
23
+ Issues = "https://github.com/rioriost/envrcctl/issues"
24
+ Repository = "https://github.com/rioriost/envrcctl"
25
+
26
+ [project.scripts]
27
+ envrcctl = "envrcctl.main:main"
28
+
29
+ [project.optional-dependencies]
30
+ test = [
31
+ "pytest>=9",
32
+ "pytest-cov>=7",
33
+ "bandit>=1.7.10",
34
+ ]
35
+
36
+ [dependency-groups]
37
+ dev = [
38
+ "build>=1.2.2",
39
+ "twine>=6.1.0",
40
+ "ruff>=0.12.0",
41
+ ]
42
+
43
+ [build-system]
44
+ requires = ["uv_build>=0.9.27,<0.10.0"]
45
+ build-backend = "uv_build"
46
+
47
+ [tool.pytest.ini_options]
48
+ testpaths = ["tests"]
49
+ python_files = ["test_*.py"]
50
+
51
+ [tool.ruff]
52
+ line-length = 100
53
+ target-version = "py314"
54
+
55
+ [tool.ruff.lint]
56
+ select = ["E", "F", "I", "B", "UP"]
@@ -217,17 +217,13 @@ def _write_envrc(doc, block: ManagedBlock) -> None:
217
217
  _ensure_not_world_writable(path)
218
218
  warn = write_envrc(path, doc, block)
219
219
  if warn:
220
- raise EnvrcctlError(
221
- ".envrc is world-writable after write. Fix permissions and retry."
222
- )
220
+ raise EnvrcctlError(".envrc is world-writable after write. Fix permissions and retry.")
223
221
 
224
222
 
225
223
  @app.command()
226
224
  def init(
227
225
  yes: bool = typer.Option(False, "--yes", help="Confirm modifying existing .envrc."),
228
- inject: bool = typer.Option(
229
- False, "--inject", help="Add inject line to managed block."
230
- ),
226
+ inject: bool = typer.Option(False, "--inject", help="Add inject line to managed block."),
231
227
  ) -> None:
232
228
  """Create .envrc if missing and insert managed block."""
233
229
 
@@ -264,9 +260,7 @@ def inherit(state: str = typer.Argument(..., help="on/off")) -> None:
264
260
  def set(
265
261
  var: str,
266
262
  value: str,
267
- inject: bool = typer.Option(
268
- False, "--inject", help="Add inject line to managed block."
269
- ),
263
+ inject: bool = typer.Option(False, "--inject", help="Add inject line to managed block."),
270
264
  ) -> None:
271
265
  """Set a non-secret export in the managed block."""
272
266
 
@@ -329,14 +323,10 @@ def list_exports() -> None:
329
323
  def secret_set(
330
324
  var: str,
331
325
  account: str = typer.Option(..., "--account", help="Keychain account name."),
332
- service: str = typer.Option(
333
- DEFAULT_SERVICE, "--service", help="Keychain service name."
334
- ),
326
+ service: str = typer.Option(DEFAULT_SERVICE, "--service", help="Keychain service name."),
335
327
  kind: str = typer.Option("runtime", "--kind", help="Secret kind (runtime/admin)."),
336
328
  stdin: bool = typer.Option(False, "--stdin", help="Read secret from stdin."),
337
- inject: bool = typer.Option(
338
- False, "--inject", help="Add inject line to managed block."
339
- ),
329
+ inject: bool = typer.Option(False, "--inject", help="Add inject line to managed block."),
340
330
  ) -> None:
341
331
  """Store a secret and add its reference to the managed block."""
342
332
 
@@ -380,8 +370,7 @@ def secret_unset(var: str) -> None:
380
370
  parsed = parse_ref(ref)
381
371
 
382
372
  shared_ref_in_use = any(
383
- name != var and other_ref == ref
384
- for name, other_ref in block.secret_refs.items()
373
+ name != var and other_ref == ref for name, other_ref in block.secret_refs.items()
385
374
  )
386
375
 
387
376
  if not shared_ref_in_use:
@@ -448,9 +437,7 @@ def secret_get(
448
437
  typer.echo(value)
449
438
  return
450
439
 
451
- auth_reason = _require_secret_access_auth(
452
- f"Access secret {var} with envrcctl"
453
- )
440
+ auth_reason = _require_secret_access_auth(f"Access secret {var} with envrcctl")
454
441
  value = _get_secret_value(backend, parsed, auth_reason)
455
442
  _record_secret_access_event(
456
443
  action="secret_get",
@@ -556,9 +543,7 @@ def exec_cmd(
556
543
  command = list(ctx.args)
557
544
  try:
558
545
  if not ctx.args:
559
- raise EnvrcctlError(
560
- "No command provided. Use -- to separate the command."
561
- )
546
+ raise EnvrcctlError("No command provided. Use -- to separate the command.")
562
547
  if not _is_interactive():
563
548
  if sys.platform == "darwin":
564
549
  raise EnvrcctlError(
@@ -575,9 +560,7 @@ def exec_cmd(
575
560
  missing = selected_keys - available_keys
576
561
  if missing:
577
562
  missing_list = ", ".join(sorted(missing))
578
- raise EnvrcctlError(
579
- f"Secrets not found in managed block: {missing_list}"
580
- )
563
+ raise EnvrcctlError(f"Secrets not found in managed block: {missing_list}")
581
564
 
582
565
  env = os.environ.copy()
583
566
  for name, value in block.exports.items():
@@ -634,9 +617,7 @@ def audit_list(
634
617
  action: str | None = typer.Option(None, "--action", help="Filter by audit action."),
635
618
  var: str | None = typer.Option(None, "--var", help="Filter by variable name."),
636
619
  status: str | None = typer.Option(None, "--status", help="Filter by audit status."),
637
- json_output: bool = typer.Option(
638
- False, "--json", help="Emit matching events as JSON."
639
- ),
620
+ json_output: bool = typer.Option(False, "--json", help="Emit matching events as JSON."),
640
621
  ) -> None:
641
622
  """List recent audit events."""
642
623
 
@@ -713,9 +694,7 @@ def audit_show(
713
694
  index: int | None = typer.Option(
714
695
  None, "--index", min=0, help="Show an event by zero-based index."
715
696
  ),
716
- json_output: bool = typer.Option(
717
- False, "--json", help="Emit the selected event as JSON."
718
- ),
697
+ json_output: bool = typer.Option(False, "--json", help="Emit the selected event as JSON."),
719
698
  ) -> None:
720
699
  """Show one audit event in detail."""
721
700
 
@@ -911,9 +890,7 @@ def doctor() -> None:
911
890
  )
912
891
  warnings += 1
913
892
 
914
- before_clean, before_exports, before_secrets = extract_unmanaged_exports(
915
- doc.before
916
- )
893
+ before_clean, before_exports, before_secrets = extract_unmanaged_exports(doc.before)
917
894
  after_clean, after_exports, after_secrets = extract_unmanaged_exports(doc.after)
918
895
  unmanaged = {**before_exports, **after_exports}
919
896
  unmanaged_secrets = {**before_secrets, **after_secrets}
@@ -983,12 +960,8 @@ def doctor() -> None:
983
960
 
984
961
  @app.command()
985
962
  def migrate(
986
- yes: bool = typer.Option(
987
- False, "--yes", help="Confirm migrating unmanaged exports."
988
- ),
989
- inject: bool = typer.Option(
990
- False, "--inject", help="Add inject line to managed block."
991
- ),
963
+ yes: bool = typer.Option(False, "--yes", help="Confirm migrating unmanaged exports."),
964
+ inject: bool = typer.Option(False, "--inject", help="Add inject line to managed block."),
992
965
  ) -> None:
993
966
  """Move unmanaged exports into the managed block."""
994
967
 
@@ -999,9 +972,7 @@ def migrate(
999
972
  doc = load_envrc(path)
1000
973
  block = ensure_managed_block(doc)
1001
974
 
1002
- before_clean, before_exports, before_secrets = extract_unmanaged_exports(
1003
- doc.before
1004
- )
975
+ before_clean, before_exports, before_secrets = extract_unmanaged_exports(doc.before)
1005
976
  after_clean, after_exports, after_secrets = extract_unmanaged_exports(doc.after)
1006
977
 
1007
978
  if before_exports or after_exports or before_secrets or after_secrets:
envrcctl-0.2.1/.gitignore DELETED
@@ -1,11 +0,0 @@
1
-
2
- .venv/
3
- .agent/
4
- .rules
5
- __pycache__/
6
- *.py[cod]
7
- .pytest_cache/
8
- .coverage
9
- htmlcov/
10
- .DS_Store
11
- .envrc
@@ -1,31 +0,0 @@
1
- [project]
2
- name = "envrcctl"
3
- version = "0.2.1"
4
- description = "Manage .envrc with managed blocks and OS-backed secrets."
5
- readme = "README.md"
6
- license = { file = "LICENSE" }
7
- requires-python = ">=3.14"
8
- dependencies = [
9
- "typer>=0.24.1",
10
- ]
11
-
12
- [project.scripts]
13
- envrcctl = "envrcctl.main:main"
14
-
15
- [build-system]
16
- requires = ["hatchling>=1.24.2"]
17
- build-backend = "hatchling.build"
18
-
19
- [tool.hatch.build.targets.wheel]
20
- packages = ["src/envrcctl"]
21
- include = ["completions/**", "src/envrcctl/envrcctl-macos-auth"]
22
-
23
- [tool.hatch.build.targets.sdist]
24
- include = ["src/envrcctl/**", "completions/**", "scripts/**", "README.md", "LICENSE", "pyproject.toml"]
25
-
26
- [project.optional-dependencies]
27
- test = [
28
- "pytest>=9.0.2",
29
- "pytest-cov>=7.0.0",
30
- "bandit>=1.7.10",
31
- ]
@@ -1,43 +0,0 @@
1
- #!/bin/sh
2
- set -eu
3
-
4
- SCRIPT_DIR="$(CDPATH= cd -- "$(dirname "$0")" && pwd)"
5
- REPO_ROOT="$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd)"
6
- SWIFT_SOURCE="${1:-$REPO_ROOT/scripts/macos/envrcctl-macos-auth.swift}"
7
- OUTPUT_PATH="${2:-$REPO_ROOT/src/envrcctl/envrcctl-macos-auth}"
8
-
9
- if [ "$(uname -s)" != "Darwin" ]; then
10
- echo "This helper can only be built on macOS." >&2
11
- exit 1
12
- fi
13
-
14
- if ! command -v swiftc >/dev/null 2>&1; then
15
- echo "swiftc not found. Install Xcode Command Line Tools first." >&2
16
- exit 1
17
- fi
18
-
19
- if [ ! -f "$SWIFT_SOURCE" ]; then
20
- echo "Swift source not found: $SWIFT_SOURCE" >&2
21
- echo "Pass the source path as the first argument or create scripts/macos/envrcctl-macos-auth.swift." >&2
22
- exit 1
23
- fi
24
-
25
- mkdir -p "$(dirname "$OUTPUT_PATH")"
26
-
27
- echo "Building macOS auth helper..."
28
- echo " source: $SWIFT_SOURCE"
29
- echo " output: $OUTPUT_PATH"
30
-
31
- swiftc \
32
- -O \
33
- -framework LocalAuthentication \
34
- -framework Security \
35
- "$SWIFT_SOURCE" \
36
- -o "$OUTPUT_PATH"
37
-
38
- chmod 755 "$OUTPUT_PATH"
39
-
40
- echo "Build complete: $OUTPUT_PATH"
41
- echo
42
- echo "You can override the helper path at runtime with:"
43
- echo " ENVRCCTL_MACOS_AUTH_HELPER=$OUTPUT_PATH"
@@ -1,35 +0,0 @@
1
- from __future__ import annotations
2
-
3
- from pathlib import Path
4
-
5
- from click.shell_completion import get_completion_class
6
- from typer.main import get_command
7
-
8
- from envrcctl.cli import app
9
-
10
- SHELLS = ("bash", "zsh", "fish")
11
-
12
-
13
- def main() -> None:
14
- repo_root = Path(__file__).resolve().parents[1]
15
- output_dir = repo_root / "completions"
16
- output_dir.mkdir(parents=True, exist_ok=True)
17
-
18
- command = get_command(app)
19
- complete_var = "_ENVRCCTL_COMPLETE"
20
-
21
- for shell in SHELLS:
22
- comp_cls = get_completion_class(shell)
23
- if comp_cls is None:
24
- raise RuntimeError(f"Unsupported shell: {shell}")
25
- comp = comp_cls(command, {}, "envrcctl", complete_var)
26
- content = comp.source()
27
- if not content.strip():
28
- raise RuntimeError(f"Failed to generate {shell} completion.")
29
- if not content.endswith("\n"):
30
- content += "\n"
31
- (output_dir / f"envrcctl.{shell}").write_text(content, encoding="utf-8")
32
-
33
-
34
- if __name__ == "__main__":
35
- main()
@@ -1,340 +0,0 @@
1
- import Foundation
2
- import LocalAuthentication
3
- import Security
4
-
5
- enum HelperError: Error, LocalizedError {
6
- case invalidArguments(String)
7
- case authenticationUnavailable(String)
8
- case authenticationFailed(String)
9
- case keychainFailure(String)
10
- case decodeFailure
11
- case inputReadFailure(String)
12
- case outputEncodeFailure
13
-
14
- var errorDescription: String? {
15
- switch self {
16
- case .invalidArguments(let message):
17
- return message
18
- case .authenticationUnavailable(let message):
19
- return message
20
- case .authenticationFailed(let message):
21
- return message
22
- case .keychainFailure(let message):
23
- return message
24
- case .decodeFailure:
25
- return "Keychain item contains non-UTF-8 data."
26
- case .inputReadFailure(let message):
27
- return message
28
- case .outputEncodeFailure:
29
- return "Failed to encode JSON response."
30
- }
31
- }
32
- }
33
-
34
- struct Arguments {
35
- let authorizeOnly: Bool
36
- let service: String?
37
- let account: String?
38
- let inputJSONPath: String?
39
- let reason: String
40
- }
41
-
42
- struct BulkRequest: Decodable {
43
- let items: [BulkRequestItem]
44
- }
45
-
46
- struct BulkRequestItem: Decodable {
47
- let service: String
48
- let account: String
49
- }
50
-
51
- struct BulkResponse: Encodable {
52
- let items: [BulkResponseItem]
53
- }
54
-
55
- struct BulkResponseItem: Encodable {
56
- let service: String
57
- let account: String
58
- let value: String
59
- }
60
-
61
- private func printErrorAndExit(_ error: Error) -> Never {
62
- let message: String
63
- if let helperError = error as? LocalizedError, let description = helperError.errorDescription {
64
- message = description
65
- } else {
66
- message = "macOS authentication helper failed."
67
- }
68
- FileHandle.standardError.write(Data((message + "\n").utf8))
69
- exit(1)
70
- }
71
-
72
- private func printHelpAndExit() -> Never {
73
- let help = """
74
- Usage:
75
- envrcctl-macos-auth --authorize-only --reason <text>
76
- envrcctl-macos-auth --service <service> --account <account> --reason <text>
77
- envrcctl-macos-auth --input-json <path|- > --reason <text>
78
-
79
- Options:
80
- --authorize-only Require device owner authentication only.
81
- --service Keychain service name.
82
- --account Keychain account name.
83
- --input-json JSON file path or '-' for stdin for bulk reads.
84
- --reason Localized reason shown in the auth prompt.
85
- --help Show this help.
86
-
87
- Bulk JSON input:
88
- {
89
- "items": [
90
- { "service": "st.rio.envrcctl", "account": "openai:prod" },
91
- { "service": "st.rio.envrcctl", "account": "github:prod" }
92
- ]
93
- }
94
- """
95
- print(help)
96
- exit(0)
97
- }
98
-
99
- private func parseArguments(_ argv: [String]) throws -> Arguments {
100
- var authorizeOnly = false
101
- var service: String?
102
- var account: String?
103
- var inputJSONPath: String?
104
- var reason: String?
105
-
106
- var index = 1
107
- while index < argv.count {
108
- let arg = argv[index]
109
- switch arg {
110
- case "--authorize-only":
111
- authorizeOnly = true
112
- index += 1
113
- case "--service":
114
- guard index + 1 < argv.count else {
115
- throw HelperError.invalidArguments("Missing value for --service.")
116
- }
117
- service = argv[index + 1]
118
- index += 2
119
- case "--account":
120
- guard index + 1 < argv.count else {
121
- throw HelperError.invalidArguments("Missing value for --account.")
122
- }
123
- account = argv[index + 1]
124
- index += 2
125
- case "--input-json":
126
- guard index + 1 < argv.count else {
127
- throw HelperError.invalidArguments("Missing value for --input-json.")
128
- }
129
- inputJSONPath = argv[index + 1]
130
- index += 2
131
- case "--reason":
132
- guard index + 1 < argv.count else {
133
- throw HelperError.invalidArguments("Missing value for --reason.")
134
- }
135
- reason = argv[index + 1]
136
- index += 2
137
- case "--help", "-h":
138
- printHelpAndExit()
139
- default:
140
- throw HelperError.invalidArguments("Unknown argument: \(arg)")
141
- }
142
- }
143
-
144
- guard let reason, !reason.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
145
- throw HelperError.invalidArguments("A non-empty --reason is required.")
146
- }
147
-
148
- if authorizeOnly {
149
- if service != nil || account != nil || inputJSONPath != nil {
150
- throw HelperError.invalidArguments(
151
- "--authorize-only cannot be combined with --service, --account, or --input-json."
152
- )
153
- }
154
- return Arguments(
155
- authorizeOnly: true,
156
- service: nil,
157
- account: nil,
158
- inputJSONPath: nil,
159
- reason: reason
160
- )
161
- }
162
-
163
- let hasSingle = service != nil || account != nil
164
- let hasBulk = inputJSONPath != nil
165
-
166
- if hasSingle && hasBulk {
167
- throw HelperError.invalidArguments(
168
- "--input-json cannot be combined with --service or --account."
169
- )
170
- }
171
-
172
- if hasBulk {
173
- return Arguments(
174
- authorizeOnly: false,
175
- service: nil,
176
- account: nil,
177
- inputJSONPath: inputJSONPath,
178
- reason: reason
179
- )
180
- }
181
-
182
- guard let service, !service.isEmpty else {
183
- throw HelperError.invalidArguments("--service is required.")
184
- }
185
- guard let account, !account.isEmpty else {
186
- throw HelperError.invalidArguments("--account is required.")
187
- }
188
-
189
- return Arguments(
190
- authorizeOnly: false,
191
- service: service,
192
- account: account,
193
- inputJSONPath: nil,
194
- reason: reason
195
- )
196
- }
197
-
198
- private func authenticate(reason: String) throws -> LAContext {
199
- let context = LAContext()
200
- context.localizedCancelTitle = "Cancel"
201
-
202
- var canEvaluateError: NSError?
203
- guard context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &canEvaluateError) else {
204
- let message =
205
- canEvaluateError?.localizedDescription
206
- ?? "Device owner authentication is unavailable."
207
- throw HelperError.authenticationUnavailable(message)
208
- }
209
-
210
- let semaphore = DispatchSemaphore(value: 0)
211
- var authError: Error?
212
-
213
- context.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: reason) { success, error in
214
- if !success {
215
- authError = error ?? HelperError.authenticationFailed("Authentication failed.")
216
- }
217
- semaphore.signal()
218
- }
219
-
220
- semaphore.wait()
221
-
222
- if let authError {
223
- if let laError = authError as? LAError {
224
- switch laError.code {
225
- case .userCancel, .userFallback, .systemCancel, .appCancel:
226
- throw HelperError.authenticationFailed("Authentication cancelled.")
227
- default:
228
- throw HelperError.authenticationFailed(laError.localizedDescription)
229
- }
230
- }
231
- throw HelperError.authenticationFailed(authError.localizedDescription)
232
- }
233
-
234
- return context
235
- }
236
-
237
- private func readSecret(service: String, account: String, context: LAContext) throws -> String {
238
- let query: [String: Any] = [
239
- kSecClass as String: kSecClassGenericPassword,
240
- kSecAttrService as String: service,
241
- kSecAttrAccount as String: account,
242
- kSecReturnData as String: true,
243
- kSecMatchLimit as String: kSecMatchLimitOne,
244
- kSecUseAuthenticationContext as String: context,
245
- ]
246
-
247
- var item: CFTypeRef?
248
- let status = SecItemCopyMatching(query as CFDictionary, &item)
249
-
250
- guard status == errSecSuccess else {
251
- let message = SecCopyErrorMessageString(status, nil) as String? ?? "Keychain read failed."
252
- throw HelperError.keychainFailure(message)
253
- }
254
-
255
- guard let data = item as? Data else {
256
- throw HelperError.keychainFailure("Keychain returned an unexpected item type.")
257
- }
258
- guard let value = String(data: data, encoding: .utf8) else {
259
- throw HelperError.decodeFailure
260
- }
261
- return value
262
- }
263
-
264
- private func readBulkRequest(from path: String) throws -> BulkRequest {
265
- let data: Data
266
- if path == "-" {
267
- data = FileHandle.standardInput.readDataToEndOfFile()
268
- if data.isEmpty {
269
- throw HelperError.inputReadFailure("No JSON input received on stdin.")
270
- }
271
- } else {
272
- let url = URL(fileURLWithPath: path)
273
- do {
274
- data = try Data(contentsOf: url)
275
- } catch {
276
- throw HelperError.inputReadFailure("Failed to read JSON input: \(path)")
277
- }
278
- }
279
-
280
- do {
281
- let request = try JSONDecoder().decode(BulkRequest.self, from: data)
282
- if request.items.isEmpty {
283
- throw HelperError.invalidArguments("Bulk JSON input must include at least one item.")
284
- }
285
- for item in request.items {
286
- if item.service.isEmpty || item.account.isEmpty {
287
- throw HelperError.invalidArguments(
288
- "Each bulk request item must include non-empty service and account values."
289
- )
290
- }
291
- }
292
- return request
293
- } catch let helperError as HelperError {
294
- throw helperError
295
- } catch {
296
- throw HelperError.invalidArguments("Failed to decode bulk JSON input.")
297
- }
298
- }
299
-
300
- private func writeBulkResponse(_ response: BulkResponse) throws {
301
- let encoder = JSONEncoder()
302
- encoder.outputFormatting = [.sortedKeys]
303
- guard let data = try? encoder.encode(response) else {
304
- throw HelperError.outputEncodeFailure
305
- }
306
- FileHandle.standardOutput.write(data)
307
- }
308
-
309
- do {
310
- let args = try parseArguments(CommandLine.arguments)
311
- let context = try authenticate(reason: args.reason)
312
-
313
- if args.authorizeOnly {
314
- exit(0)
315
- }
316
-
317
- if let inputJSONPath = args.inputJSONPath {
318
- let request = try readBulkRequest(from: inputJSONPath)
319
- let items = try request.items.map { item in
320
- BulkResponseItem(
321
- service: item.service,
322
- account: item.account,
323
- value: try readSecret(
324
- service: item.service, account: item.account, context: context)
325
- )
326
- }
327
- try writeBulkResponse(BulkResponse(items: items))
328
- exit(0)
329
- }
330
-
331
- guard let service = args.service, let account = args.account else {
332
- throw HelperError.invalidArguments("Both --service and --account are required.")
333
- }
334
-
335
- let secret = try readSecret(service: service, account: account, context: context)
336
- FileHandle.standardOutput.write(Data(secret.utf8))
337
- exit(0)
338
- } catch {
339
- printErrorAndExit(error)
340
- }
File without changes
File without changes
File without changes
File without changes
File without changes