3636# Subcommand map for prefix resolution: {(aliases...): [subcmds]}
3737SUBCMD_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' , 'import' ],
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+ # `import` 追加で `i` が `init` / `import` の両方にマッチして ambiguous に
51+ # なるため、既存ショートカット (`devbase env i` → `init`) を維持する。
52+ 'i' : 'init' ,
53+ },
54+ }
55+
4456
4557def _require_devbase_root () -> Path :
4658 """Get DEVBASE_ROOT from environment, exiting if not set."""
@@ -109,6 +121,90 @@ def _add_env_parser(subparsers):
109121 env_sub .add_parser ('edit' , help = 'Open .env in editor' )
110122 env_sub .add_parser ('project' , help = 'Setup project-specific variables' )
111123
124+ env_export = env_sub .add_parser (
125+ 'export' ,
126+ help = 'Export .env files as an encrypted bundle (age)' ,
127+ )
128+ env_export .add_argument ('dest' , nargs = '?' , default = None ,
129+ help = "Output path (default: ./devbase-env-<TS>.dbenv, '-' for stdout)" )
130+ env_export .add_argument ('--include-project' , action = 'append' , default = None ,
131+ metavar = 'NAME' , dest = 'include_projects' ,
132+ help = 'Limit to specified project (repeatable)' )
133+ env_export .add_argument ('--exclude-project' , action = 'append' , default = [],
134+ metavar = 'NAME' , dest = 'exclude_projects' ,
135+ help = 'Exclude project (repeatable)' )
136+ env_export .add_argument ('--no-global' , action = 'store_true' ,
137+ help = 'Exclude $DEVBASE_ROOT/.env' )
138+ env_export .add_argument ('--no-metadata' , action = 'store_true' ,
139+ help = 'Exclude $DEVBASE_ROOT/.env.sources.yml' )
140+ env_export .add_argument ('--recipient' , action = 'append' , default = [],
141+ metavar = 'KEY' , dest = 'recipients' ,
142+ help = ("age / OpenSSH public key (repeatable). "
143+ "Formats: 'age1...', 'ssh-ed25519 AAAA...', 'ssh-rsa AAAA...', "
144+ "'@PATH' for file reference. "
145+ "Default: ~/.ssh/id_ed25519.pub, then ~/.ssh/id_rsa.pub "
146+ "(first existing one)" ))
147+ env_export .add_argument ('--passphrase-env' , metavar = 'VAR' , default = None ,
148+ help = 'Read passphrase from environment variable VAR' )
149+ env_export .add_argument ('--passphrase-stdin' , action = 'store_true' ,
150+ help = 'Read passphrase from the first line of stdin' )
151+ env_export .add_argument ('--force-unencrypted' , action = 'store_true' ,
152+ help = 'Write as plaintext tar.gz (rejected by default; '
153+ 'warns when sensitive keys are detected)' )
154+ env_export .add_argument ('--unsafe-allow-unencrypted-bucket' , action = 'store_true' ,
155+ help = 'Allow S3 export to buckets without default encryption '
156+ '(per-object SSE is always applied regardless of this flag). '
157+ 'Has no effect for non-s3:// destinations.' )
158+
159+ env_import = env_sub .add_parser (
160+ 'import' ,
161+ help = 'Import .env files from a bundle (age-encrypted or plaintext tar.gz)' ,
162+ )
163+ env_import .add_argument ('source' ,
164+ help = "Bundle path or '-' for stdin" )
165+ env_import .add_argument ('--merge' , choices = ['keep-existing' , 'prefer-incoming' ],
166+ default = 'keep-existing' ,
167+ help = ("Key-level merge mode. keep-existing (default) keeps "
168+ "existing keys and adds new ones; prefer-incoming "
169+ "overwrites with bundle values" ))
170+ env_import .add_argument ('--replace-keys' , metavar = 'KEYS' , default = '' ,
171+ help = ("Comma-separated keys to force-overwrite from bundle "
172+ "(other keys behave like keep-existing). "
173+ "Cannot be combined with --replace" ))
174+ env_import .add_argument ('--replace' , action = 'store_true' ,
175+ help = 'Replace each target .env file wholesale (backup is taken)' )
176+ env_import .add_argument ('--dry-run' , action = 'store_true' ,
177+ help = 'Show planned diff without writing' )
178+ env_import .add_argument ('--identity' , action = 'append' , default = [],
179+ metavar = 'FILE' , dest = 'identities' ,
180+ help = ("age / OpenSSH private key file (repeatable). "
181+ "Default: ~/.ssh/id_ed25519, then ~/.ssh/id_rsa "
182+ "(first existing one)" ))
183+ env_import .add_argument ('--passphrase-env' , metavar = 'VAR' , default = None ,
184+ help = 'Read passphrase from environment variable VAR' )
185+ env_import .add_argument ('--passphrase-stdin' , action = 'store_true' ,
186+ help = 'Read passphrase from the first line of stdin' )
187+ env_import .add_argument ('--include-project' , action = 'append' , default = None ,
188+ metavar = 'NAME' , dest = 'include_projects' ,
189+ help = 'Limit to specified project (repeatable)' )
190+ env_import .add_argument ('--exclude-project' , action = 'append' , default = [],
191+ metavar = 'NAME' , dest = 'exclude_projects' ,
192+ help = 'Exclude project (repeatable)' )
193+ env_import .add_argument ('--no-global' , action = 'store_true' ,
194+ help = 'Do not import $DEVBASE_ROOT/.env' )
195+ env_import .add_argument ('--no-metadata' , action = 'store_true' ,
196+ help = 'Do not import $DEVBASE_ROOT/.env.sources.yml '
197+ '(default behavior is reference-only copy; this fully ignores it)' )
198+ env_import .add_argument ('--merge-metadata' , action = 'store_true' ,
199+ help = 'Merge new source entries into existing .env.sources.yml '
200+ '(machine-specific fields are preserved as-is from bundle; '
201+ 'run `devbase env sync` after import to refresh)' )
202+ env_import .add_argument ('--backup-dir' , metavar = 'DIR' , default = None ,
203+ help = 'Override backup directory '
204+ '(default: $DEVBASE_ROOT/backups/env-import/<ts>)' )
205+ env_import .add_argument ('--keep-last' , type = int , default = 10 , metavar = 'N' ,
206+ help = 'Keep only the last N backup directories (default: 10, 0 to disable)' )
207+
112208
113209def _add_plugin_parser (subparsers ):
114210 """Plugin group parser"""
@@ -250,14 +346,22 @@ def _create_parser():
250346 return parser
251347
252348
253- def _resolve_prefix (input_cmd , candidates ):
349+ def _resolve_prefix (input_cmd , candidates , preferences = None ):
254350 """Resolve an abbreviated command to its full name via unique prefix matching.
255351
256- Returns the full command name if exactly one candidate matches,
257- otherwise returns the input as-is (ambiguous or no match).
352+ Returns the full command name if exactly one candidate matches.
353+ If ambiguous, falls back to `preferences[input_cmd]` (if provided) to keep
354+ backward compatibility with previously-unique abbreviations.
355+ Otherwise returns the input as-is.
258356 """
259357 matches = [c for c in candidates if c .startswith (input_cmd )]
260- return matches [0 ] if len (matches ) == 1 else input_cmd
358+ if len (matches ) == 1 :
359+ return matches [0 ]
360+ if preferences and input_cmd in preferences :
361+ preferred = preferences [input_cmd ]
362+ if preferred in matches :
363+ return preferred
364+ return input_cmd
261365
262366
263367def _expand_argv ():
@@ -273,7 +377,8 @@ def _expand_argv():
273377 cmd = sys .argv [1 ]
274378 for aliases , subcmds in SUBCMD_MAP .items ():
275379 if cmd in aliases :
276- sys .argv [2 ] = _resolve_prefix (sys .argv [2 ], subcmds )
380+ preferences = SUBCMD_PREFIX_PREFERENCES .get (aliases )
381+ sys .argv [2 ] = _resolve_prefix (sys .argv [2 ], subcmds , preferences )
277382 break
278383
279384 # plugin repo sub-subcommand
0 commit comments