@rubytech/create-maxy 0.3.9 → 0.4.0
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/package.json
CHANGED
|
@@ -1,20 +1,27 @@
|
|
|
1
1
|
import { NextResponse } from 'next/server'
|
|
2
2
|
import { existsSync } from 'node:fs'
|
|
3
3
|
import { resolve } from 'node:path'
|
|
4
|
+
import { execFileSync } from 'node:child_process'
|
|
4
5
|
|
|
5
|
-
const
|
|
6
|
-
const PIN_FILE = resolve(
|
|
7
|
-
const HOME_PIN_FILE = resolve(process.env.HOME ?? '/root', '.maxy', '.admin-pin')
|
|
6
|
+
const PERSISTENT_DIR = resolve(process.env.HOME ?? '/root', '.maxy')
|
|
7
|
+
const PIN_FILE = resolve(PERSISTENT_DIR, '.admin-pin')
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* GET /api/health
|
|
11
11
|
* Returns platform state for the onboarding flow.
|
|
12
12
|
*/
|
|
13
13
|
export async function GET() {
|
|
14
|
-
const pinConfigured = existsSync(PIN_FILE) ||
|
|
14
|
+
const pinConfigured = existsSync(PIN_FILE) || !!process.env.ADMIN_PIN
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
let claudeAuthenticated = false
|
|
17
|
+
try {
|
|
18
|
+
const output = execFileSync('claude', ['auth', 'status', '--json'], {
|
|
19
|
+
encoding: 'utf-8',
|
|
20
|
+
timeout: 5000,
|
|
21
|
+
})
|
|
22
|
+
const status = JSON.parse(output)
|
|
23
|
+
claudeAuthenticated = status.loggedIn === true
|
|
24
|
+
} catch { /* not authenticated */ }
|
|
18
25
|
|
|
19
26
|
return NextResponse.json({
|
|
20
27
|
pin_configured: pinConfigured,
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server'
|
|
2
|
+
import { spawn, execFileSync } from 'node:child_process'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* GET /api/onboarding/claude-auth
|
|
6
|
+
* Check Claude Code auth status.
|
|
7
|
+
*/
|
|
8
|
+
export async function GET() {
|
|
9
|
+
try {
|
|
10
|
+
const output = execFileSync('claude', ['auth', 'status', '--json'], {
|
|
11
|
+
encoding: 'utf-8',
|
|
12
|
+
timeout: 10000,
|
|
13
|
+
env: { ...process.env, BROWSER: 'echo' },
|
|
14
|
+
})
|
|
15
|
+
const status = JSON.parse(output)
|
|
16
|
+
return NextResponse.json({
|
|
17
|
+
authenticated: status.loggedIn === true,
|
|
18
|
+
email: status.email ?? null,
|
|
19
|
+
})
|
|
20
|
+
} catch {
|
|
21
|
+
return NextResponse.json({ authenticated: false, email: null })
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* POST /api/onboarding/claude-auth
|
|
27
|
+
* Start the Claude Code login flow. Returns the auth URL.
|
|
28
|
+
*/
|
|
29
|
+
export async function POST() {
|
|
30
|
+
return new Promise<Response>((resolve) => {
|
|
31
|
+
const child = spawn('claude', ['auth', 'login'], {
|
|
32
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
33
|
+
env: { ...process.env, BROWSER: 'echo' },
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
let output = ''
|
|
37
|
+
let resolved = false
|
|
38
|
+
|
|
39
|
+
function tryResolve() {
|
|
40
|
+
if (resolved) return
|
|
41
|
+
|
|
42
|
+
// Look for the auth URL in the output
|
|
43
|
+
const match = output.match(/(https:\/\/claude\.ai\/oauth\/authorize[^\s]+)/)
|
|
44
|
+
if (match) {
|
|
45
|
+
resolved = true
|
|
46
|
+
resolve(NextResponse.json({ authUrl: match[1] }))
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
child.stdout?.on('data', (chunk: Buffer) => {
|
|
51
|
+
output += chunk.toString()
|
|
52
|
+
tryResolve()
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
child.stderr?.on('data', (chunk: Buffer) => {
|
|
56
|
+
output += chunk.toString()
|
|
57
|
+
tryResolve()
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
// Timeout after 10 seconds
|
|
61
|
+
setTimeout(() => {
|
|
62
|
+
if (!resolved) {
|
|
63
|
+
resolved = true
|
|
64
|
+
child.kill()
|
|
65
|
+
resolve(NextResponse.json(
|
|
66
|
+
{ error: 'Timed out waiting for auth URL.' },
|
|
67
|
+
{ status: 500 },
|
|
68
|
+
))
|
|
69
|
+
}
|
|
70
|
+
}, 10000)
|
|
71
|
+
|
|
72
|
+
child.on('exit', () => {
|
|
73
|
+
if (!resolved) {
|
|
74
|
+
resolved = true
|
|
75
|
+
resolve(NextResponse.json(
|
|
76
|
+
{ error: 'Login process exited without providing auth URL.' },
|
|
77
|
+
{ status: 500 },
|
|
78
|
+
))
|
|
79
|
+
}
|
|
80
|
+
})
|
|
81
|
+
})
|
|
82
|
+
}
|
|
@@ -1376,6 +1376,45 @@ a:hover {
|
|
|
1376
1376
|
border-color: var(--sage);
|
|
1377
1377
|
}
|
|
1378
1378
|
|
|
1379
|
+
.btn-primary {
|
|
1380
|
+
padding: 12px 24px;
|
|
1381
|
+
background: var(--sage);
|
|
1382
|
+
color: white;
|
|
1383
|
+
border: none;
|
|
1384
|
+
border-radius: 24px;
|
|
1385
|
+
font-family: var(--font-body);
|
|
1386
|
+
font-size: 15px;
|
|
1387
|
+
cursor: pointer;
|
|
1388
|
+
transition: background 0.15s;
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
.btn-primary:hover {
|
|
1392
|
+
background: var(--sage-hover);
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
.btn-primary:disabled {
|
|
1396
|
+
opacity: 0.5;
|
|
1397
|
+
cursor: not-allowed;
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
.btn-secondary {
|
|
1401
|
+
padding: 10px 20px;
|
|
1402
|
+
background: none;
|
|
1403
|
+
color: var(--text-secondary);
|
|
1404
|
+
border: 1px solid var(--border-strong);
|
|
1405
|
+
border-radius: 24px;
|
|
1406
|
+
font-family: var(--font-body);
|
|
1407
|
+
font-size: 14px;
|
|
1408
|
+
cursor: pointer;
|
|
1409
|
+
text-decoration: none;
|
|
1410
|
+
text-align: center;
|
|
1411
|
+
transition: border-color 0.15s;
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
.btn-secondary:hover {
|
|
1415
|
+
border-color: var(--sage);
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1379
1418
|
.admin-pin-error {
|
|
1380
1419
|
color: #c44;
|
|
1381
1420
|
font-size: 14px;
|
|
@@ -17,14 +17,14 @@ const dmSans = DM_Sans({
|
|
|
17
17
|
})
|
|
18
18
|
|
|
19
19
|
export const metadata: Metadata = {
|
|
20
|
-
title: 'Maxy —
|
|
20
|
+
title: 'Maxy — Convenience as standard.',
|
|
21
21
|
description: 'A personal AI assistant that lives in your home. Your data never leaves.',
|
|
22
22
|
icons: {
|
|
23
23
|
icon: '/favicon.ico',
|
|
24
24
|
apple: '/apple-icon.png',
|
|
25
25
|
},
|
|
26
26
|
openGraph: {
|
|
27
|
-
title: 'Maxy —
|
|
27
|
+
title: 'Maxy — Convenience as standard.',
|
|
28
28
|
description: 'A personal AI assistant that lives in your home. Your data never leaves.',
|
|
29
29
|
siteName: 'Maxy',
|
|
30
30
|
type: 'website',
|
|
@@ -33,13 +33,13 @@ export const metadata: Metadata = {
|
|
|
33
33
|
url: '/og-landscape.png',
|
|
34
34
|
width: 1200,
|
|
35
35
|
height: 628,
|
|
36
|
-
alt: 'Maxy —
|
|
36
|
+
alt: 'Maxy — Convenience as standard.',
|
|
37
37
|
},
|
|
38
38
|
],
|
|
39
39
|
},
|
|
40
40
|
twitter: {
|
|
41
41
|
card: 'summary_large_image',
|
|
42
|
-
title: 'Maxy —
|
|
42
|
+
title: 'Maxy — Convenience as standard.',
|
|
43
43
|
description: 'A personal AI assistant that lives in your home. Your data never leaves.',
|
|
44
44
|
images: ['/og-landscape.png'],
|
|
45
45
|
},
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
3
|
import { useState, useRef, useEffect, FormEvent } from 'react'
|
|
4
|
+
import { Eye, EyeOff } from 'lucide-react'
|
|
4
5
|
import { ActivityEvent, type AdminEvent } from './admin/components/ActivityEvent'
|
|
5
6
|
|
|
6
|
-
type AppState = 'loading' | 'set-pin' | 'enter-pin' | 'chat'
|
|
7
|
+
type AppState = 'loading' | 'set-pin' | 'enter-pin' | 'connect-claude' | 'chat'
|
|
7
8
|
|
|
8
9
|
interface Message {
|
|
9
10
|
role: 'admin' | 'maxy'
|
|
@@ -17,6 +18,8 @@ export default function AdminPage() {
|
|
|
17
18
|
const [confirmPin, setConfirmPin] = useState('')
|
|
18
19
|
const [pinError, setPinError] = useState('')
|
|
19
20
|
const [showPin, setShowPin] = useState(false)
|
|
21
|
+
const [authUrl, setAuthUrl] = useState<string | null>(null)
|
|
22
|
+
const [authLoading, setAuthLoading] = useState(false)
|
|
20
23
|
const [sessionKey, setSessionKey] = useState<string | null>(null)
|
|
21
24
|
const [messages, setMessages] = useState<Message[]>([])
|
|
22
25
|
const [input, setInput] = useState('')
|
|
@@ -39,7 +42,13 @@ export default function AdminPage() {
|
|
|
39
42
|
return
|
|
40
43
|
}
|
|
41
44
|
const health = await res.json()
|
|
42
|
-
|
|
45
|
+
if (!health.pin_configured) {
|
|
46
|
+
setAppState('set-pin')
|
|
47
|
+
} else if (!health.claude_authenticated) {
|
|
48
|
+
setAppState('connect-claude')
|
|
49
|
+
} else {
|
|
50
|
+
setAppState('enter-pin')
|
|
51
|
+
}
|
|
43
52
|
} catch {
|
|
44
53
|
setAppState('set-pin')
|
|
45
54
|
}
|
|
@@ -82,8 +91,10 @@ export default function AdminPage() {
|
|
|
82
91
|
return
|
|
83
92
|
}
|
|
84
93
|
|
|
85
|
-
// PIN set —
|
|
86
|
-
|
|
94
|
+
// PIN set — move to Claude auth
|
|
95
|
+
setPin('')
|
|
96
|
+
setConfirmPin('')
|
|
97
|
+
setAppState('connect-claude')
|
|
87
98
|
} catch {
|
|
88
99
|
setPinError('Could not connect.')
|
|
89
100
|
}
|
|
@@ -227,7 +238,7 @@ export default function AdminPage() {
|
|
|
227
238
|
autoFocus
|
|
228
239
|
/>
|
|
229
240
|
<button type="button" className="pin-toggle" onClick={() => setShowPin(!showPin)} aria-label={showPin ? 'Hide' : 'Show'}>
|
|
230
|
-
{showPin ?
|
|
241
|
+
{showPin ? <EyeOff size={18} /> : <Eye size={18} />}
|
|
231
242
|
</button>
|
|
232
243
|
</div>
|
|
233
244
|
<div className="pin-input-row">
|
|
@@ -252,6 +263,63 @@ export default function AdminPage() {
|
|
|
252
263
|
)
|
|
253
264
|
}
|
|
254
265
|
|
|
266
|
+
// --- Connect Claude ---
|
|
267
|
+
if (appState === 'connect-claude') {
|
|
268
|
+
async function startAuth() {
|
|
269
|
+
setAuthLoading(true)
|
|
270
|
+
try {
|
|
271
|
+
const res = await fetch('/api/onboarding/claude-auth', { method: 'POST' })
|
|
272
|
+
const data = await res.json()
|
|
273
|
+
if (data.authUrl) {
|
|
274
|
+
setAuthUrl(data.authUrl)
|
|
275
|
+
window.open(data.authUrl, '_blank')
|
|
276
|
+
}
|
|
277
|
+
} catch { /* */ }
|
|
278
|
+
setAuthLoading(false)
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async function checkAuth() {
|
|
282
|
+
const res = await fetch('/api/onboarding/claude-auth')
|
|
283
|
+
const data = await res.json()
|
|
284
|
+
if (data.authenticated) {
|
|
285
|
+
setAppState('enter-pin')
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
return (
|
|
290
|
+
<div className="chat-page admin-page">
|
|
291
|
+
<header className="chat-header">
|
|
292
|
+
<img src="/brand/maxy-black.png" alt="Maxy" className="chat-logo" />
|
|
293
|
+
<h1 className="chat-tagline">Connect Claude</h1>
|
|
294
|
+
<p className="chat-intro">Sign in with your Anthropic account to power Maxy.</p>
|
|
295
|
+
</header>
|
|
296
|
+
<div className="admin-pin-form">
|
|
297
|
+
{!authUrl ? (
|
|
298
|
+
<button
|
|
299
|
+
className="btn-primary"
|
|
300
|
+
onClick={startAuth}
|
|
301
|
+
disabled={authLoading}
|
|
302
|
+
>
|
|
303
|
+
{authLoading ? 'Starting...' : 'Sign in with Claude'}
|
|
304
|
+
</button>
|
|
305
|
+
) : (
|
|
306
|
+
<>
|
|
307
|
+
<p className="chat-intro" style={{ fontSize: '14px', marginTop: 0 }}>
|
|
308
|
+
A browser window has opened. Sign in with Anthropic, then come back here.
|
|
309
|
+
</p>
|
|
310
|
+
<a href={authUrl} target="_blank" rel="noopener noreferrer" className="btn-secondary">
|
|
311
|
+
Open sign-in page again
|
|
312
|
+
</a>
|
|
313
|
+
<button className="btn-primary" onClick={checkAuth} style={{ marginTop: '12px' }}>
|
|
314
|
+
I{'\u2019'}ve signed in
|
|
315
|
+
</button>
|
|
316
|
+
</>
|
|
317
|
+
)}
|
|
318
|
+
</div>
|
|
319
|
+
</div>
|
|
320
|
+
)
|
|
321
|
+
}
|
|
322
|
+
|
|
255
323
|
// --- Enter PIN (returning user) ---
|
|
256
324
|
if (appState === 'enter-pin') {
|
|
257
325
|
return (
|
|
@@ -274,7 +342,7 @@ export default function AdminPage() {
|
|
|
274
342
|
autoFocus
|
|
275
343
|
/>
|
|
276
344
|
<button type="button" className="pin-toggle" onClick={() => setShowPin(!showPin)} aria-label={showPin ? 'Hide' : 'Show'}>
|
|
277
|
-
{showPin ?
|
|
345
|
+
{showPin ? <EyeOff size={18} /> : <Eye size={18} />}
|
|
278
346
|
</button>
|
|
279
347
|
<button type="submit" className="chat-send" disabled={!pin}>
|
|
280
348
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|