@aion0/forge 0.10.50 → 0.10.53
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 +3 -3
- package/app/api/cache/route.ts +83 -0
- package/app/scratch/[...path]/page.tsx +24 -0
- package/cli/clean.ts +140 -0
- package/cli/mw.mjs +163 -29
- package/cli/mw.ts +7 -0
- package/components/Dashboard.tsx +96 -1
- package/components/ScratchViewer.tsx +141 -0
- package/lib/chat/link-patterns.ts +4 -4
- package/lib/pipeline.ts +7 -1
- package/lib/projects.ts +118 -9
- package/package.json +1 -1
package/RELEASE_NOTES.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
# Forge v0.10.
|
|
1
|
+
# Forge v0.10.53
|
|
2
2
|
|
|
3
3
|
Released: 2026-06-09
|
|
4
4
|
|
|
5
|
-
## Changes since v0.10.
|
|
5
|
+
## Changes since v0.10.52
|
|
6
6
|
|
|
7
7
|
|
|
8
|
-
**Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.
|
|
8
|
+
**Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.52...v0.10.53
|
|
@@ -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,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /scratch/<path...> — in-browser viewer for files under <dataDir>/scratch/.
|
|
3
|
+
*
|
|
4
|
+
* Why this page exists: the raw `/api/scratch/<path>` route returns the
|
|
5
|
+
* file with its proper MIME, which works for images / pdf but is poor UX
|
|
6
|
+
* for markdown — browsers either show raw text or trigger a download
|
|
7
|
+
* (depending on the browser's text/markdown handling).
|
|
8
|
+
*
|
|
9
|
+
* This wrapper renders .md through MarkdownContent (same renderer as
|
|
10
|
+
* chat), and falls back to a plain <pre> for other text formats. It also
|
|
11
|
+
* exposes a one-click download link for any file type.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import ScratchViewer from '@/components/ScratchViewer';
|
|
15
|
+
|
|
16
|
+
export default async function ScratchPage({
|
|
17
|
+
params,
|
|
18
|
+
}: {
|
|
19
|
+
params: Promise<{ path: string[] }>;
|
|
20
|
+
}) {
|
|
21
|
+
const { path } = await params;
|
|
22
|
+
const joined = (path || []).map(encodeURIComponent).join('/');
|
|
23
|
+
return <ScratchViewer path={joined} />;
|
|
24
|
+
}
|
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
|
|
761
|
+
const dataDir3 = join3(configDir, "data");
|
|
762
762
|
const oldSettings = join3(configDir, "settings.yaml");
|
|
763
|
-
const newSettings = join3(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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:
|
|
1360
|
+
const { join: join9, dirname } = await import("node:path");
|
|
1232
1361
|
const { fileURLToPath } = await import("node:url");
|
|
1233
|
-
const pkg = JSON.parse(readFileSync5(
|
|
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:
|
|
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(
|
|
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:
|
|
1409
|
+
const { join: join9, dirname } = await import("node:path");
|
|
1281
1410
|
const { fileURLToPath } = await import("node:url");
|
|
1282
|
-
const serverScript =
|
|
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:
|
|
1418
|
+
const { join: join9, dirname } = await import("node:path");
|
|
1290
1419
|
const { fileURLToPath } = await import("node:url");
|
|
1291
|
-
const serverScript =
|
|
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:
|
|
1610
|
-
const { join:
|
|
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
|
|
1613
|
-
const codeFile =
|
|
1746
|
+
const dataDir3 = _gdd();
|
|
1747
|
+
const codeFile = join9(dataDir3, "session-code.json");
|
|
1614
1748
|
try {
|
|
1615
|
-
if (
|
|
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(
|
|
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:
|
|
1809
|
+
const { join: join9, dirname } = await import("node:path");
|
|
1676
1810
|
const { fileURLToPath } = await import("node:url");
|
|
1677
|
-
const serverScript =
|
|
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:
|
|
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(
|
|
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 " +
|
|
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:
|
|
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:
|
|
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:
|
|
1774
|
-
const pkg = JSON.parse(readFileSync5(
|
|
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
|
package/components/Dashboard.tsx
CHANGED
|
@@ -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={() => {
|
|
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); }}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from 'react';
|
|
4
|
+
import MarkdownContent from './MarkdownContent';
|
|
5
|
+
|
|
6
|
+
const TEXT_LIKE = new Set(['md', 'txt', 'log', 'json', 'yaml', 'yml', 'csv', 'html']);
|
|
7
|
+
const IMAGE_LIKE = new Set(['png', 'jpg', 'jpeg', 'gif', 'svg']);
|
|
8
|
+
const EMBED_LIKE = new Set(['pdf']);
|
|
9
|
+
|
|
10
|
+
/** Cap inline text rendering. Anything larger falls back to a download
|
|
11
|
+
* prompt — react-markdown on multi-MB strings locks the tab. */
|
|
12
|
+
const MAX_INLINE_BYTES = 2 * 1024 * 1024;
|
|
13
|
+
|
|
14
|
+
function extOf(p: string): string {
|
|
15
|
+
const m = p.match(/\.([^./]+)$/);
|
|
16
|
+
return m ? m[1].toLowerCase() : '';
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export default function ScratchViewer({ path }: { path: string }) {
|
|
20
|
+
const decoded = (() => {
|
|
21
|
+
try {
|
|
22
|
+
return decodeURIComponent(path);
|
|
23
|
+
} catch {
|
|
24
|
+
return path;
|
|
25
|
+
}
|
|
26
|
+
})();
|
|
27
|
+
const ext = extOf(decoded);
|
|
28
|
+
const rawUrl = `/api/scratch/${path}`;
|
|
29
|
+
const downloadUrl = `${rawUrl}?download=1`;
|
|
30
|
+
|
|
31
|
+
const [text, setText] = useState<string | null>(null);
|
|
32
|
+
const [err, setErr] = useState<string>('');
|
|
33
|
+
const [tooLarge, setTooLarge] = useState(false);
|
|
34
|
+
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
if (!TEXT_LIKE.has(ext)) return;
|
|
37
|
+
let cancelled = false;
|
|
38
|
+
(async () => {
|
|
39
|
+
try {
|
|
40
|
+
const r = await fetch(rawUrl);
|
|
41
|
+
if (!r.ok) {
|
|
42
|
+
if (!cancelled) setErr(`${r.status} ${r.statusText}`);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
const len = Number(r.headers.get('content-length') || '0');
|
|
46
|
+
if (len > MAX_INLINE_BYTES) {
|
|
47
|
+
if (!cancelled) setTooLarge(true);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
const body = await r.text();
|
|
51
|
+
if (cancelled) return;
|
|
52
|
+
if (body.length > MAX_INLINE_BYTES) {
|
|
53
|
+
setTooLarge(true);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
setText(body);
|
|
57
|
+
} catch (e) {
|
|
58
|
+
if (!cancelled) setErr((e as Error).message);
|
|
59
|
+
}
|
|
60
|
+
})();
|
|
61
|
+
return () => {
|
|
62
|
+
cancelled = true;
|
|
63
|
+
};
|
|
64
|
+
}, [rawUrl, ext]);
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<div className="min-h-screen bg-[var(--bg-primary)] text-[var(--text-primary)]">
|
|
68
|
+
<header className="sticky top-0 z-10 flex items-center justify-between gap-2 px-4 py-2 border-b border-[var(--border)] bg-[var(--bg-secondary)]">
|
|
69
|
+
<div className="flex items-center gap-2 min-w-0">
|
|
70
|
+
<span className="text-[10px] uppercase tracking-wide text-[var(--text-secondary)]">scratch</span>
|
|
71
|
+
<span className="text-xs font-mono truncate" title={decoded}>
|
|
72
|
+
{decoded}
|
|
73
|
+
</span>
|
|
74
|
+
</div>
|
|
75
|
+
<div className="flex items-center gap-2 flex-shrink-0">
|
|
76
|
+
<a
|
|
77
|
+
href={rawUrl}
|
|
78
|
+
className="text-xs px-2 py-1 rounded border border-[var(--border)] hover:bg-[var(--bg-tertiary)]"
|
|
79
|
+
target="_blank"
|
|
80
|
+
rel="noopener"
|
|
81
|
+
>
|
|
82
|
+
Open raw
|
|
83
|
+
</a>
|
|
84
|
+
<a
|
|
85
|
+
href={downloadUrl}
|
|
86
|
+
className="text-xs px-2 py-1 rounded bg-[var(--accent)] text-white hover:opacity-90"
|
|
87
|
+
>
|
|
88
|
+
Download
|
|
89
|
+
</a>
|
|
90
|
+
</div>
|
|
91
|
+
</header>
|
|
92
|
+
|
|
93
|
+
<main className="px-4 py-3 max-w-4xl mx-auto">
|
|
94
|
+
{err && (
|
|
95
|
+
<div className="text-xs text-red-400 border border-red-400/40 rounded p-2 bg-red-400/5">
|
|
96
|
+
Failed to load: {err}
|
|
97
|
+
</div>
|
|
98
|
+
)}
|
|
99
|
+
|
|
100
|
+
{tooLarge && (
|
|
101
|
+
<div className="text-xs text-[var(--text-secondary)] border border-[var(--border)] rounded p-3">
|
|
102
|
+
File is larger than {Math.round(MAX_INLINE_BYTES / 1024 / 1024)} MB — inline preview skipped. Use Download or Open raw.
|
|
103
|
+
</div>
|
|
104
|
+
)}
|
|
105
|
+
|
|
106
|
+
{!err && !tooLarge && IMAGE_LIKE.has(ext) && (
|
|
107
|
+
<img src={rawUrl} alt={decoded} className="max-w-full rounded border border-[var(--border)]" />
|
|
108
|
+
)}
|
|
109
|
+
|
|
110
|
+
{!err && !tooLarge && EMBED_LIKE.has(ext) && (
|
|
111
|
+
<embed src={rawUrl} type="application/pdf" className="w-full h-[calc(100vh-60px)]" />
|
|
112
|
+
)}
|
|
113
|
+
|
|
114
|
+
{!err && !tooLarge && TEXT_LIKE.has(ext) && text != null && (
|
|
115
|
+
ext === 'md' ? (
|
|
116
|
+
<MarkdownContent content={text} />
|
|
117
|
+
) : ext === 'html' ? (
|
|
118
|
+
// Wrap in iframe srcdoc so any embedded scripts can't reach Forge's
|
|
119
|
+
// origin or steal session cookies. sandbox blocks everything.
|
|
120
|
+
<iframe
|
|
121
|
+
srcDoc={text}
|
|
122
|
+
sandbox=""
|
|
123
|
+
className="w-full h-[calc(100vh-60px)] bg-white rounded border border-[var(--border)]"
|
|
124
|
+
title={decoded}
|
|
125
|
+
/>
|
|
126
|
+
) : (
|
|
127
|
+
<pre className="text-[12px] font-mono text-[var(--text-primary)] bg-[var(--bg-tertiary)] border border-[var(--border)] rounded p-3 overflow-auto whitespace-pre-wrap break-words" style={{ fontFamily: 'Menlo, Monaco, "Courier New", monospace' }}>
|
|
128
|
+
{text}
|
|
129
|
+
</pre>
|
|
130
|
+
)
|
|
131
|
+
)}
|
|
132
|
+
|
|
133
|
+
{!err && !tooLarge && !TEXT_LIKE.has(ext) && !IMAGE_LIKE.has(ext) && !EMBED_LIKE.has(ext) && (
|
|
134
|
+
<div className="text-xs text-[var(--text-secondary)] border border-[var(--border)] rounded p-3">
|
|
135
|
+
No inline preview for <code className="font-mono">.{ext || '?'}</code> files. Use Download.
|
|
136
|
+
</div>
|
|
137
|
+
)}
|
|
138
|
+
</main>
|
|
139
|
+
</div>
|
|
140
|
+
);
|
|
141
|
+
}
|
|
@@ -83,13 +83,13 @@ export const LINK_PATTERNS: LinkPattern[] = [
|
|
|
83
83
|
},
|
|
84
84
|
// Forge scratch-dir files. LLMs frequently emit paths like
|
|
85
85
|
// `scratch/foo.md` when they write reports during chat-launched tasks.
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
//
|
|
86
|
+
// Link to the in-browser viewer at /scratch/<path> (page renders .md
|
|
87
|
+
// through the chat markdown component + download button); the viewer
|
|
88
|
+
// itself fetches /api/scratch/<path> for raw bytes.
|
|
89
89
|
{
|
|
90
90
|
id: 'scratch-file',
|
|
91
91
|
regex: /\bscratch\/([\w\-./]+?\.(?:md|txt|json|yaml|yml|csv|log|html|pdf|png|jpg|jpeg|gif|svg))\b/gi,
|
|
92
|
-
url: '/
|
|
92
|
+
url: '/scratch/{1}',
|
|
93
93
|
label: 'scratch/{1}',
|
|
94
94
|
},
|
|
95
95
|
];
|
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
|
-
|
|
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
|
-
//
|
|
217
|
-
//
|
|
218
|
-
//
|
|
219
|
-
//
|
|
220
|
-
//
|
|
221
|
-
//
|
|
222
|
-
//
|
|
223
|
-
//
|
|
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
|
|
package/package.json
CHANGED