cmdbox-cli 1.0.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.
- cmdbox/__init__.py +0 -0
- cmdbox/cli/__init__.py +0 -0
- cmdbox/cli/app.py +125 -0
- cmdbox/cli/commands/__init__.py +0 -0
- cmdbox/cli/commands/alias_fallback.py +102 -0
- cmdbox/cli/commands/command_crud.py +429 -0
- cmdbox/cli/commands/command_run.py +255 -0
- cmdbox/cli/commands/history.py +109 -0
- cmdbox/cli/commands/init.py +54 -0
- cmdbox/cli/commands/settings.py +62 -0
- cmdbox/cli/commands/tag_crud.py +277 -0
- cmdbox/cli/commands/variable_crud.py +349 -0
- cmdbox/cli/common/__init__.py +0 -0
- cmdbox/cli/common/errors.py +58 -0
- cmdbox/cli/common/update_fields.py +88 -0
- cmdbox/cli/completions/__init__.py +0 -0
- cmdbox/cli/completions/commands.py +26 -0
- cmdbox/cli/completions/fields.py +31 -0
- cmdbox/cli/completions/tags.py +24 -0
- cmdbox/cli/completions/variables.py +26 -0
- cmdbox/cli/handlers/__init__.py +0 -0
- cmdbox/cli/handlers/command_handlers.py +357 -0
- cmdbox/cli/handlers/common_handlers.py +15 -0
- cmdbox/cli/handlers/history_handlers.py +94 -0
- cmdbox/cli/handlers/init_handler.py +127 -0
- cmdbox/cli/handlers/run_handler.py +178 -0
- cmdbox/cli/handlers/settings_handler.py +59 -0
- cmdbox/cli/handlers/tag_handlers.py +220 -0
- cmdbox/cli/handlers/variable_handlers.py +272 -0
- cmdbox/cli/prompts/__init__.py +0 -0
- cmdbox/cli/prompts/completers.py +161 -0
- cmdbox/cli/prompts/prompts.py +108 -0
- cmdbox/cli/prompts/validators.py +46 -0
- cmdbox/cli/ui/__init__.py +0 -0
- cmdbox/cli/ui/console.py +31 -0
- cmdbox/cli/ui/editor.py +141 -0
- cmdbox/cli/ui/presenters/__init__.py +0 -0
- cmdbox/cli/ui/presenters/app_presenter.py +8 -0
- cmdbox/cli/ui/presenters/command_presenter.py +168 -0
- cmdbox/cli/ui/presenters/history_presenter.py +83 -0
- cmdbox/cli/ui/presenters/init_instructions.py +52 -0
- cmdbox/cli/ui/presenters/init_presenter.py +57 -0
- cmdbox/cli/ui/presenters/result_presenter.py +144 -0
- cmdbox/cli/ui/presenters/settings_presenter.py +130 -0
- cmdbox/cli/ui/presenters/tag_presenter.py +97 -0
- cmdbox/cli/ui/presenters/variable_presenter.py +103 -0
- cmdbox/cli/ui/primitives.py +410 -0
- cmdbox/cli/ui/theme.py +43 -0
- cmdbox/cli/ui/theme_builder.py +49 -0
- cmdbox/common/__init__.py +0 -0
- cmdbox/common/io.py +34 -0
- cmdbox/container.py +156 -0
- cmdbox/core/__init__.py +0 -0
- cmdbox/core/fields.py +48 -0
- cmdbox/core/paths.py +52 -0
- cmdbox/database.py +65 -0
- cmdbox/exceptions.py +10 -0
- cmdbox/init/__init__.py +0 -0
- cmdbox/init/detect.py +82 -0
- cmdbox/init/integrations/bash.sh +10 -0
- cmdbox/init/integrations/cmd.bat +14 -0
- cmdbox/init/integrations/fish.fish +11 -0
- cmdbox/init/integrations/powershell.ps1 +14 -0
- cmdbox/init/integrations/zsh.sh +10 -0
- cmdbox/init/io.py +68 -0
- cmdbox/init/specs.py +54 -0
- cmdbox/logging_setup/__init__.py +0 -0
- cmdbox/logging_setup/log_config.py +123 -0
- cmdbox/logging_setup/log_decorators.py +40 -0
- cmdbox/logging_setup/log_handlers.py +94 -0
- cmdbox/migrations/__init__.py +1 -0
- cmdbox/migrations/errors.py +10 -0
- cmdbox/migrations/runner.py +127 -0
- cmdbox/migrations/versions/__init__.py +0 -0
- cmdbox/models.py +165 -0
- cmdbox/repositories/__init__.py +0 -0
- cmdbox/repositories/base_repository.py +181 -0
- cmdbox/repositories/command_repository.py +391 -0
- cmdbox/repositories/errors.py +120 -0
- cmdbox/repositories/history_repository.py +155 -0
- cmdbox/repositories/results.py +37 -0
- cmdbox/repositories/tag_repository.py +91 -0
- cmdbox/repositories/validators.py +256 -0
- cmdbox/repositories/variable_repository.py +324 -0
- cmdbox/resolve/__init__.py +0 -0
- cmdbox/resolve/errors.py +65 -0
- cmdbox/resolve/lookup.py +137 -0
- cmdbox/resolve/resolver.py +402 -0
- cmdbox/resolve/type_defs.py +96 -0
- cmdbox/runtime/__init__.py +0 -0
- cmdbox/runtime/executor.py +454 -0
- cmdbox/runtime/results.py +25 -0
- cmdbox/runtime/shell.py +90 -0
- cmdbox/services/__init__.py +0 -0
- cmdbox/services/command_services.py +261 -0
- cmdbox/services/errors.py +37 -0
- cmdbox/services/field_selection.py +162 -0
- cmdbox/services/history_service.py +68 -0
- cmdbox/services/run_service.py +204 -0
- cmdbox/services/tag_services.py +134 -0
- cmdbox/services/variable_services.py +224 -0
- cmdbox/settings/__init__.py +0 -0
- cmdbox/settings/models.py +129 -0
- cmdbox/settings/settings_repository.py +36 -0
- cmdbox/settings/settings_service.py +144 -0
- cmdbox/version.py +1 -0
- cmdbox_cli-1.0.0.dist-info/METADATA +125 -0
- cmdbox_cli-1.0.0.dist-info/RECORD +112 -0
- cmdbox_cli-1.0.0.dist-info/WHEEL +5 -0
- cmdbox_cli-1.0.0.dist-info/entry_points.txt +2 -0
- cmdbox_cli-1.0.0.dist-info/licenses/LICENSE +21 -0
- cmdbox_cli-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
import sys
|
|
4
|
+
import subprocess
|
|
5
|
+
import tempfile
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Mapping
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
from cmdbox.runtime.results import ExecutionResult
|
|
12
|
+
from cmdbox.runtime.shell import build_shell_command
|
|
13
|
+
from cmdbox.logging_setup.log_decorators import log_action
|
|
14
|
+
|
|
15
|
+
log = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class RunContext:
|
|
20
|
+
"""
|
|
21
|
+
Represents the execution context for a runnable process.
|
|
22
|
+
|
|
23
|
+
This class defines the environment and settings under which a process is
|
|
24
|
+
executed. It includes attributes such as the current working directory,
|
|
25
|
+
environment variables, and capture settings. Being a frozen dataclass, the
|
|
26
|
+
RunContext instances are immutable.
|
|
27
|
+
|
|
28
|
+
Attributes:
|
|
29
|
+
cwd (str | None): The current working directory for the process. If None,
|
|
30
|
+
the process will inherit the working directory from the parent process.
|
|
31
|
+
env (Mapping[str, str] | None): Environment variables to set for the
|
|
32
|
+
process. If None, the process will inherit the environment from the
|
|
33
|
+
parent process.
|
|
34
|
+
capture (bool): Whether the process's output streams should be captured.
|
|
35
|
+
If False, the output streams will inherit those of the parent process.
|
|
36
|
+
shell (str | None): The shell to use for executing the command. If None,
|
|
37
|
+
the system default shell will be used.
|
|
38
|
+
timeout (int | None): The maximum time in seconds to wait for the command
|
|
39
|
+
to complete. If None, the command will run indefinitely. Defaults to None.
|
|
40
|
+
emit (bool): Whether to emit the command template. If True, the command
|
|
41
|
+
template is emitted to the current terminal window to be evaluated
|
|
42
|
+
in the current session. If False, the command is executed in a
|
|
43
|
+
different session using a subprocess. Defaults to False.
|
|
44
|
+
verbose (bool): Whether to output additional information alongside the
|
|
45
|
+
command output. Defaults to False.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
cwd: str | None = None
|
|
49
|
+
env: Mapping[str, str] | None = None
|
|
50
|
+
capture: bool = False
|
|
51
|
+
shell: str | None = None
|
|
52
|
+
timeout: int | None = None
|
|
53
|
+
emit: bool = False
|
|
54
|
+
verbose: bool = False
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class Executor:
|
|
58
|
+
|
|
59
|
+
@log_action(__name__, "run_executor_run")
|
|
60
|
+
def run(self, command: str, ctx: RunContext | None = None) -> ExecutionResult:
|
|
61
|
+
"""
|
|
62
|
+
Executes a shell command in a subprocess, capturing the output and exit code.
|
|
63
|
+
|
|
64
|
+
This method takes a shell command as a string and runs it in a subprocess. It
|
|
65
|
+
allows the caller to specify the working directory, environment variables, and
|
|
66
|
+
whether or not to capture the output through the provided context. The result
|
|
67
|
+
of the execution is returned encapsulated in an `ExecutionResult`.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
command (str): The shell command to be executed.
|
|
71
|
+
ctx (RunContext, optional): An instance of `RunContext` that provides
|
|
72
|
+
additional execution context such as the working directory,
|
|
73
|
+
environment variables, and capture preferences. Defaults to a new
|
|
74
|
+
`RunContext()` instance.
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
ExecutionResult: An object containing the executed command, the exit code,
|
|
78
|
+
and the captured standard output and error streams.
|
|
79
|
+
"""
|
|
80
|
+
if not ctx:
|
|
81
|
+
ctx = RunContext()
|
|
82
|
+
log.info(
|
|
83
|
+
"Executing command: mode=%s, multiline=%s, capture=%s, shell=%s, cmd_len=%s, cwd_set=%s, env_override=%s, timeout=%s",
|
|
84
|
+
"emit" if ctx.emit else "subprocess",
|
|
85
|
+
self.is_multiline(command),
|
|
86
|
+
ctx.capture,
|
|
87
|
+
ctx.shell,
|
|
88
|
+
len(command),
|
|
89
|
+
ctx.cwd is not None,
|
|
90
|
+
ctx.env is not None,
|
|
91
|
+
ctx.timeout,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
if ctx.emit:
|
|
95
|
+
self.emit_command(command)
|
|
96
|
+
return None # Just a safeguard, this should not return if emit is True
|
|
97
|
+
|
|
98
|
+
env = os.environ.copy()
|
|
99
|
+
if ctx.env:
|
|
100
|
+
env.update(dict(ctx.env))
|
|
101
|
+
|
|
102
|
+
if self.is_multiline(command):
|
|
103
|
+
return self.run_multiline_as_script(command, ctx=ctx, env=env)
|
|
104
|
+
|
|
105
|
+
popen_args = build_shell_command(command, preferred_shell=ctx.shell)
|
|
106
|
+
return self.execute_command(
|
|
107
|
+
command,
|
|
108
|
+
popen_args=popen_args,
|
|
109
|
+
cwd=ctx.cwd,
|
|
110
|
+
env=env,
|
|
111
|
+
capture_output=ctx.capture,
|
|
112
|
+
timeout=ctx.timeout,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
@log_action(__name__, "run_executor_run_multiline_as_script")
|
|
116
|
+
def run_multiline_as_script(
|
|
117
|
+
self, command: str, ctx: RunContext, env: dict[str, str]
|
|
118
|
+
) -> ExecutionResult:
|
|
119
|
+
"""
|
|
120
|
+
Executes a multiline command as a script in the context of a specified shell environment.
|
|
121
|
+
|
|
122
|
+
This method creates a temporary script file containing the provided multiline command and
|
|
123
|
+
executes it using subprocess. The command is normalized for consistent behavior across
|
|
124
|
+
platforms, and an optional shell-specific header is prepended based on the execution context.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
command (str): The multiline command to execute as a script.
|
|
128
|
+
ctx (RunContext): Context about the execution, including shell type, working directory,
|
|
129
|
+
and whether to capture output.
|
|
130
|
+
env (dict[str, str]): A dictionary of environment variables to set during script execution.
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
ExecutionResult: An object containing the executed command, its exit code, and captured
|
|
134
|
+
standard output and error streams.
|
|
135
|
+
"""
|
|
136
|
+
shell = (ctx.shell or "default").lower()
|
|
137
|
+
suffix = self.script_suffix_for_shell(shell)
|
|
138
|
+
script_path = None
|
|
139
|
+
|
|
140
|
+
if shell == "default":
|
|
141
|
+
log.debug("exec multiline as default shell, suffix=%s", suffix)
|
|
142
|
+
else:
|
|
143
|
+
log.debug("exec multiline as shell=%s, suffix=%s", shell, suffix)
|
|
144
|
+
|
|
145
|
+
# Normalize newlines so contents is consistent across platforms
|
|
146
|
+
script_body = command.replace("\r\n", "\n").rstrip("\n") + "\n"
|
|
147
|
+
|
|
148
|
+
try:
|
|
149
|
+
with tempfile.NamedTemporaryFile(
|
|
150
|
+
mode="w",
|
|
151
|
+
encoding="utf-8",
|
|
152
|
+
delete=False,
|
|
153
|
+
suffix=suffix,
|
|
154
|
+
) as file:
|
|
155
|
+
script_path = file.name
|
|
156
|
+
header = self.script_header_for_shell(shell)
|
|
157
|
+
if header:
|
|
158
|
+
file.write(header)
|
|
159
|
+
file.write(script_body)
|
|
160
|
+
|
|
161
|
+
popen_args = self.build_script_exec_args(script_path, shell=shell)
|
|
162
|
+
log.debug(
|
|
163
|
+
"exec multiline args_count=%s header=%s", len(popen_args), bool(header)
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
return self.execute_command(
|
|
167
|
+
command,
|
|
168
|
+
popen_args=popen_args,
|
|
169
|
+
cwd=ctx.cwd,
|
|
170
|
+
env=env,
|
|
171
|
+
capture_output=ctx.capture,
|
|
172
|
+
timeout=ctx.timeout,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
finally:
|
|
176
|
+
if script_path:
|
|
177
|
+
try:
|
|
178
|
+
os.remove(script_path)
|
|
179
|
+
except OSError:
|
|
180
|
+
pass
|
|
181
|
+
|
|
182
|
+
def execute_command(
|
|
183
|
+
self,
|
|
184
|
+
command: str,
|
|
185
|
+
*,
|
|
186
|
+
popen_args: list[str],
|
|
187
|
+
cwd: str | None = None,
|
|
188
|
+
env: dict[str, str] | None = None,
|
|
189
|
+
capture_output: bool = False,
|
|
190
|
+
timeout: int | None = None,
|
|
191
|
+
) -> ExecutionResult:
|
|
192
|
+
"""
|
|
193
|
+
Executes a command using the appropriate subprocess strategy based on
|
|
194
|
+
whether a timeout is set. When no timeout is provided, uses subprocess.run
|
|
195
|
+
for simplicity. When a timeout is provided, uses subprocess.Popen with
|
|
196
|
+
process tree cleanup to prevent orphaned child processes on timeout.
|
|
197
|
+
|
|
198
|
+
Args:
|
|
199
|
+
command (str): The original command string, used for logging and the
|
|
200
|
+
returned ExecutionResult.
|
|
201
|
+
popen_args (list[str]): The fully resolved argument list to pass to
|
|
202
|
+
the subprocess.
|
|
203
|
+
cwd (str | None): Working directory for the process.
|
|
204
|
+
env (dict[str, str] | None): Environment variables for the process.
|
|
205
|
+
capture_output (bool): Whether to capture stdout and stderr.
|
|
206
|
+
timeout (int | None): Maximum seconds to wait before killing the
|
|
207
|
+
process tree. If None, the command runs indefinitely.
|
|
208
|
+
|
|
209
|
+
Returns:
|
|
210
|
+
ExecutionResult: The result of the execution including exit code and
|
|
211
|
+
any captured output.
|
|
212
|
+
"""
|
|
213
|
+
if timeout is not None:
|
|
214
|
+
# If timeout is provided, use _run_subprocess method to handle timeout
|
|
215
|
+
try:
|
|
216
|
+
completed = self._run_subprocess(
|
|
217
|
+
popen_args,
|
|
218
|
+
cwd=cwd,
|
|
219
|
+
env=env,
|
|
220
|
+
capture_output=capture_output,
|
|
221
|
+
timeout=timeout,
|
|
222
|
+
)
|
|
223
|
+
except subprocess.TimeoutExpired:
|
|
224
|
+
log.warning("Command timed out after %s seconds", timeout)
|
|
225
|
+
return ExecutionResult(
|
|
226
|
+
command=command,
|
|
227
|
+
exit_code=124,
|
|
228
|
+
stdout="",
|
|
229
|
+
stderr=f"Command timed out after {timeout} seconds",
|
|
230
|
+
)
|
|
231
|
+
else:
|
|
232
|
+
completed = subprocess.run(
|
|
233
|
+
popen_args,
|
|
234
|
+
cwd=cwd,
|
|
235
|
+
text=True,
|
|
236
|
+
env=env,
|
|
237
|
+
capture_output=capture_output,
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
log.info("Command completed with exit code: %s", completed.returncode)
|
|
241
|
+
|
|
242
|
+
return ExecutionResult(
|
|
243
|
+
command=command,
|
|
244
|
+
exit_code=completed.returncode,
|
|
245
|
+
stdout=completed.stdout or "",
|
|
246
|
+
stderr=completed.stderr or "",
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
def _run_subprocess(
|
|
250
|
+
self,
|
|
251
|
+
popen_args: list[str],
|
|
252
|
+
*,
|
|
253
|
+
cwd: str | None,
|
|
254
|
+
env: dict[str, str] | None,
|
|
255
|
+
capture_output: bool,
|
|
256
|
+
timeout: int | None,
|
|
257
|
+
) -> subprocess.CompletedProcess:
|
|
258
|
+
"""
|
|
259
|
+
Runs a subprocess with proper process tree cleanup on timeout. On timeout,
|
|
260
|
+
the entire process tree is killed rather than just the direct child, which
|
|
261
|
+
prevents orphaned processes from continuing to run after the timeout expires.
|
|
262
|
+
|
|
263
|
+
Args:
|
|
264
|
+
popen_args (list[str]): The command and arguments to run.
|
|
265
|
+
cwd (str | None): Working directory for the process.
|
|
266
|
+
env (dict[str, str]): Environment variables for the process.
|
|
267
|
+
capture_output (bool): Whether to capture stdout and stderr.
|
|
268
|
+
timeout (int | None): Maximum seconds to wait before killing the process tree.
|
|
269
|
+
|
|
270
|
+
Returns:
|
|
271
|
+
subprocess.CompletedProcess: The result of the process.
|
|
272
|
+
|
|
273
|
+
Raises:
|
|
274
|
+
subprocess.TimeoutExpired: If the process exceeds the timeout.
|
|
275
|
+
"""
|
|
276
|
+
kwargs = dict(
|
|
277
|
+
cwd=cwd,
|
|
278
|
+
text=True,
|
|
279
|
+
env=env,
|
|
280
|
+
stdout=subprocess.PIPE if capture_output else None,
|
|
281
|
+
stderr=subprocess.PIPE if capture_output else None,
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
if sys.platform != "win32":
|
|
285
|
+
kwargs["start_new_session"] = True
|
|
286
|
+
|
|
287
|
+
with subprocess.Popen(popen_args, **kwargs) as proc:
|
|
288
|
+
try:
|
|
289
|
+
stdout, stderr = proc.communicate(timeout=timeout)
|
|
290
|
+
return subprocess.CompletedProcess(
|
|
291
|
+
popen_args, proc.returncode, stdout, stderr
|
|
292
|
+
)
|
|
293
|
+
except (subprocess.TimeoutExpired, KeyboardInterrupt):
|
|
294
|
+
self._kill_process_tree(proc)
|
|
295
|
+
proc.wait()
|
|
296
|
+
raise
|
|
297
|
+
|
|
298
|
+
@staticmethod
|
|
299
|
+
def _kill_process_tree(proc: subprocess.Popen) -> None:
|
|
300
|
+
"""
|
|
301
|
+
Kills a process and all of its children. On Windows, uses taskkill to
|
|
302
|
+
terminate the entire process tree. On Linux, sends SIGTERM to the process
|
|
303
|
+
group created by start_new_session=True.
|
|
304
|
+
|
|
305
|
+
Args:
|
|
306
|
+
proc (subprocess.Popen): The process whose tree should be killed.
|
|
307
|
+
"""
|
|
308
|
+
if sys.platform == "win32":
|
|
309
|
+
subprocess.run(
|
|
310
|
+
["taskkill", "/F", "/T", "/PID", str(proc.pid)],
|
|
311
|
+
capture_output=True,
|
|
312
|
+
)
|
|
313
|
+
else:
|
|
314
|
+
import signal
|
|
315
|
+
|
|
316
|
+
try:
|
|
317
|
+
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
|
|
318
|
+
except ProcessLookupError:
|
|
319
|
+
proc.kill()
|
|
320
|
+
|
|
321
|
+
@staticmethod
|
|
322
|
+
def emit_command(command: str) -> None:
|
|
323
|
+
"""
|
|
324
|
+
Emits a formatted command to standard output and terminates the process
|
|
325
|
+
with a success exit code.
|
|
326
|
+
|
|
327
|
+
This method takes a string command as input, appends a newline character,
|
|
328
|
+
and writes it to the standard output. It ensures the command is formatted
|
|
329
|
+
with a single trailing newline before being emitted. Once executed, the
|
|
330
|
+
method forcefully exits the process with an exit code of 0.
|
|
331
|
+
|
|
332
|
+
Args:
|
|
333
|
+
command (str): The input command that needs to be written to standard
|
|
334
|
+
output. It should be a valid string representation of the command
|
|
335
|
+
to execute.
|
|
336
|
+
"""
|
|
337
|
+
cmd = command.strip("\n") + "\n"
|
|
338
|
+
sys.stdout.write(cmd)
|
|
339
|
+
raise typer.Exit(code=0)
|
|
340
|
+
|
|
341
|
+
@staticmethod
|
|
342
|
+
def is_multiline(command: str) -> bool:
|
|
343
|
+
"""
|
|
344
|
+
Determines if the given command is multiline. A command is considered multiline
|
|
345
|
+
if it contains at least one newline character after stripping leading and trailing
|
|
346
|
+
newlines.
|
|
347
|
+
|
|
348
|
+
Args:
|
|
349
|
+
command (str): The command string to check.
|
|
350
|
+
|
|
351
|
+
Returns:
|
|
352
|
+
bool: True if the command contains at least one newline character after
|
|
353
|
+
trimming leading and trailing newline characters, False otherwise.
|
|
354
|
+
"""
|
|
355
|
+
return "\n" in command.strip("\n")
|
|
356
|
+
|
|
357
|
+
@staticmethod
|
|
358
|
+
def script_suffix_for_shell(shell: str) -> str:
|
|
359
|
+
"""
|
|
360
|
+
Determines the appropriate script file suffix for a given shell.
|
|
361
|
+
|
|
362
|
+
This function inspects the input shell name or its characteristics and returns
|
|
363
|
+
the corresponding script file suffix based on the shell's type.
|
|
364
|
+
|
|
365
|
+
Args:
|
|
366
|
+
shell (str): The name of the shell or its identifier.
|
|
367
|
+
|
|
368
|
+
Returns:
|
|
369
|
+
str: The appropriate script file suffix for the given shell.
|
|
370
|
+
"""
|
|
371
|
+
if shell == "default":
|
|
372
|
+
return ".cmd" if sys.platform == "win32" else ".sh"
|
|
373
|
+
if "cmd" in shell or shell == "cmd.exe":
|
|
374
|
+
return ".cmd"
|
|
375
|
+
if "powershell" in shell or shell == "pwsh":
|
|
376
|
+
return ".ps1"
|
|
377
|
+
if "fish" in shell:
|
|
378
|
+
return ".fish"
|
|
379
|
+
# Default to bash
|
|
380
|
+
return ".sh"
|
|
381
|
+
|
|
382
|
+
@staticmethod
|
|
383
|
+
def script_header_for_shell(shell: str) -> str:
|
|
384
|
+
"""
|
|
385
|
+
Generates a script header for the given shell type.
|
|
386
|
+
|
|
387
|
+
This method determines the appropriate script header based on the
|
|
388
|
+
specified shell string. Supported shells include bash, zsh, and fish.
|
|
389
|
+
If the shell type isn't recognized or no header is required, it returns
|
|
390
|
+
an empty string.
|
|
391
|
+
|
|
392
|
+
Args:
|
|
393
|
+
shell (str): The name of the shell for which the script header is
|
|
394
|
+
to be generated.
|
|
395
|
+
|
|
396
|
+
Returns:
|
|
397
|
+
str: The script header string corresponding to the provided shell,
|
|
398
|
+
or an empty string if no header is required.
|
|
399
|
+
"""
|
|
400
|
+
if "bash" in shell:
|
|
401
|
+
return "#!/usr/bin/env bash\n"
|
|
402
|
+
if "zsh" in shell:
|
|
403
|
+
return "#!/usr/bin/env zsh\n"
|
|
404
|
+
if "fish" in shell:
|
|
405
|
+
return "#!/usr/bin/env fish\n"
|
|
406
|
+
# Other types do not require a header
|
|
407
|
+
return ""
|
|
408
|
+
|
|
409
|
+
@staticmethod
|
|
410
|
+
def build_script_exec_args(script_path: str, shell: str) -> list[str]:
|
|
411
|
+
"""
|
|
412
|
+
Assembles a list of arguments to execute a given script based on the specified shell.
|
|
413
|
+
|
|
414
|
+
This method generates the appropriate command and arguments to execute
|
|
415
|
+
a script, tailored to the shell type provided. It ensures compatibility with
|
|
416
|
+
various shell environments such as cmd, PowerShell, bash, zsh, and fish.
|
|
417
|
+
|
|
418
|
+
Args:
|
|
419
|
+
script_path (str): The file path to the script that needs to be executed.
|
|
420
|
+
shell (str): The name or identifier of the shell environment used for script execution.
|
|
421
|
+
|
|
422
|
+
Returns:
|
|
423
|
+
list[str]: A list of arguments that, when executed, run the specified script in the given shell.
|
|
424
|
+
"""
|
|
425
|
+
if shell == "default":
|
|
426
|
+
if sys.platform == "win32":
|
|
427
|
+
return ["cmd.exe", "/d", "/s", "/c", script_path]
|
|
428
|
+
return ["sh", script_path]
|
|
429
|
+
|
|
430
|
+
if "cmd" in shell or shell == "cmd.exe":
|
|
431
|
+
return ["cmd.exe", "/d", "/s", "/c", script_path]
|
|
432
|
+
|
|
433
|
+
if "powershell" in shell or shell == "pwsh":
|
|
434
|
+
exe = "pwsh" if "pwsh" in shell else "powershell"
|
|
435
|
+
return [
|
|
436
|
+
exe,
|
|
437
|
+
"-NoProfile",
|
|
438
|
+
"-NonInteractive",
|
|
439
|
+
"-ExecutionPolicy",
|
|
440
|
+
"Bypass",
|
|
441
|
+
"-File",
|
|
442
|
+
script_path,
|
|
443
|
+
]
|
|
444
|
+
|
|
445
|
+
if "fish" in shell:
|
|
446
|
+
return ["fish", script_path]
|
|
447
|
+
|
|
448
|
+
if "zsh" in shell:
|
|
449
|
+
return ["zsh", script_path]
|
|
450
|
+
|
|
451
|
+
if "bash" in shell:
|
|
452
|
+
return ["bash", script_path]
|
|
453
|
+
|
|
454
|
+
return ["sh", script_path]
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
@dataclass(frozen=True)
|
|
5
|
+
class ExecutionResult:
|
|
6
|
+
"""
|
|
7
|
+
Represents the result of a command execution.
|
|
8
|
+
|
|
9
|
+
This class is a data container that holds details of a command execution
|
|
10
|
+
outcome, including the executed command, its exit status, and associated
|
|
11
|
+
standard output and error streams. It is designed to provide a structured
|
|
12
|
+
result of running an external command in a consistent and immutable format.
|
|
13
|
+
|
|
14
|
+
Attributes:
|
|
15
|
+
command (str): The actual command that was executed. Extrapolated
|
|
16
|
+
from the Command.template.
|
|
17
|
+
exit_code (int): The exit code returned by the command.
|
|
18
|
+
stdout (str): The standard output produced by the command.
|
|
19
|
+
stderr (str): The standard error output produced by the command.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
command: str
|
|
23
|
+
exit_code: int
|
|
24
|
+
stdout: str
|
|
25
|
+
stderr: str
|
cmdbox/runtime/shell.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
from shutil import which
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def build_shell_command(command: str, preferred_shell: str | None = None) -> list[str]:
|
|
7
|
+
"""
|
|
8
|
+
Builds a shell command list based on the provided command and preferred shell, with fallback mechanisms for
|
|
9
|
+
platform compatibility and environment-specific configurations.
|
|
10
|
+
|
|
11
|
+
This function determines the appropriate shell and constructs a command list for execution. It handles both
|
|
12
|
+
Windows and Unix-like environments, prioritizing the specified preferred shell or defaulting to environment
|
|
13
|
+
variables and standard fallback options. If no suitable shell is found, it raises a RuntimeError.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
command (str): The shell command to execute.
|
|
17
|
+
preferred_shell (str | None): The preferred shell to use for executing the command. If None, the function
|
|
18
|
+
attempts to determine an appropriate shell based on the platform, environment, and fallback defaults.
|
|
19
|
+
|
|
20
|
+
Returns:
|
|
21
|
+
list[str]: A list containing the shell command and its arguments.
|
|
22
|
+
|
|
23
|
+
Raises:
|
|
24
|
+
RuntimeError: If no usable shell is found for the current system.
|
|
25
|
+
"""
|
|
26
|
+
candidates: list[tuple[str, list[str]]] = []
|
|
27
|
+
|
|
28
|
+
# Windows options
|
|
29
|
+
if sys.platform.startswith("win"):
|
|
30
|
+
if preferred_shell:
|
|
31
|
+
candidates.append(
|
|
32
|
+
(preferred_shell, _windows_shell_args(preferred_shell, command))
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
env_shell = os.environ.get("CMDBOX_SHELL")
|
|
36
|
+
if env_shell:
|
|
37
|
+
candidates.append((env_shell, _windows_shell_args(env_shell, command)))
|
|
38
|
+
|
|
39
|
+
# Known good fallbacks
|
|
40
|
+
candidates.extend(
|
|
41
|
+
[
|
|
42
|
+
("pwsh", ["pwsh", "-NoProfile", "-Command", command]),
|
|
43
|
+
("powershell", ["powershell", "-NoProfile", "-Command", command]),
|
|
44
|
+
("cmd.exe", ["cmd.exe", "/C", command]),
|
|
45
|
+
]
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
for exe, args in candidates:
|
|
49
|
+
if which(exe):
|
|
50
|
+
return args
|
|
51
|
+
|
|
52
|
+
# Last resort option
|
|
53
|
+
return ["cmd.exe", "/C", command]
|
|
54
|
+
|
|
55
|
+
# Unix options
|
|
56
|
+
if preferred_shell:
|
|
57
|
+
candidates.append((preferred_shell, [preferred_shell, "-lc", command]))
|
|
58
|
+
|
|
59
|
+
env_shell = os.environ.get("SHELL")
|
|
60
|
+
if env_shell:
|
|
61
|
+
candidates.append((env_shell, [env_shell, "-lc", command]))
|
|
62
|
+
|
|
63
|
+
candidates.extend(
|
|
64
|
+
[
|
|
65
|
+
("/bin/bash", ["/bin/bash", "-lc", command]),
|
|
66
|
+
("/bin/sh", ["/bin/sh", "-lc", command]),
|
|
67
|
+
]
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
for exe, args in candidates:
|
|
71
|
+
if os.path.isabs(exe):
|
|
72
|
+
if os.path.exists(exe):
|
|
73
|
+
return args
|
|
74
|
+
elif which(exe):
|
|
75
|
+
return args
|
|
76
|
+
|
|
77
|
+
raise RuntimeError("No usable shell found for this system")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _windows_shell_args(shell: str, command: str) -> list[str]:
|
|
81
|
+
shell = shell.lower()
|
|
82
|
+
|
|
83
|
+
if shell in ("pwsh", "powershell"):
|
|
84
|
+
return [shell, "-NoProfile", "-Command", command]
|
|
85
|
+
|
|
86
|
+
if shell in ("cmd", "cmd.exe"):
|
|
87
|
+
return ["cmd.exe", "/C", command]
|
|
88
|
+
|
|
89
|
+
# Treat unknown shell as executable
|
|
90
|
+
return [shell, command]
|
|
File without changes
|