Coverage for /home/runner/work/bijux-cli/bijux-cli/src/bijux_cli/commands/plugins/scaffold.py: 100%
65 statements
« prev ^ index » next coverage.py v7.10.4, created at 2025-08-19 23:36 +0000
« prev ^ index » next coverage.py v7.10.4, created at 2025-08-19 23:36 +0000
1# SPDX-License-Identifier: MIT
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
25import json
26import keyword
27from pathlib import Path
28import shutil
29import unicodedata
31import typer
33from bijux_cli.commands.plugins.utils import PLUGIN_NAME_RE
34from bijux_cli.commands.utilities import (
35 emit_error_and_exit,
36 new_run_command,
37 validate_common_flags,
38)
39from bijux_cli.core.constants import (
40 HELP_DEBUG,
41 HELP_FORMAT,
42 HELP_NO_PRETTY,
43 HELP_QUIET,
44 HELP_VERBOSE,
45)
48def scaffold_plugin(
49 name: str = typer.Argument(..., help="Plugin name"),
50 output_dir: str = typer.Option(".", "--output-dir", "-o"),
51 template: str | None = typer.Option(
52 None,
53 "--template",
54 "-t",
55 help="Path or URL to a cookiecutter template (required)",
56 ),
57 force: bool = typer.Option(False, "--force", "-F"),
58 quiet: bool = typer.Option(False, "-q", "--quiet", help=HELP_QUIET),
59 verbose: bool = typer.Option(False, "-v", "--verbose", help=HELP_VERBOSE),
60 fmt: str = typer.Option("json", "-f", "--format", help=HELP_FORMAT),
61 pretty: bool = typer.Option(True, "--pretty/--no-pretty", help=HELP_NO_PRETTY),
62 debug: bool = typer.Option(False, "-d", "--debug", help=HELP_DEBUG),
63) -> None:
64 """Creates a new plugin project from a cookiecutter template.
66 This function orchestrates the scaffolding process. It performs numerous
67 validations on the plugin name and output directory, handles existing
68 directories with the `--force` flag, invokes the `cookiecutter` library
69 to generate the project, and validates the resulting plugin metadata.
71 Args:
72 name (str): The name for the new plugin (e.g., 'my-plugin').
73 output_dir (str): The directory where the new plugin project will be
74 created.
75 template (str | None): The path or URL to the `cookiecutter` template.
76 force (bool): If True, overwrites the output directory if it exists.
77 quiet (bool): If True, suppresses all output except for errors.
78 verbose (bool): If True, includes runtime metadata in error payloads.
79 fmt (str): The output format for confirmation or error messages.
80 pretty (bool): If True, pretty-prints the output.
81 debug (bool): If True, enables debug diagnostics.
83 Returns:
84 None:
86 Raises:
87 SystemExit: Always exits with a contract-compliant status code and
88 payload, indicating success or detailing an error.
89 """
90 command = "plugins scaffold"
92 fmt_lower = validate_common_flags(fmt, command, quiet)
94 if name in keyword.kwlist:
95 emit_error_and_exit(
96 f"Invalid plugin name: '{name}' is a reserved Python keyword.",
97 code=1,
98 failure="reserved_keyword",
99 command=command,
100 fmt=fmt_lower,
101 quiet=quiet,
102 include_runtime=verbose,
103 debug=debug,
104 )
106 if not PLUGIN_NAME_RE.fullmatch(name) or not name.isascii():
107 emit_error_and_exit(
108 "Invalid plugin name: only ASCII letters, digits, dash and underscore are allowed.",
109 code=1,
110 failure="invalid_name",
111 command=command,
112 fmt=fmt_lower,
113 quiet=quiet,
114 include_runtime=verbose,
115 debug=debug,
116 )
118 if not template:
119 emit_error_and_exit(
120 "No plugin template found. Please specify --template (path or URL).",
121 code=1,
122 failure="no_template",
123 command=command,
124 fmt=fmt_lower,
125 quiet=quiet,
126 include_runtime=verbose,
127 debug=debug,
128 )
130 slug = unicodedata.normalize("NFC", name)
131 parent = Path(output_dir).expanduser().resolve()
132 target = parent / slug
134 if not parent.exists():
135 try:
136 parent.mkdir(parents=True, exist_ok=True)
137 except Exception as exc:
138 emit_error_and_exit(
139 f"Failed to create output directory '{parent}': {exc}",
140 code=1,
141 failure="create_dir_failed",
142 command=command,
143 fmt=fmt_lower,
144 quiet=quiet,
145 include_runtime=verbose,
146 debug=debug,
147 )
148 elif not parent.is_dir():
149 emit_error_and_exit(
150 f"Output directory '{parent}' is not a directory.",
151 code=1,
152 failure="not_dir",
153 command=command,
154 fmt=fmt_lower,
155 quiet=quiet,
156 include_runtime=verbose,
157 debug=debug,
158 )
160 normalized = name.lower()
161 for existing in parent.iterdir():
162 if (
163 (existing.is_dir() or existing.is_symlink())
164 and existing.name.lower() == normalized
165 and existing.resolve() != target.resolve()
166 ):
167 emit_error_and_exit(
168 f"Plugin name '{name}' conflicts with existing directory '{existing.name}'. "
169 "Plugin names must be unique (case-insensitive).",
170 code=1,
171 failure="name_conflict",
172 command=command,
173 fmt=fmt_lower,
174 quiet=quiet,
175 include_runtime=verbose,
176 debug=debug,
177 )
179 if target.exists() or target.is_symlink():
180 if not force:
181 emit_error_and_exit(
182 f"Directory '{target}' is not empty – use --force to overwrite.",
183 code=1,
184 failure="dir_not_empty",
185 command=command,
186 fmt=fmt_lower,
187 quiet=quiet,
188 include_runtime=verbose,
189 debug=debug,
190 )
191 try:
192 if target.is_symlink():
193 target.unlink()
194 elif target.is_dir():
195 shutil.rmtree(target)
196 else:
197 target.unlink()
198 except Exception as exc:
199 emit_error_and_exit(
200 f"Failed to remove existing '{target}': {exc}",
201 code=1,
202 failure="remove_failed",
203 command=command,
204 fmt=fmt_lower,
205 quiet=quiet,
206 include_runtime=verbose,
207 debug=debug,
208 )
210 try:
211 from cookiecutter.main import cookiecutter
213 cookiecutter(
214 template,
215 no_input=True,
216 output_dir=str(parent),
217 extra_context={"project_name": name, "project_slug": slug},
218 )
219 if not target.is_dir():
220 raise RuntimeError("Template copy failed")
221 except ModuleNotFoundError:
222 emit_error_and_exit(
223 "cookiecutter is required but not installed.",
224 code=1,
225 failure="cookiecutter_missing",
226 command=command,
227 fmt=fmt_lower,
228 quiet=quiet,
229 include_runtime=verbose,
230 debug=debug,
231 )
232 except Exception as exc:
233 msg = f"Scaffold failed: {exc} (template not found or invalid)"
234 emit_error_and_exit(
235 msg,
236 code=1,
237 failure="scaffold_failed",
238 command=command,
239 fmt=fmt_lower,
240 quiet=quiet,
241 include_runtime=verbose,
242 debug=debug,
243 )
245 plugin_json = target / "plugin.json"
246 if not plugin_json.is_file():
247 emit_error_and_exit(
248 f"Scaffold failed: plugin.json not found in '{target}'.",
249 code=1,
250 failure="plugin_json_missing",
251 command=command,
252 fmt=fmt_lower,
253 quiet=quiet,
254 include_runtime=verbose,
255 debug=debug,
256 )
257 try:
258 meta = json.loads(plugin_json.read_text("utf-8"))
259 if not (
260 isinstance(meta, dict)
261 and meta.get("name")
262 and (meta.get("desc") or meta.get("description"))
263 ):
264 raise ValueError("Missing required fields")
265 except Exception as exc:
266 emit_error_and_exit(
267 f"Scaffold failed: plugin.json invalid: {exc}",
268 code=1,
269 failure="plugin_json_invalid",
270 command=command,
271 fmt=fmt_lower,
272 quiet=quiet,
273 include_runtime=verbose,
274 debug=debug,
275 )
277 payload = {"status": "created", "plugin": name, "dir": str(target)}
279 new_run_command(
280 command_name=command,
281 payload_builder=lambda include: payload,
282 quiet=quiet,
283 verbose=verbose,
284 fmt=fmt_lower,
285 pretty=pretty,
286 debug=debug,
287 )