@rubytech/create-maxy 0.3.6 → 0.3.8

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/dist/index.js CHANGED
@@ -280,6 +280,8 @@ function deployPayload() {
280
280
  }
281
281
  function buildPlatform() {
282
282
  log("8", TOTAL, "Installing dependencies and building...");
283
+ // Stop the running service before rebuilding (upgrade path)
284
+ spawnSync("systemctl", ["--user", "stop", "maxy"], { stdio: "pipe" });
283
285
  shell("npm", ["install", "--quiet"], { cwd: join(INSTALL_DIR, "platform") });
284
286
  shell("npm", ["run", "build"], { cwd: join(INSTALL_DIR, "platform") });
285
287
  shell("npm", ["install", "--quiet"], { cwd: join(INSTALL_DIR, "maxy") });
@@ -324,10 +326,10 @@ WantedBy=default.target
324
326
  spawnSync("sudo", ["loginctl", "enable-linger", user], { stdio: "inherit" });
325
327
  }
326
328
  catch { /* not critical */ }
327
- // Reload and start
329
+ // Reload and (re)start
328
330
  spawnSync("systemctl", ["--user", "daemon-reload"], { stdio: "inherit" });
329
331
  spawnSync("systemctl", ["--user", "enable", "maxy"], { stdio: "inherit" });
330
- spawnSync("systemctl", ["--user", "start", "maxy"], { stdio: "inherit" });
332
+ spawnSync("systemctl", ["--user", "restart", "maxy"], { stdio: "inherit" });
331
333
  // Wait for the server to come up
332
334
  console.log(" Waiting for web server...");
333
335
  for (let i = 0; i < 20; i++) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rubytech/create-maxy",
3
- "version": "0.3.6",
3
+ "version": "0.3.8",
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
+ }
@@ -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,8 +12,9 @@ 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('')
16
19
  const [sessionKey, setSessionKey] = useState<string | null>(null)
17
20
  const [messages, setMessages] = useState<Message[]>([])
@@ -25,22 +28,80 @@ export default function AdminPage() {
25
28
  messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
26
29
  }, [messages])
27
30
 
31
+ // Check platform state on mount
28
32
  useEffect(() => {
29
- if (authenticated) inputRef.current?.focus()
30
- else pinInputRef.current?.focus()
31
- }, [authenticated])
33
+ async function checkHealth() {
34
+ try {
35
+ const res = await fetch('/api/health')
36
+ if (!res.ok) {
37
+ setAppState('set-pin')
38
+ return
39
+ }
40
+ const health = await res.json()
41
+ setAppState(health.pin_configured ? 'enter-pin' : 'set-pin')
42
+ } catch {
43
+ setAppState('set-pin')
44
+ }
45
+ }
46
+ checkHealth()
47
+ }, [])
32
48
 
