"""Flask CLI commands for plugin management.""" import json from pathlib import Path import click from flask import current_app from flask.cli import with_appcontext # JSON-Schema primitive name -> Python type(s) for the no-dependency validator. _SCHEMA_TYPES = { 'string': str, 'boolean': bool, 'array': list, 'object': dict, 'integer': int, 'number': (int, float), } def _load_manifest_schema() -> dict: """Load the packaged manifest schema (ships with the app, unlike docs/).""" schema_path = Path(__file__).with_name('manifest_schema.json') with open(schema_path) as f: return json.load(f) def _check_against_schema(manifest: dict, schema: dict) -> list: """Lightweight schema check without a jsonschema dependency. Verifies required fields are present and that known typed fields hold the right JSON type; unknown fields are allowed (additionalProperties). Returns a list of human-readable error strings (empty = valid). """ errors = [] for field in schema.get('required', []): if field not in manifest: errors.append(f"missing required field '{field}'") props = schema.get('properties', {}) for key, value in manifest.items(): spec = props.get(key) if not spec: continue # additionalProperties permitted expected = spec.get('type') pytype = _SCHEMA_TYPES.get(expected) # bool is a subclass of int; guard so a boolean does not pass 'integer' if pytype and (not isinstance(value, pytype) or (expected in ('integer', 'number') and isinstance(value, bool))): errors.append(f"field '{key}' should be {expected}") if spec.get('enum') and value not in spec['enum']: errors.append( f"field '{key}' must be one of {spec['enum']}, got '{value}'") return errors @click.group('plugin') def plugin_cli(): """Plugin management commands.""" pass @plugin_cli.command('list') @with_appcontext def list_plugins(): """List all available plugins.""" pm = current_app.extensions.get('plugin_manager') if not pm: click.echo(click.style("Plugin manager not initialized", fg='red')) return plugins = pm.discover_available() if not plugins: click.echo("No plugins found in plugins directory.") return # Format output click.echo("") click.echo(click.style("Available Plugins:", fg='cyan', bold=True)) click.echo("-" * 60) for p in plugins: if p['enabled']: status = click.style("[Enabled]", fg='green') elif p['installed']: status = click.style("[Disabled]", fg='yellow') else: status = click.style("[Available]", fg='white') click.echo(f" {p['name']:20} v{p['version']:10} {status}") if p['description']: click.echo(f" {p['description'][:55]}...") if p['dependencies']: deps = ', '.join(p['dependencies']) click.echo(f" Dependencies: {deps}") click.echo("") @plugin_cli.command('install') @click.argument('name') @click.option('--skip-migrations', is_flag=True, help='Skip database migrations') @with_appcontext def install_plugin(name: str, skip_migrations: bool): """ Install a plugin. Usage: flask plugin install printers """ pm = current_app.extensions.get('plugin_manager') if not pm: click.echo(click.style("Plugin manager not initialized", fg='red')) raise SystemExit(1) click.echo(f"Installing plugin: {name}") if pm.install_plugin(name, run_migrations=not skip_migrations): click.echo(click.style(f"Successfully installed {name}", fg='green')) else: click.echo(click.style(f"Failed to install {name}", fg='red')) raise SystemExit(1) @plugin_cli.command('uninstall') @click.argument('name') @click.option('--remove-data', is_flag=True, help='Remove plugin database tables') @click.confirmation_option(prompt='Are you sure you want to uninstall this plugin?') @with_appcontext def uninstall_plugin(name: str, remove_data: bool): """ Uninstall a plugin. Usage: flask plugin uninstall printers """ pm = current_app.extensions.get('plugin_manager') if not pm: click.echo(click.style("Plugin manager not initialized", fg='red')) raise SystemExit(1) click.echo(f"Uninstalling plugin: {name}") if pm.uninstall_plugin(name, remove_data=remove_data): click.echo(click.style(f"Successfully uninstalled {name}", fg='green')) else: click.echo(click.style(f"Failed to uninstall {name}", fg='red')) raise SystemExit(1) @plugin_cli.command('enable') @click.argument('name') @with_appcontext def enable_plugin(name: str): """Enable a disabled plugin.""" pm = current_app.extensions.get('plugin_manager') if not pm: click.echo(click.style("Plugin manager not initialized", fg='red')) raise SystemExit(1) if pm.enable_plugin(name): click.echo(click.style(f"Enabled {name}", fg='green')) # Nudge the operator if the plugin's chain is ahead of the DB. try: if pm.migration_manager and \ pm.migration_manager.has_unapplied_migrations(name): click.echo(click.style( f" {name} has unapplied migrations - " f"run 'flask plugin upgrade-all'", fg='yellow')) except Exception: pass else: click.echo(click.style(f"Failed to enable {name}", fg='red')) raise SystemExit(1) @plugin_cli.command('disable') @click.argument('name') @with_appcontext def disable_plugin(name: str): """Disable an enabled plugin.""" pm = current_app.extensions.get('plugin_manager') if not pm: click.echo(click.style("Plugin manager not initialized", fg='red')) raise SystemExit(1) if pm.disable_plugin(name): click.echo(click.style(f"Disabled {name}", fg='green')) else: click.echo(click.style(f"Failed to disable {name}", fg='red')) raise SystemExit(1) @plugin_cli.command('info') @click.argument('name') @with_appcontext def plugin_info(name: str): """Show detailed information about a plugin.""" pm = current_app.extensions.get('plugin_manager') if not pm: click.echo(click.style("Plugin manager not initialized", fg='red')) raise SystemExit(1) plugin_class = pm.loader.load_plugin_class(name) if not plugin_class: click.echo(click.style(f"Plugin {name} not found", fg='red')) raise SystemExit(1) try: temp = plugin_class() meta = temp.meta except Exception as e: click.echo(click.style(f"Error loading plugin: {e}", fg='red')) raise SystemExit(1) state = pm.registry.get(name) click.echo("") click.echo("=" * 50) click.echo(click.style(f"Plugin: {meta.name}", fg='cyan', bold=True)) click.echo("=" * 50) click.echo(f"Version: {meta.version}") click.echo(f"Description: {meta.description}") click.echo(f"Author: {meta.author or 'Unknown'}") click.echo(f"API Prefix: {meta.api_prefix}") click.echo(f"Dependencies: {', '.join(meta.dependencies) or 'None'}") click.echo(f"Core Version: {meta.core_version}") click.echo("") if state: status = click.style('Enabled', fg='green') if state.enabled else click.style('Disabled', fg='yellow') click.echo(f"Status: {status}") click.echo(f"Installed: {state.installed_at}") click.echo(f"Migrations: {len(state.migrations_applied)} applied") else: click.echo(f"Status: {click.style('Not installed', fg='white')}") click.echo("") @plugin_cli.command('new') @click.argument('name') @click.option('--description', default='', help='One-sentence plugin description') @click.option('--overwrite', is_flag=True, help='Overwrite existing plugin directory') @with_appcontext def new_plugin(name: str, description: str, overwrite: bool): """Scaffold a new plugin from the bundled templates. Usage: flask plugin new cameras --description "Tracks shop-floor cameras" """ from pathlib import Path from .scaffolder import scaffold_plugin, ScaffoldError plugins_dir = Path(current_app.root_path).parent / 'plugins' if not description: description = f'{name.capitalize()} plugin (TODO: replace this description)' try: target = scaffold_plugin( name=name, description=description, plugins_dir=plugins_dir, overwrite=overwrite, ) except ScaffoldError as e: click.echo(click.style(f'Scaffold failed: {e}', fg='red')) raise SystemExit(1) click.echo(click.style(f'Created plugin at {target}', fg='green')) click.echo('') click.echo('Next steps:') click.echo(f' 1. Edit plugins/{name}/models/{name}.py with your domain fields') click.echo(f' 2. Edit plugins/{name}/api/routes.py with your endpoints') click.echo(f' 3. Add plugins/{name}/migrations/ with an Alembic chain that') click.echo(f' creates your tables (per-plugin chain, NOT the core chain;') click.echo(f' see ADR-008). Register the tables in PLUGIN_TABLE_OWNERS.') click.echo(f' 4. Run: flask plugin install {name}') click.echo(f' 5. Run: flask plugin upgrade-all') click.echo(f' 6. Run: pytest plugins/{name}/tests/') @plugin_cli.command('validate') @click.argument('name') @with_appcontext def validate_plugin(name: str): """Validate a plugin directory against the manifest schema + contract. Pre-publish gate (directory mode). Checks: manifest loads and its name matches the directory, required/typed fields per the manifest schema, the core_version range admits this framework's contract version, and every declared hard dependency exists on disk. Exits non-zero on any failure. Usage: flask plugin validate printers """ from shopdb import __contract_version__ from ..exceptions import PluginError pm = current_app.extensions.get('plugin_manager') if not pm: click.echo(click.style("Plugin manager not initialized", fg='red')) raise SystemExit(1) failures = [] # 1. Manifest loads (raises on missing/unparseable/name-mismatch). try: manifest = pm.loader.load_manifest(name) except PluginError as e: click.echo(click.style(f" manifest: {e}", fg='red')) raise SystemExit(1) click.echo(click.style(" manifest loads + name matches directory", fg='green')) # 2. Schema. schema_errors = _check_against_schema(manifest, _load_manifest_schema()) if schema_errors: for err in schema_errors: failures.append(f"schema: {err}") click.echo(click.style(f" schema: {err}", fg='red')) else: click.echo(click.style(" schema OK", fg='green')) # 3. Contract version range admits this framework. try: pm.loader.check_contract_version(name, __contract_version__) click.echo(click.style( f" core_version admits contract {__contract_version__}", fg='green')) except PluginError as e: failures.append(str(e)) click.echo(click.style(f" core_version: {e}", fg='red')) # 4. Declared hard dependencies exist on disk (name-only; ranges stripped # later when the loader gains range semantics). available = set(pm.loader.discover_plugins()) for dep in manifest.get('dependencies', []): depname = dep.split('>')[0].split('<')[0].split('=')[0].split('!')[0].split('~')[0].strip() if depname not in available: failures.append(f"dependency '{depname}' not found on disk") click.echo(click.style( f" dependency '{depname}' not found on disk", fg='red')) if not failures and manifest.get('dependencies'): click.echo(click.style(" dependencies present on disk", fg='green')) click.echo("") if failures: click.echo(click.style( f"{name}: INVALID ({len(failures)} problem(s))", fg='red')) raise SystemExit(1) click.echo(click.style(f"{name}: valid", fg='green')) @plugin_cli.command('apply-profile') @click.argument('profile', type=click.Path(exists=True, dir_okay=False)) @with_appcontext def apply_profile(profile: str): """Install AND enable exactly the plugins named in a site profile. Declarative site setup: replaces the hand-ordered install/enable sequences in the deploy runbooks. Resolves the hard-dependency closure and applies it in dependency order; idempotent. Does NOT remove anything absent from the list. Profile JSON shape: { "site": "west-jefferson", "plugins": ["machines", "printers", "computers"], "locked": ["computers"] } Usage: flask plugin apply-profile site-profile.json """ from ..exceptions import PluginError pm = current_app.extensions.get('plugin_manager') if not pm: click.echo(click.style("Plugin manager not initialized", fg='red')) raise SystemExit(1) with open(profile) as f: data = json.load(f) names = data.get('plugins', []) if not isinstance(names, list) or not names: click.echo(click.style( "Profile has no 'plugins' list to apply", fg='red')) raise SystemExit(1) click.echo(f"Applying profile: {data.get('site', profile)}") try: result = pm.apply_profile(names, locked=data.get('locked')) except PluginError as e: click.echo(click.style(f"Profile failed: {e}", fg='red')) raise SystemExit(1) if result['installed']: click.echo(click.style( f" installed: {', '.join(result['installed'])}", fg='green')) if result['enabled']: click.echo(click.style( f" enabled: {', '.join(result['enabled'])}", fg='green')) if result['already']: click.echo(click.style( f" unchanged: {', '.join(result['already'])}", fg='white')) click.echo("") click.echo(click.style( "Run 'flask plugin upgrade-all' to apply plugin migrations, then " "restart so new blueprints/routes register.", fg='yellow')) @plugin_cli.command('migrate') @click.argument('name') @click.option('--revision', default='head', help='Target revision') @with_appcontext def migrate_plugin(name: str, revision: str): """Run migrations for a specific plugin.""" pm = current_app.extensions.get('plugin_manager') if not pm: click.echo(click.style("Plugin manager not initialized", fg='red')) raise SystemExit(1) if not pm.registry.is_installed(name): click.echo(click.style(f"Plugin {name} is not installed", fg='red')) raise SystemExit(1) click.echo(f"Running migrations for {name}...") if pm.migration_manager.run_plugin_migrations(name, revision): click.echo(click.style("Migrations completed", fg='green')) else: click.echo(click.style("Migration failed", fg='red')) raise SystemExit(1) @plugin_cli.command('upgrade-all') @with_appcontext def upgrade_all_plugins(): """Run pending migrations for every discovered plugin. Idempotent. Run this after `flask db upgrade` on every deploy and upgrade. It stamps each bundled plugin's anchor revision into alembic_version_ and applies any per-plugin migrations added after the ownership cutover (ADR-008). Safe to re-run at head. """ pm = current_app.extensions.get('plugin_manager') if not pm: click.echo(click.style("Plugin manager not initialized", fg='red')) raise SystemExit(1) results = pm.upgrade_all_plugins() if not results: click.echo("No plugins discovered.") return for name, status in sorted(results.items()): if status == 'ok': click.echo(click.style(f" {name:20} ok", fg='green')) elif status == 'no-migrations': click.echo(click.style(f" {name:20} no migrations", fg='yellow')) else: click.echo(click.style(f" {name:20} {status}", fg='red'))