@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.
- package/CLAUDE.md +1 -1
- package/RELEASE_NOTES.md +83 -6
- package/app/api/bridge-info/route.ts +34 -0
- package/app/api/connectors/[id]/test/route.ts +14 -0
- package/app/api/connectors/import-config-template/route.ts +103 -13
- package/app/api/enterprise-keys/route.ts +204 -0
- package/app/api/marketplace/sync-all/route.ts +28 -0
- package/app/api/monitor/route.ts +29 -6
- package/app/api/onboarding/route.ts +897 -23
- package/app/api/projects/clone/route.ts +51 -0
- package/app/api/settings/route.ts +11 -2
- package/bin/forge-server.mjs +189 -30
- package/cli/mw.mjs +16 -6
- package/cli/mw.ts +19 -6
- package/components/ConnectorsPanel.tsx +85 -13
- package/components/CraftTerminal.tsx +12 -3
- package/components/Dashboard.tsx +55 -17
- package/components/DocTerminal.tsx +12 -6
- package/components/EnterpriseBadge.tsx +420 -0
- package/components/LoginStatusPanel.tsx +15 -1
- package/components/OnboardingWizard.tsx +418 -31
- package/components/SettingsModal.tsx +382 -63
- package/components/SkillsPanel.tsx +116 -91
- package/components/WebTerminal.tsx +36 -13
- package/dev-test.sh +34 -1
- package/install.sh +29 -2
- package/lib/agents/claude-adapter.ts +18 -4
- package/lib/agents/index.ts +33 -4
- package/lib/auth/login-status.ts +14 -0
- package/lib/chat/agent-loop.ts +23 -1
- package/lib/chat/protocols/http.ts +15 -2
- package/lib/chat/tool-dispatcher.ts +163 -1
- package/lib/connectors/registry.ts +69 -4
- package/lib/connectors/sync.ts +536 -138
- package/lib/connectors/test-runner.ts +21 -3
- package/lib/connectors/types.ts +36 -4
- package/lib/connectors/wizard-template.ts +161 -0
- package/lib/dirs.ts +5 -0
- package/lib/enterprise-known.ts +34 -0
- package/lib/enterprise-secret.ts +87 -0
- package/lib/enterprise.ts +208 -0
- package/lib/help-docs/00-overview.md +12 -0
- package/lib/help-docs/01-settings.md +47 -1
- package/lib/help-docs/17-connectors.md +25 -22
- package/lib/help-docs/CLAUDE.md +1 -0
- package/lib/init.ts +13 -6
- package/lib/marketplace-sync.ts +70 -0
- package/lib/memory/temper-provision.ts +92 -0
- package/lib/pipeline-gc.ts +5 -2
- package/lib/pipeline.ts +26 -21
- package/lib/plugins/templates.ts +76 -3
- package/lib/projects.ts +85 -0
- package/lib/settings.ts +10 -0
- package/lib/telegram-bot.ts +14 -2
- package/lib/workflow-marketplace.ts +174 -108
- package/package.json +1 -1
- package/{middleware.ts → proxy.ts} +2 -1
- package/src/core/db/database.ts +8 -2
- 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
|
-
//
|
|
28
|
-
|
|
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/bin/forge-server.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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,
|
|
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
|
-
|
|
372
|
-
|
|
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
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
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
|
|
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 {
|
|
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
|
-
|
|
882
|
-
|
|
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
|
-
|
|
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
|
-
|
|
83
|
-
|
|
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
|
-
|
|
503
|
-
|
|
504
|
-
|
|
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
|
|
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
|
|
605
|
-
|
|
606
|
-
|
|
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
|
-
|
|
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}" && ${
|
|
164
|
+
ws.send(JSON.stringify({ type: 'input', data: `cd "${craftDir}" && ${quotedCli}${sf}${resume}\n` }));
|
|
156
165
|
};
|
|
157
166
|
setTimeout(() => tryLaunch(), 500);
|
|
158
167
|
}
|