@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 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-server # production via npm link/install
9
- forge-server --dev # dev mode
10
- forge-server --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 --port 4000 --terminal-port 4001 --dir ~/.forge-staging
15
- forge-server --reset-terminal # kill terminal server (loses tmux attach)
16
- forge-server --version # show version
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-server
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-server` or `./start.sh`
77
+ 1. **Start Forge** — `forge server start` or `./start.sh`
78
78
  2. **Open browser** — `http://localhost:3000`
79
- 3. **Log in** — password is in the console output, rotates daily. Run `forge password` if you forget.
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 — protected by your daily rotating password
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` | Start Cloudflare Tunnel |
104
+ | `/tunnel_start <admin_pw>` | Start Cloudflare Tunnel |
105
105
  | `/tunnel_stop` | Stop tunnel |
106
- | `/tunnel_password <pw>` | Get login password + tunnel URL |
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
- ├── password.json # Daily auto-generated login password
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 (daily rotating password + OAuth) |
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-server` which clean up old processes on start. Or manually:
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(loadSettings());
8
+ return NextResponse.json(loadSettingsMasked());
7
9
  }
8
10
 
9
11
  export async function PUT(req: Request) {
10
- const body = await req.json() as Settings;
11
- saveSettings(body);
12
- // Restart Telegram bot in case token/chatId changed
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
  }
@@ -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 { action } = await req.json() as { action: 'start' | 'stop' };
10
+ const body = await req.json() as { action: 'start' | 'stop'; password?: string };
10
11
 
11
- if (action === 'stop') {
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
- const result = await startTunnel();
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
+ }
@@ -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">Unified AI Platform</p>
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>
@@ -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-server --stop${DATA_DIR !== join(homedir(), '.forge') ? ` --dir ${DATA_DIR}` : ''}`);
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 pwFile = join(homedir(), '.forge', 'password.json');
357
+ const dataDir = process.env.FORGE_DATA_DIR || join(homedir(), '.forge');
358
+ const codeFile = join(dataDir, 'session-code.json');
329
359
  try {
330
- const data = JSON.parse(readFileSync(pwFile, 'utf-8'));
331
- const today = new Date().toISOString().slice(0, 10);
332
- if (data.date === today) {
333
- console.log(`Login password: ${data.password}`);
334
- console.log(`Valid for: ${data.date}`);
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
- console.log('No password file found. Password is set via MW_PASSWORD env var.');
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
  });
@@ -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">