envrcctl 0.2.0__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.0 → envrcctl-0.2.2}/PKG-INFO +50 -61
  2. {envrcctl-0.2.0 → envrcctl-0.2.2}/README.md +19 -34
  3. envrcctl-0.2.2/pyproject.toml +56 -0
  4. {envrcctl-0.2.0 → envrcctl-0.2.2}/src/envrcctl/cli.py +15 -44
  5. envrcctl-0.2.0/.gitignore +0 -11
  6. envrcctl-0.2.0/pyproject.toml +0 -31
  7. envrcctl-0.2.0/scripts/build_macos_auth_helper.sh +0 -43
  8. envrcctl-0.2.0/scripts/generate_completions.py +0 -35
  9. envrcctl-0.2.0/scripts/macos/envrcctl-macos-auth.swift +0 -340
  10. {envrcctl-0.2.0 → envrcctl-0.2.2}/LICENSE +0 -0
  11. {envrcctl-0.2.0 → envrcctl-0.2.2}/completions/envrcctl.bash +0 -0
  12. {envrcctl-0.2.0 → envrcctl-0.2.2}/completions/envrcctl.fish +0 -0
  13. {envrcctl-0.2.0 → envrcctl-0.2.2}/completions/envrcctl.zsh +0 -0
  14. {envrcctl-0.2.0 → envrcctl-0.2.2}/src/envrcctl/__init__.py +0 -0
  15. {envrcctl-0.2.0 → envrcctl-0.2.2}/src/envrcctl/audit.py +0 -0
  16. {envrcctl-0.2.0 → envrcctl-0.2.2}/src/envrcctl/auth.py +0 -0
  17. {envrcctl-0.2.0 → envrcctl-0.2.2}/src/envrcctl/command_runner.py +0 -0
  18. {envrcctl-0.2.0 → envrcctl-0.2.2}/src/envrcctl/envrc.py +0 -0
  19. {envrcctl-0.2.0 → envrcctl-0.2.2}/src/envrcctl/envrcctl-macos-auth +0 -0
  20. {envrcctl-0.2.0 → envrcctl-0.2.2}/src/envrcctl/errors.py +0 -0
  21. {envrcctl-0.2.0 → envrcctl-0.2.2}/src/envrcctl/keychain.py +0 -0
  22. {envrcctl-0.2.0 → envrcctl-0.2.2}/src/envrcctl/main.py +0 -0
  23. {envrcctl-0.2.0 → envrcctl-0.2.2}/src/envrcctl/managed_block.py +0 -0
  24. {envrcctl-0.2.0 → envrcctl-0.2.2}/src/envrcctl/secrets.py +0 -0
  25. {envrcctl-0.2.0 → 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.0
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
@@ -63,16 +67,21 @@ It is designed for macOS first, with Linux support via SecretService.
63
67
 
64
68
  ## Installation
65
69
 
66
- ### macOS (Homebrew)
70
+ ### macOS (Homebrew, Apple Silicon)
67
71
 
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
- After release, Homebrew will download the release from GitHub.
79
+ This Homebrew path is intended for:
80
+
81
+ - Apple Silicon (`arm64`) Macs
82
+ - macOS installs that should not require a full Xcode.app build dependency
83
+
84
+ Intel Macs are not a target for this Homebrew distribution path.
76
85
 
77
86
  Install direnv with Homebrew:
78
87
 
@@ -92,26 +101,25 @@ pipx install envrcctl
92
101
  uv tool install envrcctl
93
102
  ```
94
103
 
95
- ### From source (macOS/Linux)
96
-
97
- ```sh
98
- git clone <REPO_URL>
99
- cd envrcctl
100
- uv sync
101
- uv run python -m envrcctl.main --help
102
- ```
103
-
104
- ### Build the macOS auth helper (macOS only)
104
+ ### About the macOS auth helper (Apple Silicon macOS only)
105
105
 
106
106
  The macOS device owner authentication flow requires a native helper named
107
107
  `envrcctl-macos-auth`.
108
108
 
109
- Build it and place the binary at either:
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.
111
+
112
+ Manual helper installation is still useful when:
113
+
114
+ - you want to place the helper in a custom location
115
+ - you are not using the Apple Silicon Homebrew distribution path
116
+
117
+ If you are building the helper yourself, use Apple Silicon (`arm64`) macOS and place the binary at either:
110
118
 
111
119
  - `src/envrcctl/envrcctl-macos-auth`
112
120
  - or a custom path set via `ENVRCCTL_MACOS_AUTH_HELPER`
113
121
 
114
- Example build flow:
122
+ Example build flow on Apple Silicon macOS:
115
123
 
116
124
  ```sh
117
125
  swiftc -O -framework LocalAuthentication -framework Security \
@@ -120,20 +128,6 @@ swiftc -O -framework LocalAuthentication -framework Security \
120
128
  chmod +x src/envrcctl/envrcctl-macos-auth
121
129
  ```
122
130
 
123
- You can also use the repository build script:
124
-
125
- ```sh
126
- sh scripts/build_macos_auth_helper.sh
127
- ```
128
-
129
- If you want to write the helper to a custom location, pass the source and output paths explicitly:
130
-
131
- ```sh
132
- sh scripts/build_macos_auth_helper.sh \
133
- scripts/macos/envrcctl-macos-auth.swift \
134
- /usr/local/bin/envrcctl-macos-auth
135
- ```
136
-
137
131
  If you install the helper elsewhere, set:
138
132
 
139
133
  ```sh
@@ -380,12 +374,7 @@ uv run python scripts/generate_completions.py
380
374
  - Audit integrity is based on a hash chain and can be checked with `envrcctl audit verify`
381
375
  - The tool refuses to write to world-writable `.envrc`
382
376
 
383
- ## Development
384
377
 
385
- ```sh
386
- uv sync
387
- .venv/bin/envrcctl --help
388
- ```
389
378
 
390
379
  ## Acknowledgements
391
380
 
@@ -29,16 +29,21 @@ It is designed for macOS first, with Linux support via SecretService.
29
29
 
30
30
  ## Installation
31
31
 
32
- ### macOS (Homebrew)
32
+ ### macOS (Homebrew, Apple Silicon)
33
33
 
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
- After release, Homebrew will download the release from GitHub.
41
+ This Homebrew path is intended for:
42
+
43
+ - Apple Silicon (`arm64`) Macs
44
+ - macOS installs that should not require a full Xcode.app build dependency
45
+
46
+ Intel Macs are not a target for this Homebrew distribution path.
42
47
 
43
48
  Install direnv with Homebrew:
44
49
 
@@ -58,26 +63,25 @@ pipx install envrcctl
58
63
  uv tool install envrcctl
59
64
  ```
60
65
 
61
- ### From source (macOS/Linux)
62
-
63
- ```sh
64
- git clone <REPO_URL>
65
- cd envrcctl
66
- uv sync
67
- uv run python -m envrcctl.main --help
68
- ```
69
-
70
- ### Build the macOS auth helper (macOS only)
66
+ ### About the macOS auth helper (Apple Silicon macOS only)
71
67
 
72
68
  The macOS device owner authentication flow requires a native helper named
73
69
  `envrcctl-macos-auth`.
74
70
 
75
- Build it and place the binary at either:
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.
73
+
74
+ Manual helper installation is still useful when:
75
+
76
+ - you want to place the helper in a custom location
77
+ - you are not using the Apple Silicon Homebrew distribution path
78
+
79
+ If you are building the helper yourself, use Apple Silicon (`arm64`) macOS and place the binary at either:
76
80
 
77
81
  - `src/envrcctl/envrcctl-macos-auth`
78
82
  - or a custom path set via `ENVRCCTL_MACOS_AUTH_HELPER`
79
83
 
80
- Example build flow:
84
+ Example build flow on Apple Silicon macOS:
81
85
 
82
86
  ```sh
83
87
  swiftc -O -framework LocalAuthentication -framework Security \
@@ -86,20 +90,6 @@ swiftc -O -framework LocalAuthentication -framework Security \
86
90
  chmod +x src/envrcctl/envrcctl-macos-auth
87
91
  ```
88
92
 
89
- You can also use the repository build script:
90
-
91
- ```sh
92
- sh scripts/build_macos_auth_helper.sh
93
- ```
94
-
95
- If you want to write the helper to a custom location, pass the source and output paths explicitly:
96
-
97
- ```sh
98
- sh scripts/build_macos_auth_helper.sh \
99
- scripts/macos/envrcctl-macos-auth.swift \
100
- /usr/local/bin/envrcctl-macos-auth
101
- ```
102
-
103
93
  If you install the helper elsewhere, set:
104
94
 
105
95
  ```sh
@@ -346,12 +336,7 @@ uv run python scripts/generate_completions.py
346
336
  - Audit integrity is based on a hash chain and can be checked with `envrcctl audit verify`
347
337
  - The tool refuses to write to world-writable `.envrc`
348
338
 
349
- ## Development
350
339
 
351
- ```sh
352
- uv sync
353
- .venv/bin/envrcctl --help
354
- ```
355
340
 
356
341
  ## Acknowledgements
357
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.0/.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.0"
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