@aion0/forge 0.10.39 → 0.10.41

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 (59) hide show
  1. package/CLAUDE.md +1 -1
  2. package/RELEASE_NOTES.md +83 -6
  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 +897 -23
  10. package/app/api/projects/clone/route.ts +51 -0
  11. package/app/api/settings/route.ts +11 -2
  12. package/bin/forge-server.mjs +189 -30
  13. package/cli/mw.mjs +16 -6
  14. package/cli/mw.ts +19 -6
  15. package/components/ConnectorsPanel.tsx +85 -13
  16. package/components/CraftTerminal.tsx +12 -3
  17. package/components/Dashboard.tsx +55 -17
  18. package/components/DocTerminal.tsx +12 -6
  19. package/components/EnterpriseBadge.tsx +420 -0
  20. package/components/LoginStatusPanel.tsx +15 -1
  21. package/components/OnboardingWizard.tsx +418 -31
  22. package/components/SettingsModal.tsx +382 -63
  23. package/components/SkillsPanel.tsx +116 -91
  24. package/components/WebTerminal.tsx +36 -13
  25. package/dev-test.sh +34 -1
  26. package/install.sh +29 -2
  27. package/lib/agents/claude-adapter.ts +18 -4
  28. package/lib/agents/index.ts +33 -4
  29. package/lib/auth/login-status.ts +14 -0
  30. package/lib/chat/agent-loop.ts +23 -1
  31. package/lib/chat/protocols/http.ts +15 -2
  32. package/lib/chat/tool-dispatcher.ts +163 -1
  33. package/lib/connectors/registry.ts +69 -4
  34. package/lib/connectors/sync.ts +536 -138
  35. package/lib/connectors/test-runner.ts +21 -3
  36. package/lib/connectors/types.ts +36 -4
  37. package/lib/connectors/wizard-template.ts +161 -0
  38. package/lib/dirs.ts +5 -0
  39. package/lib/enterprise-known.ts +34 -0
  40. package/lib/enterprise-secret.ts +87 -0
  41. package/lib/enterprise.ts +208 -0
  42. package/lib/help-docs/00-overview.md +12 -0
  43. package/lib/help-docs/01-settings.md +47 -1
  44. package/lib/help-docs/17-connectors.md +25 -22
  45. package/lib/help-docs/CLAUDE.md +1 -0
  46. package/lib/init.ts +13 -6
  47. package/lib/marketplace-sync.ts +70 -0
  48. package/lib/memory/temper-provision.ts +92 -0
  49. package/lib/pipeline-gc.ts +5 -2
  50. package/lib/pipeline.ts +26 -21
  51. package/lib/plugins/templates.ts +76 -3
  52. package/lib/projects.ts +85 -0
  53. package/lib/settings.ts +10 -0
  54. package/lib/telegram-bot.ts +14 -2
  55. package/lib/workflow-marketplace.ts +174 -108
  56. package/package.json +1 -1
  57. package/{middleware.ts → proxy.ts} +2 -1
  58. package/src/core/db/database.ts +8 -2
  59. 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
 
