hop3-cli 0.4.0__py3-none-any.whl

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.
hop3_cli/__init__.py ADDED
@@ -0,0 +1 @@
1
+ # Copyright (c) 2023-2025, Abilian SAS
@@ -0,0 +1,47 @@
1
+ # Copyright (c) 2025, Abilian SAS
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ """Command processing for the Hop3 CLI.
6
+
7
+ This package handles command parsing and local command execution:
8
+ - local: Commands handled locally without server RPC
9
+ - help: Help flag handling and help output injection
10
+ - destructive: Confirmation prompts for destructive commands
11
+ - flags: CLI flag parsing (--json, --quiet, -y, etc.)
12
+ - arguments: Argument generation (e.g., deploy archive)
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from .arguments import generate_archive, get_extra_args, pack_repository
18
+ from .destructive import confirm_destructive_action, is_destructive_command
19
+ from .flags import CliFlags, parse_flags
20
+ from .help import (
21
+ handle_help_flags,
22
+ inject_local_commands_into_help,
23
+ is_help_command,
24
+ )
25
+ from .local import (
26
+ LOCAL_COMMANDS,
27
+ LOCAL_COMMANDS_INFO,
28
+ handle_local_command,
29
+ is_local_command,
30
+ )
31
+
32
+ __all__ = [
33
+ "LOCAL_COMMANDS",
34
+ "LOCAL_COMMANDS_INFO",
35
+ "CliFlags",
36
+ "confirm_destructive_action",
37
+ "generate_archive",
38
+ "get_extra_args",
39
+ "handle_help_flags",
40
+ "handle_local_command",
41
+ "inject_local_commands_into_help",
42
+ "is_destructive_command",
43
+ "is_help_command",
44
+ "is_local_command",
45
+ "pack_repository",
46
+ "parse_flags",
47
+ ]
@@ -0,0 +1,455 @@
1
+ # Copyright (c) 2025, Abilian SAS
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ """Argument generation for CLI commands."""
6
+
7
+ from __future__ import annotations
8
+
9
+ import base64
10
+ import io
11
+ import sys
12
+ import tarfile
13
+ from collections import Counter
14
+ from pathlib import Path
15
+ from typing import TYPE_CHECKING
16
+
17
+ import pathspec
18
+
19
+ if TYPE_CHECKING:
20
+ from hop3_cli.types import JsonDict
21
+
22
+ __all__ = ["generate_archive", "get_extra_args", "pack_repository"]
23
+
24
+ # tomllib is stdlib in Python 3.11+, use toml package for 3.10
25
+ if sys.version_info >= (3, 11):
26
+ import tomllib
27
+ else:
28
+ import toml as tomllib
29
+
30
+ # Archive size limits (in bytes)
31
+ # Soft limit: warn the user but proceed
32
+ # Hard limit: refuse to upload (can be overridden on server)
33
+ SOFT_SIZE_LIMIT = 100 * 1024 * 1024 # 100 MB
34
+ HARD_SIZE_LIMIT = 1024 * 1024 * 1024 # 1 GB
35
+
36
+ # Ignore files in priority order (first found is used)
37
+ IGNORE_FILES = [".hop3ignore", ".dockerignore", ".gitignore"]
38
+
39
+
40
+ def get_extra_args(args: list[str], verbosity: int = 1) -> JsonDict:
41
+ """Generate a dictionary of extra arguments for RPC commands.
42
+
43
+ Args:
44
+ args: Command-line arguments
45
+ verbosity: Verbosity level (0=quiet, 1=normal, 2=verbose, 3=debug)
46
+
47
+ Returns:
48
+ Dictionary with extra arguments. Verbosity is always included as it's
49
+ used by the server to set the logging context for all commands.
50
+ """
51
+ # Always include verbosity - server extracts it and uses it as context
52
+ extra_args: JsonDict = {"verbosity": verbosity}
53
+
54
+ if not args:
55
+ return extra_args
56
+
57
+ command = args[0]
58
+
59
+ match command:
60
+ case "deploy":
61
+ # Parse deploy-specific flags
62
+ # args[0]="deploy", args[1]=app_name, remaining args may include --env and directory
63
+ env_vars, remaining_args, streaming = _parse_deploy_args(args[1:])
64
+
65
+ # Skip expensive archive generation if no app name provided
66
+ # Let the server return a proper usage error instead
67
+ if not remaining_args:
68
+ return extra_args
69
+
70
+ # Directory is the last non-flag argument (if any)
71
+ directory = Path(remaining_args[-1]) if len(remaining_args) > 1 else Path()
72
+ extra_args["repository"] = pack_repository(directory, verbosity=verbosity)
73
+
74
+ # Include env vars if any were specified
75
+ if env_vars:
76
+ extra_args["env_vars"] = env_vars # type: ignore[assignment] # pyrefly: ignore
77
+
78
+ # Enable streaming by default for real-time log output
79
+ extra_args["streaming"] = streaming
80
+
81
+ return extra_args
82
+
83
+
84
+ def _parse_deploy_args(args: list[str]) -> tuple[dict[str, str], list[str], bool]:
85
+ """Parse deploy command arguments, extracting --env and --no-stream flags.
86
+
87
+ Args:
88
+ args: Arguments after 'deploy' command (app_name, --env flags, directory)
89
+
90
+ Returns:
91
+ Tuple of (env_vars dict, remaining args, streaming enabled)
92
+
93
+ Example:
94
+ >>> _parse_deploy_args(['myapp', '--env', 'FOO=bar', '--env', 'BAZ=qux', '.'])
95
+ ({'FOO': 'bar', 'BAZ': 'qux'}, ['myapp', '.'], True)
96
+ >>> _parse_deploy_args(['myapp', '--no-stream'])
97
+ ({}, ['myapp'], False)
98
+ """
99
+ env_vars: dict[str, str] = {}
100
+ remaining: list[str] = []
101
+ streaming = True # Enabled by default
102
+ i = 0
103
+
104
+ while i < len(args):
105
+ arg = args[i]
106
+
107
+ if arg in {"--env", "-e"}:
108
+ # Next argument should be KEY=VALUE
109
+ if i + 1 < len(args):
110
+ env_spec = args[i + 1]
111
+ if "=" in env_spec:
112
+ key, _, value = env_spec.partition("=")
113
+ env_vars[key] = value
114
+ i += 2
115
+ else:
116
+ i += 1 # Skip malformed --env without value
117
+ elif arg.startswith("--env="):
118
+ # Handle --env=KEY=VALUE format
119
+ env_spec = arg[6:] # Remove --env=
120
+ if "=" in env_spec:
121
+ key, _, value = env_spec.partition("=")
122
+ env_vars[key] = value
123
+ i += 1
124
+ elif arg == "--no-stream":
125
+ # Disable real-time streaming (fallback to batch output)
126
+ streaming = False
127
+ i += 1
128
+ elif arg == "--stream":
129
+ # Explicitly enable streaming (default, but allow explicit)
130
+ streaming = True
131
+ i += 1
132
+ else:
133
+ remaining.append(arg)
134
+ i += 1
135
+
136
+ return env_vars, remaining, streaming
137
+
138
+
139
+ def pack_repository(directory: Path = Path(), verbosity: int = 1) -> str:
140
+ """Pack a directory into a base64-encoded tar.gz archive.
141
+
142
+ Args:
143
+ directory: Directory to pack (defaults to current directory)
144
+ verbosity: Verbosity level (0=quiet, 1=normal, 2+=verbose)
145
+
146
+ Returns:
147
+ Base64-encoded tar.gz archive
148
+ """
149
+ tar_gz = generate_archive(directory, verbosity=verbosity)
150
+ return base64.b64encode(tar_gz).decode("ascii")
151
+
152
+
153
+ def generate_archive(source_dir: Path, verbosity: int = 1) -> bytes:
154
+ """
155
+ Creates an in-memory tar.gz archive of a source directory as a bytes object,
156
+ excluding files and directories specified in ignore files.
157
+
158
+ Ignore files are checked in priority order: .hop3ignore, .dockerignore, .gitignore
159
+
160
+ Args:
161
+ source_dir: The path to the directory to archive.
162
+ verbosity: Verbosity level (0=quiet, 1=normal, 2+=verbose)
163
+
164
+ Returns:
165
+ The content of the .tar.gz archive as a bytes object.
166
+
167
+ Raises:
168
+ ValueError: If the source_dir is not a valid directory or has too many files.
169
+ FileNotFoundError: If the source_dir does not exist.
170
+ """
171
+ source_dir = Path(source_dir).resolve()
172
+ verbose = verbosity >= 2
173
+
174
+ if not source_dir.exists():
175
+ msg = (
176
+ f"Directory not found: {source_dir}\n\n"
177
+ f"Make sure you are in the directory containing your application code,\n"
178
+ f"or specify the path as the last argument:\n"
179
+ f" hop3 deploy <app_name> /path/to/app"
180
+ )
181
+ raise FileNotFoundError(msg)
182
+ if not source_dir.is_dir():
183
+ msg = f"Path is not a directory: {source_dir}"
184
+ raise ValueError(msg)
185
+
186
+ # Check if directory looks like an application
187
+ _check_directory_is_app(source_dir, verbose)
188
+
189
+ if verbose:
190
+ print(f"Creating archive from: {source_dir}", file=sys.stderr)
191
+
192
+ # --- 1. Load ignore rules (.hop3ignore, .dockerignore, or .gitignore) ---
193
+ spec, ignore_file = get_ignored_spec(source_dir)
194
+ if verbose:
195
+ if ignore_file:
196
+ print(f"Using ignore patterns from: {ignore_file}", file=sys.stderr)
197
+ else:
198
+ print(
199
+ "No ignore file found (.hop3ignore, .dockerignore, .gitignore)",
200
+ file=sys.stderr,
201
+ )
202
+
203
+ # --- 2. Walk the directory and gather files to include ---
204
+ if verbose:
205
+ print("Scanning files...", file=sys.stderr)
206
+ files_to_add = get_files_to_add(source_dir, spec)
207
+
208
+ # --- 3. Log file count ---
209
+ file_count = len(files_to_add)
210
+ if verbose:
211
+ print(f"Found {file_count} files to archive", file=sys.stderr)
212
+
213
+ # --- 4. Create the tar.gz archive in memory ---
214
+ if verbose:
215
+ print("Creating archive...", file=sys.stderr)
216
+
217
+ fileobj = io.BytesIO()
218
+
219
+ # The 'w:gz' mode creates a gzip-compressed tar file.
220
+ # We pass our BytesIO object as the file to write to.
221
+ with tarfile.open(fileobj=fileobj, mode="w:gz") as tar:
222
+ for file_path in files_to_add:
223
+ relative_path = file_path.relative_to(source_dir)
224
+ arcname = Path() / relative_path
225
+ tar.add(file_path, arcname=str(arcname))
226
+
227
+ archive_bytes = fileobj.getvalue()
228
+ _check_archive_size(archive_bytes, files_to_add, source_dir, verbose)
229
+
230
+ return archive_bytes
231
+
232
+
233
+ def _check_archive_size(
234
+ archive_bytes: bytes,
235
+ files: list[Path],
236
+ source_dir: Path,
237
+ verbose: bool,
238
+ ) -> None:
239
+ """Check archive size against soft and hard limits.
240
+
241
+ Args:
242
+ archive_bytes: The archive content
243
+ files: List of files in the archive (for diagnostics)
244
+ source_dir: Source directory (for computing relative paths)
245
+ verbose: Whether to print verbose output
246
+ """
247
+ archive_size = len(archive_bytes)
248
+ size_mb = archive_size / (1024 * 1024)
249
+
250
+ if verbose:
251
+ print(f"Archive created: {size_mb:.2f} MB", file=sys.stderr)
252
+
253
+ if archive_size > HARD_SIZE_LIMIT:
254
+ top_dirs = _get_top_directories_by_file_count(files, source_dir)
255
+ dir_summary = "\n".join(f" {d}: {c} files" for d, c in top_dirs[:5])
256
+ hard_limit_mb = HARD_SIZE_LIMIT / (1024 * 1024)
257
+
258
+ msg = (
259
+ f"Archive too large: {size_mb:.1f} MB exceeds the {hard_limit_mb:.0f} MB limit.\n"
260
+ f"\n"
261
+ f"Directories with most files:\n"
262
+ f"{dir_summary}\n"
263
+ f"\n"
264
+ f"Add directories to .hop3ignore to exclude them from deployment.\n"
265
+ f"The server may also have configurable size limits."
266
+ )
267
+ raise ValueError(msg)
268
+
269
+ if archive_size > SOFT_SIZE_LIMIT:
270
+ soft_limit_mb = SOFT_SIZE_LIMIT / (1024 * 1024)
271
+ print(
272
+ f"Warning: Large archive ({size_mb:.1f} MB). "
273
+ f"Uploads over {soft_limit_mb:.0f} MB may be slow.",
274
+ file=sys.stderr,
275
+ )
276
+
277
+
278
+ def get_ignored_spec(source_dir: Path) -> tuple[pathspec.PathSpec | None, str | None]:
279
+ """Load ignore rules from a directory.
280
+
281
+ Checks sources in priority order:
282
+ 1. [build].ignore patterns in hop3.toml
283
+ 2. [build].ignore-file reference in hop3.toml
284
+ 3. .hop3ignore file
285
+ 4. .dockerignore file
286
+ 5. .gitignore file
287
+
288
+ The first source found with patterns is used.
289
+
290
+ Returns:
291
+ Tuple of (PathSpec or None, source description or None)
292
+ """
293
+ # 1. Check hop3.toml for inline ignore patterns or ignore-file reference
294
+ hop3_toml_spec, hop3_toml_source = _get_hop3_toml_ignore_spec(source_dir)
295
+ if hop3_toml_spec is not None:
296
+ return hop3_toml_spec, hop3_toml_source
297
+
298
+ # 2. Fall back to ignore files in priority order
299
+ for ignore_file in IGNORE_FILES:
300
+ ignore_path = source_dir / ignore_file
301
+ if ignore_path.is_file():
302
+ lines = ignore_path.read_text(encoding="utf-8").splitlines()
303
+ spec = pathspec.PathSpec.from_lines("gitignore", lines) # pyrefly: ignore
304
+ return spec, ignore_file
305
+
306
+ return None, None
307
+
308
+
309
+ def _get_hop3_toml_ignore_spec(
310
+ source_dir: Path,
311
+ ) -> tuple[pathspec.PathSpec | None, str | None]:
312
+ """Extract ignore patterns from hop3.toml if present.
313
+
314
+ Checks for:
315
+ 1. [build].ignore - inline list of patterns
316
+ 2. [build].ignore-file - reference to an ignore file
317
+
318
+ Returns:
319
+ Tuple of (PathSpec or None, source description or None)
320
+ """
321
+ # Check for hop3.toml in standard locations
322
+ hop3_toml_paths = [
323
+ source_dir / "hop3" / "hop3.toml",
324
+ source_dir / "hop3.toml",
325
+ ]
326
+
327
+ for hop3_toml_path in hop3_toml_paths:
328
+ if not hop3_toml_path.is_file():
329
+ continue
330
+
331
+ try:
332
+ content = hop3_toml_path.read_text(encoding="utf-8")
333
+ data = tomllib.loads(content)
334
+ except Exception:
335
+ # If TOML parsing fails, skip and try next location
336
+ continue
337
+
338
+ build_section = data.get("build", {})
339
+ if not isinstance(build_section, dict):
340
+ continue
341
+
342
+ # Check for inline ignore patterns
343
+ ignore_patterns = build_section.get("ignore")
344
+ if ignore_patterns and isinstance(ignore_patterns, list):
345
+ spec = pathspec.PathSpec.from_lines("gitignore", ignore_patterns)
346
+ return spec, f"hop3.toml [build].ignore ({len(ignore_patterns)} patterns)"
347
+
348
+ # Check for ignore-file reference
349
+ ignore_file_ref = build_section.get("ignore-file")
350
+ if ignore_file_ref and isinstance(ignore_file_ref, str):
351
+ ignore_file_path = source_dir / ignore_file_ref
352
+ if ignore_file_path.is_file():
353
+ lines = ignore_file_path.read_text(encoding="utf-8").splitlines()
354
+ spec = pathspec.PathSpec.from_lines("gitignore", lines) # pyrefly: ignore
355
+ return spec, f"hop3.toml [build].ignore-file -> {ignore_file_ref}"
356
+
357
+ return None, None
358
+
359
+
360
+ def _get_top_directories_by_file_count(
361
+ files: list[Path], source_dir: Path
362
+ ) -> list[tuple[str, int]]:
363
+ """Get the top-level directories sorted by file count.
364
+
365
+ Args:
366
+ files: List of file paths
367
+ source_dir: The source directory (for computing relative paths)
368
+
369
+ Returns:
370
+ List of (directory_name, file_count) tuples, sorted by count descending
371
+ """
372
+ dir_counts: Counter[str] = Counter()
373
+ for f in files:
374
+ rel = f.relative_to(source_dir)
375
+ # Get top-level directory, or "(root)" for files in root
376
+ top_dir = rel.parts[0] if len(rel.parts) > 1 else "(root)"
377
+ dir_counts[top_dir] += 1
378
+
379
+ return dir_counts.most_common()
380
+
381
+
382
+ def _check_directory_is_app(source_dir: Path, verbose: bool) -> None:
383
+ """Check if the directory looks like an application and warn if not.
384
+
385
+ Args:
386
+ source_dir: The directory to check
387
+ verbose: Whether to print verbose output
388
+ """
389
+ # Common app indicators
390
+ app_indicators = [
391
+ "Procfile",
392
+ "hop3.toml",
393
+ "package.json",
394
+ "requirements.txt",
395
+ "pyproject.toml",
396
+ "Cargo.toml",
397
+ "go.mod",
398
+ "Gemfile",
399
+ "composer.json",
400
+ "pom.xml",
401
+ "build.gradle",
402
+ "Makefile",
403
+ "Dockerfile",
404
+ "docker-compose.yml",
405
+ "docker-compose.yaml",
406
+ "index.html",
407
+ "index.php",
408
+ ]
409
+
410
+ has_indicator = any((source_dir / f).exists() for f in app_indicators)
411
+
412
+ if not has_indicator:
413
+ # Check if directory has any files at all
414
+ files = list(source_dir.iterdir())
415
+ if not files:
416
+ msg = (
417
+ f"Directory is empty: {source_dir}\n\n"
418
+ f"The deploy command expects a directory containing your application code.\n"
419
+ f"Make sure you are in the correct directory."
420
+ )
421
+ raise ValueError(msg)
422
+
423
+ # Directory has files but no recognizable app structure
424
+ if verbose:
425
+ print(
426
+ f"Warning: No recognized application files found in {source_dir}\n"
427
+ f"Expected one of: {', '.join(app_indicators[:5])}...\n"
428
+ f"Proceeding anyway - the server will attempt to deploy.",
429
+ file=sys.stderr,
430
+ )
431
+
432
+
433
+ def get_files_to_add(source_dir: Path, spec: pathspec.PathSpec | None) -> list[Path]:
434
+ """Get list of files to add to archive, excluding gitignored files."""
435
+ files_to_add: list[Path] = []
436
+ for file_path in source_dir.rglob("*"):
437
+ relative_path = file_path.relative_to(source_dir)
438
+ relative_str = str(relative_path)
439
+
440
+ # Always exclude .git directory (not deployment material)
441
+ if relative_str.startswith(".git") and (
442
+ relative_str == ".git" or relative_str.startswith(".git/")
443
+ ):
444
+ continue
445
+
446
+ # Let pathspec determine if the file should be ignored
447
+ if spec and spec.match_file(relative_str):
448
+ continue
449
+
450
+ # We only add files to the tar, not directories
451
+ if not file_path.is_file():
452
+ continue
453
+
454
+ files_to_add.append(file_path)
455
+ return files_to_add
@@ -0,0 +1,191 @@
1
+ # Copyright (c) 2025, Abilian SAS
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ """Destructive command handling and confirmation prompts."""
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import TYPE_CHECKING
10
+
11
+ from hop3_cli.ui.prompts import confirm, show_destructive_warning, type_to_confirm
12
+
13
+ if TYPE_CHECKING:
14
+ from hop3_cli.config import Config
15
+ from hop3_cli.ui.rich_printer import RichPrinter
16
+
17
+
18
+ def is_destructive_command(cli_args: list[str]) -> bool:
19
+ """Check if the command is destructive (requires confirmation).
20
+
21
+ Args:
22
+ cli_args: Command-line arguments
23
+
24
+ Returns:
25
+ True if command is destructive, False otherwise
26
+ """
27
+ if not cli_args:
28
+ return False
29
+
30
+ command = cli_args[0]
31
+
32
+ # List of destructive commands that require confirmation
33
+ destructive_commands = {
34
+ "app:destroy",
35
+ "destroy", # Alias for app:destroy
36
+ "backup:delete",
37
+ "services:destroy",
38
+ }
39
+
40
+ return command in destructive_commands
41
+
42
+
43
+ def _confirm_protected_context(config: Config | None) -> tuple[bool, str | None]:
44
+ """Check if context is protected and confirm if needed.
45
+
46
+ Returns:
47
+ Tuple of (is_protected, context_name).
48
+ Returns (False, None) if user cancelled the confirmation.
49
+ """
50
+ if not config:
51
+ return False, None
52
+
53
+ is_protected = config.is_protected_context()
54
+ context_name = config.get_current_context_name()
55
+
56
+ if not is_protected:
57
+ return False, context_name
58
+
59
+ # Show extra warning for protected contexts
60
+ print(f"\n WARNING: You are operating on protected context '{context_name}'")
61
+ print(" This context is marked as protected to prevent accidental changes.\n")
62
+
63
+ if not confirm("Are you sure you want to continue with this destructive action?"):
64
+ # Signal cancellation by returning special value
65
+ return True, None # is_protected=True but context_name=None means cancelled
66
+
67
+ return True, context_name
68
+
69
+
70
+ def confirm_destructive_action(
71
+ cli_args: list[str], printer: RichPrinter, config: Config | None = None
72
+ ) -> bool:
73
+ """Prompt user to confirm a destructive action.
74
+
75
+ For protected contexts, extra confirmation is required.
76
+
77
+ Args:
78
+ cli_args: Command-line arguments
79
+ printer: Printer for output (for JSON mode detection)
80
+ config: Configuration for checking protected context (optional)
81
+
82
+ Returns:
83
+ True if user confirmed, False if cancelled
84
+ """
85
+ if printer.json_output:
86
+ # In JSON mode, auto-confirm (user should use -y flag)
87
+ return True
88
+
89
+ command = cli_args[0]
90
+ args = cli_args[1:]
91
+
92
+ # Check if required arguments are present BEFORE any confirmation prompts
93
+ # If missing, let the server handle the error message
94
+ if not _has_required_args(command, args):
95
+ return True
96
+
97
+ # Check if this is a protected context
98
+ is_protected, context_name = _confirm_protected_context(config)
99
+ if is_protected and context_name is None:
100
+ # User cancelled protected context confirmation
101
+ return False
102
+
103
+ # app:destroy or destroy command - requires type-to-confirm
104
+ if command in {"app:destroy", "destroy"}:
105
+ return _confirm_app_destroy(args, is_protected, context_name)
106
+
107
+ # backup:delete command
108
+ if command == "backup:delete":
109
+ return _confirm_backup_delete(args)
110
+
111
+ # services:destroy command
112
+ if command == "services:destroy":
113
+ return _confirm_service_destroy(args, is_protected, context_name)
114
+
115
+ # Unknown destructive command (shouldn't happen)
116
+ return confirm("This action cannot be undone. Continue?")
117
+
118
+
119
+ def _has_required_args(command: str, args: list[str]) -> bool:
120
+ """Check if a destructive command has its required arguments.
121
+
122
+ Args:
123
+ command: The command name
124
+ args: The arguments (excluding the command itself)
125
+
126
+ Returns:
127
+ True if required args are present, False otherwise
128
+ """
129
+ # Commands that require at least one argument (the target name)
130
+ commands_requiring_target = {
131
+ "app:destroy",
132
+ "destroy",
133
+ "backup:delete",
134
+ "services:destroy",
135
+ }
136
+
137
+ if command in commands_requiring_target:
138
+ return len(args) >= 1
139
+
140
+ return True
141
+
142
+
143
+ def _confirm_app_destroy(
144
+ args: list[str], is_protected: bool, context_name: str | None
145
+ ) -> bool:
146
+ """Confirm app:destroy command."""
147
+ app_name = args[0]
148
+ show_destructive_warning(
149
+ "destroy",
150
+ f"app '{app_name}'",
151
+ "All files, data, and configuration will be permanently deleted.",
152
+ )
153
+
154
+ # For protected contexts, require typing context name AND app name
155
+ if is_protected and context_name:
156
+ confirm_text = f"{context_name}/{app_name}"
157
+ return type_to_confirm(
158
+ f"Type '{confirm_text}' to confirm (context/app):", confirm_text
159
+ )
160
+ return type_to_confirm(f"Type '{app_name}' to confirm:", app_name)
161
+
162
+
163
+ def _confirm_backup_delete(args: list[str]) -> bool:
164
+ """Confirm backup:delete command."""
165
+ backup_id = args[0]
166
+ show_destructive_warning(
167
+ "delete",
168
+ f"backup '{backup_id}'",
169
+ "This backup cannot be recovered once deleted.",
170
+ )
171
+ return confirm("Are you sure you want to delete this backup?")
172
+
173
+
174
+ def _confirm_service_destroy(
175
+ args: list[str], is_protected: bool, context_name: str | None
176
+ ) -> bool:
177
+ """Confirm services:destroy command."""
178
+ addon_name = args[0]
179
+ show_destructive_warning(
180
+ "destroy",
181
+ f"service '{addon_name}'",
182
+ "All data in this service will be permanently deleted.",
183
+ )
184
+
185
+ # For protected contexts, require typing context name AND service name
186
+ if is_protected and context_name:
187
+ confirm_text = f"{context_name}/{addon_name}"
188
+ return type_to_confirm(
189
+ f"Type '{confirm_text}' to confirm (context/service):", confirm_text
190
+ )
191
+ return type_to_confirm(f"Type '{addon_name}' to confirm:", addon_name)