@aion0/forge 0.10.49 → 0.10.51

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/RELEASE_NOTES.md CHANGED
@@ -1,11 +1,8 @@
1
- # Forge v0.10.49
1
+ # Forge v0.10.51
2
2
 
3
3
  Released: 2026-06-09
4
4
 
5
- ## Changes since v0.10.48
5
+ ## Changes since v0.10.50
6
6
 
7
- ### Other
8
- - fix(idp-login): pick a failing SP as the SAML trigger, not the first one
9
7
 
10
-
11
- **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.48...v0.10.49
8
+ **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.50...v0.10.51
@@ -0,0 +1,83 @@
1
+ /**
2
+ * GET /api/cache → list cached cloned-project dirs + total size.
3
+ * DELETE /api/cache → wipe the whole cloned-projects cache.
4
+ * DELETE /api/cache?name=<entry> → wipe a single entry.
5
+ *
6
+ * Backs the "Cache" item in the user dropdown. Same data the CLI's
7
+ * `forge clean cache` shows, surfaced in the UI for users who don't
8
+ * touch the terminal.
9
+ */
10
+
11
+ import { NextResponse } from 'next/server';
12
+ import { existsSync, readdirSync, rmSync, statSync } from 'node:fs';
13
+ import { join } from 'node:path';
14
+ import { getDataDir } from '@/lib/dirs';
15
+
16
+ function dirSizeBytes(p: string): number {
17
+ let total = 0;
18
+ try {
19
+ for (const ent of readdirSync(p, { withFileTypes: true })) {
20
+ const child = join(p, ent.name);
21
+ try {
22
+ if (ent.isDirectory()) total += dirSizeBytes(child);
23
+ else total += statSync(child).size;
24
+ } catch { /* skip unreadable */ }
25
+ }
26
+ } catch { /* skip unreadable */ }
27
+ return total;
28
+ }
29
+
30
+ function listCache(): { entries: { name: string; bytes: number }[]; total_bytes: number; root: string } {
31
+ const root = join(getDataDir(), 'cloned-projects');
32
+ if (!existsSync(root)) return { entries: [], total_bytes: 0, root };
33
+ const entries: { name: string; bytes: number }[] = [];
34
+ for (const ent of readdirSync(root, { withFileTypes: true })) {
35
+ if (!ent.isDirectory()) continue;
36
+ entries.push({ name: ent.name, bytes: dirSizeBytes(join(root, ent.name)) });
37
+ }
38
+ entries.sort((a, b) => b.bytes - a.bytes);
39
+ return { entries, total_bytes: entries.reduce((s, e) => s + e.bytes, 0), root };
40
+ }
41
+
42
+ export async function GET() {
43
+ return NextResponse.json(listCache());
44
+ }
45
+
46
+ export async function DELETE(req: Request) {
47
+ const url = new URL(req.url);
48
+ const name = url.searchParams.get('name');
49
+ const root = join(getDataDir(), 'cloned-projects');
50
+ if (!existsSync(root)) {
51
+ return NextResponse.json({ ok: true, deleted: 0, freed_bytes: 0 });
52
+ }
53
+ // Reject anything that escapes the cache root or contains separators —
54
+ // we accept ONLY a direct subdirectory name (e.g. "fortinet-fortinac-dev").
55
+ if (name && (name.includes('/') || name.includes('\\') || name.includes('..'))) {
56
+ return NextResponse.json({ ok: false, error: 'invalid name' }, { status: 400 });
57
+ }
58
+
59
+ const before = listCache();
60
+ if (name) {
61
+ const target = join(root, name);
62
+ if (!existsSync(target)) {
63
+ return NextResponse.json({ ok: false, error: 'not found' }, { status: 404 });
64
+ }
65
+ const hit = before.entries.find((e) => e.name === name);
66
+ try {
67
+ rmSync(target, { recursive: true, force: true });
68
+ } catch (e) {
69
+ return NextResponse.json({ ok: false, error: (e as Error).message }, { status: 500 });
70
+ }
71
+ return NextResponse.json({ ok: true, deleted: 1, freed_bytes: hit?.bytes || 0 });
72
+ }
73
+
74
+ // Wipe all
75
+ let deleted = 0;
76
+ for (const e of before.entries) {
77
+ try {
78
+ rmSync(join(root, e.name), { recursive: true, force: true });
79
+ deleted++;
80
+ } catch { /* skip individual failures, report partial */ }
81
+ }
82
+ return NextResponse.json({ ok: true, deleted, freed_bytes: before.total_bytes });
83
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * GET /api/web-sessions → IdP-consolidated rows + cached statuses
3
+ * POST /api/web-sessions → re-probe every IdP in parallel
4
+ *
5
+ * Distinct from /api/login-status (per-connector): this endpoint is used
6
+ * by the Enterprise badge "Web sessions" section, which wants one row
7
+ * per IdP block rather than one row per SAML SP.
8
+ */
9
+
10
+ import { NextResponse } from 'next/server';
11
+ import { listIdpSources, listIdpStatus, checkSource } from '@/lib/auth/login-status';
12
+
13
+ export async function GET() {
14
+ return NextResponse.json({ rows: listIdpStatus() });
15
+ }
16
+
17
+ export async function POST() {
18
+ const sources = listIdpSources();
19
+ const results = await Promise.all(
20
+ sources.map(async (s) => {
21
+ try {
22
+ const r = await checkSource(s);
23
+ return { source: s, result: r };
24
+ } catch (e) {
25
+ return {
26
+ source: s,
27
+ result: {
28
+ ok: false,
29
+ message: `check error: ${(e as Error).message}`,
30
+ checked_at: Date.now(),
31
+ duration_ms: 0,
32
+ },
33
+ };
34
+ }
35
+ }),
36
+ );
37
+ return NextResponse.json({ rows: results });
38
+ }
package/cli/clean.ts ADDED
@@ -0,0 +1,140 @@
1
+ /**
2
+ * `forge clean` — wipe Forge's on-disk caches.
3
+ *
4
+ * Today: just `forge clean cache` which removes auto-cloned gitlab
5
+ * repos under `<dataDir>/cloned-projects/`. Pipelines that need them
6
+ * re-clone on the next dispatch (one-shot slowdown, no broken state).
7
+ *
8
+ * Add more cache targets here as they appear — keep one obvious entry
9
+ * point so users don't have to learn N subcommands.
10
+ */
11
+
12
+ import { existsSync, readdirSync, rmSync, statSync } from 'node:fs';
13
+ import { join } from 'node:path';
14
+ import { homedir } from 'node:os';
15
+
16
+ function dataDir(): string {
17
+ return process.env.FORGE_DATA_DIR || join(homedir(), '.forge', 'data');
18
+ }
19
+
20
+ function dirSizeBytes(p: string): number {
21
+ let total = 0;
22
+ try {
23
+ for (const ent of readdirSync(p, { withFileTypes: true })) {
24
+ const child = join(p, ent.name);
25
+ try {
26
+ if (ent.isDirectory()) total += dirSizeBytes(child);
27
+ else total += statSync(child).size;
28
+ } catch { /* skip unreadable */ }
29
+ }
30
+ } catch { /* skip unreadable */ }
31
+ return total;
32
+ }
33
+
34
+ function humanBytes(n: number): string {
35
+ if (n < 1024) return `${n} B`;
36
+ if (n < 1024 ** 2) return `${(n / 1024).toFixed(1)} KB`;
37
+ if (n < 1024 ** 3) return `${(n / 1024 ** 2).toFixed(1)} MB`;
38
+ return `${(n / 1024 ** 3).toFixed(1)} GB`;
39
+ }
40
+
41
+ function printHelp(): void {
42
+ console.log(`
43
+ forge clean — wipe Forge caches.
44
+
45
+ Subcommands:
46
+ forge clean cache List cached items + sizes (dry-run).
47
+ forge clean cache --yes Actually delete everything.
48
+ forge clean cache <name> --yes Delete one entry (e.g. fortinet-fortinac-dev).
49
+
50
+ Targets cleaned:
51
+ • <dataDir>/cloned-projects/ Auto-cloned gitlab repos (gitlab fallback).
52
+ `);
53
+ }
54
+
55
+ async function cmdCache(args: string[]): Promise<void> {
56
+ const root = join(dataDir(), 'cloned-projects');
57
+ const yes = args.includes('--yes') || args.includes('-y');
58
+ const target = args.find((a) => !a.startsWith('-'));
59
+
60
+ if (!existsSync(root)) {
61
+ console.log(`Nothing to clean. ${root} does not exist.`);
62
+ return;
63
+ }
64
+
65
+ const entries: { name: string; path: string; size: number }[] = [];
66
+ for (const ent of readdirSync(root, { withFileTypes: true })) {
67
+ if (!ent.isDirectory()) continue;
68
+ const p = join(root, ent.name);
69
+ entries.push({ name: ent.name, path: p, size: dirSizeBytes(p) });
70
+ }
71
+ entries.sort((a, b) => b.size - a.size);
72
+
73
+ if (entries.length === 0) {
74
+ console.log(`Nothing to clean. ${root} is empty.`);
75
+ return;
76
+ }
77
+
78
+ const totalBytes = entries.reduce((s, e) => s + e.size, 0);
79
+
80
+ // Single-target mode
81
+ if (target) {
82
+ const hit = entries.find((e) => e.name === target);
83
+ if (!hit) {
84
+ console.error(`Not found: ${target}`);
85
+ console.error(`Available: ${entries.map(e => e.name).join(', ')}`);
86
+ process.exit(1);
87
+ }
88
+ console.log(`${hit.name} ${humanBytes(hit.size)} ${hit.path}`);
89
+ if (!yes) {
90
+ console.log('\nDry-run. Add --yes to actually delete.');
91
+ return;
92
+ }
93
+ try {
94
+ rmSync(hit.path, { recursive: true, force: true });
95
+ console.log(`✓ Deleted ${hit.name}`);
96
+ } catch (e) {
97
+ console.error(`Failed: ${(e as Error).message}`);
98
+ process.exit(1);
99
+ }
100
+ return;
101
+ }
102
+
103
+ // List / wipe-all mode
104
+ console.log(`Cached repos under ${root}:\n`);
105
+ for (const e of entries) {
106
+ console.log(` ${e.name.padEnd(40)} ${humanBytes(e.size).padStart(10)}`);
107
+ }
108
+ console.log(`\nTotal: ${entries.length} entries, ${humanBytes(totalBytes)}`);
109
+
110
+ if (!yes) {
111
+ console.log('\nDry-run. Add --yes to delete everything.');
112
+ return;
113
+ }
114
+
115
+ let deleted = 0;
116
+ for (const e of entries) {
117
+ try {
118
+ rmSync(e.path, { recursive: true, force: true });
119
+ deleted++;
120
+ } catch (err) {
121
+ console.error(`Failed ${e.name}: ${(err as Error).message}`);
122
+ }
123
+ }
124
+ console.log(`\n✓ Deleted ${deleted} entries (${humanBytes(totalBytes)} freed).`);
125
+ }
126
+
127
+ export async function cleanCommand(args: string[]): Promise<void> {
128
+ const sub = args[0] || '';
129
+ switch (sub) {
130
+ case 'cache':
131
+ await cmdCache(args.slice(1));
132
+ break;
133
+ case '--help':
134
+ case '-h':
135
+ case '':
136
+ default:
137
+ printHelp();
138
+ break;
139
+ }
140
+ }
package/cli/mw.mjs CHANGED
@@ -758,15 +758,15 @@ function migrateDataDir() {
758
758
  migrated = true;
759
759
  if (process.env.FORGE_DATA_DIR) return;
760
760
  const configDir = getConfigDir();
761
- const dataDir2 = join3(configDir, "data");
761
+ const dataDir3 = join3(configDir, "data");
762
762
  const oldSettings = join3(configDir, "settings.yaml");
763
- const newSettings = join3(dataDir2, "settings.yaml");
763
+ const newSettings = join3(dataDir3, "settings.yaml");
764
764
  if (!existsSync3(oldSettings) || existsSync3(newSettings)) return;
765
765
  console.log("[forge] Migrating data from ~/.forge/ to ~/.forge/data/...");
766
- if (!existsSync3(dataDir2)) mkdirSync2(dataDir2, { recursive: true });
766
+ if (!existsSync3(dataDir3)) mkdirSync2(dataDir3, { recursive: true });
767
767
  for (const file2 of MIGRATE_FILES) {
768
768
  const src = join3(configDir, file2);
769
- const dest = join3(dataDir2, file2);
769
+ const dest = join3(dataDir3, file2);
770
770
  if (existsSync3(src) && !existsSync3(dest)) {
771
771
  try {
772
772
  copyFileSync(src, dest);
@@ -776,7 +776,7 @@ function migrateDataDir() {
776
776
  }
777
777
  }
778
778
  const oldDb = join3(configDir, "data.db");
779
- const newDb = join3(dataDir2, "workflow.db");
779
+ const newDb = join3(dataDir3, "workflow.db");
780
780
  if (existsSync3(oldDb) && !existsSync3(newDb)) {
781
781
  try {
782
782
  copyFileSync(oldDb, newDb);
@@ -786,7 +786,7 @@ function migrateDataDir() {
786
786
  }
787
787
  for (const dir of MIGRATE_DIRS) {
788
788
  const src = join3(configDir, dir);
789
- const dest = join3(dataDir2, dir);
789
+ const dest = join3(dataDir3, dir);
790
790
  if (existsSync3(src) && !existsSync3(dest)) {
791
791
  try {
792
792
  renameSync(src, dest);
@@ -1221,6 +1221,135 @@ var init_key = __esm({
1221
1221
  }
1222
1222
  });
1223
1223
 
1224
+ // cli/clean.ts
1225
+ var clean_exports = {};
1226
+ __export(clean_exports, {
1227
+ cleanCommand: () => cleanCommand
1228
+ });
1229
+ import { existsSync as existsSync7, readdirSync as readdirSync2, rmSync as rmSync3, statSync as statSync3 } from "node:fs";
1230
+ import { join as join8 } from "node:path";
1231
+ import { homedir as homedir6 } from "node:os";
1232
+ function dataDir2() {
1233
+ return process.env.FORGE_DATA_DIR || join8(homedir6(), ".forge", "data");
1234
+ }
1235
+ function dirSizeBytes2(p) {
1236
+ let total = 0;
1237
+ try {
1238
+ for (const ent of readdirSync2(p, { withFileTypes: true })) {
1239
+ const child = join8(p, ent.name);
1240
+ try {
1241
+ if (ent.isDirectory()) total += dirSizeBytes2(child);
1242
+ else total += statSync3(child).size;
1243
+ } catch {
1244
+ }
1245
+ }
1246
+ } catch {
1247
+ }
1248
+ return total;
1249
+ }
1250
+ function humanBytes(n) {
1251
+ if (n < 1024) return `${n} B`;
1252
+ if (n < 1024 ** 2) return `${(n / 1024).toFixed(1)} KB`;
1253
+ if (n < 1024 ** 3) return `${(n / 1024 ** 2).toFixed(1)} MB`;
1254
+ return `${(n / 1024 ** 3).toFixed(1)} GB`;
1255
+ }
1256
+ function printHelp4() {
1257
+ console.log(`
1258
+ forge clean \u2014 wipe Forge caches.
1259
+
1260
+ Subcommands:
1261
+ forge clean cache List cached items + sizes (dry-run).
1262
+ forge clean cache --yes Actually delete everything.
1263
+ forge clean cache <name> --yes Delete one entry (e.g. fortinet-fortinac-dev).
1264
+
1265
+ Targets cleaned:
1266
+ \u2022 <dataDir>/cloned-projects/ Auto-cloned gitlab repos (gitlab fallback).
1267
+ `);
1268
+ }
1269
+ async function cmdCache(args2) {
1270
+ const root = join8(dataDir2(), "cloned-projects");
1271
+ const yes = args2.includes("--yes") || args2.includes("-y");
1272
+ const target = args2.find((a) => !a.startsWith("-"));
1273
+ if (!existsSync7(root)) {
1274
+ console.log(`Nothing to clean. ${root} does not exist.`);
1275
+ return;
1276
+ }
1277
+ const entries = [];
1278
+ for (const ent of readdirSync2(root, { withFileTypes: true })) {
1279
+ if (!ent.isDirectory()) continue;
1280
+ const p = join8(root, ent.name);
1281
+ entries.push({ name: ent.name, path: p, size: dirSizeBytes2(p) });
1282
+ }
1283
+ entries.sort((a, b) => b.size - a.size);
1284
+ if (entries.length === 0) {
1285
+ console.log(`Nothing to clean. ${root} is empty.`);
1286
+ return;
1287
+ }
1288
+ const totalBytes = entries.reduce((s, e) => s + e.size, 0);
1289
+ if (target) {
1290
+ const hit = entries.find((e) => e.name === target);
1291
+ if (!hit) {
1292
+ console.error(`Not found: ${target}`);
1293
+ console.error(`Available: ${entries.map((e) => e.name).join(", ")}`);
1294
+ process.exit(1);
1295
+ }
1296
+ console.log(`${hit.name} ${humanBytes(hit.size)} ${hit.path}`);
1297
+ if (!yes) {
1298
+ console.log("\nDry-run. Add --yes to actually delete.");
1299
+ return;
1300
+ }
1301
+ try {
1302
+ rmSync3(hit.path, { recursive: true, force: true });
1303
+ console.log(`\u2713 Deleted ${hit.name}`);
1304
+ } catch (e) {
1305
+ console.error(`Failed: ${e.message}`);
1306
+ process.exit(1);
1307
+ }
1308
+ return;
1309
+ }
1310
+ console.log(`Cached repos under ${root}:
1311
+ `);
1312
+ for (const e of entries) {
1313
+ console.log(` ${e.name.padEnd(40)} ${humanBytes(e.size).padStart(10)}`);
1314
+ }
1315
+ console.log(`
1316
+ Total: ${entries.length} entries, ${humanBytes(totalBytes)}`);
1317
+ if (!yes) {
1318
+ console.log("\nDry-run. Add --yes to delete everything.");
1319
+ return;
1320
+ }
1321
+ let deleted = 0;
1322
+ for (const e of entries) {
1323
+ try {
1324
+ rmSync3(e.path, { recursive: true, force: true });
1325
+ deleted++;
1326
+ } catch (err) {
1327
+ console.error(`Failed ${e.name}: ${err.message}`);
1328
+ }
1329
+ }
1330
+ console.log(`
1331
+ \u2713 Deleted ${deleted} entries (${humanBytes(totalBytes)} freed).`);
1332
+ }
1333
+ async function cleanCommand(args2) {
1334
+ const sub = args2[0] || "";
1335
+ switch (sub) {
1336
+ case "cache":
1337
+ await cmdCache(args2.slice(1));
1338
+ break;
1339
+ case "--help":
1340
+ case "-h":
1341
+ case "":
1342
+ default:
1343
+ printHelp4();
1344
+ break;
1345
+ }
1346
+ }
1347
+ var init_clean = __esm({
1348
+ "cli/clean.ts"() {
1349
+ "use strict";
1350
+ }
1351
+ });
1352
+
1224
1353
  // cli/mw.ts
1225
1354
  var _cliPort = process.argv.find((a, i) => i > 0 && process.argv[i - 1] === "--port");
1226
1355
  var BASE2 = process.env.MW_URL || `http://localhost:${_cliPort || "3000"}`;
@@ -1228,9 +1357,9 @@ var [, , cmd, ...args] = process.argv;
1228
1357
  async function checkForUpdate() {
1229
1358
  try {
1230
1359
  const { readFileSync: readFileSync5 } = await import("node:fs");
1231
- const { join: join8, dirname } = await import("node:path");
1360
+ const { join: join9, dirname } = await import("node:path");
1232
1361
  const { fileURLToPath } = await import("node:url");
1233
- const pkg = JSON.parse(readFileSync5(join8(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf-8"));
1362
+ const pkg = JSON.parse(readFileSync5(join9(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf-8"));
1234
1363
  const current = pkg.version;
1235
1364
  const controller = new AbortController();
1236
1365
  const timeout = setTimeout(() => controller.abort(), 3e3);
@@ -1265,10 +1394,10 @@ async function api3(path, opts) {
1265
1394
  async function main() {
1266
1395
  if (cmd === "--version" || cmd === "-v") {
1267
1396
  const { readFileSync: readFileSync5 } = await import("node:fs");
1268
- const { join: join8, dirname } = await import("node:path");
1397
+ const { join: join9, dirname } = await import("node:path");
1269
1398
  const { fileURLToPath } = await import("node:url");
1270
1399
  try {
1271
- const pkg = JSON.parse(readFileSync5(join8(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf-8"));
1400
+ const pkg = JSON.parse(readFileSync5(join9(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf-8"));
1272
1401
  console.log(`@aion0/forge v${pkg.version}`);
1273
1402
  } catch {
1274
1403
  console.log("forge (version unknown)");
@@ -1277,18 +1406,18 @@ async function main() {
1277
1406
  }
1278
1407
  if (cmd === "--reset-password") {
1279
1408
  const { spawnSync: spawnSync3 } = await import("node:child_process");
1280
- const { join: join8, dirname } = await import("node:path");
1409
+ const { join: join9, dirname } = await import("node:path");
1281
1410
  const { fileURLToPath } = await import("node:url");
1282
- const serverScript = join8(dirname(fileURLToPath(import.meta.url)), "..", "bin", "forge-server.mjs");
1411
+ const serverScript = join9(dirname(fileURLToPath(import.meta.url)), "..", "bin", "forge-server.mjs");
1283
1412
  const passthru = process.argv.slice(3);
1284
1413
  spawnSync3("node", [serverScript, "--reset-password", ...passthru], { stdio: "inherit" });
1285
1414
  process.exit(0);
1286
1415
  }
1287
1416
  if (cmd === "--add-enterprise-key") {
1288
1417
  const { spawnSync: spawnSync3 } = await import("node:child_process");
1289
- const { join: join8, dirname } = await import("node:path");
1418
+ const { join: join9, dirname } = await import("node:path");
1290
1419
  const { fileURLToPath } = await import("node:url");
1291
- const serverScript = join8(dirname(fileURLToPath(import.meta.url)), "..", "bin", "forge-server.mjs");
1420
+ const serverScript = join9(dirname(fileURLToPath(import.meta.url)), "..", "bin", "forge-server.mjs");
1292
1421
  const r = spawnSync3("node", [serverScript, "--add-enterprise-key", ...args], { stdio: "inherit" });
1293
1422
  process.exit(r.status ?? 1);
1294
1423
  }
@@ -1326,6 +1455,11 @@ async function main() {
1326
1455
  await keyCommand2(args);
1327
1456
  break;
1328
1457
  }
1458
+ case "clean": {
1459
+ const { cleanCommand: cleanCommand2 } = await Promise.resolve().then(() => (init_clean(), clean_exports));
1460
+ await cleanCommand2(args);
1461
+ break;
1462
+ }
1329
1463
  case "task":
1330
1464
  case "t": {
1331
1465
  const newSession = args.includes("--new");
@@ -1606,13 +1740,13 @@ Resume in CLI:`);
1606
1740
  }
1607
1741
  case "tunnel_code":
1608
1742
  case "tcode": {
1609
- const { readFileSync: readFileSync5, existsSync: existsSync7 } = await import("node:fs");
1610
- const { join: join8 } = await import("node:path");
1743
+ const { readFileSync: readFileSync5, existsSync: existsSync8 } = await import("node:fs");
1744
+ const { join: join9 } = await import("node:path");
1611
1745
  const { getDataDir: _gdd } = await Promise.resolve().then(() => (init_dirs(), dirs_exports));
1612
- const dataDir2 = _gdd();
1613
- const codeFile = join8(dataDir2, "session-code.json");
1746
+ const dataDir3 = _gdd();
1747
+ const codeFile = join9(dataDir3, "session-code.json");
1614
1748
  try {
1615
- if (existsSync7(codeFile)) {
1749
+ if (existsSync8(codeFile)) {
1616
1750
  const data = JSON.parse(readFileSync5(codeFile, "utf-8"));
1617
1751
  if (data.code) {
1618
1752
  console.log(`Session code: ${data.code}`);
@@ -1625,7 +1759,7 @@ Resume in CLI:`);
1625
1759
  } catch {
1626
1760
  }
1627
1761
  try {
1628
- const tunnelState = JSON.parse(readFileSync5(join8(dataDir2, "tunnel-state.json"), "utf-8"));
1762
+ const tunnelState = JSON.parse(readFileSync5(join9(dataDir3, "tunnel-state.json"), "utf-8"));
1629
1763
  if (tunnelState.url) console.log(`Tunnel URL: ${tunnelState.url}`);
1630
1764
  } catch {
1631
1765
  }
@@ -1672,9 +1806,9 @@ ${projects.length} projects`);
1672
1806
  }
1673
1807
  case "server": {
1674
1808
  const { execSync: execSync2 } = await import("node:child_process");
1675
- const { join: join8, dirname } = await import("node:path");
1809
+ const { join: join9, dirname } = await import("node:path");
1676
1810
  const { fileURLToPath } = await import("node:url");
1677
- const serverScript = join8(dirname(fileURLToPath(import.meta.url)), "..", "bin", "forge-server.mjs");
1811
+ const serverScript = join9(dirname(fileURLToPath(import.meta.url)), "..", "bin", "forge-server.mjs");
1678
1812
  const sub = args[0] || "start";
1679
1813
  const serverArgs = args.slice(1);
1680
1814
  const flagMap = {
@@ -1747,31 +1881,31 @@ ${task.gitDiff.slice(0, 2e3)}`);
1747
1881
  case "upgrade": {
1748
1882
  const { execSync: execSync2 } = await import("node:child_process");
1749
1883
  const { lstatSync } = await import("node:fs");
1750
- const { join: join8, dirname } = await import("node:path");
1884
+ const { join: join9, dirname } = await import("node:path");
1751
1885
  const { fileURLToPath } = await import("node:url");
1752
1886
  const cliDir = dirname(fileURLToPath(import.meta.url));
1753
1887
  let isLinked = false;
1754
1888
  try {
1755
- isLinked = lstatSync(join8(cliDir, "..")).isSymbolicLink();
1889
+ isLinked = lstatSync(join9(cliDir, "..")).isSymbolicLink();
1756
1890
  } catch {
1757
1891
  }
1758
1892
  if (isLinked) {
1759
1893
  console.log("[forge] Installed via npm link (local source)");
1760
1894
  console.log("[forge] Pull latest and rebuild:");
1761
- console.log(" cd " + join8(cliDir, ".."));
1895
+ console.log(" cd " + join9(cliDir, ".."));
1762
1896
  console.log(" git pull && pnpm install && pnpm build");
1763
1897
  } else {
1764
1898
  console.log("[forge] Upgrading from npm...");
1765
1899
  try {
1766
- const { homedir: homedir6 } = await import("node:os");
1900
+ const { homedir: homedir7 } = await import("node:os");
1767
1901
  execSync2("npm install -g @aion0/forge@latest --prefer-online", {
1768
1902
  stdio: "inherit",
1769
- cwd: homedir6()
1903
+ cwd: homedir7()
1770
1904
  });
1771
1905
  try {
1772
1906
  const { readFileSync: readFileSync5 } = await import("node:fs");
1773
- const globalRoot = execSync2("npm root -g", { encoding: "utf-8", cwd: homedir6() }).trim();
1774
- const pkg = JSON.parse(readFileSync5(join8(globalRoot, "@aion0", "forge", "package.json"), "utf-8"));
1907
+ const globalRoot = execSync2("npm root -g", { encoding: "utf-8", cwd: homedir7() }).trim();
1908
+ const pkg = JSON.parse(readFileSync5(join9(globalRoot, "@aion0", "forge", "package.json"), "utf-8"));
1775
1909
  console.log(`[forge] Upgraded to v${pkg.version}. Run: forge server restart`);
1776
1910
  } catch {
1777
1911
  console.log("[forge] Upgraded. Run: forge server restart");
package/cli/mw.ts CHANGED
@@ -140,6 +140,13 @@ async function main() {
140
140
  break;
141
141
  }
142
142
 
143
+ case 'clean': {
144
+ // Wipe Forge on-disk caches (cloned gitlab repos, etc).
145
+ const { cleanCommand } = await import('./clean');
146
+ await cleanCommand(args);
147
+ break;
148
+ }
149
+
143
150
  case 'task':
144
151
  case 't': {
145
152
  // Parse --new flag to force a fresh session
@@ -176,10 +176,52 @@ export default function Dashboard({ user }: { user: any }) {
176
176
  const [expandedNotif, setExpandedNotif] = useState<string | null>(null);
177
177
  const [showUserMenu, setShowUserMenu] = useState(false);
178
178
  const [theme, setTheme] = useState<'dark' | 'light'>('dark');
179
+ const [cacheInfo, setCacheInfo] = useState<{ entries: { name: string; bytes: number }[]; total_bytes: number } | null>(null);
180
+ const [showCacheMenu, setShowCacheMenu] = useState(false);
181
+ const [cacheBusy, setCacheBusy] = useState(false);
179
182
  const [displayName, setDisplayName] = useState(user?.name || 'Forge');
180
183
  const [profileDept, setProfileDept] = useState('');
181
184
  const terminalRef = useRef<WebTerminalHandle>(null);
182
185
 
186
+ // Cache (forge-managed cloned-projects) load + clear helpers. Used by the
187
+ // user-menu "Cache" entry to show on-disk size and let the user wipe it
188
+ // without dropping to the terminal. Lazy — only fetched on menu open.
189
+ const loadCacheInfo = async () => {
190
+ try {
191
+ const r = await fetch('/api/cache');
192
+ if (!r.ok) return;
193
+ const data = await r.json();
194
+ setCacheInfo({ entries: data.entries || [], total_bytes: data.total_bytes || 0 });
195
+ } catch { /* leave previous */ }
196
+ };
197
+ const humanBytes = (n: number) => {
198
+ if (!n) return '0 B';
199
+ if (n < 1024) return `${n} B`;
200
+ if (n < 1024 ** 2) return `${(n / 1024).toFixed(1)} KB`;
201
+ if (n < 1024 ** 3) return `${(n / 1024 ** 2).toFixed(1)} MB`;
202
+ return `${(n / 1024 ** 3).toFixed(2)} GB`;
203
+ };
204
+ const clearCache = async (name?: string) => {
205
+ if (cacheBusy) return;
206
+ const ok = confirm(name ? `Delete cached repo "${name}"?` : 'Delete ALL cached repos? They will be re-cloned on next pipeline run.');
207
+ if (!ok) return;
208
+ setCacheBusy(true);
209
+ try {
210
+ const q = name ? `?name=${encodeURIComponent(name)}` : '';
211
+ const r = await fetch(`/api/cache${q}`, { method: 'DELETE' });
212
+ const data = await r.json();
213
+ if (data?.ok) {
214
+ await loadCacheInfo();
215
+ } else {
216
+ alert(`Cache clear failed: ${data?.error || `HTTP ${r.status}`}`);
217
+ }
218
+ } catch (e) {
219
+ alert(`Cache clear failed: ${(e as Error).message}`);
220
+ } finally {
221
+ setCacheBusy(false);
222
+ }
223
+ };
224
+
183
225
  // Theme: load from localStorage + apply
184
226
  useEffect(() => {
185
227
  const saved = localStorage.getItem('forge-theme') as 'dark' | 'light' | null;
@@ -621,7 +663,14 @@ export default function Dashboard({ user }: { user: any }) {
621
663
  {/* User menu */}
622
664
  <div className="relative">
623
665
  <button
624
- onClick={() => { setShowUserMenu(v => !v); setShowNotifications(false); }}
666
+ onClick={() => {
667
+ const next = !showUserMenu;
668
+ setShowUserMenu(next);
669
+ setShowNotifications(false);
670
+ // Lazy-load cache size whenever the menu opens — keeps
671
+ // the cache row's "· N MB" current without polling.
672
+ if (next) loadCacheInfo();
673
+ }}
625
674
  className="text-[var(--text-secondary)] hover:text-[var(--text-primary)] flex items-center gap-1 px-1"
626
675
  >
627
676
  <span className="flex flex-col items-end leading-tight">
@@ -734,6 +783,52 @@ export default function Dashboard({ user }: { user: any }) {
734
783
  )}
735
784
  </div>
736
785
  )}
786
+ {/* Cache — show on-disk size of auto-cloned gitlab repos
787
+ under <dataDir>/cloned-projects/, with a click-to-clear
788
+ sub-menu listing each entry. Same backend as
789
+ `forge clean cache` CLI. */}
790
+ <button
791
+ onClick={() => setShowCacheMenu(v => !v)}
792
+ className="w-full text-left text-[11px] px-3 py-1.5 text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)] flex items-center gap-2"
793
+ >
794
+ <span className="w-3 text-center">💾</span>
795
+ <span className="flex-1">Cache</span>
796
+ <span className="text-[9px] text-[var(--text-secondary)]">
797
+ {cacheInfo ? humanBytes(cacheInfo.total_bytes) : '…'}
798
+ </span>
799
+ <span className="text-[9px] text-[var(--text-secondary)]">{showCacheMenu ? '▾' : '▸'}</span>
800
+ </button>
801
+ {showCacheMenu && (
802
+ <div className="pl-6 pr-1 py-0.5 border-l border-[var(--border)] ml-3 mb-1 max-h-48 overflow-y-auto">
803
+ {!cacheInfo && (
804
+ <div className="text-[10px] text-[var(--text-secondary)] py-1 px-2">Loading…</div>
805
+ )}
806
+ {cacheInfo && cacheInfo.entries.length === 0 && (
807
+ <div className="text-[10px] text-[var(--text-secondary)] py-1 px-2">Empty.</div>
808
+ )}
809
+ {cacheInfo?.entries.map((e) => (
810
+ <button
811
+ key={e.name}
812
+ disabled={cacheBusy}
813
+ onClick={() => clearCache(e.name)}
814
+ className="w-full text-left px-2 py-1 text-[10px] text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)] flex items-center gap-2 disabled:opacity-50"
815
+ title={`Click to delete ${e.name}`}
816
+ >
817
+ <span className="flex-1 truncate">{e.name}</span>
818
+ <span className="text-[9px] text-[var(--text-secondary)] flex-shrink-0">{humanBytes(e.bytes)}</span>
819
+ </button>
820
+ ))}
821
+ {cacheInfo && cacheInfo.entries.length > 0 && (
822
+ <button
823
+ disabled={cacheBusy}
824
+ onClick={() => clearCache()}
825
+ className="w-full text-left px-2 py-1 mt-1 text-[10px] text-red-400 hover:bg-[var(--bg-tertiary)] disabled:opacity-50 border-t border-[var(--border)]"
826
+ >
827
+ Clear All ({humanBytes(cacheInfo.total_bytes)})
828
+ </button>
829
+ )}
830
+ </div>
831
+ )}
737
832
  <div className="border-t border-[var(--border)] my-1" />
738
833
  <button
739
834
  onClick={() => { setShowHelp(v => !v); setShowUserMenu(false); }}
@@ -143,7 +143,7 @@ export default function EnterpriseBadge({ onOpenSettings: _onOpenSettings }: { o
143
143
 
144
144
  const loadWebSessions = async () => {
145
145
  try {
146
- const r = await fetch('/api/login-status');
146
+ const r = await fetch('/api/web-sessions');
147
147
  if (!r.ok) return;
148
148
  const data = await r.json();
149
149
  ingestLoginRows((data?.rows || []) as LoginRow[]);
@@ -154,7 +154,7 @@ export default function EnterpriseBadge({ onOpenSettings: _onOpenSettings }: { o
154
154
  if (webBusy) return;
155
155
  setWebBusy(true); setWebErr('');
156
156
  try {
157
- const r = await fetch('/api/login-status', { method: 'POST' });
157
+ const r = await fetch('/api/web-sessions', { method: 'POST' });
158
158
  const data = await r.json();
159
159
  if (!r.ok || data?.error) {
160
160
  setWebErr(data?.error || `HTTP ${r.status}`);
@@ -15,11 +15,17 @@ function resolveAbsoluteBin(cmd: string): string {
15
15
  if (cached !== undefined) return cached;
16
16
  try {
17
17
  const out = execSync(`which ${cmd}`, { encoding: 'utf-8', timeout: 3000, stdio: ['pipe', 'pipe', 'pipe'] }).trim();
18
- const resolved = out || cmd;
19
- _whichCache.set(cmd, resolved);
20
- return resolved;
21
- } catch {
22
- _whichCache.set(cmd, cmd);
18
+ if (out) {
19
+ _whichCache.set(cmd, out);
20
+ return out;
21
+ }
22
+ // `which` exited 0 but printed nothing — don't poison cache, retry next call.
23
+ return cmd;
24
+ } catch (e) {
25
+ // Restart-window contention can blow past the 3s timeout. Caching the
26
+ // failure here would pin bare `cmd` for the rest of the process — let
27
+ // the next call retry instead.
28
+ console.warn(`[agents] which ${cmd} failed:`, (e as Error).message);
23
29
  return cmd;
24
30
  }
25
31
  }
@@ -225,6 +231,10 @@ export function getAgent(id?: AgentId): AgentAdapter {
225
231
  /** Clear adapter cache (call after settings change) */
226
232
  export function clearAgentCache(): void {
227
233
  adapterCache.clear();
234
+ // Also drop the `which <bin>` resolution cache. Without this, a failed
235
+ // resolution during the restart window stayed in cache and every later
236
+ // terminal-launch returned bare `claude`.
237
+ _whichCache.clear();
228
238
  }
229
239
 
230
240
  /** Auto-detect all available agents (called on startup) */
@@ -47,8 +47,15 @@ export interface IdpLoginResult {
47
47
  error?: string;
48
48
  }
49
49
 
50
- interface IdpTemplateBlock {
50
+ export interface IdpTemplateBlock {
51
51
  host?: string;
52
+ /** Optional display name override for the Login Status panel.
53
+ * Falls back to `host` when missing. */
54
+ display_name?: string;
55
+ /** Optional explicit URL the Login Status probe opens. Defaults to
56
+ * `https://<host>/`. Override when the IdP's logged-in landing page
57
+ * is at a specific path (e.g. `/saml-idp/portal/` for FAC). */
58
+ probe_url?: string;
52
59
  /** Optional alt hostnames the IdP also uses (e.g. regional SAML servers). */
53
60
  alt_hosts?: string[];
54
61
  saml_sps?: string[];
@@ -83,7 +90,7 @@ interface IdpTemplateBlock {
83
90
  * (legacy) or an array of objects (multi-IdP). Returns normalized
84
91
  * array of valid blocks.
85
92
  */
86
- function readIdpBlocks(): IdpTemplateBlock[] {
93
+ export function readIdpBlocks(): IdpTemplateBlock[] {
87
94
  const tryRead = (sourceId?: string): IdpTemplateBlock[] | null => {
88
95
  const t = resolveWizardTemplate(sourceId);
89
96
  const raw = (t?.template as { _idp?: IdpTemplateBlock | IdpTemplateBlock[] } | undefined)?._idp;
@@ -109,13 +116,8 @@ function readIdpBlocks(): IdpTemplateBlock[] {
109
116
  * 1. A SP whose login-status cache currently shows ✗ — opening it forces
110
117
  * a SAML redirect to the IdP, which is exactly what we want when the
111
118
  * IdP cookie has expired even though some sibling SPs still have a
112
- * valid session cookie (Mantis can be ✓ while pmdb/tp are ✗ because
113
- * per-SP cookies are independent of the IdP SSO state).
114
- * 2. Otherwise first installed SP with a base_url (legacy fallback).
115
- *
116
- * Without (1) the IdP login flow would open Mantis (first in saml_sps),
117
- * see the cookie still good, declare "no form needed", and exit — leaving
118
- * the broken siblings unrefreshed.
119
+ * valid session cookie.
120
+ * 2. Otherwise first installed SP with a base_url.
119
121
  */
120
122
  function pickTriggerUrl(saml_sps: string[]): string | null {
121
123
  // eslint-disable-next-line @typescript-eslint/no-require-imports
@@ -20,10 +20,10 @@
20
20
  import { existsSync, readFileSync, writeFileSync } from 'node:fs';
21
21
  import { join } from 'node:path';
22
22
  import { getDataDir } from '../dirs';
23
+ import { runConnectorTest, runBrowserUrlProbe } from '../connectors/test-runner';
23
24
  import { listConnectorIds, getConnector, getInstalledConnector } from '../connectors/registry';
24
- import { runConnectorTest } from '../connectors/test-runner';
25
25
  import { expandSettingsTokens } from '../plugins/templates';
26
- import type { ConnectorDefinition } from '../connectors/types';
26
+ import { readIdpBlocks, type IdpTemplateBlock } from './idp-login';
27
27
 
28
28
  export type LoginCategory = 'browser' | 'token' | 'external';
29
29
 
@@ -112,20 +112,14 @@ export function invalidateCachedResult(sourceId: string): void {
112
112
  // ─── Source enumeration ─────────────────────────────────────────────
113
113
 
114
114
  /**
115
- * Build the canonical list of LoginSources. Browser/HTTP sources come
116
- * from connector manifests; external sources are hardcoded here.
117
- *
118
- * Connector-derived rows are filtered to those with `test:` declared —
119
- * connectors without a test block (e.g. `nac` which uses per-call SSH
120
- * password) are intentionally omitted.
115
+ * Build the canonical list of LoginSources from connector manifests.
116
+ * Connector-derived rows are filtered to those with `test:` declared and
117
+ * actually installed (so an uninstalled connector with empty creds
118
+ * doesn't pollute the panel with permanent 401s).
121
119
  */
122
120
  export function listSources(): LoginSource[] {
123
121
  const out: LoginSource[] = [];
124
122
 
125
- // 1. Connector-derived — skip any connector the user hasn't installed
126
- // (no entry in connector-configs.json). An un-installed connector
127
- // would just probe with default/empty settings and always fail,
128
- // polluting the panel.
129
123
  for (const id of listConnectorIds()) {
130
124
  const def = getConnector(id);
131
125
  if (!def || !def.test) continue;
@@ -133,25 +127,17 @@ export function listSources(): LoginSource[] {
133
127
  const probe = def.test.probe || 'http';
134
128
  const category: LoginCategory = probe === 'browser' ? 'browser' : 'token';
135
129
 
136
- // Refresh hint per category
137
130
  let refresh: RefreshHint;
138
131
  if (probe === 'browser') {
139
- // For browser sources, host_match (e.g. "{base_url}/*") needs
140
- // settings tokens expanded — the user-saved {base_url} lives in
141
- // the connector's installed config, not in plain settings.
142
132
  const rawHm = def.host_match || def.connectors?.[0]?.host_match || '';
143
133
  const inst = getInstalledConnector(id);
144
134
  const expanded = inst ? expandSettingsTokens(rawHm, inst.config as any) : rawHm;
145
- // Strip Chrome match-pattern suffix like `/*`
146
135
  const url = expanded.replace(/\/\*$/, '/');
147
- // Heuristic: if expansion left literal `{...}` tokens behind,
148
- // settings are incomplete — fall back to opening Settings.
149
136
  const stillUnresolved = /\{[^}]+\}/.test(url) || !/^https?:\/\//.test(url);
150
137
  refresh = stillUnresolved
151
138
  ? { kind: 'open-settings', section: 'connectors' }
152
139
  : { kind: 'open-url', url, description: 'Open in a new tab to log in' };
153
140
  } else {
154
- // HTTP/token — fix by editing connector settings (PAT, API token)
155
141
  refresh = { kind: 'open-settings', section: 'connectors' };
156
142
  }
157
143
 
@@ -164,12 +150,6 @@ export function listSources(): LoginSource[] {
164
150
  });
165
151
  }
166
152
 
167
- // GitLab 2FA used to be an external row here, but `2fa_verify` is
168
- // interactive auth (not a probe) and bare SSH greet doesn't catch
169
- // expiry. Removed in favour of a static hint on the GitLab
170
- // connector itself — user copies the command when they actually
171
- // hit a 2FA expiry instead of being nagged proactively.
172
-
173
153
  return out;
174
154
  }
175
155
 
@@ -185,6 +165,8 @@ export async function checkSource(source: LoginSource): Promise<LoginCheckResult
185
165
 
186
166
  if (source.id.startsWith('connector:')) {
187
167
  r = await checkConnector(source.id.slice('connector:'.length));
168
+ } else if (source.id.startsWith('idp:')) {
169
+ r = await checkIdp(source.id.slice('idp:'.length));
188
170
  } else {
189
171
  r = { ok: false, message: `unknown source ${source.id}` };
190
172
  }
@@ -201,8 +183,6 @@ export async function checkSource(source: LoginSource): Promise<LoginCheckResult
201
183
  async function checkConnector(
202
184
  connectorId: string,
203
185
  ): Promise<Omit<LoginCheckResult, 'checked_at' | 'duration_ms'>> {
204
- // Direct lib call — self-fetching the route hits the auth middleware
205
- // (no session cookie) and would return "unauthorized" for every probe.
206
186
  try {
207
187
  const r = await runConnectorTest(connectorId);
208
188
  return {
@@ -215,6 +195,57 @@ async function checkConnector(
215
195
  }
216
196
  }
217
197
 
198
+ // ─── IdP-consolidated sources (Enterprise badge "Web sessions") ────
199
+ // The right-side panel iterates per-connector via listSources(); the
200
+ // Enterprise badge wants a coarser view that asks "is the IdP cookie
201
+ // still alive?" — one row per _idp block, not one per SP.
202
+
203
+ function idpProbeUrl(block: IdpTemplateBlock): string {
204
+ return (block.probe_url && block.probe_url.trim()) || `https://${block.host}/`;
205
+ }
206
+
207
+ export function listIdpSources(): LoginSource[] {
208
+ const out: LoginSource[] = [];
209
+ for (const block of readIdpBlocks()) {
210
+ if (!block.host) continue;
211
+ out.push({
212
+ id: `idp:${block.host}`,
213
+ label: block.display_name || block.host,
214
+ category: 'browser',
215
+ detail: block.saml_sps?.length
216
+ ? `IdP for: ${block.saml_sps.join(', ')}`
217
+ : 'Identity provider',
218
+ refresh: {
219
+ kind: 'open-url',
220
+ url: idpProbeUrl(block),
221
+ description: 'Open IdP portal to re-authenticate',
222
+ },
223
+ });
224
+ }
225
+ return out;
226
+ }
227
+
228
+ export function listIdpStatus(): LoginStatusRow[] {
229
+ return listIdpSources().map((source) => ({ source, result: getCachedResult(source.id) }));
230
+ }
231
+
232
+ async function checkIdp(
233
+ host: string,
234
+ ): Promise<Omit<LoginCheckResult, 'checked_at' | 'duration_ms'>> {
235
+ const block = readIdpBlocks().find((b) => b.host === host);
236
+ const probe_url = block ? idpProbeUrl(block) : `https://${host}/`;
237
+ try {
238
+ const r = await runBrowserUrlProbe({ id: `idp:${host}`, host, probe_url });
239
+ return {
240
+ ok: !!r.ok,
241
+ message: r.ok ? (r.message || 'ok') : (r.error || `HTTP ${r.status || '?'}`),
242
+ landed_url: r.url,
243
+ };
244
+ } catch (e) {
245
+ return { ok: false, message: `probe error: ${(e as Error).message}` };
246
+ }
247
+ }
248
+
218
249
  // ─── Bulk view ──────────────────────────────────────────────────────
219
250
 
220
251
  export function listStatus(): LoginStatusRow[] {
@@ -270,6 +270,54 @@ async function runBrowserProbe(
270
270
  };
271
271
  }
272
272
 
273
+ /**
274
+ * Run a browser probe against an ad-hoc URL — used by the Login Status
275
+ * panel's IdP rows (no connector backing them). Reuses the same
276
+ * `connector.probe` bridge RPC as runBrowserProbe — the extension
277
+ * opens host_match in a tab, waits for navigation, returns landed URL.
278
+ * If the landed URL host matches `expected_host`, the user is still
279
+ * signed in. If it redirected to a login page (different host or path),
280
+ * not signed in.
281
+ */
282
+ export async function runBrowserUrlProbe(opts: {
283
+ id: string; // identifier for telemetry, e.g. 'idp:fac.corp.fortinet.com'
284
+ host: string; // expected host (e.g. 'fac.corp.fortinet.com')
285
+ probe_url: string; // URL to navigate to (e.g. 'https://fac.corp.fortinet.com/saml-idp/portal/')
286
+ timeout_ms?: number;
287
+ }): Promise<TestResult> {
288
+ const t0 = Date.now();
289
+ let value: unknown;
290
+ try {
291
+ value = await bridgeRpc('connector.probe', {
292
+ pluginId: opts.id,
293
+ host_match: opts.probe_url, // extension navigates to this URL directly
294
+ runner: 'main',
295
+ timeout_ms: opts.timeout_ms || 15_000,
296
+ });
297
+ } catch (e) {
298
+ return { ok: false, error: (e as Error).message || 'probe error', duration_ms: Date.now() - t0 };
299
+ }
300
+ const r = (value || {}) as { ok?: boolean; url?: string; error?: string };
301
+ const landed = r.url || '';
302
+ const failedNetwork = !landed || landed.startsWith('chrome-error://') || landed.startsWith('about:');
303
+ // Pass if landed host matches expected. A redirect to a different host
304
+ // (e.g. fac.corp.fortinet.com → ms-login.fortinet.com when SSO expired)
305
+ // means the user needs to re-authenticate.
306
+ let onExpected = false;
307
+ try { onExpected = !failedNetwork && new URL(landed).hostname.toLowerCase() === opts.host.toLowerCase(); } catch { /* bad url */ }
308
+ return {
309
+ ok: onExpected,
310
+ message: onExpected ? `Session active · ${landed}` : undefined,
311
+ error: onExpected
312
+ ? undefined
313
+ : (failedNetwork
314
+ ? `Network unreachable — ${landed || '(no url)'}. VPN / hostname / firewall?`
315
+ : `Not signed in — redirected to ${landed}`),
316
+ url: landed,
317
+ duration_ms: Date.now() - t0,
318
+ };
319
+ }
320
+
273
321
  /**
274
322
  * Run a connector's `test:` probe. Returns a structured result. The HTTP
275
323
  * route + Login Status panel both call this — they share the same
package/lib/pipeline.ts CHANGED
@@ -1792,7 +1792,13 @@ async function scheduleReadyNodes(pipeline: Pipeline, workflow: Workflow) {
1792
1792
  // declared, skip the auto-worktree creation entirely — otherwise we
1793
1793
  // produce dead `.forge/worktrees/pipeline-<id>` directories that
1794
1794
  // nothing ever uses but pile up on disk per pipeline run.
1795
- const useWorktree = nodeDef.worktree !== false && !nodeDef.workdir;
1795
+ //
1796
+ // Also skip on non-git projects (scratch, or any user project where
1797
+ // `.git` is missing). Pipelines without a project bound default to
1798
+ // the scratch project, which has hasGit:false — without this guard
1799
+ // the worktree branch fired `git branch` / `git worktree add` on a
1800
+ // non-repo and surfaced "fatal: not a git repository" in the run log.
1801
+ const useWorktree = nodeDef.worktree !== false && !nodeDef.workdir && projectInfo.hasGit;
1796
1802
  const branchName = nodeDef.branch ? resolveTemplate(nodeDef.branch, ctx) : `pipeline/${pipeline.id.slice(0, 8)}`;
1797
1803
  if (useWorktree) try {
1798
1804
  const wtRoot = getProjectWorktreeRoot(projectInfo);
package/lib/projects.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { readdirSync, existsSync, statSync, readFileSync, mkdirSync, writeFileSync } from 'node:fs';
2
+ import { execSync } from 'node:child_process';
2
3
  import { join, resolve, isAbsolute, parse } from 'node:path';
3
4
  import { homedir } from 'node:os';
4
5
  import { loadSettings, saveSettings } from './settings';
@@ -178,7 +179,7 @@ export function getProjectInfo(name: string): LocalProject | null {
178
179
  */
179
180
  export interface ResolveResult {
180
181
  project: LocalProject;
181
- source: 'existing' | 'cloned' | 'scratch';
182
+ source: 'existing' | 'cloned' | 'gitlab-cloned' | 'scratch';
182
183
  clone_url?: string;
183
184
  }
184
185
 
@@ -206,6 +207,95 @@ export function getDefaultCloneRoot(): string {
206
207
  return join(getDataDir(), 'scratch');
207
208
  }
208
209
 
210
+ /** Read the gitlab connector config without pulling in the connectors
211
+ * module statically — projects.ts is imported very early by init/dirs;
212
+ * going through a dynamic require keeps the dependency graph one-way.
213
+ * Returns null when the connector isn't installed/configured. */
214
+ function readGitlabConnector(): { base_url: string; token: string; default_project_path?: string } | null {
215
+ try {
216
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
217
+ const { getInstalledConnector } = require('./connectors/registry') as typeof import('./connectors/registry');
218
+ const inst = getInstalledConnector('gitlab');
219
+ if (!inst) return null;
220
+ const cfg = (inst.config || {}) as { base_url?: string; token?: string; default_project_path?: string };
221
+ if (!cfg.base_url || !cfg.token) return null;
222
+ return {
223
+ base_url: String(cfg.base_url).replace(/\/+$/, ''),
224
+ token: String(cfg.token),
225
+ default_project_path: cfg.default_project_path,
226
+ };
227
+ } catch { return null; }
228
+ }
229
+
230
+ /** Per-process freshness cache for cloned repos. Within this window the
231
+ * same repo path skips the `git fetch` step and reuses the on-disk
232
+ * state. Pipelines call resolveOrCloneProject once per node per
233
+ * for_each iteration — without this, a 5-node × 10-item run does 50
234
+ * fetches (~50s of network) before any real work starts. */
235
+ const CLONE_FRESH_WINDOW_MS = 5 * 60 * 1000;
236
+ const _cloneFreshAt = new Map<string, number>();
237
+
238
+ /** Try to clone (or fast-update) a gitlab project into a Forge-managed
239
+ * cache dir. Returns the cached LocalProject on success, null when the
240
+ * connector isn't configured or git fails. Used by resolveOrCloneProject
241
+ * when no real local project matches the requested name.
242
+ *
243
+ * Cache layout: <dataDir>/cloned-projects/<owner>-<repo>/
244
+ * Within the per-process freshness window: skip network entirely.
245
+ * Past it (or on first call): `git fetch` to refresh refs. */
246
+ function tryGitlabClone(repoPath: string): LocalProject | null {
247
+ const gl = readGitlabConnector();
248
+ if (!gl) return null;
249
+ const path = repoPath.replace(/^\/+|\/+$/g, '');
250
+ if (!path || !path.includes('/')) return null; // not a repo path shape
251
+ const safe = path.replace(/[^A-Za-z0-9._-]+/g, '-');
252
+ const cacheRoot = join(getDataDir(), 'cloned-projects');
253
+ const target = join(cacheRoot, safe);
254
+ try { mkdirSync(cacheRoot, { recursive: true }); } catch {}
255
+
256
+ // Inject PAT into URL. gitlab accepts `oauth2:<token>` basic auth on
257
+ // HTTPS. URL-encode the token in case it contains '@' or '/'.
258
+ const cloneUrl = `${gl.base_url}/${path}.git`;
259
+ const authedUrl = cloneUrl.replace(/^(https?:\/\/)/, `$1oauth2:${encodeURIComponent(gl.token)}@`);
260
+
261
+ const alreadyCloned = existsSync(join(target, '.git'));
262
+ const lastFreshAt = _cloneFreshAt.get(target) || 0;
263
+ const stillFresh = alreadyCloned && (Date.now() - lastFreshAt) < CLONE_FRESH_WINDOW_MS;
264
+
265
+ try {
266
+ if (!alreadyCloned) {
267
+ execSync(`git clone --quiet "${authedUrl}" "${target}"`, { stdio: 'pipe', timeout: 120000 });
268
+ _cloneFreshAt.set(target, Date.now());
269
+ } else if (!stillFresh) {
270
+ // Best-effort refresh; failures (offline, network, auth drift) are
271
+ // non-fatal — fall back to whatever's cached. Pipelines see stale
272
+ // code, not "fatal: not a git repo".
273
+ try {
274
+ execSync(`git -C "${target}" remote set-url origin "${authedUrl}"`, { stdio: 'pipe', timeout: 5000 });
275
+ execSync(`git -C "${target}" fetch --quiet origin`, { stdio: 'pipe', timeout: 30000 });
276
+ } catch { /* keep cached state */ }
277
+ _cloneFreshAt.set(target, Date.now());
278
+ }
279
+ // else: within freshness window — skip network entirely
280
+ } catch (e) {
281
+ console.warn(`[projects] gitlab clone of ${path} failed:`, (e as Error).message);
282
+ return null;
283
+ }
284
+
285
+ let mtime: string;
286
+ try { mtime = statSync(target).mtime.toISOString(); }
287
+ catch { mtime = new Date().toISOString(); }
288
+ return {
289
+ name: path,
290
+ path: target,
291
+ root: cacheRoot,
292
+ hasGit: existsSync(join(target, '.git')),
293
+ hasClaudeMd: existsSync(join(target, 'CLAUDE.md')),
294
+ language: null,
295
+ lastModified: mtime,
296
+ };
297
+ }
298
+
209
299
  export function resolveOrCloneProject(name: string | undefined): ResolveResult {
210
300
  const trimmed = (name || '').trim();
211
301
  if (trimmed) {
@@ -213,14 +303,33 @@ export function resolveOrCloneProject(name: string | undefined): ResolveResult {
213
303
  if (hit) return { project: hit, source: 'existing' };
214
304
  }
215
305
 
216
- // Default to scratch when the name doesn't match a real project root. The
217
- // earlier behavior was to auto-clone from gitlab.default_project_path — but
218
- // that ran a `git clone` in-band (slow, surprising, leaves dirs scattered
219
- // under ~/IdeaProjects). Pipelines now always run inside Forge-managed
220
- // `<dataDir>/scratch/worktrees/pipeline-<id>/` unless the user has
221
- // explicitly added the named project under Settings → Project roots.
222
- // Explicit clone is still available via `POST /api/projects/clone` for
223
- // users who want it.
306
+ // Fallback to gitlab connector when no local match. Two cases worth
307
+ // honouring:
308
+ // 1. `name` itself looks like a gitlab repo path (e.g. "team/repo")
309
+ // caller meant that path literally.
310
+ // 2. `name` is empty / unknown — pull the gitlab connector's
311
+ // `default_project_path` (e.g. "fortinet/fortinac-dev") and use
312
+ // it as the fallback repo.
313
+ // Either way we clone into <dataDir>/cloned-projects/ and reuse on
314
+ // subsequent runs. The previous behaviour silently dropped to scratch
315
+ // and code-fix pipelines exploded with "fatal: not a git repository".
316
+ const gl = readGitlabConnector();
317
+ if (gl) {
318
+ const targetPath = trimmed && trimmed.includes('/')
319
+ ? trimmed
320
+ : (gl.default_project_path || '').trim();
321
+ if (targetPath) {
322
+ const cloned = tryGitlabClone(targetPath);
323
+ if (cloned) return { project: cloned, source: 'gitlab-cloned', clone_url: `${gl.base_url}/${targetPath.replace(/^\/+|\/+$/g, '')}.git` };
324
+ }
325
+ }
326
+
327
+ // Last resort: scratch project. Most non-code pipelines (chat
328
+ // dispatch, mantis-only, notifications) are fine here. Code-fix
329
+ // pipelines that try to run git will surface their own errors —
330
+ // pipeline.ts:1795 already skips framework auto-worktree, so the
331
+ // noise is limited to the pipeline's own shell nodes (which need
332
+ // a real project to make sense).
224
333
  return { project: scratchProject(), source: 'scratch' };
225
334
  }
226
335
 
@@ -306,7 +306,11 @@ async function handleAgentsPost(id: string, body: any, res: ServerResponse): Pro
306
306
  const { resolveTerminalLaunch, clearAgentCache } = await import('./agents/index.js');
307
307
  clearAgentCache(); // ensure fresh settings are read
308
308
  launchInfo = resolveTerminalLaunch(agentConfig.agentId);
309
- } catch {}
309
+ } catch (e) {
310
+ // Silent fallback used to leak bare `claude` to the UI on restart.
311
+ // Log loudly so future regressions surface in forge.log.
312
+ console.warn(`[workspace] open_terminal: resolveTerminalLaunch failed, falling back to bare 'claude':`, (e as Error).message);
313
+ }
310
314
 
311
315
  // resolveOnly: return launch info + current session ID (no side effects)
312
316
  if (body.resolveOnly) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aion0/forge",
3
- "version": "0.10.49",
3
+ "version": "0.10.51",
4
4
  "description": "Unified AI workflow platform — multi-model task orchestration, persistent sessions, web terminal, remote access",
5
5
  "type": "module",
6
6
  "scripts": {