appforge-cli 1.1.4__tar.gz → 1.2.4__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 (27) hide show
  1. {appforge_cli-1.1.4 → appforge_cli-1.2.4}/PKG-INFO +1 -1
  2. appforge_cli-1.2.4/appforge/ai.py +78 -0
  3. {appforge_cli-1.1.4 → appforge_cli-1.2.4}/appforge/capacitor.py +13 -3
  4. appforge_cli-1.2.4/appforge/cli.py +334 -0
  5. appforge_cli-1.2.4/appforge/config.py +52 -0
  6. appforge_cli-1.2.4/appforge/create.py +93 -0
  7. appforge_cli-1.2.4/appforge/github.py +351 -0
  8. appforge_cli-1.2.4/appforge/knowledge_base.json +262 -0
  9. appforge_cli-1.2.4/appforge/utils.py +129 -0
  10. {appforge_cli-1.1.4 → appforge_cli-1.2.4}/appforge_cli.egg-info/PKG-INFO +1 -1
  11. {appforge_cli-1.1.4 → appforge_cli-1.2.4}/appforge_cli.egg-info/SOURCES.txt +1 -0
  12. {appforge_cli-1.1.4 → appforge_cli-1.2.4}/pyproject.toml +1 -1
  13. {appforge_cli-1.1.4 → appforge_cli-1.2.4}/setup.py +1 -1
  14. appforge_cli-1.1.4/appforge/ai.py +0 -42
  15. appforge_cli-1.1.4/appforge/cli.py +0 -192
  16. appforge_cli-1.1.4/appforge/config.py +0 -26
  17. appforge_cli-1.1.4/appforge/github.py +0 -207
  18. appforge_cli-1.1.4/appforge/knowledge_base.json +0 -181
  19. appforge_cli-1.1.4/appforge/utils.py +0 -100
  20. {appforge_cli-1.1.4 → appforge_cli-1.2.4}/README.md +0 -0
  21. {appforge_cli-1.1.4 → appforge_cli-1.2.4}/appforge/__init__.py +0 -0
  22. {appforge_cli-1.1.4 → appforge_cli-1.2.4}/appforge/detector.py +0 -0
  23. {appforge_cli-1.1.4 → appforge_cli-1.2.4}/appforge_cli.egg-info/dependency_links.txt +0 -0
  24. {appforge_cli-1.1.4 → appforge_cli-1.2.4}/appforge_cli.egg-info/entry_points.txt +0 -0
  25. {appforge_cli-1.1.4 → appforge_cli-1.2.4}/appforge_cli.egg-info/requires.txt +0 -0
  26. {appforge_cli-1.1.4 → appforge_cli-1.2.4}/appforge_cli.egg-info/top_level.txt +0 -0
  27. {appforge_cli-1.1.4 → appforge_cli-1.2.4}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: appforge-cli
3
- Version: 1.1.4
3
+ Version: 1.2.4
4
4
  Summary: Convert web, Flutter, and native apps into Android/iOS apps automatically using cloud builds.
5
5
  Author: AppForge Team
6
6
  Author-email: AppForge Team <juniorsir.bot@gmail.com>
