shipit-cli 0.1.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.
shipit/cli.py ADDED
@@ -0,0 +1,1281 @@
1
+ import logging
2
+ import os
3
+ import shlex
4
+ import shutil
5
+ import sys
6
+ import json
7
+ import yaml
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+ from typing import (
11
+ Any,
12
+ Dict,
13
+ List,
14
+ Optional,
15
+ Protocol,
16
+ Set,
17
+ TypedDict,
18
+ Union,
19
+ cast,
20
+ )
21
+ from shutil import copy, copytree, ignore_patterns
22
+
23
+ import sh # type: ignore[import-untyped]
24
+ import starlark as sl
25
+ import typer
26
+ from rich import box
27
+ from rich.console import Console
28
+ from rich.panel import Panel
29
+ from rich.rule import Rule
30
+ from rich.syntax import Syntax
31
+
32
+ from shipit.version import version as shipit_version
33
+ from shipit.generator import generate_shipit
34
+
35
+
36
+ console = Console()
37
+
38
+ app = typer.Typer(invoke_without_command=True)
39
+
40
+ DIR_PATH = Path(__file__).resolve().parent
41
+ ASSETS_PATH = DIR_PATH / "assets"
42
+
43
+
44
+ @dataclass
45
+ class Serve:
46
+ name: str
47
+ provider: str
48
+ build: List["Step"]
49
+ deps: List["Package"]
50
+ commands: Dict[str, str]
51
+ assets: Optional[Dict[str, str]] = None
52
+ prepare: Optional[List["PrepareStep"]] = None
53
+ workers: Optional[List[str]] = None
54
+ mounts: Optional[Dict[str, str]] = None
55
+
56
+
57
+ @dataclass
58
+ class Package:
59
+ name: str
60
+ version: Optional[str] = None
61
+
62
+ def __str__(self) -> str: # pragma: no cover - simple representation
63
+ return f"{self.name}@{self.version}"
64
+
65
+
66
+ @dataclass
67
+ class RunStep:
68
+ command: str
69
+ inputs: Optional[List[str]] = None
70
+ outputs: Optional[List[str]] = None
71
+ group: Optional[str] = None
72
+
73
+
74
+ @dataclass
75
+ class CopyStep:
76
+ source: str
77
+ target: str
78
+ ignore: Optional[List[str]] = None
79
+
80
+
81
+ @dataclass
82
+ class EnvStep:
83
+ variables: Dict[str, str]
84
+
85
+ def __str__(self) -> str: # pragma: no cover - simple representation
86
+ return " ".join([f"{key}={value}" for key, value in self.variables.items()])
87
+
88
+
89
+ @dataclass
90
+ class UseStep:
91
+ dependencies: List[Package]
92
+
93
+
94
+ @dataclass
95
+ class PathStep:
96
+ path: str
97
+
98
+
99
+ Step = Union[RunStep, CopyStep, EnvStep, PathStep, UseStep]
100
+ PrepareStep = Union[RunStep]
101
+
102
+
103
+ @dataclass
104
+ class Build:
105
+ deps: List[Package]
106
+ steps: List[Step]
107
+
108
+
109
+ def write_stdout(line: str) -> None:
110
+ sys.stdout.write(line) # print to console
111
+
112
+
113
+ def write_stderr(line: str) -> None:
114
+ sys.stderr.write(line) # print to console
115
+
116
+
117
+ class MapperItem(TypedDict):
118
+ dependencies: Dict[str, str]
119
+ scripts: Set[str]
120
+ env: Dict[str, str]
121
+ aliases: Dict[str, str]
122
+
123
+
124
+ class Builder(Protocol):
125
+ def build(self, env: Dict[str, str], steps: List[Step]) -> None: ...
126
+ def build_assets(self, assets: Dict[str, str]) -> None: ...
127
+ def build_prepare(self, serve: Serve) -> None: ...
128
+ def build_serve(self, serve: Serve) -> None: ...
129
+ def finalize_build(self, serve: Serve) -> None: ...
130
+ def prepare(self, env: Dict[str, str], prepare: List[PrepareStep]) -> None: ...
131
+ def getenv(self, name: str) -> Optional[str]: ...
132
+ def run_serve_command(self, command: str) -> None: ...
133
+ def run_command(
134
+ self, command: str, extra_args: Optional[List[str]] | None = None
135
+ ) -> Any: ...
136
+ def serve_mount(self, name: str) -> str: ...
137
+ def get_asset(self, name: str) -> str: ...
138
+
139
+
140
+ class DockerBuilder:
141
+ def __init__(self, src_dir: Path, docker_client: Optional[str] = None) -> None:
142
+ self.src_dir = src_dir
143
+ self.docker_file_contents = ""
144
+ self.docker_path = self.src_dir / ".shipit" / "docker"
145
+ self.depot_metadata = self.docker_path / "depot-build.json"
146
+ self.docker_file_path = self.docker_path / "Dockerfile"
147
+ self.docker_name_path = self.docker_path / "name"
148
+ self.docker_ignore_path = self.docker_path / "Dockerfile.dockerignore"
149
+ self.shipit_docker_path = Path("/shipit")
150
+ self.docker_client = docker_client or "docker"
151
+ self.env = {
152
+ "HOME": "/root",
153
+ }
154
+
155
+ @property
156
+ def is_depot(self) -> bool:
157
+ return self.docker_client == "depot"
158
+
159
+ def getenv(self, name: str) -> Optional[str]:
160
+ return self.env.get(name) or os.environ.get(name)
161
+
162
+ def mkdir(self, path: Path) -> Path:
163
+ path = self.shipit_docker_path / path
164
+ self.docker_file_contents += f"RUN mkdir -p {str(path.absolute())}\n"
165
+ return path.absolute()
166
+
167
+ def build_dockerfile(self, image_name: str) -> None:
168
+ self.docker_file_path.write_text(self.docker_file_contents)
169
+ self.docker_name_path.write_text(image_name)
170
+ self.print_dockerfile()
171
+ extra_args = []
172
+ if self.is_depot:
173
+ # We load the docker image back into the local docker daemon
174
+ # extra_args += ["--load"]
175
+ extra_args += ["--save", f"--metadata-file={self.depot_metadata.absolute()}"]
176
+ sh.Command(self.docker_client)(
177
+ "build",
178
+ "-f",
179
+ (self.docker_path / "Dockerfile").absolute(),
180
+ "-t",
181
+ image_name,
182
+ "--platform",
183
+ "linux/amd64",
184
+ ".",
185
+ *extra_args,
186
+ _cwd=self.src_dir.absolute(),
187
+ _env=os.environ, # Pass the current environment variables to the Docker client
188
+ _out=write_stdout,
189
+ _err=write_stderr,
190
+ )
191
+ if self.is_depot:
192
+ json_text = self.depot_metadata.read_text()
193
+ json_data = json.loads(json_text)
194
+ build_data = json_data["depot.build"]
195
+ image_id = build_data["buildID"]
196
+ project = build_data["projectID"]
197
+ sh.Command("depot")(
198
+ "pull",
199
+ "--platform",
200
+ "linux/amd64",
201
+ "--project",
202
+ project,
203
+ image_id,
204
+ _cwd=self.src_dir.absolute(),
205
+ _env=os.environ, # Pass the current environment variables to the Docker client
206
+ _out=write_stdout,
207
+ _err=write_stderr,
208
+ )
209
+ # console.print(f"[bold]Image ID:[/bold] {image_id}")
210
+
211
+ def finalize_build(self, serve: Serve) -> None:
212
+ console.print(f"\n[bold]Building Docker file[/bold]")
213
+ self.build_dockerfile(serve.name)
214
+ console.print(Rule(characters="-", style="bright_black"))
215
+ console.print(f"[bold]Build complete ✅[/bold]")
216
+
217
+ def run_command(self, command: str, extra_args: Optional[List[str]] = None) -> Any:
218
+ image_name = self.docker_name_path.read_text()
219
+ return sh.Command(
220
+ "docker"
221
+ )(
222
+ "run",
223
+ "-p",
224
+ "80:80",
225
+ "--rm",
226
+ image_name,
227
+ command,
228
+ *(extra_args or []),
229
+ _env=os.environ, # Pass the current environment variables to the Docker client
230
+ _out=write_stdout,
231
+ _err=write_stderr,
232
+ )
233
+
234
+ def create_file(self, path: Path, content: str, mode: int = 0o755) -> Path:
235
+ # docker_files = self.docker_path / "files" / path.name
236
+ # docker_files.write_text(content)
237
+ # docker_files.chmod(mode)
238
+ self.docker_file_contents += f"""
239
+ RUN cat > {path.absolute()} <<'EOF'
240
+ {content}
241
+ EOF
242
+
243
+ RUN chmod {oct(mode)[2:]} {path.absolute()}
244
+ """
245
+
246
+ return path.absolute()
247
+
248
+ def print_dockerfile(self) -> None:
249
+ docker_file = self.docker_path / "Dockerfile"
250
+ manifest_panel = Panel(
251
+ Syntax(
252
+ docker_file.read_text(),
253
+ "dockerfile",
254
+ theme="monokai",
255
+ background_color="default",
256
+ line_numbers=True,
257
+ ),
258
+ box=box.SQUARE,
259
+ border_style="bright_black",
260
+ expand=False,
261
+ )
262
+ console.print(manifest_panel, markup=False, highlight=True)
263
+
264
+ def add_dependency(self, dependency: Package):
265
+ if dependency.name == "pie":
266
+ self.docker_file_contents += f"RUN apt-get update && apt-get -y --no-install-recommends install gcc make autoconf libtool bison re2c pkg-config libpq-dev\n"
267
+ self.docker_file_contents += f"RUN curl -L --output /usr/bin/pie https://github.com/php/pie/releases/download/1.2.0/pie.phar && chmod +x /usr/bin/pie\n"
268
+ return
269
+ elif dependency.name == "static-web-server":
270
+ if dependency.version:
271
+ self.docker_file_contents += (
272
+ f"ENV SWS_INSTALL_VERSION={dependency.version}\n"
273
+ )
274
+ self.docker_file_contents += f"RUN curl --proto '=https' --tlsv1.2 -sSfL https://get.static-web-server.net | sh\n"
275
+ return
276
+ if dependency.version:
277
+ self.docker_file_contents += (
278
+ f"RUN pkgm install {dependency.name}@{dependency.version}\n"
279
+ )
280
+ else:
281
+ self.docker_file_contents += f"RUN pkgm install {dependency.name}\n"
282
+
283
+ def build(self, env: Dict[str, str], steps: List[Step]) -> None:
284
+ base_path = self.docker_path
285
+ shutil.rmtree(base_path, ignore_errors=True)
286
+ base_path.mkdir(parents=True, exist_ok=True)
287
+ self.docker_file_contents = "FROM debian:bookworm-slim\n"
288
+ self.docker_file_contents += """
289
+ RUN apt-get update \\
290
+ && apt-get -y --no-install-recommends install sudo curl ca-certificates locate git zip unzip \\
291
+ && rm -rf /var/lib/apt/lists/*
292
+
293
+ SHELL ["/bin/bash", "-o", "pipefail", "-c"]
294
+
295
+ RUN curl https://pkgx.sh | sh
296
+ """
297
+ # docker_file_contents += "RUN curl https://mise.run | sh\n"
298
+ self.docker_file_contents += """
299
+ RUN curl https://get.wasmer.io -sSfL | sh -s "v6.1.0-rc.3"
300
+ ENV PATH="/root/.wasmer/bin:${PATH}"
301
+ """
302
+ self.docker_file_contents += "WORKDIR /app\n"
303
+ for step in steps:
304
+ if isinstance(step, RunStep):
305
+ if step.inputs:
306
+ pre = "\\\n " + "".join(
307
+ [
308
+ f"--mount=type=bind,source={input},target={input} \\\n "
309
+ for input in step.inputs
310
+ ]
311
+ )
312
+ else:
313
+ pre = ""
314
+ self.docker_file_contents += f"RUN {pre}{step.command}\n"
315
+ elif isinstance(step, CopyStep):
316
+ self.docker_file_contents += f"COPY {step.source} {step.target}\n"
317
+ elif isinstance(step, EnvStep):
318
+ env_vars = " ".join(
319
+ [f"{key}={value}" for key, value in step.variables.items()]
320
+ )
321
+ self.docker_file_contents += f"ENV {env_vars}\n"
322
+ elif isinstance(step, PathStep):
323
+ self.docker_file_contents += f"ENV PATH={step.path}:$PATH\n"
324
+ elif isinstance(step, UseStep):
325
+ for dependency in step.dependencies:
326
+ self.add_dependency(dependency)
327
+
328
+ self.docker_ignore_path.write_text("""
329
+ .shipit
330
+ Shipit
331
+ """)
332
+
333
+ def build_assets(self, assets: Dict[str, str]) -> None:
334
+ raise NotImplementedError
335
+
336
+ def get_path(self) -> Path:
337
+ return Path("/")
338
+
339
+ def get_build_path(self) -> Path:
340
+ return self.get_path() / "app"
341
+
342
+ def get_serve_path(self) -> Path:
343
+ return self.get_path() / "serve"
344
+
345
+ def get_assets_path(self) -> Path:
346
+ path = self.get_path() / "assets"
347
+ self.mkdir(path)
348
+ return path
349
+
350
+ def prepare(self, env: Dict[str, str], prepare: List[PrepareStep]) -> None:
351
+ raise NotImplementedError
352
+
353
+ def build_serve(self, serve: Serve) -> None:
354
+ serve_command_path = self.mkdir(Path("serve") / "bin")
355
+ console.print(f"[bold]Serve Commands:[/bold]")
356
+ for dep in serve.deps:
357
+ self.add_dependency(dep)
358
+
359
+ build_path = self.get_build_path()
360
+ for command in serve.commands:
361
+ console.print(f"* {command}")
362
+ command_path = serve_command_path / command
363
+ self.create_file(
364
+ command_path,
365
+ f"#!/bin/bash\ncd {build_path}\n{serve.commands[command]}",
366
+ mode=0o755,
367
+ )
368
+
369
+ def serve_mount(self, name: str) -> str:
370
+ path = self.mkdir(Path("serve") / "mounts" / name)
371
+ return str(path.absolute())
372
+
373
+ def get_asset(self, name: str) -> str:
374
+ asset_path = ASSETS_PATH / name
375
+ return asset_path.read_text()
376
+
377
+ def run_serve_command(self, command: str) -> None:
378
+ path = self.shipit_docker_path / "serve" / "bin" / command
379
+ self.run_command(str(path))
380
+
381
+
382
+ class LocalBuilder:
383
+ def __init__(self, src_dir: Path) -> None:
384
+ self.src_dir = src_dir
385
+ self.local_path = self.src_dir / ".shipit" / "local"
386
+ self.prepare_bash_script = self.local_path / "prepare" / "prepare.sh"
387
+
388
+ def execute_step(self, step: Step, env: Dict[str, str], build_path: Path) -> None:
389
+ if isinstance(step, UseStep):
390
+ console.print(f"[bold]Using dependencies:[/bold] {step.dependencies}")
391
+ elif isinstance(step, RunStep):
392
+ extra = ""
393
+ if step.inputs:
394
+ for input in step.inputs:
395
+ copy((self.src_dir / input), (build_path / input))
396
+ all_inputs = ", ".join(step.inputs)
397
+ extra = f" [bright_black]# using {all_inputs}[/bright_black]"
398
+ console.print(
399
+ f"[bright_black]$[/bright_black] [bold]{step.command}[/bold]{extra}"
400
+ )
401
+ command_line = step.command
402
+ parts = shlex.split(command_line)
403
+ program = parts[0]
404
+ extended_paths = [
405
+ str(build_path / path) for path in env["PATH"].split(os.pathsep)
406
+ ]
407
+ extended_paths.append(os.environ["PATH"])
408
+ PATH = os.pathsep.join(extended_paths) # type: ignore
409
+ exe = shutil.which(program, path=PATH)
410
+ if not exe:
411
+ raise Exception(f"Program is not installed: {program}")
412
+ cmd = sh.Command(exe) # "grep"
413
+ result = cmd(
414
+ *parts[1:],
415
+ _env={**env, "PATH": PATH},
416
+ _cwd=build_path,
417
+ _out=write_stdout,
418
+ _err=write_stderr,
419
+ )
420
+ elif isinstance(step, CopyStep):
421
+ ignore_extra = ""
422
+ if step.ignore:
423
+ ignore_extra = (
424
+ f" [bright_black]# ignoring {', '.join(step.ignore)}[/bright_black]"
425
+ )
426
+ if step.target == ".":
427
+ console.print(f"[bold]Copy from {step.source}[/bold]{ignore_extra}")
428
+ else:
429
+ console.print(
430
+ f"[bold]Copy to {step.target} from {step.source}[/bold]{ignore_extra}"
431
+ )
432
+ ignore_matches = step.ignore if step.ignore else []
433
+ ignore_matches.append(".shipit")
434
+ ignore_matches.append("Shipit")
435
+ copytree(
436
+ (self.src_dir / step.source),
437
+ (build_path / step.target),
438
+ dirs_exist_ok=True,
439
+ ignore=ignore_patterns(*ignore_matches),
440
+ )
441
+ elif isinstance(step, EnvStep):
442
+ print(f"Setting environment variables: {step}")
443
+ env.update(step.variables)
444
+ elif isinstance(step, PathStep):
445
+ console.print(f"[bold]Add {step.path}[/bold] to PATH")
446
+ fullpath = step.path
447
+ env["PATH"] = f"{fullpath}{os.pathsep}{env['PATH']}"
448
+ else:
449
+ raise Exception(f"Unknown step type: {type(step)}")
450
+
451
+ def build(self, env: Dict[str, str], steps: List[Step]) -> None:
452
+ console.print(f"\n[bold]Building package[/bold]")
453
+ base_path = self.local_path
454
+ shutil.rmtree(base_path, ignore_errors=True)
455
+ base_path.mkdir(parents=True, exist_ok=True)
456
+ temp_path = base_path / "build"
457
+ temp_path.mkdir(exist_ok=False)
458
+ logging.info(f"Initialized temporary build path: {temp_path}")
459
+ for step in steps:
460
+ console.print(Rule(characters="-", style="bright_black"))
461
+ self.execute_step(step, env, temp_path)
462
+
463
+ if "PATH" in env:
464
+ path = base_path / ".path"
465
+ path.write_text(env["PATH"]) # type: ignore
466
+
467
+ console.print(Rule(characters="-", style="bright_black"))
468
+ console.print(f"[bold]Build complete ✅[/bold]")
469
+
470
+ def mkdir(self, path: Path) -> Path:
471
+ path = self.get_path() / path
472
+ path.mkdir(parents=True, exist_ok=True)
473
+ return path.absolute()
474
+
475
+ def create_file(self, path: Path, content: str, mode: int = 0o755) -> Path:
476
+ path.write_text(content)
477
+ path.chmod(mode)
478
+ return path.absolute()
479
+
480
+ def run_command(self, command: str, extra_args: Optional[List[str]] = None) -> Any:
481
+ return sh.Command(command)(
482
+ *(extra_args or []),
483
+ _out=write_stdout,
484
+ _err=write_stderr,
485
+ _env=os.environ,
486
+ )
487
+
488
+ def getenv(self, name: str) -> Optional[str]:
489
+ return os.environ.get(name)
490
+
491
+ def get_path(self) -> Path:
492
+ return self.local_path
493
+
494
+ def get_build_path(self) -> Path:
495
+ return self.get_path() / "build"
496
+
497
+ def get_serve_path(self) -> Path:
498
+ return self.get_path() / "serve"
499
+
500
+ def get_assets_path(self) -> Path:
501
+ path = self.get_path() / "assets"
502
+ self.mkdir(path)
503
+ return path
504
+
505
+ def build_assets(self, assets: Dict[str, str]) -> None:
506
+ assets_path = self.get_assets_path()
507
+ for asset in assets:
508
+ asset_path = assets_path / asset
509
+ self.create_file(asset_path, assets[asset])
510
+
511
+ def build_prepare(self, serve: Serve) -> None:
512
+ app_dir = self.get_build_path()
513
+ self.prepare_bash_script.parent.mkdir(parents=True, exist_ok=True)
514
+ commands: List[str] = []
515
+ if serve.prepare:
516
+ for step in serve.prepare:
517
+ if isinstance(step, RunStep):
518
+ commands.append(step.command)
519
+ content = "#!/bin/bash\ncd {app_dir}\n{body}".format(
520
+ app_dir=app_dir, body="\n".join(commands)
521
+ )
522
+ self.prepare_bash_script.write_text(content)
523
+ self.prepare_bash_script.chmod(0o755)
524
+
525
+ def finalize_build(self, serve: Serve) -> None:
526
+ pass
527
+
528
+ def prepare(self, env: Dict[str, str], prepare: List[PrepareStep]) -> None:
529
+ sh.Command(f"{self.prepare_bash_script.absolute()}")(
530
+ _out=write_stdout, _err=write_stderr
531
+ )
532
+
533
+ def build_serve(self, serve: Serve) -> None:
534
+ console.print("\n[bold]Building serve[/bold]")
535
+ build_path = self.get_build_path()
536
+ serve_command_path = self.get_serve_path() / "bin"
537
+ serve_command_path.mkdir(parents=True, exist_ok=False)
538
+ path = self.get_path() / ".path"
539
+ # path_resolved = [str((build_path/path).resolve()) for path in path.read_text().split(os.pathsep) if path]
540
+ # path_text = os.pathsep.join(path_resolved)
541
+ path_text = path.read_text()
542
+ console.print(f"[bold]Serve Commands:[/bold]")
543
+ for command in serve.commands:
544
+ console.print(f"* {command}")
545
+ command_path = serve_command_path / command
546
+ command_path.write_text(
547
+ f"#!/bin/bash\ncd {build_path}\nPATH={path_text}:$PATH {serve.commands[command]}"
548
+ )
549
+ command_path.chmod(0o755)
550
+
551
+ def run_serve_command(self, command: str) -> None:
552
+ console.print(f"\n[bold]Running {command} command[/bold]")
553
+ base_path = self.get_serve_path() / "bin"
554
+ command_path = base_path / command
555
+ sh.Command(str(command_path))(_out=write_stdout, _err=write_stderr)
556
+
557
+ def serve_mount(self, name: str) -> str:
558
+ base_path = self.get_serve_path() / "mounts" / name
559
+ base_path.mkdir(parents=True, exist_ok=True)
560
+ return str(base_path.absolute())
561
+
562
+ def get_asset(self, name: str) -> str:
563
+ asset_path = ASSETS_PATH / name
564
+ return asset_path.read_text()
565
+
566
+
567
+ class WasmerBuilder:
568
+ mapper: Dict[str, MapperItem] = {
569
+ "python": {
570
+ "dependencies": {
571
+ "latest": "wasmer/python-native@=0.1.11",
572
+ "3.13": "wasmer/python-native@=0.1.11",
573
+ },
574
+ "scripts": {"python"},
575
+ "aliases": {},
576
+ "env": {
577
+ "PYTHONEXECUTABLE": "/bin/python",
578
+ "PYTHONHOME": "/cpython",
579
+ "PYTHONPATH": "/.venv/lib/python3.13/site-packages",
580
+ "HOME": "/app",
581
+ },
582
+ },
583
+ "php": {
584
+ "dependencies": {
585
+ "latest": "php/php-32@=8.3.2104",
586
+ "8.3": "php/php-32@=8.3.2104",
587
+ },
588
+ "scripts": {"php"},
589
+ "aliases": {},
590
+ "env": {},
591
+ },
592
+ "bash": {
593
+ "dependencies": {
594
+ "latest": "wasmer/bash@=1.0.24",
595
+ "8.3": "wasmer/bash@=1.0.24",
596
+ },
597
+ "scripts": {"bash", "sh"},
598
+ "aliases": {},
599
+ "env": {},
600
+ },
601
+ "static-web-server": {
602
+ "dependencies": {
603
+ "latest": "wasmer/static-web-server@=1.1.0",
604
+ "2.38.0": "wasmer/static-web-server@=1.1.0",
605
+ "0.1": "wasmer/static-web-server@=1.1.0",
606
+ },
607
+ "scripts": {"webserver"},
608
+ "aliases": {"static-web-server": "webserver"},
609
+ "env": {},
610
+ },
611
+ }
612
+
613
+ def __init__(
614
+ self,
615
+ inner_builder: Builder,
616
+ src_dir: Path,
617
+ registry: Optional[str] = None,
618
+ token: Optional[str] = None,
619
+ ) -> None:
620
+ self.src_dir = src_dir
621
+ self.inner_builder = inner_builder
622
+ # The path where we store the directory of the wasmer app in the inner builder
623
+ self.wasmer_dir_path = Path(self.src_dir / ".shipit" / "wasmer_dir")
624
+ self.wasmer_registry = registry
625
+ self.wasmer_token = token
626
+ self.default_env = {
627
+ "SHIPIT_PYTHON_EXTRA_INDEX_URL": "https://pythonindex.wasix.org/simple",
628
+ "SHIPIT_PYTHON_CROSS_PLATFORM": "wasix_wasm32",
629
+ # "SHIPIT_PYTHON_PRECOMPILE": "true",
630
+ }
631
+
632
+ def getenv(self, name: str) -> Optional[str]:
633
+ return self.inner_builder.getenv(name) or self.default_env.get(name)
634
+
635
+ def build(self, env: Dict[str, str], build: List[Step]) -> None:
636
+ return self.inner_builder.build(env, build)
637
+
638
+ def build_assets(self, assets: Dict[str, str]) -> None:
639
+ return self.inner_builder.build_assets(assets)
640
+
641
+ def get_build_path(self) -> Path:
642
+ return Path("/app")
643
+
644
+ def build_prepare(self, serve: Serve) -> None:
645
+ print("Building prepare")
646
+ inner = cast(Any, self.inner_builder)
647
+ prepare_dir = inner.mkdir(Path("wasmer") / "prepare")
648
+ env = {}
649
+ for dep in serve.deps:
650
+ if dep.name in self.mapper:
651
+ dep_env = self.mapper[dep.name].get("env")
652
+ if dep_env is not None:
653
+ env.update(dep_env)
654
+ if env:
655
+ env_lines = [f"export {k}={v}" for k, v in env.items()]
656
+ env_lines = "\n".join(env_lines)
657
+ else:
658
+ env_lines = ""
659
+
660
+ commands: List[str] = []
661
+ if serve.prepare:
662
+ for step in serve.prepare:
663
+ if isinstance(step, RunStep):
664
+ commands.append(step.command)
665
+ body = "\n".join(filter(None, [env_lines, *commands]))
666
+ inner.create_file(
667
+ Path(prepare_dir) / "prepare.sh",
668
+ f"#!/bin/bash\ncd /app\n{body}",
669
+ mode=0o755,
670
+ )
671
+
672
+ def finalize_build(self, serve: Serve) -> None:
673
+ inner = cast(Any, self.inner_builder)
674
+ inner.finalize_build(serve)
675
+
676
+ def prepare(self, env: Dict[str, str], prepare: List[PrepareStep]) -> None:
677
+ inner = cast(Any, self.inner_builder)
678
+ prepare_dir = inner.mkdir(Path("wasmer") / "prepare")
679
+ self.run_serve_command(
680
+ "bash",
681
+ extra_args=[
682
+ f"--mapdir=/prepare:{prepare_dir}",
683
+ "--",
684
+ "/prepare/prepare.sh",
685
+ ],
686
+ )
687
+
688
+ def build_serve(self, serve: Serve) -> None:
689
+ from tomlkit import comment, document, nl, table, aot, string
690
+
691
+ doc = document()
692
+ doc.add(comment(f"File generated by Shipit {shipit_version}"))
693
+ package = table()
694
+ doc.add("package", package)
695
+ package.add("entrypoint", "start")
696
+ dependencies = table()
697
+ doc.add("dependencies", dependencies)
698
+
699
+ binaries = {}
700
+
701
+ deps = serve.deps or []
702
+ # We add bash if it's not present, as the prepare command is run in bash
703
+ if serve.prepare:
704
+ if not any(dep.name == "bash" for dep in deps):
705
+ deps.append(Package("bash"))
706
+
707
+ if deps:
708
+ console.print(f"[bold]Mapping dependencies:[/bold]")
709
+ for dep in deps:
710
+ if dep.name in self.mapper:
711
+ version = dep.version or "latest"
712
+ if version in self.mapper[dep.name]["dependencies"]:
713
+ console.print(
714
+ f"* {dep.name}@{version} mapped to {self.mapper[dep.name]['dependencies'][version]}"
715
+ )
716
+ package_name, version = self.mapper[dep.name]["dependencies"][
717
+ version
718
+ ].split("@")
719
+ dependencies.add(package_name, version)
720
+ for script in self.mapper[dep.name]["scripts"]:
721
+ binaries[script] = {
722
+ "script": f"{package_name}:{script}",
723
+ "env": self.mapper[dep.name].get("env"),
724
+ }
725
+ for alias, script in self.mapper[dep.name]["aliases"].items():
726
+ binaries[alias] = {
727
+ "script": f"{package_name}:{script}",
728
+ "env": self.mapper[dep.name].get("env"),
729
+ }
730
+ else:
731
+ raise Exception(
732
+ f"Dependency {dep.name}@{version} not found in Wasmer"
733
+ )
734
+ else:
735
+ raise Exception(f"Dependency {dep.name} not found in Wasmer")
736
+
737
+ fs = table()
738
+ doc.add("fs", fs)
739
+ inner = cast(Any, self.inner_builder)
740
+ if serve.assets:
741
+ fs.add("/assets", str((inner.get_path() / "assets").absolute()))
742
+ fs.add("/app", str(inner.get_build_path().absolute()))
743
+ if serve.mounts:
744
+ for mount in serve.mounts:
745
+ fs.add(mount, serve.mounts[mount])
746
+
747
+ doc.add(nl())
748
+ if serve.commands:
749
+ commands = aot()
750
+ doc.add("command", commands)
751
+ for command_name, command_line in serve.commands.items():
752
+ command = table()
753
+ commands.append(command)
754
+ parts = shlex.split(command_line)
755
+ program = parts[0]
756
+ command.add("name", command_name)
757
+ program_binary = binaries[program]
758
+ command.add("module", program_binary["script"])
759
+ command.add("runner", "wasi")
760
+ wasi_args = table()
761
+ wasi_args.add("cwd", "/app")
762
+ wasi_args.add("main-args", parts[1:])
763
+ env = program_binary.get("env")
764
+ if env is not None:
765
+ wasi_args.add(
766
+ "env",
767
+ [f"{k}={v}" for k, v in env.items()],
768
+ )
769
+ title = string("annotations.wasi", literal=False)
770
+ command.add(title, wasi_args)
771
+
772
+ inner = cast(Any, self.inner_builder)
773
+ wasmer_dir = inner.mkdir(Path("wasmer"))
774
+ # Dump the wasmer_dir path to a file
775
+ self.wasmer_dir_path.write_text(str(wasmer_dir))
776
+
777
+ manifest = doc.as_string().replace(
778
+ '[command."annotations.wasi"]', "[command.annotations.wasi]"
779
+ )
780
+ console.print(f"\n[bold]Created wasmer.toml manifest ✅[/bold]")
781
+ manifest_panel = Panel(
782
+ Syntax(
783
+ manifest.strip(),
784
+ "toml",
785
+ theme="monokai",
786
+ background_color="default",
787
+ line_numbers=True,
788
+ ),
789
+ box=box.SQUARE,
790
+ border_style="bright_black",
791
+ expand=False,
792
+ )
793
+ console.print(manifest_panel, markup=False, highlight=True)
794
+ inner.create_file(Path(wasmer_dir) / "wasmer.toml", manifest)
795
+
796
+ # Crete app.yaml
797
+ yaml_config = {
798
+ "kind": "wasmer.io/App.v0",
799
+ "package": ".",
800
+ # "capabilities": {
801
+ # "database": {
802
+ # "engine": "mysql"
803
+ # }
804
+ # },
805
+ # "volumes": [
806
+ # {
807
+ # "name": "wp-content",
808
+ # "mount": "/app/wp-content"
809
+ # }
810
+ # ]
811
+ }
812
+
813
+ app_yaml = yaml.dump(yaml_config)
814
+ inner.create_file(Path(wasmer_dir) / "app.yaml", app_yaml)
815
+
816
+ # self.inner_builder.build_serve(serve)
817
+
818
+ @property
819
+ def wasmer_path(self) -> Path:
820
+ return self.wasmer_dir_path.read_text()
821
+
822
+ def run_serve_command(
823
+ self, command: str, extra_args: Optional[List[str]] = None
824
+ ) -> None:
825
+ console.print(f"\n[bold]Serving site[/bold]: running {command} command")
826
+ wasmer_path = self.wasmer_dir_path.read_text()
827
+ extra_args = extra_args or []
828
+
829
+ if self.wasmer_registry:
830
+ extra_args = [f"--registry={self.wasmer_registry}"] + extra_args
831
+ self.inner_builder.run_command(
832
+ "wasmer",
833
+ ["run", str(wasmer_path), "--net", f"--command={command}", *extra_args],
834
+ )
835
+
836
+ def serve_mount(self, name: str) -> str:
837
+ return self.inner_builder.serve_mount(name)
838
+
839
+ def get_asset(self, name: str) -> str:
840
+ return self.inner_builder.get_asset(name)
841
+
842
+ def run_command(
843
+ self, command: str, extra_args: Optional[List[str]] | None = None
844
+ ) -> Any:
845
+ return self.inner_builder.run_command(command, extra_args or [])
846
+
847
+ def deploy(
848
+ self, app_owner: Optional[str] = None, app_name: Optional[str] = None
849
+ ) -> str:
850
+ if not app_owner or not app_name:
851
+ raise Exception("app_owner and app_name must be set")
852
+ extra_args = []
853
+ if self.wasmer_registry:
854
+ extra_args += ["--registry", self.wasmer_registry]
855
+ if self.wasmer_token:
856
+ extra_args += ["--token", self.wasmer_token]
857
+ self.inner_builder.run_command(
858
+ "wasmer",
859
+ [
860
+ "package",
861
+ "push",
862
+ self.wasmer_path,
863
+ "--namespace",
864
+ app_owner,
865
+ "--non-interactive",
866
+ *extra_args,
867
+ ],
868
+ )
869
+ return self.inner_builder.run_command(
870
+ "wasmer",
871
+ [
872
+ "deploy",
873
+ "--publish-package",
874
+ "--dir",
875
+ self.wasmer_path,
876
+ "--app-name",
877
+ app_name,
878
+ "--owner",
879
+ app_owner,
880
+ "--non-interactive",
881
+ *extra_args,
882
+ ],
883
+ )
884
+
885
+
886
+ class Ctx:
887
+ def __init__(self, builder: Builder) -> None:
888
+ self.builder = builder
889
+ self.packages: Dict[str, Package] = {}
890
+ self.builds: List[Build] = []
891
+ self.steps: List[Step] = []
892
+ self.serves: Dict[str, Serve] = {}
893
+
894
+ def add_package(self, package: Package) -> str:
895
+ index = f"{package.name}@{package.version}" if package.version else package.name
896
+ self.packages[index] = package
897
+ return f"ref:package:{index}"
898
+
899
+ def get_ref(self, index: str) -> Any:
900
+ if index.startswith("ref:package:"):
901
+ return self.packages[index[len("ref:package:") :]]
902
+ elif index.startswith("ref:build:"):
903
+ return self.builds[int(index[len("ref:build:") :])]
904
+ elif index.startswith("ref:serve:"):
905
+ return self.serves[index[len("ref:serve:") :]]
906
+ elif index.startswith("ref:step:"):
907
+ return self.steps[int(index[len("ref:step:") :])]
908
+ else:
909
+ raise Exception(f"Invalid reference: {index}")
910
+
911
+ def get_refs(self, indices: List[str]) -> List[Any]:
912
+ return [self.get_ref(index) for index in indices if index is not None]
913
+
914
+ def add_build(self, build: Build) -> str:
915
+ self.builds.append(build)
916
+ return f"ref:build:{len(self.builds) - 1}"
917
+
918
+ def add_serve(self, serve: Serve) -> str:
919
+ self.serves[serve.name] = serve
920
+ return f"ref:serve:{serve.name}"
921
+
922
+ def add_step(self, step: Step) -> Optional[str]:
923
+ if step is None:
924
+ return None
925
+ self.steps.append(step)
926
+ return f"ref:step:{len(self.steps) - 1}"
927
+
928
+ def getenv(self, name: str) -> Optional[str]:
929
+ return self.builder.getenv(name)
930
+
931
+ def get_asset(self, name: str) -> Optional[str]:
932
+ return self.builder.get_asset(name)
933
+
934
+ def dep(self, name: str, version: Optional[str] = None) -> str:
935
+ package = Package(name, version)
936
+ return self.add_package(package)
937
+
938
+ def serve(
939
+ self,
940
+ name: str,
941
+ provider: str,
942
+ build: List[str],
943
+ deps: List[str],
944
+ commands: Dict[str, str],
945
+ assets: Optional[Dict[str, str]] = None,
946
+ prepare: Optional[List[str]] = None,
947
+ workers: Optional[List[str]] = None,
948
+ mounts: Optional[Dict[str, str]] = None,
949
+ ) -> str:
950
+ build_refs = [cast(Step, r) for r in self.get_refs(build)]
951
+ prepare_steps: Optional[List[PrepareStep]] = None
952
+ if prepare is not None:
953
+ # Resolve referenced steps and keep only RunStep for prepare
954
+ resolved = [cast(Step, r) for r in self.get_refs(prepare)]
955
+ prepare_steps = [
956
+ cast(RunStep, s) for s in resolved if isinstance(s, RunStep)
957
+ ]
958
+ dep_refs = [cast(Package, r) for r in self.get_refs(deps)]
959
+ serve = Serve(
960
+ name=name,
961
+ provider=provider,
962
+ build=build_refs,
963
+ assets=assets,
964
+ deps=dep_refs,
965
+ commands=commands,
966
+ prepare=prepare_steps,
967
+ workers=workers,
968
+ mounts=mounts,
969
+ )
970
+ return self.add_serve(serve)
971
+
972
+ def path(self, path: str) -> Optional[str]:
973
+ step = PathStep(path)
974
+ return self.add_step(step)
975
+
976
+ def use(self, *dependencies: str) -> Optional[str]:
977
+ deps = [cast(Package, r) for r in self.get_refs(list(dependencies))]
978
+ step = UseStep(deps)
979
+ return self.add_step(step)
980
+
981
+ def run(self, *args: Any, **kwargs: Any) -> Optional[str]:
982
+ step = RunStep(*args, **kwargs)
983
+ return self.add_step(step)
984
+
985
+ def copy(
986
+ self, source: str, target: str, ignore: Optional[List[str]] = None
987
+ ) -> Optional[str]:
988
+ step = CopyStep(source, target, ignore)
989
+ return self.add_step(step)
990
+
991
+ def buildpath(self, name: str) -> str:
992
+ return str((self.builder.get_build_path() / name).absolute())
993
+
994
+ def env(self, **env_vars: str) -> Optional[str]:
995
+ step = EnvStep(env_vars)
996
+ return self.add_step(step)
997
+
998
+ def serve_mount(self, name: str) -> Optional[str]:
999
+ return self.builder.serve_mount(name)
1000
+
1001
+
1002
+ def print_help() -> None:
1003
+ panel = Panel(
1004
+ f"Shipit {shipit_version}",
1005
+ box=box.ROUNDED,
1006
+ border_style="blue",
1007
+ expand=False,
1008
+ )
1009
+ console.print(panel)
1010
+
1011
+
1012
+ @app.command(name="auto")
1013
+ def auto(
1014
+ path: Path = typer.Argument(
1015
+ Path("."),
1016
+ help="Project path (defaults to current directory).",
1017
+ show_default=False,
1018
+ ),
1019
+ wasmer: bool = typer.Option(
1020
+ False,
1021
+ help="Use Wasmer to build and serve the project.",
1022
+ ),
1023
+ docker: bool = typer.Option(
1024
+ False,
1025
+ help="Use Docker to build the project.",
1026
+ ),
1027
+ docker_client: Optional[str] = typer.Option(
1028
+ None,
1029
+ help="Use a specific Docker client (such as depot, podman, etc.)",
1030
+ ),
1031
+ start: bool = typer.Option(
1032
+ False,
1033
+ help="Run the start command after building.",
1034
+ ),
1035
+ regenerate: bool = typer.Option(
1036
+ None,
1037
+ help="Regenerate the Shipit file.",
1038
+ ),
1039
+ regenerate_path: Optional[Path] = typer.Option(
1040
+ None,
1041
+ help="Regenerate the Shipit file onto the provided path.",
1042
+ ),
1043
+ wasmer_deploy: Optional[bool] = typer.Option(
1044
+ False,
1045
+ help="Deploy the project to Wasmer.",
1046
+ ),
1047
+ wasmer_token: Optional[str] = typer.Option(
1048
+ None,
1049
+ help="Wasmer token.",
1050
+ ),
1051
+ wasmer_registry: Optional[str] = typer.Option(
1052
+ None,
1053
+ help="Wasmer registry.",
1054
+ ),
1055
+ wasmer_app_owner: Optional[str] = typer.Option(
1056
+ None,
1057
+ help="Owner of the Wasmer app.",
1058
+ ),
1059
+ wasmer_app_name: Optional[str] = typer.Option(
1060
+ None,
1061
+ help="Name of the Wasmer app.",
1062
+ ),
1063
+ ):
1064
+ if not (path / "Shipit").exists() or regenerate or regenerate_path is not None:
1065
+ generate(path, out=regenerate_path)
1066
+
1067
+ build(path, wasmer=(wasmer or wasmer_deploy), docker=docker, docker_client=docker_client,)
1068
+ if start or wasmer_deploy:
1069
+ serve(
1070
+ path,
1071
+ wasmer=wasmer,
1072
+ docker=docker,
1073
+ docker_client=docker_client,
1074
+ start=start,
1075
+ wasmer_token=wasmer_token,
1076
+ wasmer_registry=wasmer_registry,
1077
+ wasmer_deploy=wasmer_deploy,
1078
+ wasmer_app_owner=wasmer_app_owner,
1079
+ wasmer_app_name=wasmer_app_name,
1080
+ )
1081
+ # deploy(path)
1082
+
1083
+
1084
+ @app.command(name="generate")
1085
+ def generate(
1086
+ path: Path = typer.Argument(
1087
+ Path("."),
1088
+ help="Project path (defaults to current directory).",
1089
+ show_default=False,
1090
+ ),
1091
+ out: Optional[Path] = typer.Option(
1092
+ None,
1093
+ help="Output path (defaults to the Shipit file in the provided path).",
1094
+ ),
1095
+ ):
1096
+ if out is None:
1097
+ out = path / "Shipit"
1098
+ content = generate_shipit(path)
1099
+ out.write_text(content)
1100
+ console.print(f"[bold]Generated Shipit[/bold] at {out.absolute()}")
1101
+
1102
+
1103
+ @app.callback(
1104
+ invoke_without_command=True,
1105
+ context_settings={"allow_extra_args": True, "ignore_unknown_options": True},
1106
+ )
1107
+ def _default(ctx: typer.Context) -> None:
1108
+ print_help()
1109
+
1110
+
1111
+ @app.command(name="deploy")
1112
+ def deploy(
1113
+ path: Path = typer.Argument(
1114
+ Path("."),
1115
+ help="Project path (defaults to current directory).",
1116
+ show_default=False,
1117
+ ),
1118
+ ) -> None:
1119
+ pass
1120
+
1121
+
1122
+ @app.command(name="serve")
1123
+ def serve(
1124
+ path: Path = typer.Argument(
1125
+ Path("."),
1126
+ help="Project path (defaults to current directory).",
1127
+ show_default=False,
1128
+ ),
1129
+ wasmer: bool = typer.Option(
1130
+ False,
1131
+ help="Use Wasmer to build and serve the project.",
1132
+ ),
1133
+ docker: bool = typer.Option(
1134
+ False,
1135
+ help="Use Docker to build the project.",
1136
+ ),
1137
+ docker_client: Optional[str] = typer.Option(
1138
+ None,
1139
+ help="Use a specific Docker client (such as depot, podman, etc.)",
1140
+ ),
1141
+ start: Optional[bool] = typer.Option(
1142
+ True,
1143
+ help="Run the start command after building.",
1144
+ ),
1145
+ wasmer_deploy: Optional[bool] = typer.Option(
1146
+ False,
1147
+ help="Deploy the project to Wasmer.",
1148
+ ),
1149
+ wasmer_token: Optional[str] = typer.Option(
1150
+ None,
1151
+ help="Wasmer token.",
1152
+ ),
1153
+ wasmer_registry: Optional[str] = typer.Option(
1154
+ None,
1155
+ help="Wasmer registry.",
1156
+ ),
1157
+ wasmer_app_owner: Optional[str] = typer.Option(
1158
+ None,
1159
+ help="Owner of the Wasmer app.",
1160
+ ),
1161
+ wasmer_app_name: Optional[str] = typer.Option(
1162
+ None,
1163
+ help="Name of the Wasmer app.",
1164
+ ),
1165
+ ) -> None:
1166
+ builder: Builder
1167
+ if docker or docker_client:
1168
+ builder = DockerBuilder(path, docker_client)
1169
+ else:
1170
+ builder = LocalBuilder(path)
1171
+ if wasmer or wasmer_deploy:
1172
+ builder = WasmerBuilder(
1173
+ builder, path, registry=wasmer_registry, token=wasmer_token
1174
+ )
1175
+ if start:
1176
+ builder.run_serve_command("start")
1177
+
1178
+ if wasmer_deploy:
1179
+ if isinstance(builder, WasmerBuilder):
1180
+ builder.deploy(app_owner=wasmer_app_owner, app_name=wasmer_app_name)
1181
+ else:
1182
+ raise Exception("Wasmer deploy is only supported for Wasmer builders")
1183
+
1184
+
1185
+ @app.command(name="build")
1186
+ def build(
1187
+ path: Path = typer.Argument(
1188
+ Path("."),
1189
+ help="Project path (defaults to current directory).",
1190
+ show_default=False,
1191
+ ),
1192
+ wasmer: bool = typer.Option(
1193
+ False,
1194
+ help="Use Wasmer to build and serve the project.",
1195
+ ),
1196
+ docker: bool = typer.Option(
1197
+ False,
1198
+ help="Use Docker to build the project.",
1199
+ ),
1200
+ docker_client: Optional[str] = typer.Option(
1201
+ None,
1202
+ help="Use a specific Docker client (such as depot, podman, etc.)",
1203
+ ),
1204
+ ) -> None:
1205
+ ab_file = path / "Shipit"
1206
+ if not ab_file.exists():
1207
+ raise FileNotFoundError(
1208
+ f"Shipit file not found at {ab_file}. Please run `shipit generate {path}` to create it."
1209
+ )
1210
+ source = open(ab_file).read()
1211
+ builder: Builder
1212
+ if docker or docker_client:
1213
+ builder = DockerBuilder(path, docker_client)
1214
+ else:
1215
+ builder = LocalBuilder(path)
1216
+ if wasmer:
1217
+ builder = WasmerBuilder(builder, path)
1218
+
1219
+ ctx = Ctx(builder)
1220
+ glb = sl.Globals.standard()
1221
+ mod = sl.Module()
1222
+
1223
+ mod.add_callable("getenv", ctx.getenv)
1224
+ mod.add_callable("dep", ctx.dep)
1225
+ mod.add_callable("serve", ctx.serve)
1226
+ mod.add_callable("run", ctx.run)
1227
+ mod.add_callable("use", ctx.run)
1228
+ mod.add_callable("copy", ctx.copy)
1229
+ mod.add_callable("path", ctx.path)
1230
+ mod.add_callable("buildpath", ctx.buildpath)
1231
+ mod.add_callable("get_asset", ctx.get_asset)
1232
+ mod.add_callable("env", ctx.env)
1233
+ mod.add_callable("use", ctx.use)
1234
+ mod.add_callable("serve_mount", ctx.serve_mount)
1235
+
1236
+ dialect = sl.Dialect.extended()
1237
+ dialect.enable_f_strings = True
1238
+
1239
+ ast = sl.parse("shipit", source, dialect=dialect)
1240
+
1241
+ sl.eval(mod, ast, glb)
1242
+ # assert len(ctx.builds) == 1, "Only one build is allowed for now"
1243
+ assert len(ctx.serves) <= 1, "Only one serve is allowed for now"
1244
+ # build = ctx.builds[0]
1245
+ env = {
1246
+ "PATH": "",
1247
+ "COLORTERM": os.environ.get("COLORTERM", ""),
1248
+ "LSCOLORS": os.environ.get("LSCOLORS", "0"),
1249
+ "LS_COLORS": os.environ.get("LS_COLORS", "0"),
1250
+ "CLICOLOR": os.environ.get("CLICOLOR", "0"),
1251
+ }
1252
+ serve = next(iter(ctx.serves.values()))
1253
+
1254
+ # Build and serve
1255
+ builder.build(env, serve.build)
1256
+ if serve.prepare:
1257
+ builder.build_prepare(serve)
1258
+ if serve.assets:
1259
+ builder.build_assets(serve.assets)
1260
+ builder.build_serve(serve)
1261
+ builder.finalize_build(serve)
1262
+ if serve.prepare:
1263
+ builder.prepare(env, serve.prepare)
1264
+
1265
+
1266
+ def main() -> None:
1267
+ args = sys.argv[1:]
1268
+ # If no subcommand or first token looks like option/path → default to "build"
1269
+ available_commands = [cmd.name for cmd in app.registered_commands]
1270
+ if not args or args[0].startswith("-") or args[0] not in available_commands:
1271
+ sys.argv = [sys.argv[0], "auto", *args]
1272
+
1273
+ try:
1274
+ app()
1275
+ except Exception as e:
1276
+ console.print(f"[bold red]{type(e).__name__}[/bold red]: {e}")
1277
+ # raise e
1278
+
1279
+
1280
+ if __name__ == "__main__":
1281
+ main()