@aion0/forge 0.10.36 → 0.10.37

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.
@@ -83,6 +83,35 @@ interface TestResult {
83
83
 
84
84
  const SECRET_MASK = '••••••••';
85
85
 
86
+ // ─── Config template import ───────────────────────────────
87
+
88
+ interface TemplatePrompt {
89
+ key: string;
90
+ label: string;
91
+ hint?: string;
92
+ url?: string;
93
+ url_label?: string;
94
+ secret?: boolean;
95
+ required?: boolean;
96
+ targets: Array<{ connector: string; field_path: string }>;
97
+ }
98
+
99
+ interface TemplateModalState {
100
+ template: any;
101
+ pending: TemplatePrompt[];
102
+ static_apply_targets: string[];
103
+ missing_manifests: string[];
104
+ values: Record<string, string>;
105
+ }
106
+
107
+ interface TemplateApplyResult {
108
+ applied: string[];
109
+ enabled_changed: string[];
110
+ skipped_missing_manifest: string[];
111
+ fields_preserved: Array<{ connector: string; field: string; reason: string }>;
112
+ fields_left_empty: Array<{ connector: string; field: string }>;
113
+ }
114
+
86
115
  export default function ConnectorsPanel() {
87
116
  const [state, setState] = useState<MarketState | null>(null);
88
117
  const [busyId, setBusyId] = useState('');
@@ -102,6 +131,16 @@ export default function ConnectorsPanel() {
102
131
  const [dragOver, setDragOver] = useState(false);
103
132
  const fileInputRef = useRef<HTMLInputElement>(null);
104
133
 
134
+ // ─── Config template import ───────────────────────────────
135
+ // The "Import Template" button accepts a JSON file that pre-fills shared
136
+ // defaults (base URLs, etc.) and prompts the user once for each ${key}
137
+ // placeholder. Same key shared by multiple connectors → asked once.
138
+ const tplFileInputRef = useRef<HTMLInputElement>(null);
139
+ const [tplAnalyzing, setTplAnalyzing] = useState(false);
140
+ const [tplApplying, setTplApplying] = useState(false);
141
+ const [tplModal, setTplModal] = useState<TemplateModalState | null>(null);
142
+ const [tplResult, setTplResult] = useState<TemplateApplyResult | null>(null);
143
+
105
144
  const [testing, setTesting] = useState(false);
106
145
  const [testResult, setTestResult] = useState<TestResult | null>(null);
107
146
 
@@ -153,6 +192,12 @@ export default function ConnectorsPanel() {
153
192
  }).catch(() => {});
154
193
  }, []);
155
194
 
195
+ // Forge ships a bundled connector template (templates/connector-config-template.json),
196
+ // so the Import button is always one-click. Users can override by
197
+ // dropping their own at <dataDir>/config-template.json — the GET
198
+ // endpoint picks the override automatically. No mount-time probe
199
+ // needed; we just always show the one-click button.
200
+
156
201
  // ─── Actions ──────────────────────────────────────────────