@@ -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,17 +313,132 @@ 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
+
389
+ // ── Find pids listening on a TCP port (portable) ──
390
+ // macOS ships lsof; minimal Linux containers (RHEL/CentOS/Alpine) often
391
+ // don't. Try lsof → ss (iproute2, ~universal on modern Linux) → fuser
392
+ // (psmisc) in order. Empty result means no listener, period.
393
+ //
394
+ // Without this, `forge server stop` on Linux silently leaked next-server
395
+ // because `lsof -ti:8403` threw ENOENT and the whole stop path was
396
+ // wrapped in try/catch — the port lookup just disappeared.
397
+ function findPortPids(port) {
398
+ // 1) lsof
399
+ try {
400
+ const out = execSync(`lsof -ti:${port}`, {
401
+ encoding: 'utf-8', timeout: 3000, stdio: ['pipe', 'pipe', 'pipe'],
402
+ }).trim();
403
+ if (out) return [...new Set(out.split('\n').map(s => s.trim()).filter(Boolean))];
404
+ } catch { /* fall through */ }
405
+ // 2) ss -tlnp — line looks like:
406
+ // LISTEN 0 511 *:8403 ... users:(("next-server",pid=903438,fd=14))
407
+ try {
408
+ const out = execSync(`ss -tlnp 2>/dev/null`, {
409
+ encoding: 'utf-8', timeout: 3000, stdio: ['pipe', 'pipe', 'pipe'],
410
+ });
411
+ const pids = new Set();
412
+ for (const line of out.split('\n')) {
413
+ // Match port at end of local-address field, before either whitespace
414
+ // (IPv4 `*:8403 `) or end-of-field (IPv6 `[::]:8403 `).
415
+ if (!new RegExp(`:${port}\\b`).test(line)) continue;
416
+ for (const m of line.matchAll(/pid=(\d+)/g)) pids.add(m[1]);
417
+ }
418
+ if (pids.size) return [...pids];
419
+ } catch { /* fall through */ }
420
+ // 3) fuser
421
+ try {
422
+ const out = execSync(`fuser ${port}/tcp 2>/dev/null`, {
423
+ encoding: 'utf-8', timeout: 3000, stdio: ['pipe', 'pipe', 'pipe'],
424
+ });
425
+ const pids = out.trim().split(/\s+/).filter(p => /^\d+$/.test(p));
426
+ if (pids.length) return [...new Set(pids)];
427
+ } catch { /* nothing more */ }
428
+ return [];
429
+ }
430
+
292
431
  // ── Reset terminal server (kill port + tmux sessions) ──
