@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", "
|
|
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
|
@@ -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
|
|
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
|
-
|
|
45
|
+
const storedHash = getStoredPinHash()
|
|
46
|
+
|
|
47
|
+
if (!storedHash) {
|
|
29
48
|
return NextResponse.json(
|
|
30
|
-
{ error: '
|
|
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 !==
|
|
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 [
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
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/
|
|
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
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
|
|
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={
|
|
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={
|
|
311
|
+
<form className="chat-form" onSubmit={(e) => { e.preventDefault(); sendMessage(input.trim()) }}>
|
|
210
312
|
<input
|
|
211
313
|
ref={inputRef}
|
|
212
314
|
className="chat-input"
|