157
202
  async function sync() {
158
203
  setSyncing(true); setError('');
@@ -229,6 +274,92 @@ export default function ConnectorsPanel() {
229
274
  if (f) uploadFile(f);
230
275
  }
231
276
 
277
+ // ─── Config template: one-click import (bundled or local override) ──
278
+ // Always hits GET — the server picks the user override at
279
+ // <dataDir>/config-template.json if present, else the bundled
280
+ // templates/connector-config-template.json that ships with Forge.
281
+ async function onClickImport() {
282
+ if (tplAnalyzing || tplApplying) return;
283
+ setTplAnalyzing(true); setError(''); setTplResult(null);
284
+ try {
285
+ const r = await fetch('/api/connectors/import-config-template');
286
+ const j = await r.json();
287
+ if (!r.ok || j.ok === false) {
288
+ setError(j.error || 'template load failed');
289
+ return;
290
+ }
291
+ if (!j.pending?.length) {
292
+ await applyTemplate(j.template, {});
293
+ return;
294
+ }
295
+ setTplModal({
296
+ template: j.template,
297
+ pending: j.pending,
298
+ static_apply_targets: j.static_apply_targets || [],
299
+ missing_manifests: j.missing_manifests || [],
300
+ values: {},
301
+ });
302
+ } catch (e) {
303
+ setError(e instanceof Error ? e.message : String(e));
304
+ } finally { setTplAnalyzing(false); }
305
+ }
306
+
307
+ // ─── Config template: analyze (file pick) ─────────────────
308
+ async function onPickTemplateFile(ev: React.ChangeEvent<HTMLInputElement>) {
309
+ const f = ev.target.files?.[0];
310
+ ev.target.value = '';
311
+ if (!f) return;
312
+ setTplAnalyzing(true); setError(''); setTplResult(null);
313
+ try {
314
+ const fd = new FormData();
315
+ fd.append('file', f);
316
+ const r = await fetch('/api/connectors/import-config-template', {
317
+ method: 'POST', body: fd,
318
+ });
319
+ const j = await r.json();
320
+ if (!r.ok || j.ok === false) {
321
+ setError(j.error || 'template analyze failed');
322
+ return;
323
+ }
324
+ // If nothing to ask, apply directly (only static defaults).
325
+ if (!j.pending?.length) {
326
+ await applyTemplate(j.template, {});
327
+ return;
328
+ }
329
+ setTplModal({
330
+ template: j.template,
331
+ pending: j.pending,
332
+ static_apply_targets: j.static_apply_targets || [],
333
+ missing_manifests: j.missing_manifests || [],
334
+ values: {},
335
+ });
336
+ } catch (e) {
337
+ setError(e instanceof Error ? e.message : String(e));
338
+ } finally { setTplAnalyzing(false); }
339
+ }
340
+
341
+ async function applyTemplate(template: any, values: Record<string, string>) {
342
+ setTplApplying(true); setError('');
343
+ try {
344
+ const r = await fetch('/api/connectors/import-config-template', {
345
+ method: 'POST',
346
+ headers: { 'Content-Type': 'application/json' },
347
+ body: JSON.stringify({ template, values }),
348
+ });
349
+ const j = await r.json();
350
+ if (!r.ok || j.ok === false) {
351
+ setError(j.error || 'template apply failed');
352
+ return;
353
+ }
354
+ setTplModal(null);
355
+ setTplResult(j as TemplateApplyResult);
356
+ await refresh();
357
+ if (selectedId) await loadDetail(selectedId);
358
+ } catch (e) {
359
+ setError(e instanceof Error ? e.message : String(e));
360
+ } finally { setTplApplying(false); }
361
+ }
362
+
232
363
  async function runTest() {
233
364
  if (!selectedId) return;
234
365
  setTesting(true); setTestResult(null);
@@ -334,6 +465,31 @@ export default function ConnectorsPanel() {
334
465
  onChange={onPickFile}
335
466
  className="hidden"
336
467
  />
468
+ <input
469
+ ref={tplFileInputRef}
470
+ type="file"
471
+ accept=".json,application/json"
472
+ onChange={onPickTemplateFile}
473
+ className="hidden"
474
+ />
475
+ <button
476
+ type="button"
477
+ onClick={onClickImport}
478
+ disabled={tplAnalyzing || tplApplying}
479
+ title="One-click import — uses the bundled connector template (or your <dataDir>/config-template.json override). Asks once for each missing token; shared keys (e.g. gitlab_pat used by gitlab+jenkins) are asked once."
480
+ className="text-[10px] px-2.5 py-0.5 border border-[var(--border)] text-[var(--text-secondary)] rounded hover:border-[var(--accent)] hover:text-[var(--accent)] transition-colors disabled:opacity-40"
481
+ >
482
+ {tplAnalyzing ? 'Analyzing…' : tplApplying ? 'Applying…' : '↥ Import Template'}
483
+ </button>
484
+ <button
485
+ type="button"
486
+ onClick={() => tplFileInputRef.current?.click()}
487
+ disabled={tplAnalyzing || tplApplying}
488
+ title="Override: pick a different template file"
489
+ className="text-[10px] px-1.5 py-0.5 text-[var(--text-secondary)] hover:text-[var(--accent)] disabled:opacity-40"
490
+ >
491
+ (or file…)
492
+ </button>
337
493
  <button
338
494
  type="button"
339
495
  onClick={() => fileInputRef.current?.click()}
@@ -639,6 +795,176 @@ export default function ConnectorsPanel() {
639
795
  })()}
640
796
  </div>
641
797
  </div>
798
+
799
+ {/* ─── Template Import: prompt modal ─────────────────── */}
800
+ {tplModal && (
801
+ <TemplateImportModal
802
+ state={tplModal}
803
+ onChange={(values) => setTplModal({ ...tplModal, values })}
804
+ onCancel={() => setTplModal(null)}
805
+ onSubmit={(values) => applyTemplate(tplModal.template, values)}
806
+ submitting={tplApplying}
807
+ />
808
+ )}
809
+
810
+ {/* ─── Template Import: result toast ─────────────────── */}
811
+ {tplResult && (
812
+ <TemplateImportResultModal
813
+ result={tplResult}
814
+ onClose={() => setTplResult(null)}
815
+ />
816
+ )}
817
+ </div>
818
+ );
819
+ }
820
+
821
+ // ─── Template Import: prompt modal ──────────────────────────
822
+
823
+ function TemplateImportModal({
824
+ state, onChange, onCancel, onSubmit, submitting,
825
+ }: {
826
+ state: TemplateModalState;
827
+ onChange: (values: Record<string, string>) => void;
828
+ onCancel: () => void;
829
+ onSubmit: (values: Record<string, string>) => void;
830
+ submitting: boolean;
831
+ }) {
832
+ const required = state.pending.filter(p => p.required);
833
+ const missingRequired = required.filter(p => !(state.values[p.key] || '').trim());
834
+
835
+ return (
836
+ <div className="fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-6">
837
+ <div className="bg-[var(--bg-primary)] border border-[var(--border)] rounded-md shadow-xl max-w-2xl w-full max-h-[85vh] flex flex-col">
838
+ <div className="px-4 py-3 border-b border-[var(--border)] shrink-0">
839
+ <h3 className="text-sm font-medium text-[var(--text-primary)]">Import Connector Config Template</h3>
840
+ <p className="text-[11px] text-[var(--text-secondary)] mt-1">
841
+ Static defaults will be applied to <strong>{state.static_apply_targets.length}</strong> connector{state.static_apply_targets.length === 1 ? '' : 's'} ({state.static_apply_targets.join(', ')}).
842
+ {state.missing_manifests.length > 0 && (
843
+ <> Skipped (not installed): <span className="text-amber-500">{state.missing_manifests.join(', ')}</span>.</>
844
+ )}
845
+ <br />
846
+ Fill in the fields below — values shared across connectors are asked once. Existing non-empty fields are preserved.
847
+ </p>
848
+ </div>
849
+ <div className="flex-1 overflow-y-auto px-4 py-3 space-y-3">
850
+ {state.pending.map(p => {
851
+ const v = state.values[p.key] ?? '';
852
+ return (
853
+ <div key={p.key} className="space-y-1">
854
+ <label className="text-[11px] font-medium text-[var(--text-primary)] flex items-center gap-1.5 flex-wrap">
855
+ {p.label}
856
+ {p.required ? <span className="text-red-500">*</span> : <span className="text-[9px] text-[var(--text-secondary)]">(optional)</span>}
857
+ {p.url && (
858
+ <a
859
+ href={p.url}
860
+ target="_blank"
861
+ rel="noopener noreferrer"
862
+ className="ml-auto text-[10px] text-[var(--accent)] hover:underline"
863
+ title={p.url}
864
+ >
865
+ ↗ {p.url_label || 'Get token'}
866
+ </a>
867
+ )}
868
+ </label>
869
+ {p.hint && (
870
+ <p className="text-[10px] text-[var(--text-secondary)] leading-snug">{p.hint}</p>
871
+ )}
872
+ <input
873
+ type={p.secret ? 'password' : 'text'}
874
+ value={v}
875
+ onChange={(e) => onChange({ ...state.values, [p.key]: e.target.value })}
876
+ placeholder={p.required ? 'required' : 'leave blank to skip'}
877
+ className="w-full text-[11px] bg-[var(--bg-tertiary)] border border-[var(--border)] rounded px-2 py-1 text-[var(--text-primary)] font-mono focus:outline-none focus:border-[var(--accent)]"
878
+ />
879
+ <p className="text-[9px] text-[var(--text-secondary)]">
880
+ → {p.targets.map(t => `${t.connector}${t.field_path ? '.' + t.field_path : ''}`).join(', ')}
881
+ </p>
882
+ </div>
883
+ );
884
+ })}
885
+ </div>
886
+ <div className="px-4 py-2 border-t border-[var(--border)] shrink-0 flex items-center justify-end gap-2">
887
+ {missingRequired.length > 0 && (
888
+ <span className="text-[10px] text-amber-500 mr-auto">
889
+ {missingRequired.length} required field{missingRequired.length === 1 ? '' : 's'} empty
890
+ </span>
891
+ )}
892
+ <button
893
+ type="button"
894
+ onClick={onCancel}
895
+ disabled={submitting}
896
+ className="text-[10px] px-2.5 py-1 border border-[var(--border)] text-[var(--text-secondary)] rounded hover:border-[var(--text-primary)] disabled:opacity-40"
897
+ >
898
+ Cancel
899
+ </button>
900
+ <button
901
+ type="button"
902
+ onClick={() => onSubmit(state.values)}
903
+ disabled={submitting || missingRequired.length > 0}
904
+ className="text-[10px] px-2.5 py-1 border border-[var(--accent)] text-[var(--accent)] rounded hover:bg-[var(--accent)] hover:text-white disabled:opacity-40"
905
+ >
906
+ {submitting ? 'Applying…' : 'Apply'}
907
+ </button>
908
+ </div>
909
+ </div>
910
+ </div>
911
+ );
912
+ }
913
+
914
+ function TemplateImportResultModal({
915
+ result, onClose,
916
+ }: { result: TemplateApplyResult; onClose: () => void; }) {
917
+ return (
918
+ <div className="fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-6">
919
+ <div className="bg-[var(--bg-primary)] border border-[var(--border)] rounded-md shadow-xl max-w-xl w-full max-h-[80vh] flex flex-col">
920
+ <div className="px-4 py-3 border-b border-[var(--border)] shrink-0 flex items-center">
921
+ <h3 className="text-sm font-medium text-[var(--text-primary)]">Import Complete</h3>
922
+ </div>
923
+ <div className="flex-1 overflow-y-auto px-4 py-3 space-y-3 text-[11px] text-[var(--text-primary)]">
924
+ <div>
925
+ <div className="text-[10px] uppercase tracking-wide text-[var(--text-secondary)] mb-1">Applied ({result.applied.length})</div>
926
+ <div className="text-emerald-500">{result.applied.join(', ') || '—'}</div>
927
+ </div>
928
+ {result.fields_preserved.length > 0 && (
929
+ <div>
930
+ <div className="text-[10px] uppercase tracking-wide text-[var(--text-secondary)] mb-1">Preserved (existing values not overwritten)</div>
931
+ <ul className="text-[var(--text-secondary)] font-mono text-[10px] space-y-0.5">
932
+ {result.fields_preserved.map((f, i) => (
933
+ <li key={i}>{f.connector}.{f.field}</li>
934
+ ))}
935
+ </ul>
936
+ </div>
937
+ )}
938
+ {result.fields_left_empty.length > 0 && (
939
+ <div>
940
+ <div className="text-[10px] uppercase tracking-wide text-[var(--text-secondary)] mb-1">Left empty (optional fields skipped)</div>
941
+ <ul className="text-amber-500 font-mono text-[10px] space-y-0.5">
942
+ {result.fields_left_empty.map((f, i) => (
943
+ <li key={i}>{f.connector}.{f.field}</li>
944
+ ))}
945
+ </ul>
946
+ </div>
947
+ )}
948
+ {result.skipped_missing_manifest.length > 0 && (
949
+ <div>
950
+ <div className="text-[10px] uppercase tracking-wide text-[var(--text-secondary)] mb-1">Skipped (manifest not installed)</div>
951
+ <div className="text-amber-500">{result.skipped_missing_manifest.join(', ')}</div>
952
+ <p className="text-[10px] text-[var(--text-secondary)] mt-1">
953
+ Sync the marketplace first (Refresh button), then re-import the template.
954
+ </p>
955
+ </div>
956
+ )}
957
+ </div>
958
+ <div className="px-4 py-2 border-t border-[var(--border)] shrink-0 flex justify-end">
959
+ <button
960
+ type="button"
961
+ onClick={onClose}
962
+ className="text-[10px] px-2.5 py-1 border border-[var(--accent)] text-[var(--accent)] rounded hover:bg-[var(--accent)] hover:text-white"
963
+ >
964
+ OK
965
+ </button>
966
+ </div>
967
+ </div>
642
968
  </div>
643
969
  );
644
970
  }
