Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions etc/_devbase
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ _devbase() {
'edit:Open .env in editor'
'project:Setup project-specific variables'
'export:Export .env files as an encrypted bundle (age)'
'import:Import .env bundle (age decrypt + merge)'
)

plugin_subcommands=(
Expand Down Expand Up @@ -163,6 +164,24 @@ _devbase() {
'--passphrase-stdin[Read passphrase from stdin]' \
'--force-unencrypted[Write as plaintext tar.gz]'
;;
import)
_arguments \
'1:source:_files' \
'--merge[Merge strategy]:mode:(keep-existing prefer-incoming)' \
'--replace-keys[Replace only these keys (comma-separated)]:keys:' \
'--replace[Replace existing files entirely]' \
'--dry-run[Preview changes without writing]' \
'*--identity[age / OpenSSH private key file (repeatable)]:file:_files' \
'--passphrase-env[Read passphrase from env var]:var:' \
'--passphrase-stdin[Read passphrase from stdin]' \
'*--include-project[Limit to specified project (repeatable)]:name:' \
'*--exclude-project[Exclude project (repeatable)]:name:' \
'--no-global[Do not import $DEVBASE_ROOT/.env]' \
'--no-metadata[Do not import $DEVBASE_ROOT/.env.sources.yml]' \
'--merge-metadata[Add only new sources entries from bundle]' \
'--backup-dir[Override backup directory]:dir:_files -/' \
'--keep-last[Keep only the last N backup directories]:n:'
;;
*)
_describe -t env-commands 'env command' env_subcommands
;;
Expand Down
7 changes: 6 additions & 1 deletion etc/devbase-completion.bash
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ _devbase_completions() {

local commands="init status shell-rc container ct env plugin pl snapshot ss up down login build ps help"
local container_subcommands="up down ps login logs scale build"
local env_subcommands="init sync list set get delete edit project export"
local env_subcommands="init sync list set get delete edit project export import"
local plugin_subcommands="list install uninstall update info sync repo"
local repo_subcommands="add remove list refresh"
local snapshot_subcommands="create list restore copy delete rotate"
Expand Down Expand Up @@ -86,6 +86,11 @@ _devbase_completions() {
COMPREPLY=($(compgen -W "--include-project --exclude-project --no-global --no-metadata --recipient --passphrase-env --passphrase-stdin --force-unencrypted" -- "$cur"))
fi
;;
import)
if [[ "$cur" == -* ]]; then
COMPREPLY=($(compgen -W "--merge --replace-keys --replace --dry-run --identity --passphrase-env --passphrase-stdin --include-project --exclude-project --no-global --no-metadata --merge-metadata --backup-dir --keep-last" -- "$cur"))
fi
;;
esac
fi
# plugin subcommand arguments
Expand Down
54 changes: 53 additions & 1 deletion lib/devbase/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
# Subcommand map for prefix resolution: {(aliases...): [subcmds]}
SUBCMD_MAP = {
('container', 'ct'): ['up', 'down', 'ps', 'login', 'logs', 'scale', 'build'],
('env',): ['init', 'sync', 'list', 'set', 'get', 'delete', 'edit', 'project', 'export'],
('env',): ['init', 'sync', 'list', 'set', 'get', 'delete', 'edit', 'project', 'export', 'import'],
Comment thread
takemi-ohama marked this conversation as resolved.
Comment thread
takemi-ohama marked this conversation as resolved.
('plugin', 'pl'): ['list', 'install', 'uninstall', 'update', 'info', 'sync', 'repo'],
('snapshot', 'ss'): ['create', 'list', 'restore', 'copy', 'delete', 'rotate'],
}
Expand All @@ -47,6 +47,9 @@
SUBCMD_PREFIX_PREFERENCES = {
('env',): {
'e': 'edit',
# `import` 追加で `i` が `init` / `import` の両方にマッチして ambiguous に
# なるため、既存ショートカット (`devbase env i` → `init`) を維持する。
'i': 'init',
},
}

Expand Down Expand Up @@ -149,6 +152,55 @@ def _add_env_parser(subparsers):
help='Write as plaintext tar.gz (rejected by default; '
'warns when sensitive keys are detected)')

