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 +1 -0
- hop3_cli/commands/__init__.py +47 -0
- hop3_cli/commands/arguments.py +455 -0
- hop3_cli/commands/destructive.py +191 -0
- hop3_cli/commands/flags.py +145 -0
- hop3_cli/commands/help.py +227 -0
- hop3_cli/commands/local/__init__.py +116 -0
- hop3_cli/commands/local/auth_cmd.py +52 -0
- hop3_cli/commands/local/completion_cmd.py +602 -0
- hop3_cli/commands/local/context_cmd.py +356 -0
- hop3_cli/commands/local/help_text.py +163 -0
- hop3_cli/commands/local/init_cmd.py +201 -0
- hop3_cli/commands/local/login_cmd.py +742 -0
- hop3_cli/commands/local/settings_cmd.py +125 -0
- hop3_cli/commands/local/ssh_ops.py +285 -0
- hop3_cli/commands/local/version_cmd.py +24 -0
- hop3_cli/config.py +505 -0
- hop3_cli/exceptions.py +19 -0
- hop3_cli/exit_codes.py +102 -0
- hop3_cli/main.py +344 -0
- hop3_cli/rpc/__init__.py +29 -0
- hop3_cli/rpc/client.py +369 -0
- hop3_cli/rpc/responses.py +302 -0
- hop3_cli/rpc/streaming.py +223 -0
- hop3_cli/rpc/tunnel.py +177 -0
- hop3_cli/tokens.py +26 -0
- hop3_cli/types.py +12 -0
- hop3_cli/ui/__init__.py +30 -0
- hop3_cli/ui/console.py +19 -0
- hop3_cli/ui/messages.py +40 -0
- hop3_cli/ui/prompts.py +90 -0
- hop3_cli/ui/rich_printer.py +286 -0
- hop3_cli-0.4.0.dist-info/METADATA +114 -0
- hop3_cli-0.4.0.dist-info/RECORD +36 -0
- hop3_cli-0.4.0.dist-info/WHEEL +4 -0
- hop3_cli-0.4.0.dist-info/entry_points.txt +4 -0
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)
|