@aion0/forge 0.3.3 → 0.3.5

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.
@@ -19,6 +19,7 @@ interface Skill {
19
19
  installedVersion: string;
20
20
  hasUpdate: boolean;
21
21
  installedProjects: string[];
22
+ deletedRemotely: boolean;
22
23
  }
23
24
 
24
25
  interface ProjectInfo {
@@ -315,8 +316,9 @@ export default function SkillsPanel({ projectFilter }: { projectFilter?: string
315
316
  {skill.tags.slice(0, 2).map(t => (
316
317
  <span key={t} className="text-[7px] px-1 rounded bg-[var(--bg-tertiary)] text-[var(--text-secondary)]">{t}</span>
317
318
  ))}
318
- {skill.hasUpdate && <span className="text-[8px] text-[var(--yellow)] ml-auto">update</span>}
319
- {isInstalled && !skill.hasUpdate && <span className="text-[8px] text-[var(--green)] ml-auto">installed</span>}
319
+ {skill.deletedRemotely && <span className="text-[8px] text-[var(--red)] ml-auto">deleted remotely</span>}
320
+ {!skill.deletedRemotely && skill.hasUpdate && <span className="text-[8px] text-[var(--yellow)] ml-auto">update</span>}
321
+ {!skill.deletedRemotely && isInstalled && !skill.hasUpdate && <span className="text-[8px] text-[var(--green)] ml-auto">installed</span>}
320
322
  </div>
321
323
  </div>
322
324
  );
@@ -421,7 +423,8 @@ export default function SkillsPanel({ projectFilter }: { projectFilter?: string
421
423
  (skill?.type || localItem?.type) === 'skill' ? 'bg-purple-500/20 text-purple-400' : 'bg-blue-500/20 text-blue-400'
422
424
  }`}>{(skill?.type || localItem?.type) === 'skill' ? 'Skill' : 'Command'}</span>
423
425
  {isLocal && <span className="text-[7px] px-1 rounded bg-green-500/10 text-green-400">local</span>}
424
- {skill && <span className="text-[9px] text-[var(--text-secondary)] font-mono">v{skill.version}</span>}
426
+ {skill?.deletedRemotely && <span className="text-[7px] px-1.5 py-0.5 rounded bg-red-500/20 text-red-400 font-medium">Deleted remotely</span>}
427
+ {skill && !skill.deletedRemotely && <span className="text-[9px] text-[var(--text-secondary)] font-mono">v{skill.version}</span>}
425
428
  {skill?.installedVersion && skill.installedVersion !== skill.version && (
426
429
  <span className="text-[9px] text-[var(--yellow)] font-mono">installed: v{skill.installedVersion}</span>
427
430
  )}
@@ -433,7 +436,7 @@ export default function SkillsPanel({ projectFilter }: { projectFilter?: string
433
436
  {skill && skill.score > 0 && <span className="text-[9px] text-[var(--text-secondary)]">{skill.score}pt</span>}
434
437
 
435
438
  {/* Update button */}
436
- {skill?.hasUpdate && (
439
+ {skill?.hasUpdate && !skill.deletedRemotely && (
437
440
  <button
438
441
  onClick={async () => {
439
442
  if (skill.installedGlobal) await install(skill.name, 'global');
@@ -445,6 +448,25 @@ export default function SkillsPanel({ projectFilter }: { projectFilter?: string
445
448
  </button>
446
449
  )}
447
450
 
451
+ {/* Delete button for skills removed from remote registry */}
452
+ {skill?.deletedRemotely && (
453
+ <button
454
+ onClick={async () => {
455
+ if (!confirm(`"${skill.name}" was deleted from the remote repository.\n\nDelete the local installation as well?`)) return;
456
+ await fetch('/api/skills', {
457
+ method: 'POST',
458
+ headers: { 'Content-Type': 'application/json' },
459
+ body: JSON.stringify({ action: 'purge-deleted', name: skill.name }),
460
+ });
461
+ setExpandedSkill(null);
462
+ fetchSkills();
463
+ }}
464
+ className="text-[9px] px-2 py-1 bg-red-500/20 text-red-400 border border-red-500/40 rounded hover:bg-red-500/30 transition-colors ml-auto"
465
+ >
466
+ Delete local
467
+ </button>
468
+ )}
469
+
448
470
  {/* Local item actions: install to other projects, delete */}
449
471
  {isLocal && localItem && (
450
472
  <>
@@ -505,8 +527,8 @@ export default function SkillsPanel({ projectFilter }: { projectFilter?: string
505
527
  </>
506
528
  )}
507
529
 
508
- {/* Install dropdown — registry items only */}
509
- {skill && <div className="relative ml-auto">
530
+ {/* Install dropdown — registry items only (not deleted remotely) */}
531
+ {skill && !skill.deletedRemotely && <div className="relative ml-auto">
510
532
  <button
511
533
  onClick={() => setInstallTarget(prev =>
512
534
  prev.skill === skill.name && prev.show ? { skill: '', show: false } : { skill: skill.name, show: true }
@@ -0,0 +1,46 @@
1
+ 'use client';
2
+
3
+ import React from 'react';
4
+
5
+ export interface Tab {
6
+ id: number;
7
+ label: string;
8
+ }
9
+
10
+ export interface TabBarProps {
11
+ tabs: Tab[];
12
+ activeId: number;
13
+ onActivate: (id: number) => void;
14
+ onClose: (id: number) => void;
15
+ }
16
+
17
+ export default function TabBar({ tabs, activeId, onActivate, onClose }: TabBarProps) {
18
+ if (tabs.length === 0) return null;
19
+
20
+ return (
21
+ <div className="flex items-center border-b border-[var(--border)] bg-[var(--bg-tertiary)] overflow-x-auto shrink-0">
22
+ {tabs.map(tab => (
23
+ <div
24
+ key={tab.id}
25
+ className={`flex items-center gap-1 px-3 py-1.5 text-[11px] cursor-pointer border-r border-[var(--border)]/30 shrink-0 group ${
26
+ tab.id === activeId
27
+ ? 'bg-[var(--bg-primary)] text-[var(--text-primary)] border-b-2 border-b-[var(--accent)]'
28
+ : 'text-[var(--text-secondary)] hover:bg-[var(--bg-secondary)] hover:text-[var(--text-primary)]'
29
+ }`}
30
+ onClick={() => onActivate(tab.id)}
31
+ >
32
+ <span className="truncate max-w-[120px]">{tab.label}</span>
33
+ <button
34
+ onClick={(e) => {
35
+ e.stopPropagation();
36
+ onClose(tab.id);
37
+ }}
38
+ className="text-[9px] w-4 h-4 flex items-center justify-center rounded hover:bg-[var(--bg-tertiary)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] opacity-0 group-hover:opacity-100 shrink-0"
39
+ >
40
+ x
41
+ </button>
42
+ </div>
43
+ ))}
44
+ </div>
45
+ );
46
+ }
@@ -42,15 +42,16 @@ interface TabState {
42
42
  // ─── Layout persistence ──────────────────────────────────────
43
43
 
44
44
  function getWsUrl() {
45
- if (typeof window === 'undefined') return 'ws://localhost:3001';
45
+ if (typeof window === 'undefined') return `ws://localhost:${parseInt(process.env.TERMINAL_PORT || '3001')}`;
46
46
  const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
47
47
  const wsHost = window.location.hostname;
48
48
  // When accessed via tunnel or non-localhost, use the Next.js proxy path
49
- // so the WS goes through the same origin (no need to expose port 3001)
50
49
  if (wsHost !== 'localhost' && wsHost !== '127.0.0.1') {
51
50
  return `${wsProtocol}//${window.location.host}/terminal-ws`;
52
51
  }
53
- return `${wsProtocol}//${wsHost}:3001`;
52
+ // Terminal port = web port + 1
53
+ const webPort = parseInt(window.location.port) || 3000;
54
+ return `${wsProtocol}//${wsHost}:${webPort + 1}`;
54
55
  }
55
56
 
56
57
  /** Load shared terminal state via API (always available, doesn't depend on terminal WebSocket server) */
@@ -129,7 +129,7 @@ function pushLog(line: string) {
129
129
  if (state.log.length > MAX_LOG_LINES) state.log.shift();
130
130
  }
131
131
 
132
- export async function startTunnel(localPort: number = 3000): Promise<{ url?: string; error?: string }> {
132
+ export async function startTunnel(localPort: number = parseInt(process.env.PORT || '3000')): Promise<{ url?: string; error?: string }> {
133
133
  // Check if this worker already has a process
134
134
  if (state.process) {
135
135
  return state.url ? { url: state.url } : { error: 'Tunnel is starting...' };
package/lib/init.ts CHANGED
@@ -92,6 +92,12 @@ export function ensureInitialized() {
92
92
  // Session watcher is safe (file-based, idempotent)
93
93
  startWatcherLoop();
94
94
 
95
+ // Issue scanner — auto-scan GitHub issues for configured projects
96
+ try {
97
+ const { startScanner } = require('./issue-scanner');
98
+ startScanner();
99
+ } catch {}
100
+
95
101
  // If services are managed externally (forge-server), skip
96
102
  if (process.env.FORGE_EXTERNAL_SERVICES === '1') {
97
103
  // Password display
@@ -0,0 +1,298 @@
1
+ /**
2
+ * Issue Scanner — periodically scans GitHub issues for configured projects
3
+ * and triggers issue-auto-fix pipeline for new issues.
4
+ *
5
+ * Per-project config stored in DB:
6
+ * - enabled: boolean
7
+ * - interval: minutes (0 = manual only)
8
+ * - labels: string[] (only process issues with these labels, empty = all)
9
+ * - baseBranch: string (default: auto-detect)
10
+ */
11
+
12
+ import { execSync } from 'node:child_process';
13
+ import { getDb } from '@/src/core/db/database';
14
+ import { getDbPath } from '@/src/config';
15
+ import { startPipeline } from './pipeline';
16
+ import { loadSettings } from './settings';
17
+ import { homedir } from 'node:os';
18
+
19
+ function db() { return getDb(getDbPath()); }
20
+
21
+ export interface IssueAutofixConfig {
22
+ projectPath: string;
23
+ projectName: string;
24
+ enabled: boolean;
25
+ interval: number; // minutes, 0 = manual
26
+ labels: string[]; // filter labels
27
+ baseBranch: string; // default: '' (auto-detect)
28
+ }
29
+
30
+ // ─── DB setup ────────────────────────────────────────────
31
+
32
+ function migrateTable() {
33
+ try { db().exec('ALTER TABLE issue_autofix_config ADD COLUMN last_scan_at TEXT'); } catch {}
34
+ }
35
+
36
+ export function ensureTable() {
37
+ db().exec(`
38
+ CREATE TABLE IF NOT EXISTS issue_autofix_config (
39
+ project_path TEXT PRIMARY KEY,
40
+ project_name TEXT NOT NULL,
41
+ enabled INTEGER NOT NULL DEFAULT 0,
42
+ interval_min INTEGER NOT NULL DEFAULT 30,
43
+ labels TEXT NOT NULL DEFAULT '[]',
44
+ base_branch TEXT NOT NULL DEFAULT '',
45
+ last_scan_at TEXT
46
+ );
47
+ CREATE TABLE IF NOT EXISTS issue_autofix_processed (
48
+ project_path TEXT NOT NULL,
49
+ issue_number INTEGER NOT NULL,
50
+ pipeline_id TEXT,
51
+ pr_number INTEGER,
52
+ status TEXT NOT NULL DEFAULT 'processing',
53
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
54
+ PRIMARY KEY (project_path, issue_number)
55
+ );
56
+ `);
57
+ migrateTable();
58
+ }
59
+
60
+ // ─── Config CRUD ─────────────────────────────────────────
61
+
62
+ export function getConfig(projectPath: string): IssueAutofixConfig | null {
63
+ ensureTable();
64
+ const row = db().prepare('SELECT * FROM issue_autofix_config WHERE project_path = ?').get(projectPath) as any;
65
+ if (!row) return null;
66
+ return {
67
+ projectPath: row.project_path,
68
+ projectName: row.project_name,
69
+ enabled: !!row.enabled,
70
+ interval: row.interval_min,
71
+ labels: JSON.parse(row.labels || '[]'),
72
+ baseBranch: row.base_branch || '',
73
+ };
74
+ }
75
+
76
+ export function saveConfig(config: IssueAutofixConfig): void {
77
+ ensureTable();
78
+ db().prepare(`
79
+ INSERT OR REPLACE INTO issue_autofix_config (project_path, project_name, enabled, interval_min, labels, base_branch)
80
+ VALUES (?, ?, ?, ?, ?, ?)
81
+ `).run(config.projectPath, config.projectName, config.enabled ? 1 : 0, config.interval, JSON.stringify(config.labels), config.baseBranch);
82
+ }
83
+
84
+ export function listConfigs(): IssueAutofixConfig[] {
85
+ ensureTable();
86
+ return (db().prepare('SELECT * FROM issue_autofix_config WHERE enabled = 1').all() as any[]).map(row => ({
87
+ projectPath: row.project_path,
88
+ projectName: row.project_name,
89
+ enabled: !!row.enabled,
90
+ interval: row.interval_min,
91
+ labels: JSON.parse(row.labels || '[]'),
92
+ baseBranch: row.base_branch || '',
93
+ }));
94
+ }
95
+
96
+ // ─── Issue scanning ──────────────────────────────────────
97
+
98
+ function getRepoFromProject(projectPath: string): string | null {
99
+ try {
100
+ return execSync('gh repo view --json nameWithOwner -q .nameWithOwner', {
101
+ cwd: projectPath, encoding: 'utf-8', timeout: 10000, stdio: ['pipe', 'pipe', 'pipe'],
102
+ }).trim() || null;
103
+ } catch {
104
+ try {
105
+ const url = execSync('git remote get-url origin', {
106
+ cwd: projectPath, encoding: 'utf-8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'],
107
+ }).trim();
108
+ return url.replace(/.*github\.com[:/]/, '').replace(/\.git$/, '') || null;
109
+ } catch { return null; }
110
+ }
111
+ }
112
+
113
+ function fetchOpenIssues(projectPath: string, labels: string[]): { number: number; title: string; error?: string }[] {
114
+ const repo = getRepoFromProject(projectPath);
115
+ if (!repo) return [{ number: -1, title: '', error: 'Could not detect GitHub repo. Run: gh auth login' }];
116
+ try {
117
+ const labelFilter = labels.length > 0 ? ` --label "${labels.join(',')}"` : '';
118
+ const out = execSync(`gh issue list --state open --json number,title${labelFilter} -R ${repo}`, {
119
+ cwd: projectPath, encoding: 'utf-8', timeout: 15000, stdio: ['pipe', 'pipe', 'pipe'],
120
+ });
121
+ return JSON.parse(out) || [];
122
+ } catch (e: any) {
123
+ const msg = e.stderr?.toString() || e.message || 'gh CLI failed';
124
+ return [{ number: -1, title: '', error: msg.includes('auth') ? 'GitHub CLI not authenticated. Run: gh auth login' : msg }];
125
+ }
126
+ }
127
+
128
+ function isProcessed(projectPath: string, issueNumber: number): boolean {
129
+ const row = db().prepare('SELECT 1 FROM issue_autofix_processed WHERE project_path = ? AND issue_number = ?')
130
+ .get(projectPath, issueNumber);
131
+ return !!row;
132
+ }
133
+
134
+ function markProcessed(projectPath: string, issueNumber: number, pipelineId: string): void {
135
+ db().prepare(`
136
+ INSERT OR REPLACE INTO issue_autofix_processed (project_path, issue_number, pipeline_id, status)
137
+ VALUES (?, ?, ?, 'processing')
138
+ `).run(projectPath, issueNumber, pipelineId);
139
+ }
140
+
141
+ export function updateProcessedStatus(projectPath: string, issueNumber: number, status: string, prNumber?: number): void {
142
+ if (prNumber) {
143
+ db().prepare('UPDATE issue_autofix_processed SET status = ?, pr_number = ? WHERE project_path = ? AND issue_number = ?')
144
+ .run(status, prNumber, projectPath, issueNumber);
145
+ } else {
146
+ db().prepare('UPDATE issue_autofix_processed SET status = ? WHERE project_path = ? AND issue_number = ?')
147
+ .run(status, projectPath, issueNumber);
148
+ }
149
+ }
150
+
151
+ /** Reset a processed issue so it can be re-scanned or retried */
152
+ export function resetProcessedIssue(projectPath: string, issueNumber: number): void {
153
+ db().prepare('DELETE FROM issue_autofix_processed WHERE project_path = ? AND issue_number = ?')
154
+ .run(projectPath, issueNumber);
155
+ }
156
+
157
+ export function getProcessedIssues(projectPath: string): { issueNumber: number; pipelineId: string; prNumber: number | null; status: string; createdAt: string }[] {
158
+ ensureTable();
159
+ return (db().prepare('SELECT * FROM issue_autofix_processed WHERE project_path = ? ORDER BY created_at DESC').all(projectPath) as any[]).map(r => ({
160
+ issueNumber: r.issue_number,
161
+ pipelineId: r.pipeline_id,
162
+ prNumber: r.pr_number,
163
+ status: r.status,
164
+ createdAt: r.created_at,
165
+ }));
166
+ }
167
+
168
+ // ─── Scan & trigger ──────────────────────────────────────
169
+
170
+ function saveLastScan(projectPath: string) {
171
+ const now = Date.now();
172
+ scannerState.lastScan.set(projectPath, now);
173
+ try { db().prepare('UPDATE issue_autofix_config SET last_scan_at = ? WHERE project_path = ?').run(new Date(now).toISOString(), projectPath); } catch {}
174
+ }
175
+
176
+ function loadLastScan(projectPath: string): number | null {
177
+ try {
178
+ const row = db().prepare('SELECT last_scan_at FROM issue_autofix_config WHERE project_path = ?').get(projectPath) as any;
179
+ return row?.last_scan_at ? new Date(row.last_scan_at).getTime() : null;
180
+ } catch { return null; }
181
+ }
182
+
183
+ export function scanAndTrigger(config: IssueAutofixConfig): { triggered: number; issues: number[]; total: number; error?: string } {
184
+ saveLastScan(config.projectPath);
185
+ const issues = fetchOpenIssues(config.projectPath, config.labels);
186
+
187
+ // Check for errors
188
+ if (issues.length === 1 && (issues[0] as any).error) {
189
+ return { triggered: 0, issues: [], total: 0, error: (issues[0] as any).error };
190
+ }
191
+
192
+ const triggered: number[] = [];
193
+
194
+ for (const issue of issues) {
195
+ if (issue.number < 0) continue;
196
+ if (isProcessed(config.projectPath, issue.number)) continue;
197
+
198
+ try {
199
+ const pipeline = startPipeline('issue-auto-fix', {
200
+ issue_id: String(issue.number),
201
+ project: config.projectName,
202
+ base_branch: config.baseBranch || 'auto-detect',
203
+ });
204
+ markProcessed(config.projectPath, issue.number, pipeline.id);
205
+ triggered.push(issue.number);
206
+ console.log(`[issue-scanner] Triggered fix for #${issue.number} "${issue.title}" in ${config.projectName} (pipeline: ${pipeline.id})`);
207
+ } catch (e) {
208
+ console.error(`[issue-scanner] Failed to trigger for #${issue.number}:`, e);
209
+ }
210
+ }
211
+
212
+ return { triggered: triggered.length, issues: triggered, total: issues.length };
213
+ }
214
+
215
+ // ─── Periodic scanner ────────────────────────────────────
216
+
217
+ // Use global symbol to prevent multiple workers from starting duplicate scanners
218
+ const scannerKey = Symbol.for('forge-issue-scanner');
219
+ const gAny = globalThis as any;
220
+ if (!gAny[scannerKey]) gAny[scannerKey] = { timers: new Map<string, NodeJS.Timeout>(), started: false, lastScan: new Map<string, number>() };
221
+ const scannerState = gAny[scannerKey] as { timers: Map<string, NodeJS.Timeout>; started: boolean; lastScan: Map<string, number> };
222
+
223
+ export function startScanner() {
224
+ if (scannerState.started) return;
225
+ scannerState.started = true;
226
+ ensureTable();
227
+ const configs = listConfigs();
228
+ for (const config of configs) {
229
+ if (config.interval > 0 && !scannerState.timers.has(config.projectPath)) {
230
+ const projectPath = config.projectPath;
231
+ const intervalMs = config.interval * 60 * 1000;
232
+
233
+ // Restore lastScan from DB
234
+ const dbLastScan = loadLastScan(projectPath);
235
+ if (dbLastScan) {
236
+ scannerState.lastScan.set(projectPath, dbLastScan);
237
+ } else {
238
+ scannerState.lastScan.set(projectPath, Date.now());
239
+ }
240
+
241
+ // Calculate delay: if time since lastScan > interval, run soon; otherwise wait remaining
242
+ const elapsed = Date.now() - (scannerState.lastScan.get(projectPath) || 0);
243
+ const firstDelay = Math.max(0, intervalMs - elapsed);
244
+
245
+ const startInterval = () => {
246
+ const timer = setInterval(() => {
247
+ const latestConfig = getConfig(projectPath);
248
+ if (!latestConfig || !latestConfig.enabled) return;
249
+ try { scanAndTrigger(latestConfig); } catch {}
250
+ }, intervalMs);
251
+ scannerState.timers.set(projectPath, timer);
252
+ };
253
+
254
+ // First run after remaining delay, then regular interval
255
+ if (firstDelay > 0) {
256
+ setTimeout(() => {
257
+ const latestConfig = getConfig(projectPath);
258
+ if (latestConfig?.enabled) { try { scanAndTrigger(latestConfig); } catch {} }
259
+ startInterval();
260
+ }, firstDelay);
261
+ } else {
262
+ // Overdue — run immediately then start interval
263
+ const latestConfig = getConfig(projectPath);
264
+ if (latestConfig?.enabled) { try { scanAndTrigger(latestConfig); } catch {} }
265
+ startInterval();
266
+ }
267
+
268
+ const nextTime = new Date((scannerState.lastScan.get(projectPath) || Date.now()) + intervalMs);
269
+ console.log(`[issue-scanner] Started scanner for ${config.projectName} (every ${config.interval}min, next: ${nextTime.toLocaleTimeString()})`);
270
+ }
271
+ }
272
+ }
273
+
274
+ export function getNextScanTime(projectPath: string): { lastScan: string | null; nextScan: string | null } {
275
+ const last = scannerState.lastScan.get(projectPath);
276
+ const config = getConfig(projectPath);
277
+ if (!last || !config || !config.enabled || config.interval <= 0) return { lastScan: null, nextScan: null };
278
+ const next = last + config.interval * 60 * 1000;
279
+ return {
280
+ lastScan: new Date(last).toISOString(),
281
+ nextScan: new Date(next).toISOString(),
282
+ };
283
+ }
284
+
285
+ export function restartScanner() {
286
+ for (const timer of scannerState.timers.values()) clearInterval(timer);
287
+ scannerState.timers.clear();
288
+ scannerState.started = false;
289
+ startScanner();
290
+ }
291
+
292
+ export function stopScanner(projectPath: string) {
293
+ const timer = scannerState.timers.get(projectPath);
294
+ if (timer) {
295
+ clearInterval(timer);
296
+ scannerState.timers.delete(projectPath);
297
+ }
298
+ }