@aion0/forge 0.10.40 → 0.10.42

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.
Files changed (61) hide show
  1. package/CLAUDE.md +1 -1
  2. package/RELEASE_NOTES.md +4 -7
  3. package/app/api/bridge-info/route.ts +34 -0
  4. package/app/api/connectors/[id]/test/route.ts +14 -0
  5. package/app/api/connectors/import-config-template/route.ts +103 -13
  6. package/app/api/enterprise-keys/route.ts +204 -0
  7. package/app/api/marketplace/sync-all/route.ts +28 -0
  8. package/app/api/monitor/route.ts +29 -6
  9. package/app/api/onboarding/route.ts +920 -23
  10. package/app/api/projects/clone/route.ts +51 -0
  11. package/app/api/settings/route.ts +11 -2
  12. package/app/chat/page.tsx +8 -5
  13. package/bin/forge-server.mjs +98 -1
  14. package/cli/mw.mjs +16 -6
  15. package/cli/mw.ts +19 -6
  16. package/components/ConnectorsPanel.tsx +85 -13
  17. package/components/CraftTerminal.tsx +12 -3
  18. package/components/Dashboard.tsx +55 -17
  19. package/components/DocTerminal.tsx +12 -6
  20. package/components/EnterpriseBadge.tsx +420 -0
  21. package/components/LoginStatusPanel.tsx +15 -1
  22. package/components/OnboardingWizard.tsx +418 -31
  23. package/components/SettingsModal.tsx +382 -63
  24. package/components/SkillsPanel.tsx +116 -91
  25. package/components/WebTerminal.tsx +36 -13
  26. package/dev-test.sh +34 -1
  27. package/install.sh +29 -2
  28. package/lib/agents/claude-adapter.ts +18 -4
  29. package/lib/agents/index.ts +33 -4
  30. package/lib/auth/login-status.ts +14 -0
  31. package/lib/chat/agent-loop.ts +23 -1
  32. package/lib/chat/llm/anthropic.ts +6 -1
  33. package/lib/chat/protocols/http.ts +15 -2
  34. package/lib/chat/tool-dispatcher.ts +163 -1
  35. package/lib/connectors/registry.ts +69 -4
  36. package/lib/connectors/sync.ts +536 -138
  37. package/lib/connectors/test-runner.ts +21 -3
  38. package/lib/connectors/types.ts +36 -4
  39. package/lib/connectors/wizard-template.ts +161 -0
  40. package/lib/dirs.ts +5 -0
  41. package/lib/enterprise-known.ts +34 -0
  42. package/lib/enterprise-secret.ts +87 -0
  43. package/lib/enterprise.ts +208 -0
  44. package/lib/help-docs/00-overview.md +12 -0
  45. package/lib/help-docs/01-settings.md +47 -1
  46. package/lib/help-docs/17-connectors.md +25 -22
  47. package/lib/help-docs/CLAUDE.md +1 -0
  48. package/lib/init.ts +13 -6
  49. package/lib/marketplace-sync.ts +70 -0
  50. package/lib/memory/temper-provision.ts +92 -0
  51. package/lib/pipeline-gc.ts +5 -2
  52. package/lib/pipeline.ts +26 -21
  53. package/lib/plugins/templates.ts +76 -3
  54. package/lib/projects.ts +85 -0
  55. package/lib/settings.ts +10 -0
  56. package/lib/telegram-bot.ts +14 -2
  57. package/lib/workflow-marketplace.ts +174 -108
  58. package/package.json +1 -1
  59. package/{middleware.ts → proxy.ts} +2 -1
  60. package/src/core/db/database.ts +8 -2
  61. package/templates/connector-config-template.json +0 -7
@@ -0,0 +1,51 @@
1
+ /**
2
+ * POST /api/projects/clone
3
+ *
4
+ * Body: { name?: string }
5
+ *
6
+ * Trigger the same auto-clone path the pipeline orchestrator uses: tries to
7
+ * clone the GitLab connector's default_project_path under projectRoots[0]
8
+ * (or ~/IdeaProjects), refreshes projectRoots, and returns the resolved
9
+ * project. Used by the onboarding wizard's empty-state banner and by the
10
+ * chat UI when a pipeline fails with "Project not found".
11
+ */
12
+
13
+ import { NextResponse } from 'next/server';
14
+ import { resolveOrCloneProject, getDefaultCloneRoot } from '@/lib/projects';
15
+ import { getInstalledConnector } from '@/lib/connectors/registry';
16
+
17
+ export async function POST(req: Request) {
18
+ let body: any = {};
19
+ try { body = await req.json(); } catch { /* empty body is fine */ }
20
+ const name = typeof body?.name === 'string' ? body.name.trim() : '';
21
+
22
+ const r = resolveOrCloneProject(name);
23
+ // Scratch fallback isn't really a "success" for the caller — they asked for
24
+ // a real clone. Surface why we couldn't do it so the UI can prompt the user
25
+ // to fix the underlying gap (e.g. set gitlab default_project_path).
26
+ if (r.source === 'scratch') {
27
+ const gl = getInstalledConnector('gitlab');
28
+ const path = String(gl?.config?.default_project_path || '').trim();
29
+ const base = String(gl?.config?.base_url || '').trim();
30
+ const reason = !gl ? 'gitlab_connector_missing'
31
+ : !path ? 'gitlab_default_project_path_missing'
32
+ : !base ? 'gitlab_base_url_missing'
33
+ : 'clone_failed';
34
+ return NextResponse.json({
35
+ ok: false,
36
+ reason,
37
+ fallback_project: { name: r.project.name, path: r.project.path },
38
+ target_root: getDefaultCloneRoot(),
39
+ hint: reason === 'clone_failed'
40
+ ? 'git clone failed — see server log. Pipelines will still run inside <dataDir>/scratch as a fallback.'
41
+ : 'set gitlab default_project_path under Settings → Connectors → gitlab; meanwhile pipelines run inside <dataDir>/scratch.',
42
+ }, { status: 200 });
43
+ }
44
+
45
+ return NextResponse.json({
46
+ ok: true,
47
+ project: { name: r.project.name, path: r.project.path },
48
+ source: r.source,
49
+ clone_url: r.clone_url,
50
+ });
51
+ }
@@ -24,8 +24,17 @@ export async function PUT(req: Request) {
24
24
  return NextResponse.json({ ok: false, error: 'Invalid field' }, { status: 400 });
25
25
  }
