feat(extensions): add update command to extensions cli (#38651)

This commit is contained in:
Ville Brofeldt
2026-03-16 07:02:42 -07:00
committed by GitHub
parent afe093f1ca
commit dcb414aa06
10 changed files with 514 additions and 4 deletions

View File

@@ -50,6 +50,8 @@ from superset_extensions_cli.utils import (
validate_display_name,
validate_publisher,
validate_technical_name,
write_json,
write_toml,
)
REMOTE_ENTRY_REGEX = re.compile(r"^remoteEntry\..+\.js$")
@@ -292,6 +294,7 @@ def app() -> None:
@app.command()
def validate() -> None:
"""Validate the extension structure and metadata consistency."""
validate_npm()
cwd = Path.cwd()
@@ -372,12 +375,167 @@ def validate() -> None:
click.secho(" Convention requires: frontend/src/index.tsx", fg="yellow")
sys.exit(1)
# Validate version and license consistency across extension.json, frontend, and backend
mismatches: list[str] = []
frontend_pkg_path = cwd / "frontend" / "package.json"
frontend_pkg = None
if frontend_pkg_path.is_file():
frontend_pkg = read_json(frontend_pkg_path)
if frontend_pkg:
if frontend_pkg.get("version") != extension.version:
mismatches.append(
f" frontend/package.json version: {frontend_pkg.get('version')} "
f"(expected {extension.version})"
)
if extension.license and frontend_pkg.get("license") != extension.license:
mismatches.append(
f" frontend/package.json license: {frontend_pkg.get('license')} "
f"(expected {extension.license})"
)
backend_pyproject_path = cwd / "backend" / "pyproject.toml"
if backend_pyproject_path.is_file():
backend_pyproject = read_toml(backend_pyproject_path)
if backend_pyproject:
project = backend_pyproject.get("project", {})
if project.get("version") != extension.version:
mismatches.append(
f" backend/pyproject.toml version: {project.get('version')} "
f"(expected {extension.version})"
)
if extension.license and project.get("license") != extension.license:
mismatches.append(
f" backend/pyproject.toml license: {project.get('license')} "
f"(expected {extension.license})"
)
if mismatches:
click.secho("❌ Metadata mismatch detected:", err=True, fg="red")
for mismatch in mismatches:
click.secho(mismatch, err=True, fg="red")
click.secho(
"Run `superset-extensions update` to sync from extension.json.",
fg="yellow",
)
sys.exit(1)
click.secho("✅ Validation successful", fg="green")
@app.command()
@click.option(
"--version",
"version_opt",
is_flag=False,
flag_value="__prompt__",
default=None,
help="Set a new version. Prompts for value if none given.",
)
@click.option(
"--license",
"license_opt",
is_flag=False,
flag_value="__prompt__",
default=None,
help="Set a new license. Prompts for value if none given.",
)
def update(version_opt: str | None, license_opt: str | None) -> None:
"""Update derived and generated files in the extension project."""
cwd = Path.cwd()
extension_json_path = cwd / "extension.json"
extension_data = read_json(extension_json_path)
if not extension_data:
click.secho("❌ extension.json not found.", err=True, fg="red")
sys.exit(1)
try:
extension = ExtensionConfig.model_validate(extension_data)
except Exception as e:
click.secho(f"❌ Invalid extension.json: {e}", err=True, fg="red")
sys.exit(1)
# Resolve version: prompt if flag used without value
if version_opt == "__prompt__":
version_opt = click.prompt("Version", default=extension.version)
target_version = (
version_opt
if version_opt and version_opt != extension.version
else extension.version
)
# Resolve license: prompt if flag used without value
if license_opt == "__prompt__":
license_opt = click.prompt("License", default=extension.license or "")
target_license = (
license_opt
if license_opt and license_opt != extension.license
else extension.license
)
updated: list[str] = []
# Update extension.json if version or license changed
ext_changed = False
if version_opt and version_opt != extension.version:
extension_data["version"] = target_version
ext_changed = True
if license_opt and license_opt != extension.license:
extension_data["license"] = target_license
ext_changed = True
if ext_changed:
try:
ExtensionConfig.model_validate(extension_data)
except Exception as e:
click.secho(f"❌ Invalid value: {e}", err=True, fg="red")
sys.exit(1)
write_json(extension_json_path, extension_data)
updated.append("extension.json")
# Update frontend/package.json
frontend_pkg_path = cwd / "frontend" / "package.json"
if frontend_pkg_path.is_file():
frontend_pkg = read_json(frontend_pkg_path)
if frontend_pkg:
pkg_changed = False
if frontend_pkg.get("version") != target_version:
frontend_pkg["version"] = target_version
pkg_changed = True
if target_license and frontend_pkg.get("license") != target_license:
frontend_pkg["license"] = target_license
pkg_changed = True
if pkg_changed:
write_json(frontend_pkg_path, frontend_pkg)
updated.append("frontend/package.json")
# Update backend/pyproject.toml
backend_pyproject_path = cwd / "backend" / "pyproject.toml"
if backend_pyproject_path.is_file():
backend_pyproject = read_toml(backend_pyproject_path)
if backend_pyproject:
project = backend_pyproject.setdefault("project", {})
toml_changed = False
if project.get("version") != target_version:
project["version"] = target_version
toml_changed = True
if target_license and project.get("license") != target_license:
project["license"] = target_license
toml_changed = True
if toml_changed:
write_toml(backend_pyproject_path, backend_pyproject)
updated.append("backend/pyproject.toml")
if updated:
for path in updated:
click.secho(f"✅ Updated {path}", fg="green")
else:
click.secho("✅ All files already up to date.", fg="green")
@app.command()
@click.pass_context
def build(ctx: click.Context) -> None:
"""Build extension assets."""
ctx.invoke(validate)
cwd = Path.cwd()
frontend_dir = cwd / "frontend"
@@ -413,6 +571,7 @@ def build(ctx: click.Context) -> None:
)
@click.pass_context
def bundle(ctx: click.Context, output: Path | None) -> None:
"""Package the extension into a .supx file."""
ctx.invoke(build)
cwd = Path.cwd()
@@ -453,6 +612,7 @@ def bundle(ctx: click.Context, output: Path | None) -> None:
@app.command()
@click.pass_context
def dev(ctx: click.Context) -> None:
"""Automatically rebuild the extension as files change."""
cwd = Path.cwd()
frontend_dir = cwd / "frontend"
backend_dir = cwd / "backend"
@@ -647,6 +807,7 @@ def init(
frontend_opt: bool | None,
backend_opt: bool | None,
) -> None:
"""Scaffold a new extension project."""
# Get extension names with graceful validation
names = prompt_for_extension_info(display_name_opt, publisher_opt, name_opt)