@aion0/forge 0.2.11 → 0.2.13
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/CLAUDE.md +9 -9
- package/README.md +10 -10
- package/app/api/settings/route.ts +52 -5
- package/app/api/tunnel/route.ts +12 -4
- package/app/api/upgrade/route.ts +31 -0
- package/app/api/version/route.ts +59 -0
- package/app/login/page.tsx +35 -6
- package/bin/forge-server.mjs +1 -1
- package/cli/mw.ts +42 -13
- package/components/Dashboard.tsx +37 -0
- package/components/SettingsModal.tsx +297 -35
- package/components/TunnelToggle.tsx +48 -5
- package/install.sh +2 -2
- package/instrumentation.ts +9 -4
- package/lib/auth.ts +13 -9
- package/lib/cloudflared.ts +6 -0
- package/lib/crypto.ts +68 -0
- package/lib/init.ts +47 -9
- package/lib/password.ts +67 -47
- package/lib/settings.ts +38 -3
- package/lib/telegram-bot.ts +56 -23
- package/lib/telegram-standalone.ts +0 -1
- package/package.json +1 -1
package/CLAUDE.md
CHANGED
|
@@ -5,15 +5,15 @@
|
|
|
5
5
|
# ── Start ──
|
|
6
6
|
./start.sh # production (kill old processes → build → start)
|
|
7
7
|
./start.sh dev # development (hot-reload)
|
|
8
|
-
forge
|
|
9
|
-
forge
|
|
10
|
-
forge
|
|
11
|
-
forge
|
|
12
|
-
forge
|
|
13
|
-
forge
|
|
14
|
-
forge
|
|
15
|
-
forge
|
|
16
|
-
forge
|
|
8
|
+
forge server start # production via npm link/install
|
|
9
|
+
forge server start --dev # dev mode
|
|
10
|
+
forge server start --background # background, logs to ~/.forge/forge.log
|
|
11
|
+
forge server stop # stop background server
|
|
12
|
+
forge server restart # stop + start (safe for remote)
|
|
13
|
+
forge server rebuild # force rebuild
|
|
14
|
+
forge server start --port 4000 --terminal-port 4001 --dir ~/.forge-staging
|
|
15
|
+
forge server start --reset-terminal # kill terminal server (loses tmux attach)
|
|
16
|
+
forge --version # show version
|
|
17
17
|
|
|
18
18
|
# ── Test ──
|
|
19
19
|
./dev-test.sh # test instance (port 4000, data ~/.forge-test)
|
package/README.md
CHANGED
|
@@ -51,7 +51,7 @@ Forge is a self-hosted web platform that turns [Claude Code](https://docs.anthro
|
|
|
51
51
|
|
|
52
52
|
```bash
|
|
53
53
|
npm install -g @aion0/forge
|
|
54
|
-
forge
|
|
54
|
+
forge server start
|
|
55
55
|
```
|
|
56
56
|
|
|
57
57
|
Open `http://localhost:3000` — a login password is printed in the console.
|
|
@@ -74,9 +74,9 @@ pnpm install
|
|
|
74
74
|
|
|
75
75
|
## Quick Start
|
|
76
76
|
|
|
77
|
-
1. **Start Forge** — `forge
|
|
77
|
+
1. **Start Forge** — `forge server start` or `./start.sh`
|
|
78
78
|
2. **Open browser** — `http://localhost:3000`
|
|
79
|
-
3. **Log in** —
|
|
79
|
+
3. **Log in** — set an admin password in Settings. Run `forge password` for info.
|
|
80
80
|
4. **Configure** — Settings → add project directories and (optionally) Telegram bot token
|
|
81
81
|
5. **Start coding** — Open a terminal tab, run `claude`, and vibe
|
|
82
82
|
|
|
@@ -86,7 +86,7 @@ Access Forge from anywhere — iPad, phone, coffee shop:
|
|
|
86
86
|
|
|
87
87
|
1. Click the **tunnel button** in the header
|
|
88
88
|
2. A temporary Cloudflare URL is generated (no account needed)
|
|
89
|
-
3. Open it on any device —
|
|
89
|
+
3. Open it on any device — requires admin password + session code (2FA)
|
|
90
90
|
|
|
91
91
|
Health checks run every 60 seconds. If the tunnel drops, it auto-restarts and notifies you via Telegram.
|
|
92
92
|
|
|
@@ -101,9 +101,9 @@ Mobile-first control for Forge. Create a bot via [@BotFather](https://t.me/botfa
|
|
|
101
101
|
| `/sessions` | AI summary of Claude Code sessions |
|
|
102
102
|
| `/docs` | Docs session summary or file search |
|
|
103
103
|
| `/note` | Quick note — sent to Docs Claude for filing |
|
|
104
|
-
| `/tunnel_start
|
|
104
|
+
| `/tunnel_start <admin_pw>` | Start Cloudflare Tunnel |
|
|
105
105
|
| `/tunnel_stop` | Stop tunnel |
|
|
106
|
-
| `/
|
|
106
|
+
| `/tunnel_code <admin_pw>` | Get session code for remote login |
|
|
107
107
|
| `/watch` | Monitor session / list watchers |
|
|
108
108
|
|
|
109
109
|
Whitelist-protected — only configured Chat IDs can interact. Supports multiple users (comma-separated IDs).
|
|
@@ -210,7 +210,7 @@ All data lives in `~/.forge/`:
|
|
|
210
210
|
~/.forge/
|
|
211
211
|
├── .env.local # Environment variables (AUTH_SECRET, API keys)
|
|
212
212
|
├── settings.yaml # Main configuration
|
|
213
|
-
├──
|
|
213
|
+
├── session-code.json # Session code for remote login 2FA
|
|
214
214
|
├── data.db # SQLite database (tasks, sessions)
|
|
215
215
|
├── terminal-state.json # Terminal tab layout
|
|
216
216
|
├── tunnel-state.json # Tunnel process state
|
|
@@ -232,7 +232,7 @@ claudePath: claude
|
|
|
232
232
|
tunnelAutoStart: false
|
|
233
233
|
telegramBotToken: ""
|
|
234
234
|
telegramChatId: "" # Comma-separated for multiple users
|
|
235
|
-
telegramTunnelPassword: ""
|
|
235
|
+
telegramTunnelPassword: "" # Admin password (encrypted)
|
|
236
236
|
notifyOnComplete: true
|
|
237
237
|
notifyOnFailure: true
|
|
238
238
|
taskModel: default # default / sonnet / opus / haiku
|
|
@@ -281,7 +281,7 @@ forge-server.mjs (single process)
|
|
|
281
281
|
| Frontend | Next.js 16, React 19, Tailwind CSS 4, xterm.js, ReactFlow |
|
|
282
282
|
| Backend | Next.js Route Handlers, SQLite (better-sqlite3) |
|
|
283
283
|
| Terminal | node-pty, tmux, WebSocket |
|
|
284
|
-
| Auth | NextAuth v5 (
|
|
284
|
+
| Auth | NextAuth v5 (admin password + session code 2FA + OAuth) |
|
|
285
285
|
| Tunnel | Cloudflare cloudflared (zero-config) |
|
|
286
286
|
| Bot | Telegram Bot API |
|
|
287
287
|
| Pipeline | YAML-based DAG engine with visual editor |
|
|
@@ -314,7 +314,7 @@ echo "AUTH_SECRET=$(openssl rand -hex 32)" >> ~/.forge/.env.local
|
|
|
314
314
|
<details>
|
|
315
315
|
<summary><strong>Orphan processes after Ctrl+C</strong></summary>
|
|
316
316
|
|
|
317
|
-
Use `./start.sh` or `forge
|
|
317
|
+
Use `./start.sh` or `forge server start` which clean up old processes on start. Or manually:
|
|
318
318
|
|
|
319
319
|
```bash
|
|
320
320
|
./check-forge-status.sh # see what's running
|
|
@@ -1,15 +1,62 @@
|
|
|
1
1
|
import { NextResponse } from 'next/server';
|
|
2
|
-
import { loadSettings, saveSettings, type Settings } from '@/lib/settings';
|
|
2
|
+
import { loadSettings, loadSettingsMasked, saveSettings, type Settings } from '@/lib/settings';
|
|
3
3
|
import { restartTelegramBot } from '@/lib/init';
|
|
4
|
+
import { SECRET_FIELDS } from '@/lib/crypto';
|
|
5
|
+
import { verifyAdmin } from '@/lib/password';
|
|
4
6
|
|
|
5
7
|
export async function GET() {
|
|
6
|
-
return NextResponse.json(
|
|
8
|
+
return NextResponse.json(loadSettingsMasked());
|
|
7
9
|
}
|
|
8
10
|
|
|
9
11
|
export async function PUT(req: Request) {
|
|
10
|
-
const body = await req.json()
|
|
11
|
-
|
|
12
|
-
//
|
|
12
|
+
const body = await req.json();
|
|
13
|
+
|
|
14
|
+
// Handle secret field updates separately
|
|
15
|
+
if (body._secretUpdate) {
|
|
16
|
+
const { field, adminPassword, newValue } = body._secretUpdate as {
|
|
17
|
+
field: string;
|
|
18
|
+
adminPassword: string;
|
|
19
|
+
newValue: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
// Validate field name
|
|
23
|
+
if (!SECRET_FIELDS.includes(field as any)) {
|
|
24
|
+
return NextResponse.json({ ok: false, error: 'Invalid field' }, { status: 400 });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Verify admin password
|
|
28
|
+
if (!verifyAdmin(adminPassword)) {
|
|
29
|
+
return NextResponse.json({ ok: false, error: 'Wrong password' }, { status: 403 });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Update the specific field
|
|
33
|
+
const current = loadSettings();
|
|
34
|
+
(current as any)[field] = newValue;
|
|
35
|
+
saveSettings(current);
|
|
36
|
+
|
|
37
|
+
// Restart Telegram bot if token changed
|
|
38
|
+
if (field === 'telegramBotToken') {
|
|
39
|
+
restartTelegramBot();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return NextResponse.json({ ok: true });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Normal settings update — strip masked secrets so we don't overwrite with placeholder
|
|
46
|
+
const settings = loadSettings();
|
|
47
|
+
const updated = body as Settings;
|
|
48
|
+
|
|
49
|
+
for (const field of SECRET_FIELDS) {
|
|
50
|
+
// Keep existing encrypted value if frontend sent masked placeholder
|
|
51
|
+
if (updated[field] === '••••••••' || updated[field] === '') {
|
|
52
|
+
updated[field] = settings[field];
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Remove internal fields
|
|
57
|
+
delete (updated as any)._secretStatus;
|
|
58
|
+
|
|
59
|
+
saveSettings(updated);
|
|
13
60
|
restartTelegramBot();
|
|
14
61
|
return NextResponse.json({ ok: true });
|
|
15
62
|
}
|
package/app/api/tunnel/route.ts
CHANGED
|
@@ -1,18 +1,26 @@
|
|
|
1
1
|
import { NextResponse } from 'next/server';
|
|
2
2
|
import { startTunnel, stopTunnel, getTunnelStatus } from '@/lib/cloudflared';
|
|
3
|
+
import { verifyAdmin } from '@/lib/password';
|
|
3
4
|
|
|
4
5
|
export async function GET() {
|
|
5
6
|
return NextResponse.json(getTunnelStatus());
|
|
6
7
|
}
|
|
7
8
|
|
|
8
9
|
export async function POST(req: Request) {
|
|
9
|
-
const
|
|
10
|
+
const body = await req.json() as { action: 'start' | 'stop'; password?: string };
|
|
10
11
|
|
|
11
|
-
if (action === '
|
|
12
|
+
if (body.action === 'start') {
|
|
13
|
+
if (!body.password || !verifyAdmin(body.password)) {
|
|
14
|
+
return NextResponse.json({ ok: false, error: 'Wrong password' }, { status: 403 });
|
|
15
|
+
}
|
|
16
|
+
const result = await startTunnel();
|
|
17
|
+
return NextResponse.json({ ok: !result.error, ...getTunnelStatus() });
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (body.action === 'stop') {
|
|
12
21
|
stopTunnel();
|
|
13
22
|
return NextResponse.json({ ok: true, ...getTunnelStatus() });
|
|
14
23
|
}
|
|
15
24
|
|
|
16
|
-
|
|
17
|
-
return NextResponse.json({ ok: !result.error, ...getTunnelStatus() });
|
|
25
|
+
return NextResponse.json({ ok: false, error: 'Invalid action' }, { status: 400 });
|
|
18
26
|
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { lstatSync } from 'node:fs';
|
|
4
|
+
import { execSync } from 'node:child_process';
|
|
5
|
+
|
|
6
|
+
export async function POST() {
|
|
7
|
+
try {
|
|
8
|
+
// Check if installed via npm link (symlink = local dev)
|
|
9
|
+
let isLinked = false;
|
|
10
|
+
try { isLinked = lstatSync(join(process.cwd())).isSymbolicLink(); } catch {}
|
|
11
|
+
|
|
12
|
+
if (isLinked) {
|
|
13
|
+
return NextResponse.json({
|
|
14
|
+
ok: false,
|
|
15
|
+
error: 'Local dev install (npm link). Run: git pull && pnpm install && pnpm build',
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
execSync('cd /tmp && npm install -g @aion0/forge', { timeout: 120000 });
|
|
20
|
+
|
|
21
|
+
return NextResponse.json({
|
|
22
|
+
ok: true,
|
|
23
|
+
message: 'Upgraded. Restart server to apply.',
|
|
24
|
+
});
|
|
25
|
+
} catch (e) {
|
|
26
|
+
return NextResponse.json({
|
|
27
|
+
ok: false,
|
|
28
|
+
error: `Upgrade failed: ${e instanceof Error ? e.message : String(e)}`,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
// Cache npm version check for 1 hour
|
|
6
|
+
let cachedLatest: { version: string; checkedAt: number } | null = null;
|
|
7
|
+
const CACHE_TTL = 60 * 60 * 1000; // 1 hour
|
|
8
|
+
|
|
9
|
+
function getCurrentVersion(): string {
|
|
10
|
+
try {
|
|
11
|
+
const pkg = JSON.parse(readFileSync(join(process.cwd(), 'package.json'), 'utf-8'));
|
|
12
|
+
return pkg.version;
|
|
13
|
+
} catch {
|
|
14
|
+
return '0.0.0';
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function getLatestVersion(): Promise<string> {
|
|
19
|
+
if (cachedLatest && Date.now() - cachedLatest.checkedAt < CACHE_TTL) {
|
|
20
|
+
return cachedLatest.version;
|
|
21
|
+
}
|
|
22
|
+
try {
|
|
23
|
+
const controller = new AbortController();
|
|
24
|
+
const timeout = setTimeout(() => controller.abort(), 5000);
|
|
25
|
+
const res = await fetch('https://registry.npmjs.org/@aion0/forge/latest', {
|
|
26
|
+
signal: controller.signal,
|
|
27
|
+
headers: { 'Accept': 'application/json' },
|
|
28
|
+
});
|
|
29
|
+
clearTimeout(timeout);
|
|
30
|
+
if (!res.ok) return cachedLatest?.version || '';
|
|
31
|
+
const data = await res.json();
|
|
32
|
+
cachedLatest = { version: data.version, checkedAt: Date.now() };
|
|
33
|
+
return data.version;
|
|
34
|
+
} catch {
|
|
35
|
+
return cachedLatest?.version || '';
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function compareVersions(a: string, b: string): number {
|
|
40
|
+
const pa = a.split('.').map(Number);
|
|
41
|
+
const pb = b.split('.').map(Number);
|
|
42
|
+
for (let i = 0; i < 3; i++) {
|
|
43
|
+
if ((pa[i] || 0) < (pb[i] || 0)) return -1;
|
|
44
|
+
if ((pa[i] || 0) > (pb[i] || 0)) return 1;
|
|
45
|
+
}
|
|
46
|
+
return 0;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function GET() {
|
|
50
|
+
const current = getCurrentVersion();
|
|
51
|
+
const latest = await getLatestVersion();
|
|
52
|
+
const hasUpdate = latest && compareVersions(current, latest) < 0;
|
|
53
|
+
|
|
54
|
+
return NextResponse.json({
|
|
55
|
+
current,
|
|
56
|
+
latest: latest || current,
|
|
57
|
+
hasUpdate,
|
|
58
|
+
});
|
|
59
|
+
}
|
package/app/login/page.tsx
CHANGED
|
@@ -1,20 +1,30 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { useState } from 'react';
|
|
3
|
+
import { useState, useEffect } from 'react';
|
|
4
4
|
import { signIn } from 'next-auth/react';
|
|
5
5
|
|
|
6
6
|
export default function LoginPage() {
|
|
7
7
|
const [password, setPassword] = useState('');
|
|
8
|
+
const [sessionCode, setSessionCode] = useState('');
|
|
8
9
|
const [error, setError] = useState('');
|
|
10
|
+
const [isRemote, setIsRemote] = useState(false);
|
|
11
|
+
|
|
12
|
+
useEffect(() => {
|
|
13
|
+
const host = window.location.hostname;
|
|
14
|
+
setIsRemote(!['localhost', '127.0.0.1'].includes(host));
|
|
15
|
+
}, []);
|
|
9
16
|
|
|
10
17
|
const handleLocal = async (e: React.FormEvent) => {
|
|
11
18
|
e.preventDefault();
|
|
19
|
+
setError('');
|
|
12
20
|
const result = await signIn('credentials', {
|
|
13
21
|
password,
|
|
22
|
+
sessionCode: isRemote ? sessionCode : '',
|
|
23
|
+
isRemote: String(isRemote),
|
|
14
24
|
redirect: false,
|
|
15
25
|
}) as { error?: string; ok?: boolean } | undefined;
|
|
16
26
|
if (result?.error) {
|
|
17
|
-
setError('Wrong password');
|
|
27
|
+
setError(isRemote ? 'Wrong password or session code' : 'Wrong password');
|
|
18
28
|
} else if (result?.ok) {
|
|
19
29
|
window.location.href = window.location.origin + '/';
|
|
20
30
|
}
|
|
@@ -25,18 +35,32 @@ export default function LoginPage() {
|
|
|
25
35
|
<div className="w-80 space-y-6">
|
|
26
36
|
<div className="text-center">
|
|
27
37
|
<h1 className="text-2xl font-bold text-[var(--text-primary)]">Forge</h1>
|
|
28
|
-
<p className="text-sm text-[var(--text-secondary)] mt-1">
|
|
38
|
+
<p className="text-sm text-[var(--text-secondary)] mt-1">
|
|
39
|
+
{isRemote ? 'Remote Access' : 'Local Access'}
|
|
40
|
+
</p>
|
|
29
41
|
</div>
|
|
30
42
|
|
|
31
|
-
{/* Local password login */}
|
|
32
43
|
<form onSubmit={handleLocal} className="space-y-3">
|
|
33
44
|
<input
|
|
34
45
|
type="password"
|
|
35
46
|
value={password}
|
|
36
|
-
onChange={e => setPassword(e.target.value)}
|
|
37
|
-
placeholder="Password"
|
|
47
|
+
onChange={e => { setPassword(e.target.value); setError(''); }}
|
|
48
|
+
placeholder="Admin Password"
|
|
49
|
+
autoFocus
|
|
38
50
|
className="w-full px-3 py-2 bg-[var(--bg-tertiary)] border border-[var(--border)] rounded text-sm text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)]"
|
|
39
51
|
/>
|
|
52
|
+
{isRemote && (
|
|
53
|
+
<input
|
|
54
|
+
type="text"
|
|
55
|
+
inputMode="numeric"
|
|
56
|
+
pattern="[0-9]*"
|
|
57
|
+
maxLength={8}
|
|
58
|
+
value={sessionCode}
|
|
59
|
+
onChange={e => { setSessionCode(e.target.value.replace(/\D/g, '')); setError(''); }}
|
|
60
|
+
placeholder="Session Code (8 digits)"
|
|
61
|
+
className="w-full px-3 py-2 bg-[var(--bg-tertiary)] border border-[var(--border)] rounded text-sm text-[var(--text-primary)] font-mono tracking-widest text-center focus:outline-none focus:border-[var(--accent)]"
|
|
62
|
+
/>
|
|
63
|
+
)}
|
|
40
64
|
{error && <p className="text-xs text-[var(--red)]">{error}</p>}
|
|
41
65
|
<button
|
|
42
66
|
type="submit"
|
|
@@ -44,6 +68,11 @@ export default function LoginPage() {
|
|
|
44
68
|
>
|
|
45
69
|
Sign In
|
|
46
70
|
</button>
|
|
71
|
+
{isRemote && (
|
|
72
|
+
<p className="text-[10px] text-[var(--text-secondary)] text-center">
|
|
73
|
+
Session code is generated when tunnel starts. Use /tunnel_code in Telegram to get it.
|
|
74
|
+
</p>
|
|
75
|
+
)}
|
|
47
76
|
</form>
|
|
48
77
|
|
|
49
78
|
</div>
|
package/bin/forge-server.mjs
CHANGED
|
@@ -195,7 +195,7 @@ function startBackground() {
|
|
|
195
195
|
console.log(`[forge] Terminal: ws://localhost:${terminalPort}`);
|
|
196
196
|
console.log(`[forge] Data: ${DATA_DIR}`);
|
|
197
197
|
console.log(`[forge] Log: ${LOG_FILE}`);
|
|
198
|
-
console.log(`[forge] Stop: forge
|
|
198
|
+
console.log(`[forge] Stop: forge server stop`);
|
|
199
199
|
}
|
|
200
200
|
|
|
201
201
|
// ── Stop ──
|
package/cli/mw.ts
CHANGED
|
@@ -20,6 +20,35 @@ const BASE = process.env.MW_URL || 'http://localhost:3000';
|
|
|
20
20
|
|
|
21
21
|
const [, , cmd, ...args] = process.argv;
|
|
22
22
|
|
|
23
|
+
/** Check npm for newer version, print reminder if available */
|
|
24
|
+
async function checkForUpdate() {
|
|
25
|
+
try {
|
|
26
|
+
const { readFileSync } = await import('node:fs');
|
|
27
|
+
const { join, dirname } = await import('node:path');
|
|
28
|
+
const { fileURLToPath } = await import('node:url');
|
|
29
|
+
const pkg = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf-8'));
|
|
30
|
+
const current = pkg.version;
|
|
31
|
+
|
|
32
|
+
const controller = new AbortController();
|
|
33
|
+
const timeout = setTimeout(() => controller.abort(), 3000);
|
|
34
|
+
const res = await fetch('https://registry.npmjs.org/@aion0/forge/latest', {
|
|
35
|
+
signal: controller.signal,
|
|
36
|
+
headers: { 'Accept': 'application/json' },
|
|
37
|
+
});
|
|
38
|
+
clearTimeout(timeout);
|
|
39
|
+
if (!res.ok) return;
|
|
40
|
+
const data = await res.json();
|
|
41
|
+
const latest = data.version;
|
|
42
|
+
|
|
43
|
+
const [ca, cb, cc] = current.split('.').map(Number);
|
|
44
|
+
const [la, lb, lc] = latest.split('.').map(Number);
|
|
45
|
+
if (la > ca || (la === ca && lb > cb) || (la === ca && lb === cb && lc > cc)) {
|
|
46
|
+
console.log(`\n Update available: v${current} → v${latest}`);
|
|
47
|
+
console.log(` Run: forge upgrade\n`);
|
|
48
|
+
}
|
|
49
|
+
} catch {}
|
|
50
|
+
}
|
|
51
|
+
|
|
23
52
|
async function api(path: string, opts?: RequestInit) {
|
|
24
53
|
const res = await fetch(`${BASE}${path}`, opts);
|
|
25
54
|
if (!res.ok) {
|
|
@@ -322,22 +351,22 @@ async function main() {
|
|
|
322
351
|
|
|
323
352
|
case 'password':
|
|
324
353
|
case 'pw': {
|
|
325
|
-
const { readFileSync } = await import('node:fs');
|
|
354
|
+
const { readFileSync, existsSync } = await import('node:fs');
|
|
326
355
|
const { homedir } = await import('node:os');
|
|
327
356
|
const { join } = await import('node:path');
|
|
328
|
-
const
|
|
357
|
+
const dataDir = process.env.FORGE_DATA_DIR || join(homedir(), '.forge');
|
|
358
|
+
const codeFile = join(dataDir, 'session-code.json');
|
|
329
359
|
try {
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
} else {
|
|
336
|
-
console.log(`Password expired (was for ${data.date}). Restart server to generate new one.`);
|
|
360
|
+
if (existsSync(codeFile)) {
|
|
361
|
+
const data = JSON.parse(readFileSync(codeFile, 'utf-8'));
|
|
362
|
+
if (data.code) {
|
|
363
|
+
console.log(`Session code: ${data.code} (for remote login 2FA)`);
|
|
364
|
+
}
|
|
337
365
|
}
|
|
338
|
-
} catch {
|
|
339
|
-
|
|
340
|
-
|
|
366
|
+
} catch {}
|
|
367
|
+
console.log('Admin password: configured in Settings → Admin Password');
|
|
368
|
+
console.log('Local login: admin password only');
|
|
369
|
+
console.log('Remote login: admin password + session code');
|
|
341
370
|
break;
|
|
342
371
|
}
|
|
343
372
|
|
|
@@ -512,7 +541,7 @@ Shortcuts: t=task, ls=tasks, w=watch, s=status, l=log, f=flows, p=projects, pw=p
|
|
|
512
541
|
}
|
|
513
542
|
}
|
|
514
543
|
|
|
515
|
-
main().catch(err => {
|
|
544
|
+
main().then(() => checkForUpdate()).catch(err => {
|
|
516
545
|
console.error(err.message);
|
|
517
546
|
process.exit(1);
|
|
518
547
|
});
|
package/components/Dashboard.tsx
CHANGED
|
@@ -50,8 +50,16 @@ export default function Dashboard({ user }: { user: any }) {
|
|
|
50
50
|
const [providers, setProviders] = useState<ProviderInfo[]>([]);
|
|
51
51
|
const [projects, setProjects] = useState<ProjectInfo[]>([]);
|
|
52
52
|
const [onlineCount, setOnlineCount] = useState<{ total: number; remote: number }>({ total: 0, remote: 0 });
|
|
53
|
+
const [versionInfo, setVersionInfo] = useState<{ current: string; latest: string; hasUpdate: boolean } | null>(null);
|
|
54
|
+
const [upgrading, setUpgrading] = useState(false);
|
|
55
|
+
const [upgradeResult, setUpgradeResult] = useState<string | null>(null);
|
|
53
56
|
const terminalRef = useRef<WebTerminalHandle>(null);
|
|
54
57
|
|
|
58
|
+
// Version check (once on mount)
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
fetch('/api/version').then(r => r.json()).then(setVersionInfo).catch(() => {});
|
|
61
|
+
}, []);
|
|
62
|
+
|
|
55
63
|
// Heartbeat for online user tracking
|
|
56
64
|
useEffect(() => {
|
|
57
65
|
const ping = () => {
|
|
@@ -96,6 +104,35 @@ export default function Dashboard({ user }: { user: any }) {
|
|
|
96
104
|
<header className="h-12 border-b-2 border-[var(--border)] flex items-center justify-between px-4 shrink-0 bg-[var(--bg-secondary)]">
|
|
97
105
|
<div className="flex items-center gap-4">
|
|
98
106
|
<span className="text-sm font-bold text-[var(--accent)]">Forge</span>
|
|
107
|
+
{versionInfo && (
|
|
108
|
+
<span className="flex items-center gap-1.5">
|
|
109
|
+
<span className="text-[10px] text-[var(--text-secondary)]">v{versionInfo.current}</span>
|
|
110
|
+
{versionInfo.hasUpdate && !upgradeResult && (
|
|
111
|
+
<button
|
|
112
|
+
disabled={upgrading}
|
|
113
|
+
onClick={async () => {
|
|
114
|
+
setUpgrading(true);
|
|
115
|
+
try {
|
|
116
|
+
const res = await fetch('/api/upgrade', { method: 'POST' });
|
|
117
|
+
const data = await res.json();
|
|
118
|
+
setUpgradeResult(data.ok ? data.message : data.error);
|
|
119
|
+
if (data.ok) setVersionInfo(v => v ? { ...v, hasUpdate: false } : v);
|
|
120
|
+
} catch { setUpgradeResult('Upgrade failed'); }
|
|
121
|
+
setUpgrading(false);
|
|
122
|
+
}}
|
|
123
|
+
className="text-[9px] px-1.5 py-0.5 bg-[var(--accent)] text-white rounded hover:opacity-90 disabled:opacity-50"
|
|
124
|
+
title={`Update to v${versionInfo.latest}`}
|
|
125
|
+
>
|
|
126
|
+
{upgrading ? 'Upgrading...' : `Update v${versionInfo.latest}`}
|
|
127
|
+
</button>
|
|
128
|
+
)}
|
|
129
|
+
{upgradeResult && (
|
|
130
|
+
<span className="text-[9px] text-[var(--green)] max-w-[200px] truncate" title={upgradeResult}>
|
|
131
|
+
{upgradeResult}
|
|
132
|
+
</span>
|
|
133
|
+
)}
|
|
134
|
+
</span>
|
|
135
|
+
)}
|
|
99
136
|
|
|
100
137
|
{/* View mode toggle */}
|
|
101
138
|
<div className="flex bg-[var(--bg-tertiary)] rounded p-0.5">
|