appmanager-server 0.2.1__tar.gz → 0.3.1__tar.gz

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.
Files changed (130) hide show
  1. appmanager_server-0.3.1/CHANGELOG.md +59 -0
  2. appmanager_server-0.3.1/MANIFEST.in +17 -0
  3. {appmanager_server-0.2.1 → appmanager_server-0.3.1}/PKG-INFO +7 -2
  4. {appmanager_server-0.2.1 → appmanager_server-0.3.1}/README.md +5 -0
  5. {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/__init__.py +1 -1
  6. {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/admin/app_installer.py +367 -25
  7. {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/admin/routes.py +131 -0
  8. {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/api/routes.py +23 -11
  9. {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/auth/routes.py +2 -0
  10. {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/cli.py +182 -0
  11. {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/config.py +27 -2
  12. appmanager_server-0.3.1/appmanager/dependency_manager.py +442 -0
  13. {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/extension.py +23 -0
  14. {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/middleware.py +38 -4
  15. {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/security.py +19 -4
  16. {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/signals.py +1 -0
  17. appmanager_server-0.3.1/appmanager/templates/admin/app_detail.html +276 -0
  18. {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/templates/admin/dashboard.html +197 -69
  19. {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager_server.egg-info/PKG-INFO +7 -2
  20. {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager_server.egg-info/SOURCES.txt +4 -1
  21. {appmanager_server-0.2.1 → appmanager_server-0.3.1}/pyproject.toml +2 -2
  22. appmanager_server-0.3.1/tests/test_app_updates.py +136 -0
  23. appmanager_server-0.3.1/tests/test_dependency_manager.py +139 -0
  24. appmanager_server-0.3.1/tests/test_nested_portal.py +123 -0
  25. {appmanager_server-0.2.1 → appmanager_server-0.3.1}/tests/test_packaging_and_cli.py +37 -2
  26. appmanager_server-0.2.1/.env.example +0 -51
  27. appmanager_server-0.2.1/CHANGELOG.md +0 -31
  28. appmanager_server-0.2.1/MANIFEST.in +0 -11
  29. appmanager_server-0.2.1/appmanager/templates/admin/app_detail.html +0 -136
  30. {appmanager_server-0.2.1 → appmanager_server-0.3.1}/LICENSE +0 -0
  31. {appmanager_server-0.2.1 → appmanager_server-0.3.1}/SECURITY.md +0 -0
  32. {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/admin/__init__.py +0 -0
  33. {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/admin/members.py +0 -0
  34. {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/admin/registry.py +0 -0
  35. {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/api/__init__.py +0 -0
  36. {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/app_config.py +0 -0
  37. {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/auth/__init__.py +0 -0
  38. {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/auth/oauth.py +0 -0
  39. {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/auth/utils.py +0 -0
  40. {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/bridge.py +0 -0
  41. {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/database.py +0 -0
  42. {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/extensions.py +0 -0
  43. {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/health.py +0 -0
  44. {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/hooks.py +0 -0
  45. {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/models.py +0 -0
  46. {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/py.typed +0 -0
  47. {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/sdk.py +0 -0
  48. {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/security_scanner.py +0 -0
  49. {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/static/css/app.css +0 -0
  50. {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/static/vendor/fonts/fonts.css +0 -0
  51. {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/static/vendor/fonts/jetbrainsmono-0.woff2 +0 -0
  52. {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/static/vendor/fonts/jetbrainsmono-1.woff2 +0 -0
  53. {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/static/vendor/fonts/jetbrainsmono-10.woff2 +0 -0
  54. {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/static/vendor/fonts/jetbrainsmono-11.woff2 +0 -0
  55. {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/static/vendor/fonts/jetbrainsmono-12.woff2 +0 -0
  56. {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/static/vendor/fonts/jetbrainsmono-13.woff2 +0 -0
  57. {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/static/vendor/fonts/jetbrainsmono-14.woff2 +0 -0
  58. {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/static/vendor/fonts/jetbrainsmono-15.woff2 +0 -0
  59. {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/static/vendor/fonts/jetbrainsmono-16.woff2 +0 -0
  60. {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/static/vendor/fonts/jetbrainsmono-17.woff2 +0 -0
  61. {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/static/vendor/fonts/jetbrainsmono-18.woff2 +0 -0
  62. {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/static/vendor/fonts/jetbrainsmono-19.woff2 +0 -0
  63. {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/static/vendor/fonts/jetbrainsmono-2.woff2 +0 -0
  64. {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/static/vendor/fonts/jetbrainsmono-20.woff2 +0 -0
  65. {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/static/vendor/fonts/jetbrainsmono-21.woff2 +0 -0
  66. {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/static/vendor/fonts/jetbrainsmono-22.woff2 +0 -0
  67. {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/static/vendor/fonts/jetbrainsmono-23.woff2 +0 -0
  68. {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/static/vendor/fonts/jetbrainsmono-3.woff2 +0 -0
  69. {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/static/vendor/fonts/jetbrainsmono-4.woff2 +0 -0
  70. {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/static/vendor/fonts/jetbrainsmono-5.woff2 +0 -0
  71. {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/static/vendor/fonts/jetbrainsmono-6.woff2 +0 -0
  72. {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/static/vendor/fonts/jetbrainsmono-7.woff2 +0 -0
  73. {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/static/vendor/fonts/jetbrainsmono-8.woff2 +0 -0
  74. {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/static/vendor/fonts/jetbrainsmono-9.woff2 +0 -0
  75. {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/static/vendor/fonts/spacegrotesk-0.woff2 +0 -0
  76. {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/static/vendor/fonts/spacegrotesk-1.woff2 +0 -0
  77. {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/static/vendor/fonts/spacegrotesk-10.woff2 +0 -0
  78. {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/static/vendor/fonts/spacegrotesk-11.woff2 +0 -0
  79. {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/static/vendor/fonts/spacegrotesk-2.woff2 +0 -0
  80. {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/static/vendor/fonts/spacegrotesk-3.woff2 +0 -0
  81. {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/static/vendor/fonts/spacegrotesk-4.woff2 +0 -0
  82. {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/static/vendor/fonts/spacegrotesk-5.woff2 +0 -0
  83. {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/static/vendor/fonts/spacegrotesk-6.woff2 +0 -0
  84. {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/static/vendor/fonts/spacegrotesk-7.woff2 +0 -0
  85. {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/static/vendor/fonts/spacegrotesk-8.woff2 +0 -0
  86. {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/static/vendor/fonts/spacegrotesk-9.woff2 +0 -0
  87. {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/static/vendor/htmx/htmx.min.js +0 -0
  88. {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/templates/admin/_member_drawer.html +0 -0
  89. {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/templates/admin/_members_table.html +0 -0
  90. {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/templates/admin/flairs.html +0 -0
  91. {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/templates/admin/permissions.html +0 -0
  92. {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/templates/admin/roles.html +0 -0
  93. {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/templates/admin/users.html +0 -0
  94. {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/templates/auth/login.html +0 -0
  95. {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/templates/auth/profile.html +0 -0
  96. {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/templates/base.html +0 -0
  97. {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/templates/errors/401.html +0 -0
  98. {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/templates/errors/403.html +0 -0
  99. {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/templates/errors/404.html +0 -0
  100. {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/templates/errors/500.html +0 -0
  101. {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager/templates/index.html +0 -0
  102. {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager_server.egg-info/dependency_links.txt +0 -0
  103. {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager_server.egg-info/entry_points.txt +0 -0
  104. {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager_server.egg-info/requires.txt +0 -0
  105. {appmanager_server-0.2.1 → appmanager_server-0.3.1}/appmanager_server.egg-info/top_level.txt +0 -0
  106. {appmanager_server-0.2.1 → appmanager_server-0.3.1}/requirements.txt +0 -0
  107. {appmanager_server-0.2.1 → appmanager_server-0.3.1}/setup.cfg +0 -0
  108. {appmanager_server-0.2.1 → appmanager_server-0.3.1}/tests/test_api.py +0 -0
  109. {appmanager_server-0.2.1 → appmanager_server-0.3.1}/tests/test_app_settings.py +0 -0
  110. {appmanager_server-0.2.1 → appmanager_server-0.3.1}/tests/test_app_settings_config.py +0 -0
  111. {appmanager_server-0.2.1 → appmanager_server-0.3.1}/tests/test_auth.py +0 -0
  112. {appmanager_server-0.2.1 → appmanager_server-0.3.1}/tests/test_cli_dev_and_templates.py +0 -0
  113. {appmanager_server-0.2.1 → appmanager_server-0.3.1}/tests/test_cli_health.py +0 -0
  114. {appmanager_server-0.2.1 → appmanager_server-0.3.1}/tests/test_cli_scaffolding.py +0 -0
  115. {appmanager_server-0.2.1 → appmanager_server-0.3.1}/tests/test_error_pages.py +0 -0
  116. {appmanager_server-0.2.1 → appmanager_server-0.3.1}/tests/test_extension_apps.py +0 -0
  117. {appmanager_server-0.2.1 → appmanager_server-0.3.1}/tests/test_extension_pattern.py +0 -0
  118. {appmanager_server-0.2.1 → appmanager_server-0.3.1}/tests/test_hooks.py +0 -0
  119. {appmanager_server-0.2.1 → appmanager_server-0.3.1}/tests/test_installer.py +0 -0
  120. {appmanager_server-0.2.1 → appmanager_server-0.3.1}/tests/test_invalid_install_failures.py +0 -0
  121. {appmanager_server-0.2.1 → appmanager_server-0.3.1}/tests/test_manifest_sdk.py +0 -0
  122. {appmanager_server-0.2.1 → appmanager_server-0.3.1}/tests/test_middleware.py +0 -0
  123. {appmanager_server-0.2.1 → appmanager_server-0.3.1}/tests/test_role_management.py +0 -0
  124. {appmanager_server-0.2.1 → appmanager_server-0.3.1}/tests/test_sdk.py +0 -0
  125. {appmanager_server-0.2.1 → appmanager_server-0.3.1}/tests/test_security_hardening.py +0 -0
  126. {appmanager_server-0.2.1 → appmanager_server-0.3.1}/tests/test_security_precheck_flow.py +0 -0
  127. {appmanager_server-0.2.1 → appmanager_server-0.3.1}/tests/test_security_scanner.py +0 -0
  128. {appmanager_server-0.2.1 → appmanager_server-0.3.1}/tests/test_signals.py +0 -0
  129. {appmanager_server-0.2.1 → appmanager_server-0.3.1}/tests/test_user_elevation_and_visibility.py +0 -0
  130. {appmanager_server-0.2.1 → appmanager_server-0.3.1}/tests/test_user_tracking.py +0 -0
@@ -0,0 +1,59 @@
1
+ # Changelog
2
+
3
+ All notable changes to **`appmanager-server`** will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ---
9
+
10
+ ## [0.3.1] - 2026-08-28
11
+
12
+ ### Fixed
13
+ - **App & Role Deletion Type-To-Confirm Modal**: Fixed modal validation logic and dynamic form URL in Admin Dashboard where typing the exact app/role slug did not enable the confirmation submission.
14
+
15
+ ---
16
+
17
+ ## [0.3.0] - 2026-08-28
18
+
19
+ ### Added
20
+ - **Dependency & Environment Conflict Engine (`appmanager.dependency_manager`)**:
21
+ - Python runtime version verification comparing manifest `requires_python` against `platform.python_version()`.
22
+ - PEP 508 requirement analyzer checking active environment distributions for satisfied packages, packages to install, and conflicts against core host frameworks (`Flask`, `SQLAlchemy`, etc.).
23
+ - Shared dependency preservation analyzer protecting packages needed by other sub-apps when uninstalling.
24
+ - **Configurable Virtual Environment Modes (`APP_VENV_MODE`)**:
25
+ - `"singular"` (Default): Single shared host environment.
26
+ - `"isolated"`: Per-app isolated virtual environment (`.venv/`).
27
+ - **One-Click Sub-App Updates & Replacements**:
28
+ - Git Update action (`POST /admin/apps/<id>/update-git`) pulling remote changes with automated AST security scan, dependency sync, and cache reload.
29
+ - In-place ZIP replacement (`POST /admin/apps/<id>/precheck-replace-zip` -> `confirm-replace-zip`) with atomic rollback protection.
30
+ - **CLI Commands**:
31
+ - `appmanager update <slug> [--zip <path>]`
32
+ - `appmanager check-deps [slug] [--all] [--install]`
33
+ - `appmanager install-deps [slug] [--all]`
34
+ - **Nested AppManager Portals**: Support for nesting AppManager instances as sub-apps within parent AppManager instances with recursive dispatching.
35
+
36
+ ---
37
+
38
+ ## [0.2.1] - 2026-08-28
39
+
40
+ ### Added
41
+ - **Configurable Dev Magic Login (`ALLOW_DEV_MAGIC_LOGIN`)**: Prevents showing one-click developer login buttons in production when SMTP is unconfigured unless explicitly permitted.
42
+ - **Configurable Admin Role Provisioning (`ADMIN_EMAILS` & `FIRST_USER_IS_ADMIN`)**:
43
+ - `ADMIN_EMAILS`: Specify comma-separated list of admin email addresses.
44
+ - `FIRST_USER_IS_ADMIN`: Controls whether the very first registered user in a blank database receives the admin role (defaults to `true`).
45
+
46
+ ### Changed
47
+ - Improved error messaging on `/auth/login` when SMTP is unconfigured.
48
+ - Expanded authentication test suite.
49
+
50
+ ---
51
+
52
+ ## [0.2.0] - 2026-08-28
53
+
54
+ ### Added
55
+ - Initial PyPI release under package name **`appmanager-server`**.
56
+ - CLI commands and aliases: `appmanager-server`, `appmgr-server`, `appmanager`, and `appmgr`.
57
+ - AST Security Scanner (`appmanager.security_scanner.SecurityScanner`) for ZIP and Git package pre-installation checks.
58
+ - Python 3.10-3.13 support.
59
+ - PyPI Trusted Publishing via GitHub Actions OIDC.
@@ -0,0 +1,17 @@
1
+ include LICENSE
2
+ include README.md
3
+ include CHANGELOG.md
4
+ include SECURITY.md
5
+ include requirements.txt
6
+ include .env.example
7
+ graft appmanager/templates
8
+ graft appmanager/static
9
+ global-exclude __pycache__
10
+ global-exclude *.py[cod]
11
+ global-exclude .env*
12
+ global-exclude *.db
13
+ global-exclude *.sqlite*
14
+ prune instance
15
+ prune installed_apps
16
+ prune .venv
17
+ prune .pytest_cache
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: appmanager-server
3
- Version: 0.2.1
4
- Summary: Multi-tenant Flask sub-application portal & dynamic WSGI dispatcher with in-process isolation, telemetry, and automated health checks.
3
+ Version: 0.3.1
4
+ Summary: High-performance multi-tenant WSGI portal, dynamic sub-app dispatcher, and extension framework for Python/Flask.
5
5
  Author: AppManager Contributors
6
6
  License: MIT
7
7
  Project-URL: Homepage, https://github.com/r3sbarra/appmanager-server
@@ -282,6 +282,11 @@ Available UI slots:
282
282
  | `appmanager dev <slug>` | Run standalone local test server with mock authentication |
283
283
  | `appmanager seed` | Seed database with default starter apps, roles, and flairs |
284
284
  | `appmanager new-subapp <name>` | Scaffold a sub-app with templates (`basic`, `api`, `extension`, `htmx`, `full`) |
285
+ | `appmanager install-git <url>` | Install a sub-app from Git repository with security pre-check audit |
286
+ | `appmanager install-zip <path>` | Install a sub-app from ZIP package with automated security pre-check |
287
+ | `appmanager update <slug>` | One-click update for Git sub-apps or in-place replacement via `--zip <path>` |
288
+ | `appmanager check-deps [slug]` | Inspect dependency health, Python version compatibility, and conflicts (`--all`, `--install`) |
289
+ | `appmanager install-deps [slug]` | Automatically install declared requirements into single or isolated venv (`--all`) |
285
290
  | `appmanager validate-subapp <path>` | Validate a sub-app folder or ZIP package against manifest rules |
286
291
  | `appmanager export-app <slug>` | Package an installed sub-app into a deployable ZIP archive |
287
292
  | `appmanager reload-app <slug>` | Invalidate in-memory WSGI cache for zero-downtime updates |
@@ -221,6 +221,11 @@ Available UI slots:
221
221
  | `appmanager dev <slug>` | Run standalone local test server with mock authentication |
222
222
  | `appmanager seed` | Seed database with default starter apps, roles, and flairs |
223
223
  | `appmanager new-subapp <name>` | Scaffold a sub-app with templates (`basic`, `api`, `extension`, `htmx`, `full`) |
224
+ | `appmanager install-git <url>` | Install a sub-app from Git repository with security pre-check audit |
225
+ | `appmanager install-zip <path>` | Install a sub-app from ZIP package with automated security pre-check |
226
+ | `appmanager update <slug>` | One-click update for Git sub-apps or in-place replacement via `--zip <path>` |
227
+ | `appmanager check-deps [slug]` | Inspect dependency health, Python version compatibility, and conflicts (`--all`, `--install`) |
228
+ | `appmanager install-deps [slug]` | Automatically install declared requirements into single or isolated venv (`--all`) |
224
229
  | `appmanager validate-subapp <path>` | Validate a sub-app folder or ZIP package against manifest rules |
225
230
  | `appmanager export-app <slug>` | Package an installed sub-app into a deployable ZIP archive |
226
231
  | `appmanager reload-app <slug>` | Invalidate in-memory WSGI cache for zero-downtime updates |
@@ -8,7 +8,7 @@ from appmanager.database import db
8
8
  from appmanager.extension import AppManager
9
9
  from appmanager.middleware import DynamicAppDispatcherMiddleware
10
10
 
11
- __version__ = "0.2.1"
11
+ __version__ = "0.3.1"
12
12
 
13
13
  __all__ = [
14
14
  "AppManager",
@@ -1,6 +1,8 @@
1
1
  import ast
2
2
  import importlib.util
3
+ import inspect
3
4
  import json
5
+ import logging
4
6
  import os
5
7
  import shutil
6
8
  import subprocess
@@ -14,6 +16,12 @@ from flask import current_app
14
16
  from werkzeug.utils import secure_filename
15
17
 
16
18
  from appmanager.database import db
19
+ from appmanager.dependency_manager import (
20
+ DependencyAnalysisReport,
21
+ analyze_dependencies,
22
+ analyze_uninstall_dependencies,
23
+ install_app_dependencies,
24
+ )
17
25
  from appmanager.models import InstalledApp, User, UserAppPermission
18
26
  from appmanager.security import (
19
27
  extract_zip_safely,
@@ -21,7 +29,9 @@ from appmanager.security import (
21
29
  validate_entrypoint_path,
22
30
  )
23
31
  from appmanager.security_scanner import SecurityScanReport, run_security_scan
24
- from appmanager.signals import subapp_installed, subapp_uninstalled
32
+ from appmanager.signals import subapp_installed, subapp_reloaded, subapp_uninstalled, subapp_updated
33
+
34
+ logger = logging.getLogger("appmanager.installer")
25
35
 
26
36
 
27
37
  def sanitize_slug(slug_text: str) -> str:
@@ -210,32 +220,51 @@ def validate_subapp_package(path_or_zip: str) -> Tuple[bool, List[str], Dict[str
210
220
 
211
221
  def discover_entrypoint(app_dir: str) -> str:
212
222
  """
213
- Search for common Flask entrypoint files (app.py, wsgi.py, main.py, run.py) or manifest.json.
214
- Returns 'module_name:app_variable' string e.g. 'app:app'
223
+ Search for common Flask entrypoint files (app.py, wsgi.py, main.py, run.py, src/app.py, packages)
224
+ or manifest.json. Returns 'module_name:app_variable' string e.g. 'app:app'
215
225
  """
216
226
  manifest = parse_manifest(app_dir)
217
227
  if manifest and "entry_point" in manifest:
218
228
  return manifest["entry_point"]
219
229
 
220
- candidates = ["app.py", "wsgi.py", "main.py", "run.py"]
230
+ candidates = [
231
+ "app.py",
232
+ "wsgi.py",
233
+ "main.py",
234
+ "run.py",
235
+ "server.py",
236
+ os.path.join("src", "app.py"),
237
+ os.path.join("src", "main.py"),
238
+ os.path.join("src", "wsgi.py"),
239
+ ]
221
240
  for candidate in candidates:
222
241
  if os.path.exists(os.path.join(app_dir, candidate)):
223
- module_name = candidate[:-3] # Strip .py
242
+ module_name = candidate[:-3].replace(os.sep, ".") # Strip .py and normalize slashes
224
243
  return f"{module_name}:app"
225
244
 
245
+ # Check for package directories with __init__.py (e.g. appmanager, app, <slug>)
246
+ slug = os.path.basename(app_dir.rstrip("/\\"))
247
+ for pkg_cand in [slug, slug.replace("-", "_"), "app", "appmanager"]:
248
+ init_file = os.path.join(app_dir, pkg_cand, "__init__.py")
249
+ if os.path.exists(init_file):
250
+ return f"{pkg_cand}:create_app"
251
+
226
252
  # Default fallback
227
253
  return "app:app"
228
254
 
229
255
 
230
- def install_dependencies(app_dir: str) -> None:
231
- req_file = os.path.join(app_dir, "requirements.txt")
232
- if os.path.exists(req_file):
233
- try:
234
- print(f"[INSTALLER] Installing dependencies for sub-app at {app_dir}...")
235
- subprocess.check_call([sys.executable, "-m", "pip", "install", "-r", req_file])
236
- print("[INSTALLER] Sub-app dependencies installed successfully.")
237
- except Exception as e:
238
- print(f"[INSTALLER WARNING] Failed to install requirements.txt: {e}")
256
+ def install_dependencies(app_dir: str) -> Tuple[bool, str]:
257
+ """
258
+ Installs sub-app dependencies using the configured virtual environment mode (singular or isolated).
259
+ """
260
+ venv_mode = "singular"
261
+ if current_app:
262
+ venv_mode = current_app.config.get("APP_VENV_MODE", "singular")
263
+
264
+ ok, msg = install_app_dependencies(app_dir, venv_mode=venv_mode)
265
+ if not ok:
266
+ print(f"[INSTALLER WARNING] {msg}")
267
+ return ok, msg
239
268
 
240
269
 
241
270
  def load_wsgi_app_from_path(app_dir: str, entry_point: str = "app:app") -> Any:
@@ -253,7 +282,9 @@ def load_wsgi_app_from_path(app_dir: str, entry_point: str = "app:app") -> Any:
253
282
 
254
283
  module_path = module_path_or_err
255
284
  if not os.path.exists(module_path):
256
- raise FileNotFoundError(f"Entrypoint file '{module_name}.py' not found in {app_dir}")
285
+ raise FileNotFoundError(
286
+ f"Entrypoint module/file '{module_name}' was not found in sub-app package."
287
+ )
257
288
 
258
289
  # Unique scoped module spec name to avoid sys.path pollution
259
290
  slug = os.path.basename(app_dir)
@@ -267,15 +298,37 @@ def load_wsgi_app_from_path(app_dir: str, entry_point: str = "app:app") -> Any:
267
298
  sys.modules[scoped_module_name] = mod
268
299
  spec.loader.exec_module(mod)
269
300
 
270
- # Check for Flask app instance or callable create_app factory
301
+ # Check for Flask app instance, callable create_app factory, or WSGI middleware
271
302
  if hasattr(mod, app_var_name):
272
303
  app_obj = getattr(mod, app_var_name)
273
- if callable(app_obj) and not hasattr(app_obj, "wsgi_app"):
274
- # Factory function e.g. create_app()
275
- app_instance = app_obj()
276
- else:
277
- app_instance = app_obj
278
- return app_instance
304
+ if hasattr(app_obj, "wsgi_app"):
305
+ return app_obj
306
+
307
+ if callable(app_obj):
308
+ if inspect.isfunction(app_obj) or inspect.ismethod(app_obj):
309
+ try:
310
+ sig = inspect.signature(app_obj)
311
+ params = list(sig.parameters.values())
312
+ # Standard WSGI callable: (environ, start_response)
313
+ if len(params) == 2 and params[0].name in ("environ", "env"):
314
+ return app_obj
315
+ required_params = [
316
+ p
317
+ for p in params
318
+ if p.default == inspect.Parameter.empty
319
+ and p.kind
320
+ in (
321
+ inspect.Parameter.POSITIONAL_ONLY,
322
+ inspect.Parameter.POSITIONAL_OR_KEYWORD,
323
+ )
324
+ ]
325
+ if len(required_params) == 0:
326
+ return app_obj()
327
+ except Exception:
328
+ pass
329
+ return app_obj
330
+
331
+ return app_obj
279
332
 
280
333
  raise AttributeError(f"Module '{module_name}' in {app_dir} does not export '{app_var_name}'")
281
334
 
@@ -289,10 +342,14 @@ def _write_staging_metadata(staging_dir: str, meta: Dict[str, Any]) -> None:
289
342
  """Saves staging session metadata to disk inside the sandboxed staging directory."""
290
343
  meta_path = os.path.join(staging_dir, "_staging_meta.json")
291
344
  try:
292
- # Don't serialize scan_report object directly to JSON if it's an object
345
+ # Don't serialize scan_report / dependency_report object directly to JSON if it's an object
293
346
  serializable = dict(meta)
294
347
  if "scan_report" in serializable and hasattr(serializable["scan_report"], "to_dict"):
295
348
  serializable["scan_report"] = serializable["scan_report"].to_dict()
349
+ if "dependency_report" in serializable and hasattr(
350
+ serializable["dependency_report"], "to_dict"
351
+ ):
352
+ serializable["dependency_report"] = serializable["dependency_report"].to_dict()
296
353
  with open(meta_path, "w", encoding="utf-8") as f:
297
354
  json.dump(serializable, f)
298
355
  except Exception as e:
@@ -366,6 +423,7 @@ def stage_git_repo(
366
423
  if not is_safe_repo_url(repo_url):
367
424
  raise ValueError(f"Invalid or unsafe git repository URL: '{repo_url}'")
368
425
 
426
+ logger.info("Staging Git repository installation from '%s'", repo_url)
369
427
  cleanup_expired_staged_sessions()
370
428
 
371
429
  import tempfile
@@ -390,6 +448,7 @@ def stage_git_repo(
390
448
  res.stderr.strip() or f"Failed to clone repository from '{repo_url}'."
391
449
  )
392
450
  except Exception as git_err:
451
+ logger.error("Failed to clone Git repository '%s': %s", repo_url, git_err)
393
452
  if os.path.exists(temp_stage_dir):
394
453
  shutil.rmtree(temp_stage_dir, ignore_errors=True)
395
454
  raise ValueError(f"Failed to clone Git repository: {git_err}")
@@ -417,6 +476,10 @@ def stage_git_repo(
417
476
  # Run static security pre-check
418
477
  scan_report = run_security_scan(temp_stage_dir)
419
478
 
479
+ # Run dependency analysis
480
+ venv_mode = current_app.config.get("APP_VENV_MODE", "singular") if current_app else "singular"
481
+ dep_report = analyze_dependencies(temp_stage_dir, manifest=manifest, venv_mode=venv_mode)
482
+
420
483
  _staged_installations[staging_id] = {
421
484
  "staging_id": staging_id,
422
485
  "staging_dir": temp_stage_dir,
@@ -427,9 +490,17 @@ def stage_git_repo(
427
490
  "entry_point": resolved_entry_point,
428
491
  "manifest": manifest,
429
492
  "scan_report": scan_report,
493
+ "dependency_report": dep_report,
430
494
  "created_at": time.time(),
431
495
  }
432
496
  _write_staging_metadata(temp_stage_dir, _staged_installations[staging_id])
497
+ logger.info(
498
+ "Staged Git app '%s' (staging_id=%s, risk=%s, safe=%s)",
499
+ resolved_slug,
500
+ staging_id,
501
+ scan_report.risk_level,
502
+ scan_report.is_safe,
503
+ )
433
504
 
434
505
  return staging_id, scan_report, manifest
435
506
 
@@ -494,6 +565,10 @@ def stage_zip_file(
494
565
  # Run static security pre-check
495
566
  scan_report = run_security_scan(temp_stage_dir)
496
567
 
568
+ # Run dependency analysis
569
+ venv_mode = current_app.config.get("APP_VENV_MODE", "singular") if current_app else "singular"
570
+ dep_report = analyze_dependencies(temp_stage_dir, manifest=manifest, venv_mode=venv_mode)
571
+
497
572
  _staged_installations[staging_id] = {
498
573
  "staging_id": staging_id,
499
574
  "staging_dir": temp_stage_dir,
@@ -504,9 +579,17 @@ def stage_zip_file(
504
579
  "entry_point": resolved_entry_point,
505
580
  "manifest": manifest,
506
581
  "scan_report": scan_report,
582
+ "dependency_report": dep_report,
507
583
  "created_at": time.time(),
508
584
  }
509
585
  _write_staging_metadata(temp_stage_dir, _staged_installations[staging_id])
586
+ logger.info(
587
+ "Staged ZIP package '%s' (filename: %s, risk=%s, safe=%s)",
588
+ resolved_slug,
589
+ filename,
590
+ scan_report.risk_level,
591
+ scan_report.is_safe,
592
+ )
510
593
 
511
594
  return staging_id, scan_report, manifest
512
595
 
@@ -535,6 +618,7 @@ def finalize_staged_installation(
535
618
 
536
619
  final_slug = sanitize_slug(slug or session.get("slug") or session.get("name") or "sub-app")
537
620
  target_dir = os.path.join(current_app.config["INSTALLED_APPS_DIR"], final_slug)
621
+ logger.info("Finalizing installation of staged app '%s' into '%s'", final_slug, target_dir)
538
622
 
539
623
  if os.path.exists(target_dir):
540
624
  if os.path.exists(staging_dir):
@@ -585,6 +669,7 @@ def finalize_staged_installation(
585
669
  else:
586
670
  initial_settings[s_key] = s_val
587
671
 
672
+ logger.info("Installing dependencies for '%s'...", final_slug)
588
673
  install_dependencies(target_dir)
589
674
 
590
675
  installed_app = InstalledApp(
@@ -707,13 +792,267 @@ def export_app_to_zip(slug: str, output_path: Optional[str] = None) -> str:
707
792
  return output_path
708
793
 
709
794
 
795
+ def update_app_from_git(app_id_or_slug: Any) -> Tuple[bool, str, Dict[str, Any]]:
796
+ """
797
+ Pulls latest commits from remote Git repository for an installed app, executes
798
+ AST security pre-check audit on updated files, updates dependencies, and reloads WSGI cache.
799
+ """
800
+ if isinstance(app_id_or_slug, int) or (
801
+ isinstance(app_id_or_slug, str) and app_id_or_slug.isdigit()
802
+ ):
803
+ app_record = db.session.get(InstalledApp, int(app_id_or_slug))
804
+ else:
805
+ app_record = InstalledApp.query.filter_by(slug=str(app_id_or_slug)).first()
806
+
807
+ if not app_record:
808
+ return False, "Application not found in database.", {}
809
+
810
+ slug = app_record.slug
811
+ app_dir = os.path.join(current_app.config["INSTALLED_APPS_DIR"], slug)
812
+
813
+ if not os.path.exists(app_dir):
814
+ return False, f"App directory for '{app_record.name}' does not exist on disk.", {}
815
+
816
+ git_dir = os.path.join(app_dir, ".git")
817
+ if not os.path.exists(git_dir):
818
+ return (
819
+ False,
820
+ f"'{app_record.name}' was not installed via Git (no .git directory found).",
821
+ {},
822
+ )
823
+
824
+ # 1. Fetch and pull changes
825
+ try:
826
+ # Fetch remote refs
827
+ fetch_res = subprocess.run(
828
+ ["git", "fetch", "origin"],
829
+ cwd=app_dir,
830
+ capture_output=True,
831
+ text=True,
832
+ timeout=60,
833
+ )
834
+ if fetch_res.returncode != 0:
835
+ return False, f"Git fetch failed: {fetch_res.stderr.strip()}", {}
836
+
837
+ # Reset / pull to origin HEAD
838
+ pull_res = subprocess.run(
839
+ ["git", "pull", "--ff-only"],
840
+ cwd=app_dir,
841
+ capture_output=True,
842
+ text=True,
843
+ timeout=60,
844
+ )
845
+ if pull_res.returncode != 0:
846
+ # Fallback to merge or pull origin HEAD
847
+ pull_res = subprocess.run(
848
+ ["git", "pull", "origin", "HEAD"],
849
+ cwd=app_dir,
850
+ capture_output=True,
851
+ text=True,
852
+ timeout=60,
853
+ )
854
+ if pull_res.returncode != 0:
855
+ return False, f"Git pull failed: {pull_res.stderr.strip()}", {}
856
+
857
+ except Exception as git_err:
858
+ return False, f"Git operation error: {str(git_err)}", {}
859
+
860
+ # 2. Run AST Security Scan on updated repository
861
+ scan_report = run_security_scan(app_dir)
862
+ if not scan_report.is_safe:
863
+ return (
864
+ False,
865
+ f"Security scan failed on updated code: {scan_report.summary}",
866
+ {"scan_report": scan_report.to_dict()},
867
+ )
868
+
869
+ # 3. Analyze and install dependencies
870
+ venv_mode = current_app.config.get("APP_VENV_MODE", "singular") if current_app else "singular"
871
+ manifest = parse_manifest(app_dir) or {}
872
+ dep_report = analyze_dependencies(app_dir, manifest=manifest, venv_mode=venv_mode)
873
+
874
+ dep_ok, dep_msg = install_dependencies(app_dir)
875
+
876
+ # 4. Update InstalledApp metadata
877
+ if manifest:
878
+ if manifest.get("name"):
879
+ app_record.name = manifest["name"]
880
+ if manifest.get("description"):
881
+ app_record.description = manifest["description"]
882
+ if manifest.get("entry_point"):
883
+ app_record.entry_point = manifest["entry_point"]
884
+
885
+ db.session.commit()
886
+
887
+ # 5. Clear WSGI cache and trigger signals
888
+ if hasattr(current_app, "extensions") and "appmanager" in current_app.extensions:
889
+ current_app.extensions["appmanager"].clear_cache(slug=slug)
890
+
891
+ try:
892
+ subapp_reloaded.send(None, slug=slug)
893
+ subapp_updated.send(None, app_slug=slug, app_id=app_record.id, update_type="git")
894
+ except Exception:
895
+ pass
896
+
897
+ try:
898
+ from appmanager.hooks import trigger_hook
899
+
900
+ trigger_hook("on_app_updated", app_id=app_record.id, app_slug=slug, update_type="git")
901
+ except Exception:
902
+ pass
903
+
904
+ return (
905
+ True,
906
+ f"Application '{app_record.name}' updated successfully from Git.",
907
+ {
908
+ "scan_report": scan_report.to_dict(),
909
+ "dependency_report": dep_report.to_dict(),
910
+ "dep_output": dep_msg,
911
+ "manifest": manifest,
912
+ },
913
+ )
914
+
915
+
916
+ def stage_zip_replacement(
917
+ app_id_or_slug: Any,
918
+ zip_file_storage: Any,
919
+ ) -> Tuple[str, SecurityScanReport, Dict[str, Any], DependencyAnalysisReport]:
920
+ """
921
+ Stages an uploaded ZIP file for replacing an existing installed application.
922
+ """
923
+ if isinstance(app_id_or_slug, int) or (
924
+ isinstance(app_id_or_slug, str) and app_id_or_slug.isdigit()
925
+ ):
926
+ app_record = db.session.get(InstalledApp, int(app_id_or_slug))
927
+ else:
928
+ app_record = InstalledApp.query.filter_by(slug=str(app_id_or_slug)).first()
929
+
930
+ if not app_record:
931
+ raise ValueError("Target application to replace was not found.")
932
+
933
+ staging_id, scan_report, manifest = stage_zip_file(
934
+ zip_file_storage=zip_file_storage,
935
+ name=app_record.name,
936
+ slug=app_record.slug,
937
+ entry_point=app_record.entry_point,
938
+ )
939
+
940
+ session = _staged_installations[staging_id]
941
+ session["action"] = "replace"
942
+ session["target_app_id"] = app_record.id
943
+ _write_staging_metadata(session["staging_dir"], session)
944
+
945
+ dep_report = session["dependency_report"]
946
+ return staging_id, scan_report, manifest, dep_report
947
+
948
+
949
+ def finalize_zip_replacement(staging_id: str) -> InstalledApp:
950
+ """
951
+ Finalizes replacing an existing app's files with newly staged ZIP archive contents.
952
+ """
953
+ session = get_staged_session(staging_id)
954
+ if not session:
955
+ raise ValueError(f"Staged session '{staging_id}' not found or expired.")
956
+
957
+ _staged_installations.pop(staging_id, None)
958
+
959
+ staging_dir = session["staging_dir"]
960
+ target_app_id = session.get("target_app_id")
961
+ target_slug = session.get("slug")
962
+
963
+ if target_app_id:
964
+ app_record = db.session.get(InstalledApp, target_app_id)
965
+ else:
966
+ app_record = InstalledApp.query.filter_by(slug=target_slug).first()
967
+
968
+ if not app_record:
969
+ if os.path.exists(staging_dir):
970
+ shutil.rmtree(staging_dir, ignore_errors=True)
971
+ raise ValueError("Target app to replace was not found.")
972
+
973
+ target_dir = os.path.join(current_app.config["INSTALLED_APPS_DIR"], app_record.slug)
974
+
975
+ # Atomically replace target directory
976
+ temp_backup_dir = None
977
+ try:
978
+ import tempfile
979
+
980
+ if os.path.exists(target_dir):
981
+ temp_backup_dir = tempfile.mkdtemp(prefix="app_backup_")
982
+ # Move existing app out
983
+ for item in os.listdir(target_dir):
984
+ s = os.path.join(target_dir, item)
985
+ d = os.path.join(temp_backup_dir, item)
986
+ shutil.move(s, d)
987
+
988
+ # Move new staged files in
989
+ meta_path = os.path.join(staging_dir, "_staging_meta.json")
990
+ if os.path.exists(meta_path):
991
+ try:
992
+ os.remove(meta_path)
993
+ except Exception:
994
+ pass
995
+
996
+ for item in os.listdir(staging_dir):
997
+ s = os.path.join(staging_dir, item)
998
+ d = os.path.join(target_dir, item)
999
+ shutil.move(s, d)
1000
+
1001
+ if os.path.exists(staging_dir):
1002
+ shutil.rmtree(staging_dir, ignore_errors=True)
1003
+
1004
+ if temp_backup_dir and os.path.exists(temp_backup_dir):
1005
+ shutil.rmtree(temp_backup_dir, ignore_errors=True)
1006
+
1007
+ except Exception as e:
1008
+ if temp_backup_dir and os.path.exists(temp_backup_dir) and os.path.exists(target_dir):
1009
+ # Restore backup on failure
1010
+ shutil.rmtree(target_dir, ignore_errors=True)
1011
+ os.makedirs(target_dir, exist_ok=True)
1012
+ for item in os.listdir(temp_backup_dir):
1013
+ shutil.move(os.path.join(temp_backup_dir, item), os.path.join(target_dir, item))
1014
+ shutil.rmtree(temp_backup_dir, ignore_errors=True)
1015
+ raise RuntimeError(f"Failed to replace application files: {e}")
1016
+
1017
+ # Update dependencies and manifest
1018
+ install_dependencies(target_dir)
1019
+
1020
+ manifest = parse_manifest(target_dir) or {}
1021
+ if manifest:
1022
+ if manifest.get("name"):
1023
+ app_record.name = manifest["name"]
1024
+ if manifest.get("description"):
1025
+ app_record.description = manifest["description"]
1026
+ if manifest.get("entry_point"):
1027
+ app_record.entry_point = manifest["entry_point"]
1028
+
1029
+ db.session.commit()
1030
+
1031
+ # Clear cache and trigger reload
1032
+ if hasattr(current_app, "extensions") and "appmanager" in current_app.extensions:
1033
+ current_app.extensions["appmanager"].clear_cache(slug=app_record.slug)
1034
+
1035
+ try:
1036
+ subapp_reloaded.send(None, slug=app_record.slug)
1037
+ subapp_updated.send(None, app_slug=app_record.slug, app_id=app_record.id, update_type="zip")
1038
+ except Exception:
1039
+ pass
1040
+
1041
+ return app_record
1042
+
1043
+
710
1044
  def uninstall_app(app_id: int) -> Tuple[bool, str]:
711
1045
  app_record = db.session.get(InstalledApp, app_id)
712
1046
  if not app_record:
713
1047
  return False, "App not found."
714
1048
 
715
1049
  slug = app_record.slug
716
- target_dir = os.path.join(current_app.config["INSTALLED_APPS_DIR"], slug)
1050
+ installed_apps_dir = current_app.config["INSTALLED_APPS_DIR"]
1051
+ target_dir = os.path.join(installed_apps_dir, slug)
1052
+
1053
+ # 1. Analyze dependencies to preserve shared packages
1054
+ dep_summary = analyze_uninstall_dependencies(slug, installed_apps_dir)
1055
+
717
1056
  if os.path.exists(target_dir):
718
1057
  shutil.rmtree(target_dir, ignore_errors=True)
719
1058
 
@@ -736,4 +1075,7 @@ def uninstall_app(app_id: int) -> Tuple[bool, str]:
736
1075
  except Exception:
737
1076
  pass
738
1077
 
739
- return True, "App uninstalled successfully."
1078
+ preserved_msg = (
1079
+ f" ({dep_summary['message']})" if dep_summary.get("shared_packages_preserved") else ""
1080
+ )
1081
+ return True, f"Application '{app_record.name}' uninstalled successfully.{preserved_msg}"