@@ -0,0 +1,78 @@
1
+ import os
2
+ import json
3
+ import re # <-- Import the Regular Expression module
4
+ from .utils import print_info, print_success, print_error, CYAN, RESET, GRAY
5
+
6
+ def load_knowledge_base():
7
+ """Loads the AI keyword-to-plugin mappings from the JSON file."""
8
+ try:
9
+ current_dir = os.path.dirname(os.path.abspath(__file__))
10
+ json_path = os.path.join(current_dir, 'knowledge_base.json')
11
+ with open(json_path, 'r') as f:
12
+ return json.load(f)
13
+ except Exception as e:
14
+ print_error(f"Critical Error: Could not load AI knowledge base: {e}")
15
+ return {}
16
+
17
+ def scan_codebase_for_permissions():
18
+ """
19
+ Universally scans project files. It now uses regex to robustly parse
20
+ Flutter pubspec.yaml files, ignoring indentation differences.
21
+ """
22
+ ai_knowledge_base = load_knowledge_base()
23
+
24
+ print_info(f"{CYAN}🤖 AI Agent scanning codebase for native features...{RESET}")
25
+
26
+ found_plugins = {}
27
+ pubspec_already_scanned = False
28
+
29
+ for root, dirs, files in os.walk('.'):
30
+ dirs[:] = [d for d in dirs if d not in ['node_modules', '.git', 'android', 'ios', 'dist', 'build', '.next', '.nuxt', 'www', '.dart_tool']]
31
+
32
+ for file in files:
33
+ file_path = os.path.join(root, file)
34
+
35
+ # --- FLUTTER SCANNER (Now with Regex!) ---
36
+ if file == 'pubspec.yaml' and not pubspec_already_scanned:
37
+ try:
38
+ with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
39
+ content = f.read()
40
+
41
+ if 'workspace:' in content:
42
+ print_info(f"Ignoring Flutter workspace file: {GRAY}{file_path}{RESET}")
43
+ continue
44
+
45
+ print_info(f"Analyzing dependencies in {GRAY}{file_path}{RESET}")
46
+ for keyword, data in ai_knowledge_base.items():
47
+ if keyword.startswith("pubspec:"):
48
+ package_name = keyword.split(":")[1]
49
+
50
+ # --- THIS IS THE REGEX FIX ---
51
+ # This pattern looks for:
52
+ # ^ - start of a line
53
+ # \s* - any amount of whitespace (spaces, tabs)
54
+ # {name} - the package name
55
+ # : - a colon
56
+ pattern = re.compile(f"^\s*{re.escape(package_name)}:", re.MULTILINE)
57
+
58
+ if pattern.search(content):
59
+ found_plugins[data["plugin"]] = data
60
+ # ----------------------------
61
+
62
+ pubspec_already_scanned = True
63
+ except Exception:
64
+ pass
65
+
66
+ # --- WEB SCANNER (un-changed) ---
67
+ elif file.endswith(('.js', '.ts', '.jsx', '.tsx', '.vue', '.svelte', '.html')):
68
+ try:
69
+ with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
70
+ content = f.read()
71
+ for keyword, data in ai_knowledge_base.items():
72
+ if not keyword.startswith("pubspec:"):
73
+ if keyword in content and data["plugin"] not in found_plugins:
74
+ found_plugins[data["plugin"]] = data
75
+ except Exception:
76
+ pass
77
+
78
+ return found_plugins
@@ -6,10 +6,20 @@ from .utils import print_success, print_error, print_info, run_with_spinner
6
6
 
7
7
  def run_cmd(command, hide_output=True):
8
8
  try:
9
- stdout = subprocess.DEVNULL if hide_output else None
10
- subprocess.run(command, shell=True, check=True, stdout=stdout, stderr=stdout)
9
+ if hide_output:
10
+ # Capture both stdout and stderr to hide them unless there's an error
11
+ result = subprocess.run(
12
+ command, shell=True, check=True, capture_output=True, text=True
13
+ )
14
+ else:
15
+ # Stream output directly to terminal
16
+ subprocess.run(command, shell=True, check=True)
11
17
  except subprocess.CalledProcessError as e:
12
- error_message = f"Command '{command}' failed. Please check your setup."
18
+ # If the command fails, print the actual error from the command!
19
+ error_message = f"Command '{command}' failed."
20
+ # The real error from npm/git/etc. will be in stderr
21
+ if e.stderr:
22
+ error_message += f"\n\n--- Error Details ---\n{e.stderr.strip()}\n---------------------"
13
23
  print_error(error_message)
14
24
 
15
25
 
