@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
|
@@ -16,25 +16,21 @@ connector list; the user picks what they want from the marketplace.
|
|
|
16
16
|
|
|
17
17
|
## Marketplace flow
|
|
18
18
|
|
|
19
|
-
1. On boot, Forge
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
-
|
|
25
|
-
-
|
|
26
|
-
- **Update 0.5 → 0.6
|
|
27
|
-
3. Clicking **Install** fetches `<id>/manifest.yaml` from the registry
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
Sync re-runs every hour and on manual **Refresh** in the Settings panel.
|
|
36
|
-
Network failures are silent — the cache from the last successful sync
|
|
37
|
-
keeps the marketplace usable offline.
|
|
19
|
+
1. On boot, Forge syncs every configured **marketplace source**:
|
|
20
|
+
- **Public** — default `https://raw.githubusercontent.com/aiwatching/forge-connectors/main` (override via `connectorsRepoUrl`). Fetched anonymously.
|
|
21
|
+
- **Enterprise repos** — any keys registered via Settings → Marketplace Providers (or `--enterprise-key=`). Each gets pulled via the GitHub Contents API with its bearer PAT. Sources are ordered by configured priority; the first one added wins on conflict.
|
|
22
|
+
Each source's `registry.json` is cached at `<dataDir>/connectors/sources/<source-id>/registry-cache.json`.
|
|
23
|
+
2. The Settings → Connectors panel merges all source caches into a single marketplace view:
|
|
24
|
+
- Same id present in multiple sources → highest-priority source is "in use", lower sources land in `hidden_sources` (shown in the detail pane).
|
|
25
|
+
- Every row carries a source badge: `🌐 public` / `🔒 <tenant>` / `📦 local` (install-local upload).
|
|
26
|
+
- Install state: **Available** / **Installed v0.5.0** / **Update 0.5 → 0.6**.
|
|
27
|
+
3. Clicking **Install** fetches `<id>/manifest.yaml` from the winning source, writes it to `<dataDir>/connectors/<id>/manifest.yaml`, and records `installed_source_id` in `<dataDir>/connector-configs.json`. The badge in the marketplace reflects that installed_source — not the current registry winner — so users can see "I installed mantis from fortinet" even after the public version bumps.
|
|
28
|
+
4. The user fills in settings (host URL, PAT, etc.) via the existing settings UI in the extension or `/api/connectors/<id>/settings`.
|
|
29
|
+
5. Chat tools become available the instant the manifest lands on disk — no Forge restart.
|
|
30
|
+
|
|
31
|
+
Sync re-runs on manual **Refresh** in the Settings panel and after adding a new enterprise key. Network failures per-source are isolated — the offline cache from each source's last successful sync keeps the marketplace usable.
|
|
32
|
+
|
|
33
|
+
See `01-settings.md` § Marketplace Providers for how to register enterprise keys and what the priority rules mean in practice.
|
|
38
34
|
|
|
39
35
|
## Repository layout
|
|
40
36
|
|
|
@@ -57,11 +53,18 @@ source of truth for that user.
|
|
|
57
53
|
```
|
|
58
54
|
<dataDir>/
|
|
59
55
|
├── connectors/
|
|
60
|
-
│ ├──
|
|
61
|
-
│ ├──
|
|
56
|
+
│ ├── sources/
|
|
57
|
+
│ │ ├── public/
|
|
58
|
+
│ │ │ ├── registry-cache.json last fetch from public source
|
|
59
|
+
│ │ │ └── wizard-template.json optional wizard template cache
|
|
60
|
+
│ │ └── enterprise-fortinet/ one dir per enterprise key
|
|
61
|
+
│ │ ├── registry-cache.json
|
|
62
|
+
│ │ └── wizard-template.json
|
|
63
|
+
│ ├── mantis/manifest.yaml installed manifest copy (one per id, regardless of source)
|
|
62
64
|
│ ├── gitlab/manifest.yaml
|
|
63
65
|
│ └── …
|
|
64
|
-
|
|
66
|
+
├── connector-configs.json { "<id>": { config, installed_version, enabled, installed_source_id } }
|
|
67
|
+
└── .enterprise-keys.json encrypted list of registered enterprise keys
|
|
65
68
|
```
|
|
66
69
|
|
|
67
70
|
Secret fields (`type: secret`) in `config` are encrypted at rest with
|
package/lib/help-docs/CLAUDE.md
CHANGED
|
@@ -79,6 +79,7 @@ The token is valid for 24 hours. Store it in a variable and reuse for all API ca
|
|
|
79
79
|
- Craft/custom tab/mini-app/extend project/AI-generated tab/builder → `15-crafts.md`
|
|
80
80
|
- GitLab/glab/MR/merge request/issue auto-fix/epic → `16-gitlab-autofix.md`
|
|
81
81
|
- Connector/connector marketplace/install connector/forge-connectors registry/Mantis manifest → `17-connectors.md`
|
|
82
|
+
- Enterprise key / enterprise marketplace / `--enterprise-key=` / `🔒 fortinet` badge / "add fortinet key" / Marketplace Providers panel / `add_enterprise_key` / multi-source marketplace / hidden_sources / wizard template per company → `01-settings.md` § Marketplace Providers (+ `17-connectors.md` for the connector side, `00-overview.md` for install)
|
|
82
83
|
- Build / author / write / create a new connector / "make me a connector for X" / custom connector → `21-build-connector.md`
|
|
83
84
|
- Chrome MCP / chrome-devtools-mcp / dev-time browser / CDP / remote debugging → `18-chrome-mcp.md`
|
|
84
85
|
- Job / scheduled job / connector poll / "Jobs tab" → tell user: **Jobs is deprecated**; use Schedules (`13-schedules.md`) instead.
|
package/lib/init.ts
CHANGED
|
@@ -285,13 +285,20 @@ export function restartTelegramBot() {
|
|
|
285
285
|
|
|
286
286
|
let telegramChild: ReturnType<typeof spawn> | null = null;
|
|
287
287
|
|
|
288
|
+
/** Instance tag every standalone spawn carries — lets /api/monitor filter
|
|
289
|
+
* by the running web port so dev-test (port 4000) doesn't surface the
|
|
290
|
+
* user's production Forge (port 8403) processes and vice versa. */
|
|
291
|
+
function instanceTag(): string {
|
|
292
|
+
return `--forge-port=${process.env.PORT || 8403}`;
|
|
293
|
+
}
|
|
294
|
+
|
|
288
295
|
function startTelegramProcess() {
|
|
289
296
|
if (telegramChild) return;
|
|
290
297
|
const settings = loadSettings();
|
|
291
298
|
if (!settings.telegramBotToken || !settings.telegramChatId) return;
|
|
292
299
|
|
|
293
300
|
const script = join(process.cwd(), 'lib', 'telegram-standalone.ts');
|
|
294
|
-
telegramChild = spawn('npx', ['tsx', script], {
|
|
301
|
+
telegramChild = spawn('npx', ['tsx', script, instanceTag()], {
|
|
295
302
|
stdio: ['ignore', 'inherit', 'inherit'],
|
|
296
303
|
env: { ...process.env, PORT: String(process.env.PORT || 8403) },
|
|
297
304
|
detached: false,
|
|
@@ -315,7 +322,7 @@ function startTerminalProcess() {
|
|
|
315
322
|
tester.once('listening', () => {
|
|
316
323
|
tester.close();
|
|
317
324
|
const script = join(process.cwd(), 'lib', 'terminal-standalone.ts');
|
|
318
|
-
terminalChild = spawn('npx', ['tsx', script], {
|
|
325
|
+
terminalChild = spawn('npx', ['tsx', script, instanceTag()], {
|
|
319
326
|
stdio: ['ignore', 'inherit', 'inherit'],
|
|
320
327
|
env: { ...Object.fromEntries(Object.entries(process.env).filter(([k]) => k !== 'CLAUDECODE')) } as NodeJS.ProcessEnv,
|
|
321
328
|
detached: false,
|
|
@@ -352,7 +359,7 @@ function startWorkspaceProcess() {
|
|
|
352
359
|
|
|
353
360
|
function launchWorkspaceDaemon() {
|
|
354
361
|
const script = join(process.cwd(), 'lib', 'workspace-standalone.ts');
|
|
355
|
-
workspaceChild = spawn('npx', ['tsx', script], {
|
|
362
|
+
workspaceChild = spawn('npx', ['tsx', script, instanceTag()], {
|
|
356
363
|
stdio: ['ignore', 'inherit', 'inherit'],
|
|
357
364
|
env: { ...process.env },
|
|
358
365
|
detached: false,
|
|
@@ -376,7 +383,7 @@ function startChatProcess() {
|
|
|
376
383
|
tester.once('listening', () => {
|
|
377
384
|
tester.close();
|
|
378
385
|
const script = join(process.cwd(), 'lib', 'chat-standalone.ts');
|
|
379
|
-
chatChild = spawn('npx', ['tsx', script], {
|
|
386
|
+
chatChild = spawn('npx', ['tsx', script, instanceTag()], {
|
|
380
387
|
stdio: ['ignore', 'inherit', 'inherit'],
|
|
381
388
|
env: { ...process.env },
|
|
382
389
|
detached: false,
|
|
@@ -402,7 +409,7 @@ function startBrowserBridgeProcess() {
|
|
|
402
409
|
tester.once('listening', () => {
|
|
403
410
|
tester.close();
|
|
404
411
|
const script = join(process.cwd(), 'lib', 'browser-bridge-standalone.ts');
|
|
405
|
-
bridgeChild = spawn('npx', ['tsx', script], {
|
|
412
|
+
bridgeChild = spawn('npx', ['tsx', script, instanceTag()], {
|
|
406
413
|
stdio: ['ignore', 'inherit', 'inherit'],
|
|
407
414
|
env: { ...process.env },
|
|
408
415
|
detached: false,
|
|
@@ -419,7 +426,7 @@ function startMemoryProcess() {
|
|
|
419
426
|
if (memoryChild) return;
|
|
420
427
|
// No HTTP port — pure background poller. Just spawn-if-not-running.
|
|
421
428
|
const script = join(process.cwd(), 'lib', 'memory-standalone.ts');
|
|
422
|
-
memoryChild = spawn('npx', ['tsx', script], {
|
|
429
|
+
memoryChild = spawn('npx', ['tsx', script, instanceTag()], {
|
|
423
430
|
stdio: ['ignore', 'inherit', 'inherit'],
|
|
424
431
|
env: { ...process.env },
|
|
425
432
|
detached: false,
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unified marketplace sync — one call refreshes everything from every
|
|
3
|
+
* configured source (public + each enterprise tenant):
|
|
4
|
+
* - registry.json + wizard templates per source (lib/connectors/sync)
|
|
5
|
+
* - installed connector manifests (so on-disk yaml updates)
|
|
6
|
+
* - workflows: pipelines + recipes per source (lib/workflow-marketplace)
|
|
7
|
+
* - skills: public only for now; enterprise skill sync is a separate task
|
|
8
|
+
*
|
|
9
|
+
* Errors per sub-step are isolated — a failing skills repo doesn't block
|
|
10
|
+
* connector sync, etc. Returned summary spells out each leg so the UI
|
|
11
|
+
* can show what actually landed.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { syncRegistry } from './connectors/sync';
|
|
15
|
+
import { syncMarketplace as syncWorkflows } from './workflow-marketplace';
|
|
16
|
+
import { syncSkills } from './skills';
|
|
17
|
+
|
|
18
|
+
export interface MarketplaceSyncResult {
|
|
19
|
+
ok: boolean;
|
|
20
|
+
connectors: { ok: boolean; sources_ok?: number; sources_total?: number; manifests_refreshed?: number; error?: string };
|
|
21
|
+
workflows: { ok: boolean; recipes?: number; pipelines?: number; error?: string };
|
|
22
|
+
skills: { ok: boolean; synced?: number; error?: string };
|
|
23
|
+
fetched_at: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function syncAll(): Promise<MarketplaceSyncResult> {
|
|
27
|
+
const fetched_at = new Date().toISOString();
|
|
28
|
+
|
|
29
|
+
// 1. Connectors — registry + wizard templates per source + installed manifests
|
|
30
|
+
let connectors: MarketplaceSyncResult['connectors'];
|
|
31
|
+
try {
|
|
32
|
+
const r = await syncRegistry({ refreshInstalled: true });
|
|
33
|
+
connectors = {
|
|
34
|
+
ok: !!r.ok,
|
|
35
|
+
sources_ok: (r.sources || []).filter(s => s.ok).length,
|
|
36
|
+
sources_total: (r.sources || []).length,
|
|
37
|
+
manifests_refreshed: r.manifests_refreshed || 0,
|
|
38
|
+
error: r.error,
|
|
39
|
+
};
|
|
40
|
+
} catch (e) {
|
|
41
|
+
connectors = { ok: false, error: (e as Error).message };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// 2. Workflows — pipelines + recipes per source
|
|
45
|
+
let workflows: MarketplaceSyncResult['workflows'];
|
|
46
|
+
try {
|
|
47
|
+
const r = await syncWorkflows();
|
|
48
|
+
workflows = { ok: !!r.ok, recipes: r.recipes, pipelines: r.pipelines, error: r.error };
|
|
49
|
+
} catch (e) {
|
|
50
|
+
workflows = { ok: false, error: (e as Error).message };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// 3. Skills — public only for now (enterprise skill sources are a
|
|
54
|
+
// separate phase in the marketplace roadmap).
|
|
55
|
+
let skills: MarketplaceSyncResult['skills'];
|
|
56
|
+
try {
|
|
57
|
+
const r = await syncSkills();
|
|
58
|
+
skills = { ok: !r.error, synced: r.synced, error: r.error };
|
|
59
|
+
} catch (e) {
|
|
60
|
+
skills = { ok: false, error: (e as Error).message };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
ok: connectors.ok && workflows.ok && skills.ok,
|
|
65
|
+
connectors,
|
|
66
|
+
workflows,
|
|
67
|
+
skills,
|
|
68
|
+
fetched_at,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Temper auto-provisioning — calls Temper's POST /v1/onboarding/provision
|
|
3
|
+
* with the super-admin token (baked into an enterprise wizard template as
|
|
4
|
+
* `_temperAdmin: { url, token }` and shipped encrypted via ent:) to create
|
|
5
|
+
* a per-user memory account on first onboarding.
|
|
6
|
+
*
|
|
7
|
+
* Org + group are get-or-create (idempotent on slug). User is non-idempotent
|
|
8
|
+
* — a 409 on re-run means the user already exists; treat that as a soft skip
|
|
9
|
+
* so re-running the wizard doesn't blow up.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export interface ProvisionInput {
|
|
13
|
+
url: string;
|
|
14
|
+
adminToken: string;
|
|
15
|
+
username: string;
|
|
16
|
+
email: string;
|
|
17
|
+
company: string;
|
|
18
|
+
dept: string;
|
|
19
|
+
displayName?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ProvisionResult {
|
|
23
|
+
ok: true;
|
|
24
|
+
user_id: string;
|
|
25
|
+
username: string;
|
|
26
|
+
email: string;
|
|
27
|
+
display_name?: string;
|
|
28
|
+
org_slug: string;
|
|
29
|
+
group_slug: string;
|
|
30
|
+
api_key: string;
|
|
31
|
+
api_key_prefix?: string;
|
|
32
|
+
default_password?: string;
|
|
33
|
+
must_change_password?: boolean;
|
|
34
|
+
created?: { org?: boolean; group?: boolean; user?: boolean };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface ProvisionError {
|
|
38
|
+
ok: false;
|
|
39
|
+
status: number;
|
|
40
|
+
error: string;
|
|
41
|
+
/** True when 409 — user exists; caller may treat as soft-skip. */
|
|
42
|
+
conflict?: boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function provisionTemperUser(input: ProvisionInput): Promise<ProvisionResult | ProvisionError> {
|
|
46
|
+
const baseUrl = input.url.replace(/\/+$/, '');
|
|
47
|
+
if (!baseUrl) return { ok: false, status: 0, error: 'temper url missing' };
|
|
48
|
+
if (!input.adminToken) return { ok: false, status: 0, error: 'temper admin token missing' };
|
|
49
|
+
if (!input.username || !input.email || !input.company || !input.dept) {
|
|
50
|
+
return { ok: false, status: 0, error: 'username/email/company/dept all required' };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const body = {
|
|
54
|
+
username: input.username,
|
|
55
|
+
email: input.email,
|
|
56
|
+
company: input.company,
|
|
57
|
+
dept: input.dept,
|
|
58
|
+
...(input.displayName ? { display_name: input.displayName } : {}),
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
let res: Response;
|
|
62
|
+
try {
|
|
63
|
+
res = await fetch(`${baseUrl}/v1/onboarding/provision`, {
|
|
64
|
+
method: 'POST',
|
|
65
|
+
headers: {
|
|
66
|
+
'Content-Type': 'application/json',
|
|
67
|
+
'X-API-Key': input.adminToken,
|
|
68
|
+
},
|
|
69
|
+
body: JSON.stringify(body),
|
|
70
|
+
});
|
|
71
|
+
} catch (e) {
|
|
72
|
+
return { ok: false, status: 0, error: `network: ${(e as Error).message}` };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
let parsed: any = null;
|
|
76
|
+
const text = await res.text();
|
|
77
|
+
try { parsed = JSON.parse(text); } catch { parsed = { raw: text }; }
|
|
78
|
+
|
|
79
|
+
if (res.ok) {
|
|
80
|
+
if (!parsed?.api_key || !parsed?.user_id) {
|
|
81
|
+
return { ok: false, status: res.status, error: 'response missing api_key/user_id' };
|
|
82
|
+
}
|
|
83
|
+
return { ok: true, ...parsed };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
ok: false,
|
|
88
|
+
status: res.status,
|
|
89
|
+
error: parsed?.error || parsed?.message || `HTTP ${res.status}`,
|
|
90
|
+
conflict: res.status === 409,
|
|
91
|
+
};
|
|
92
|
+
}
|
package/lib/pipeline-gc.ts
CHANGED
|
@@ -20,8 +20,8 @@
|
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
22
|
import { readdirSync, statSync, rmSync, existsSync } from 'node:fs';
|
|
23
|
+
import { scanProjects, getProjectWorktreeRoot } from './projects';
|
|
23
24
|
import { join } from 'node:path';
|
|
24
|
-
import { scanProjects } from './projects';
|
|
25
25
|
import { getPipeline } from './pipeline';
|
|
26
26
|
import { loadSettings } from './settings';
|
|
27
27
|
|
|
@@ -45,7 +45,10 @@ export function gcPipelineTmp(opts: { dryRun?: boolean } = {}): GcResult {
|
|
|
45
45
|
let scanned = 0;
|
|
46
46
|
|
|
47
47
|
for (const proj of scanProjects()) {
|
|
48
|
-
|
|
48
|
+
// Scratch uses a flatter <dataDir>/scratch/worktrees/ layout — see
|
|
49
|
+
// getProjectWorktreeRoot for the rationale. Every other project sticks
|
|
50
|
+
// to the regular <project>/.forge/worktrees/ convention.
|
|
51
|
+
const wtDir = getProjectWorktreeRoot(proj);
|
|
49
52
|
if (!existsSync(wtDir)) continue;
|
|
50
53
|
let entries: string[];
|
|
51
54
|
try { entries = readdirSync(wtDir); } catch { continue; }
|
package/lib/pipeline.ts
CHANGED
|
@@ -11,7 +11,7 @@ import { execSync } from 'node:child_process';
|
|
|
11
11
|
import { join } from 'node:path';
|
|
12
12
|
import YAML from 'yaml';
|
|
13
13
|
import { createTask, getTask, onTaskEvent, taskModelOverrides, taskAppendSystemPromptOverrides, cancelTask } from './task-manager';
|
|
14
|
-
import { getProjectInfo } from './projects';
|
|
14
|
+
import { getProjectInfo, resolveOrCloneProject, getProjectWorktreeRoot } from './projects';
|
|
15
15
|
import { loadSettings } from './settings';
|
|
16
16
|
import { getAgent, listAgents } from './agents';
|
|
17
17
|
import type { Task } from '../src/types';
|
|
@@ -755,9 +755,8 @@ function computePipelineTmpDir(pipeline: Pipeline): string {
|
|
|
755
755
|
if (!name) return '';
|
|
756
756
|
const proj = getProjectInfo(name);
|
|
757
757
|
if (!proj) return '';
|
|
758
|
-
//
|
|
759
|
-
|
|
760
|
-
return join(proj.path, '.forge', 'worktrees', `pipeline-${pipeline.id}`);
|
|
758
|
+
// Worktree root differs for scratch vs real projects — see getProjectWorktreeRoot.
|
|
759
|
+
return join(getProjectWorktreeRoot(proj), `pipeline-${pipeline.id}`);
|
|
761
760
|
}
|
|
762
761
|
|
|
763
762
|
/**
|
|
@@ -1048,15 +1047,19 @@ function scheduleNextConversationTurn(pipeline: Pipeline, contextForAgent: strin
|
|
|
1048
1047
|
return;
|
|
1049
1048
|
}
|
|
1050
1049
|
|
|
1051
|
-
// Resolve project
|
|
1050
|
+
// Resolve project — three-tier fallback in lib/projects.ts:
|
|
1051
|
+
// 1. existing project by name
|
|
1052
|
+
// 2. auto-clone GitLab default_project_path → projectRoots[0] / ~/IdeaProjects
|
|
1053
|
+
// 3. scratch (<dataDir>/scratch, always writable)
|
|
1054
|
+
// Never fails. The scratch fallback means a pipeline that needs no real
|
|
1055
|
+
// repo still runs (connector-only flows, talk-only agents); pipelines that
|
|
1056
|
+
// truly need source files will fail later inside their own nodes with a
|
|
1057
|
+
// domain-specific error, not a "Project not found".
|
|
1052
1058
|
const projectName = agentDef.project || pipeline.input.project || '';
|
|
1053
|
-
const
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
pipeline.
|
|
1057
|
-
savePipeline(pipeline);
|
|
1058
|
-
notifyPipelineComplete(pipeline);
|
|
1059
|
-
return;
|
|
1059
|
+
const resolved = resolveOrCloneProject(projectName);
|
|
1060
|
+
const projectInfo = resolved.project;
|
|
1061
|
+
if (resolved.source !== 'existing') {
|
|
1062
|
+
console.log(`[pipeline ${pipeline.id.slice(0,8)}] project resolved via ${resolved.source}: ${projectInfo.path}`);
|
|
1060
1063
|
}
|
|
1061
1064
|
|
|
1062
1065
|
// Build the prompt: role context + conversation history + new message
|
|
@@ -1771,13 +1774,14 @@ async function scheduleReadyNodes(pipeline: Pipeline, workflow: Workflow) {
|
|
|
1771
1774
|
const project = resolveTemplate(nodeDef.project, ctx);
|
|
1772
1775
|
const prompt = resolveTemplate(nodeDef.prompt, ctx, isShell);
|
|
1773
1776
|
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1777
|
+
// Three-tier project resolution (existing → gitlab clone → scratch).
|
|
1778
|
+
// Never null. Worktrees land under projectInfo.path/.forge/worktrees/
|
|
1779
|
+
// regardless of which tier resolved — scratch is Forge-owned and
|
|
1780
|
+
// always writable, so even the worst case keeps the pipeline running.
|
|
1781
|
+
const resolved = resolveOrCloneProject(project);
|
|
1782
|
+
const projectInfo = resolved.project;
|
|
1783
|
+
if (resolved.source !== 'existing') {
|
|
1784
|
+
console.log(`[pipeline ${pipeline.id.slice(0,8)} node ${nodeId}] project '${project}' resolved via ${resolved.source}: ${projectInfo.path}`);
|
|
1781
1785
|
}
|
|
1782
1786
|
|
|
1783
1787
|
// All pipeline steps use worktree for isolated execution.
|
|
@@ -1791,8 +1795,9 @@ async function scheduleReadyNodes(pipeline: Pipeline, workflow: Workflow) {
|
|
|
1791
1795
|
const useWorktree = nodeDef.worktree !== false && !nodeDef.workdir;
|
|
1792
1796
|
const branchName = nodeDef.branch ? resolveTemplate(nodeDef.branch, ctx) : `pipeline/${pipeline.id.slice(0, 8)}`;
|
|
1793
1797
|
if (useWorktree) try {
|
|
1794
|
-
const
|
|
1795
|
-
|
|
1798
|
+
const wtRoot = getProjectWorktreeRoot(projectInfo);
|
|
1799
|
+
const worktreePath = `${wtRoot}/${branchName.replace(/\//g, '-')}`;
|
|
1800
|
+
mkdirSync(wtRoot, { recursive: true });
|
|
1796
1801
|
|
|
1797
1802
|
// Create branch if needed.
|
|
1798
1803
|
// Silent catch: `git branch X` fails with "already exists" — the
|
package/lib/plugins/templates.ts
CHANGED
|
@@ -6,29 +6,94 @@
|
|
|
6
6
|
* tool arguments ({args.*}) are left literal — the extension's runner expands
|
|
7
7
|
* those at execution time.
|
|
8
8
|
*
|
|
9
|
+
* `{connector:<id>.<field>}` cross-references another connector's runtime
|
|
10
|
+
* config — e.g. Jenkins inject_params can pull a GitLab PAT without baking
|
|
11
|
+
* it. Read at dispatch time, never persisted in the dependent connector's
|
|
12
|
+
* own config, so changing the GitLab token automatically reflects in
|
|
13
|
+
* Jenkins.
|
|
14
|
+
*
|
|
9
15
|
* See docs/Connector-DeclarativeExtract-Spec.md §4.
|
|
10
16
|
*/
|
|
11
17
|
|
|
18
|
+
import { getInstalledConnector } from '../connectors/registry';
|
|
19
|
+
import { loadSettings } from '../settings';
|
|
20
|
+
|
|
12
21
|
type SettingsMap = Record<string, any>;
|
|
13
22
|
|
|
23
|
+
/**
|
|
24
|
+
* `{user.<field>}` resolves to the operator's global identity from
|
|
25
|
+
* settings.yaml. Replaces per-connector username fields so an org
|
|
26
|
+
* has one source of truth.
|
|
27
|
+
*
|
|
28
|
+
* {user.name} displayName → fallback: local-part of displayEmail → ''
|
|
29
|
+
* {user.email} displayEmail
|
|
30
|
+
* {user.login} email local-part (same as {user.name}'s fallback)
|
|
31
|
+
*/
|
|
32
|
+
function resolveUserRef(ref: string): string | null {
|
|
33
|
+
let s: { displayName?: string; displayEmail?: string };
|
|
34
|
+
try { s = loadSettings() as any; } catch { return null; }
|
|
35
|
+
const email = s.displayEmail || '';
|
|
36
|
+
const localPart = email.includes('@') ? email.split('@')[0] : email;
|
|
37
|
+
switch (ref) {
|
|
38
|
+
case 'name':
|
|
39
|
+
return (s.displayName && s.displayName.trim() && s.displayName.trim() !== 'Forge')
|
|
40
|
+
? s.displayName.trim()
|
|
41
|
+
: localPart;
|
|
42
|
+
case 'email':
|
|
43
|
+
return email;
|
|
44
|
+
case 'login':
|
|
45
|
+
return localPart;
|
|
46
|
+
default:
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Look up `{connector:<id>.<field>}` by reading the target connector's
|
|
53
|
+
* runtime config from connector-configs.json. Secret fields are
|
|
54
|
+
* auto-decrypted by getInstalledConnector. Missing connector / field
|
|
55
|
+
* returns null so the caller leaves the token literal (matches the
|
|
56
|
+
* "unknown token → pass through" behaviour of the other resolvers).
|
|
57
|
+
*/
|
|
58
|
+
function resolveConnectorRef(ref: string): string | null {
|
|
59
|
+
const dot = ref.indexOf('.');
|
|
60
|
+
if (dot < 0) return null;
|
|
61
|
+
const id = ref.slice(0, dot).trim();
|
|
62
|
+
const field = ref.slice(dot + 1).trim();
|
|
63
|
+
if (!id || !field) return null;
|
|
64
|
+
const inst = getInstalledConnector(id);
|
|
65
|
+
if (!inst) return null;
|
|
66
|
+
const v = (inst.config as Record<string, unknown>)[field];
|
|
67
|
+
if (v == null) return null;
|
|
68
|
+
return typeof v === 'string' ? v : String(v);
|
|
69
|
+
}
|
|
70
|
+
|
|
14
71
|
/**
|
|
15
72
|
* Expand {base_url} and {settings.<key>} tokens in a string. Other tokens
|
|
16
73
|
* (including {args.*}) are passed through unchanged so the extension can
|
|
17
74
|
* finish substitution at run time.
|
|
18
75
|
*/
|
|
19
76
|
export function expandSettingsTokens(template: string, settings: SettingsMap | undefined): string {
|
|
20
|
-
if (!template
|
|
77
|
+
if (!template) return template;
|
|
21
78
|
return template.replace(/\{([^{}]+)\}/g, (full, raw) => {
|
|
22
79
|
const key = String(raw).trim();
|
|
23
80
|
if (key === 'base_url') {
|
|
24
|
-
const v = settings
|
|
81
|
+
const v = settings?.base_url;
|
|
25
82
|
return typeof v === 'string' ? stripTrailingSlashes(v) : full;
|
|
26
83
|
}
|
|
27
84
|
if (key.startsWith('settings.')) {
|
|
28
85
|
const path = key.slice('settings.'.length);
|
|
29
|
-
const v = settings[path];
|
|
86
|
+
const v = settings?.[path];
|
|
30
87
|
return typeof v === 'string' ? stripTrailingSlashes(v) : full;
|
|
31
88
|
}
|
|
89
|
+
if (key.startsWith('connector:')) {
|
|
90
|
+
const v = resolveConnectorRef(key.slice('connector:'.length));
|
|
91
|
+
return v == null ? full : v;
|
|
92
|
+
}
|
|
93
|
+
if (key.startsWith('user.')) {
|
|
94
|
+
const v = resolveUserRef(key.slice('user.'.length));
|
|
95
|
+
return v == null ? full : v;
|
|
96
|
+
}
|
|
32
97
|
return full;
|
|
33
98
|
});
|
|
34
99
|
}
|
|
@@ -78,6 +143,14 @@ export function expandAllTokens(
|
|
|
78
143
|
if (typeof v === 'number' || typeof v === 'boolean') return String(v);
|
|
79
144
|
try { return JSON.stringify(v); } catch { return full; }
|
|
80
145
|
}
|
|
146
|
+
if (key.startsWith('connector:')) {
|
|
147
|
+
const v = resolveConnectorRef(key.slice('connector:'.length));
|
|
148
|
+
return v == null ? full : v;
|
|
149
|
+
}
|
|
150
|
+
if (key.startsWith('user.')) {
|
|
151
|
+
const v = resolveUserRef(key.slice('user.'.length));
|
|
152
|
+
return v == null ? full : v;
|
|
153
|
+
}
|
|
81
154
|
return full;
|
|
82
155
|
});
|
|
83
156
|
}
|
package/lib/projects.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readdirSync, existsSync, statSync, readFileSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
3
4
|
import { loadSettings } from './settings';
|
|
4
5
|
import { getDataDir } from './dirs';
|
|
5
6
|
|
|
@@ -144,6 +145,90 @@ export function getProjectInfo(name: string): LocalProject | null {
|
|
|
144
145
|
return projects.find(p => p.name === name) || null;
|
|
145
146
|
}
|
|
146
147
|
|
|
148
|
+
/**
|
|
149
|
+
* Resolve a project by name, with a best-effort fallback for the common
|
|
150
|
+
* "user hasn't added any projects yet" case:
|
|
151
|
+
*
|
|
152
|
+
* 1. If `name` matches a scanned project — return it. (Same as getProjectInfo.)
|
|
153
|
+
* 2. Else if the GitLab connector has `default_project_path` set, try to
|
|
154
|
+
* clone it under `projectRoots[0]` (or ~/IdeaProjects if none set), add
|
|
155
|
+
* that parent to projectRoots, and return the cloned project.
|
|
156
|
+
* 3. Else return null — callers fall back to their existing failure path
|
|
157
|
+
* (which typically surfaces a clear "Project not found" error so the
|
|
158
|
+
* user can fix it via Settings → Project roots).
|
|
159
|
+
*
|
|
160
|
+
* Designed to be additive: never throws, never overwrites existing projects,
|
|
161
|
+
* idempotent on re-entry (clone is skipped if the target dir already exists).
|
|
162
|
+
*
|
|
163
|
+
* Returns a `{ project, source }` so callers can log how the project was
|
|
164
|
+
* resolved without re-scanning.
|
|
165
|
+
*/
|
|
166
|
+
export interface ResolveResult {
|
|
167
|
+
project: LocalProject;
|
|
168
|
+
source: 'existing' | 'cloned' | 'scratch';
|
|
169
|
+
clone_url?: string;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Pick a writable directory to clone into, in priority order:
|
|
174
|
+
* 1. settings.projectRoots[0] — what the user already trusts
|
|
175
|
+
* 2. ~/IdeaProjects — common convention; mkdir on demand
|
|
176
|
+
* 3. <dataDir>/scratch — Forge-owned, always writable, last resort
|
|
177
|
+
*
|
|
178
|
+
* Each candidate is mkdir-tested before use so we never return a path the
|
|
179
|
+
* caller will fail to clone into.
|
|
180
|
+
*/
|
|
181
|
+
export function getDefaultCloneRoot(): string {
|
|
182
|
+
const s = loadSettings();
|
|
183
|
+
const candidates: string[] = [];
|
|
184
|
+
if (s.projectRoots.length > 0) candidates.push(s.projectRoots[0]);
|
|
185
|
+
candidates.push(join(homedir(), 'IdeaProjects'));
|
|
186
|
+
candidates.push(join(getDataDir(), 'scratch'));
|
|
187
|
+
for (const c of candidates) {
|
|
188
|
+
try { mkdirSync(c, { recursive: true }); return c; }
|
|
189
|
+
catch { /* try next */ }
|
|
190
|
+
}
|
|
191
|
+
// mkdir of <dataDir>/scratch should always work (we own it); reaching here
|
|
192
|
+
// means the dataDir itself is broken, which has bigger problems than this.
|
|
193
|
+
return join(getDataDir(), 'scratch');
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function resolveOrCloneProject(name: string | undefined): ResolveResult {
|
|
197
|
+
const trimmed = (name || '').trim();
|
|
198
|
+
if (trimmed) {
|
|
199
|
+
const hit = getProjectInfo(trimmed);
|
|
200
|
+
if (hit) return { project: hit, source: 'existing' };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Default to scratch when the name doesn't match a real project root. The
|
|
204
|
+
// earlier behavior was to auto-clone from gitlab.default_project_path — but
|
|
205
|
+
// that ran a `git clone` in-band (slow, surprising, leaves dirs scattered
|
|
206
|
+
// under ~/IdeaProjects). Pipelines now always run inside Forge-managed
|
|
207
|
+
// `<dataDir>/scratch/worktrees/pipeline-<id>/` unless the user has
|
|
208
|
+
// explicitly added the named project under Settings → Project roots.
|
|
209
|
+
// Explicit clone is still available via `POST /api/projects/clone` for
|
|
210
|
+
// users who want it.
|
|
211
|
+
return { project: scratchProject(), source: 'scratch' };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Where pipeline worktrees go for a given project.
|
|
216
|
+
*
|
|
217
|
+
* • Regular project (any `projectRoots` dir) → `<project>/.forge/worktrees/`
|
|
218
|
+
* — the dot-prefixed `.forge/` keeps Forge's scratch out of the way of
|
|
219
|
+
* the user's source tree.
|
|
220
|
+
* • Scratch project (`<dataDir>/scratch/`) → `<dataDir>/scratch/worktrees/`
|
|
221
|
+
* — scratch already lives under `.forge/data/`, so the inner `.forge/`
|
|
222
|
+
* would just be a redundant nesting. Flatter path here.
|
|
223
|
+
*
|
|
224
|
+
* Used by pipeline.ts (worktree creation + `{{run.tmp_dir}}`) and
|
|
225
|
+
* pipeline-gc.ts (scan + cleanup).
|
|
226
|
+
*/
|
|
227
|
+
export function getProjectWorktreeRoot(project: LocalProject): string {
|
|
228
|
+
if (project.name === SCRATCH_PROJECT_NAME) return join(project.path, 'worktrees');
|
|
229
|
+
return join(project.path, '.forge', 'worktrees');
|
|
230
|
+
}
|
|
231
|
+
|
|
147
232
|
export function getProjectClaudeMd(projectPath: string): string | null {
|
|
148
233
|
const claudeMdPath = join(projectPath, 'CLAUDE.md');
|
|
149
234
|
if (!existsSync(claudeMdPath)) return null;
|
package/lib/settings.ts
CHANGED
|
@@ -135,6 +135,14 @@ export interface Settings {
|
|
|
135
135
|
maxConcurrentPipelines: number;
|
|
136
136
|
displayName: string;
|
|
137
137
|
displayEmail: string;
|
|
138
|
+
/** User profile — company + dept. Auto-filled by the wizard when the
|
|
139
|
+
* user picks an enterprise template (company = tenant display name,
|
|
140
|
+
* dept = chosen department's display name). Editable in Settings →
|
|
141
|
+
* Identity. Changing `dept` re-applies the matching dept template
|
|
142
|
+
* to rotate dept-flavored connector defaults (default_project_path,
|
|
143
|
+
* jenkins instance, etc.). */
|
|
144
|
+
company: string;
|
|
145
|
+
dept: string;
|
|
138
146
|
favoriteProjects: string[];
|
|
139
147
|
defaultAgent: string;
|
|
140
148
|
telegramAgent: string;
|
|
@@ -228,6 +236,8 @@ const defaults: Settings = {
|
|
|
228
236
|
maxConcurrentPipelines: 5,
|
|
229
237
|
displayName: 'Forge',
|
|
230
238
|
displayEmail: '',
|
|
239
|
+
company: '',
|
|
240
|
+
dept: '',
|
|
231
241
|
favoriteProjects: [],
|
|
232
242
|
defaultAgent: 'claude',
|
|
233
243
|
telegramAgent: '',
|