@aion0/forge 0.3.3 → 0.3.4
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.
- package/CLAUDE.md +2 -1
- package/app/api/issue-scanner/route.ts +116 -0
- package/app/api/pipelines/route.ts +11 -3
- package/app/api/skills/route.ts +12 -0
- package/bin/forge-server.mjs +80 -21
- package/cli/mw.ts +2 -1
- package/components/DocTerminal.tsx +3 -2
- package/components/PipelineView.tsx +3 -2
- package/components/ProjectManager.tsx +273 -2
- package/components/SkillsPanel.tsx +28 -6
- package/components/WebTerminal.tsx +4 -3
- package/lib/cloudflared.ts +1 -1
- package/lib/init.ts +6 -0
- package/lib/issue-scanner.ts +298 -0
- package/lib/pipeline.ts +296 -28
- package/lib/skills.ts +30 -2
- package/lib/task-manager.ts +39 -0
- package/lib/terminal-standalone.ts +5 -1
- package/next-env.d.ts +1 -1
- package/next.config.ts +3 -1
- package/package.json +1 -1
- package/src/core/db/database.ts +1 -0
- package/src/types/index.ts +1 -1
|
@@ -77,7 +77,15 @@ export default function ProjectManager() {
|
|
|
77
77
|
const [diffFile, setDiffFile] = useState<string | null>(null);
|
|
78
78
|
const [projectSkills, setProjectSkills] = useState<{ name: string; displayName: string; type: string; scope: string; version: string; installedVersion: string; hasUpdate: boolean; source: 'registry' | 'local' }[]>([]);
|
|
79
79
|
const [showSkillsDetail, setShowSkillsDetail] = useState(false);
|
|
80
|
-
const [projectTab, setProjectTab] = useState<'code' | 'skills' | 'claudemd'>('code');
|
|
80
|
+
const [projectTab, setProjectTab] = useState<'code' | 'skills' | 'claudemd' | 'issues'>('code');
|
|
81
|
+
// Issue autofix state
|
|
82
|
+
const [issueConfig, setIssueConfig] = useState<{ enabled: boolean; interval: number; labels: string[]; baseBranch: string } | null>(null);
|
|
83
|
+
const [issueProcessed, setIssueProcessed] = useState<{ issueNumber: number; pipelineId: string; prNumber: number | null; status: string; createdAt: string }[]>([]);
|
|
84
|
+
const [issueScanning, setIssueScanning] = useState(false);
|
|
85
|
+
const [issueManualId, setIssueManualId] = useState('');
|
|
86
|
+
const [issueNextScan, setIssueNextScan] = useState<string | null>(null);
|
|
87
|
+
const [issueLastScan, setIssueLastScan] = useState<string | null>(null);
|
|
88
|
+
const [retryModal, setRetryModal] = useState<{ issueNumber: number; context: string } | null>(null);
|
|
81
89
|
const [claudeMdContent, setClaudeMdContent] = useState('');
|
|
82
90
|
const [claudeMdExists, setClaudeMdExists] = useState(false);
|
|
83
91
|
const [claudeTemplates, setClaudeTemplates] = useState<{ id: string; name: string; description: string; tags: string[]; builtin: boolean; content: string }[]>([]);
|
|
@@ -223,6 +231,59 @@ export default function ProjectManager() {
|
|
|
223
231
|
if (selectedProject) fetchProjectSkills(selectedProject.path);
|
|
224
232
|
};
|
|
225
233
|
|
|
234
|
+
const fetchIssueConfig = useCallback(async (projectPath: string) => {
|
|
235
|
+
try {
|
|
236
|
+
const res = await fetch(`/api/issue-scanner?project=${encodeURIComponent(projectPath)}`);
|
|
237
|
+
const data = await res.json();
|
|
238
|
+
setIssueConfig(data.config || { enabled: false, interval: 30, labels: [], baseBranch: '' });
|
|
239
|
+
setIssueProcessed(data.processed || []);
|
|
240
|
+
setIssueLastScan(data.lastScan || null);
|
|
241
|
+
setIssueNextScan(data.nextScan || null);
|
|
242
|
+
} catch {}
|
|
243
|
+
}, []);
|
|
244
|
+
|
|
245
|
+
const saveIssueConfig = async (projectPath: string, config: any) => {
|
|
246
|
+
await fetch('/api/issue-scanner', {
|
|
247
|
+
method: 'POST',
|
|
248
|
+
headers: { 'Content-Type': 'application/json' },
|
|
249
|
+
body: JSON.stringify({ action: 'save-config', projectPath, projectName: selectedProject?.name, ...config }),
|
|
250
|
+
});
|
|
251
|
+
fetchIssueConfig(projectPath);
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
const scanNow = async (projectPath: string) => {
|
|
255
|
+
setIssueScanning(true);
|
|
256
|
+
try {
|
|
257
|
+
const res = await fetch('/api/issue-scanner', {
|
|
258
|
+
method: 'POST',
|
|
259
|
+
headers: { 'Content-Type': 'application/json' },
|
|
260
|
+
body: JSON.stringify({ action: 'scan', projectPath }),
|
|
261
|
+
});
|
|
262
|
+
const data = await res.json();
|
|
263
|
+
if (data.error) {
|
|
264
|
+
alert(data.error);
|
|
265
|
+
} else if (data.triggered > 0) {
|
|
266
|
+
alert(`Triggered ${data.triggered} issue fix(es): #${data.issues.join(', #')}`);
|
|
267
|
+
} else {
|
|
268
|
+
alert(`Scanned ${data.total} open issues — no new issues to process`);
|
|
269
|
+
}
|
|
270
|
+
await fetchIssueConfig(projectPath);
|
|
271
|
+
} catch (e) {
|
|
272
|
+
alert('Scan failed');
|
|
273
|
+
}
|
|
274
|
+
setIssueScanning(false);
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
const triggerIssue = async (projectPath: string, issueId: string) => {
|
|
278
|
+
await fetch('/api/issue-scanner', {
|
|
279
|
+
method: 'POST',
|
|
280
|
+
headers: { 'Content-Type': 'application/json' },
|
|
281
|
+
body: JSON.stringify({ action: 'trigger', projectPath, issueId, projectName: selectedProject?.name }),
|
|
282
|
+
});
|
|
283
|
+
setIssueManualId('');
|
|
284
|
+
fetchIssueConfig(projectPath);
|
|
285
|
+
};
|
|
286
|
+
|
|
226
287
|
const fetchClaudeMd = useCallback(async (projectPath: string) => {
|
|
227
288
|
try {
|
|
228
289
|
const [contentRes, statusRes, listRes] = await Promise.all([
|
|
@@ -338,10 +399,14 @@ export default function ProjectManager() {
|
|
|
338
399
|
setFileContent(null);
|
|
339
400
|
setGitResult(null);
|
|
340
401
|
setCommitMsg('');
|
|
402
|
+
setIssueConfig(null);
|
|
403
|
+
setIssueProcessed([]);
|
|
341
404
|
fetchGitInfo(p);
|
|
342
405
|
fetchTree(p);
|
|
343
406
|
fetchProjectSkills(p.path);
|
|
344
|
-
|
|
407
|
+
if (projectTab === 'issues') fetchIssueConfig(p.path);
|
|
408
|
+
if (projectTab === 'claudemd') fetchClaudeMd(p.path);
|
|
409
|
+
}, [fetchGitInfo, fetchTree, fetchProjectSkills, fetchIssueConfig, fetchClaudeMd, projectTab]);
|
|
345
410
|
|
|
346
411
|
const openFile = useCallback(async (path: string) => {
|
|
347
412
|
if (!selectedProject) return;
|
|
@@ -543,6 +608,15 @@ export default function ProjectManager() {
|
|
|
543
608
|
CLAUDE.md
|
|
544
609
|
{claudeMdExists && <span className="ml-1 text-[8px] text-[var(--green)]">•</span>}
|
|
545
610
|
</button>
|
|
611
|
+
<button
|
|
612
|
+
onClick={() => { setProjectTab('issues'); if (selectedProject) fetchIssueConfig(selectedProject.path); }}
|
|
613
|
+
className={`text-[9px] px-2 py-0.5 rounded transition-colors ${
|
|
614
|
+
projectTab === 'issues' ? 'bg-[var(--bg-secondary)] text-[var(--text-primary)] shadow-sm' : 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
|
|
615
|
+
}`}
|
|
616
|
+
>
|
|
617
|
+
Issues
|
|
618
|
+
{issueConfig?.enabled && <span className="ml-1 text-[8px] text-[var(--green)]">•</span>}
|
|
619
|
+
</button>
|
|
546
620
|
</div>
|
|
547
621
|
</div>
|
|
548
622
|
{projectTab === 'code' && gitInfo?.lastCommit && (
|
|
@@ -851,6 +925,157 @@ export default function ProjectManager() {
|
|
|
851
925
|
</div>
|
|
852
926
|
)}
|
|
853
927
|
|
|
928
|
+
{/* Issues tab — auto-fix config + history */}
|
|
929
|
+
{projectTab === 'issues' && selectedProject && issueConfig && (
|
|
930
|
+
<div className="flex-1 overflow-auto p-4 space-y-4">
|
|
931
|
+
{/* Config */}
|
|
932
|
+
<div className="space-y-3">
|
|
933
|
+
<div className="flex items-center gap-3">
|
|
934
|
+
<label className="flex items-center gap-2 cursor-pointer">
|
|
935
|
+
<input
|
|
936
|
+
type="checkbox"
|
|
937
|
+
checked={issueConfig.enabled}
|
|
938
|
+
onChange={e => setIssueConfig({ ...issueConfig, enabled: e.target.checked })}
|
|
939
|
+
className="accent-[var(--accent)]"
|
|
940
|
+
/>
|
|
941
|
+
<span className="text-[11px] text-[var(--text-primary)] font-semibold">Enable Issue Auto-fix</span>
|
|
942
|
+
</label>
|
|
943
|
+
{issueConfig.enabled && (<>
|
|
944
|
+
<button
|
|
945
|
+
onClick={() => scanNow(selectedProject.path)}
|
|
946
|
+
disabled={issueScanning}
|
|
947
|
+
className="text-[9px] px-2 py-0.5 border border-[var(--accent)] text-[var(--accent)] rounded hover:bg-[var(--accent)] hover:text-white disabled:opacity-50"
|
|
948
|
+
>
|
|
949
|
+
{issueScanning ? 'Scanning...' : 'Scan Now'}
|
|
950
|
+
</button>
|
|
951
|
+
{issueLastScan && (
|
|
952
|
+
<span className="text-[8px] text-[var(--text-secondary)]">
|
|
953
|
+
Last: {new Date(issueLastScan).toLocaleTimeString()}
|
|
954
|
+
</span>
|
|
955
|
+
)}
|
|
956
|
+
{issueNextScan && (
|
|
957
|
+
<span className="text-[8px] text-[var(--text-secondary)]">
|
|
958
|
+
Next: {new Date(issueNextScan).toLocaleTimeString()}
|
|
959
|
+
</span>
|
|
960
|
+
)}
|
|
961
|
+
</> )}
|
|
962
|
+
</div>
|
|
963
|
+
|
|
964
|
+
{issueConfig.enabled && (
|
|
965
|
+
<div className="grid grid-cols-2 gap-3">
|
|
966
|
+
<div>
|
|
967
|
+
<label className="text-[9px] text-[var(--text-secondary)] block mb-1">Scan Interval (minutes, 0=manual)</label>
|
|
968
|
+
<input
|
|
969
|
+
type="number"
|
|
970
|
+
value={issueConfig.interval}
|
|
971
|
+
onChange={e => setIssueConfig({ ...issueConfig, interval: parseInt(e.target.value) || 0 })}
|
|
972
|
+
className="w-full px-2 py-1 bg-[var(--bg-tertiary)] border border-[var(--border)] rounded text-[10px] text-[var(--text-primary)]"
|
|
973
|
+
/>
|
|
974
|
+
</div>
|
|
975
|
+
<div>
|
|
976
|
+
<label className="text-[9px] text-[var(--text-secondary)] block mb-1">Base Branch (empty=auto)</label>
|
|
977
|
+
<input
|
|
978
|
+
type="text"
|
|
979
|
+
value={issueConfig.baseBranch}
|
|
980
|
+
onChange={e => setIssueConfig({ ...issueConfig, baseBranch: e.target.value })}
|
|
981
|
+
placeholder="main"
|
|
982
|
+
className="w-full px-2 py-1 bg-[var(--bg-tertiary)] border border-[var(--border)] rounded text-[10px] text-[var(--text-primary)]"
|
|
983
|
+
/>
|
|
984
|
+
</div>
|
|
985
|
+
<div className="col-span-2">
|
|
986
|
+
<label className="text-[9px] text-[var(--text-secondary)] block mb-1">Labels Filter (comma-separated, empty=all)</label>
|
|
987
|
+
<input
|
|
988
|
+
type="text"
|
|
989
|
+
value={issueConfig.labels.join(', ')}
|
|
990
|
+
onChange={e => setIssueConfig({ ...issueConfig, labels: e.target.value.split(',').map(s => s.trim()).filter(Boolean) })}
|
|
991
|
+
placeholder="bug, fix"
|
|
992
|
+
className="w-full px-2 py-1 bg-[var(--bg-tertiary)] border border-[var(--border)] rounded text-[10px] text-[var(--text-primary)]"
|
|
993
|
+
/>
|
|
994
|
+
</div>
|
|
995
|
+
</div>
|
|
996
|
+
)}
|
|
997
|
+
<div className="mt-3">
|
|
998
|
+
<button
|
|
999
|
+
onClick={() => saveIssueConfig(selectedProject.path, issueConfig)}
|
|
1000
|
+
className="text-[10px] px-4 py-1.5 bg-[var(--accent)] text-white rounded hover:opacity-90"
|
|
1001
|
+
>Save Configuration</button>
|
|
1002
|
+
</div>
|
|
1003
|
+
</div>
|
|
1004
|
+
|
|
1005
|
+
{/* Manual trigger */}
|
|
1006
|
+
<div className="border-t border-[var(--border)] pt-3">
|
|
1007
|
+
<div className="text-[9px] text-[var(--text-secondary)] uppercase mb-2">Manual Trigger</div>
|
|
1008
|
+
<div className="flex gap-2">
|
|
1009
|
+
<input
|
|
1010
|
+
type="text"
|
|
1011
|
+
value={issueManualId}
|
|
1012
|
+
onChange={e => setIssueManualId(e.target.value)}
|
|
1013
|
+
placeholder="Issue #"
|
|
1014
|
+
className="w-24 px-2 py-1 bg-[var(--bg-tertiary)] border border-[var(--border)] rounded text-[10px] text-[var(--text-primary)]"
|
|
1015
|
+
/>
|
|
1016
|
+
<button
|
|
1017
|
+
onClick={() => issueManualId && triggerIssue(selectedProject.path, issueManualId)}
|
|
1018
|
+
disabled={!issueManualId}
|
|
1019
|
+
className="text-[9px] px-3 py-1 bg-[var(--accent)] text-white rounded hover:opacity-90 disabled:opacity-50"
|
|
1020
|
+
>Fix Issue</button>
|
|
1021
|
+
</div>
|
|
1022
|
+
</div>
|
|
1023
|
+
|
|
1024
|
+
{/* History */}
|
|
1025
|
+
{issueProcessed.length > 0 && (
|
|
1026
|
+
<div className="border-t border-[var(--border)] pt-3">
|
|
1027
|
+
<div className="text-[9px] text-[var(--text-secondary)] uppercase mb-2">Processed Issues</div>
|
|
1028
|
+
<div className="border border-[var(--border)] rounded overflow-hidden">
|
|
1029
|
+
{issueProcessed.map(p => (
|
|
1030
|
+
<div key={p.issueNumber} className="border-b border-[var(--border)]/30 last:border-b-0">
|
|
1031
|
+
<div className="flex items-center gap-2 px-3 py-1.5 text-[10px]">
|
|
1032
|
+
<span className="text-[var(--text-primary)] font-mono">#{p.issueNumber}</span>
|
|
1033
|
+
<span className={`text-[8px] px-1 rounded ${
|
|
1034
|
+
p.status === 'done' ? 'bg-green-500/10 text-green-400' :
|
|
1035
|
+
p.status === 'failed' ? 'bg-red-500/10 text-red-400' :
|
|
1036
|
+
'bg-yellow-500/10 text-yellow-400'
|
|
1037
|
+
}`}>{p.status}</span>
|
|
1038
|
+
{p.prNumber && <span className="text-[var(--accent)]">PR #{p.prNumber}</span>}
|
|
1039
|
+
{p.pipelineId && (
|
|
1040
|
+
<button
|
|
1041
|
+
onClick={() => {
|
|
1042
|
+
const event = new CustomEvent('forge:view-pipeline', { detail: { pipelineId: p.pipelineId } });
|
|
1043
|
+
window.dispatchEvent(event);
|
|
1044
|
+
}}
|
|
1045
|
+
className="text-[8px] text-[var(--text-secondary)] hover:text-[var(--accent)] font-mono"
|
|
1046
|
+
title="View pipeline"
|
|
1047
|
+
>{p.pipelineId.slice(0, 8)}</button>
|
|
1048
|
+
)}
|
|
1049
|
+
<span className="text-[var(--text-secondary)] text-[8px]">{p.createdAt}</span>
|
|
1050
|
+
<div className="ml-auto flex gap-1">
|
|
1051
|
+
{(p.status === 'failed' || p.status === 'done' || p.status === 'processing') && (
|
|
1052
|
+
<button
|
|
1053
|
+
onClick={() => setRetryModal({ issueNumber: p.issueNumber, context: '' })}
|
|
1054
|
+
className="text-[8px] text-[var(--accent)] hover:underline"
|
|
1055
|
+
>Retry</button>
|
|
1056
|
+
)}
|
|
1057
|
+
<button
|
|
1058
|
+
onClick={async () => {
|
|
1059
|
+
if (!confirm(`Delete record for issue #${p.issueNumber}?`)) return;
|
|
1060
|
+
await fetch('/api/issue-scanner', {
|
|
1061
|
+
method: 'POST',
|
|
1062
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1063
|
+
body: JSON.stringify({ action: 'reset', projectPath: selectedProject!.path, issueId: p.issueNumber }),
|
|
1064
|
+
});
|
|
1065
|
+
fetchIssueConfig(selectedProject!.path);
|
|
1066
|
+
}}
|
|
1067
|
+
className="text-[8px] text-[var(--text-secondary)] hover:text-[var(--red)]"
|
|
1068
|
+
>Delete</button>
|
|
1069
|
+
</div>
|
|
1070
|
+
</div>
|
|
1071
|
+
</div>
|
|
1072
|
+
))}
|
|
1073
|
+
</div>
|
|
1074
|
+
</div>
|
|
1075
|
+
)}
|
|
1076
|
+
</div>
|
|
1077
|
+
)}
|
|
1078
|
+
|
|
854
1079
|
{/* Git panel — bottom (code tab only) */}
|
|
855
1080
|
{projectTab === 'code' && gitInfo && (
|
|
856
1081
|
<div className="border-t border-[var(--border)] shrink-0">
|
|
@@ -938,6 +1163,52 @@ export default function ProjectManager() {
|
|
|
938
1163
|
</div>
|
|
939
1164
|
)}
|
|
940
1165
|
</div>
|
|
1166
|
+
|
|
1167
|
+
{/* Retry modal */}
|
|
1168
|
+
{retryModal && (
|
|
1169
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={() => setRetryModal(null)}>
|
|
1170
|
+
<div className="bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg shadow-xl w-[420px] max-h-[80vh] flex flex-col" onClick={e => e.stopPropagation()}>
|
|
1171
|
+
<div className="px-4 py-3 border-b border-[var(--border)]">
|
|
1172
|
+
<h3 className="text-sm font-semibold text-[var(--text-primary)]">Retry Issue #{retryModal.issueNumber}</h3>
|
|
1173
|
+
<p className="text-[10px] text-[var(--text-secondary)] mt-0.5">Add context to help the AI fix the issue better this time.</p>
|
|
1174
|
+
</div>
|
|
1175
|
+
<div className="p-4">
|
|
1176
|
+
<textarea
|
|
1177
|
+
value={retryModal.context}
|
|
1178
|
+
onChange={e => setRetryModal({ ...retryModal, context: e.target.value })}
|
|
1179
|
+
placeholder="e.g. The previous fix caused a merge conflict. Rebase from main first, then fix only the validation logic in src/utils.ts..."
|
|
1180
|
+
className="w-full h-32 px-3 py-2 bg-[var(--bg-tertiary)] border border-[var(--border)] rounded text-[11px] text-[var(--text-primary)] placeholder:text-[var(--text-secondary)]/50 resize-none focus:outline-none focus:border-[var(--accent)]"
|
|
1181
|
+
autoFocus
|
|
1182
|
+
/>
|
|
1183
|
+
</div>
|
|
1184
|
+
<div className="px-4 py-3 border-t border-[var(--border)] flex justify-end gap-2">
|
|
1185
|
+
<button
|
|
1186
|
+
onClick={() => setRetryModal(null)}
|
|
1187
|
+
className="text-[11px] px-3 py-1.5 text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
|
1188
|
+
>Cancel</button>
|
|
1189
|
+
<button
|
|
1190
|
+
onClick={async () => {
|
|
1191
|
+
if (!selectedProject) return;
|
|
1192
|
+
await fetch('/api/issue-scanner', {
|
|
1193
|
+
method: 'POST',
|
|
1194
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1195
|
+
body: JSON.stringify({
|
|
1196
|
+
action: 'retry',
|
|
1197
|
+
projectPath: selectedProject.path,
|
|
1198
|
+
projectName: selectedProject.name,
|
|
1199
|
+
issueId: retryModal.issueNumber,
|
|
1200
|
+
context: retryModal.context,
|
|
1201
|
+
}),
|
|
1202
|
+
});
|
|
1203
|
+
setRetryModal(null);
|
|
1204
|
+
fetchIssueConfig(selectedProject.path);
|
|
1205
|
+
}}
|
|
1206
|
+
className="text-[11px] px-4 py-1.5 bg-[var(--accent)] text-white rounded hover:opacity-90"
|
|
1207
|
+
>Retry</button>
|
|
1208
|
+
</div>
|
|
1209
|
+
</div>
|
|
1210
|
+
</div>
|
|
1211
|
+
)}
|
|
941
1212
|
</div>
|
|
942
1213
|
);
|
|
943
1214
|
}
|
|
@@ -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.
|
|
319
|
-
{
|
|
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-[
|
|
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 }
|
|
@@ -42,15 +42,16 @@ interface TabState {
|
|
|
42
42
|
// ─── Layout persistence ──────────────────────────────────────
|
|
43
43
|
|
|
44
44
|
function getWsUrl() {
|
|
45
|
-
if (typeof window === 'undefined') return
|
|
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
|
-
|
|
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) */
|
package/lib/cloudflared.ts
CHANGED
|
@@ -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
|