@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.
- package/app/api/code/route.ts +31 -4
- package/app/api/docs/route.ts +48 -3
- package/app/api/git/route.ts +131 -0
- package/app/api/online/route.ts +40 -0
- package/app/api/preview/[...path]/route.ts +64 -0
- package/app/api/preview/route.ts +135 -0
- package/app/api/tasks/[id]/route.ts +8 -2
- package/components/CodeViewer.tsx +205 -4
- package/components/Dashboard.tsx +67 -1
- package/components/DocsViewer.tsx +54 -5
- package/components/NewTaskModal.tsx +7 -7
- package/components/PreviewPanel.tsx +154 -0
- package/components/ProjectManager.tsx +410 -0
- package/components/SettingsModal.tsx +4 -1
- package/components/TaskDetail.tsx +1 -1
- package/components/WebTerminal.tsx +109 -9
- package/lib/task-manager.ts +70 -12
- package/lib/telegram-bot.ts +99 -1
- package/package.json +1 -1
package/app/api/code/route.ts
CHANGED
|
@@ -123,12 +123,39 @@ export async function GET(req: Request) {
|
|
|
123
123
|
}
|
|
124
124
|
try {
|
|
125
125
|
const stat = statSync(fullPath);
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
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
|
}
|
package/app/api/docs/route.ts
CHANGED
|
@@ -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
|
|
39
|
-
|
|
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
|
|
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
|
|
42
|
+
if (!updated) return NextResponse.json({ error: 'Cannot edit this task' }, { status: 400 });
|
|
37
43
|
return NextResponse.json(updated);
|
|
38
44
|
}
|
|
39
45
|
|