33
- async function handlePinSubmit(e: FormEvent) {
49
+ useEffect(() => {
50
+ if (appState === 'set-pin' || appState === 'enter-pin') {
51
+ setTimeout(() => pinInputRef.current?.focus(), 100)
52
+ }
53
+ if (appState === 'chat') {
54
+ setTimeout(() => inputRef.current?.focus(), 100)
55
+ }
56
+ }, [appState])
57
+
58
+ async function handleSetPin(e: FormEvent) {
34
59
  e.preventDefault()
35
60
  setPinError('')
36
61
 
62
+ if (pin.length < 4) {
63
+ setPinError('PIN must be at least 4 characters.')
64
+ return
65
+ }
66
+ if (pin !== confirmPin) {
67
+ setPinError('PINs do not match.')
68
+ return
69
+ }
70
+
37
71
  try {
38
- const res = await fetch('/api/admin/session', {
72
+ const res = await fetch('/api/onboarding/set-pin', {
39
73
  method: 'POST',
40
74
  headers: { 'Content-Type': 'application/json' },
41
75
  body: JSON.stringify({ pin }),
42
76
  })
43
77
 
78
+ if (!res.ok) {
79
+ const data = await res.json().catch(() => ({}))
80
+ setPinError(data.error || 'Failed to set PIN.')
81
+ return
82
+ }
83
+
84
+ // PIN set — now log in with it
85
+ await doLogin(pin)
86
+ } catch {
87
+ setPinError('Could not connect.')
88
+ }
89
+ }
90
+
91
+ async function handleLogin(e: FormEvent) {
92
+ e.preventDefault()
93
+ setPinError('')
94
+ await doLogin(pin)
95
+ }
96
+
97
+ async function doLogin(pinValue: string) {
98
+ try {
99
+ const res = await fetch('/api/admin/session', {
100
+ method: 'POST',
101
+ headers: { 'Content-Type': 'application/json' },
102
+ body: JSON.stringify({ pin: pinValue }),
103
+ })
104
+
44
105
  if (!res.ok) {
45
106
  const data = await res.json().catch(() => ({}))
46
107
  setPinError(data.error || 'Invalid PIN')
@@ -49,7 +110,9 @@ export default function AdminPage() {
49
110
 
50
111
  const data = await res.json()
51
112
  setSessionKey(data.session_key)
52
- setAuthenticated(true)
113
+ setPin('')
114
+ setConfirmPin('')
115
+ setAppState('chat')
53
116
  } catch {
54
117
  setPinError('Could not connect.')
55
118
  }
@@ -97,16 +160,10 @@ export default function AdminPage() {
97
160
  if (payload === '[DONE]') continue
98
161
 
99
162
  let parsed: { type: string; [key: string]: unknown }
100
- try {
101
- parsed = JSON.parse(payload)
102
- } catch {
103
- continue
104
- }
105
-
163
+ try { parsed = JSON.parse(payload) } catch { continue }
106
164
  if (parsed.type === 'done' || parsed.type === 'session_init') continue
107
165
 
108
166
  const event = parsed as AdminEvent
109
-
110
167
  setMessages(prev => {
111
168
  const updated = [...prev]
112
169
  const msg = updated[messageIndex]
@@ -136,12 +193,56 @@ export default function AdminPage() {
136
193
  }
137
194
  }
138
195
 
139
- function handleSubmit(e: FormEvent) {
140
- e.preventDefault()
141
- sendMessage(input.trim())
196
+ // --- Loading ---
197
+ if (appState === 'loading') {
198
+ return (
199
+ <div className="chat-page admin-page">
200
+ <header className="chat-header">
201
+ <img src="/brand/maxy-black.png" alt="Maxy" className="chat-logo" />
202
+ </header>
203
+ </div>
204
+ )
205
+ }
206
+
207
+ // --- Set PIN (first boot) ---
208
+ if (appState === 'set-pin') {
209
+ return (
210
+ <div className="chat-page admin-page">
211
+ <header className="chat-header">
212
+ <img src="/brand/maxy-black.png" alt="Maxy" className="chat-logo" />
213
+ <h1 className="chat-tagline">Welcome to Maxy</h1>
214
+ <p className="chat-intro">Choose a PIN to secure your admin access.</p>
215
+ </header>
216
+ <div className="admin-pin-form">
217
+ <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>
237
+ </form>
238
+ {pinError && <p className="admin-pin-error">{pinError}</p>}
239
+ </div>
240
+ </div>
241
+ )
142
242
  }
143
243
 
144
- if (!authenticated) {
244
+ // --- Enter PIN (returning user) ---
245
+ if (appState === 'enter-pin') {
145
246
  return (
146
247
  <div className="chat-page admin-page">
147
248
  <header className="chat-header">
@@ -150,7 +251,7 @@ export default function AdminPage() {
150
251
  <p className="chat-intro">Convenience as standard.</p>
151
252
  </header>
152
253
  <div className="admin-pin-form">
153
- <form onSubmit={handlePinSubmit}>
254
+ <form onSubmit={handleLogin}>
154
255
  <input
155
256
  ref={pinInputRef}
156
257
  type="password"
@@ -173,6 +274,7 @@ export default function AdminPage() {
173
274
  )
174
275
  }
175
276
 
277
+ // --- Chat ---
176
278
  return (
177
279
  <div className="chat-page admin-page">
178
280
  <header className="chat-header">
@@ -206,7 +308,7 @@ export default function AdminPage() {
206
308
  </div>
207
309
 
208
310
  <div className="chat-input-area">
209
- <form className="chat-form" onSubmit={handleSubmit}>
311
+ <form className="chat-form" onSubmit={(e) => { e.preventDefault(); sendMessage(input.trim()) }}>
210
312
  <input
211
313
  ref={inputRef}
212
314
  className="chat-input"