env_import = env_sub.add_parser(
'import',
help='Import .env files from a bundle (age-encrypted or plaintext tar.gz)',
)
env_import.add_argument('source',
help="Bundle path or '-' for stdin")
env_import.add_argument('--merge', choices=['keep-existing', 'prefer-incoming'],
default='keep-existing',
help=("Key-level merge mode. keep-existing (default) keeps "
"existing keys and adds new ones; prefer-incoming "
"overwrites with bundle values"))
env_import.add_argument('--replace-keys', metavar='KEYS', default='',
help=("Comma-separated keys to force-overwrite from bundle "
"(other keys behave like keep-existing). "
"Cannot be combined with --replace"))
env_import.add_argument('--replace', action='store_true',
help='Replace each target .env file wholesale (backup is taken)')
env_import.add_argument('--dry-run', action='store_true',
help='Show planned diff without writing')
env_import.add_argument('--identity', action='append', default=[],
metavar='FILE', dest='identities',
help=("age / OpenSSH private key file (repeatable). "
"Default: ~/.ssh/id_ed25519, then ~/.ssh/id_rsa "
"(first existing one)"))
env_import.add_argument('--passphrase-env', metavar='VAR', default=None,
help='Read passphrase from environment variable VAR')
env_import.add_argument('--passphrase-stdin', action='store_true',
help='Read passphrase from the first line of stdin')
env_import.add_argument('--include-project', action='append', default=None,
metavar='NAME', dest='include_projects',
help='Limit to specified project (repeatable)')
env_import.add_argument('--exclude-project', action='append', default=[],
metavar='NAME', dest='exclude_projects',
help='Exclude project (repeatable)')
env_import.add_argument('--no-global', action='store_true',
help='Do not import $DEVBASE_ROOT/.env')
env_import.add_argument('--no-metadata', action='store_true',
help='Do not import $DEVBASE_ROOT/.env.sources.yml '
'(default behavior is reference-only copy; this fully ignores it)')
env_import.add_argument('--merge-metadata', action='store_true',
help='Merge new source entries into existing .env.sources.yml '
'(machine-specific fields are preserved as-is from bundle; '
'run `devbase env sync` after import to refresh)')
env_import.add_argument('--backup-dir', metavar='DIR', default=None,
help='Override backup directory '
'(default: $DEVBASE_ROOT/backups/env-import/<ts>)')
env_import.add_argument('--keep-last', type=int, default=10, metavar='N',
help='Keep only the last N backup directories (default: 10, 0 to disable)')


def _add_plugin_parser(subparsers):
"""Plugin group parser"""
Expand Down
28 changes: 28 additions & 0 deletions lib/devbase/commands/env.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ def cmd_env(devbase_root: Path, args) -> int:
'edit': lambda: cmd_env_edit(devbase_root),
'project': lambda: cmd_env_project(devbase_root),
'export': lambda: cmd_env_export(devbase_root, args),
'import': lambda: cmd_env_import(devbase_root, args),
}

handler = handlers.get(subcmd)
Expand Down Expand Up @@ -401,6 +402,33 @@ def cmd_env_export(devbase_root: Path, args) -> int:
return export(devbase_root, opts)


def cmd_env_import(devbase_root: Path, args) -> int:
"""devbase env import"""
from devbase.env.io_import import ImportOptions, import_bundle

replace_keys_arg = getattr(args, 'replace_keys', '') or ''
replace_keys = [k.strip() for k in replace_keys_arg.split(',') if k.strip()]

opts = ImportOptions(
source=getattr(args, 'source'),
merge=getattr(args, 'merge', 'keep-existing'),
replace_keys=replace_keys,
replace=getattr(args, 'replace', False),
dry_run=getattr(args, 'dry_run', False),
identities=list(getattr(args, 'identities', []) or []),
passphrase_env=getattr(args, 'passphrase_env', None),
passphrase_stdin=getattr(args, 'passphrase_stdin', False),
include_projects=getattr(args, 'include_projects', None),
exclude_projects=list(getattr(args, 'exclude_projects', []) or []),
include_global=not getattr(args, 'no_global', False),
include_metadata=not getattr(args, 'no_metadata', False),
merge_metadata=getattr(args, 'merge_metadata', False),
backup_dir=getattr(args, 'backup_dir', None),
keep_last=getattr(args, 'keep_last', 10),
)
return import_bundle(devbase_root, opts)


def _update_source_metadata(devbase_root: Path, env_file: EnvFile) -> None:
"""ソースメタデータを更新する"""
sources = SourcesManager(devbase_root)
Expand Down
12 changes: 8 additions & 4 deletions lib/devbase/env/io_export.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@

from __future__ import annotations

import getpass
import os
import re
import sys
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
Expand Down Expand Up @@ -69,11 +71,13 @@ def _read_passphrase(opts: ExportOptions) -> Optional[str]:
)
return value
if opts.passphrase_stdin:
import sys
# tty で対話実行している場合、ユーザーが「ハングしている」と誤解しないよう
# stderr へプロンプトを出してから stdin を待つ (パイプ入力時は出さない)。
# tty で対話実行している場合は getpass.getpass でエコー抑止
# (パイプ入力時は echo の概念がないので従来どおり stdin.readline で読む)。
if sys.stdin.isatty():
print("passphrase: ", end='', file=sys.stderr, flush=True)
try:
return getpass.getpass("passphrase: ", stream=sys.stderr)
except EOFError as e:
raise ExportError("stdin からパスフレーズを読み取れませんでした") from e
line = sys.stdin.readline()
if not line:
raise ExportError("stdin からパスフレーズを読み取れませんでした")
Expand Down
Loading