Skip to content

Commit 9567b3d

Browse files
takemi-ohamaclaude
andauthored
feat(env): PLAN03-1 PR1 devbase env export (Local + Stdio) (#14)
* chore(PLAN03-1-export-local): Draft PR 作成 * feat(env): devbase env export を追加 (PLAN03-1 PR1) - lib/devbase/env/bundle.py: tar.gz + manifest.yml バンドル構築/展開、sha256 検証、未知 version 拒否、パストラバーサル拒否 - lib/devbase/env/cipher.py: pyrage 経由の age 暗号化/復号 (X25519 / OpenSSH ed25519,rsa / passphrase / @path 参照) - lib/devbase/env/storage.py: Local + Stdio backend、s3/gs は本 PR では未実装で明示エラー - lib/devbase/env/io_export.py: 機密キー検知警告、既定鍵 (~/.ssh/id_rsa.pub) 自動利用、--passphrase-stdin と DEST='-' 併用拒否 - cli.py / commands/env.py: env export サブコマンド登録 + SUBCMD_MAP 更新 - pyproject.toml: pyrage>=1.2 を deps、pytest>=8.0 を dev group、tool.pytest.ini_options 追加 - tests/env, tests/cli: ラウンドトリップ + 異常系 28 件 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(env): レビュー指摘の修正 (storage/bundle/cipher) - storage.py: LocalBackend で file:// URI を url2pathname で実パスへ変換 - bundle.py: manifest.files の要素型 (dict, path: str, sha256: str) を検証 - cipher.py: age 秘密鍵判定をバイト列で行い、UTF-8 デコード失敗を明示エラー化 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(env): round 2 レビュー指摘の修正 (堅牢性 + test 追加) - storage: file:// URI の netloc が空/localhost 以外なら StorageError で拒否 (codex major) - bundle: tar 内の重複エントリを BundleError で検出 (codex major) - cipher: _resolve_recipient の @path 再帰に深さ制限 (上限 5) を追加 (gemini minor) - tests/storage: file:// URI roundtrip と remote host 拒否の test を追加 (gemini minor) - tests/bundle: _validate_manifest 不正系 (files が list でない / entry が dict でない / path 不正 / sha256 不正) + 重複エントリの test を追加 (gemini minor) - tests/cipher: @path 循環参照で CipherError を返す test を追加 (gemini minor) * fix(env): sha256 必須化と ed25519 デフォルト鍵対応 (round 3) - bundle.py: manifest.files[*].sha256 を必須の 64 文字 16 進文字列として検証 None / 欠落 / 長さ違い / 非 16 進は BundleError。完全性チェック迂回を防止 - cipher.py: default_recipient_paths / default_identity_paths に ed25519 (id_ed25519.pub / id_ed25519) を追加し、rsa より優先 - tests: sha256 欠落 / None / 長さ違い / 非 16 進の異常系テストを追加 - tests: ed25519 がデフォルトパス候補に含まれ rsa より優先されることを検証 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(env): round 4 レビュー指摘の修正 (異常系の堅牢化) - bundle: yaml.safe_load の結果が dict でない場合に BundleError を送出 (top-level が list/str/数値の場合に AttributeError が漏れるのを防止) - cipher: @path 参照ファイルが UTF-8 でない場合 CipherError に包んで再送出 (UnicodeDecodeError が呼び出し側に漏れていた) - storage.resolve: Windows ドライブレター (C:\path 等) を urlparse が scheme と誤認する問題に対応し LocalBackend にフォールバック 各修正に対応する異常系 test を追加 (合計 +5 test)。 * fix(env): _resolve_identity の OSError を CipherError に包む (round 5) - lib/devbase/env/cipher.py: path.read_bytes() を try/except OSError で ラップし、I/O エラー時も CipherError で統一されたエラー型を返す - tests/env/test_cipher.py: monkeypatch で read_bytes に OSError を 発生させて CipherError に包まれることを検証する test を追加 gemini round 5 指摘 (minor / 堅牢性) に対応。 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(env): round 6 レビュー指摘の修正 (決定性 + 完全性 + 堅牢性) - bundle.pack: gzip.GzipFile(mtime=0) でラップし出力を完全に決定的にする - bundle._validate_manifest: tar 内ファイルセットと manifest の完全一致を 検証し、manifest に記載のない未知ファイルを BundleError で拒否する - cipher._resolve_recipient: @path の read_text で発生する OSError を CipherError に包んで一貫したエラーハンドリングにする - cipher._resolve_identity: OpenSSH ヘッダで先に SSH 鍵を判別する分岐を 追加し、鍵形式判別を明示化 (将来の形式追加もしやすくする) - tests: pack 決定性 / unknown file 拒否 / @path OSError ラップ / OpenSSH ヘッダ優先判別の test を追加 * fix(env): @path 参照ファイルのコメント・空行をスキップする (round 6 追加) recipient ファイルにコメント (# 始まり) や空行が混在していても扱えるよう、 有効な最初の行のみを採用する。テストも追加。 * fix(env): round 1 レビュー指摘の修正 (TOCTOU + BundleError 統一 + prefix 互換 + completion) - storage.py: LocalBackend.write_bytes を os.open(mode=0o600, O_CREAT|O_TRUNC|O_WRONLY) で 作成時点から 0600 を強制し、umask に依らない TOCTOU 安全な書き込みに変更 (codex major / gemini minor — 同一指摘)。既存ファイル上書き時も先に chmod で権限を絞る。 read_bytes / write_bytes の OSError を StorageError にラップ (gemini minor)。 - bundle.py: unpack() の tarfile.open / getmembers / extractfile で発生する tarfile.TarError / OSError を BundleError にラップ (gemini major)。 make_entries_from_disk の exists() を is_file() に変更し、対象パスが ディレクトリだった場合の IsADirectoryError を防止 (gemini minor)。 _validate_manifest に manifest.files の path 重複検出を追加 (codex minor)。 - cli.py: SUBCMD_PREFIX_PREFERENCES を追加し、`devbase env e` が引き続き edit に 解決されるように prefix 解決の後方互換を維持 (codex minor)。 - etc/devbase-completion.bash, etc/_devbase: env export サブコマンドと 各オプションを補完に追加 (codex minor)。 - tests: storage の TOCTOU / OSError ラップ / 既存ファイル 0600 上書き、 bundle の path 重複 / 壊れた tar / is_file 切替、CLI prefix の後方互換テストを追加。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(env): round 2 advisory レビュー指摘の修正 (docstring / help / stdin prompt) - io_export.py: `_resolve_recipients` の docstring を更新し、既定鍵が `id_ed25519.pub` → `id_rsa.pub` の優先順で探索される実態に合わせる - cli.py: `--recipient` の help を `Default: ~/.ssh/id_ed25519.pub, then ~/.ssh/id_rsa.pub (first existing one)` に修正 - io_export.py: `--passphrase-stdin` で `sys.stdin.isatty()` の場合に `passphrase: ` プロンプトを stderr に表示し、対話実行時のハング感を解消 - 暗号化キー未指定エラーメッセージも ed25519 優先を反映 - tests/cli/test_env_export.py: tty / 非 tty 双方の挙動を検証する 2 ケース追加 Refs: PR #14 review comments 3280597873 / 3280597877 / 3280597881 --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 5861ff0 commit 9567b3d

18 files changed

Lines changed: 1887 additions & 8 deletions

etc/_devbase

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ _devbase() {
7373
'delete:Delete a variable'
7474
'edit:Open .env in editor'
7575
'project:Setup project-specific variables'
76+
'export:Export .env files as an encrypted bundle (age)'
7677
)
7778

7879
plugin_subcommands=(
@@ -150,6 +151,18 @@ _devbase() {
150151
get|delete)
151152
_arguments '1:key:'
152153
;;
154+
export)
155+
_arguments \
156+
'1:dest:_files' \
157+
'*--include-project[Limit to specified project (repeatable)]:name:' \
158+
'*--exclude-project[Exclude project (repeatable)]:name:' \
159+
'--no-global[Exclude $DEVBASE_ROOT/.env]' \
160+
'--no-metadata[Exclude $DEVBASE_ROOT/.env.sources.yml]' \
161+
'*--recipient[age / OpenSSH public key (repeatable)]:key:' \
162+
'--passphrase-env[Read passphrase from env var]:var:' \
163+
'--passphrase-stdin[Read passphrase from stdin]' \
164+
'--force-unencrypted[Write as plaintext tar.gz]'
165+
;;
153166
*)
154167
_describe -t env-commands 'env command' env_subcommands
155168
;;

