@aion0/forge 0.2.1 → 0.2.2

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.
@@ -123,12 +123,39 @@ export async function GET(req: Request) {
123
123
  }
124
124
  try {
125
125
  const stat = statSync(fullPath);
126
- if (stat.size > 500_000) {
127
- return NextResponse.json({ content: '// File too large to display', language: 'text' });
126
+ const ext = extname(fullPath).replace('.', '').toLowerCase();
127
+ const size = stat.size;
128
+ const sizeKB = Math.round(size / 1024);
129
+ const sizeMB = (size / (1024 * 1024)).toFixed(1);
130
+
131
+ // Binary/unsupported file types
132
+ const BINARY_EXTS = new Set([
133
+ 'png', 'jpg', 'jpeg', 'gif', 'bmp', 'ico', 'webp', 'svg', 'avif',
134
+ 'mp3', 'mp4', 'wav', 'ogg', 'webm', 'mov', 'avi',
135
+ 'zip', 'gz', 'tar', 'bz2', 'xz', '7z', 'rar',
136
+ 'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx',
137
+ 'exe', 'dll', 'so', 'dylib', 'bin', 'o', 'a',
138
+ 'woff', 'woff2', 'ttf', 'eot', 'otf',
139
+ 'sqlite', 'db', 'sqlite3',
140
+ 'class', 'jar', 'war',
141
+ 'pyc', 'pyo', 'wasm',
142
+ ]);
143
+ if (BINARY_EXTS.has(ext)) {
144
+ return NextResponse.json({ binary: true, fileType: ext, size, sizeLabel: sizeKB > 1024 ? `${sizeMB} MB` : `${sizeKB} KB` });
128
145
  }
146
+
147
+ const force = searchParams.get('force') === '1';
148
+
149
+ // Large file warning (> 200KB needs confirmation, > 2MB blocked)
150
+ if (size > 2_000_000) {
151
+ return NextResponse.json({ tooLarge: true, size, sizeLabel: `${sizeMB} MB`, message: 'File exceeds 2 MB limit' });
152
+ }
153
+ if (size > 200_000 && !force) {
154
+ return NextResponse.json({ large: true, size, sizeLabel: `${sizeKB} KB`, language: ext });
155
+ }
156
+
129
157
  const content = readFileSync(fullPath, 'utf-8');
130
- const ext = extname(fullPath).replace('.', '') || 'text';
131
- return NextResponse.json({ content, language: ext });
158
+ return NextResponse.json({ content, language: ext, size });
132
159
  } catch {
133
160
  return NextResponse.json({ error: 'File not found' }, { status: 404 });
134
161
  }
@@ -8,9 +8,12 @@ interface FileNode {
8
8
  name: string;
9
9
  path: string; // relative to docRoot
10
10
  type: 'file' | 'dir';
11
+ fileType?: 'md' | 'image' | 'other';
11
12
  children?: FileNode[];
12
13
  }
13
14
 
15
+ const IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.bmp', '.ico', '.avif']);
16
+
14
17
  function scanDir(dir: string, base: string, depth: number = 0): FileNode[] {
15
18
  if (depth > 6) return [];
16
19
  try {
@@ -35,8 +38,15 @@ function scanDir(dir: string, base: string, depth: number = 0): FileNode[] {
35
38
  if (children.length > 0) {
36
39
  nodes.push({ name: entry.name, path: relPath, type: 'dir', children });
37
40
  }
38
- } else if (extname(entry.name) === '.md') {
39
- nodes.push({ name: entry.name, path: relPath, type: 'file' });
41
+ } else {
42
+ const ext = extname(entry.name).toLowerCase();
43
+ if (ext === '.md') {
44
+ nodes.push({ name: entry.name, path: relPath, type: 'file', fileType: 'md' });
45
+ } else if (IMAGE_EXTS.has(ext)) {
46
+ nodes.push({ name: entry.name, path: relPath, type: 'file', fileType: 'image' });
47
+ } else if (!entry.name.startsWith('.')) {
48
+ nodes.push({ name: entry.name, path: relPath, type: 'file', fileType: 'other' });
49
+ }
40
50
  }
41
51
  }
42
52
  return nodes;
@@ -60,15 +70,50 @@ export async function GET(req: Request) {
60
70
 
61
71
  const rootNames = docRoots.map(r => r.split('/').pop() || r);
62
72
 
73
+ // Serve image
74
+ const imagePath = searchParams.get('image');
75
+ if (imagePath && rootIdx < docRoots.length) {
76
+ const root = docRoots[rootIdx];
77
+ const fullPath = join(root, imagePath);
78
+ if (!fullPath.startsWith(root)) {
79
+ return NextResponse.json({ error: 'Invalid path' }, { status: 400 });
80
+ }
81
+ try {
82
+ const { readFileSync: readBin } = require('node:fs');
83
+ const data = readBin(fullPath);
84
+ const ext = extname(fullPath).toLowerCase();
85
+ const mimeMap: Record<string, string> = {
86
+ '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
87
+ '.gif': 'image/gif', '.svg': 'image/svg+xml', '.webp': 'image/webp',
88
+ '.bmp': 'image/bmp', '.ico': 'image/x-icon', '.avif': 'image/avif',
89
+ };
90
+ return new Response(data, {
91
+ headers: { 'Content-Type': mimeMap[ext] || 'application/octet-stream', 'Cache-Control': 'public, max-age=3600' },
92
+ });
93
+ } catch {
94
+ return NextResponse.json({ error: 'Image not found' }, { status: 404 });
95
+ }
96
+ }
97
+
63
98
  // Read file content
64
99
  if (filePath && rootIdx < docRoots.length) {
65
100
  const root = docRoots[rootIdx];
66
101
  const fullPath = join(root, filePath);
67
- // Security: ensure path doesn't escape root
68
102
  if (!fullPath.startsWith(root)) {
69
103
  return NextResponse.json({ error: 'Invalid path' }, { status: 400 });
70
104
  }
71
105
  try {
106
+ const stat = statSync(fullPath);
107
+ const size = stat.size;
108
+ const sizeKB = Math.round(size / 1024);
109
+ const sizeMB = (size / (1024 * 1024)).toFixed(1);
110
+
111
+ if (size > 2_000_000) {
112
+ return NextResponse.json({ tooLarge: true, size, sizeLabel: `${sizeMB} MB`, message: 'File exceeds 2 MB limit' });
113
+ }
114
+ if (size > 200_000) {
115
+ return NextResponse.json({ large: true, size, sizeLabel: `${sizeKB} KB` });
116
+ }
72
117
  const content = readFileSync(fullPath, 'utf-8');
73
118
  return NextResponse.json({ content });
74
119
  } catch {
@@ -0,0 +1,131 @@
1
+ import { NextResponse, type NextRequest } from 'next/server';
2
+ import { execSync } from 'node:child_process';
3
+ import { existsSync, readdirSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+ import { homedir } from 'node:os';
6
+ import { loadSettings } from '@/lib/settings';
7
+
8
+ function isUnderProjectRoot(dir: string): boolean {
9
+ const settings = loadSettings();
10
+ const roots = (settings.projectRoots || []).map(r => r.replace(/^~/, homedir()));
11
+ return roots.some(root => dir.startsWith(root) || dir === root);
12
+ }
13
+
14
+ function git(cmd: string, cwd: string): string {
15
+ return execSync(`git ${cmd}`, { cwd, encoding: 'utf-8', timeout: 15000 }).trim();
16
+ }
17
+
18
+ // GET /api/git?dir=<path> — git status for a project
19
+ export async function GET(req: NextRequest) {
20
+ const dir = req.nextUrl.searchParams.get('dir');
21
+ if (!dir || !isUnderProjectRoot(dir)) {
22
+ return NextResponse.json({ error: 'Invalid directory' }, { status: 400 });
23
+ }
24
+
25
+ try {
26
+ const branch = git('rev-parse --abbrev-ref HEAD', dir);
27
+ const statusRaw = execSync('git status --porcelain -u', { cwd: dir, encoding: 'utf-8', timeout: 15000 });
28
+ const changes = statusRaw.replace(/\n$/, '').split('\n').filter(Boolean).map(line => ({
29
+ status: line.substring(0, 2).trim() || 'M',
30
+ path: line.substring(3).replace(/\/$/, ''),
31
+ }));
32
+
33
+ let remote = '';
34
+ try { remote = git('remote get-url origin', dir); } catch {}
35
+
36
+ let ahead = 0;
37
+ let behind = 0;
38
+ try {
39
+ const counts = git(`rev-list --left-right --count HEAD...origin/${branch}`, dir);
40
+ const [a, b] = counts.split('\t');
41
+ ahead = parseInt(a) || 0;
42
+ behind = parseInt(b) || 0;
43
+ } catch {}
44
+
45
+ const lastCommit = git('log -1 --format="%h %s" 2>/dev/null || echo ""', dir);
46
+
47
+ // Git log — recent commits
48
+ let log: { hash: string; message: string; author: string; date: string }[] = [];
49
+ try {
50
+ const logOut = git('log --format="%h||%s||%an||%ar" -20', dir);
51
+ log = logOut.split('\n').filter(Boolean).map(line => {
52
+ const [hash, message, author, date] = line.split('||');
53
+ return { hash, message, author, date };
54
+ });
55
+ } catch {}
56
+
57
+ return NextResponse.json({ branch, changes, remote, ahead, behind, lastCommit, log });
58
+ } catch (e: any) {
59
+ return NextResponse.json({ error: e.message }, { status: 500 });
60
+ }
61
+ }
62
+
63
+ // POST /api/git — git operations (commit, push, pull, clone)
64
+ export async function POST(req: NextRequest) {
65
+ const body = await req.json();
66
+ const { action, dir, message, files, repoUrl, targetDir } = body;
67
+
68
+ if (action === 'clone') {
69
+ // Clone a repo into a project root
70
+ if (!repoUrl) return NextResponse.json({ error: 'repoUrl required' }, { status: 400 });
71
+ const settings = loadSettings();
72
+ const roots = (settings.projectRoots || []).map(r => r.replace(/^~/, homedir()));
73
+ const cloneTarget = targetDir || roots[0];
74
+ if (!cloneTarget) return NextResponse.json({ error: 'No project root configured' }, { status: 400 });
75
+
76
+ try {
77
+ const output = execSync(`git clone "${repoUrl}"`, {
78
+ cwd: cloneTarget,
79
+ encoding: 'utf-8',
80
+ timeout: 60000,
81
+ });
82
+ // Extract cloned dir name from URL
83
+ const repoName = repoUrl.split('/').pop()?.replace(/\.git$/, '') || 'repo';
84
+ return NextResponse.json({ ok: true, path: join(cloneTarget, repoName), output });
85
+ } catch (e: any) {
86
+ return NextResponse.json({ error: e.message }, { status: 500 });
87
+ }
88
+ }
89
+
90
+ if (!dir || !isUnderProjectRoot(dir)) {
91
+ return NextResponse.json({ error: 'Invalid directory' }, { status: 400 });
92
+ }
93
+
94
+ try {
95
+ if (action === 'commit') {
96
+ if (!message) return NextResponse.json({ error: 'message required' }, { status: 400 });
97
+ if (files && files.length > 0) {
98
+ for (const f of files) {
99
+ git(`add "${f}"`, dir);
100
+ }
101
+ } else {
102
+ git('add -A', dir);
103
+ }
104
+ git(`commit -m "${message.replace(/"/g, '\\"')}"`, dir);
105
+ return NextResponse.json({ ok: true });
106
+ }
107
+
108
+ if (action === 'push') {
109
+ const output = git('push', dir);
110
+ return NextResponse.json({ ok: true, output });
111
+ }
112
+
113
+ if (action === 'pull') {
114
+ const output = git('pull', dir);
115
+ return NextResponse.json({ ok: true, output });
116
+ }
117
+
118
+ if (action === 'stage') {
119
+ if (files && files.length > 0) {
120
+ for (const f of files) git(`add "${f}"`, dir);
121
+ } else {
122
+ git('add -A', dir);
123
+ }
124
+ return NextResponse.json({ ok: true });
125
+ }
126
+
127
+ return NextResponse.json({ error: 'Unknown action' }, { status: 400 });
128
+ } catch (e: any) {
129
+ return NextResponse.json({ error: e.message }, { status: 500 });
130
+ }
131
+ }
@@ -0,0 +1,40 @@
1
+ import { NextResponse, type NextRequest } from 'next/server';
2
+
3
+ // Track active users: IP/identifier → last seen timestamp
4
+ const activeUsers = new Map<string, { lastSeen: number; isRemote: boolean }>();
5
+ const TIMEOUT = 30_000; // 30s — user is "offline" if no ping in 30s
6
+
7
+ function cleanup() {
8
+ const now = Date.now();
9
+ for (const [key, val] of activeUsers) {
10
+ if (now - val.lastSeen > TIMEOUT) activeUsers.delete(key);
11
+ }
12
+ }
13
+
14
+ // POST /api/online — heartbeat ping
15
+ export async function POST(req: NextRequest) {
16
+ cleanup();
17
+
18
+ const ip = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim()
19
+ || req.headers.get('x-real-ip')
20
+ || 'local';
21
+ const host = req.headers.get('host') || '';
22
+ const isRemote = host.includes('.trycloudflare.com') || (ip !== 'local' && ip !== '127.0.0.1' && ip !== '::1');
23
+
24
+ activeUsers.set(ip, { lastSeen: Date.now(), isRemote });
25
+
26
+ const total = activeUsers.size;
27
+ const remote = [...activeUsers.values()].filter(v => v.isRemote).length;
28
+
29
+ return NextResponse.json({ total, remote });
30
+ }
31
+
32
+ // GET /api/online — just get counts
33
+ export async function GET() {
34
+ cleanup();
35
+
36
+ const total = activeUsers.size;
37
+ const remote = [...activeUsers.values()].filter(v => v.isRemote).length;
38
+
39
+ return NextResponse.json({ total, remote });
40
+ }
@@ -0,0 +1,64 @@
1
+ import { NextResponse, type NextRequest } from 'next/server';
2
+ import { readFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { homedir } from 'node:os';
5
+
6
+ const CONFIG_FILE = join(homedir(), '.forge', 'preview.json');
7
+
8
+ function getPort(): number {
9
+ try {
10
+ const data = JSON.parse(readFileSync(CONFIG_FILE, 'utf-8'));
11
+ return data.port || 0;
12
+ } catch {
13
+ return 0;
14
+ }
15
+ }
16
+
17
+ async function proxy(req: NextRequest) {
18
+ const port = getPort();
19
+ if (!port) {
20
+ return NextResponse.json({ error: 'Preview not configured' }, { status: 503 });
21
+ }
22
+
23
+ const url = new URL(req.url);
24
+ const path = url.pathname.replace(/^\/api\/preview/, '') || '/';
25
+ const target = `http://localhost:${port}${path}${url.search}`;
26
+
27
+ try {
28
+ const headers: Record<string, string> = {};
29
+ req.headers.forEach((v, k) => {
30
+ if (!['host', 'connection', 'transfer-encoding'].includes(k.toLowerCase())) {
31
+ headers[k] = v;
32
+ }
33
+ });
34
+
35
+ const res = await fetch(target, {
36
+ method: req.method,
37
+ headers,
38
+ body: req.method !== 'GET' && req.method !== 'HEAD' ? await req.arrayBuffer() : undefined,
39
+ redirect: 'manual',
40
+ });
41
+
42
+ const responseHeaders = new Headers();
43
+ res.headers.forEach((v, k) => {
44
+ if (!['transfer-encoding', 'content-encoding'].includes(k.toLowerCase())) {
45
+ responseHeaders.set(k, v);
46
+ }
47
+ });
48
+
49
+ return new NextResponse(res.body, {
50
+ status: res.status,
51
+ headers: responseHeaders,
52
+ });
53
+ } catch {
54
+ return NextResponse.json({ error: `Cannot connect to localhost:${port}` }, { status: 502 });
55
+ }
56
+ }
57
+
58
+ export const GET = proxy;
59
+ export const POST = proxy;
60
+ export const PUT = proxy;
61
+ export const DELETE = proxy;
62
+ export const PATCH = proxy;
63
+ export const HEAD = proxy;
64
+ export const OPTIONS = proxy;
@@ -0,0 +1,135 @@
1
+ import { NextResponse } from 'next/server';
2
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
3
+ import { join, dirname } from 'node:path';
4
+ import { homedir } from 'node:os';
5
+ import { spawn, execSync, type ChildProcess } from 'node:child_process';
6
+
7
+ const CONFIG_FILE = join(homedir(), '.forge', 'preview.json');
8
+
9
+ // Persist tunnel state across hot-reloads
10
+ const stateKey = Symbol.for('mw-preview-state');
11
+ const g = globalThis as any;
12
+ if (!g[stateKey]) g[stateKey] = { process: null, port: 0, url: null, status: 'stopped' };
13
+ const state: { process: ChildProcess | null; port: number; url: string | null; status: string } = g[stateKey];
14
+
15
+ function getConfig(): { port: number } {
16
+ try {
17
+ return JSON.parse(readFileSync(CONFIG_FILE, 'utf-8'));
18
+ } catch {
19
+ return { port: 0 };
20
+ }
21
+ }
22
+
23
+ function saveConfig(config: { port: number }) {
24
+ const dir = dirname(CONFIG_FILE);
25
+ mkdirSync(dir, { recursive: true });
26
+ writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
27
+ }
28
+
29
+ function getCloudflaredPath(): string | null {
30
+ const binPath = join(homedir(), '.forge', 'bin', 'cloudflared');
31
+ if (existsSync(binPath)) return binPath;
32
+ try {
33
+ return execSync('which cloudflared', { encoding: 'utf-8' }).trim();
34
+ } catch {
35
+ return null;
36
+ }
37
+ }
38
+
39
+ // GET — get current preview status
40
+ export async function GET() {
41
+ return NextResponse.json({
42
+ port: state.port,
43
+ url: state.url,
44
+ status: state.status,
45
+ });
46
+ }
47
+
48
+ // POST — start/stop preview tunnel
49
+ export async function POST(req: Request) {
50
+ const { port, action } = await req.json();
51
+
52
+ if (action === 'stop' || port === 0) {
53
+ if (state.process) {
54
+ state.process.kill('SIGTERM');
55
+ state.process = null;
56
+ }
57
+ state.port = 0;
58
+ state.url = null;
59
+ state.status = 'stopped';
60
+ saveConfig({ port: 0 });
61
+ return NextResponse.json({ port: 0, url: null, status: 'stopped' });
62
+ }
63
+
64
+ const p = parseInt(port) || 0;
65
+ if (!p || p < 1 || p > 65535) {
66
+ return NextResponse.json({ error: 'Invalid port' }, { status: 400 });
67
+ }
68
+
69
+ // Kill existing tunnel if any
70
+ if (state.process) {
71
+ state.process.kill('SIGTERM');
72
+ state.process = null;
73
+ }
74
+
75
+ const binPath = getCloudflaredPath();
76
+ if (!binPath) {
77
+ return NextResponse.json({ error: 'cloudflared not installed. Start the main tunnel first to auto-download it.' }, { status: 500 });
78
+ }
79
+
80
+ state.port = p;
81
+ state.status = 'starting';
82
+ state.url = null;
83
+ saveConfig({ port: p });
84
+
85
+ // Start tunnel
86
+ return new Promise<NextResponse>((resolve) => {
87
+ let resolved = false;
88
+
89
+ const child = spawn(binPath, ['tunnel', '--url', `http://localhost:${p}`], {
90
+ stdio: ['ignore', 'pipe', 'pipe'],
91
+ });
92
+ state.process = child;
93
+
94
+ const handleOutput = (data: Buffer) => {
95
+ const text = data.toString();
96
+ const urlMatch = text.match(/(https:\/\/[a-z0-9-]+\.trycloudflare\.com)/);
97
+ if (urlMatch && !state.url) {
98
+ state.url = urlMatch[1];
99
+ state.status = 'running';
100
+ if (!resolved) {
101
+ resolved = true;
102
+ resolve(NextResponse.json({ port: p, url: state.url, status: 'running' }));
103
+ }
104
+ }
105
+ };
106
+
107
+ child.stdout?.on('data', handleOutput);
108
+ child.stderr?.on('data', handleOutput);
109
+
110
+ child.on('exit', () => {
111
+ state.process = null;
112
+ state.status = 'stopped';
113
+ state.url = null;
114
+ if (!resolved) {
115
+ resolved = true;
116
+ resolve(NextResponse.json({ port: p, url: null, status: 'stopped', error: 'Tunnel exited' }));
117
+ }
118
+ });
119
+
120
+ child.on('error', (err) => {
121
+ state.status = 'error';
122
+ if (!resolved) {
123
+ resolved = true;
124
+ resolve(NextResponse.json({ error: err.message }, { status: 500 }));
125
+ }
126
+ });
127
+
128
+ setTimeout(() => {
129
+ if (!resolved) {
130
+ resolved = true;
131
+ resolve(NextResponse.json({ port: p, url: null, status: state.status, error: 'Timeout waiting for tunnel URL' }));
132
+ }
133
+ }, 30000);
134
+ });
135
+ }
@@ -1,5 +1,6 @@
1
1
  import { NextResponse } from 'next/server';
2
2
  import { getTask, cancelTask, deleteTask, retryTask, updateTask } from '@/lib/task-manager';
3
+ import { getProjectInfo } from '@/lib/projects';
3
4
 
4
5
  // Get task details (including full log)
5
6
  export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
@@ -28,12 +29,17 @@ export async function POST(req: Request, { params }: { params: Promise<{ id: str
28
29
  return NextResponse.json({ error: 'Unknown action' }, { status: 400 });
29
30
  }
30
31
 
31
- // Edit a queued task
32
+ // Edit a task
32
33
  export async function PATCH(req: Request, { params }: { params: Promise<{ id: string }> }) {
33
34
  const { id } = await params;
34
35
  const body = await req.json();
36
+ // Resolve projectName to projectPath if changed
37
+ if (body.projectName && !body.projectPath) {
38
+ const project = getProjectInfo(body.projectName);
39
+ if (project) body.projectPath = project.path;
40
+ }
35
41
  const updated = updateTask(id, body);
36
- if (!updated) return NextResponse.json({ error: 'Cannot edit (only queued tasks)' }, { status: 400 });
42
+ if (!updated) return NextResponse.json({ error: 'Cannot edit this task' }, { status: 400 });
37
43
  return NextResponse.json(updated);
38
44
  }
39
45