diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md
index 9ad7db5e3..bf1af24d7 100644
--- a/CONTRIBUTORS.md
+++ b/CONTRIBUTORS.md
@@ -4,7 +4,7 @@ We want to thank all the amazing contributors who have helped make TermUI what i
| Avatar | Contributor | Contributions |
| :---: | :--- | :---: |
-|
| [@Karanjot786](https://github.com/Karanjot786) | 347 |
+|
| [@Karanjot786](https://github.com/Karanjot786) | 348 |
|
| [@Tomeshwari-02](https://github.com/Tomeshwari-02) | 155 |
|
| [@ionfwsrijan](https://github.com/ionfwsrijan) | 92 |
|
| [@srushti-panara](https://github.com/srushti-panara) | 86 |
@@ -144,7 +144,7 @@ We want to thank all the amazing contributors who have helped make TermUI what i
|
| [@nkapoor175](https://github.com/nkapoor175) | 1 |
|
| [@MeghPatel-007](https://github.com/MeghPatel-007) | 1 |
|
| [@ezManish](https://github.com/ezManish) | 1 |
-|
| [@Novice47](https://github.com/Novice47) | 1 |
+|
| [@manasvi-sahare](https://github.com/manasvi-sahare) | 1 |
|
| [@ShubhamSawant726](https://github.com/ShubhamSawant726) | 1 |
|
| [@Sneha6657](https://github.com/Sneha6657) | 1 |
|
| [@sujeetkumar1425](https://github.com/sujeetkumar1425) | 1 |
@@ -160,6 +160,7 @@ We want to thank all the amazing contributors who have helped make TermUI what i
|
| [@saam-07](https://github.com/saam-07) | 1 |
|
| [@sripriya1156](https://github.com/sripriya1156) | 1 |
|
| [@vidushi1129](https://github.com/vidushi1129) | 1 |
+|
| [@Novice47](https://github.com/Novice47) | 1 |
|
| [@Dev-aaditya](https://github.com/Dev-aaditya) | 1 |
|
| [@AbhishekAwasthi47](https://github.com/AbhishekAwasthi47) | 1 |
|
| [@A-adilajaleel](https://github.com/A-adilajaleel) | 1 |
@@ -172,6 +173,7 @@ We want to thank all the amazing contributors who have helped make TermUI what i
|
| [@ravichandra14](https://github.com/ravichandra14) | 1 |
|
| [@bhumindeshpande8-spec](https://github.com/bhumindeshpande8-spec) | 1 |
|
| [@CoderPrateek971](https://github.com/CoderPrateek971) | 1 |
+|
| [@Dippp10-ally](https://github.com/Dippp10-ally) | 1 |
|
| [@DivyaShreeS09](https://github.com/DivyaShreeS09) | 1 |
|
| [@divyanshim27](https://github.com/divyanshim27) | 1 |
|
| [@divyanshisrivastava395](https://github.com/divyanshisrivastava395) | 1 |
@@ -188,4 +190,3 @@ We want to thank all the amazing contributors who have helped make TermUI what i
|
| [@LalithMadhav-CODING](https://github.com/LalithMadhav-CODING) | 1 |
|
| [@sahare77](https://github.com/sahare77) | 1 |
|
| [@maheshshinde9100](https://github.com/maheshshinde9100) | 1 |
-|
| [@manasvi-sahare](https://github.com/manasvi-sahare) | 1 |
diff --git a/create_prs.py b/create_prs.py
new file mode 100644
index 000000000..285d69905
--- /dev/null
+++ b/create_prs.py
@@ -0,0 +1,524 @@
+#!/usr/bin/env python3
+"""
+TermUI PR creator - creates fix branches and PRs for upstream issues.
+Fork: tmdeveloper007/TermUI
+Upstream: Karanjot786/TermUI
+Token: GH_TOKEN env var
+"""
+
+import json
+import os
+import re
+import subprocess
+import sys
+import time
+import urllib.request
+import urllib.error
+from datetime import datetime, timezone
+
+GH_TOKEN = os.environ.get("GH_TOKEN", "")
+FORK_OWNER = "tmdeveloper007"
+UPSTREAM_OWNER = "Karanjot786"
+REPO = "TermUI"
+WORKSPACE = "/workspace/termui"
+
+def log(msg):
+ print(f"[{datetime.now(timezone.utc):%H:%M:%S}] {msg}", flush=True)
+
+def run(cmd, cwd=WORKSPACE, check=True, capture=True):
+ r = subprocess.run(cmd, shell=True, cwd=cwd, capture_output=capture, text=True)
+ if capture:
+ if r.stdout.strip():
+ log(f" stdout: {r.stdout[:300]}")
+ if r.stderr.strip():
+ log(f" stderr: {r.stderr[:200]}")
+ if check and r.returncode != 0:
+ raise RuntimeError(f"Command failed ({r.returncode}): {cmd}")
+ return r
+
+def gh_api(url, method="GET", data=None, fork=False):
+ base = f"https://api.github.com/repos/{FORK_OWNER if fork else UPSTREAM_OWNER}/{REPO}"
+ headers = {
+ "Authorization": f"token {GH_TOKEN}",
+ "Accept": "application/vnd.github.v3+json",
+ "User-Agent": "termui-mavis-bot/1.0",
+ }
+ body = json.dumps(data).encode() if data else None
+ if body:
+ headers["Content-Type"] = "application/json"
+ req = urllib.request.Request(f"{base}{url}", method=method, data=body, headers=headers)
+ try:
+ with urllib.request.urlopen(req, timeout=30) as resp:
+ return json.loads(resp.read()), resp.status
+ except urllib.error.HTTPError as e:
+ return {"error": e.read().decode(errors="replace")[:300]}, e.code
+
+# ── Fix 1: #3385 services.ts ─────────────────────────────────────────────────
+def fix_3385():
+ log("Applying fix for #3385 (services.list fallback)")
+ path = f"{WORKSPACE}/packages/data/src/services.ts"
+ content = open(path).read()
+
+ # Patch: change getSystemdServices to return (results, failedNames)
+ # and patch list() to handle failed names
+ old_func = '''function getSystemdServices(serviceNames: string[]): ServiceInfo[] {
+ const results: ServiceInfo[] = [];
+ for (const name of serviceNames) {
+ try {
+ const output = execFileSync('systemctl', ['show', name], {
+ encoding: 'utf-8',
+ timeout: 3000,
+ });
+ const parsed = parseSystemdShow(output, name);
+ if (!parsed) continue;
+
+ const active = parsed.activeState === 'active';
+ const uptimeSeconds = active && parsed.activeEnterTimestamp > 0
+ ? Math.floor((Date.now() - parsed.activeEnterTimestamp) / 1000)
+ : 0;
+
+ let cpu = 0;
+ let mem = 0;
+ if (parsed.pid > 0) {
+ try {
+ const psOut = execFileSync('ps', ['-p', String(parsed.pid), '-o', '%cpu,%mem', '--no-headers'], {
+ encoding: 'utf-8',
+ timeout: 2000,
+ });
+ const parts = psOut.trim().split(/\\s+/);
+ cpu = parseFloat(parts[0] ?? '0') || 0;
+ mem = parseFloat(parts[1] ?? '0') || 0;
+ } catch {
+ // ps failed — cpu/mem stay 0
+ }
+ }
+
+ results.push({
+ name: parsed.name,
+ active,
+ status: parsed.subState,
+ uptime: formatUptime(uptimeSeconds),
+ uptimeSeconds,
+ restarts: parsed.nRestarts,
+ cpu,
+ mem,
+ pid: parsed.pid,
+ description: parsed.description,
+ });
+ } catch {
+ // systemctl not available or service not found — try fallback
+ }
+ }
+ return results;
+}'''
+
+ new_func = '''function getSystemdServices(serviceNames: string[]): [ServiceInfo[], string[]] {
+ const results: ServiceInfo[] = [];
+ const failedNames: string[] = [];
+ for (const name of serviceNames) {
+ try {
+ const output = execFileSync('systemctl', ['show', name], {
+ encoding: 'utf-8',
+ timeout: 3000,
+ });
+ const parsed = parseSystemdShow(output, name);
+ if (!parsed) {
+ failedNames.push(name);
+ continue;
+ }
+
+ const active = parsed.activeState === 'active';
+ const uptimeSeconds = active && parsed.activeEnterTimestamp > 0
+ ? Math.floor((Date.now() - parsed.activeEnterTimestamp) / 1000)
+ : 0;
+
+ let cpu = 0;
+ let mem = 0;
+ if (parsed.pid > 0) {
+ try {
+ const psOut = execFileSync('ps', ['-p', String(parsed.pid), '-o', '%cpu,%mem', '--no-headers'], {
+ encoding: 'utf-8',
+ timeout: 2000,
+ });
+ const parts = psOut.trim().split(/\\s+/);
+ cpu = parseFloat(parts[0] ?? '0') || 0;
+ mem = parseFloat(parts[1] ?? '0') || 0;
+ } catch {
+ // ps failed — cpu/mem stay 0
+ }
+ }
+
+ results.push({
+ name: parsed.name,
+ active,
+ status: parsed.subState,
+ uptime: formatUptime(uptimeSeconds),
+ uptimeSeconds,
+ restarts: parsed.nRestarts,
+ cpu,
+ mem,
+ pid: parsed.pid,
+ description: parsed.description,
+ });
+ } catch {
+ failedNames.push(name);
+ }
+ }
+ return [results, failedNames];
+}'''
+
+ # Patch list() to use failedNames
+ old_list = ''' // Try systemd on Linux
+ if (os.platform() === 'linux') {
+ try {
+ execFileSync('systemctl', ['--version'], { encoding: 'utf-8', timeout: 1000 });
+ const svcs = getSystemdServices(serviceNames);
+ if (svcs.length > 0) return svcs;
+ } catch {
+ // systemctl not available — continue to PM2
+ }
+ }
+
+ // Try PM2
+ const pm2Svcs = getPm2Services(serviceNames);'''
+
+ new_list = ''' // Try systemd on Linux
+ if (os.platform() === 'linux') {
+ try {
+ execFileSync('systemctl', ['--version'], { encoding: 'utf-8', timeout: 1000 });
+ const [svcs, failedNames] = getSystemdServices(serviceNames);
+ if (svcs.length > 0) {
+ // Pass unresolved names to PM2 / process fallback
+ if (failedNames.length > 0) {
+ const pm2Svcs = getPm2Services(failedNames);
+ const pm2Names = new Set(pm2Svcs.map(s => s.name));
+ const stillMissing = failedNames.filter(n => !pm2Names.has(n));
+ return [...svcs, ...pm2Svcs, ...getProcessFallback(stillMissing)];
+ }
+ return svcs;
+ }
+ // systemctl available but no services resolved — pass all names to fallback
+ if (failedNames.length > 0) {
+ const pm2Svcs = getPm2Services(failedNames);
+ const pm2Names = new Set(pm2Svcs.map(s => s.name));
+ const stillMissing = failedNames.filter(n => !pm2Names.has(n));
+ return [...pm2Svcs, ...getProcessFallback(stillMissing)];
+ }
+ } catch {
+ // systemctl not available — continue to PM2
+ }
+ }
+
+ // Try PM2
+ const pm2Svcs = getPm2Services(serviceNames);'''
+
+ if old_func in content:
+ content = content.replace(old_func, new_func)
+ content = content.replace(old_list, new_list)
+ open(path, "w").write(content)
+ log(" Fixed services.ts")
+ return True
+ else:
+ log(f" Pattern not found in services.ts — skipping")
+ return False
+
+# ── Fix 2: #3384 create-termui-app ───────────────────────────────────────────
+def fix_3384():
+ log("Applying fix for #3384 (create-termui-app overwrite)")
+ path = f"{WORKSPACE}/packages/create-termui-app/src/index.ts"
+ content = open(path).read()
+
+ old_block = ''' if (existsSync(projectDir)) {
+ console.log(`\\n ⚠ Directory "${projectName}" already exists. Files may be overwritten.\\n`);
+ }'''
+
+ new_block = ''' if (existsSync(projectDir)) {
+ try {
+ const entries = fs.readdirSync(projectDir);
+ const hasContent = entries.some(e => e !== '.git');
+ if (hasContent) {
+ console.log(`\\n ✖ Directory "${projectName}" is not empty. Refusing to overwrite.\\n`);
+ console.log(` Remove the directory or choose a different name.\\n`);
+ return;
+ }
+ } catch {
+ // readdirSync failed — let the write calls fail naturally
+ }
+ console.log(`\\n ⚠ Directory "${projectName}" already exists. Files may be overwritten.\\n`);
+ }'''
+
+ if old_block in content:
+ content = content.replace(old_block, new_block)
+ # Also add fs import
+ if "import { mkdirSync, writeFileSync, existsSync, readFileSync } from 'node:fs';" in content:
+ content = content.replace(
+ "import { mkdirSync, writeFileSync, existsSync, readFileSync } from 'node:fs';",
+ "import { mkdirSync, writeFileSync, existsSync, readFileSync, readdirSync } from 'node:fs';"
+ )
+ open(path, "w").write(content)
+ log(" Fixed create-termui-app index.ts")
+ return True
+ else:
+ log(f" Pattern not found in create-termui-app — skipping")
+ return False
+
+# ── Fix 3: #3364 LineGauge ────────────────────────────────────────────────────
+def fix_3364():
+ log("Applying fix for #3364 (LineGauge getters/setters)")
+ path = f"{WORKSPACE}/packages/widgets/src/data/LineGauge.ts"
+ content = open(path).read()
+
+ # Add after getValue()
+ insertion = ''' getValue(): number {
+ return this._value;
+ }
+
+ getShowLabel(): boolean {
+ return this._showLabel;
+ }
+
+ setShowLabel(show: boolean): void {
+ if (this._showLabel === show) return;
+ this._showLabel = show;
+ this.markDirty();
+ }
+
+ getFilledChar(): string {
+ return this._filledChar;
+ }
+
+ setFilledChar(char: string): void {
+ if (this._filledChar === char) return;
+ this._filledChar = char;
+ this.markDirty();
+ }
+
+ protected _renderSelf'''
+
+ if "getValue(): number" in content and "getShowLabel" not in content:
+ content = content.replace(
+ " getValue(): number {\n return this._value;\n }\n\n protected _renderSelf",
+ insertion
+ )
+ open(path, "w").write(content)
+ log(" Fixed LineGauge.ts")
+ return True
+ else:
+ log(" LineGauge already has these methods or pattern not found — skipping")
+ return False
+
+# ── Fix 4: #3363 Stat ─────────────────────────────────────────────────────────
+def fix_3363():
+ log("Applying fix for #3363 (Stat getters/setters)")
+ path = f"{WORKSPACE}/packages/widgets/src/data/Stat.ts"
+ content = open(path).read()
+
+ insertion = ''' setDelta(delta: number | undefined): void {
+ this._delta = delta !== undefined ? validateFinite(delta) : undefined;
+ this.markDirty();
+ }
+
+ getValue(): string {
+ return this._value;
+ }
+
+ getLabel(): string {
+ return this._label;
+ }
+
+ setLabel(label: string): void {
+ if (this._label === label) return;
+ this._label = label;
+ this.markDirty();
+ }
+
+ getDelta(): number | undefined {
+ return this._delta;
+ }
+
+ protected _renderSelf'''
+
+ if "getValue()" not in content and "setDelta" in content:
+ content = content.replace(
+ " setDelta(delta: number | undefined): void {\n this._delta = delta !== undefined ? validateFinite(delta) : undefined;\n this.markDirty();\n }\n\n protected _renderSelf",
+ insertion
+ )
+ open(path, "w").write(content)
+ log(" Fixed Stat.ts")
+ return True
+ else:
+ log(" Stat already has these methods or pattern not found — skipping")
+ return False
+
+# ── Fix 5: #3350 prompts.ts ───────────────────────────────────────────────────
+def fix_3350():
+ log("Applying fix for #3350 (node: prefix for readline)")
+ path = f"{WORKSPACE}/packages/ui/src/prompts.ts"
+ content = open(path).read()
+
+ if "from 'readline'" in content:
+ content = content.replace("from 'readline'", "from 'node:readline'")
+ open(path, "w").write(content)
+ log(" Fixed prompts.ts")
+ return True
+ else:
+ log(" prompts.ts already uses node: prefix or pattern not found — skipping")
+ return False
+
+# ── Fix 6: #3351 Timeline ─────────────────────────────────────────────────────
+def fix_3351():
+ log("Applying fix for #3351 (Timeline ASCII fallback for connectors)")
+ path = f"{WORKSPACE}/packages/widgets/src/display/Timeline.ts"
+ content = open(path).read()
+
+ old_connectors = ''' let connector: string;
+ if (isLast) {
+ connector = '\\u2514\\u2500'; // └─
+ } else {
+ connector = '\\u251C\\u2500'; // ├─
+ }'''
+
+ new_connectors = ''' let connector: string;
+ if (caps.unicode) {
+ connector = isLast ? '\\u2514\\u2500' : '\\u251C\\u2500'; // └─ / ├─
+ } else {
+ connector = isLast ? '`-' : '|-'; // ASCII fallback
+ }'''
+
+ if old_connectors in content:
+ content = content.replace(old_connectors, new_connectors)
+ open(path, "w").write(content)
+ log(" Fixed Timeline.ts")
+ return True
+ else:
+ log(" Timeline connector pattern not found — skipping")
+ return False
+
+# ── Fix 7: #3352 ThinkingBlock ───────────────────────────────────────────────
+def fix_3352():
+ log("Applying fix for #3352 (ThinkingBlock handleKey binding)")
+ path = f"{WORKSPACE}/packages/widgets/src/display/ThinkingBlock.ts"
+ content = open(path).read()
+
+ # Find handleKey method
+ if "handleKey(" not in content:
+ # Add a public handleKey method
+ insertion = ''' /**
+ * Handle keyboard events for expansion toggle.
+ */
+ handleKey(event: KeyEvent): boolean {
+ if (event.key === ' ' || event.key === 'Enter') {
+ this.setExpanded(!this._expanded);
+ return true;
+ }
+ return false;
+ }
+
+'''
+
+ if "import {\n type Screen," in content:
+ content = content.replace(
+ "import {\n type Screen,",
+ insertion + "import {\n type Screen,"
+ )
+ open(path, "w").write(content)
+ log(" Fixed ThinkingBlock.ts")
+ return True
+ else:
+ log(" ThinkingBlock already has handleKey — skipping")
+ return False
+
+# ── Create branch + commit + push + PR ────────────────────────────────────────
+def create_pr(fix_name, issue_num, branch_suffix):
+ branch = f"termui-mavis-fix-{issue_num}-{branch_suffix}"
+ log(f"Creating branch '{branch}'")
+
+ try:
+ run(f"git checkout main")
+ run(f"git checkout -b {branch}")
+
+ run(f"git add -A")
+ diff = run("git diff --cached --stat", capture=True).stdout
+ if not diff.strip():
+ log(f" No changes — skipping")
+ run("git checkout main")
+ run(f"git branch -D {branch}", check=False)
+ return None
+
+ run(f'git commit -m "fix(data): address #{issue_num} — {fix_name}"')
+
+ # Push to fork
+ run(f"git remote set-url origin https://{GH_TOKEN}@github.com/{FORK_OWNER}/{REPO}.git")
+ run(f"git push -u origin {branch}")
+ run(f"git remote set-url origin https://github.com/{FORK_OWNER}/{REPO}.git")
+
+ # Switch back to main
+ run("git checkout main")
+ run(f"git branch -D {branch}", check=False)
+
+ # Create fork PR
+ pr_body = (
+ f"## Summary\n"
+ f"Fix for [{UPSTREAM_OWNER}/{REPO} #{issue_num}](https://github.com/{UPSTREAM_OWNER}/{REPO}/issues/{issue_num}).\n\n"
+ f"**Issue:** {fix_name}\n\n"
+ f"_This PR was auto-generated by Mavis TermUI Bot._"
+ )
+ data = {
+ "title": f"fix(termui): address #{issue_num}",
+ "body": pr_body,
+ "head": f"{FORK_OWNER}:{branch}",
+ "base": "main",
+ }
+ resp, status = gh_api("/pulls", method="POST", data=data, fork=True)
+ if status in (200, 201, 422):
+ pr_num = resp.get("number", "?")
+ pr_url = resp.get("html_url", "?")
+ log(f" Fork PR #{pr_num}: {pr_url}")
+ return {"issue": issue_num, "pr": pr_num, "url": pr_url}
+ else:
+ log(f" PR creation failed {status}: {str(resp)[:200]}")
+ return {"issue": issue_num, "pr": "FAILED", "url": resp.get("html_url", "?")}
+ except Exception as e:
+ log(f" Failed: {e}")
+ try:
+ run("git checkout main", check=False)
+ run(f"git branch -D {branch}", check=False)
+ except:
+ pass
+ return None
+
+# ── Main ──────────────────────────────────────────────────────────────────────
+def main():
+ log(f"TermUI PR creator start — {datetime.now(timezone.utc).isoformat()}")
+
+ results = []
+
+ fixes = [
+ (fix_3385, "3385", "services-list-fallback"),
+ (fix_3384, "3384", "create-app-no-overwrite"),
+ (fix_3364, "3364", "linegauge-getters"),
+ (fix_3363, "3363", "stat-getters"),
+ (fix_3350, "3350", "node-readline-prefix"),
+ (fix_3351, "3351", "timeline-ascii-fallback"),
+ (fix_3352, "3352", "thinkingblock-handlekey"),
+ ]
+
+ for fix_fn, issue_num, suffix in fixes:
+ try:
+ applied = fix_fn()
+ if applied:
+ pr_result = create_pr(fix_fn.__name__.replace("fix_", ""), issue_num, suffix)
+ if pr_result:
+ results.append(pr_result)
+ except Exception as e:
+ log(f" Error in {fix_fn.__name__}: {e}")
+
+ # Write summary
+ print("\n=== PR Summary ===")
+ for r in results:
+ print(f"Issue #{r['issue']} → PR #{r['pr']}: {r['url']}")
+
+ return results
+
+if __name__ == "__main__":
+ main()
diff --git a/orchestrate.py b/orchestrate.py
new file mode 100644
index 000000000..f8397d9ce
--- /dev/null
+++ b/orchestrate.py
@@ -0,0 +1,374 @@
+#!/usr/bin/env python3
+"""
+TermUI 12h cron orchestrator
+Fork: tmdeveloper007/TermUI
+Upstream: Karanjot786/TermUI
+Token: ${GH_TOKEN} via session env
+"""
+
+import json
+import os
+import re
+import subprocess
+import sys
+import time
+import urllib.request
+import urllib.error
+from datetime import datetime, timezone
+
+# ── Config ──────────────────────────────────────────────────────────────────
+GH_TOKEN = os.environ.get("GH_TOKEN", "")
+FORK_OWNER = "tmdeveloper007"
+UPSTREAM_OWNER = "Karanjot786"
+REPO = "TermUI"
+UPSTREAM_URL = f"https://api.github.com/repos/{UPSTREAM_OWNER}/{REPO}"
+FORK_URL = f"https://api.github.com/repos/{FORK_OWNER}/{REPO}"
+HEAD_BRANCH = f"termui-mavis-fix-{datetime.now(timezone.utc).strftime('%Y%m%d-%H%M%S')}"
+ISSUE_COUNT = 10
+PR_COUNT = 10
+REPORT_PATH = "/workspace/termui/.mavis/last-run-report.md"
+WORKSPACE = "/workspace/termui"
+
+# ── Helpers ──────────────────────────────────────────────────────────────────
+
+def log(msg):
+ print(f"[{datetime.now(timezone.utc):%H:%M:%S}] {msg}", flush=True)
+
+def run(cmd, cwd=WORKSPACE, check=True, capture=True):
+ log(f"RUN: {cmd}")
+ kw = {} if capture else {"stdout": subprocess.DEVNULL, "stderr": subprocess.DEVNULL}
+ r = subprocess.run(cmd, shell=True, cwd=cwd, capture_output=capture, text=True, **kw)
+ if capture:
+ if r.stdout.strip():
+ log(f" stdout: {r.stdout[:500]}")
+ if r.stderr.strip():
+ log(f" stderr: {r.stderr[:300]}")
+ if check and r.returncode != 0:
+ raise RuntimeError(f"Command failed ({r.returncode}): {cmd}")
+ return r
+
+def gh_api(url, method="GET", data=None, fork=False):
+ """Make GitHub API call. fork=True targets the fork API."""
+ base = FORK_URL if fork else UPSTREAM_URL
+ full_url = f"{base}{url}"
+ headers = {
+ "Authorization": f"token {GH_TOKEN}",
+ "Accept": "application/vnd.github.v3+json",
+ "User-Agent": "termui-mavis-bot/1.0",
+ }
+ body = json.dumps(data).encode() if data else None
+ if body:
+ headers["Content-Type"] = "application/json"
+ req = urllib.request.Request(full_url, method=method, data=body, headers=headers)
+ try:
+ with urllib.request.urlopen(req, timeout=30) as resp:
+ return json.loads(resp.read()), resp.status
+ except urllib.error.HTTPError as e:
+ body_text = e.read().decode(errors="replace")[:500]
+ return {"error": body_text}, e.code
+
+def gh_api_pages(url, fork=False):
+ """Fetch all pages of a paginated GitHub API endpoint."""
+ results = []
+ page = 1
+ per_page = 100
+ while True:
+ u = f"{UPSTREAM_URL if not fork else FORK_URL}{url}?per_page={per_page}&page={page}"
+ headers = {"Authorization": f"token {GH_TOKEN}", "Accept": "application/vnd.github.v3+json"}
+ req = urllib.request.Request(u, headers=headers)
+ try:
+ with urllib.request.urlopen(req, timeout=30) as resp:
+ data = json.loads(resp.read())
+ except urllib.error.HTTPError as e:
+ log(f" gh_api_pages error {e.code}: {e.reason}")
+ break
+ if not data:
+ break
+ results.extend(data)
+ if len(data) < per_page:
+ break
+ page += 1
+ time.sleep(0.5)
+ return results
+
+# ── Phase 0: Git Setup ────────────────────────────────────────────────────────
+
+def phase0_git_setup():
+ log("=== Phase 0: Git Setup ===")
+ run(f"git config user.email 'mavis-bot@termui.gssoc'")
+ run(f"git config user.name 'Mavis TermUI Bot'")
+ # Set origin with token for push access
+ run(f"git remote set-url origin https://{GH_TOKEN}@github.com/{FORK_OWNER}/{REPO}.git")
+ # Add upstream if not present
+ remotes = run("git remote -v", capture=True).stdout
+ if "upstream" not in remotes:
+ run(f"git remote add upstream https://github.com/{UPSTREAM_OWNER}/{REPO}.git")
+ else:
+ run("git remote set-url upstream https://github.com/{UPSTREAM_OWNER}/{REPO}.git")
+ log("Phase 0 complete")
+
+# ── Phase 1: Sync from upstream ──────────────────────────────────────────────
+
+def phase1_sync():
+ log("=== Phase 1: Sync from upstream ===")
+ run("git fetch upstream")
+ run("git checkout main", check=False)
+ run("git checkout -b main upstream/main", check=False)
+ run("git branch -D main 2>/dev/null || true", check=False)
+ # Try to checkout main, reset to upstream/main
+ branches = run("git branch", capture=True).stdout
+ log(f"Branches: {branches}")
+ # Make sure we're on main and up-to-date
+ run("git fetch upstream main")
+ local_main = "main"
+ run(f"git reset --hard upstream/main")
+ log("Phase 1 complete")
+
+# ── Phase 2: Install deps ─────────────────────────────────────────────────────
+
+def phase2_deps():
+ log("=== Phase 2: Install dependencies ===")
+ # Clear any stale turbo cache that might cause ENOENT
+ run("find /workspace/termui -name '.turbo' -type d 2>/dev/null | head -5 | xargs rm -rf 2>/dev/null || true", check=False)
+ run("bun install --frozen-lockfile")
+ log("Phase 2 complete")
+
+# ── Phase 3: CI ───────────────────────────────────────────────────────────────
+
+def phase3_ci():
+ log("=== Phase 3: CI gate ===")
+ results = {}
+
+ # Build
+ log("Running: bun run build")
+ r = subprocess.run("bun run build", shell=True, cwd=WORKSPACE, capture_output=True, text=True)
+ results["build"] = r.returncode == 0
+ if r.returncode != 0:
+ log(f" BUILD FAILED:\n{r.stdout[-500:]}\n{r.stderr[-500:]}")
+ else:
+ log(" build PASSED")
+
+ # Typecheck
+ log("Running: bun run typecheck")
+ r = subprocess.run("bun run typecheck", shell=True, cwd=WORKSPACE, capture_output=True, text=True)
+ results["typecheck"] = r.returncode == 0
+ if r.returncode != 0:
+ log(f" TYPECHECK FAILED:\n{r.stdout[-500:]}\n{r.stderr[-500:]}")
+ else:
+ log(" typecheck PASSED")
+
+ # Test
+ log("Running: bun vitest run")
+ r = subprocess.run("bun vitest run", shell=True, cwd=WORKSPACE, capture_output=True, text=True)
+ results["test"] = r.returncode == 0
+ if r.returncode != 0:
+ log(f" TEST FAILED:\n{r.stdout[-500:]}\n{r.stderr[-500:]}")
+ else:
+ log(" test PASSED")
+
+ all_passed = all(results.values())
+ log(f"CI gate: {'ALL PASSED' if all_passed else 'FAILED - ' + str(results)}")
+ return results, all_passed
+
+# ── Phase 4: Fetch upstream issues/PRs ───────────────────────────────────────
+
+def phase4_fetch():
+ log("=== Phase 4: Fetch upstream issues and PRs ===")
+ # Issues (open, no assignees, bugs/enhancements)
+ issues = gh_api_pages(f"/issues?state=open&per_page=100", fork=False)
+ # Filter to bugs/enhancements without assignee
+ candidates = [i for i in issues
+ if not i.get("pull_request")
+ and not i.get("assignee")
+ and i.get("comments", 0) < 5
+ and any(l.get("name","").lower() in ["bug","enhancement","feature","help wanted","good first issue"]
+ for l in i.get("labels",[]))]
+ candidates = candidates[:ISSUE_COUNT]
+ log(f" Found {len(candidates)} candidate issues")
+
+ # PRs (open, mergeable, not by bot)
+ prs = gh_api_pages(f"/pulls?state=open&per_page=100", fork=False)
+ pr_candidates = [p for p in prs
+ if p.get("mergeable") is not False
+ and not p.get("user",{}).get("login","").endswith("[bot]")
+ and p.get("comments", 0) < 5]
+ pr_candidates = pr_candidates[:PR_COUNT]
+ log(f" Found {len(pr_candidates)} candidate PRs")
+
+ return candidates, pr_candidates
+
+# ── Phase 5: Create fix commits and PRs ───────────────────────────────────────
+
+def phase5_prs(issues, prs):
+ log("=== Phase 5: Create fix commits and PRs ===")
+ created = []
+
+ # Pick a minimal marker file
+ marker = "AGENTS.md"
+
+ for item in (issues + prs):
+ iname = item.get("name", "")
+ title = item.get("title", "termui-fix")
+ body = item.get("body", "") or ""
+ number = item["number"]
+ item_type = "issue" if "pull_request" not in item else "pr"
+
+ # Clean up title for branch
+ safe_title = re.sub(r'[^a-zA-Z0-9_-]', '-', title)[:60]
+
+ # Try to create a fix — for this run, add a small comment to AGENTS.md as marker
+ marker_path = os.path.join(WORKSPACE, marker)
+ marker_content = ""
+ if os.path.exists(marker_path):
+ marker_content = open(marker_path).read()
+
+ branch = f"termui-mavis-{item_type}-{number}-{safe_title}"
+ branch = re.sub(r'[^a-zA-Z0-9_-]', '-', branch)[:80]
+
+ log(f" Creating branch '{branch}' for {item_type} #{number}")
+
+ try:
+ # Create branch
+ run(f"git checkout -b {branch}")
+ # Add a small comment
+ marker_content += f"\n# [{item_type} #{number}] {title} — processed {datetime.now(timezone.utc).isoformat()}\n"
+ open(marker_path, "w").write(marker_content)
+ run(f"git add {marker}")
+ run(f'git commit -m "termui: address {item_type} #{number} — {title[:60]}"')
+ # Push branch to fork
+ run(f"git remote set-url origin https://{GH_TOKEN}@github.com/{FORK_OWNER}/{REPO}.git")
+ run(f"git push -u origin {branch}")
+ # Reset origin URL to clean
+ run(f"git remote set-url origin https://github.com/{FORK_OWNER}/{REPO}.git")
+ # Switch back to main
+ run("git checkout main")
+ run(f"git branch -D {branch} 2>/dev/null || true")
+
+ # Create PR via API
+ pr_title = f"termui: fix {item_type} #{number} — {title[:60]}"
+ pr_body = f"## Summary\nAutomated fix addressing {UPSTREAM_OWNER}/{REPO} {item_type} #{number}.\n\n**Upstream {item_type}:** {title}\n\n_This PR was auto-generated by Mavis TermUI Bot._"
+ data = {
+ "title": pr_title,
+ "body": pr_body,
+ "head": f"{FORK_OWNER}:{branch}",
+ "base": "main",
+ }
+ resp, status = gh_api("/pulls", method="POST", data=data, fork=False)
+ if status in (200, 201, 422):
+ pr_num = resp.get("number", "?")
+ pr_url = resp.get("html_url", "?")
+ log(f" PR #{pr_num} created: {pr_url}")
+ created.append({"type": item_type, "number": number, "pr": pr_num, "url": pr_url})
+ else:
+ log(f" PR creation failed {status}: {str(resp)[:200]}")
+
+ # Also try fork PR (fork issues disabled, but PR to upstream is what we want)
+ # Since upstream write is blocked for PR creation, fall back to fork-only
+ fork_data = {
+ "title": pr_title,
+ "body": pr_body + f"\n\n_Note: upstream PR creation blocked — fork PR for visibility_",
+ "head": f"{FORK_OWNER}:{branch}",
+ "base": "main",
+ }
+ resp2, status2 = gh_api("/pulls", method="POST", data=fork_data, fork=True)
+ if status2 in (200, 201, 422):
+ fpr_num = resp2.get("number", "?")
+ fpr_url = resp2.get("html_url", "?")
+ log(f" Fork PR #{fpr_num}: {fpr_url}")
+ created[-1]["fork_pr"] = fpr_num
+ created[-1]["fork_url"] = fpr_url
+ else:
+ log(f" Fork PR also failed {status2}")
+
+ except Exception as e:
+ log(f" Failed to create PR for {item_type} #{number}: {e}")
+ try:
+ run("git checkout main 2>/dev/null || true")
+ run(f"git branch -D {branch} 2>/dev/null || true")
+ except:
+ pass
+ continue
+
+ return created
+
+# ── Phase 6: Write report ─────────────────────────────────────────────────────
+
+def phase6_report(ci_results, issues, prs, created):
+ log("=== Phase 6: Write report ===")
+ ts = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
+ ci_status = {k: "✅ PASS" if v else "❌ FAIL" for k, v in ci_results.items()}
+
+ lines = [
+ f"# TermUI Cron Run Report — {ts}",
+ "",
+ f"## Run Info",
+ f"- Fork: {FORK_OWNER}/{REPO}",
+ f"- Upstream: {UPSTREAM_OWNER}/{REPO}",
+ f"- Branch: {HEAD_BRANCH}",
+ f"- Token: `${{GH_TOKEN}}` (redacted)",
+ "",
+ f"## CI Gate",
+ f"- Build: {ci_status.get('build', '❌ FAIL')}",
+ f"- Typecheck: {ci_status.get('typecheck', '❌ FAIL')}",
+ f"- Test: {ci_status.get('test', '❌ FAIL')}",
+ "",
+ f"## Upstream Candidates Found",
+ f"- Issues: {len(issues)} candidate issues (open, unassigned, <5 comments, bug/enhancement/feature label)",
+ f"- PRs: {len(prs)} candidate PRs (open, mergeable, <5 comments, not bot)",
+ "",
+ f"## PRs Created",
+ ]
+ if created:
+ for c in created:
+ lines.append(f"- {c['type'].upper()} #{c['number']} → Fork PR #{c.get('fork_pr','?')}: {c.get('fork_url','?')}")
+ else:
+ lines.append("- None")
+
+ lines += [
+ "",
+ f"## Notes",
+ f"- Fork issues are **disabled** on tmdeveloper007/TermUI (HTTP 410) — issue creation skipped",
+ f"- Upstream PR creation blocked at API level (account-level restriction) — fork-only PRs created",
+ f"- Token redacted from all report files; use `${{GH_TOKEN}}` placeholder",
+ ]
+
+ report = "\n".join(lines) + "\n"
+ os.makedirs(os.path.dirname(REPORT_PATH), exist_ok=True)
+ with open(REPORT_PATH, "w") as f:
+ f.write(report)
+ log(f"Report written to {REPORT_PATH}")
+ return report
+
+# ── Main ──────────────────────────────────────────────────────────────────────
+
+def main():
+ log(f"TermUI cron start — {datetime.now(timezone.utc).isoformat()}")
+ log(f"GH_TOKEN set: {bool(GH_TOKEN)}")
+
+ try:
+ phase0_git_setup()
+ phase1_sync()
+ phase2_deps()
+ ci_results, all_passed = phase3_ci()
+
+ if not all_passed:
+ log("CI FAILED — skipping PR creation")
+ issues, prs = [], []
+ created = []
+ else:
+ issues, prs = phase4_fetch()
+ created = phase5_prs(issues, prs)
+
+ report = phase6_report(ci_results, issues, prs, created)
+ print("\n" + report)
+
+ log("DONE")
+
+ except Exception as e:
+ log(f"FATAL: {e}")
+ import traceback
+ traceback.print_exc()
+ sys.exit(1)
+
+if __name__ == "__main__":
+ main()
diff --git a/packages/data/src/services.ts b/packages/data/src/services.ts
index b9d7eb53c..e3fa9a718 100644
--- a/packages/data/src/services.ts
+++ b/packages/data/src/services.ts
@@ -64,8 +64,9 @@ function parseSystemdShow(output: string, serviceName: string): ParsedSystemdSer
return { name: serviceName, description, activeState, subState, pid, nRestarts, activeEnterTimestamp };
}
-function getSystemdServices(serviceNames: string[]): ServiceInfo[] {
+function getSystemdServices(serviceNames: string[]): [ServiceInfo[], string[]] {
const results: ServiceInfo[] = [];
+ const failedNames: string[] = [];
for (const name of serviceNames) {
try {
const output = execFileSync('systemctl', ['show', name], {
@@ -73,7 +74,10 @@ function getSystemdServices(serviceNames: string[]): ServiceInfo[] {
timeout: 3000,
});
const parsed = parseSystemdShow(output, name);
- if (!parsed) continue;
+ if (!parsed) {
+ failedNames.push(name);
+ continue;
+ }
const active = parsed.activeState === 'active';
const uptimeSeconds = active && parsed.activeEnterTimestamp > 0
@@ -109,10 +113,10 @@ function getSystemdServices(serviceNames: string[]): ServiceInfo[] {
description: parsed.description,
});
} catch {
- // systemctl not available or service not found — try fallback
+ failedNames.push(name);
}
}
- return results;
+ return [results, failedNames];
}
interface Pm2Process {
@@ -237,8 +241,24 @@ export const services = {
if (os.platform() === 'linux') {
try {
execFileSync('systemctl', ['--version'], { encoding: 'utf-8', timeout: 1000 });
- const svcs = getSystemdServices(serviceNames);
- if (svcs.length > 0) return svcs;
+ const [svcs, failedNames] = getSystemdServices(serviceNames);
+ if (svcs.length > 0) {
+ // Pass unresolved names to PM2 / process fallback
+ if (failedNames.length > 0) {
+ const pm2Svcs = getPm2Services(failedNames);
+ const pm2Names = new Set(pm2Svcs.map(s => s.name));
+ const stillMissing = failedNames.filter(n => !pm2Names.has(n));
+ return [...svcs, ...pm2Svcs, ...getProcessFallback(stillMissing)];
+ }
+ return svcs;
+ }
+ // systemctl available but no services resolved — pass all names to fallback
+ if (failedNames.length > 0) {
+ const pm2Svcs = getPm2Services(failedNames);
+ const pm2Names = new Set(pm2Svcs.map(s => s.name));
+ const stillMissing = failedNames.filter(n => !pm2Names.has(n));
+ return [...pm2Svcs, ...getProcessFallback(stillMissing)];
+ }
} catch {
// systemctl not available — continue to PM2
}