etc/devbase-completion.bash

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ _devbase_completions() {
1212

1313
local commands="init status shell-rc container ct env plugin pl snapshot ss up down login build ps help"
1414
local container_subcommands="up down ps login logs scale build"
15-
local env_subcommands="init sync list set get delete edit project"
15+
local env_subcommands="init sync list set get delete edit project export"
1616
local plugin_subcommands="list install uninstall update info sync repo"
1717
local repo_subcommands="add remove list refresh"
1818
local snapshot_subcommands="create list restore copy delete rotate"
@@ -81,6 +81,11 @@ _devbase_completions() {
8181
COMPREPLY=($(compgen -W "--project -p" -- "$cur"))
8282
fi
8383
;;
84+
export)
85+
if [[ "$cur" == -* ]]; then
86+
COMPREPLY=($(compgen -W "--include-project --exclude-project --no-global --no-metadata --recipient --passphrase-env --passphrase-stdin --force-unencrypted" -- "$cur"))
87+
fi
88+
;;
8489
esac
8590
fi
8691
# plugin subcommand arguments

lib/devbase/cli.py

Lines changed: 55 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -36,11 +36,20 @@
3636
# Subcommand map for prefix resolution: {(aliases...): [subcmds]}
3737
SUBCMD_MAP = {
3838
('container', 'ct'): ['up', 'down', 'ps', 'login', 'logs', 'scale', 'build'],
39-
('env',): ['init', 'sync', 'list', 'set', 'get', 'delete', 'edit', 'project'],
39+
('env',): ['init', 'sync', 'list', 'set', 'get', 'delete', 'edit', 'project', 'export'],
4040
('plugin', 'pl'): ['list', 'install', 'uninstall', 'update', 'info', 'sync', 'repo'],
4141
('snapshot', 'ss'): ['create', 'list', 'restore', 'copy', 'delete', 'rotate'],
4242
}
4343

44+
# 後方互換: prefix が複数候補にマッチする場合に、特定の入力を特定のサブコマンドに
45+
# 優先的に解決させる。例えば `devbase env e` は従来 `edit` のみに解決されていたが、
46+
# `export` 追加後は ambiguous になるため、既存ショートカットを維持するために維持先を明示する。
47+
SUBCMD_PREFIX_PREFERENCES = {
48+
('env',): {
49+
'e': 'edit',
50+
},
51+
}
52+
4453

4554
def _require_devbase_root() -> Path:
4655
"""Get DEVBASE_ROOT from environment, exiting if not set."""
@@ -109,6 +118,37 @@ def _add_env_parser(subparsers):
109118
env_sub.add_parser('edit', help='Open .env in editor')
110119
env_sub.add_parser('project', help='Setup project-specific variables')
111120

121+
env_export = env_sub.add_parser(
122+
'export',
123+
help='Export .env files as an encrypted bundle (age)',
124+
)
125+
env_export.add_argument('dest', nargs='?', default=None,
126+
help="Output path (default: ./devbase-env-<TS>.dbenv, '-' for stdout)")
127+
env_export.add_argument('--include-project', action='append', default=None,
128+
metavar='NAME', dest='include_projects',
129+
help='Limit to specified project (repeatable)')
130+
env_export.add_argument('--exclude-project', action='append', default=[],
131+
metavar='NAME', dest='exclude_projects',
132+
help='Exclude project (repeatable)')
133+
env_export.add_argument('--no-global', action='store_true',
134+
help='Exclude $DEVBASE_ROOT/.env')
135+
env_export.add_argument('--no-metadata', action='store_true',
136+
help='Exclude $DEVBASE_ROOT/.env.sources.yml')
137+
env_export.add_argument('--recipient', action='append', default=[],
138+
metavar='KEY', dest='recipients',
139+
help=("age / OpenSSH public key (repeatable). "
140+
"Formats: 'age1...', 'ssh-ed25519 AAAA...', 'ssh-rsa AAAA...', "
141+
"'@PATH' for file reference. "
142+
"Default: ~/.ssh/id_ed25519.pub, then ~/.ssh/id_rsa.pub "
143+
"(first existing one)"))
144+
env_export.add_argument('--passphrase-env', metavar='VAR', default=None,
145+
help='Read passphrase from environment variable VAR')
146+
env_export.add_argument('--passphrase-stdin', action='store_true',
147+
help='Read passphrase from the first line of stdin')
148+
env_export.add_argument('--force-unencrypted', action='store_true',
149+
help='Write as plaintext tar.gz (rejected by default; '
150+
'warns when sensitive keys are detected)')
151+
112152

113153
def _add_plugin_parser(subparsers):
114154
"""Plugin group parser"""
@@ -250,14 +290,22 @@ def _create_parser():
250290
return parser
251291

252292

253-
def _resolve_prefix(input_cmd, candidates):
293+
def _resolve_prefix(input_cmd, candidates, preferences=None):
254294
"""Resolve an abbreviated command to its full name via unique prefix matching.
255295
256-
Returns the full command name if exactly one candidate matches,
257-
otherwise returns the input as-is (ambiguous or no match).
296+
Returns the full command name if exactly one candidate matches.
297+
If ambiguous, falls back to `preferences[input_cmd]` (if provided) to keep
298+
backward compatibility with previously-unique abbreviations.
299+
Otherwise returns the input as-is.
258300
"""
259301
matches = [c for c in candidates if c.startswith(input_cmd)]
260-
return matches[0] if len(matches) == 1 else input_cmd
302+
if len(matches) == 1:
303+
return matches[0]
304+
if preferences and input_cmd in preferences:
305+
preferred = preferences[input_cmd]
306+
if preferred in matches:
307+
return preferred
308+
return input_cmd
261309

262310

263311
def _expand_argv():
@@ -273,7 +321,8 @@ def _expand_argv():
273321
cmd = sys.argv[1]
274322
for aliases, subcmds in SUBCMD_MAP.items():
275323
if cmd in aliases:
276-
sys.argv[2] = _resolve_prefix(sys.argv[2], subcmds)
324+
preferences = SUBCMD_PREFIX_PREFERENCES.get(aliases)
325+
sys.argv[2] = _resolve_prefix(sys.argv[2], subcmds, preferences)
277326
break
278327

279328
# plugin repo sub-subcommand

lib/devbase/commands/env.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ def cmd_env(devbase_root: Path, args) -> int:
3333
'delete': lambda: cmd_env_delete(devbase_root, getattr(args, 'key', '')),
3434
'edit': lambda: cmd_env_edit(devbase_root),
3535
'project': lambda: cmd_env_project(devbase_root),
36+
'export': lambda: cmd_env_export(devbase_root, args),
3637
}
3738