@@ -0,0 +1,334 @@
1
+ import os
2
+ import sys
3
+ import argparse
4
+ from .utils import (print_header, print_footer, print_info, print_success, prompt, print_error, print_warning,
5
+ GRAY, RESET, BOLD, CYAN, YELLOW, GREEN, RED,
6
+ cursor_up, clear_from_cursor)
7
+ from .detector import detect_project
8
+ from .capacitor import setup_capacitor, apply_configuration
9
+ from .config import save_local_config, load_local_config, load_history, get_app_id
10
+ from .github import push_and_build, check_status as check_by_id, download_apk as download_by_id, check_status, download_apk, get_build_status_by_id
11
+ import time
12
+ from .ai import scan_codebase_for_permissions
13
+ from .capacitor import install_plugins
14
+ from .create import create_project
15
+
16
+ def init_project():
17
+ project_info = detect_project()
18
+ category = project_info.get("category", "web")
19
+ proj_type = project_info.get("type", "unknown")
20
+
21
+ save_local_config({"category": category, "project_type": proj_type})
22
+
23
+ if category == "web":
24
+ web_dir = prompt("Confirm web build directory", project_info["webDir"])
25
+ platform_choice = prompt("Select platform (android, ios, both)", "android").lower()
26
+ if platform_choice == "both": platforms = ["android", "ios"]
27
+ elif platform_choice == "ios": platforms = ["ios"]
28
+ else: platforms = ["android"]
29
+
30
+ save_local_config({"webDir": web_dir, "platform": platform_choice})
31
+ setup_capacitor(web_dir, platforms)
32
+ else:
33
+ print_info(f"Native {proj_type} project detected. Bypassing Capacitor setup.")
34
+ platform_choice = prompt("Select platform to build in cloud (android, ios, both)", "android").lower()
35
+ save_local_config({"platform": platform_choice})
36
+
37
+ # --- UNIVERSAL AI PERMISSION SYSTEM ---
38
+ print("")
39
+ # Pass the category to the AI!
40
+ detected_features = scan_codebase_for_permissions()
41
+
42
+ if detected_features:
43
+ print_success(f"AI detected {len(detected_features)} native features required by your app:")
44
+
45
+ for data in detected_features.values():
46
+ print(f" {CYAN}●{RESET} {data['feature']} {GRAY}({data['desc']}){RESET}")
47
+
48
+ print_info("The AppForge Cloud Server will automatically inject these permissions during the build process.")
49
+ else:
50
+ print_info("AI scan complete. No special native permissions detected.")
51
+ # --------------------------------------
52
+ try:
53
+ import subprocess
54
+ # Find the top-level directory of the git repo
55
+ git_root = subprocess.check_output(['git', 'rev-parse', '--show-toplevel'], text=True).strip()
56
+
57
+ # Get the relative path from the git root to the current directory
58
+ sub_path = os.path.relpath(os.getcwd(), git_root)
59
+
60
+ # If we are in a sub-directory, save it
61
+ if sub_path and sub_path != '.':
62
+ print_info(f"Detected sub-project path: {GRAY}{sub_path}{RESET}")
63
+ save_local_config({"sub_path": sub_path})
64
+
65
+ except (subprocess.CalledProcessError, FileNotFoundError):
66
+ # Not a git repo, or git not installed. Assume it's a standalone project.
67
+ save_local_config({"sub_path": "."})
68
+ print_success("Ready to send to the AppForge Cloud Builder.")
69
+
70
+ def configure_project():
71
+ config = load_local_config()
72
+ print_info("Interactive Configuration")
73
+ app_name = prompt("App name", config.get("appName", "My App"))
74
+ package_id = prompt("Package ID", config.get("packageId", "com.example.app"))
75
+ icon_path = prompt("Icon path (local PNG)", config.get("iconPath", ""))
76
+ splash_img = prompt("Splash image (local PNG)", config.get("splashImg", ""))
77
+ splash_bg = prompt("Splash background color (HEX)", config.get("splashBg", "#ffffff"))
78
+
79
+ new_config = {
80
+ "appName": app_name,
81
+ "packageId": package_id,
82
+ "iconPath": icon_path,
83
+ "splashImg": splash_img,
84
+ "splashBg": splash_bg
85
+ }
86
+ save_local_config(new_config)
87
+
88
+ web_dir = config.get("webDir", "dist")
89
+ apply_configuration(app_name, package_id, icon_path, splash_img, splash_bg, web_dir)
90
+ return new_config
91
+
92
+ def display_app_details(config):
93
+ print(f"{BOLD}App Configuration:{RESET}")
94
+ print(f" {GRAY}Name:{RESET} {config.get('appName', 'Not set')}")
95
+ print(f" {GRAY}Package ID:{RESET} {config.get('packageId', 'Not set')}")
96
+ print(f" {GRAY}Web Directory:{RESET} {config.get('webDir', 'Not set')}")
97
+ print(f" {GRAY}Icon:{RESET} {config.get('iconPath', 'Not set')}")
98
+ print(f" {GRAY}Splash Image:{RESET} {config.get('splashImg', 'Not set')}")
99
+ print(f" {GRAY}Splash Color:{RESET} {config.get('splashBg', 'Not set')}")
100
+ print("-" * 30)
101
+
102
+ def build_app():
103
+ config = load_local_config()
104
+ if not config:
105
+ print_error("Project not initialized. Run 'appforge init' first.")
106
+
107
+ app_name = config.get("appName")
108
+ package_id = config.get("packageId")
109
+ platform = config.get("platform", "android")
110
+ config_was_updated = False
111
+
112
+ if not app_name or not package_id:
113
+ print_info("Some required app details are missing. Let's set them up.")
114
+ new_app_name = prompt("App name", app_name or "My App")
115
+ new_package_id = prompt("Package ID", package_id or "com.example.app")
116
+ new_platform = prompt("Platform (android, ios, both)", platform)
117
+
118
+ config['appName'] = new_app_name
119
+ config['packageId'] = new_package_id
120
+ config['platform'] = new_platform.lower()
121
+ config_was_updated = True
122
+
123
+ # --- VERSION INCREMENT LOGIC ---
124
+ current_version_str = config.get("version", "1.0.0")
125
+ try:
126
+ parts = current_version_str.split('.')
127
+ parts[-1] = str(int(parts[-1]) + 1)
128
+ new_version_str = '.'.join(parts)
129
+ except:
130
+ new_version_str = "1.0.1"
131
+
132
+ print_info(f"Preparing build for version: {CYAN}{new_version_str}{RESET}")
133
+ config['version'] = new_version_str
134
+
135
+ if config_was_updated:
136
+ save_local_config(config)
137
+ print_success("App details updated.\n")
138
+
139
+ print_info("Ready to build with the following configuration:")
140
+ display_app_details(config)
141
+ print(f" {GRAY}Target Platform:{RESET} {config.get('platform', 'android')}")
142
+ print(f" {GRAY}Next Version:{RESET} {new_version_str}\n")
143
+
144
+ # Ask ONLY ONCE if they want to build
145
+ action = prompt("Proceed with build? (Y/n)", default="y").lower()
146
+
147
+ if action in ['y', 'yes', '']:
148
+ # 1. Save new version to local memory
149
+ save_local_config({"version": new_version_str})
150
+
151
+ # 2. Sync CLI memory to Capacitor native config
152
+ if config.get("category") == "web":
153
+ apply_configuration(
154
+ config.get('appName', 'My App'),
155
+ config.get('packageId', 'com.example.app'),
156
+ config.get('iconPath'),
157
+ config.get('splashImg'),
158
+ config.get('splashBg'),
159
+ config.get('webDir', 'dist')
160
+ )
161
+
162
+ # 3. Zip and send to cloud
163
+ push_and_build(config)
164
+ print_success("Your app is now building in the cloud!")
165
+
166
+ # 4. Ask to watch logs
167
+ watch = prompt("Watch live build logs? (Y/n)", default="y").lower()
168
+ if watch in ['y', 'yes', '']:
169
+ status_check()
170
+ else:
171
+ print_info("Run 'appforge status' later to check its progress.")
172
+
173
+ else:
174
+ print(f"\n{GREEN}Run 'appforge configure' to edit App configuration{RESET}")
175
+ print("\n")
176
+ print_info("Build cancelled.")
177
+ def show_history():
178
+ """Displays a list of past builds with their current cloud status."""
179
+ history = load_history()
180
+ if not history:
181
+ print_info("No build history found. Run 'appforge build' to create your first app!")
182
+ return
183
+
184
+ print(f"\n{BOLD}Your Recent AppForge Builds:{RESET}")
185
+ print(f" {GRAY}{'ID':<10} {'PROJECT':<20} {'PLATFORM':<10} {'TRIGGERED (UTC)':<22} {'STATUS'}{RESET}")
186
+ print("-" * 80)
187
+
188
+ entries_to_display = history[:10]
189
+
190
+ print_info("Fetching latest statuses from the cloud...\n")
191
+
192
+ for entry in entries_to_display:
193
+ from datetime import datetime
194
+
195
+ # Format the timestamp
196
+ dt_obj = datetime.fromisoformat(entry['triggered_at'].replace('Z', '+00:00'))
197
+ formatted_time = dt_obj.strftime('%Y-%m-%d %H:%M:%S')
198
+
199
+ # Fetch real-time status from GitHub
200
+ run_id = entry.get("run_id")
201
+ if run_id:
202
+ status, conclusion = get_build_status_by_id(run_id)
203
+ else:
204
+ # Fallback for old history items
205
+ status, conclusion = entry.get("status", "unknown"), entry.get("conclusion", None)
206
+
207
+ # --- STATIC STATUS DISPLAY LOGIC ---
208
+ status_text = ""
209
+ if status in ["in_progress", "queued"]:
210
+ # Static yellow text instead of animation
211
+ status_text = f"{YELLOW}▶ Building...{RESET}"
212
+ elif status == "completed":
213
+ if conclusion == "success":
214
+ status_text = f"{GREEN}✔ Success{RESET}"
215
+ else:
216
+ status_text = f"{RED}✖ Failed{RESET}"
217
+ else:
218
+ status_text = f"{GRAY}{(status or 'Unknown').capitalize()}{RESET}"
219
+
220
+ # Print the row
221
+ print(f" {CYAN}{entry['app_id']:<9}{RESET} {entry['project_name']:<20} {entry['platform']:<10} {formatted_time:<22} {status_text}")
222
+
223
+ print("-" * 80)
224
+
225
+ # Update the prompt text to be perfectly clear
226
+ choice = prompt("Enter a number or App ID to re-download, or press Enter to exit").strip().lower()
227
+
228
+ selected_entry = None # This will hold the build the user wants
229
+
230
+ # Case 1: User entered a number from the list
231
+ if choice.isdigit() and 0 < int(choice) <= len(entries_to_display):
232
+ selected_index = int(choice) - 1
233
+ selected_entry = entries_to_display[selected_index]
234
+
235
+ # Case 2: User entered text (could be an App ID)
236
+ elif choice:
237
+ # Find the *first* (most recent) entry in the full history that matches the ID.
238
+ # We use startswith so users can just type the first few characters (e.g., '9a6b').
239
+ selected_entry = next((entry for entry in history if entry['app_id'].lower().startswith(choice)), None)
240
+
241
+ # Now, if we found an entry by either method, proceed with the download
242
+ if selected_entry:
243
+ run_id_to_download = selected_entry.get("run_id")
244
+
245
+ if run_id_to_download:
246
+ print_info(f"Fetching artifact for Build Run ID: {run_id_to_download} (App ID: {selected_entry['app_id']})")
247
+ download_apk(run_id=run_id_to_download)
248
+ else:
249
+ print_warning("This is a legacy build entry without a unique Run ID. Cannot guarantee the correct artifact.")
250
+
251
+ # Case 3: User typed something, but it was invalid
252
+ elif choice:
253
+ print_warning("Invalid input. Please enter a number from the list or a valid App ID.")
254
+
255
+ def status_check():
256
+ check_status()
257
+
258
+ def download_app():
259
+ download_apk()
260
+
261
+ def show_argu():
262
+ print("Commands:")
263
+ print(" appforge init - Initialize an AppForge project")
264
+ print(" appforge configure - Open interactive settings")
265
+ print(f" {CYAN}create <framework> <name>{RESET} Scaffold a new app (vite, flutter, html)")
266
+ print(" appforge build - Package project and send to build repo")
267
+ print(" appforge status - Check build status")
268
+ print(" appforge download - Download the built APK")
269
+ print(f" {CYAN}history{RESET} View a list of your past builds")
270
+ print(" appforge help - Show this help message")
271
+
272
+ def show_help():
273
+ """The animated welcome sequence for the very first run."""
274
+ welcome_message = f"{BOLD}▲ AppForge{RESET} - The Universal App Builder"
275
+
276
+ # Typing animation
277
+ for char in welcome_message:
278
+ sys.stdout.write(char)
279
+ sys.stdout.flush()
280
+ time.sleep(0.05)
281
+ print("\n")
282
+ time.sleep(0.5)
283
+
284
+ print_success("Installation complete!")
285
+ print_info("Welcome to the AppForge ecosystem.")
286
+
287
+ print("\nGetting Started:")
288
+ print(" 1. `cd` into your project directory")
289
+ print(" 2. Run `appforge init` to begin\n")
290
+
291
+ show_argu()
292
+ sys.exit(0)
293
+
294
+ def main():
295
+
296
+ print_header()
297
+
298
+ # --- FIXED ARGPARSE LOGIC ---
299
+ parser = argparse.ArgumentParser(add_help=False)
300
+ # The first word is the command (defaulting to "help")
301
+ parser.add_argument("command", nargs="?", default="help", help="Command to run")
302
+ # All remaining words get bundled into a list called "args"
303
+ parser.add_argument("args", nargs=argparse.REMAINDER, help="Additional arguments")
304
+
305
+ # parse_known_args is safer here than parse_args
306
+ args, unknown = parser.parse_known_args()
307
+
308
+ # If there were extra arguments that slipped past, add them to our list
309
+ if unknown:
310
+ args.args.extend(unknown)
311
+ # ----------------------------
312
+
313
+ try:
314
+ if args.command == "create":
315
+ # Check if they provided the framework and name
316
+ if not args.args or len(args.args) < 2:
317
+ print_error("Usage: appforge create <framework> <project-name>\nExample: appforge create flutter my_new_app")
318
+
319
+ framework = args.args[0].lower()
320
+ project_name = args.args[1]
321
+ create_project(framework, project_name)
322
+
323
+ elif args.command == "init": init_project()
324
+ elif args.command == "build": build_app()
325
+ elif args.command == "configure": configure_project()
326
+ elif args.command == "history": show_history()
327
+ elif args.command == "status": status_check()
328
+ elif args.command == "download": download_apk()
329
+ else: show_help()
330
+ finally:
331
+ print_footer()
332
+
333
+ if __name__ == "__main__":
334
+ main()
@@ -0,0 +1,52 @@
1
+ import os
2
+ import json
3
+ import uuid
4
+ from .utils import print_error
5
+
6
+ LOCAL_CONFIG_FILE = ".appforge.json"
7
+ HISTORY_FILE = os.path.expanduser("~/.appforge_history.json")
8
+
9
+ def load_history():
10
+ """Loads the global build history from the user's home directory."""
11
+ if not os.path.exists(HISTORY_FILE):
12
+ return []
13
+ try:
14
+ with open(HISTORY_FILE, "r") as f:
15
+ return json.load(f)
16
+ except (json.JSONDecodeError, FileNotFoundError):
17
+ return []
18
+
19
+ def save_history(history_data):
20
+ """Saves the global build history."""
21
+ with open(HISTORY_FILE, "w") as f:
22
+ json.dump(history_data, f, indent=2)
23
+
24
+ def add_to_history(new_build_entry):
25
+ """Adds a new build record to the history."""
26
+ history = load_history()
27
+ # Prepend the new entry to the top of the list
28
+ history.insert(0, new_build_entry)
29
+ # Keep the history from getting too long (e.g., max 50 entries)
30
+ if len(history) > 50:
31
+ history = history[:50]
32
+ save_history(history)
33
+
34
+ def load_local_config():
35
+ if not os.path.exists(LOCAL_CONFIG_FILE):
36
+ return {}
37
+ with open(LOCAL_CONFIG_FILE, "r") as f:
38
+ return json.load(f)
39
+
40
+ def save_local_config(data):
41
+ config = load_local_config()
42
+ config.update(data)
43
+ with open(LOCAL_CONFIG_FILE, "w") as f:
44
+ json.dump(config, f, indent=2)
45
+
46
+ def get_app_id():
47
+ config = load_local_config()
48
+ if "app_id" not in config:
49
+ new_id = str(uuid.uuid4())[:8] # Generate a short 8-character unique ID
50
+ save_local_config({"app_id": new_id})
51
+ return new_id
52
+ return config["app_id"]
@@ -0,0 +1,93 @@
1
+ import os
2
+ import shutil
3
+ import subprocess
4
+ import re
5
+ from .utils import print_success, print_info, print_error, prompt, print_progress_bar, CYAN, RESET, BOLD
6
+
7
+ # The TEMPLATES dictionary stays the same
8
+ TEMPLATES = {
9
+ "vite": {
10
+ "cmd": "npm create vite@latest {name} -- --template react-ts",
11
+ "desc": "React + TypeScript + Vite (Fast Web App)"
12
+ },
13
+ "flutter": {
14
+ "cmd": "git clone --progress https://github.com/flutter/samples.git _temp_samples",
15
+ "desc": "Official Flutter Starter App (Native)"
16
+ },
17
+ "html": {
18
+ "cmd": None,
19
+ "desc": "Plain HTML/CSS/JS (Simple Web App)"
20
+ }
21
+ }
22
+
23
+ def generate_html_template(project_name):
24
+ # This function stays exactly the same
25
+ os.makedirs(project_name, exist_ok=True)
26
+ os.makedirs(os.path.join(project_name, "www"), exist_ok=True)
27
+ html_content = f"""...""" # Keep your HTML content
28
+ with open(os.path.join(project_name, "www", "index.html"), "w") as f:
29
+ f.write(html_content)
30
+
31
+ def create_project(framework, project_name):
32
+ """Generates a new project with a real progress bar for git clone."""
33
+ if framework not in TEMPLATES:
34
+ print_error(f"Unknown framework: {framework}.")
35
+
36
+ if os.path.exists(project_name):
37
+ print_error(f"Directory '{project_name}' already exists.")
38
+
39
+ template = TEMPLATES[framework]
40
+ print_info(f"Creating a new {BOLD}{template['desc']}{RESET} project in {CYAN}./{project_name}{RESET}...")
41
+
42
+ if framework == "html":
43
+ generate_html_template(project_name)
44
+ else:
45
+ # --- NEW REAL-TIME PROGRESS BAR LOGIC ---
46
+ cmd = template["cmd"].format(name=project_name)
47
+
48
+ try:
49
+ # We open a subprocess and read its stderr line by line
50
+ process = subprocess.Popen(
51
+ cmd,
52
+ shell=True,
53
+ stderr=subprocess.PIPE,
54
+ stdout=subprocess.DEVNULL, # Hide normal git output
55
+ text=True,
56
+ bufsize=1 # Line-buffered
57
+ )
58
+
59
+ # Regex to find the percentage in git's output
60
+ # It looks for "Receiving objects: 25%"
61
+ progress_regex = re.compile(r"(\d+)%")
62
+
63
+ # Read each line of output from the running git command
64
+ for line in iter(process.stderr.readline, ''):
65
+ match = progress_regex.search(line)
66
+ if match:
67
+ percentage = float(match.group(1))
68
+ print_progress_bar(percentage, f"Downloading {framework} template")
69
+
70
+ process.wait() # Wait for the command to finish
71
+ if process.returncode != 0:
72
+ raise Exception("Git clone failed.")
73
+
74
+ print_progress_bar(100.0, f"Downloading {framework} template")
75
+
76
+ # Post-clone cleanup for Flutter
77
+ if framework == "flutter":
78
+ shutil.move("_temp_samples/provider_counter", project_name)
79
+ shutil.rmtree("_temp_samples", ignore_errors=True)
80
+ shutil.rmtree(os.path.join(project_name, ".git"), ignore_errors=True)
81
+
82
+ except Exception as e:
83
+ # Clean up partial downloads on failure
84
+ if os.path.exists("_temp_samples"):
85
+ shutil.rmtree("_temp_samples", ignore_errors=True)
86
+ print_error(f"Project creation failed: {e}")
87
+ # --- END OF NEW LOGIC ---
88
+
89
+ print_success(f"Project '{project_name}' created successfully!")
90
+ print("\nNext steps:")
91
+ print(f" {CYAN}cd {project_name}{RESET}")
92
+ print(f" {CYAN}appforge init{RESET}")
93
+ print("")