@rubytech/create-maxy 0.3.7 → 0.3.9

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.7",
3
+ "version": "0.3.9",
4
4
  "description": "Install Maxy — your personal AI assistant",
5
5
  "bin": {
6
6
  "create-maxy": "./dist/index.js"
@@ -2,10 +2,27 @@ import { NextResponse } from 'next/server'
2
2
  import { registerSession } from '../../../lib/claude-agent'
3
3
  import { readFileSync, readdirSync, existsSync } from 'node:fs'
4
4
  import { resolve } from 'node:path'
5
+ import { createHash } from 'node:crypto'
5
6
 
6
- const ADMIN_PIN = process.env.ADMIN_PIN
7
+ const PERSISTENT_DIR = resolve(process.env.HOME ?? '/root', '.maxy')
8
+ const PIN_FILE = resolve(PERSISTENT_DIR, '.admin-pin')
7
9
  const ACCOUNTS_DIR = resolve(process.cwd(), '../platform/config/accounts')
8
10
 
11
+ function hashPin(pin: string): string {
12
+ return createHash('sha256').update(pin).digest('hex')
13
+ }
14
+
15
+ function getStoredPinHash(): string | null {
16
+ if (existsSync(PIN_FILE)) {
17
+ return readFileSync(PIN_FILE, 'utf-8').trim()
18
+ }
19
+ // Fallback to env var (legacy)
20
+ if (process.env.ADMIN_PIN) {
21
+ return hashPin(process.env.ADMIN_PIN)
22
+ }
23
+ return null
24
+ }
25
+
9
26
  function getDefaultAccountId(): string | null {
10
27
  if (!existsSync(ACCOUNTS_DIR)) return null
11
28
  const entries = readdirSync(ACCOUNTS_DIR, { withFileTypes: true })
@@ -25,9 +42,11 @@ function getDefaultAccountId(): string | null {
25
42
  * Body: { pin: string }
26
43
  */
27
44
  export async function POST(req: Request) {
28
- if (!ADMIN_PIN) {
45
+ const storedHash = getStoredPinHash()
46
+
47
+ if (!storedHash) {
29
48
  return NextResponse.json(
30
- { error: 'Admin access is not configured. Set ADMIN_PIN environment variable.' },
49
+ { error: 'PIN not configured. Complete onboarding first.' },
31
50
  { status: 503 },
32
51
  )
33
52
  }
@@ -39,14 +58,11 @@ export async function POST(req: Request) {
39
58
  return NextResponse.json({ error: 'Invalid request' }, { status: 400 })
40
59
  }
41
60
 
42
- if (!body.pin || body.pin !== ADMIN_PIN) {
61
+ if (!body.pin || hashPin(body.pin) !== storedHash) {
43
62
  return NextResponse.json({ error: 'Invalid PIN' }, { status: 401 })
44
63
  }
45
64
 
46
- const accountId = getDefaultAccountId()
47
- if (!accountId) {
48
- return NextResponse.json({ error: 'No account configured' }, { status: 503 })
49
- }
65
+ const accountId = getDefaultAccountId() ?? 'default'
50
66
 
51
67
  const sessionKey = crypto.randomUUID()
52
68
  registerSession(sessionKey, 'admin', accountId)
@@ -0,0 +1,23 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { existsSync } from 'node:fs'
3
+ import { resolve } from 'node:path'
4
+
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')
8
+
9
+ /**
10
+ * GET /api/health
11
+ * Returns platform state for the onboarding flow.
12
+ */
13
+ export async function GET() {
14
+ const pinConfigured = existsSync(PIN_FILE) || existsSync(HOME_PIN_FILE) || !!process.env.ADMIN_PIN
15
+
16
+ // TODO: check Claude Code auth status
17
+ const claudeAuthenticated = false
18
+
19
+ return NextResponse.json({
20
+ pin_configured: pinConfigured,
21
+ claude_authenticated: claudeAuthenticated,
22
+ })
23
+ }
@@ -0,0 +1,44 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { existsSync, writeFileSync, mkdirSync, readFileSync } from 'node:fs'
3
+ import { resolve } from 'node:path'
4
+ import { createHash } from 'node:crypto'
5
+
6
+ const PERSISTENT_DIR = resolve(process.env.HOME ?? '/root', '.maxy')
7
+ const PIN_FILE = resolve(PERSISTENT_DIR, '.admin-pin')
8
+
9
+ function hashPin(pin: string): string {
10
+ return createHash('sha256').update(pin).digest('hex')
11
+ }
12
+
13
+ /**
14
+ * POST /api/onboarding/set-pin
15
+ * Sets the admin PIN. Only works when no PIN is configured.
16
+ * Body: { pin: string }
17
+ */
18
+ export async function POST(req: Request) {
19
+ if (existsSync(PIN_FILE)) {
20
+ return NextResponse.json(
21
+ { error: 'PIN is already configured.' },
22
+ { status: 409 },
23
+ )
24
+ }
25
+
26
+ let body: { pin: string }
27
+ try {
28
+ body = await req.json()
29
+ } catch {
30
+ return NextResponse.json({ error: 'Invalid request' }, { status: 400 })
31
+ }
32
+
33
+ if (!body.pin || body.pin.length < 4) {
34
+ return NextResponse.json(
35
+ { error: 'PIN must be at least 4 characters.' },
36
+ { status: 400 },
37
+ )
38
+ }
39
+
40
+ mkdirSync(PERSISTENT_DIR, { recursive: true })
41
+ writeFileSync(PIN_FILE, hashPin(body.pin), { mode: 0o600 })
42
+
43
+ return NextResponse.json({ ok: true })
44
+ }
@@ -1340,11 +1340,42 @@ 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
+
1348
1379
  .admin-pin-error {
1349
1380
  color: #c44;
1350
1381
  font-size: 14px;
@@ -3,6 +3,8 @@
3
3
  import { useState, useRef, useEffect, FormEvent } from 'react'
4
4
  import { ActivityEvent, type AdminEvent } from './admin/components/ActivityEvent'
5
5
 
6
+ type AppState = 'loading' | 'set-pin' | 'enter-pin' | 'chat'
7
+
6
8
  interface Message {
7
9
  role: 'admin' | 'maxy'
8
10
  content?: string
@@ -10,9 +12,11 @@ interface Message {
10
12
  }
11
13
 
12
14
  export default function AdminPage() {
13
- const [authenticated, setAuthenticated] = useState(false)
15
+ const [appState, setAppState] = useState<AppState>('loading')
14
16
  const [pin, setPin] = useState('')
17
+ const [confirmPin, setConfirmPin] = useState('')
15
18
  const [pinError, setPinError] = useState('')
19
+ const [showPin, setShowPin] = useState(false)
16
20
  const [sessionKey, setSessionKey] = useState<string | null>(null)
17
21
  const [messages, setMessages] = useState<Message[]>([])
18
22
  const [input, setInput] = useState('')
@@ -25,22 +29,80 @@ export default function AdminPage() {
25
29
  messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
26
30
  }, [messages])
27
31
 
32
+ // Check platform state on mount
28
33
  useEffect(() => {
29
- if (authenticated) inputRef.current?.focus()
30
- else pinInputRef.current?.focus()
31
- }, [authenticated])
34
+ async function checkHealth() {
35
+ try {
36
+ const res = await fetch('/api/health')
37
+ if (!res.ok) {
38
+ setAppState('set-pin')
39
+ return
40
+ }
41
+ const health = await res.json()
42
+ setAppState(health.pin_configured ? 'enter-pin' : 'set-pin')
43
+ } catch {
44
+ setAppState('set-pin')
45
+ }
46
+ }
47
+ checkHealth()
48
+ }, [])
49
+
50
+ useEffect(() => {
51
+ if (appState === 'set-pin' || appState === 'enter-pin') {
52
+ setTimeout(() => pinInputRef.current?.focus(), 100)
53
+ }
54
+ if (appState === 'chat') {
55
+ setTimeout(() => inputRef.current?.focus(), 100)
56
+ }
57
+ }, [appState])
32
58
 
33
- async function handlePinSubmit(e: FormEvent) {
59
+ async function handleSetPin(e: FormEvent) {
34
60
  e.preventDefault()
35
61
  setPinError('')
36
62
 
63
+ if (pin.length < 4) {
64
+ setPinError('PIN must be at least 4 characters.')
65
+ return
66
+ }
67
+ if (pin !== confirmPin) {
68
+ setPinError('PINs do not match.')
69
+ return
70
+ }
71
+
37
72
  try {
38
- const res = await fetch('/api/admin/session', {
73
+ const res = await fetch('/api/onboarding/set-pin', {
39
74
  method: 'POST',
40
75
  headers: { 'Content-Type': 'application/json' },
41
76
  body: JSON.stringify({ pin }),
42
77
  })
43
78
 
79
+ if (!res.ok) {
80
+ const data = await res.json().catch(() => ({}))
81
+ setPinError(data.error || 'Failed to set PIN.')
82
+ return
83
+ }
84
+
85
+ // PIN set — now log in with it
86
+ await doLogin(pin)
87
+ } catch {
88
+ setPinError('Could not connect.')
89
+ }
90
+ }
91
+
92
+ async function handleLogin(e: FormEvent) {
93
+ e.preventDefault()
94
+ setPinError('')
95
+ await doLogin(pin)
96
+ }
97
+
98
+ async function doLogin(pinValue: string) {
99
+ try {
100
+ const res = await fetch('/api/admin/session', {
101
+ method: 'POST',
102
+ headers: { 'Content-Type': 'application/json' },
103
+ body: JSON.stringify({ pin: pinValue }),
104
+ })
105
+
44
106
  if (!res.ok) {
45
107
  const data = await res.json().catch(() => ({}))
46
108
  setPinError(data.error || 'Invalid PIN')
@@ -49,7 +111,9 @@ export default function AdminPage() {
49
111
 
50
112
  const data = await res.json()
51
113
  setSessionKey(data.session_key)
52
- setAuthenticated(true)
114
+ setPin('')
115
+ setConfirmPin('')
116
+ setAppState('chat')
53
117
  } catch {
54
118
  setPinError('Could not connect.')
55
119
  }
@@ -97,16 +161,10 @@ export default function AdminPage() {
97
161
  if (payload === '[DONE]') continue
98
162
 
99
163
  let parsed: { type: string; [key: string]: unknown }
100
- try {
101
- parsed = JSON.parse(payload)
102
- } catch {
103
- continue
104
- }
105
-
164
+ try { parsed = JSON.parse(payload) } catch { continue }
106
165
  if (parsed.type === 'done' || parsed.type === 'session_init') continue
107
166
 
108
167
  const event = parsed as AdminEvent
109
-
110
168
  setMessages(prev => {
111
169
  const updated = [...prev]
112
170
  const msg = updated[messageIndex]
@@ -136,12 +194,66 @@ export default function AdminPage() {
136
194
  }
137
195
  }
138
196
 
139
- function handleSubmit(e: FormEvent) {
140
- e.preventDefault()
141
- sendMessage(input.trim())
197
+ // --- Loading ---
198
+ if (appState === 'loading') {
199
+ return (
200
+ <div className="chat-page admin-page">
201
+ <header className="chat-header">
202
+ <img src="/brand/maxy-black.png" alt="Maxy" className="chat-logo" />
203
+ </header>
204
+ </div>
205
+ )
206
+ }
207
+
208
+ // --- Set PIN (first boot) ---
209
+ if (appState === 'set-pin') {
210
+ return (
211
+ <div className="chat-page admin-page">
212
+ <header className="chat-header">
213
+ <img src="/brand/maxy-black.png" alt="Maxy" className="chat-logo" />
214
+ <h1 className="chat-tagline">Welcome to Maxy</h1>
215
+ <p className="chat-intro">Choose a PIN to secure your admin access.</p>
216
+ </header>
217
+ <div className="admin-pin-form">
218
+ <form onSubmit={handleSetPin}>
219
+ <div className="pin-input-row">
220
+ <input
221
+ ref={pinInputRef}
222
+ type={showPin ? 'text' : 'password'}
223
+ value={pin}
224
+ onChange={e => setPin(e.target.value)}
225
+ placeholder="Choose a PIN"
226
+ className="chat-input"
227
+ autoFocus
228
+ />
229
+ <button type="button" className="pin-toggle" onClick={() => setShowPin(!showPin)} aria-label={showPin ? 'Hide' : 'Show'}>
230
+ {showPin ? '◉' : '○'}
231
+ </button>
232
+ </div>
233
+ <div className="pin-input-row">
234
+ <input
235
+ type={showPin ? 'text' : 'password'}
236
+ value={confirmPin}
237
+ onChange={e => setConfirmPin(e.target.value)}
238
+ placeholder="Confirm PIN"
239
+ className="chat-input"
240
+ />
241
+ <button type="submit" className="chat-send" disabled={!pin || !confirmPin} aria-label="Set PIN">
242
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
243
+ <line x1="5" y1="12" x2="19" y2="12" />
244
+ <polyline points="12 5 19 12 12 19" />
245
+ </svg>
246
+ </button>
247
+ </div>
248
+ </form>
249
+ {pinError && <p className="admin-pin-error">{pinError}</p>}
250
+ </div>
251
+ </div>
252
+ )
142
253
  }
143
254
 
144
- if (!authenticated) {
255
+ // --- Enter PIN (returning user) ---
256
+ if (appState === 'enter-pin') {
145
257
  return (
146
258
  <div className="chat-page admin-page">
147
259
  <header className="chat-header">
@@ -150,22 +262,27 @@ export default function AdminPage() {
150
262
  <p className="chat-intro">Convenience as standard.</p>
151
263
  </header>
152
264
  <div className="admin-pin-form">
153
- <form onSubmit={handlePinSubmit}>
154
- <input
155
- ref={pinInputRef}
156
- type="password"
157
- value={pin}
158
- onChange={e => setPin(e.target.value)}
159
- placeholder="Enter PIN"
160
- className="chat-input"
161
- autoFocus
162
- />
163
- <button type="submit" className="chat-send" disabled={!pin}>
164
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
165
- <line x1="5" y1="12" x2="19" y2="12" />
166
- <polyline points="12 5 19 12 12 19" />
167
- </svg>
168
- </button>
265
+ <form onSubmit={handleLogin}>
266
+ <div className="pin-input-row">
267
+ <input
268
+ ref={pinInputRef}
269
+ type={showPin ? 'text' : 'password'}
270
+ value={pin}
271
+ onChange={e => setPin(e.target.value)}
272
+ placeholder="Enter PIN"
273
+ className="chat-input"
274
+ autoFocus
275
+ />
276
+ <button type="button" className="pin-toggle" onClick={() => setShowPin(!showPin)} aria-label={showPin ? 'Hide' : 'Show'}>
277
+ {showPin ? '◉' : '○'}
278
+ </button>
279
+ <button type="submit" className="chat-send" disabled={!pin}>
280
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
281
+ <line x1="5" y1="12" x2="19" y2="12" />
282
+ <polyline points="12 5 19 12 12 19" />
283
+ </svg>
284
+ </button>
285
+ </div>
169
286
  </form>
170
287
  {pinError && <p className="admin-pin-error">{pinError}</p>}
171
288
  </div>
@@ -173,6 +290,7 @@ export default function AdminPage() {
173
290
  )
174
291
  }
175
292
 
293
+ // --- Chat ---
176
294
  return (
177
295
  <div className="chat-page admin-page">
178
296
  <header className="chat-header">
@@ -206,7 +324,7 @@ export default function AdminPage() {
206
324
  </div>
207
325
 
208
326
  <div className="chat-input-area">
209
- <form className="chat-form" onSubmit={handleSubmit}>
327
+ <form className="chat-form" onSubmit={(e) => { e.preventDefault(); sendMessage(input.trim()) }}>
210
328
  <input
211
329
  ref={inputRef}
212
330
  className="chat-input"