26
26
 
27
- // Verify admin password
28
- if (!verifyAdmin(adminPassword)) {
27
+ // First-time admin-password set bypass: when the user is setting the
28
+ // admin password itself AND no existing password is stored, accept
29
+ // without verifyAdmin (there's nothing to verify against). After the
30
+ // initial set, normal "verify current password" flow applies.
31
+ const existingSettings = loadSettings();
32
+ const isFirstAdminSet =
33
+ field === 'telegramTunnelPassword'
34
+ && !existingSettings.telegramTunnelPassword
35
+ && !!newValue;
36
+
37
+ if (!isFirstAdminSet && !verifyAdmin(adminPassword)) {
29
38
  return NextResponse.json({ ok: false, error: 'Wrong password' }, { status: 403 });
30
39
  }
31
40
 
package/app/chat/page.tsx CHANGED
@@ -19,7 +19,7 @@
19
19
 
20
20
  'use client';
21
21
 
22
- import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
22
+ import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
23
23
  import MarkdownContent from '@/components/MarkdownContent';
24
24
  import WatchesPanel from '@/components/WatchesPanel';
25
25
  import type { ContentBlock, Message, Session } from '@/lib/chat/types';
@@ -497,7 +497,10 @@ function RoleBlock({ role, ts, children }: { role: 'user' | 'assistant'; ts?: nu
497
497
  );
498
498
  }
499
499
 
500
- function MessageView({ m }: { m: Message }) {
500
+ // Memoized keystroke in the composer re-renders the page, and without
501
+ // memo every prior message re-runs its markdown parse → typing in a long
502
+ // chat became visibly laggy.
503
+ const MessageView = memo(function MessageView({ m }: { m: Message }) {
501
504
  return (
502
505
  <RoleBlock role={m.role} ts={m.ts}>
503
506
  {m.blocks.map((b, i) => (
@@ -510,9 +513,9 @@ function MessageView({ m }: { m: Message }) {
510
513
  )}
511
514
  </RoleBlock>
512
515
  );
513
- }
516
+ });
514
517
 
515
- function BlockView({ b, role }: { b: ContentBlock; role?: 'user' | 'assistant' }) {
518
+ const BlockView = memo(function BlockView({ b, role }: { b: ContentBlock; role?: 'user' | 'assistant' }) {
516
519
  if (b.type === 'text') {
517
520
  return <MarkdownContent content={b.text} linkify={role === 'assistant'} />;
518
521
  }
@@ -524,7 +527,7 @@ function BlockView({ b, role }: { b: ContentBlock; role?: 'user' | 'assistant' }
524
527
  return <ToolResultBlockView content={txt} isError={!!b.is_error} />;
525
528
  }
526
529
  return null;
527
- }
530
+ });
528
531
 
529
532
  function ToolUseBlockView({ name, input }: { name: string; input: unknown }) {
530
533
  const [open, setOpen] = useState(false);
@@ -93,6 +93,28 @@ const isRestart = process.argv.includes('--restart');
93
93
  const isRebuild = process.argv.includes('--rebuild');
94
94
  const resetTerminal = process.argv.includes('--reset-terminal');
95
95
  const resetPassword = process.argv.includes('--reset-password');
96
+ const addEnterpriseKeyOnly = process.argv.includes('--add-enterprise-key');
97
+
98
+ // ── Enterprise keys (--enterprise-key=…) ──
99
+ //
100
+ // Collected here so they're seen at startup and (when --add-enterprise-key
101
+ // is also passed) we can persist + exit without spinning up the server.
102
+ // Supports both `--enterprise-key=foo` and `--enterprise-key foo` forms;
103
+ // repeat for multiple keys.
104
+ function collectEnterpriseKeys() {
105
+ const keys = [];
106
+ for (let i = 0; i < process.argv.length; i++) {
107
+ const arg = process.argv[i];
108
+ if (arg.startsWith('--enterprise-key=')) {
109
+ keys.push(arg.slice('--enterprise-key='.length));
110
+ } else if (arg === '--enterprise-key' && i + 1 < process.argv.length) {
111
+ keys.push(process.argv[i + 1]);
112
+ i += 1;
113
+ }
114
+ }
115
+ return keys.map((k) => k.trim()).filter(Boolean);
116
+ }
117
+ const enterpriseKeysFromArgv = collectEnterpriseKeys();
96
118
 
97
119
  const webPort = parseInt(getArg('--port')) || 8403;
98
120
  const terminalPort = parseInt(getArg('--terminal-port')) || (webPort + 1);
@@ -221,7 +243,9 @@ process.env.WORKSPACE_PORT = String(workspacePort);
221
243
  process.env.FORGE_DATA_DIR = DATA_DIR;
222
244
 
223
245
  // ── Password setup (first run or --reset-password) ──
224
- if (!isStop) {
246
+ // Skipped in --add-enterprise-key persist-and-exit mode: registering a
247
+ // key from install.sh must not block on an interactive prompt.
248
+ if (!isStop && !addEnterpriseKeyOnly) {
225
249
  const YAML = await import('yaml');
226
250
  const settingsFile = join(DATA_DIR, 'settings.yaml');
227
251
  let settings = {};
@@ -289,6 +313,79 @@ if (!isStop) {
289
313
  }
290
314
  }
291
315
 
316
+ // ── Enterprise keys (--enterprise-key=…) ──
317
+ //
318
+ // Each key is encrypted with AES-256-GCM via the same `.encrypt-key`
319
+ // file the password uses, then appended to `<dataDir>/.enterprise-keys.json`
320
+ // as `{ v: 1, keys: ["enc:…"] }`. lib/enterprise.ts reads + decrypts on
321
+ // the runtime side. Duplicate-by-tenant dedup happens lazily on first
322
+ // listEnterpriseSources(); this layer just appends.
323
+ if (!isStop && (enterpriseKeysFromArgv.length > 0 || addEnterpriseKeyOnly)) {
324
+ if (addEnterpriseKeyOnly && enterpriseKeysFromArgv.length === 0) {
325
+ console.error('[forge] --add-enterprise-key requires at least one --enterprise-key=<value>');
326
+ process.exit(1);
327
+ }
328
+ const crypto = await import('node:crypto');
329
+ const ENC_KEY_FILE = join(DATA_DIR, '.encrypt-key');
330
+ const KEYS_FILE = join(DATA_DIR, '.enterprise-keys.json');
331
+ let encKey;
332
+ if (existsSync(ENC_KEY_FILE)) {
333
+ encKey = Buffer.from(readFileSync(ENC_KEY_FILE, 'utf-8').trim(), 'hex');
334
+ } else {
335
+ encKey = crypto.randomBytes(32);
336
+ if (!existsSync(DATA_DIR)) mkdirSync(DATA_DIR, { recursive: true });
337
+ writeFileSync(ENC_KEY_FILE, encKey.toString('hex'), { mode: 0o600 });
338
+ }
339
+ const encryptOne = (plain) => {
340
+ const iv = crypto.randomBytes(12);
341
+ const cipher = crypto.createCipheriv('aes-256-gcm', encKey, iv);
342
+ const enc = Buffer.concat([cipher.update(plain, 'utf-8'), cipher.final()]);
343
+ const tag = cipher.getAuthTag();
344
+ return `enc:${iv.toString('base64')}.${tag.toString('base64')}.${enc.toString('base64')}`;
345
+ };
346
+ const decryptOne = (blob) => {
347
+ if (!blob.startsWith('enc:')) return blob;
348
+ try {
349
+ const [ivB64, tagB64, dataB64] = blob.slice(4).split('.');
350
+ const iv = Buffer.from(ivB64, 'base64');
351
+ const tag = Buffer.from(tagB64, 'base64');
352
+ const data = Buffer.from(dataB64, 'base64');
353
+ const d = crypto.createDecipheriv('aes-256-gcm', encKey, iv);
354
+ d.setAuthTag(tag);
355
+ return Buffer.concat([d.update(data), d.final()]).toString('utf-8');
356
+ } catch { return ''; }
357
+ };
358
+ let existing = { v: 1, keys: [] };
359
+ if (existsSync(KEYS_FILE)) {
360
+ try {
361
+ const parsed = JSON.parse(readFileSync(KEYS_FILE, 'utf-8'));
362
+ if (parsed && Array.isArray(parsed.keys)) existing = { v: 1, keys: parsed.keys };
363
+ } catch {
364
+ // Corrupt — start fresh; runtime will surface its own diagnostics if
365
+ // anything was meaningful there.
366
+ }
367
+ }
368
+ // Dedup against existing plaintexts so a second run of
369
+ // ./dev-test.sh --enterprise-key=…
370
+ // doesn't quietly append a duplicate and grow the file forever.
371
+ const existingPlaintexts = new Set(existing.keys.map(decryptOne).filter(Boolean));
372
+ let added = 0;
373
+ for (const k of enterpriseKeysFromArgv) {
374
+ if (existingPlaintexts.has(k)) continue;
375
+ existing.keys.push(encryptOne(k));
376
+ existingPlaintexts.add(k);
377
+ added += 1;
378
+ }
379
+ if (!existsSync(DATA_DIR)) mkdirSync(DATA_DIR, { recursive: true });
380
+ writeFileSync(KEYS_FILE, JSON.stringify(existing, null, 2), { mode: 0o600 });
381
+ const skipped = enterpriseKeysFromArgv.length - added;
382
+ console.log(`[forge] Persisted ${added} new enterprise key(s)${skipped ? `, skipped ${skipped} duplicate` : ''} → ${KEYS_FILE}`);
383
+
384
+ if (addEnterpriseKeyOnly) {
385
+ process.exit(0);
386
+ }
387
+ }
388
+
292
389
  // ── Find pids listening on a TCP port (portable) ──
293
390
  // macOS ships lsof; minimal Linux containers (RHEL/CentOS/Alpine) often
294
391
  // don't. Try lsof → ss (iproute2, ~universal on modern Linux) → fuser
package/cli/mw.mjs CHANGED
@@ -741,6 +741,7 @@ __export(dirs_exports, {
741
741
  getClaudeDir: () => getClaudeDir,
742
742
  getConfigDir: () => getConfigDir,
743
743
  getDataDir: () => getDataDir,
744
+ getEnterpriseKeysPath: () => getEnterpriseKeysPath,
744
745
  migrateDataDir: () => migrateDataDir
745
746
  });
746
747
  import { homedir as homedir2 } from "node:os";
@@ -796,6 +797,9 @@ function migrateDataDir() {
796
797
  }
797
798
  console.log("[forge] Migration complete. Old files kept as backup.");
798
799
  }
800
+ function getEnterpriseKeysPath() {
801
+ return join3(getDataDir(), ".enterprise-keys.json");
802
+ }
799
803
  function getClaudeDir() {
800
804
  return process.env.CLAUDE_HOME || join3(homedir2(), ".claude");
801
805
  }
@@ -874,16 +878,22 @@ async function main() {
874
878
  process.exit(0);
875
879
  }
876
880
  if (cmd === "--reset-password") {
877
- const { execSync: execSync2 } = await import("node:child_process");
881
+ const { spawnSync: spawnSync2 } = await import("node:child_process");
878
882
  const { join: join4, dirname } = await import("node:path");
879
883
  const { fileURLToPath } = await import("node:url");
880
884
  const serverScript = join4(dirname(fileURLToPath(import.meta.url)), "..", "bin", "forge-server.mjs");
881
- try {
882
- execSync2(`node ${serverScript} --reset-password`, { stdio: "inherit" });
883
- } catch {
884
- }
885
+ const passthru = process.argv.slice(3);
886
+ spawnSync2("node", [serverScript, "--reset-password", ...passthru], { stdio: "inherit" });
885
887
  process.exit(0);
886
888
  }
889
+ if (cmd === "--add-enterprise-key") {
890
+ const { spawnSync: spawnSync2 } = await import("node:child_process");
891
+ const { join: join4, dirname } = await import("node:path");
892
+ const { fileURLToPath } = await import("node:url");
893
+ const serverScript = join4(dirname(fileURLToPath(import.meta.url)), "..", "bin", "forge-server.mjs");
894
+ const r = spawnSync2("node", [serverScript, "--add-enterprise-key", ...args], { stdio: "inherit" });
895
+ process.exit(r.status ?? 1);
896
+ }
887
897
  switch (cmd) {
888
898
  case "chat":
889
899
  case "c": {
@@ -1418,7 +1428,7 @@ Options for 'forge server start':
1418
1428
  Shortcuts: c=chat, j=jobs, wt=worktree, t=task, ls=tasks, w=watch, s=status, l=log, f=flows, p=projects, pw=password`);
1419
1429
  }
1420
1430
  }
1421
- var skipUpdateCheck = ["upgrade", "uninstall", "--version", "-v", "--reset-password"];
1431
+ var skipUpdateCheck = ["upgrade", "uninstall", "--version", "-v", "--reset-password", "--add-enterprise-key"];
1422
1432
  main().then(() => {
1423
1433
  if (!skipUpdateCheck.includes(cmd)) return checkForUpdate();
1424
1434
  }).catch((err) => {
package/cli/mw.ts CHANGED
@@ -74,17 +74,30 @@ async function main() {
74
74
  }
75
75
 
76
76
  if (cmd === '--reset-password') {
77
- // Shortcut: delegate to forge-server.mjs --reset-password
78
- const { execSync } = await import('node:child_process');
77
+ // Shortcut: delegate to forge-server.mjs --reset-password.
78
+ // Forward any trailing args so `--dir <path>` targets a specific
79
+ // instance — e.g. `forge --reset-password --dir ~/.forge-test`.
80
+ const { spawnSync } = await import('node:child_process');
79
81
  const { join, dirname } = await import('node:path');
80
82
  const { fileURLToPath } = await import('node:url');
81
83
  const serverScript = join(dirname(fileURLToPath(import.meta.url)), '..', 'bin', 'forge-server.mjs');
82
- try {
83
- execSync(`node ${serverScript} --reset-password`, { stdio: 'inherit' });
84
- } catch {}
84
+ const passthru = process.argv.slice(3);
85
+ spawnSync('node', [serverScript, '--reset-password', ...passthru], { stdio: 'inherit' });
85
86
  process.exit(0);
86
87
  }
87
88
 
89
+ if (cmd === '--add-enterprise-key') {
90
+ // Shortcut: delegate persist-and-exit to forge-server.mjs. Caller is
91
+ // expected to also pass one or more `--enterprise-key=<value>`
92
+ // forge --add-enterprise-key --enterprise-key=fortinet:github_pat_xxx
93
+ const { spawnSync } = await import('node:child_process');
94
+ const { join, dirname } = await import('node:path');
95
+ const { fileURLToPath } = await import('node:url');
96
+ const serverScript = join(dirname(fileURLToPath(import.meta.url)), '..', 'bin', 'forge-server.mjs');
97
+ const r = spawnSync('node', [serverScript, '--add-enterprise-key', ...args], { stdio: 'inherit' });
98
+ process.exit(r.status ?? 1);
99
+ }
100
+
88
101
  switch (cmd) {
89
102
  case 'chat':
90
103
  case 'c': {
@@ -624,7 +637,7 @@ Shortcuts: c=chat, j=jobs, wt=worktree, t=task, ls=tasks, w=watch, s=status, l=l
624
637
  }
625
638
  }
626
639
 
627
- const skipUpdateCheck = ['upgrade', 'uninstall', '--version', '-v', '--reset-password'];
640
+ const skipUpdateCheck = ['upgrade', 'uninstall', '--version', '-v', '--reset-password', '--add-enterprise-key'];
628
641
  main().then(() => { if (!skipUpdateCheck.includes(cmd)) return checkForUpdate(); }).catch(err => {
629
642
  console.error(err.message);
630
643
  process.exit(1);
@@ -27,8 +27,36 @@ interface MarketEntry {
27
27
  author?: string;
28
28
  installed_version?: string;
29
29
  update_available?: boolean;
30
+ /** Source that publishes the proposed update — may differ from
31
+ * source_id (the installed origin). E.g. public is installed, then
32
+ * the user adds an enterprise source that publishes a higher version. */
33
+ update_source_id?: string;
30
34
  compatible: boolean;
31
35
  source: 'registry' | 'local';
36
+ /** Which marketplace source provided the winning entry:
37
+ * `public` | `enterprise-<tenant>` | undefined for local-only. */
38
+ source_id?: string;
39
+ /** Lower-priority sources that also list this id but were outranked. */
40
+ hidden_sources?: { source_id: string; display_name: string; version: string }[];
41
+ }
42
+
43
+ /** Compact badge describing a source. `enterprise-…` → 🔒 + name; `public` → 🌐;
44
+ * install-local (no registry source) → 📦 local. Tooltip carries full id. */
45
+ function SourceBadge({ entry, size = 'sm' }: { entry: Pick<MarketEntry, 'source' | 'source_id' | 'hidden_sources'>; size?: 'xs' | 'sm' }) {
46
+ const px = size === 'xs' ? 'text-[8px] px-1 py-0.5' : 'text-[9px] px-1.5 py-0.5';
47
+ if (entry.source === 'local' || !entry.source_id) {
48
+ return <span className={`${px} rounded bg-[var(--accent)]/15 text-[var(--accent)]`} title="Installed locally, not from any registry">📦 local</span>;
49
+ }
50
+ if (entry.source_id === 'public') {
51
+ return <span className={`${px} rounded bg-[var(--bg-tertiary)] text-[var(--text-secondary)]`} title="Public marketplace">🌐 public</span>;
52
+ }
53
+ // enterprise-<tenant>
54
+ const tenant = entry.source_id.replace(/^enterprise-/, '');
55
+ const hiddenCount = entry.hidden_sources?.length || 0;
56
+ const title = hiddenCount > 0
57
+ ? `Enterprise source: ${tenant} (overrides ${hiddenCount} lower-priority source${hiddenCount > 1 ? 's' : ''})`
58
+ : `Enterprise source: ${tenant}`;
59
+ return <span className={`${px} rounded bg-amber-500/15 text-amber-400`} title={title}>🔒 {tenant}</span>;
32
60
  }
33
61
 
34
62
  interface MarketState {
@@ -499,13 +527,9 @@ export default function ConnectorsPanel() {
499
527
  >
500
528
  {uploading ? 'Installing…' : '+ Upload'}
501
529
  </button>
502
- <button
503
- onClick={sync}
504
- disabled={syncing}
505
- className="text-[10px] px-2.5 py-0.5 border border-[var(--accent)] text-[var(--accent)] rounded hover:bg-[var(--accent)] hover:text-white transition-colors disabled:opacity-40"
506
- >
507
- {syncing ? 'Syncing…' : 'Refresh'}
508
- </button>
530
+ {/* Per-tab Sync removed — use Marketplace top → "↻ Sync all"
531
+ which covers every type (connectors + workflows + skills)
532
+ from every source (public + enterprise) in one trip. */}
509
533
  </div>
510
534
 
511
535
  {dragOver && (
@@ -550,9 +574,7 @@ export default function ConnectorsPanel() {
550
574
  {update && (
551
575
  <span className="text-[8px] px-1 py-0.5 rounded bg-yellow-500/10 text-yellow-400">update</span>
552
576
  )}
553
- {e.source === 'local' && (
554
- <span className="text-[8px] px-1 py-0.5 rounded bg-[var(--accent)]/15 text-[var(--accent)]">local</span>
555
- )}
577
+ <SourceBadge entry={e} size="xs" />
556
578
  </div>
557
579
  <div className="flex items-center gap-1.5 mt-0.5">
558
580
  <span className="text-[9px] text-[var(--text-secondary)]">
@@ -601,9 +623,15 @@ export default function ConnectorsPanel() {
601
623
  update available · v{me.installed_version} → v{me.version}
602
624
  </span>
603
625
  )}
604
- {me.source === 'local' && (
605
- <span className="text-[9px] px-1.5 py-0.5 rounded bg-[var(--accent)]/15 text-[var(--accent)]">
606
- local · not in registry
626
+ <SourceBadge entry={me} />
627
+ {installed && me.update_source_id && me.update_source_id !== me.source_id && (
628
+ <span
629
+ className="text-[9px] px-1.5 py-0.5 rounded bg-amber-500/10 text-amber-400"
630
+ title={`Installed manifest comes from ${me.source_id}, but ${me.update_source_id} also publishes this connector at a higher priority. Reinstall to switch.`}
631
+ >
632
+ ↻ also in {me.update_source_id === 'public'
633
+ ? 'public'
634
+ : me.update_source_id.replace(/^enterprise-/, '')}
607
635
  </span>
608
636
  )}
609
637
  </div>
@@ -630,6 +658,16 @@ export default function ConnectorsPanel() {
630
658
  {busyId === me.id ? '…' : 'Update'}
631
659
  </button>
632
660
  )}
661
+ {installed && !update && (
662
+ <button
663
+ onClick={() => act('update', me.id)}
664
+ disabled={busyId === me.id}
665
+ title="Re-pull this connector's manifest from the highest-priority source (force, even if version matches). Use this when you bumped the manifest version but the marketplace top-level Sync doesn't seem to have picked it up."
666
+ className="text-[10px] px-2 py-1 rounded border border-[var(--border)] text-[var(--text-secondary)] hover:border-[var(--text-primary)] hover:text-[var(--text-primary)] transition-colors disabled:opacity-40"
667
+ >
668
+ {busyId === me.id ? '…' : '↻'}
669
+ </button>
670
+ )}
633
671
  {installed && (
634
672
  <button
635
673
  onClick={() => act('uninstall', me.id)}
@@ -642,6 +680,40 @@ export default function ConnectorsPanel() {
642
680
  </div>
643
681
  </div>
644
682
 
683
+ {/* Sources — surface enterprise winner + outranked rows so
684
+ the user can see "this mantis comes from fortinet, public
685
+ has one too but it's hidden". Hide entirely for the boring
686
+ case (public winner with no overlap). */}
687
+ {(() => {
688
+ const isEnterpriseWinner = me.source_id && me.source_id !== 'public' && me.source !== 'local';
689
+ const hasHidden = !!me.hidden_sources?.length;
690
+ if (!isEnterpriseWinner && !hasHidden) return null;
691
+ return (
692
+ <div>
693
+ <h3 className="text-[11px] font-semibold text-[var(--text-primary)] mb-1.5">
694
+ Sources <span className="text-[9px] text-[var(--text-secondary)] font-normal">· higher priority first</span>
695
+ </h3>
696
+ <div className="rounded border border-[var(--border)] divide-y divide-[var(--border)] bg-[var(--bg-tertiary)]">
697
+ <div className="px-2.5 py-1.5 flex items-center gap-2">
698
+ <SourceBadge entry={me} size="xs" />
699
+ <span className="text-[10px] font-mono text-[var(--text-primary)]">v{me.version}</span>
700
+ <span className="text-[9px] text-green-400 ml-auto">in use</span>
701
+ </div>
702
+ {me.hidden_sources?.map((hs) => (
703
+ <div key={hs.source_id} className="px-2.5 py-1.5 flex items-center gap-2 opacity-60">
704
+ <SourceBadge
705
+ entry={{ source: 'registry', source_id: hs.source_id }}
706
+ size="xs"
707
+ />
708
+ <span className="text-[10px] font-mono text-[var(--text-secondary)]">v{hs.version}</span>
709
+ <span className="text-[9px] text-[var(--text-secondary)] ml-auto">hidden</span>
710
+ </div>
711
+ ))}
712
+ </div>
713
+ </div>
714
+ );
715
+ })()}
716
+
645
717
  {/* Tools — only when manifest is on disk */}
646
718
  {installed && detail && (
647
719
  <div>
@@ -139,7 +139,7 @@ export default function CraftTerminal({
139
139
  ws.send(JSON.stringify({ type: 'create', sessionName: activeSessionRef.current, cols: term.cols, rows: term.rows }));
140
140
  // After creation, cd into craft dir and start the chosen agent
141
141
  // Wait until agents fetch settles so we can pick the right CLI + resume flag
142
- const tryLaunch = (attempt = 0) => {
142
+ const tryLaunch = async (attempt = 0) => {
143
143
  if (ws.readyState !== WebSocket.OPEN) return;
144
144
  const list = agentsRef.current;
145
145
  const id = agentIdRef.current;
@@ -148,11 +148,20 @@ export default function CraftTerminal({
148
148
  return;
149
149
  }
150
150
  const a = list.find(x => x.id === id) || list[0];
151
- const cli = a?.path || a?.id || 'claude';
151
+ // Resolve cliCmd via the API so derived agents (e.g.
152
+ // forti-k2 base: claude) inherit the base's absolute
153
+ // path instead of falling back to bare a.id / 'claude'.
154
+ let cli = a?.path || 'claude';
155
+ const targetId = a?.id || 'claude';
156
+ try {
157
+ const info = await fetch(`/api/agents?resolve=${encodeURIComponent(targetId)}`).then(r => r.ok ? r.json() : null);
158
+ if (info?.cliCmd) cli = info.cliCmd;
159
+ } catch {}
160
+ const quotedCli = `"${cli}"`;
152
161
  const isClaude = (a?.id === 'claude') || (a as any)?.cliType === 'claude-code';
153
162
  const sf = (isClaude && skipPermRef.current) ? ' --dangerously-skip-permissions' : '';
154
163
  const resume = resumeIdRef.current && isClaude ? ` --resume ${resumeIdRef.current}` : '';
155
- ws.send(JSON.stringify({ type: 'input', data: `cd "${craftDir}" && ${cli}${sf}${resume}\n` }));
164
+ ws.send(JSON.stringify({ type: 'input', data: `cd "${craftDir}" && ${quotedCli}${sf}${resume}\n` }));
156
165
  };
157
166
  setTimeout(() => tryLaunch(), 500);
158
167
  }
@@ -26,6 +26,7 @@ const SettingsModal = lazy(() => import('./SettingsModal'));
26
26
  const MonitorPanel = lazy(() => import('./MonitorPanel'));
27
27
  const LoginStatusPanel = lazy(() => import('./LoginStatusPanel'));
28
28
  const ActivityPanel = lazy(() => import('./ActivityPanel'));
29
+ const EnterpriseBadge = lazy(() => import('./EnterpriseBadge'));
29
30
  const WorkspaceView = lazy(() => import('./WorkspaceView'));
30
31
  // WorkspaceTree moved into ProjectDetail — no longer needed at Dashboard level
31
32
  import { OnboardingBanner, OnboardingDrawer } from './OnboardingWizard';
@@ -136,12 +137,27 @@ export default function Dashboard({ user }: { user: any }) {
136
137
  const [showSettings, setShowSettings] = useState(false);
137
138
  const [needsOnboarding, setNeedsOnboarding] = useState(false);
138
139
  const [showOnboarding, setShowOnboarding] = useState(false);
140
+ // E4: when EnterpriseBadge fires forge:open-onboarding after a fresh
141
+ // key add, the source id rides along so the wizard scopes to that
142
+ // new tenant immediately instead of resolving the priority chain.
143
+ const [onboardingSourceId, setOnboardingSourceId] = useState<string | null>(null);
144
+ useEffect(() => {
145
+ const handler = (e: Event) => {
146
+ const detail = (e as CustomEvent).detail || {};
147
+ setOnboardingSourceId(detail.source_id || null);
148
+ setShowOnboarding(true);
149
+ };
150
+ window.addEventListener('forge:open-onboarding', handler);
151
+ return () => window.removeEventListener('forge:open-onboarding', handler);
152
+ }, []);
139
153
  useEffect(() => {
140
154
  fetch('/api/onboarding').then(r => r.json()).then(j => {
141
155
  const need = !!(j?.ok && !j.onboardingCompleted);
142
156
  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.
157
+ // Auto-open the drawer when setup is needed — saves the user a
158
+ // click. When there's no enterprise yet, the wizard's first page
159
+ // is a splash asking "Add enterprise key, or continue with
160
+ // public?" so opt-out is one click away.
145
161
  if (need) setShowOnboarding(true);
146
162
  }).catch(() => {});
147
163
  }, []);
@@ -161,6 +177,7 @@ export default function Dashboard({ user }: { user: any }) {
161
177
  const [showUserMenu, setShowUserMenu] = useState(false);
162
178
  const [theme, setTheme] = useState<'dark' | 'light'>('dark');
163
179
  const [displayName, setDisplayName] = useState(user?.name || 'Forge');
180
+ const [profileDept, setProfileDept] = useState('');
164
181
  const terminalRef = useRef<WebTerminalHandle>(null);
165
182
 
166
183
  // Theme: load from localStorage + apply
@@ -182,7 +199,10 @@ export default function Dashboard({ user }: { user: any }) {
182
199
  // Fetch display name from settings
183
200
  const refreshDisplayName = useCallback(() => {
184
201
  fetch('/api/settings').then(r => r.json())
185
- .then((s: any) => { if (s.displayName) setDisplayName(s.displayName); })
202
+ .then((s: any) => {
203
+ if (s.displayName) setDisplayName(s.displayName);
204
+ if (typeof s.dept === 'string') setProfileDept(s.dept);
205
+ })
186
206
  .catch(() => {});
187
207
  }, []);
188
208
  useEffect(() => { refreshDisplayName(); }, [refreshDisplayName]);
@@ -228,17 +248,24 @@ export default function Dashboard({ user }: { user: any }) {
228
248
  return () => clearInterval(id);
229
249
  }, []);
230
250
 
231
- // Login status badge — load cached results on mount so the user
232
- // dropdown shows the red dot count without forcing a check.
251
+ // Login status badge — poll cached results on a 10s interval so the
252
+ // red-dot count picks up changes from the Test button / wizard apply /
253
+ // Reinstall without forcing the user to open + close the full panel.
254
+ // GET is cheap (reads JSON file, no probe), so this is fine.
233
255
  useEffect(() => {
234
- fetch('/api/login-status')
235
- .then((r) => r.json())
236
- .then((j) => {
237
- const rows = (j.rows || []) as Array<{ result: { ok: boolean } | null }>;
238
- const broken = rows.filter((r) => r.result && !r.result.ok).length;
239
- setLoginBadge({ broken, total: rows.length });
240
- })
241
- .catch(() => {});
256
+ const refresh = () => {
257
+ fetch('/api/login-status')
258
+ .then((r) => r.json())
259
+ .then((j) => {
260
+ const rows = (j.rows || []) as Array<{ result: { ok: boolean } | null }>;
261
+ const broken = rows.filter((r) => r.result && !r.result.ok).length;
262
+ setLoginBadge({ broken, total: rows.length });
263
+ })
264
+ .catch(() => {});
265
+ };
266
+ refresh();
267
+ const id = setInterval(refresh, 10_000);
268
+ return () => clearInterval(id);
242
269
  }, []);
243
270
 
244
271
  // Notifications: poll unread count at 30s, full fetch when panel opens
@@ -312,9 +339,11 @@ export default function Dashboard({ user }: { user: any }) {
312
339
  )}
313
340
  {showOnboarding && (
314
341
  <OnboardingDrawer
315
- onClose={() => setShowOnboarding(false)}
342
+ initialSourceId={onboardingSourceId}
343
+ onClose={() => { setShowOnboarding(false); setOnboardingSourceId(null); }}
316
344
  onComplete={() => {
317
345
  setShowOnboarding(false);
346
+ setOnboardingSourceId(null);
318
347
  setNeedsOnboarding(false);
319
348
  // Reload settings so chat picks up new API profile etc.
320
349
  fetchData();
@@ -353,9 +382,13 @@ export default function Dashboard({ user }: { user: any }) {
353
382
  <header className="h-12 border-b-2 border-[var(--border)] flex items-center justify-between px-4 shrink-0 bg-[var(--bg-secondary)]">
354
383
  <div className="flex items-center gap-4">
355
384
  <img src="/icon.png" alt="Forge" width={28} height={28} className="rounded" />
356
- <span className="text-sm font-bold text-[var(--accent)]">
357
- Forge{displayName && displayName !== 'Forge' ? ` · ${displayName}` : ''}
358
- </span>
385
+ <span className="text-sm font-bold text-[var(--accent)]">Forge</span>
386
+ {/* Enterprise sources popover with per-tenant Re-sync / Reinstall /
387
+ Remove rows + inline Add tenant. Sits where the displayName used
388
+ to be so it's the first signal you see on cold open. */}
389
+ <Suspense fallback={null}>
390
+ <EnterpriseBadge onOpenSettings={() => setShowSettings(true)} />
391
+ </Suspense>
359
392
  {versionInfo && (
360
393
  <span className="flex items-center gap-1.5">
361
394
  <span className="text-[10px] text-[var(--text-secondary)]">v{versionInfo.current}</span>
@@ -638,6 +671,11 @@ export default function Dashboard({ user }: { user: any }) {
638
671
  onClick={() => { setShowUserMenu(v => !v); setShowNotifications(false); }}
639
672
  className="text-[10px] text-[var(--text-secondary)] hover:text-[var(--text-primary)] flex items-center gap-1 px-1"
640
673
  >
674
+ {profileDept && (
675
+ <span className="text-[9px] px-1 py-[1px] rounded bg-emerald-500/15 text-emerald-500 border border-emerald-500/30 mr-1" title="Active department">
676
+ {profileDept}
677
+ </span>
678
+ )}
641
679
  {displayName} <span className="text-[8px]">▾</span>
642
680
  </button>
643
681
  {showUserMenu && (