3839
handler = handlers.get(subcmd)
@@ -382,6 +383,24 @@ def cmd_env_project(devbase_root: Path) -> int:
382383
return 0
383384

384385

386+
def cmd_env_export(devbase_root: Path, args) -> int:
387+
"""devbase env export"""
388+
from devbase.env.io_export import ExportOptions, export
389+
390+
opts = ExportOptions(
391+
dest=getattr(args, 'dest', None),
392+
include_global=not getattr(args, 'no_global', False),
393+
include_metadata=not getattr(args, 'no_metadata', False),
394+
include_projects=getattr(args, 'include_projects', None),
395+
exclude_projects=list(getattr(args, 'exclude_projects', []) or []),
396+
recipients=list(getattr(args, 'recipients', []) or []),
397+
passphrase_env=getattr(args, 'passphrase_env', None),
398+
passphrase_stdin=getattr(args, 'passphrase_stdin', False),
399+
force_unencrypted=getattr(args, 'force_unencrypted', False),
400+
)
401+
return export(devbase_root, opts)
402+
403+
385404
def _update_source_metadata(devbase_root: Path, env_file: EnvFile) -> None:
386405
"""ソースメタデータを更新する"""
387406
sources = SourcesManager(devbase_root)

0 commit comments

Comments
 (0)