@aion0/forge 0.10.37 → 0.10.38

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/RELEASE_NOTES.md CHANGED
@@ -1,8 +1,8 @@
1
- # Forge v0.10.37
1
+ # Forge v0.10.38
2
2
 
3
- Released: 2026-06-04
3
+ Released: 2026-06-05
4
4
 
5
- ## Changes since v0.10.36
5
+ ## Changes since v0.10.37
6
6
 
7
7
 
8
- **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.36...v0.10.37
8
+ **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.37...v0.10.38
@@ -58,6 +58,11 @@ interface OnboardingPayload {
58
58
  setAsDefault?: boolean;
59
59
  };
60
60
  connectorValues?: Record<string, string>; // template ${key} → value
61
+ /** Subset of template connector ids to install. Missing/empty = install
62
+ * everything in the template. Unselected ids are skipped entirely —
63
+ * no installFromRegistry, no setConnectorConfig. Lets users opt out of
64
+ * Jenkins/NAC/FortiNCM etc. on first run. */
65
+ selectedConnectors?: string[];
61
66
  pipelines?: string[]; // marketplace names to install
62
67
  projectRoots?: string[]; // paths to append
63
68
  }
@@ -154,13 +159,19 @@ function applyCliAgent(a: OnboardingPayload['cliAgent']): string | null {
154
159
  return null;
155
160
  }
156
161
 
