@rubytech/create-maxy 0.3.8 → 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,6 +1,6 @@
1
1
  {
2
2
  "name": "@rubytech/create-maxy",
3
- "version": "0.3.8",
3
+ "version": "0.4.0",
4
4
  "description": "Install Maxy — your personal AI assistant",
5
5
  "bin": {
6
6
  "create-maxy": "./dist/index.js"
@@ -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 INSTALL_DIR = resolve(process.cwd(), '..')
6
- const PIN_FILE = resolve(INSTALL_DIR, '.maxy', '.admin-pin')
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) || existsSync(HOME_PIN_FILE) || !!process.env.ADMIN_PIN
14
+ const pinConfigured = existsSync(PIN_FILE) || !!process.env.ADMIN_PIN
15
15
 
16
- // TODO: check Claude Code auth status
17
- const claudeAuthenticated = false
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
+ }
@@ -1340,11 +1340,81 @@ a:hover {
1340
1340
 
1341
1341
  .admin-pin-form form {
1342
1342
  display: flex;
1343
+ flex-direction: column;
1343
1344
  gap: 8px;
1344
1345
  width: 100%;
1345
1346
  max-width: 300px;
1346
1347
  }
1347
1348
 
1349
+ .pin-input-row {
1350
+ display: flex;
1351
+ gap: 8px;
1352
+ align-items: center;
1353
+ }
1354
+
1355
+ .pin-input-row .chat-input {
1356
+ flex: 1;
1357
+ }
1358
+
1359
+ .pin-toggle {
1360
+ background: none;
1361
+ border: 1px solid var(--border-strong);
1362
+ border-radius: 50%;
1363
+ width: 40px;
1364
+ height: 40px;
1365
+ cursor: pointer;
1366
+ font-size: 16px;
1367
+ color: var(--text-secondary);
1368
+ flex-shrink: 0;
1369
+ display: flex;
1370
+ align-items: center;
1371
+ justify-content: center;
1372
+ transition: border-color 0.15s;
1373
+ }
1374
+
1375
+ .pin-toggle:hover {
1376
+ border-color: var(--sage);
1377
+ }
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
+
1348
1418
  .admin-pin-error {
1349
1419
  color: #c44;
1350
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 — No Stress. Quiet Life.',
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 — No Stress. Quiet Life.',
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 — No Stress. Quiet Life.',
36
+ alt: 'Maxy — Convenience as standard.',
37
37
  },
38
38
  ],
39
39
  },
40
40
  twitter: {
41
41
  card: 'summary_large_image',
42
- title: 'Maxy — No Stress. Quiet Life.',
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'
@@ -16,6 +17,9 @@ export default function AdminPage() {
16
17
  const [pin, setPin] = useState('')
17
18
  const [confirmPin, setConfirmPin] = useState('')
18
19
  const [pinError, setPinError] = useState('')
20
+ const [showPin, setShowPin] = useState(false)
21
+ const [authUrl, setAuthUrl] = useState<string | null>(null)
22
+ const [authLoading, setAuthLoading] = useState(false)
19
23
  const [sessionKey, setSessionKey] = useState<string | null>(null)
20
24
  const [messages, setMessages] = useState<Message[]>([])
21
25
  const [input, setInput] = useState('')
@@ -38,7 +42,13 @@ export default function AdminPage() {
38
42
  return
39
43
  }
40
44
  const health = await res.json()
41
- setAppState(health.pin_configured ? 'enter-pin' : 'set-pin')
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
+ }
42
52
  } catch {
43
53
  setAppState('set-pin')
44
54
  }
@@ -81,8 +91,10 @@ export default function AdminPage() {
81
91
  return
82
92
  }
83
93
 
84
- // PIN set — now log in with it
85
- await doLogin(pin)
94
+ // PIN set — move to Claude auth
95
+ setPin('')
96
+ setConfirmPin('')
97
+ setAppState('connect-claude')
86
98
  } catch {
87
99
  setPinError('Could not connect.')
88
100
  }
