appmanager-server 0.2.0__py3-none-any.whl
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.
- appmanager/__init__.py +52 -0
- appmanager/admin/__init__.py +3 -0
- appmanager/admin/app_installer.py +739 -0
- appmanager/admin/members.py +95 -0
- appmanager/admin/registry.py +372 -0
- appmanager/admin/routes.py +900 -0
- appmanager/api/__init__.py +5 -0
- appmanager/api/routes.py +373 -0
- appmanager/app_config.py +101 -0
- appmanager/auth/__init__.py +3 -0
- appmanager/auth/oauth.py +77 -0
- appmanager/auth/routes.py +224 -0
- appmanager/auth/utils.py +175 -0
- appmanager/bridge.py +90 -0
- appmanager/cli.py +1171 -0
- appmanager/config.py +58 -0
- appmanager/database.py +120 -0
- appmanager/extension.py +198 -0
- appmanager/extensions.py +225 -0
- appmanager/health.py +133 -0
- appmanager/hooks.py +187 -0
- appmanager/middleware.py +256 -0
- appmanager/models.py +307 -0
- appmanager/py.typed +1 -0
- appmanager/sdk.py +30 -0
- appmanager/security.py +227 -0
- appmanager/security_scanner.py +582 -0
- appmanager/signals.py +21 -0
- appmanager/static/css/app.css +808 -0
- appmanager/static/vendor/fonts/fonts.css +288 -0
- appmanager/static/vendor/fonts/jetbrainsmono-0.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-1.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-10.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-11.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-12.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-13.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-14.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-15.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-16.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-17.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-18.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-19.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-2.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-20.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-21.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-22.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-23.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-3.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-4.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-5.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-6.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-7.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-8.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-9.woff2 +0 -0
- appmanager/static/vendor/fonts/spacegrotesk-0.woff2 +0 -0
- appmanager/static/vendor/fonts/spacegrotesk-1.woff2 +0 -0
- appmanager/static/vendor/fonts/spacegrotesk-10.woff2 +0 -0
- appmanager/static/vendor/fonts/spacegrotesk-11.woff2 +0 -0
- appmanager/static/vendor/fonts/spacegrotesk-2.woff2 +0 -0
- appmanager/static/vendor/fonts/spacegrotesk-3.woff2 +0 -0
- appmanager/static/vendor/fonts/spacegrotesk-4.woff2 +0 -0
- appmanager/static/vendor/fonts/spacegrotesk-5.woff2 +0 -0
- appmanager/static/vendor/fonts/spacegrotesk-6.woff2 +0 -0
- appmanager/static/vendor/fonts/spacegrotesk-7.woff2 +0 -0
- appmanager/static/vendor/fonts/spacegrotesk-8.woff2 +0 -0
- appmanager/static/vendor/fonts/spacegrotesk-9.woff2 +0 -0
- appmanager/static/vendor/htmx/htmx.min.js +1 -0
- appmanager/templates/admin/_member_drawer.html +98 -0
- appmanager/templates/admin/_members_table.html +108 -0
- appmanager/templates/admin/app_detail.html +136 -0
- appmanager/templates/admin/dashboard.html +902 -0
- appmanager/templates/admin/flairs.html +90 -0
- appmanager/templates/admin/permissions.html +88 -0
- appmanager/templates/admin/roles.html +119 -0
- appmanager/templates/admin/users.html +156 -0
- appmanager/templates/auth/login.html +58 -0
- appmanager/templates/auth/profile.html +44 -0
- appmanager/templates/base.html +76 -0
- appmanager/templates/errors/401.html +19 -0
- appmanager/templates/errors/403.html +19 -0
- appmanager/templates/errors/404.html +18 -0
- appmanager/templates/errors/500.html +19 -0
- appmanager/templates/index.html +54 -0
- appmanager_server-0.2.0.dist-info/METADATA +320 -0
- appmanager_server-0.2.0.dist-info/RECORD +89 -0
- appmanager_server-0.2.0.dist-info/WHEEL +5 -0
- appmanager_server-0.2.0.dist-info/entry_points.txt +5 -0
- appmanager_server-0.2.0.dist-info/licenses/LICENSE +21 -0
- appmanager_server-0.2.0.dist-info/top_level.txt +1 -0
appmanager/cli.py
ADDED
|
@@ -0,0 +1,1171 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
|
|
7
|
+
from appmanager import __version__, create_app, create_dispatchable_app
|
|
8
|
+
from appmanager.admin.app_installer import (
|
|
9
|
+
export_app_to_zip,
|
|
10
|
+
load_wsgi_app_from_path,
|
|
11
|
+
parse_manifest,
|
|
12
|
+
sanitize_slug,
|
|
13
|
+
validate_subapp_package,
|
|
14
|
+
)
|
|
15
|
+
from appmanager.database import db
|
|
16
|
+
from appmanager.health import check_all_apps_health
|
|
17
|
+
from appmanager.models import AppHealthLog, InstalledApp, MagicLinkToken, Role, User
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def run_health_checks(app=None):
|
|
21
|
+
"""
|
|
22
|
+
Runs health checks across all registered active sub-apps.
|
|
23
|
+
"""
|
|
24
|
+
if app is None:
|
|
25
|
+
app = create_app()
|
|
26
|
+
with app.app_context():
|
|
27
|
+
print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] Starting sub-app health checks...")
|
|
28
|
+
results = check_all_apps_health()
|
|
29
|
+
for slug, status, time_ms in results:
|
|
30
|
+
print(f" -> App '{slug}': Status={status.upper()}, ResponseTime={time_ms:.2f}ms")
|
|
31
|
+
print("Health checks completed successfully.\n")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def run_scheduled_tasks(app=None):
|
|
35
|
+
"""
|
|
36
|
+
Executes sub-app cron routines declared in manifest.json and performs system cleanup.
|
|
37
|
+
Designed to be run as a PythonAnywhere Scheduled Task or system cron.
|
|
38
|
+
"""
|
|
39
|
+
if app is None:
|
|
40
|
+
app = create_app()
|
|
41
|
+
with app.app_context():
|
|
42
|
+
print(
|
|
43
|
+
f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] Executing scheduled tasks & maintenance..."
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
# 1. Run sub-app scheduled cron tasks
|
|
47
|
+
apps = InstalledApp.query.filter_by(is_active=True).all()
|
|
48
|
+
for app_record in apps:
|
|
49
|
+
target_dir = os.path.join(app.config["INSTALLED_APPS_DIR"], app_record.slug)
|
|
50
|
+
if not os.path.exists(target_dir):
|
|
51
|
+
continue
|
|
52
|
+
manifest = parse_manifest(target_dir)
|
|
53
|
+
if not manifest or "scheduled_tasks" not in manifest:
|
|
54
|
+
continue
|
|
55
|
+
|
|
56
|
+
scheduled = manifest["scheduled_tasks"]
|
|
57
|
+
if isinstance(scheduled, list):
|
|
58
|
+
for task_info in scheduled:
|
|
59
|
+
task_name = task_info.get("name", "unnamed_task")
|
|
60
|
+
entry_point = task_info.get("entry_point")
|
|
61
|
+
if not entry_point or ":" not in entry_point:
|
|
62
|
+
continue
|
|
63
|
+
module_name, func_name = entry_point.split(":")
|
|
64
|
+
try:
|
|
65
|
+
load_wsgi_app_from_path(target_dir, app_record.entry_point)
|
|
66
|
+
mod_path = os.path.join(target_dir, f"{module_name}.py")
|
|
67
|
+
if os.path.exists(mod_path):
|
|
68
|
+
import importlib.util
|
|
69
|
+
|
|
70
|
+
spec = importlib.util.spec_from_file_location(
|
|
71
|
+
f"cron_{app_record.slug}_{module_name}", mod_path
|
|
72
|
+
)
|
|
73
|
+
mod = importlib.util.module_from_spec(spec)
|
|
74
|
+
spec.loader.exec_module(mod)
|
|
75
|
+
if hasattr(mod, func_name) and callable(getattr(mod, func_name)):
|
|
76
|
+
print(
|
|
77
|
+
f" -> Executing scheduled task '{task_name}' for app '{app_record.slug}'..."
|
|
78
|
+
)
|
|
79
|
+
getattr(mod, func_name)()
|
|
80
|
+
except Exception as e:
|
|
81
|
+
print(
|
|
82
|
+
f" [ERROR] Scheduled task '{task_name}' in app '{app_record.slug}' failed: {e}"
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
# 2. Run Health Checks
|
|
86
|
+
run_health_checks(app)
|
|
87
|
+
|
|
88
|
+
# 3. Perform maintenance & database cleanup
|
|
89
|
+
now = datetime.now(timezone.utc)
|
|
90
|
+
expired_count = MagicLinkToken.query.filter(MagicLinkToken.expires_at < now).delete()
|
|
91
|
+
|
|
92
|
+
# Keep only latest 100 health logs per app
|
|
93
|
+
for app_record in apps:
|
|
94
|
+
old_logs = (
|
|
95
|
+
AppHealthLog.query.filter_by(app_id=app_record.id)
|
|
96
|
+
.order_by(AppHealthLog.checked_at.desc())
|
|
97
|
+
.offset(100)
|
|
98
|
+
.all()
|
|
99
|
+
)
|
|
100
|
+
for log in old_logs:
|
|
101
|
+
db.session.delete(log)
|
|
102
|
+
|
|
103
|
+
db.session.commit()
|
|
104
|
+
print(f"Maintenance completed. Purged {expired_count} expired tokens.\n")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def list_apps(app=None):
|
|
108
|
+
"""
|
|
109
|
+
Lists all registered applications and their status.
|
|
110
|
+
"""
|
|
111
|
+
if app is None:
|
|
112
|
+
app = create_app()
|
|
113
|
+
with app.app_context():
|
|
114
|
+
apps = InstalledApp.query.all()
|
|
115
|
+
print(f"\nRegistered Applications ({len(apps)} total):")
|
|
116
|
+
print("-" * 65)
|
|
117
|
+
for a in apps:
|
|
118
|
+
latest_health = (
|
|
119
|
+
AppHealthLog.query.filter_by(app_id=a.id)
|
|
120
|
+
.order_by(AppHealthLog.checked_at.desc())
|
|
121
|
+
.first()
|
|
122
|
+
)
|
|
123
|
+
status_str = latest_health.status.upper() if latest_health else "UNKNOWN"
|
|
124
|
+
auth_str = "Protected" if a.requires_auth else "Public"
|
|
125
|
+
default_str = " [DEFAULT]" if a.is_default else ""
|
|
126
|
+
active_str = "Active" if a.is_active else "INACTIVE"
|
|
127
|
+
print(
|
|
128
|
+
f" - [{a.id}] {a.name} (slug: {a.slug}){default_str} | Status: {active_str} | Auth: {auth_str} | Health: {status_str}"
|
|
129
|
+
)
|
|
130
|
+
print("-" * 65 + "\n")
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def set_user_role(email: str, role: str = "admin", app=None) -> int:
|
|
134
|
+
"""
|
|
135
|
+
Elevates or updates a user's role via CLI.
|
|
136
|
+
"""
|
|
137
|
+
if app is None:
|
|
138
|
+
try:
|
|
139
|
+
from flask import current_app
|
|
140
|
+
|
|
141
|
+
app = current_app._get_current_object()
|
|
142
|
+
except Exception:
|
|
143
|
+
app = create_app()
|
|
144
|
+
|
|
145
|
+
with app.app_context():
|
|
146
|
+
clean_email = email.strip().lower()
|
|
147
|
+
user = User.query.filter_by(email=clean_email).first()
|
|
148
|
+
if not user:
|
|
149
|
+
user = User(
|
|
150
|
+
email=clean_email,
|
|
151
|
+
name=clean_email.split("@")[0].capitalize(),
|
|
152
|
+
role=role.strip().lower(),
|
|
153
|
+
is_active=True,
|
|
154
|
+
)
|
|
155
|
+
db.session.add(user)
|
|
156
|
+
db.session.commit()
|
|
157
|
+
print(
|
|
158
|
+
f"✅ Created user '{user.email}' (ID: {user.id}) with role: {user.role.upper()}\n"
|
|
159
|
+
)
|
|
160
|
+
return 0
|
|
161
|
+
|
|
162
|
+
user.role = role.strip().lower()
|
|
163
|
+
db.session.commit()
|
|
164
|
+
print(
|
|
165
|
+
f"✅ Successfully updated user '{user.email}' (ID: {user.id}) role to: {user.role.upper()}\n"
|
|
166
|
+
)
|
|
167
|
+
return 0
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def list_users_cli(app=None) -> int:
|
|
171
|
+
"""
|
|
172
|
+
Lists all registered users and their roles via CLI.
|
|
173
|
+
"""
|
|
174
|
+
if app is None:
|
|
175
|
+
try:
|
|
176
|
+
from flask import current_app
|
|
177
|
+
|
|
178
|
+
app = current_app._get_current_object()
|
|
179
|
+
except Exception:
|
|
180
|
+
app = create_app()
|
|
181
|
+
|
|
182
|
+
with app.app_context():
|
|
183
|
+
users = User.query.order_by(User.created_at.desc()).all()
|
|
184
|
+
print(f"\nRegistered Users ({len(users)} total):")
|
|
185
|
+
print("-" * 75)
|
|
186
|
+
for u in users:
|
|
187
|
+
online_str = "Online" if u.is_online() else "Offline"
|
|
188
|
+
active_str = "Active" if u.is_active else "Suspended"
|
|
189
|
+
print(
|
|
190
|
+
f" - [{u.id}] {u.email} | Name: {u.name or '-'} | Role: {u.role.upper()} | Status: {active_str} ({online_str})"
|
|
191
|
+
)
|
|
192
|
+
print("-" * 75 + "\n")
|
|
193
|
+
return 0
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def list_roles_cli(app=None) -> int:
|
|
197
|
+
"""
|
|
198
|
+
Lists all configured roles and member counts via CLI.
|
|
199
|
+
"""
|
|
200
|
+
if app is None:
|
|
201
|
+
try:
|
|
202
|
+
from flask import current_app
|
|
203
|
+
|
|
204
|
+
app = current_app._get_current_object()
|
|
205
|
+
except Exception:
|
|
206
|
+
app = create_app()
|
|
207
|
+
|
|
208
|
+
with app.app_context():
|
|
209
|
+
roles = Role.query.order_by(Role.is_system.desc(), Role.name.asc()).all()
|
|
210
|
+
print(f"\nConfigured Roles ({len(roles)} total):")
|
|
211
|
+
print("-" * 75)
|
|
212
|
+
for r in roles:
|
|
213
|
+
m_count = User.query.filter_by(role=r.slug).count()
|
|
214
|
+
type_str = "System" if r.is_system else "Custom"
|
|
215
|
+
print(
|
|
216
|
+
f" - [{r.id}] {r.name} (slug: {r.slug}) | Type: {type_str} | Members: {m_count} | {r.description or ''}"
|
|
217
|
+
)
|
|
218
|
+
print("-" * 75 + "\n")
|
|
219
|
+
return 0
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def create_role_cli(name: str, slug: str = None, description: str = None, app=None) -> int:
|
|
223
|
+
"""
|
|
224
|
+
Creates a new custom role via CLI.
|
|
225
|
+
"""
|
|
226
|
+
if app is None:
|
|
227
|
+
try:
|
|
228
|
+
from flask import current_app
|
|
229
|
+
|
|
230
|
+
app = current_app._get_current_object()
|
|
231
|
+
except Exception:
|
|
232
|
+
app = create_app()
|
|
233
|
+
|
|
234
|
+
with app.app_context():
|
|
235
|
+
clean_slug = sanitize_slug(slug or name)
|
|
236
|
+
existing = Role.query.filter((Role.slug == clean_slug) | (Role.name == name)).first()
|
|
237
|
+
if existing:
|
|
238
|
+
print(f"❌ Role with name '{name}' or slug '{clean_slug}' already exists.\n")
|
|
239
|
+
return 1
|
|
240
|
+
|
|
241
|
+
new_role = Role(name=name, slug=clean_slug, description=description or "", is_system=False)
|
|
242
|
+
db.session.add(new_role)
|
|
243
|
+
db.session.commit()
|
|
244
|
+
print(f"✅ Successfully created role '{new_role.name}' (slug: {new_role.slug})\n")
|
|
245
|
+
return 0
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def seed_db(app=None):
|
|
249
|
+
"""
|
|
250
|
+
Seeds database with initial reference apps and extensions.
|
|
251
|
+
"""
|
|
252
|
+
if app is None:
|
|
253
|
+
app = create_app()
|
|
254
|
+
with app.app_context():
|
|
255
|
+
counter_app = InstalledApp.query.filter_by(slug="sample-counter").first()
|
|
256
|
+
if not counter_app:
|
|
257
|
+
counter_app = InstalledApp(
|
|
258
|
+
name="Counter Sub-App",
|
|
259
|
+
slug="sample-counter",
|
|
260
|
+
description="Interactive Flask counter demonstration app",
|
|
261
|
+
source_type="zip",
|
|
262
|
+
source_url="sample-counter.zip",
|
|
263
|
+
entry_point="app:app",
|
|
264
|
+
is_active=True,
|
|
265
|
+
)
|
|
266
|
+
db.session.add(counter_app)
|
|
267
|
+
print("[SEED] Registered sample-counter app in database.")
|
|
268
|
+
|
|
269
|
+
template_app = InstalledApp.query.filter_by(slug="template-app").first()
|
|
270
|
+
if not template_app:
|
|
271
|
+
template_app = InstalledApp(
|
|
272
|
+
name="Template Reference App",
|
|
273
|
+
slug="template-app",
|
|
274
|
+
description="Standardized Flask template application demonstrating health check, bridge telemetry, and scheduled cron task integration.",
|
|
275
|
+
source_type="zip",
|
|
276
|
+
source_url="template-app.zip",
|
|
277
|
+
entry_point="app:app",
|
|
278
|
+
is_active=True,
|
|
279
|
+
)
|
|
280
|
+
db.session.add(template_app)
|
|
281
|
+
print("[SEED] Registered template-app in database.")
|
|
282
|
+
|
|
283
|
+
extension_app = InstalledApp.query.filter_by(slug="extension-flairs").first()
|
|
284
|
+
if not extension_app:
|
|
285
|
+
extension_app = InstalledApp(
|
|
286
|
+
name="User Flairs Extension",
|
|
287
|
+
slug="extension-flairs",
|
|
288
|
+
description="Adds customizable user titles, flairs, and badges to member profiles and admin views.",
|
|
289
|
+
source_type="zip",
|
|
290
|
+
source_url="extension-flairs.zip",
|
|
291
|
+
entry_point="extension:extension",
|
|
292
|
+
app_type="extension",
|
|
293
|
+
target_app="appmanager",
|
|
294
|
+
has_web_ui=False,
|
|
295
|
+
is_active=True,
|
|
296
|
+
)
|
|
297
|
+
db.session.add(extension_app)
|
|
298
|
+
print("[SEED] Registered extension-flairs in database.")
|
|
299
|
+
else:
|
|
300
|
+
extension_app.app_type = "extension"
|
|
301
|
+
extension_app.target_app = "appmanager"
|
|
302
|
+
extension_app.has_web_ui = False
|
|
303
|
+
print("[SEED] Updated extension-flairs DB record.")
|
|
304
|
+
|
|
305
|
+
db.session.commit()
|
|
306
|
+
print("[SEED] Database seeding complete.\n")
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def init_project():
|
|
310
|
+
"""
|
|
311
|
+
Initializes local directory with installed_apps/ folder and .env template if not present.
|
|
312
|
+
"""
|
|
313
|
+
os.makedirs("installed_apps", exist_ok=True)
|
|
314
|
+
print("Created ./installed_apps/ directory.")
|
|
315
|
+
if not os.path.exists(".env"):
|
|
316
|
+
env_sample = (
|
|
317
|
+
"# AppManager Environment Configuration\n"
|
|
318
|
+
"SECRET_KEY=dev-secret-key-change-in-production-min-32-chars\n"
|
|
319
|
+
"JWT_SECRET=dev-jwt-secret-key-change-in-production-min-32-chars\n"
|
|
320
|
+
"APP_BASE_URL=http://localhost:5000\n"
|
|
321
|
+
"PORT=5000\n"
|
|
322
|
+
)
|
|
323
|
+
with open(".env", "w") as f:
|
|
324
|
+
f.write(env_sample)
|
|
325
|
+
print("Created default .env file.")
|
|
326
|
+
print("Initialization complete. Run `appmanager seed` followed by `appmanager run` to start.")
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def new_subapp(name: str, slug: str = None, output_dir: str = None, template: str = "basic"):
|
|
330
|
+
"""
|
|
331
|
+
Scaffolds a new AppManager sub-application or extension.
|
|
332
|
+
Templates: 'basic', 'api', 'extension', 'htmx', 'full'
|
|
333
|
+
"""
|
|
334
|
+
slug = sanitize_slug(slug or name)
|
|
335
|
+
dest_dir = output_dir or os.path.join("installed_apps", slug)
|
|
336
|
+
os.makedirs(dest_dir, exist_ok=True)
|
|
337
|
+
|
|
338
|
+
manifest_data = {
|
|
339
|
+
"name": name,
|
|
340
|
+
"slug": slug,
|
|
341
|
+
"version": "1.0.0",
|
|
342
|
+
"description": f"Standardized sub-application for {name}.",
|
|
343
|
+
"entry_point": "app:app",
|
|
344
|
+
"health_check_path": "/health",
|
|
345
|
+
"app_type": "extension" if template == "extension" else "standalone",
|
|
346
|
+
"has_web_ui": True,
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
if template == "api":
|
|
350
|
+
manifest_data["description"] = f"RESTful JSON API backend for {name}."
|
|
351
|
+
manifest_data["entry_point"] = "app:app"
|
|
352
|
+
app_code = f"""from flask import Flask, jsonify, request
|
|
353
|
+
from appmanager.sdk import AppManagerClient
|
|
354
|
+
|
|
355
|
+
app = Flask(__name__)
|
|
356
|
+
client = AppManagerClient('{slug}')
|
|
357
|
+
|
|
358
|
+
@app.route('/')
|
|
359
|
+
def root():
|
|
360
|
+
user = client.get_current_user(request.headers)
|
|
361
|
+
return jsonify({{
|
|
362
|
+
'message': 'Welcome to {name} API',
|
|
363
|
+
'app': '{slug}',
|
|
364
|
+
'user': user,
|
|
365
|
+
'endpoints': ['/api/v1/status', '/api/v1/metrics']
|
|
366
|
+
}})
|
|
367
|
+
|
|
368
|
+
@app.route('/api/v1/status')
|
|
369
|
+
def status():
|
|
370
|
+
return jsonify({{'status': 'operational', 'app': '{slug}', 'version': '1.0.0'}})
|
|
371
|
+
|
|
372
|
+
@app.route('/api/v1/events', methods=['POST'])
|
|
373
|
+
def record_event():
|
|
374
|
+
payload = request.get_json(silent=True) or {{}}
|
|
375
|
+
client.report_event('custom_api_event', payload)
|
|
376
|
+
return jsonify({{'recorded': True, 'data': payload}})
|
|
377
|
+
|
|
378
|
+
@app.route('/health')
|
|
379
|
+
def health():
|
|
380
|
+
return jsonify({{'status': 'healthy', 'app': '{slug}'}})
|
|
381
|
+
|
|
382
|
+
if __name__ == '__main__':
|
|
383
|
+
app.run(port=5001, debug=True)
|
|
384
|
+
"""
|
|
385
|
+
|
|
386
|
+
elif template == "extension":
|
|
387
|
+
manifest_data["target_app"] = "appmanager"
|
|
388
|
+
manifest_data["ui_slots"] = ["dashboard_widget", "user_badge"]
|
|
389
|
+
manifest_data["settings"] = {
|
|
390
|
+
"banner_text": {
|
|
391
|
+
"type": "string",
|
|
392
|
+
"default": f"Welcome to {name}!",
|
|
393
|
+
"description": "Message displayed on the host dashboard widget.",
|
|
394
|
+
},
|
|
395
|
+
"accent_color": {
|
|
396
|
+
"type": "string",
|
|
397
|
+
"default": "#38bdf8",
|
|
398
|
+
"description": "Primary accent color hex.",
|
|
399
|
+
},
|
|
400
|
+
}
|
|
401
|
+
app_code = f'''from flask import Flask, jsonify, render_template_string, request
|
|
402
|
+
from markupsafe import Markup
|
|
403
|
+
from appmanager.sdk import AppManagerClient
|
|
404
|
+
|
|
405
|
+
app = Flask(__name__)
|
|
406
|
+
client = AppManagerClient('{slug}')
|
|
407
|
+
|
|
408
|
+
def render_dashboard_widget(user=None):
|
|
409
|
+
banner = client.get_setting('banner_text', default='Welcome to {name}!')
|
|
410
|
+
color = client.get_setting('accent_color', default='#38bdf8')
|
|
411
|
+
html = f"""
|
|
412
|
+
<div style="background: #1e293b; border: 1px solid #334155; border-radius: 12px; padding: 1.5rem; margin-bottom: 1.5rem; display: flex; align-items: center; justify-content: space-between;">
|
|
413
|
+
<div>
|
|
414
|
+
<div style="font-weight: 700; font-size: 1.1rem; color: {{color}};">✨ {{banner}}</div>
|
|
415
|
+
<div style="color: #94a3b8; font-size: 0.85rem; margin-top: 0.25rem;">Custom extension widget mounted via AppManager Hook System.</div>
|
|
416
|
+
</div>
|
|
417
|
+
<a href="/apps/{slug}/" style="background: #6366f1; color: white; padding: 0.5rem 1rem; border-radius: 8px; text-decoration: none; font-size: 0.85rem; font-weight: 600;">Manage Extension →</a>
|
|
418
|
+
</div>
|
|
419
|
+
"""
|
|
420
|
+
return Markup(html)
|
|
421
|
+
|
|
422
|
+
client.register_slot('dashboard_widget', render_dashboard_widget, priority=5)
|
|
423
|
+
|
|
424
|
+
@app.route('/')
|
|
425
|
+
def index():
|
|
426
|
+
user = client.get_current_user(request.headers)
|
|
427
|
+
banner = client.get_setting('banner_text', default='Welcome to {name}!')
|
|
428
|
+
return f"""
|
|
429
|
+
<!DOCTYPE html>
|
|
430
|
+
<html>
|
|
431
|
+
<head>
|
|
432
|
+
<title>{name} Extension</title>
|
|
433
|
+
<style>
|
|
434
|
+
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0f172a; color: white; padding: 40px; display: flex; justify-content: center; }}
|
|
435
|
+
.card {{ background: #1e293b; border-radius: 12px; padding: 32px; max-width: 600px; width: 100%; border: 1px solid #334155; }}
|
|
436
|
+
h1 {{ color: #38bdf8; }}
|
|
437
|
+
</style>
|
|
438
|
+
</head>
|
|
439
|
+
<body>
|
|
440
|
+
<div class="card">
|
|
441
|
+
<h1>🧩 {name} Extension</h1>
|
|
442
|
+
<p>Active banner: <strong>{{banner}}</strong></p>
|
|
443
|
+
<p>Current user: <strong>{{user.get('email') if user else 'Anonymous'}}</strong></p>
|
|
444
|
+
<br>
|
|
445
|
+
<a href="/" style="color: #94a3b8; text-decoration: none;">← Return to Dashboard</a>
|
|
446
|
+
</div>
|
|
447
|
+
</body>
|
|
448
|
+
</html>
|
|
449
|
+
"""
|
|
450
|
+
|
|
451
|
+
@app.route('/health')
|
|
452
|
+
def health():
|
|
453
|
+
return jsonify({{'status': 'healthy', 'extension': '{slug}'}})
|
|
454
|
+
'''
|
|
455
|
+
|
|
456
|
+
elif template == "htmx":
|
|
457
|
+
app_code = f'''from flask import Flask, jsonify, render_template_string, request
|
|
458
|
+
from appmanager.sdk import AppManagerClient
|
|
459
|
+
|
|
460
|
+
app = Flask(__name__)
|
|
461
|
+
client = AppManagerClient('{slug}')
|
|
462
|
+
|
|
463
|
+
HTMX_TEMPLATE = """
|
|
464
|
+
<!DOCTYPE html>
|
|
465
|
+
<html>
|
|
466
|
+
<head>
|
|
467
|
+
<title>{name}</title>
|
|
468
|
+
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
|
469
|
+
<style>
|
|
470
|
+
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0f172a; color: white; display: flex; justify-content: center; padding: 40px 20px; margin: 0; }}
|
|
471
|
+
.card {{ background: #1e293b; border-radius: 16px; padding: 32px; max-width: 640px; width: 100%; border: 1px solid #334155; }}
|
|
472
|
+
h1 {{ color: #38bdf8; margin-top: 0; }}
|
|
473
|
+
.box {{ background: #0f172a; border: 1px solid #334155; padding: 16px; border-radius: 8px; margin: 16px 0; }}
|
|
474
|
+
.btn {{ background: #6366f1; color: white; border: none; padding: 8px 16px; border-radius: 6px; cursor: pointer; font-weight: 600; }}
|
|
475
|
+
.btn:hover {{ background: #4f46e5; }}
|
|
476
|
+
</style>
|
|
477
|
+
</head>
|
|
478
|
+
<body>
|
|
479
|
+
<div class="card">
|
|
480
|
+
<h1>⚡ {name} (HTMX Reactive)</h1>
|
|
481
|
+
<p>Interactive, server-driven reactive UI powered by HTMX.</p>
|
|
482
|
+
|
|
483
|
+
<div id="live-area" class="box">
|
|
484
|
+
<p>Click below to fetch dynamic live data from server without full reload:</p>
|
|
485
|
+
<button class="btn" hx-get="live-data" hx-target="#live-area" hx-swap="innerHTML">Load Server Telemetry</button>
|
|
486
|
+
</div>
|
|
487
|
+
|
|
488
|
+
<br>
|
|
489
|
+
<a href="/" style="color: #94a3b8; font-size: 0.9rem; text-decoration: none;">← Return to AppManager Portal</a>
|
|
490
|
+
</div>
|
|
491
|
+
</body>
|
|
492
|
+
</html>
|
|
493
|
+
"""
|
|
494
|
+
|
|
495
|
+
@app.route('/')
|
|
496
|
+
def index():
|
|
497
|
+
return render_template_string(HTMX_TEMPLATE)
|
|
498
|
+
|
|
499
|
+
@app.route('/live-data')
|
|
500
|
+
def live_data():
|
|
501
|
+
from datetime import datetime
|
|
502
|
+
client.report_event('htmx_data_refresh')
|
|
503
|
+
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
504
|
+
return f"""
|
|
505
|
+
<div style="color: #4ade80; font-weight: 600;">✅ Server Response Generated at {{now}}</div>
|
|
506
|
+
<p style="color: #94a3b8; font-size: 0.9rem;">Telemetric event dispatched to AppManager host.</p>
|
|
507
|
+
<button class="btn" hx-get="live-data" hx-target="#live-area" hx-swap="innerHTML">Refresh Again</button>
|
|
508
|
+
"""
|
|
509
|
+
|
|
510
|
+
@app.route('/health')
|
|
511
|
+
def health():
|
|
512
|
+
return jsonify({{'status': 'healthy', 'app': '{slug}', 'type': 'htmx'}})
|
|
513
|
+
'''
|
|
514
|
+
|
|
515
|
+
else:
|
|
516
|
+
# 'basic' or 'full' template
|
|
517
|
+
if template == "full":
|
|
518
|
+
manifest_data["scheduled_tasks"] = [
|
|
519
|
+
{"name": "daily_cleanup", "entry_point": "tasks:run_cleanup", "frequency": "daily"}
|
|
520
|
+
]
|
|
521
|
+
|
|
522
|
+
app_code = f'''from flask import Flask, jsonify, render_template_string, request
|
|
523
|
+
from appmanager_sdk import AppManagerClient, AppManifest, Setting
|
|
524
|
+
|
|
525
|
+
app = Flask(__name__)
|
|
526
|
+
client = AppManagerClient('{slug}')
|
|
527
|
+
|
|
528
|
+
# Declarative Sub-App Manifest
|
|
529
|
+
manifest = AppManifest(
|
|
530
|
+
name="{name}",
|
|
531
|
+
slug="{slug}",
|
|
532
|
+
version="1.0.0",
|
|
533
|
+
description="A modern sub-application for AppManager.",
|
|
534
|
+
entry_point="app:app",
|
|
535
|
+
health_check_path="/health",
|
|
536
|
+
has_web_ui=True,
|
|
537
|
+
requires_auth=True,
|
|
538
|
+
ui_slots=["dashboard_widget"]
|
|
539
|
+
)
|
|
540
|
+
|
|
541
|
+
INDEX_HTML = """
|
|
542
|
+
<!DOCTYPE html>
|
|
543
|
+
<html>
|
|
544
|
+
<head>
|
|
545
|
+
<title>{name}</title>
|
|
546
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
547
|
+
<style>
|
|
548
|
+
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0f172a; color: #f8fafc; margin: 0; padding: 40px 20px; display: flex; justify-content: center; }}
|
|
549
|
+
.card {{ background: #1e293b; border-radius: 12px; padding: 32px; max-width: 600px; width: 100%; border: 1px solid #334155; }}
|
|
550
|
+
h1 {{ margin-top: 0; color: #38bdf8; }}
|
|
551
|
+
.badge {{ background: #334155; padding: 4px 8px; border-radius: 6px; font-size: 0.85rem; }}
|
|
552
|
+
.btn {{ background: #0284c7; color: white; border: none; padding: 10px 20px; border-radius: 6px; cursor: pointer; text-decoration: none; display: inline-block; margin-top: 16px; font-weight: 600; }}
|
|
553
|
+
.btn:hover {{ background: #0369a1; }}
|
|
554
|
+
</style>
|
|
555
|
+
</head>
|
|
556
|
+
<body>
|
|
557
|
+
<div class="card">
|
|
558
|
+
<h1>🚀 {name}</h1>
|
|
559
|
+
<p>This sub-app is running under AppManager at <span class="badge">/apps/{slug}/</span></p>
|
|
560
|
+
{{% if user %}}
|
|
561
|
+
<p>Logged in as: <strong>{{{{ user.email }}}}</strong> (Role: {{{{ user.role }}}})</p>
|
|
562
|
+
{{% else %}}
|
|
563
|
+
<p>Browsing anonymously.</p>
|
|
564
|
+
{{% endif %}}
|
|
565
|
+
<a href="/" class="btn">Return to Portal</a>
|
|
566
|
+
</div>
|
|
567
|
+
</body>
|
|
568
|
+
</html>
|
|
569
|
+
"""
|
|
570
|
+
|
|
571
|
+
@app.route('/')
|
|
572
|
+
def index():
|
|
573
|
+
user = client.get_current_user(request.headers)
|
|
574
|
+
client.report_event('page_view', {{'path': '/'}})
|
|
575
|
+
return render_template_string(INDEX_HTML, user=user)
|
|
576
|
+
|
|
577
|
+
@app.route('/health')
|
|
578
|
+
def health():
|
|
579
|
+
return jsonify({{'status': 'healthy', 'app': '{slug}', 'version': '1.0.0'}})
|
|
580
|
+
|
|
581
|
+
if __name__ == '__main__':
|
|
582
|
+
import sys
|
|
583
|
+
if '--generate-manifest' in sys.argv or 'generate-manifest' in sys.argv:
|
|
584
|
+
manifest.cli()
|
|
585
|
+
else:
|
|
586
|
+
app.run(port=5001, debug=True)
|
|
587
|
+
'''
|
|
588
|
+
|
|
589
|
+
# Write manifest.json
|
|
590
|
+
with open(os.path.join(dest_dir, "manifest.json"), "w", encoding="utf-8") as f:
|
|
591
|
+
json.dump(manifest_data, f, indent=2)
|
|
592
|
+
|
|
593
|
+
# Write app.py
|
|
594
|
+
with open(os.path.join(dest_dir, "app.py"), "w", encoding="utf-8") as f:
|
|
595
|
+
f.write(app_code)
|
|
596
|
+
|
|
597
|
+
# Write requirements.txt
|
|
598
|
+
with open(os.path.join(dest_dir, "requirements.txt"), "w", encoding="utf-8") as f:
|
|
599
|
+
f.write("Flask>=3.0.0\nappmanager-sdk>=0.1.0\n")
|
|
600
|
+
|
|
601
|
+
# If template == 'full', write tasks.py
|
|
602
|
+
if template == "full":
|
|
603
|
+
tasks_code = """def run_cleanup():
|
|
604
|
+
print("[TASK] Running background cleanup task...")
|
|
605
|
+
"""
|
|
606
|
+
with open(os.path.join(dest_dir, "tasks.py"), "w", encoding="utf-8") as f:
|
|
607
|
+
f.write(tasks_code)
|
|
608
|
+
|
|
609
|
+
print(f"\n✅ Created new sub-app '{name}' (template: {template}) at: {dest_dir}")
|
|
610
|
+
print(f" Manifest: {os.path.join(dest_dir, 'manifest.json')}")
|
|
611
|
+
print(f" Entry: {os.path.join(dest_dir, 'app.py')}\n")
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def dev_subapp(
|
|
615
|
+
slug: str,
|
|
616
|
+
host: str = "127.0.0.1",
|
|
617
|
+
port: int = 5001,
|
|
618
|
+
user_email: str = "admin@example.com",
|
|
619
|
+
user_role: str = "admin",
|
|
620
|
+
) -> int:
|
|
621
|
+
"""
|
|
622
|
+
Runs a standalone local development server for a specific sub-app with mock user headers.
|
|
623
|
+
"""
|
|
624
|
+
from werkzeug.serving import run_simple
|
|
625
|
+
|
|
626
|
+
target_dir = os.path.join("installed_apps", slug)
|
|
627
|
+
if not os.path.exists(target_dir):
|
|
628
|
+
print(f"❌ Sub-app directory '{target_dir}' not found.")
|
|
629
|
+
return 1
|
|
630
|
+
|
|
631
|
+
manifest = parse_manifest(target_dir) or {}
|
|
632
|
+
entry_point = manifest.get("entry_point", "app:app")
|
|
633
|
+
|
|
634
|
+
print("\n=======================================================")
|
|
635
|
+
print(f" AppManager Dev Server: '{slug}'")
|
|
636
|
+
print(f" Running at: http://{host}:{port}/")
|
|
637
|
+
print(f" Mock User: {user_email} (Role: {user_role})")
|
|
638
|
+
print("=======================================================\n")
|
|
639
|
+
|
|
640
|
+
sub_app_obj = load_wsgi_app_from_path(target_dir, entry_point)
|
|
641
|
+
raw_wsgi = getattr(sub_app_obj, "wsgi_app", sub_app_obj)
|
|
642
|
+
|
|
643
|
+
def dev_mock_wrapper(environ, start_response):
|
|
644
|
+
environ["HTTP_X_APPMANAGER_USER_ID"] = "1"
|
|
645
|
+
environ["HTTP_X_APPMANAGER_USER_EMAIL"] = user_email
|
|
646
|
+
environ["HTTP_X_APPMANAGER_USER_ROLE"] = user_role
|
|
647
|
+
environ["HTTP_X_APPMANAGER_SUBAPP_SLUG"] = slug
|
|
648
|
+
environ["APPMANAGER_SUBAPP_SLUG"] = slug
|
|
649
|
+
return raw_wsgi(environ, start_response)
|
|
650
|
+
|
|
651
|
+
run_simple(host, port, dev_mock_wrapper, use_reloader=True, use_debugger=True)
|
|
652
|
+
return 0
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def list_hooks_cli() -> int:
|
|
656
|
+
"""
|
|
657
|
+
Lists registered UI slots and hooks across AppManager and active extensions.
|
|
658
|
+
"""
|
|
659
|
+
from appmanager.hooks import hooks
|
|
660
|
+
|
|
661
|
+
slots = hooks.get_registered_slots()
|
|
662
|
+
hks = hooks.get_registered_hooks()
|
|
663
|
+
|
|
664
|
+
print("\n--- AppManager Registered UI Slots ---")
|
|
665
|
+
if not slots:
|
|
666
|
+
print(" (No UI slots currently registered)")
|
|
667
|
+
else:
|
|
668
|
+
for slot_name, entries in slots.items():
|
|
669
|
+
print(f" • Slot: '{slot_name}' ({len(entries)} listener(s))")
|
|
670
|
+
for e in entries:
|
|
671
|
+
print(f" -> App: {e.get('app_slug') or 'core'}, Priority: {e.get('priority')}")
|
|
672
|
+
|
|
673
|
+
print("\n--- AppManager Registered Lifecycle Hooks ---")
|
|
674
|
+
if not hks:
|
|
675
|
+
print(" (No lifecycle hooks currently registered)")
|
|
676
|
+
else:
|
|
677
|
+
for hook_name, entries in hks.items():
|
|
678
|
+
print(f" • Hook: '{hook_name}' ({len(entries)} listener(s))")
|
|
679
|
+
for e in entries:
|
|
680
|
+
print(f" -> App: {e.get('app_slug') or 'core'}, Priority: {e.get('priority')}")
|
|
681
|
+
print("")
|
|
682
|
+
return 0
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
def validate_subapp_cli(path_or_zip: str) -> int:
|
|
686
|
+
"""
|
|
687
|
+
CLI command to validate a sub-app directory or zip file.
|
|
688
|
+
"""
|
|
689
|
+
print(f"\n🔍 Validating sub-app at: {path_or_zip} ...")
|
|
690
|
+
is_valid, errors, manifest = validate_subapp_package(path_or_zip)
|
|
691
|
+
if is_valid:
|
|
692
|
+
print("✅ PASSED: Package is valid.")
|
|
693
|
+
print(f" Name: {manifest.get('name')}")
|
|
694
|
+
print(f" Slug: {manifest.get('slug')}")
|
|
695
|
+
print(f" Entry Point: {manifest.get('entry_point', 'app:app')}")
|
|
696
|
+
print(f" Health Path: {manifest.get('health_check_path', '/health')}\n")
|
|
697
|
+
return 0
|
|
698
|
+
else:
|
|
699
|
+
print("❌ FAILED: Validation errors found:")
|
|
700
|
+
for err in errors:
|
|
701
|
+
print(f" - {err}")
|
|
702
|
+
print("")
|
|
703
|
+
return 1
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
def export_subapp_cli(slug: str, output: str = None, app=None) -> int:
|
|
707
|
+
"""
|
|
708
|
+
CLI command to export an installed sub-app to a distributable zip.
|
|
709
|
+
"""
|
|
710
|
+
if app is None:
|
|
711
|
+
try:
|
|
712
|
+
from flask import current_app
|
|
713
|
+
|
|
714
|
+
app = current_app._get_current_object()
|
|
715
|
+
except Exception:
|
|
716
|
+
app = create_app()
|
|
717
|
+
|
|
718
|
+
with app.app_context():
|
|
719
|
+
try:
|
|
720
|
+
zip_path = export_app_to_zip(slug, output_path=output)
|
|
721
|
+
print(f"✅ Successfully exported '{slug}' to: {zip_path}\n")
|
|
722
|
+
return 0
|
|
723
|
+
except Exception as e:
|
|
724
|
+
print(f"❌ Export failed: {e}\n")
|
|
725
|
+
return 1
|
|
726
|
+
|
|
727
|
+
|
|
728
|
+
def print_security_scan_results(scan_report) -> None:
|
|
729
|
+
print("\n=======================================================")
|
|
730
|
+
print(" 🛡️ AppManager Security Pre-Check Audit")
|
|
731
|
+
print(f" Status: [{scan_report.risk_level}]")
|
|
732
|
+
print(
|
|
733
|
+
f" Files Scanned: {scan_report.files_scanned} ({scan_report.py_files_scanned} Python files)"
|
|
734
|
+
)
|
|
735
|
+
print(f" Findings: {len(scan_report.findings)}")
|
|
736
|
+
print(f" Summary: {scan_report.summary}")
|
|
737
|
+
print("=======================================================")
|
|
738
|
+
if not scan_report.findings:
|
|
739
|
+
print(" ✅ Clean AST & dependency scan. No vulnerabilities found.\n")
|
|
740
|
+
else:
|
|
741
|
+
print("\n Detailed Findings:")
|
|
742
|
+
for idx, f in enumerate(scan_report.findings, start=1):
|
|
743
|
+
sev_tag = f"[{f.severity}]"
|
|
744
|
+
loc = f"{f.file or 'unknown'}:{f.line}" if f.line else f"{f.file or 'unknown'}"
|
|
745
|
+
print(f" {idx}. {sev_tag:<10} {f.category} ({loc})")
|
|
746
|
+
print(f" Issue: {f.message}")
|
|
747
|
+
if f.snippet:
|
|
748
|
+
print(f" Snippet: {f.snippet}")
|
|
749
|
+
print("")
|
|
750
|
+
|
|
751
|
+
|
|
752
|
+
def install_git_cli(
|
|
753
|
+
repo_url: str,
|
|
754
|
+
name: str = None,
|
|
755
|
+
slug: str = None,
|
|
756
|
+
entry_point: str = None,
|
|
757
|
+
yes: bool = False,
|
|
758
|
+
app=None,
|
|
759
|
+
) -> int:
|
|
760
|
+
"""
|
|
761
|
+
CLI command to pull and install a sub-app from a Git repository with security pre-checks.
|
|
762
|
+
"""
|
|
763
|
+
if app is None:
|
|
764
|
+
try:
|
|
765
|
+
from flask import current_app
|
|
766
|
+
|
|
767
|
+
app = current_app._get_current_object()
|
|
768
|
+
except Exception:
|
|
769
|
+
app = create_app()
|
|
770
|
+
|
|
771
|
+
with app.app_context():
|
|
772
|
+
from appmanager.admin.app_installer import (
|
|
773
|
+
cancel_staged_app,
|
|
774
|
+
finalize_staged_installation,
|
|
775
|
+
stage_git_repo,
|
|
776
|
+
)
|
|
777
|
+
|
|
778
|
+
print(f"\n🚀 Staging and pre-checking Git repository: {repo_url} ...")
|
|
779
|
+
try:
|
|
780
|
+
fallback_name = name or os.path.basename(repo_url.rstrip("/")).replace(".git", "")
|
|
781
|
+
staging_id, scan_report, manifest = stage_git_repo(
|
|
782
|
+
repo_url=repo_url,
|
|
783
|
+
name=fallback_name,
|
|
784
|
+
slug=slug,
|
|
785
|
+
entry_point=entry_point,
|
|
786
|
+
)
|
|
787
|
+
except Exception as e:
|
|
788
|
+
print(f"❌ Failed to clone and stage repository: {e}")
|
|
789
|
+
return 1
|
|
790
|
+
|
|
791
|
+
print_security_scan_results(scan_report)
|
|
792
|
+
|
|
793
|
+
if not yes:
|
|
794
|
+
try:
|
|
795
|
+
ans = (
|
|
796
|
+
input("Pre-installation checks complete. Proceed with installation? [y/N]: ")
|
|
797
|
+
.strip()
|
|
798
|
+
.lower()
|
|
799
|
+
)
|
|
800
|
+
except (KeyboardInterrupt, EOFError):
|
|
801
|
+
ans = "n"
|
|
802
|
+
if ans != "y":
|
|
803
|
+
cancel_staged_app(staging_id)
|
|
804
|
+
print("🚫 Installation cancelled by user.\n")
|
|
805
|
+
return 1
|
|
806
|
+
|
|
807
|
+
try:
|
|
808
|
+
installed = finalize_staged_installation(
|
|
809
|
+
staging_id=staging_id,
|
|
810
|
+
name=name,
|
|
811
|
+
slug=slug,
|
|
812
|
+
entry_point=entry_point,
|
|
813
|
+
)
|
|
814
|
+
print(f"✅ Successfully installed '{installed.name}' (/apps/{installed.slug})\n")
|
|
815
|
+
return 0
|
|
816
|
+
except Exception as err:
|
|
817
|
+
print(f"❌ Installation failed: {err}\n")
|
|
818
|
+
return 1
|
|
819
|
+
|
|
820
|
+
|
|
821
|
+
def install_zip_cli(
|
|
822
|
+
zip_path: str,
|
|
823
|
+
name: str = None,
|
|
824
|
+
slug: str = None,
|
|
825
|
+
entry_point: str = None,
|
|
826
|
+
yes: bool = False,
|
|
827
|
+
app=None,
|
|
828
|
+
) -> int:
|
|
829
|
+
"""
|
|
830
|
+
CLI command to install a sub-app from a local ZIP archive with security pre-checks.
|
|
831
|
+
"""
|
|
832
|
+
if not os.path.exists(zip_path):
|
|
833
|
+
print(f"❌ ZIP file not found: {zip_path}")
|
|
834
|
+
return 1
|
|
835
|
+
|
|
836
|
+
if app is None:
|
|
837
|
+
try:
|
|
838
|
+
from flask import current_app
|
|
839
|
+
|
|
840
|
+
app = current_app._get_current_object()
|
|
841
|
+
except Exception:
|
|
842
|
+
app = create_app()
|
|
843
|
+
|
|
844
|
+
with app.app_context():
|
|
845
|
+
from werkzeug.datastructures import FileStorage
|
|
846
|
+
|
|
847
|
+
from appmanager.admin.app_installer import (
|
|
848
|
+
cancel_staged_app,
|
|
849
|
+
finalize_staged_installation,
|
|
850
|
+
stage_zip_file,
|
|
851
|
+
)
|
|
852
|
+
|
|
853
|
+
print(f"\n🚀 Staging and pre-checking ZIP package: {zip_path} ...")
|
|
854
|
+
fallback_name = name or os.path.splitext(os.path.basename(zip_path))[0]
|
|
855
|
+
|
|
856
|
+
try:
|
|
857
|
+
with open(zip_path, "rb") as f:
|
|
858
|
+
fs = FileStorage(stream=f, filename=os.path.basename(zip_path))
|
|
859
|
+
staging_id, scan_report, manifest = stage_zip_file(
|
|
860
|
+
zip_file_storage=fs,
|
|
861
|
+
name=fallback_name,
|
|
862
|
+
slug=slug,
|
|
863
|
+
entry_point=entry_point,
|
|
864
|
+
)
|
|
865
|
+
except Exception as e:
|
|
866
|
+
print(f"❌ Failed to extract and stage ZIP package: {e}")
|
|
867
|
+
return 1
|
|
868
|
+
|
|
869
|
+
print_security_scan_results(scan_report)
|
|
870
|
+
|
|
871
|
+
if not yes:
|
|
872
|
+
try:
|
|
873
|
+
ans = (
|
|
874
|
+
input("Pre-installation checks complete. Proceed with installation? [y/N]: ")
|
|
875
|
+
.strip()
|
|
876
|
+
.lower()
|
|
877
|
+
)
|
|
878
|
+
except (KeyboardInterrupt, EOFError):
|
|
879
|
+
ans = "n"
|
|
880
|
+
if ans != "y":
|
|
881
|
+
cancel_staged_app(staging_id)
|
|
882
|
+
print("🚫 Installation cancelled by user.\n")
|
|
883
|
+
return 1
|
|
884
|
+
|
|
885
|
+
try:
|
|
886
|
+
installed = finalize_staged_installation(
|
|
887
|
+
staging_id=staging_id,
|
|
888
|
+
name=name,
|
|
889
|
+
slug=slug,
|
|
890
|
+
entry_point=entry_point,
|
|
891
|
+
)
|
|
892
|
+
print(f"✅ Successfully installed '{installed.name}' (/apps/{installed.slug})\n")
|
|
893
|
+
return 0
|
|
894
|
+
except Exception as err:
|
|
895
|
+
print(f"❌ Installation failed: {err}\n")
|
|
896
|
+
return 1
|
|
897
|
+
|
|
898
|
+
|
|
899
|
+
def reload_subapp_cli(slug: str, app=None) -> int:
|
|
900
|
+
"""
|
|
901
|
+
Clears cache for a sub-app.
|
|
902
|
+
"""
|
|
903
|
+
if app is None:
|
|
904
|
+
try:
|
|
905
|
+
from flask import current_app
|
|
906
|
+
|
|
907
|
+
app = current_app._get_current_object()
|
|
908
|
+
except Exception:
|
|
909
|
+
app = create_app()
|
|
910
|
+
|
|
911
|
+
if hasattr(app, "extensions") and "appmanager" in app.extensions:
|
|
912
|
+
app.extensions["appmanager"].clear_cache(slug=slug)
|
|
913
|
+
print(f"✅ In-memory cache cleared for sub-app '{slug}'.\n")
|
|
914
|
+
return 0
|
|
915
|
+
|
|
916
|
+
|
|
917
|
+
def run_server(host="0.0.0.0", port=5000, reload=True):
|
|
918
|
+
"""
|
|
919
|
+
Starts the WSGI Dynamic Dispatcher local development server.
|
|
920
|
+
"""
|
|
921
|
+
from werkzeug.serving import run_simple
|
|
922
|
+
|
|
923
|
+
application = create_dispatchable_app()
|
|
924
|
+
print("\n=======================================================")
|
|
925
|
+
print(f" AppManager v{__version__} starting at http://{host}:{port}")
|
|
926
|
+
print("=======================================================\n")
|
|
927
|
+
run_simple(host, int(port), application, use_reloader=reload, use_debugger=reload)
|
|
928
|
+
|
|
929
|
+
|
|
930
|
+
def main(args=None):
|
|
931
|
+
if args is None:
|
|
932
|
+
args = sys.argv[1:]
|
|
933
|
+
|
|
934
|
+
parser = argparse.ArgumentParser(
|
|
935
|
+
prog="appmanager",
|
|
936
|
+
description="AppManager CLI - Multi-tenant WSGI Portal & Sub-App Dispatcher",
|
|
937
|
+
)
|
|
938
|
+
parser.add_argument("-v", "--version", action="version", version=f"%(prog)s {__version__}")
|
|
939
|
+
|
|
940
|
+
subparsers = parser.add_subparsers(dest="command", help="Available subcommands")
|
|
941
|
+
|
|
942
|
+
# run command
|
|
943
|
+
run_parser = subparsers.add_parser("run", help="Start the development WSGI dispatcher server")
|
|
944
|
+
run_parser.add_argument(
|
|
945
|
+
"--host",
|
|
946
|
+
default=os.getenv("HOST", "0.0.0.0"),
|
|
947
|
+
help="Host to bind server (default: 0.0.0.0)",
|
|
948
|
+
)
|
|
949
|
+
run_parser.add_argument(
|
|
950
|
+
"-p",
|
|
951
|
+
"--port",
|
|
952
|
+
type=int,
|
|
953
|
+
default=int(os.getenv("PORT", 5000)),
|
|
954
|
+
help="Port to bind server (default: 5000)",
|
|
955
|
+
)
|
|
956
|
+
run_parser.add_argument(
|
|
957
|
+
"--no-reload", dest="reload", action="store_false", help="Disable automatic code reloader"
|
|
958
|
+
)
|
|
959
|
+
run_parser.set_defaults(reload=True)
|
|
960
|
+
|
|
961
|
+
# seed command
|
|
962
|
+
subparsers.add_parser(
|
|
963
|
+
"seed", help="Seed the database with default sample sub-apps and extensions"
|
|
964
|
+
)
|
|
965
|
+
|
|
966
|
+
# check-health command
|
|
967
|
+
subparsers.add_parser("check-health", help="Run health checks across all registered sub-apps")
|
|
968
|
+
|
|
969
|
+
# run-scheduled-tasks command
|
|
970
|
+
subparsers.add_parser(
|
|
971
|
+
"run-scheduled-tasks", help="Run background scheduled cron jobs and maintenance"
|
|
972
|
+
)
|
|
973
|
+
|
|
974
|
+
# list-apps command
|
|
975
|
+
subparsers.add_parser("list-apps", help="List all registered sub-apps and their status")
|
|
976
|
+
|
|
977
|
+
# init command
|
|
978
|
+
subparsers.add_parser(
|
|
979
|
+
"init", help="Bootstrap local directory with installed_apps/ and default configuration"
|
|
980
|
+
)
|
|
981
|
+
|
|
982
|
+
# new-subapp command
|
|
983
|
+
new_parser = subparsers.add_parser(
|
|
984
|
+
"new-subapp", help="Scaffold a new standardized Flask sub-app or extension"
|
|
985
|
+
)
|
|
986
|
+
new_parser.add_argument("name", help="Human-readable name of the sub-app")
|
|
987
|
+
new_parser.add_argument("--slug", help="URL slug for sub-app (defaults to sanitized name)")
|
|
988
|
+
new_parser.add_argument(
|
|
989
|
+
"--output-dir", help="Target directory (defaults to ./installed_apps/<slug>)"
|
|
990
|
+
)
|
|
991
|
+
new_parser.add_argument(
|
|
992
|
+
"--template",
|
|
993
|
+
choices=["basic", "standalone", "api", "extension", "htmx", "full"],
|
|
994
|
+
default="basic",
|
|
995
|
+
help="Template preset (basic, api, extension, htmx, full)",
|
|
996
|
+
)
|
|
997
|
+
|
|
998
|
+
# dev command (Local Sub-App Development Server with mock auth)
|
|
999
|
+
dev_parser = subparsers.add_parser(
|
|
1000
|
+
"dev", help="Run a local development server for a specific sub-app"
|
|
1001
|
+
)
|
|
1002
|
+
dev_parser.add_argument("slug", help="Slug of the sub-app to run")
|
|
1003
|
+
dev_parser.add_argument("--host", default="127.0.0.1", help="Host to bind (default: 127.0.0.1)")
|
|
1004
|
+
dev_parser.add_argument(
|
|
1005
|
+
"-p", "--port", type=int, default=5001, help="Port to bind (default: 5001)"
|
|
1006
|
+
)
|
|
1007
|
+
dev_parser.add_argument(
|
|
1008
|
+
"--email", default="admin@example.com", help="Mock authenticated user email"
|
|
1009
|
+
)
|
|
1010
|
+
dev_parser.add_argument(
|
|
1011
|
+
"--role", choices=["admin", "user"], default="admin", help="Mock authenticated user role"
|
|
1012
|
+
)
|
|
1013
|
+
|
|
1014
|
+
# hooks command (List registered UI slots and hooks)
|
|
1015
|
+
subparsers.add_parser("hooks", help="List registered UI slots and lifecycle hooks")
|
|
1016
|
+
|
|
1017
|
+
# validate-subapp command
|
|
1018
|
+
val_parser = subparsers.add_parser(
|
|
1019
|
+
"validate-subapp", help="Validate a sub-app folder or ZIP package"
|
|
1020
|
+
)
|
|
1021
|
+
val_parser.add_argument("path", help="Path to sub-app folder or .zip file")
|
|
1022
|
+
|
|
1023
|
+
# export-app command
|
|
1024
|
+
exp_parser = subparsers.add_parser(
|
|
1025
|
+
"export-app", help="Export an installed sub-app into a deployable ZIP"
|
|
1026
|
+
)
|
|
1027
|
+
exp_parser.add_argument("slug", help="Slug of the installed app to export")
|
|
1028
|
+
exp_parser.add_argument("-o", "--output", help="Output ZIP path")
|
|
1029
|
+
|
|
1030
|
+
# reload-app command
|
|
1031
|
+
rel_parser = subparsers.add_parser("reload-app", help="Clear WSGI module cache for an app")
|
|
1032
|
+
rel_parser.add_argument("slug", help="Slug of the installed app to reload")
|
|
1033
|
+
|
|
1034
|
+
# set-role command (CLI User Elevation)
|
|
1035
|
+
role_parser = subparsers.add_parser(
|
|
1036
|
+
"set-role", help="Update or elevate a user role (admin or user)"
|
|
1037
|
+
)
|
|
1038
|
+
role_parser.add_argument("email", help="Email of the user to update")
|
|
1039
|
+
role_parser.add_argument(
|
|
1040
|
+
"--role", choices=["admin", "user"], default="admin", help="Role to assign (default: admin)"
|
|
1041
|
+
)
|
|
1042
|
+
|
|
1043
|
+
# make-admin command (Convenience CLI Elevation)
|
|
1044
|
+
admin_parser = subparsers.add_parser(
|
|
1045
|
+
"make-admin", help="Convenience shortcut to elevate a user to admin"
|
|
1046
|
+
)
|
|
1047
|
+
admin_parser.add_argument("email", help="Email of the user to elevate to admin")
|
|
1048
|
+
|
|
1049
|
+
# list-users command
|
|
1050
|
+
subparsers.add_parser("list-users", help="List all registered users and their roles")
|
|
1051
|
+
|
|
1052
|
+
# list-roles command
|
|
1053
|
+
subparsers.add_parser("list-roles", help="List all configured roles and member counts")
|
|
1054
|
+
|
|
1055
|
+
# create-role command
|
|
1056
|
+
role_create_parser = subparsers.add_parser("create-role", help="Create a new custom role")
|
|
1057
|
+
role_create_parser.add_argument("name", help="Display name of the role")
|
|
1058
|
+
role_create_parser.add_argument("--slug", help="Identifier slug (defaults to sanitized name)")
|
|
1059
|
+
role_create_parser.add_argument("--description", help="Description of the role")
|
|
1060
|
+
|
|
1061
|
+
# install-git command
|
|
1062
|
+
git_inst_parser = subparsers.add_parser(
|
|
1063
|
+
"install-git", help="Pull and install a sub-app from Git with security pre-checks"
|
|
1064
|
+
)
|
|
1065
|
+
git_inst_parser.add_argument("url", help="Git repository URL")
|
|
1066
|
+
git_inst_parser.add_argument("--name", help="Display name of the application")
|
|
1067
|
+
git_inst_parser.add_argument("--slug", help="URL slug identifier")
|
|
1068
|
+
git_inst_parser.add_argument("--entry-point", help="Entry point string (e.g. app:app)")
|
|
1069
|
+
git_inst_parser.add_argument(
|
|
1070
|
+
"-y", "--yes", action="store_true", help="Skip confirmation prompt and install directly"
|
|
1071
|
+
)
|
|
1072
|
+
|
|
1073
|
+
# install-zip command
|
|
1074
|
+
zip_inst_parser = subparsers.add_parser(
|
|
1075
|
+
"install-zip", help="Install a sub-app from a ZIP file with security pre-checks"
|
|
1076
|
+
)
|
|
1077
|
+
zip_inst_parser.add_argument("path", help="Path to ZIP archive")
|
|
1078
|
+
zip_inst_parser.add_argument("--name", help="Display name of the application")
|
|
1079
|
+
zip_inst_parser.add_argument("--slug", help="URL slug identifier")
|
|
1080
|
+
zip_inst_parser.add_argument("--entry-point", help="Entry point string (e.g. app:app)")
|
|
1081
|
+
zip_inst_parser.add_argument(
|
|
1082
|
+
"-y", "--yes", action="store_true", help="Skip confirmation prompt and install directly"
|
|
1083
|
+
)
|
|
1084
|
+
|
|
1085
|
+
# generate-manifest command (delegates to appmanager-sdk)
|
|
1086
|
+
gen_man_parser = subparsers.add_parser(
|
|
1087
|
+
"generate-manifest", help="Generate manifest.json from a Python sub-app"
|
|
1088
|
+
)
|
|
1089
|
+
gen_man_parser.add_argument(
|
|
1090
|
+
"target", nargs="?", default="app:app", help="Target module or file (default: app:app)"
|
|
1091
|
+
)
|
|
1092
|
+
gen_man_parser.add_argument(
|
|
1093
|
+
"--out", "-o", default="manifest.json", help="Output manifest path (default: manifest.json)"
|
|
1094
|
+
)
|
|
1095
|
+
|
|
1096
|
+
parsed = parser.parse_args(args)
|
|
1097
|
+
|
|
1098
|
+
if not parsed.command:
|
|
1099
|
+
parser.print_help()
|
|
1100
|
+
return 0
|
|
1101
|
+
|
|
1102
|
+
if parsed.command == "run":
|
|
1103
|
+
run_server(host=parsed.host, port=parsed.port, reload=parsed.reload)
|
|
1104
|
+
elif parsed.command == "seed":
|
|
1105
|
+
seed_db()
|
|
1106
|
+
elif parsed.command == "check-health":
|
|
1107
|
+
run_health_checks()
|
|
1108
|
+
elif parsed.command == "run-scheduled-tasks":
|
|
1109
|
+
run_scheduled_tasks()
|
|
1110
|
+
elif parsed.command == "list-apps":
|
|
1111
|
+
list_apps()
|
|
1112
|
+
elif parsed.command == "init":
|
|
1113
|
+
init_project()
|
|
1114
|
+
elif parsed.command == "new-subapp":
|
|
1115
|
+
new_subapp(
|
|
1116
|
+
name=parsed.name,
|
|
1117
|
+
slug=parsed.slug,
|
|
1118
|
+
output_dir=parsed.output_dir,
|
|
1119
|
+
template=parsed.template,
|
|
1120
|
+
)
|
|
1121
|
+
elif parsed.command == "dev":
|
|
1122
|
+
return dev_subapp(
|
|
1123
|
+
slug=parsed.slug,
|
|
1124
|
+
host=parsed.host,
|
|
1125
|
+
port=parsed.port,
|
|
1126
|
+
user_email=parsed.email,
|
|
1127
|
+
user_role=parsed.role,
|
|
1128
|
+
)
|
|
1129
|
+
elif parsed.command == "hooks":
|
|
1130
|
+
return list_hooks_cli()
|
|
1131
|
+
elif parsed.command == "validate-subapp":
|
|
1132
|
+
return validate_subapp_cli(parsed.path)
|
|
1133
|
+
elif parsed.command == "install-git":
|
|
1134
|
+
return install_git_cli(
|
|
1135
|
+
repo_url=parsed.url,
|
|
1136
|
+
name=parsed.name,
|
|
1137
|
+
slug=parsed.slug,
|
|
1138
|
+
entry_point=parsed.entry_point,
|
|
1139
|
+
yes=parsed.yes,
|
|
1140
|
+
)
|
|
1141
|
+
elif parsed.command == "install-zip":
|
|
1142
|
+
return install_zip_cli(
|
|
1143
|
+
zip_path=parsed.path,
|
|
1144
|
+
name=parsed.name,
|
|
1145
|
+
slug=parsed.slug,
|
|
1146
|
+
entry_point=parsed.entry_point,
|
|
1147
|
+
yes=parsed.yes,
|
|
1148
|
+
)
|
|
1149
|
+
elif parsed.command == "export-app":
|
|
1150
|
+
return export_subapp_cli(slug=parsed.slug, output=parsed.output)
|
|
1151
|
+
elif parsed.command == "reload-app":
|
|
1152
|
+
return reload_subapp_cli(slug=parsed.slug)
|
|
1153
|
+
elif parsed.command == "set-role":
|
|
1154
|
+
return set_user_role(email=parsed.email, role=parsed.role)
|
|
1155
|
+
elif parsed.command == "make-admin":
|
|
1156
|
+
return set_user_role(email=parsed.email, role="admin")
|
|
1157
|
+
elif parsed.command == "list-users":
|
|
1158
|
+
return list_users_cli()
|
|
1159
|
+
elif parsed.command == "list-roles":
|
|
1160
|
+
return list_roles_cli()
|
|
1161
|
+
elif parsed.command == "create-role":
|
|
1162
|
+
return create_role_cli(name=parsed.name, slug=parsed.slug, description=parsed.description)
|
|
1163
|
+
elif parsed.command == "generate-manifest":
|
|
1164
|
+
from appmanager_sdk.cli import main as sdk_cli_main
|
|
1165
|
+
|
|
1166
|
+
return sdk_cli_main(["generate", parsed.target, "--out", parsed.out])
|
|
1167
|
+
return 0
|
|
1168
|
+
|
|
1169
|
+
|
|
1170
|
+
if __name__ == "__main__":
|
|
1171
|
+
sys.exit(main())
|