157
- async function applyConnectors(values: Record<string, string> | undefined): Promise<{
162
+ async function applyConnectors(
163
+ values: Record<string, string> | undefined,
164
+ selectedConnectors: string[] | undefined,
165
+ ): Promise<{
158
166
  applied: string[];
159
167
  installed_from_registry: string[];
160
168
  skipped_missing_manifest: string[];
169
+ skipped_unselected: string[];
161
170
  fields_preserved: Array<{ connector: string; field: string }>;
162
171
  }> {
163
172
  const template = resolveTemplate();
173
+ // Undefined = install everything; explicit [] = install nothing.
174
+ const selected = selectedConnectors ? new Set(selectedConnectors) : null;
164
175
  // Auto-inject user identity from settings so connectors (e.g. tp.username)
165
176
  // can reference {user_name} / {user_email} without prompting again.
166
177
  // User-supplied values still win over auto-injected ones.
@@ -173,10 +184,12 @@ async function applyConnectors(values: Record<string, string> | undefined): Prom
173
184
  const applied: string[] = [];
174
185
  const installedFromRegistry: string[] = [];
175
186
  const missing: string[] = [];
187
+ const skippedUnselected: string[] = [];
176
188
  const preserved: Array<{ connector: string; field: string }> = [];
177
189
 
178
190
  for (const [id, row] of Object.entries(template)) {
179
191
  if (id.startsWith('_')) continue; // metadata keys
192
+ if (selected && !selected.has(id)) { skippedUnselected.push(id); continue; }
180
193
  let def = getConnector(id);
181
194
  if (!def) {
182
195
  // Manifest not on disk yet — fetch from forge-connectors registry
@@ -217,6 +230,7 @@ async function applyConnectors(values: Record<string, string> | undefined): Prom
217
230
  applied,
218
231
  installed_from_registry: installedFromRegistry,
219
232
  skipped_missing_manifest: missing,
233
+ skipped_unselected: skippedUnselected,
220
234
  fields_preserved: preserved,
221
235
  };
222
236
  }
@@ -299,6 +313,30 @@ export async function GET() {
299
313
  // which froze Dashboard mount + every other API on the same worker.
300
314
  const detected: Array<{ name: string; path: string; version: string }> = [];
301
315
 
316
+ // Connector list from the template (excluding `_*` metadata keys),
317
+ // in template order. UI renders this as checkboxes — default all
318
+ // selected, user can opt out before Apply. `default_enabled` mirrors
319
+ // the template's `enabled:` (e.g. fortincm is opt-in).
320
+ const templateConnectors: Array<{
321
+ id: string;
322
+ default_enabled: boolean;
323
+ has_prompts: boolean;
324
+ already_installed: boolean;
325
+ }> = [];
326
+ for (const [connId, row] of Object.entries(template)) {
327
+ if (connId.startsWith('_')) continue;
328
+ const cfg = (row as any)?.config ?? {};
329
+ const hasPrompts = Object.values(cfg).some(
330
+ v => typeof v === 'string' && /\$\{[a-zA-Z0-9_]+\}/.test(v),
331
+ );
332
+ templateConnectors.push({
333
+ id: connId,
334
+ default_enabled: (row as any)?.enabled !== false,
335
+ has_prompts: hasPrompts,
336
+ already_installed: !!getInstalledConnector(connId),
337
+ });
338
+ }
339
+
302
340
  // Per-prompt "already set" probe — for each ${key} the template uses,
303
341
  // find which connector field references it and check if that field
304
342
  // is already non-empty. Lets the wizard show "•••• (keep existing)"
@@ -342,6 +380,7 @@ export async function GET() {
342
380
  projectRoots: settings.projectRoots || [],
343
381
  },
344
382
  detected_cli: detected,
383
+ template_connectors: templateConnectors,
345
384
  template_prompts: template._prompts || {},
346
385
  prompt_values_set: promptValuesSet,
347
386
  prompt_targets: promptTargets,
@@ -392,7 +431,7 @@ export async function POST(req: Request) {
392
431
  }
393
432
 
394
433
  try {
395
- const r = await applyConnectors(payload.connectorValues);
434
+ const r = await applyConnectors(payload.connectorValues, payload.selectedConnectors);
396
435
  phases.push({ phase: 'connectors', ok: true, detail: r });
397
436
  } catch (e) {
398
437
  phases.push({ phase: 'connectors', ok: false, error: e instanceof Error ? e.message : String(e) });
@@ -49,6 +49,13 @@ interface OnboardingState {
49
49
  projectRoots: string[];
50
50
  };
51
51
  detected_cli: Array<{ name: string; path: string; version: string }>;
52
+ /** All non-meta connector ids in the template, in template order. */
53
+ template_connectors: Array<{
54
+ id: string;
55
+ default_enabled: boolean;
56
+ has_prompts: boolean;
57
+ already_installed: boolean;
58
+ }>;
52
59
  template_prompts: Record<string, PromptDef>;
53
60
  /** Per ${key} prompt: true = at least one target field already has a real value;
54
61
  * leave blank to keep it. Backend's applyConnectors preserves existing on empty. */
@@ -138,6 +145,11 @@ export function OnboardingDrawer({ onClose, onComplete }: { onClose: () => void;
138
145
 
139
146
  // Section 3: Connector template values (one per ${key})
140
147
  const [connectorValues, setConnectorValues] = useState<Record<string, string>>({});
148
+ // Section 3.5: which template connectors to install. Default = all
149
+ // (populated from template_connectors on state load). User-typed values
150
+ // for un-selected connectors are kept in connectorValues so re-checking
151
+ // restores them — only the payload skips unselected ones.
152
+ const [selectedConnectors, setSelectedConnectors] = useState<Set<string>>(new Set());
141
153
 
142
154
  // Section 4: Pipelines
143
155
  const [selectedPipelines, setSelectedPipelines] = useState<Set<string>>(new Set());
@@ -179,7 +191,7 @@ export function OnboardingDrawer({ onClose, onComplete }: { onClose: () => void;
179
191
  // drawer into a "checks" view that runs glab-sync / login-status /
180
192
  // backend monitor probes — so users see what's actually live before
181
193
  // we dismiss the wizard.
182
- type CheckState = { status: 'pending' | 'running' | 'ok' | 'fail'; message?: string; detail?: any };
194
+ type CheckState = { status: 'pending' | 'running' | 'ok' | 'fail' | 'skipped'; message?: string; detail?: any };
183
195
  const [phase, setPhase] = useState<'form' | 'checks'>('form');
184
196
  const [checks, setChecks] = useState<Record<string, CheckState>>({
185
197
  glabCli: { status: 'pending' },
@@ -188,19 +200,29 @@ export function OnboardingDrawer({ onClose, onComplete }: { onClose: () => void;
188
200
  });
189
201
 
190
202
  async function runChecks() {
191
- // glab sync — only meaningful if gitlab connector enabled w/ a token.
192
- setChecks(c => ({ ...c, glabCli: { status: 'running' } }));
193
- fetch('/api/connectors/gitlab/sync-cli', { method: 'POST' })
194
- .then(r => r.json().then(j => ({ ok: r.ok && j.ok !== false, j })))
195
- .then(({ ok, j }) => setChecks(c => ({
196
- ...c,
197
- glabCli: {
198
- status: ok ? 'ok' : 'fail',
199
- message: ok ? 'glab CLI synced with GitLab token' : (j.error || j.stderr || 'sync failed'),
200
- detail: j,
201
- },
202
- })))
203
- .catch(e => setChecks(c => ({ ...c, glabCli: { status: 'fail', message: e.message } })));
203
+ // glab sync — only meaningful when the user opted into gitlab.
204
+ // Avoid hitting the API at all when gitlab isn't selected, otherwise
205
+ // the route returns a hard error ("not installed or not enabled")
206
+ // and the check shows red for something the user explicitly skipped.
207
+ if (!selectedConnectors.has('gitlab')) {
208
+ setChecks(c => ({ ...c, glabCli: {
209
+ status: 'skipped',
210
+ message: 'gitlab not selected skipped',
211
+ } }));
212
+ } else {
213
+ setChecks(c => ({ ...c, glabCli: { status: 'running' } }));
214
+ fetch('/api/connectors/gitlab/sync-cli', { method: 'POST' })
215
+ .then(r => r.json().then(j => ({ ok: r.ok && j.ok !== false, j })))
216
+ .then(({ ok, j }) => setChecks(c => ({
217
+ ...c,
218
+ glabCli: {
219
+ status: ok ? 'ok' : 'fail',
220
+ message: ok ? 'glab CLI synced with GitLab token' : (j.error || j.stderr || 'sync failed'),
221
+ detail: j,
222
+ },
223
+ })))
224
+ .catch(e => setChecks(c => ({ ...c, glabCli: { status: 'fail', message: e.message } })));
225
+ }
204
226
 
205
227
  // Login status — POST triggers fresh check across all sources.
206
228
  setChecks(c => ({ ...c, loginStatus: { status: 'running' } }));
@@ -250,7 +272,7 @@ export function OnboardingDrawer({ onClose, onComplete }: { onClose: () => void;
250
272
  if (!initializedRef.current) { initializedRef.current = true; return; }
251
273
  setDirty(true);
252
274
  }, [displayName, displayEmail, apiKey, apiBaseUrl, apiModel, apiProfileId, apiProvider,
253
- connectorValues, selectedPipelines, projectInput, cliAgentId, cliAgentPath]);
275
+ connectorValues, selectedConnectors, selectedPipelines, projectInput, cliAgentId, cliAgentPath]);
254
276
  // Inline marketplace sync (so user doesn't have to leave the wizard
255
277
  // to populate the pipeline list when it's empty / stale).
256
278
  const [syncingMarket, setSyncingMarket] = useState(false);
@@ -301,6 +323,20 @@ export function OnboardingDrawer({ onClose, onComplete }: { onClose: () => void;
301
323
  setDisplayEmail(s.current.displayEmail);
302
324
  setSelectedPipelines(new Set(s.suggested_pipelines));
303
325
 
326
+ // Default-select rules:
327
+ // - template ships enabled=true → on by default
328
+ // - user already installed it (any prior run / manual setup) →
329
+ // on, even if template ships enabled=false (e.g. fortincm).
330
+ // Otherwise re-running the wizard would silently deselect a
331
+ // working connector and the next Apply would skip writing
332
+ // fresh tokens to it.
333
+ const defaultSelected = new Set(
334
+ (s.template_connectors || [])
335
+ .filter(c => c.default_enabled || c.already_installed)
336
+ .map(c => c.id),
337
+ );
338
+ setSelectedConnectors(defaultSelected);
339
+
304
340
  // Pre-fill any prompt that declares a `default` (e.g. jenkins
305
341
  // instance name = "default-jenkins"). User-set values from a prior
306
342
  // run would shadow these via the "currently set" path on apply.
@@ -339,7 +375,7 @@ export function OnboardingDrawer({ onClose, onComplete }: { onClose: () => void;
339
375
  }).catch(() => setState({
340
376
  ok: false, onboardingCompleted: false,
341
377
  current: { displayName: '', displayEmail: '', apiProfileIds: [], apiProfile: null, chatAgent: '', defaultAgent: '', agents: [], projectRoots: [] },
342
- detected_cli: [], template_prompts: {}, prompt_values_set: {}, prompt_targets: {}, suggested_pipelines: [],
378
+ detected_cli: [], template_connectors: [], template_prompts: {}, prompt_values_set: {}, prompt_targets: {}, suggested_pipelines: [],
343
379
  }));
344
380
  }, []);
345
381
 
@@ -356,9 +392,19 @@ export function OnboardingDrawer({ onClose, onComplete }: { onClose: () => void;
356
392
  if (applying) return;
357
393
  setApplying(true); setResult(null);
358
394
  try {
395
+ // Strip values whose target connector got unchecked — keeping them
396
+ // in state lets the user re-check and recover the typed value, but
397
+ // we don't want to write tokens into connectors we're not installing.
398
+ const filteredValues: Record<string, string> = {};
399
+ for (const [k, v] of Object.entries(connectorValues)) {
400
+ const targets = state?.prompt_targets?.[k] || [];
401
+ const anyTargetSelected = targets.some(t => selectedConnectors.has(t.connector));
402
+ if (anyTargetSelected || targets.length === 0) filteredValues[k] = v;
403
+ }
359
404
  const payload: any = {
360
405
  identity: { displayName, displayEmail },
361
- connectorValues,
406
+ connectorValues: filteredValues,
407
+ selectedConnectors: Array.from(selectedConnectors),
362
408
  pipelines: Array.from(selectedPipelines),
363
409
  projectRoots: projectInput.split(/[\n,]/).map(s => s.trim()).filter(Boolean),
364
410
  };
@@ -638,54 +684,84 @@ export function OnboardingDrawer({ onClose, onComplete }: { onClose: () => void;
638
684
  </Section>
639
685
 
640
686
  {/* ── 3. Connectors ────────────────────────────────────── */}
641
- <Section title="3. Connector tokens" hint="Forge ships a Fortinet template. Shared keys (e.g. GitLab PAT for both gitlab + jenkins) are asked once. Tokens encrypted at rest.">
642
- {promptGroups.length === 0 && (
687
+ <Section title="3. Connector tokens" hint="Pick which connectors to install. Default = team-recommended set. Shared keys (e.g. GitLab PAT also used by Jenkins) only asked once.">
688
+ {(state.template_connectors || []).length === 0 && (
643
689
  <p className="text-[10px] text-amber-500">
644
690
  No template loaded. Make sure the marketplace has been synced (Settings → Connectors → Sync).
645
691
  </p>
646
692
  )}
647
- {promptGroups.map(([connector, keys]) => (
648
- <div key={connector} className="border-l-2 border-[var(--accent)]/40 pl-2.5 space-y-2.5">
649
- <div className="text-[10px] font-mono uppercase tracking-wider text-[var(--accent)]">
650
- {connector}
651
- </div>
652
- {keys.map(key => {
653
- const p = prompts[key];
654
- const v = connectorValues[key] ?? '';
655
- const isSet = !!state.prompt_values_set?.[key];
656
- const targets = state.prompt_targets?.[key] || [];
657
- const sharedWith = targets.filter(t => t.connector !== connector);
658
- return (
659
- <div key={key} className="space-y-1">
660
- <label className="text-[10px] font-medium text-[var(--text-primary)] flex items-center gap-1.5 flex-wrap">
661
- {p.label}
662
- {p.required && !isSet && <span className="text-red-500">*</span>}
663
- {!p.required && !isSet && <span className="text-[9px] text-[var(--text-secondary)]">(optional)</span>}
664
- {isSet && <span className="text-[9px] text-emerald-500">● currently set</span>}
665
- {p.url && (
666
- <a href={p.url} target="_blank" rel="noopener noreferrer" className="ml-auto text-[10px] text-[var(--accent)] hover:underline" title={p.url}>
667
- ↗ {p.url_label || 'Get token'}
668
- </a>
669
- )}
670
- </label>
671
- {p.hint && <p className="text-[9px] text-[var(--text-secondary)] leading-snug">{p.hint}</p>}
672
- {sharedWith.length > 0 && (
673
- <p className="text-[9px] text-[var(--text-secondary)] font-mono">
674
- also used by: {sharedWith.map(t => `${t.connector}.${t.field}`).join(', ')}
675
- </p>
676
- )}
677
- <input
678
- type={p.secret ? 'password' : 'text'}
679
- className={inputCls + ' font-mono'}
680
- value={v}
681
- onChange={e => setConnectorValues({ ...connectorValues, [key]: e.target.value })}
682
- placeholder={isSet ? '•••••••• (leave blank to keep current)' : (p.required ? 'required' : 'leave blank')}
683
- />
684
- </div>
685
- );
686
- })}
693
+ {/* Bulk select/deselect */}
694
+ {(state.template_connectors || []).length > 0 && (
695
+ <div className="flex items-center gap-1.5 text-[10px] text-[var(--text-secondary)]">
696
+ <button
697
+ type="button"
698
+ onClick={() => setSelectedConnectors(new Set((state.template_connectors || []).map(c => c.id)))}
699
+ className="px-1.5 py-0.5 border border-[var(--border)] rounded hover:border-[var(--text-primary)]"
700
+ >Select all</button>
701
+ <button
702
+ type="button"
703
+ onClick={() => setSelectedConnectors(new Set())}
704
+ className="px-1.5 py-0.5 border border-[var(--border)] rounded hover:border-[var(--text-primary)]"
705
+ >Deselect all</button>
706
+ <span className="ml-auto">{selectedConnectors.size}/{(state.template_connectors || []).length} selected</span>
687
707
  </div>
688
- ))}
708
+ )}
709
+ {(state.template_connectors || []).map(({ id: connector, has_prompts, already_installed }) => {
710
+ const checked = selectedConnectors.has(connector);
711
+ const promptKeys = (promptGroups.find(([c]) => c === connector)?.[1]) || [];
712
+ const toggle = () => {
713
+ setSelectedConnectors(prev => {
714
+ const next = new Set(prev);
715
+ if (next.has(connector)) next.delete(connector); else next.add(connector);
716
+ return next;
717
+ });
718
+ };
719
+ return (
720
+ <div key={connector} className={`border-l-2 pl-2.5 space-y-2 ${checked ? 'border-[var(--accent)]/40' : 'border-[var(--border)] opacity-60'}`}>
721
+ <label className="flex items-center gap-1.5 text-[11px] cursor-pointer">
722
+ <input type="checkbox" checked={checked} onChange={toggle} />
723
+ <span className="font-mono uppercase tracking-wider text-[var(--accent)]">{connector}</span>
724
+ {already_installed && <span className="text-[9px] text-emerald-500">● installed</span>}
725
+ {!has_prompts && <span className="text-[9px] text-[var(--text-secondary)]">(no tokens needed)</span>}
726
+ </label>
727
+ {checked && promptKeys.map(key => {
728
+ const p = prompts[key];
729
+ const v = connectorValues[key] ?? '';
730
+ const isSet = !!state.prompt_values_set?.[key];
731
+ const targets = state.prompt_targets?.[key] || [];
732
+ const sharedWith = targets.filter(t => t.connector !== connector);
733
+ return (
734
+ <div key={key} className="space-y-1 ml-5">
735
+ <label className="text-[10px] font-medium text-[var(--text-primary)] flex items-center gap-1.5 flex-wrap">
736
+ {p.label}
737
+ {p.required && !isSet && <span className="text-red-500">*</span>}
738
+ {!p.required && !isSet && <span className="text-[9px] text-[var(--text-secondary)]">(optional)</span>}
739
+ {isSet && <span className="text-[9px] text-emerald-500">● currently set</span>}
740
+ {p.url && (
741
+ <a href={p.url} target="_blank" rel="noopener noreferrer" className="ml-auto text-[10px] text-[var(--accent)] hover:underline" title={p.url}>
742
+ ↗ {p.url_label || 'Get token'}
743
+ </a>
744
+ )}
745
+ </label>
746
+ {p.hint && <p className="text-[9px] text-[var(--text-secondary)] leading-snug">{p.hint}</p>}
747
+ {sharedWith.length > 0 && (
748
+ <p className="text-[9px] text-[var(--text-secondary)] font-mono">
749
+ also used by: {sharedWith.map(t => `${t.connector}.${t.field}`).join(', ')}
750
+ </p>
751
+ )}
752
+ <input
753
+ type={p.secret ? 'password' : 'text'}
754
+ className={inputCls + ' font-mono'}
755
+ value={v}
756
+ onChange={e => setConnectorValues({ ...connectorValues, [key]: e.target.value })}
757
+ placeholder={isSet ? '•••••••• (leave blank to keep current)' : (p.required ? 'required' : 'leave blank')}
758
+ />
759
+ </div>
760
+ );
761
+ })}
762
+ </div>
763
+ );
764
+ })}
689
765
  </Section>
690
766
 
691
767
  {/* ── 4. Pipelines ─────────────────────────────────────── */}
@@ -876,17 +952,19 @@ function CheckRow({
876
952
  }: {
877
953
  title: string;
878
954
  hint?: string;
879
- state: { status: 'pending' | 'running' | 'ok' | 'fail'; message?: string; detail?: any };
955
+ state: { status: 'pending' | 'running' | 'ok' | 'fail' | 'skipped'; message?: string; detail?: any };
880
956
  renderDetail?: (d: any) => React.ReactNode;
881
957
  }) {
882
958
  const [open, setOpen] = useState(false);
883
959
  const icon = state.status === 'ok' ? '✓'
884
960
  : state.status === 'fail' ? '✗'
885
961
  : state.status === 'running' ? '⟳'
962
+ : state.status === 'skipped' ? '–'
886
963
  : '○';
887
964
  const color = state.status === 'ok' ? 'text-emerald-500'
888
965
  : state.status === 'fail' ? 'text-red-400'
889
966
  : state.status === 'running' ? 'text-amber-500'
967
+ : state.status === 'skipped' ? 'text-[var(--text-secondary)]'
890
968
  : 'text-[var(--text-secondary)]';
891
969
  return (
892
970
  <div className="border border-[var(--border)] rounded p-2.5 space-y-1">
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aion0/forge",
3
- "version": "0.10.37",
3
+ "version": "0.10.38",
4
4
  "description": "Unified AI workflow platform — multi-model task orchestration, persistent sessions, web terminal, remote access",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -113,7 +113,7 @@
113
113
  "username": "admin",
114
114
  "password": "${fortincm_password}"
115
115
  },
116
- "enabled": false
116
+ "enabled": true
117
117
  },
118
118
 
119
119
  "scap": {