@@ -28,6 +28,7 @@ const LoginStatusPanel = lazy(() => import('./LoginStatusPanel'));
28
28
  const ActivityPanel = lazy(() => import('./ActivityPanel'));
29
29
  const WorkspaceView = lazy(() => import('./WorkspaceView'));
30
30
  // WorkspaceTree moved into ProjectDetail — no longer needed at Dashboard level
31
+ import { OnboardingBanner, OnboardingDrawer } from './OnboardingWizard';
31
32
 
32
33
  interface UsageSummary {
33
34
  provider: string;
@@ -133,6 +134,17 @@ export default function Dashboard({ user }: { user: any }) {
133
134
  const [activeTaskId, setActiveTaskId] = useState<string | null>(null);
134
135
  const [showNewTask, setShowNewTask] = useState(false);
135
136
  const [showSettings, setShowSettings] = useState(false);
137
+ const [needsOnboarding, setNeedsOnboarding] = useState(false);
138
+ const [showOnboarding, setShowOnboarding] = useState(false);
139
+ useEffect(() => {
140
+ fetch('/api/onboarding').then(r => r.json()).then(j => {
141
+ const need = !!(j?.ok && !j.onboardingCompleted);
142
+ setNeedsOnboarding(need);
143
+ // Auto-open the drawer when setup is needed — saves the user a click.
144
+ // They can still click "Skip" inside the drawer to dismiss.
145
+ if (need) setShowOnboarding(true);
146
+ }).catch(() => {});
147
+ }, []);
136
148
  const [showMonitor, setShowMonitor] = useState(false);
137
149
  const [showLoginStatus, setShowLoginStatus] = useState(false);
138
150
  const [loginBadge, setLoginBadge] = useState<{ broken: number; total: number } | null>(null);
@@ -294,7 +306,22 @@ export default function Dashboard({ user }: { user: any }) {
294
306
  const queued = tasks.filter(t => t.status === 'queued');
295
307
 
296
308
  return (
297
- <div className="h-screen flex">
309
+ <div className="h-screen flex flex-col">
310
+ {needsOnboarding && (
311
+ <OnboardingBanner onOpen={() => setShowOnboarding(true)} />
312
+ )}
313
+ {showOnboarding && (
314
+ <OnboardingDrawer
315
+ onClose={() => setShowOnboarding(false)}
316
+ onComplete={() => {
317
+ setShowOnboarding(false);
318
+ setNeedsOnboarding(false);
319
+ // Reload settings so chat picks up new API profile etc.
320
+ fetchData();
321
+ }}
322
+ />
323
+ )}
324
+ <div className="flex-1 flex min-h-0">
298
325
  {/* Browser — left side */}
299
326
  {browserMode === 'left' && (
300
327
  <>
@@ -951,6 +978,7 @@ export default function Dashboard({ user }: { user: any }) {
951
978
  </Suspense>
952
979
  )}
953
980
 
981
+ </div>{/* /flex-1 inner container */}
954
982
  {showMonitor && <Suspense fallback={null}><MonitorPanel onClose={() => setShowMonitor(false)} /></Suspense>}
955
983
  {showLoginStatus && <Suspense fallback={null}><LoginStatusPanel onClose={() => { setShowLoginStatus(false); /* refresh badge after panel actions */ fetch('/api/login-status').then(r => r.json()).then(j => { const rows = j.rows || []; const broken = rows.filter((r: any) => r.result && !r.result.ok).length; setLoginBadge({ broken, total: rows.length }); }).catch(() => {}); }} /></Suspense>}
956
984