@@ -215,25 +227,35 @@ export default function AdminPage() {
215
227
  </header>
216
228
  <div className="admin-pin-form">
217
229
  <form onSubmit={handleSetPin}>
218
- <input
219
- ref={pinInputRef}
220
- type="password"
221
- value={pin}
222
- onChange={e => setPin(e.target.value)}
223
- placeholder="Choose a PIN"
224
- className="chat-input"
225
- autoFocus
226
- />
227
- <input
228
- type="password"
229
- value={confirmPin}
230
- onChange={e => setConfirmPin(e.target.value)}
231
- placeholder="Confirm PIN"
232
- className="chat-input"
233
- />
234
- <button type="submit" className="chat-send" disabled={!pin || !confirmPin}>
235
- Set PIN
236
- </button>
230
+ <div className="pin-input-row">
231
+ <input
232
+ ref={pinInputRef}
233
+ type={showPin ? 'text' : 'password'}
234
+ value={pin}
235
+ onChange={e => setPin(e.target.value)}
236
+ placeholder="Choose a PIN"
237
+ className="chat-input"
238
+ autoFocus
239
+ />
240
+ <button type="button" className="pin-toggle" onClick={() => setShowPin(!showPin)} aria-label={showPin ? 'Hide' : 'Show'}>
241
+ {showPin ? <EyeOff size={18} /> : <Eye size={18} />}
242
+ </button>
243
+ </div>
244
+ <div className="pin-input-row">
245
+ <input
246
+ type={showPin ? 'text' : 'password'}
247
+ value={confirmPin}
248
+ onChange={e => setConfirmPin(e.target.value)}
249
+ placeholder="Confirm PIN"
250
+ className="chat-input"
251
+ />
252
+ <button type="submit" className="chat-send" disabled={!pin || !confirmPin} aria-label="Set PIN">
253
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
254
+ <line x1="5" y1="12" x2="19" y2="12" />
255
+ <polyline points="12 5 19 12 12 19" />
256
+ </svg>
257
+ </button>
258
+ </div>
237
259
  </form>
238
260
  {pinError && <p className="admin-pin-error">{pinError}</p>}
239
261
  </div>
@@ -241,6 +263,63 @@ export default function AdminPage() {
241
263
  )
242
264
  }
243
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
+
244
323
  // --- Enter PIN (returning user) ---
245
324
  if (appState === 'enter-pin') {
246
325
  return (
@@ -252,21 +331,26 @@ export default function AdminPage() {
252
331
  </header>
253
332
  <div className="admin-pin-form">
254
333
  <form onSubmit={handleLogin}>
255
- <input
256
- ref={pinInputRef}
257
- type="password"
258
- value={pin}
259
- onChange={e => setPin(e.target.value)}
260
- placeholder="Enter PIN"
261
- className="chat-input"
262
- autoFocus
263
- />
264
- <button type="submit" className="chat-send" disabled={!pin}>
265
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
266
- <line x1="5" y1="12" x2="19" y2="12" />
267
- <polyline points="12 5 19 12 12 19" />
268
- </svg>
269
- </button>
334
+ <div className="pin-input-row">
335
+ <input
336
+ ref={pinInputRef}
337
+ type={showPin ? 'text' : 'password'}
338
+ value={pin}
339
+ onChange={e => setPin(e.target.value)}
340
+ placeholder="Enter PIN"
341
+ className="chat-input"
342
+ autoFocus
343
+ />
344
+ <button type="button" className="pin-toggle" onClick={() => setShowPin(!showPin)} aria-label={showPin ? 'Hide' : 'Show'}>
345
+ {showPin ? <EyeOff size={18} /> : <Eye size={18} />}
346
+ </button>
347
+ <button type="submit" className="chat-send" disabled={!pin}>
348
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
349
+ <line x1="5" y1="12" x2="19" y2="12" />
350
+ <polyline points="12 5 19 12 12 19" />
351
+ </svg>
352
+ </button>
353
+ </div>
270
354
  </form>
271
355
  {pinError && <p className="admin-pin-error">{pinError}</p>}
272
356
  </div>