@aion0/forge 0.10.40 → 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 -5
- 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 +98 -1
- 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,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 {
|
|
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
|
}
|
package/components/Dashboard.tsx
CHANGED
|
@@ -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
|
|
144
|
-
//
|
|
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) => {
|
|
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 —
|
|
232
|
-
//
|
|
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
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
|
-
|
|
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
|
-
|
|
358
|
-
|
|
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 && (
|
|
@@ -30,11 +30,17 @@ export default function DocTerminal({ docRoot, agent }: { docRoot: string; agent
|
|
|
30
30
|
fetch('/api/settings').then(r => r.json())
|
|
31
31
|
.then((s: any) => { if (s.skipPermissions) skipPermRef.current = true; })
|
|
32
32
|
.catch(() => {});
|
|
33
|
+
// Resolve via ?resolve= so derived agents (forti-k2 base: claude
|
|
34
|
+
// with no own path) inherit the base's absolute path. The plain
|
|
35
|
+
// /api/agents list returns the row's literal `path`, which is
|
|
36
|
+
// empty for derived agents and gives a useless fallback.
|
|
33
37
|
fetch('/api/agents').then(r => r.json())
|
|
34
|
-
.then(data => {
|
|
38
|
+
.then(async (data: any) => {
|
|
35
39
|
const targetId = agent || data.defaultAgent || 'claude';
|
|
36
|
-
|
|
37
|
-
|
|
40
|
+
try {
|
|
41
|
+
const info = await fetch(`/api/agents?resolve=${encodeURIComponent(targetId)}`).then(r => r.ok ? r.json() : null);
|
|
42
|
+
if (info?.cliCmd) agentCmdRef.current = info.cliCmd;
|
|
43
|
+
} catch {}
|
|
38
44
|
})
|
|
39
45
|
.catch(() => {});
|
|
40
46
|
}, []);
|
|
@@ -97,7 +103,7 @@ export default function DocTerminal({ docRoot, agent }: { docRoot: string; agent
|
|
|
97
103
|
setTimeout(() => {
|
|
98
104
|
if (socket.readyState === WebSocket.OPEN) {
|
|
99
105
|
const sf = skipPermRef.current ? ' --dangerously-skip-permissions' : '';
|
|
100
|
-
socket.send(JSON.stringify({ type: 'input', data: `cd "${docRootRef.current}" && ${agentCmdRef.current} -c${sf}\n` }));
|
|
106
|
+
socket.send(JSON.stringify({ type: 'input', data: `cd "${docRootRef.current}" && "${agentCmdRef.current}" -c${sf}\n` }));
|
|
101
107
|
}
|
|
102
108
|
}, 300);
|
|
103
109
|
}
|
|
@@ -166,13 +172,13 @@ export default function DocTerminal({ docRoot, agent }: { docRoot: string; agent
|
|
|
166
172
|
</span>
|
|
167
173
|
<div className="ml-auto flex items-center gap-1">
|
|
168
174
|
<button
|
|
169
|
-
onClick={() => { const sf = skipPermRef.current ? ' --dangerously-skip-permissions' : ''; runCommand(`cd "${docRoot}" && ${agentCmdRef.current}${sf}`); }}
|
|
175
|
+
onClick={() => { const sf = skipPermRef.current ? ' --dangerously-skip-permissions' : ''; runCommand(`cd "${docRoot}" && "${agentCmdRef.current}"${sf}`); }}
|
|
170
176
|
className="text-[10px] px-2 py-0.5 text-[var(--accent)] hover:bg-[#2a2a4a] rounded"
|
|
171
177
|
>
|
|
172
178
|
New
|
|
173
179
|
</button>
|
|
174
180
|
<button
|
|
175
|
-
onClick={() => { const sf = skipPermRef.current ? ' --dangerously-skip-permissions' : ''; runCommand(`cd "${docRoot}" && ${agentCmdRef.current} -c${sf}`); }}
|
|
181
|
+
onClick={() => { const sf = skipPermRef.current ? ' --dangerously-skip-permissions' : ''; runCommand(`cd "${docRoot}" && "${agentCmdRef.current}" -c${sf}`); }}
|
|
176
182
|
className="text-[10px] px-2 py-0.5 text-gray-400 hover:text-white hover:bg-[#2a2a4a] rounded"
|
|
177
183
|
>
|
|
178
184
|
Resume
|