@aion0/forge 0.2.1 → 0.2.3

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.
@@ -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,28 @@
1
+ import { NextResponse } from 'next/server';
2
+ import { getPipeline, cancelPipeline, deletePipeline } from '@/lib/pipeline';
3
+
4
+ // GET /api/pipelines/:id
5
+ export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
6
+ const { id } = await params;
7
+ const pipeline = getPipeline(id);
8
+ if (!pipeline) return NextResponse.json({ error: 'Not found' }, { status: 404 });
9
+ return NextResponse.json(pipeline);
10
+ }
11
+
12
+ // POST /api/pipelines/:id — actions (cancel)
13
+ export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
14
+ const { id } = await params;
15
+ const { action } = await req.json();
16
+
17
+ if (action === 'cancel') {
18
+ const ok = cancelPipeline(id);
19
+ return NextResponse.json({ ok });
20
+ }
21
+
22
+ if (action === 'delete') {
23
+ const ok = deletePipeline(id);
24
+ return NextResponse.json({ ok });
25
+ }
26
+
27
+ return NextResponse.json({ error: 'Unknown action' }, { status: 400 });
28
+ }
@@ -0,0 +1,52 @@
1
+ import { NextResponse } from 'next/server';
2
+ import { listPipelines, listWorkflows, startPipeline } from '@/lib/pipeline';
3
+ import { writeFileSync, mkdirSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+ import { homedir } from 'node:os';
6
+ import YAML from 'yaml';
7
+
8
+ const FLOWS_DIR = join(homedir(), '.forge', 'flows');
9
+
10
+ // GET /api/pipelines — list all pipelines + available workflows
11
+ export async function GET(req: Request) {
12
+ const { searchParams } = new URL(req.url);
13
+ const type = searchParams.get('type');
14
+
15
+ if (type === 'workflows') {
16
+ return NextResponse.json(listWorkflows());
17
+ }
18
+
19
+ return NextResponse.json(listPipelines().sort((a, b) => b.createdAt.localeCompare(a.createdAt)));
20
+ }
21
+
22
+ // POST /api/pipelines — start a pipeline or save a workflow
23
+ export async function POST(req: Request) {
24
+ const body = await req.json();
25
+
26
+ // Save workflow YAML from visual editor
27
+ if (body.action === 'save-workflow' && body.yaml) {
28
+ try {
29
+ mkdirSync(FLOWS_DIR, { recursive: true });
30
+ const parsed = YAML.parse(body.yaml);
31
+ const name = parsed.name || 'unnamed';
32
+ const filePath = join(FLOWS_DIR, `${name}.yaml`);
33
+ writeFileSync(filePath, body.yaml, 'utf-8');
34
+ return NextResponse.json({ ok: true, name, path: filePath });
35
+ } catch (e: any) {
36
+ return NextResponse.json({ error: e.message }, { status: 400 });
37
+ }
38
+ }
39
+
40
+ // Start pipeline
41
+ const { workflow, input } = body;
42
+ if (!workflow) {
43
+ return NextResponse.json({ error: 'workflow name required' }, { status: 400 });
44
+ }
45
+
46
+ try {
47
+ const pipeline = startPipeline(workflow, input || {});
48
+ return NextResponse.json(pipeline);
49
+ } catch (e: any) {
50
+ return NextResponse.json({ error: e.message }, { status: 400 });
51
+ }
52
+ }
@@ -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