Coverage for / home / runner / work / bijux-cli / bijux-cli / src / bijux_cli / cli / plugins / commands / scaffold.py: 98%
91 statements
« prev ^ index » next coverage.py v7.13.2, created at 2026-01-26 17:59 +0000
« prev ^ index » next coverage.py v7.13.2, created at 2026-01-26 17:59 +0000
1# SPDX-License-Identifier: Apache-2.0
2# Copyright © 2025 Bijan Mousavi
4"""Implements the `plugins scaffold` subcommand for the Bijux CLI.
6This module contains the logic for creating a new plugin project from a
7`cookiecutter` template. It validates the proposed plugin name, handles the
8destination directory setup (including forcing overwrites), and invokes
9`cookiecutter` to generate the project structure.
11Output Contract:
12 * Success: `{"status": "created", "plugin": str, "dir": str}`
13 * Error: `{"error": "...", "code": int}`
15Exit Codes:
16 * `0`: Success.
17 * `1`: A fatal error occurred (e.g., cookiecutter not installed, invalid
18 template, name conflict, filesystem error).
19 * `2`: An invalid flag was provided (e.g., bad format).
20 * `3`: An ASCII or encoding error was detected in the environment.
21"""
23from __future__ import annotations
25from dataclasses import dataclass
26import json
27import keyword
28from pathlib import Path
29import shutil
30import unicodedata
32import typer
34from bijux_cli.cli.core.command import (
35 new_run_command,
36 raise_exit_intent,
37 validate_common_flags,
38)
39from bijux_cli.cli.core.constants import (
40 OPT_FORMAT,
41 OPT_LOG_LEVEL,
42 OPT_PRETTY,
43 OPT_QUIET,
44)
45from bijux_cli.cli.core.help_text import (
46 HELP_FORMAT,
47 HELP_LOG_LEVEL,
48 HELP_NO_PRETTY,
49 HELP_QUIET,
50)
51from bijux_cli.core.enums import ErrorType, LogLevel, OutputFormat
52from bijux_cli.core.precedence import current_execution_policy
53from bijux_cli.plugins.catalog import PLUGIN_NAME_RE
56@dataclass(frozen=True)
57class ScaffoldIntent:
58 """Resolved intent for plugin scaffolding."""
60 name: str
61 template: str
62 target: Path
63 force: bool
64 quiet: bool
65 include_runtime: bool
66 log_level: LogLevel
67 fmt: OutputFormat
70def _build_scaffold_intent(
71 *,
72 name: str,
73 output_dir: str,
74 template: str | None,
75 force: bool,
76 command: str,
77 fmt: OutputFormat,
78 quiet: bool,
79 include_runtime: bool,
80 log_level: LogLevel,
81) -> ScaffoldIntent:
82 """Validate inputs and build a scaffold intent."""
83 if name in keyword.kwlist:
84 raise_exit_intent(
85 f"Invalid plugin name: '{name}' is a reserved Python keyword.",
86 code=1,
87 failure="reserved_keyword",
88 command=command,
89 fmt=fmt,
90 quiet=quiet,
91 include_runtime=include_runtime,
92 log_level=log_level,
93 error_type=ErrorType.USER_INPUT,
94 )
96 if not PLUGIN_NAME_RE.fullmatch(name) or not name.isascii():
97 raise_exit_intent(
98 "Invalid plugin name: only ASCII letters, digits, dash and underscore are allowed.",
99 code=1,
100 failure="invalid_name",
101 command=command,
102 fmt=fmt,
103 quiet=quiet,
104 include_runtime=include_runtime,
105 log_level=log_level,
106 error_type=ErrorType.USER_INPUT,
107 )
109 if not template:
110 raise_exit_intent(
111 "No plugin template found. Please specify --template (path or URL).",
112 code=1,
113 failure="no_template",
114 command=command,
115 fmt=fmt,
116 quiet=quiet,
117 include_runtime=include_runtime,
118 log_level=log_level,
119 error_type=ErrorType.USER_INPUT,
120 )
121 if template is None: 121 ↛ 122line 121 didn't jump to line 122 because the condition on line 121 was never true
122 raise RuntimeError("Template must be provided")
124 slug = unicodedata.normalize("NFC", name)
125 parent = Path(output_dir).expanduser().resolve()
126 target = parent / slug
128 if not parent.exists():
129 try:
130 parent.mkdir(parents=True, exist_ok=True)
131 except Exception as exc:
132 raise_exit_intent(
133 f"Failed to create output directory '{parent}': {exc}",
134 code=1,
135 failure="create_dir_failed",
136 command=command,
137 fmt=fmt,
138 quiet=quiet,
139 include_runtime=include_runtime,
140 log_level=log_level,
141 )
142 elif not parent.is_dir():
143 raise_exit_intent(
144 f"Output directory '{parent}' is not a directory.",
145 code=1,
146 failure="not_dir",
147 command=command,
148 fmt=fmt,
149 quiet=quiet,
150 include_runtime=include_runtime,
151 log_level=log_level,
152 )
154 normalized = name.lower()
155 for existing in parent.iterdir():
156 if (
157 (existing.is_dir() or existing.is_symlink())
158 and existing.name.lower() == normalized
159 and existing.resolve() != target.resolve()
160 ):
161 raise_exit_intent(
162 f"Plugin name '{name}' conflicts with existing directory '{existing.name}'. "
163 "Plugin names must be unique (case-insensitive).",
164 code=1,
165 failure="name_conflict",
166 command=command,
167 fmt=fmt,
168 quiet=quiet,
169 include_runtime=include_runtime,
170 log_level=log_level,
171 )
173 if target.exists() or target.is_symlink():
174 if not force:
175 raise_exit_intent(
176 f"Directory '{target}' is not empty – use --force to overwrite.",
177 code=1,
178 failure="dir_not_empty",
179 command=command,
180 fmt=fmt,
181 quiet=quiet,
182 include_runtime=include_runtime,
183 log_level=log_level,
184 )
185 try:
186 if target.is_symlink():
187 target.unlink()
188 elif target.is_dir():
189 shutil.rmtree(target)
190 else:
191 target.unlink()
192 except Exception as exc:
193 raise_exit_intent(
194 f"Failed to remove existing '{target}': {exc}",
195 code=1,
196 failure="remove_failed",
197 command=command,
198 fmt=fmt,
199 quiet=quiet,
200 include_runtime=include_runtime,
201 log_level=log_level,
202 )
204 return ScaffoldIntent(
205 name=name,
206 template=template,
207 target=target,
208 force=force,
209 quiet=quiet,
210 include_runtime=include_runtime,
211 log_level=log_level,
212 fmt=fmt,
213 )
216def _scaffold_project(intent: ScaffoldIntent) -> dict[str, str]:
217 """Run cookiecutter and validate the output."""
218 try:
219 from cookiecutter.main import cookiecutter
221 cookiecutter(
222 intent.template,
223 no_input=True,
224 output_dir=str(intent.target.parent),
225 extra_context={
226 "project_name": intent.name,
227 "project_slug": intent.target.name,
228 },
229 )
230 if not intent.target.is_dir():
231 raise RuntimeError("Template copy failed")
232 except ModuleNotFoundError:
233 raise_exit_intent(
234 "cookiecutter is required but not installed.",
235 code=1,
236 failure="cookiecutter_missing",
237 command="plugins scaffold",
238 fmt=intent.fmt,
239 quiet=intent.quiet,
240 include_runtime=intent.include_runtime,
241 log_level=intent.log_level,
242 )
243 except Exception as exc:
244 msg = f"Scaffold failed: {exc} (template not found or invalid)"
245 raise_exit_intent(
246 msg,
247 code=1,
248 failure="scaffold_failed",
249 command="plugins scaffold",
250 fmt=intent.fmt,
251 quiet=intent.quiet,
252 include_runtime=intent.include_runtime,
253 log_level=intent.log_level,
254 )
256 plugin_json = intent.target / "plugin.json"
257 if not plugin_json.is_file():
258 raise_exit_intent(
259 f"Scaffold failed: plugin.json not found in '{intent.target}'.",
260 code=1,
261 failure="plugin_json_missing",
262 command="plugins scaffold",
263 fmt=intent.fmt,
264 quiet=intent.quiet,
265 include_runtime=intent.include_runtime,
266 log_level=intent.log_level,
267 )
268 try:
269 meta = json.loads(plugin_json.read_text("utf-8"))
270 if not (
271 isinstance(meta, dict)
272 and meta.get("name")
273 and (meta.get("desc") or meta.get("description"))
274 and meta.get("schema_version")
275 and meta.get("bijux_cli_version")
276 ):
277 raise ValueError("Missing required fields")
278 except Exception as exc:
279 raise_exit_intent(
280 f"Scaffold failed: plugin.json invalid: {exc}",
281 code=1,
282 failure="plugin_json_invalid",
283 command="plugins scaffold",
284 fmt=intent.fmt,
285 quiet=intent.quiet,
286 include_runtime=intent.include_runtime,
287 log_level=intent.log_level,
288 )
290 return {"status": "created", "plugin": intent.name, "dir": str(intent.target)}
293def scaffold_plugin(
294 name: str = typer.Argument(..., help="Plugin name"),
295 output_dir: str = typer.Option(".", "--output-dir", "-o"),
296 template: str | None = typer.Option(
297 None,
298 "--template",
299 "-t",
300 help="Path or URL to a cookiecutter template (required)",
301 ),
302 force: bool = typer.Option(False, "--force", "-F"),
303 quiet: bool = typer.Option(False, *OPT_QUIET, help=HELP_QUIET),
304 fmt: str = typer.Option("json", *OPT_FORMAT, help=HELP_FORMAT),
305 pretty: bool = typer.Option(True, OPT_PRETTY, help=HELP_NO_PRETTY),
306 log_level: str = typer.Option("info", *OPT_LOG_LEVEL, help=HELP_LOG_LEVEL),
307) -> None:
308 """Creates a new plugin project from a cookiecutter template.
310 This function orchestrates the scaffolding process. It performs numerous
311 validations on the plugin name and output directory, handles existing
312 directories with the `--force` flag, invokes the `cookiecutter` library
313 to generate the project, and validates the resulting plugin metadata.
315 Args:
316 name (str): The name for the new plugin (e.g., 'my-plugin').
317 output_dir (str): The directory where the new plugin project will be
318 created.
319 template (str | None): The path or URL to the `cookiecutter` template.
320 force (bool): If True, overwrites the output directory if it exists.
321 quiet (bool): If True, suppresses all output except for errors.
322 fmt (str): The output format for confirmation or error messages.
323 pretty (bool): If True, pretty-prints the output.
324 log_level (str): Logging level for diagnostics.
326 Returns:
327 None:
329 Raises:
330 SystemExit: Always exits with a contract-compliant status code and
331 payload, indicating success or detailing an error.
332 """
333 command = "plugins scaffold"
335 policy = current_execution_policy()
336 quiet = policy.quiet
337 include_runtime = policy.include_runtime
338 log_level_value = policy.log_level
339 pretty = policy.pretty
340 fmt_lower = validate_common_flags(
341 fmt,
342 command,
343 quiet,
344 include_runtime=include_runtime,
345 log_level=log_level_value,
346 )
348 intent = _build_scaffold_intent(
349 name=name,
350 output_dir=output_dir,
351 template=template,
352 force=force,
353 command=command,
354 fmt=fmt_lower,
355 quiet=quiet,
356 include_runtime=include_runtime,
357 log_level=log_level_value,
358 )
359 payload = _scaffold_project(intent)
361 new_run_command(
362 command_name=command,
363 payload_builder=lambda include: payload,
364 quiet=quiet,
365 fmt=fmt_lower,
366 pretty=pretty,
367 log_level=log_level_value,
368 )