293
432
  if (resetTerminal) {
294
433
  console.log(`[forge] Resetting terminal server (port ${terminalPort})...`);
295
- try {
296
- const pids = execSync(`lsof -ti:${terminalPort}`, { encoding: 'utf-8' }).trim();
297
- for (const pid of pids.split('\n').filter(Boolean)) {
298
- try { execSync(`kill ${pid.trim()}`); } catch {}
434
+ const pids = findPortPids(terminalPort);
435
+ if (pids.length === 0) {
436
+ console.log(`[forge] No process on port ${terminalPort}`);
437
+ } else {
438
+ for (const pid of pids) {
439
+ try { process.kill(parseInt(pid), 'SIGTERM'); } catch {}
299
440
  }
300
441
  console.log(`[forge] Killed terminal server on port ${terminalPort}`);
301
- } catch {
302
- console.log(`[forge] No process on port ${terminalPort}`);
303
442
  }
304
443
  }
305
444
 
@@ -338,14 +477,10 @@ function cleanupOrphans() {
338
477
  try {
339
478
  // Kill processes on our ports
340
479
  for (const port of [webPort, terminalPort]) {
341
- try {
342
- const pids = execSync(`lsof -ti:${port}`, { encoding: 'utf-8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'] }).trim();
343
- for (const pid of pids.split('\n').filter(Boolean)) {
344
- const p = pid.trim();
345
- if (p === myPid || protectedPids.has(p)) continue;
346
- try { process.kill(parseInt(p), 'SIGTERM'); } catch {}
347
- }
348
- } catch {}
480
+ for (const p of findPortPids(port)) {
481
+ if (p === myPid || protectedPids.has(p)) continue;
482
+ try { process.kill(parseInt(p), 'SIGTERM'); } catch {}
483
+ }
349
484
  }
350
485
  // Kill standalone processes: our instance's + orphans without any tag
351
486
  try {
@@ -365,11 +500,25 @@ function cleanupOrphans() {
365
500
  // imported lib/task-manager (directly or via lib/pipeline) starts its
366
501
  // own setInterval task runner that never exits — those run in parallel
367
502
  // with the real runner and silently steal tasks. Detect via lsof on
368
- // workflow.db, exclude our own next-server + standalones.
503
+ // workflow.db (Mac), with a fuser fallback for lsof-less Linux.
369
504
  try {
370
505
  const dbPath = join(DATA_DIR, 'workflow.db');
371
- const lsofOut = execSync(`lsof -t "${dbPath}"`, { encoding: 'utf-8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'] }).trim();
372
- for (const pid of lsofOut.split('\n').map(s => s.trim()).filter(Boolean)) {
506
+ let holders = [];
507
+ try {
508
+ const out = execSync(`lsof -t "${dbPath}"`, {
509
+ encoding: 'utf-8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'],
510
+ }).trim();
511
+ holders = out.split('\n').map(s => s.trim()).filter(Boolean);
512
+ } catch {
513
+ try {
514
+ // `fuser <file>` writes pids to stderr, not stdout. Merge streams.
515
+ const out = execSync(`fuser "${dbPath}" 2>&1`, {
516
+ encoding: 'utf-8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'],
517
+ });
518
+ holders = out.replace(/^.*?:/, '').trim().split(/\s+/).filter(p => /^\d+$/.test(p));
519
+ } catch { /* both unavailable — zombie scan disabled */ }
520
+ }
521
+ for (const pid of holders) {
373
522
  if (pid === myPid || protectedPids.has(pid)) continue;
374
523
  let cmd = '';
375
524
  try {
@@ -458,25 +607,35 @@ async function stopServer() {
458
607
  } catch {}
459
608
  try { unlinkSync(PID_FILE); } catch {}
460
609
 
461
- // Also kill by port (in case PID file is stale)
462
- const portPids = [];
463
- try {
464
- const pids = execSync(`lsof -ti:${webPort}`, { encoding: 'utf-8', timeout: 3000 }).trim();
465
- for (const p of pids.split('\n').filter(Boolean)) {
466
- const pid = parseInt(p.trim());
467
- try { process.kill(pid, 'SIGTERM'); stopped = true; portPids.push(pid); } catch {}
468
- }
469
- if (pids) console.log(`[forge] Killed processes on port ${webPort}`);
470
- } catch {}
610
+ // Also kill by port (in case PID file is stale). Use findPortPids
611
+ // so Linux-without-lsof still works.
612
+ const portPids = findPortPids(webPort);
613
+ for (const p of portPids) {
614
+ const pid = parseInt(p);
615
+ try { process.kill(pid, 'SIGTERM'); stopped = true; } catch {}
616
+ }
617
+ if (portPids.length > 0) console.log(`[forge] Killed processes on port ${webPort}`);
471
618
 
472
- // Force kill after 2 seconds if SIGTERM didn't work
619
+ // Force kill survivors after 2 seconds.
473
620
  if (portPids.length > 0) {
474
621
  await new Promise(r => setTimeout(r, 2000));
475
622
  for (const pid of portPids) {
476
- try { process.kill(pid, 'SIGKILL'); } catch {}
623
+ try { process.kill(parseInt(pid), 'SIGKILL'); } catch {}
477
624
  }
478
625
  }
479
626
 
627
+ // Final verify — if anything still listens on the port (e.g. a child
628
+ // re-bound, or our SIGKILL hit EPERM), surface it loudly. Silent leak
629
+ // is exactly the bug we're fixing.
630
+ const survivors = findPortPids(webPort);
631
+ if (survivors.length > 0) {
632
+ console.warn(
633
+ `[forge] WARNING: port ${webPort} still bound by pid(s) ${survivors.join(', ')} after stop. ` +
634
+ `Likely a different user / cron-launched / sudo'd instance. ` +
635
+ `Run: kill ${survivors.join(' ')} (or with sudo) to free it.`,
636
+ );
637
+ }
638
+
480
639
  if (!stopped) {
481
640
  console.log('[forge] No running server found');
482
641
  }
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
  }