@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.
Files changed (59) hide show
  1. package/CLAUDE.md +1 -1
  2. package/RELEASE_NOTES.md +83 -5
  3. package/app/api/bridge-info/route.ts +34 -0
  4. package/app/api/connectors/[id]/test/route.ts +14 -0
  5. package/app/api/connectors/import-config-template/route.ts +103 -13
  6. package/app/api/enterprise-keys/route.ts +204 -0
  7. package/app/api/marketplace/sync-all/route.ts +28 -0
  8. package/app/api/monitor/route.ts +29 -6
  9. package/app/api/onboarding/route.ts +897 -23
  10. package/app/api/projects/clone/route.ts +51 -0
  11. package/app/api/settings/route.ts +11 -2
  12. package/bin/forge-server.mjs +98 -1
  13. package/cli/mw.mjs +16 -6
  14. package/cli/mw.ts +19 -6
  15. package/components/ConnectorsPanel.tsx +85 -13
  16. package/components/CraftTerminal.tsx +12 -3
  17. package/components/Dashboard.tsx +55 -17
  18. package/components/DocTerminal.tsx +12 -6
  19. package/components/EnterpriseBadge.tsx +420 -0
  20. package/components/LoginStatusPanel.tsx +15 -1
  21. package/components/OnboardingWizard.tsx +418 -31
  22. package/components/SettingsModal.tsx +382 -63
  23. package/components/SkillsPanel.tsx +116 -91
  24. package/components/WebTerminal.tsx +36 -13
  25. package/dev-test.sh +34 -1
  26. package/install.sh +29 -2
  27. package/lib/agents/claude-adapter.ts +18 -4
  28. package/lib/agents/index.ts +33 -4
  29. package/lib/auth/login-status.ts +14 -0
  30. package/lib/chat/agent-loop.ts +23 -1
  31. package/lib/chat/protocols/http.ts +15 -2
  32. package/lib/chat/tool-dispatcher.ts +163 -1
  33. package/lib/connectors/registry.ts +69 -4
  34. package/lib/connectors/sync.ts +536 -138
  35. package/lib/connectors/test-runner.ts +21 -3
  36. package/lib/connectors/types.ts +36 -4
  37. package/lib/connectors/wizard-template.ts +161 -0
  38. package/lib/dirs.ts +5 -0
  39. package/lib/enterprise-known.ts +34 -0
  40. package/lib/enterprise-secret.ts +87 -0
  41. package/lib/enterprise.ts +208 -0
  42. package/lib/help-docs/00-overview.md +12 -0
  43. package/lib/help-docs/01-settings.md +47 -1
  44. package/lib/help-docs/17-connectors.md +25 -22
  45. package/lib/help-docs/CLAUDE.md +1 -0
  46. package/lib/init.ts +13 -6
  47. package/lib/marketplace-sync.ts +70 -0
  48. package/lib/memory/temper-provision.ts +92 -0
  49. package/lib/pipeline-gc.ts +5 -2
  50. package/lib/pipeline.ts +26 -21
  51. package/lib/plugins/templates.ts +76 -3
  52. package/lib/projects.ts +85 -0
  53. package/lib/settings.ts +10 -0
  54. package/lib/telegram-bot.ts +14 -2
  55. package/lib/workflow-marketplace.ts +174 -108
  56. package/package.json +1 -1
  57. package/{middleware.ts → proxy.ts} +2 -1
  58. package/src/core/db/database.ts +8 -2
  59. package/templates/connector-config-template.json +0 -7
@@ -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 fetches `registry.json` from `connectorsRepoUrl`
20
- (default `https://raw.githubusercontent.com/aiwatching/forge-connectors/main`)
21
- and caches it at `<dataDir>/connectors/registry-cache.json`.
22
- 2. The Settings Connectors panel reads the cache and lists every
23
- available connector with its install state:
24
- - **Available** in registry, not installed
25
- - **Installed v0.5.0** manifest on disk, config row present
26
- - **Update 0.5 → 0.6** — registry has newer version
27
- 3. Clicking **Install** fetches `<id>/manifest.yaml` from the registry,
28
- writes it to `<dataDir>/connectors/<id>/manifest.yaml`, and adds a
29
- row to `<dataDir>/connector-configs.json`.
30
- 4. The user fills in settings (host URL, PAT, etc.) via the existing
31
- settings UI in the extension or `/api/connectors/<id>/settings`.
32
- 5. Chat tools become available the instant the manifest lands on disk —
33
- no Forge restart.
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
- │ ├── registry-cache.json last fetch from forge-connectors
61
- │ ├── mantis/manifest.yaml installed manifest copy
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
- └── connector-configs.json { "<id>": { config, installed_version, enabled } }
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
@@ -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
+ }
@@ -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
- const wtDir = join(proj.path, '.forge', 'worktrees');
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
- // `.forge/worktrees/` matches the existing convention used by the auto-
759
- // worktree path (see line ~1450) and by older fortinet-* pipelines.
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 projectInfo = getProjectInfo(projectName);
1054
- if (!projectInfo) {
1055
- pipeline.status = 'failed';
1056
- pipeline.completedAt = new Date().toISOString();
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
- const projectInfo = getProjectInfo(project);
1775
- if (!projectInfo) {
1776
- nodeState.status = 'failed';
1777
- nodeState.error = `Project not found: ${project}`;
1778
- savePipeline(pipeline);
1779
- notifyStep(pipeline, nodeId, 'failed', nodeState.error);
1780
- continue;
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 worktreePath = `${projectInfo.path}/.forge/worktrees/${branchName.replace(/\//g, '-')}`;
1795
- mkdirSync(`${projectInfo.path}/.forge/worktrees`, { recursive: true });
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
@@ -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 || !settings) return 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.base_url;
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: '',