prjct-cli 0.12.0 → 0.12.2
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/bin/serve.js +12 -30
- package/package.json +1 -1
- package/packages/web/app/page.tsx +6 -1
- package/packages/web/components/AppSidebar/AppSidebar.tsx +97 -39
- package/packages/web/components/MigrationGate/MigrationGate.tsx +304 -0
- package/packages/web/components/MigrationGate/index.ts +1 -0
- package/packages/web/lib/services/migration.server.ts +4 -22
- package/packages/web/server.ts +22 -2
package/bin/serve.js
CHANGED
|
@@ -65,55 +65,39 @@ function writeState(state) {
|
|
|
65
65
|
}
|
|
66
66
|
|
|
67
67
|
/**
|
|
68
|
-
* Get package.json version
|
|
68
|
+
* Get package.json version
|
|
69
69
|
*/
|
|
70
|
-
function
|
|
70
|
+
function getPackageVersion(pkgPath) {
|
|
71
71
|
try {
|
|
72
72
|
const pkg = JSON.parse(fs.readFileSync(path.join(pkgPath, 'package.json'), 'utf8'))
|
|
73
|
-
|
|
74
|
-
// Simple hash of dependencies
|
|
75
|
-
let hash = 0
|
|
76
|
-
for (let i = 0; i < deps.length; i++) {
|
|
77
|
-
const char = deps.charCodeAt(i)
|
|
78
|
-
hash = ((hash << 5) - hash) + char
|
|
79
|
-
hash = hash & hash // Convert to 32bit integer
|
|
80
|
-
}
|
|
81
|
-
return { version: pkg.version, depsHash: hash.toString(16) }
|
|
73
|
+
return pkg.version || '0.0.0'
|
|
82
74
|
} catch {
|
|
83
|
-
return
|
|
75
|
+
return '0.0.0'
|
|
84
76
|
}
|
|
85
77
|
}
|
|
86
78
|
|
|
87
79
|
/**
|
|
88
80
|
* Check if dependencies need to be installed
|
|
81
|
+
* Simple logic: install only if node_modules missing OR version changed
|
|
89
82
|
*/
|
|
90
83
|
function needsInstall(pkgDir, stateKey) {
|
|
91
84
|
const nodeModules = path.join(pkgDir, 'node_modules')
|
|
92
85
|
|
|
93
|
-
// If node_modules doesn't exist,
|
|
86
|
+
// If node_modules doesn't exist, need install
|
|
94
87
|
if (!fs.existsSync(nodeModules)) {
|
|
95
88
|
return { needed: true, reason: 'node_modules not found' }
|
|
96
89
|
}
|
|
97
90
|
|
|
98
91
|
const state = readState()
|
|
99
|
-
const
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
// If no saved state, need install to track
|
|
103
|
-
if (!savedInfo) {
|
|
104
|
-
return { needed: true, reason: 'first time tracking' }
|
|
105
|
-
}
|
|
92
|
+
const currentVersion = getPackageVersion(pkgDir)
|
|
93
|
+
const savedVersion = state[stateKey]?.version
|
|
106
94
|
|
|
107
95
|
// If version changed, need install
|
|
108
|
-
if (
|
|
109
|
-
return { needed: true, reason:
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
// If dependencies hash changed, need install
|
|
113
|
-
if (savedInfo.depsHash !== pkgInfo.depsHash) {
|
|
114
|
-
return { needed: true, reason: 'dependencies changed' }
|
|
96
|
+
if (savedVersion && savedVersion !== currentVersion) {
|
|
97
|
+
return { needed: true, reason: `${savedVersion} → ${currentVersion}` }
|
|
115
98
|
}
|
|
116
99
|
|
|
100
|
+
// node_modules exists and version unchanged = skip
|
|
117
101
|
return { needed: false }
|
|
118
102
|
}
|
|
119
103
|
|
|
@@ -122,10 +106,8 @@ function needsInstall(pkgDir, stateKey) {
|
|
|
122
106
|
*/
|
|
123
107
|
function markInstalled(pkgDir, stateKey) {
|
|
124
108
|
const state = readState()
|
|
125
|
-
const pkgInfo = getPackageInfo(pkgDir)
|
|
126
109
|
state[stateKey] = {
|
|
127
|
-
version:
|
|
128
|
-
depsHash: pkgInfo.depsHash,
|
|
110
|
+
version: getPackageVersion(pkgDir),
|
|
129
111
|
installedAt: new Date().toISOString()
|
|
130
112
|
}
|
|
131
113
|
writeState(state)
|
package/package.json
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { getProjects } from '@/lib/services/projects.server'
|
|
2
2
|
import { getGlobalStats } from '@/lib/services/stats.server'
|
|
3
3
|
import { DashboardContent } from '@/components/DashboardContent'
|
|
4
|
+
import { MigrationGate } from '@/components/MigrationGate'
|
|
4
5
|
|
|
5
6
|
export default async function Dashboard() {
|
|
6
7
|
const [projects, stats] = await Promise.all([
|
|
@@ -8,5 +9,9 @@ export default async function Dashboard() {
|
|
|
8
9
|
getGlobalStats()
|
|
9
10
|
])
|
|
10
11
|
|
|
11
|
-
return
|
|
12
|
+
return (
|
|
13
|
+
<MigrationGate>
|
|
14
|
+
<DashboardContent projects={projects} stats={stats} />
|
|
15
|
+
</MigrationGate>
|
|
16
|
+
)
|
|
12
17
|
}
|
|
@@ -7,81 +7,132 @@ import {
|
|
|
7
7
|
Settings,
|
|
8
8
|
HelpCircle,
|
|
9
9
|
Menu,
|
|
10
|
+
PanelLeft,
|
|
10
11
|
} from 'lucide-react'
|
|
11
12
|
import { Logo } from '@/components/Logo'
|
|
12
13
|
import { cn } from '@/lib/utils'
|
|
13
14
|
import { useState, useEffect } from 'react'
|
|
14
15
|
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet'
|
|
16
|
+
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
|
15
17
|
|
|
16
18
|
const navItems = [
|
|
17
19
|
{ href: '/', icon: LayoutDashboard, label: 'Dashboard' },
|
|
18
20
|
]
|
|
19
21
|
|
|
20
|
-
function SidebarContent({
|
|
22
|
+
function SidebarContent({
|
|
23
|
+
onNavigate,
|
|
24
|
+
isCollapsed = false,
|
|
25
|
+
onToggleCollapse
|
|
26
|
+
}: {
|
|
27
|
+
onNavigate?: () => void
|
|
28
|
+
isCollapsed?: boolean
|
|
29
|
+
onToggleCollapse?: () => void
|
|
30
|
+
}) {
|
|
21
31
|
const pathname = usePathname()
|
|
22
32
|
|
|
23
33
|
return (
|
|
24
34
|
<>
|
|
25
35
|
{/* Header */}
|
|
26
|
-
<div className=
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
36
|
+
<div className={cn(
|
|
37
|
+
"flex h-14 items-center border-b border-border",
|
|
38
|
+
isCollapsed ? "justify-center px-2" : "justify-between px-3"
|
|
39
|
+
)}>
|
|
40
|
+
{!isCollapsed && (
|
|
41
|
+
<Link href="/" onClick={onNavigate}>
|
|
42
|
+
<Logo size="xs" showText rounded />
|
|
43
|
+
</Link>
|
|
44
|
+
)}
|
|
45
|
+
{onToggleCollapse && (
|
|
46
|
+
<Tooltip>
|
|
47
|
+
<TooltipTrigger asChild>
|
|
48
|
+
<button
|
|
49
|
+
onClick={onToggleCollapse}
|
|
50
|
+
className="h-8 w-8 rounded-md flex items-center justify-center text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors"
|
|
51
|
+
aria-label={isCollapsed ? "Expand sidebar" : "Collapse sidebar"}
|
|
52
|
+
>
|
|
53
|
+
<PanelLeft className="h-4 w-4" />
|
|
54
|
+
</button>
|
|
55
|
+
</TooltipTrigger>
|
|
56
|
+
<TooltipContent side="right">
|
|
57
|
+
{isCollapsed ? "Expand" : "Collapse"}
|
|
58
|
+
</TooltipContent>
|
|
59
|
+
</Tooltip>
|
|
60
|
+
)}
|
|
30
61
|
</div>
|
|
31
62
|
|
|
32
63
|
{/* Nav */}
|
|
33
|
-
<nav className="flex-1 overflow-y-auto py-4 px-3">
|
|
64
|
+
<nav className={cn("flex-1 overflow-y-auto py-4", isCollapsed ? "px-2" : "px-3")}>
|
|
34
65
|
<div className="space-y-1">
|
|
35
66
|
{navItems.map(({ href, icon: Icon, label }) => {
|
|
36
67
|
const isActive = pathname === href || (href !== '/' && pathname.startsWith(href))
|
|
37
|
-
|
|
68
|
+
const linkContent = (
|
|
38
69
|
<Link
|
|
39
70
|
key={href}
|
|
40
71
|
href={href}
|
|
41
72
|
onClick={onNavigate}
|
|
42
73
|
className={cn(
|
|
43
|
-
'flex items-center
|
|
74
|
+
'flex items-center rounded-md transition-colors min-h-[44px]',
|
|
75
|
+
isCollapsed ? 'justify-center px-2' : 'gap-3 px-3',
|
|
76
|
+
'py-2.5',
|
|
44
77
|
isActive
|
|
45
78
|
? 'bg-accent text-accent-foreground'
|
|
46
79
|
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
|
|
47
80
|
)}
|
|
48
81
|
>
|
|
49
82
|
<Icon className="h-5 w-5 shrink-0" />
|
|
50
|
-
<span className="text-sm font-medium">{label}</span>
|
|
83
|
+
{!isCollapsed && <span className="text-sm font-medium">{label}</span>}
|
|
51
84
|
</Link>
|
|
52
85
|
)
|
|
86
|
+
|
|
87
|
+
if (isCollapsed) {
|
|
88
|
+
return (
|
|
89
|
+
<Tooltip key={href}>
|
|
90
|
+
<TooltipTrigger asChild>{linkContent}</TooltipTrigger>
|
|
91
|
+
<TooltipContent side="right">{label}</TooltipContent>
|
|
92
|
+
</Tooltip>
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
return linkContent
|
|
53
96
|
})}
|
|
54
97
|
</div>
|
|
55
98
|
</nav>
|
|
56
99
|
|
|
57
100
|
{/* Footer */}
|
|
58
|
-
<div className="border-t border-border
|
|
59
|
-
|
|
60
|
-
href
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
101
|
+
<div className={cn("border-t border-border space-y-1", isCollapsed ? "p-2" : "p-3")}>
|
|
102
|
+
{[
|
|
103
|
+
{ href: '/settings', icon: Settings, label: 'Settings' },
|
|
104
|
+
{ href: '/help', icon: HelpCircle, label: 'Need help?' }
|
|
105
|
+
].map(({ href, icon: Icon, label }) => {
|
|
106
|
+
const isActive = pathname === href
|
|
107
|
+
const linkContent = (
|
|
108
|
+
<Link
|
|
109
|
+
key={href}
|
|
110
|
+
href={href}
|
|
111
|
+
onClick={onNavigate}
|
|
112
|
+
className={cn(
|
|
113
|
+
'flex items-center rounded-md transition-colors min-h-[44px]',
|
|
114
|
+
isCollapsed ? 'justify-center px-2' : 'gap-3 px-3',
|
|
115
|
+
'py-2.5',
|
|
116
|
+
isActive
|
|
117
|
+
? 'bg-accent text-accent-foreground'
|
|
118
|
+
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
|
|
119
|
+
)}
|
|
120
|
+
>
|
|
121
|
+
<Icon className="h-5 w-5 shrink-0" />
|
|
122
|
+
{!isCollapsed && <span className="text-sm font-medium">{label}</span>}
|
|
123
|
+
</Link>
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
if (isCollapsed) {
|
|
127
|
+
return (
|
|
128
|
+
<Tooltip key={href}>
|
|
129
|
+
<TooltipTrigger asChild>{linkContent}</TooltipTrigger>
|
|
130
|
+
<TooltipContent side="right">{label}</TooltipContent>
|
|
131
|
+
</Tooltip>
|
|
132
|
+
)
|
|
133
|
+
}
|
|
134
|
+
return linkContent
|
|
135
|
+
})}
|
|
85
136
|
</div>
|
|
86
137
|
</>
|
|
87
138
|
)
|
|
@@ -90,6 +141,7 @@ function SidebarContent({ onNavigate }: { onNavigate?: () => void }) {
|
|
|
90
141
|
export function AppSidebar() {
|
|
91
142
|
const [isOpen, setIsOpen] = useState(false)
|
|
92
143
|
const [isMobile, setIsMobile] = useState(false)
|
|
144
|
+
const [isCollapsed, setIsCollapsed] = useState(false)
|
|
93
145
|
|
|
94
146
|
// Detect mobile on mount and resize
|
|
95
147
|
useEffect(() => {
|
|
@@ -120,10 +172,16 @@ export function AppSidebar() {
|
|
|
120
172
|
)
|
|
121
173
|
}
|
|
122
174
|
|
|
123
|
-
// Desktop:
|
|
175
|
+
// Desktop: Collapsible sidebar
|
|
124
176
|
return (
|
|
125
|
-
<aside className=
|
|
126
|
-
|
|
177
|
+
<aside className={cn(
|
|
178
|
+
"hidden md:flex h-full flex-col border-r border-border bg-card transition-all duration-200",
|
|
179
|
+
isCollapsed ? "w-14" : "w-60"
|
|
180
|
+
)}>
|
|
181
|
+
<SidebarContent
|
|
182
|
+
isCollapsed={isCollapsed}
|
|
183
|
+
onToggleCollapse={() => setIsCollapsed(!isCollapsed)}
|
|
184
|
+
/>
|
|
127
185
|
</aside>
|
|
128
186
|
)
|
|
129
187
|
}
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect } from 'react'
|
|
4
|
+
import { Button } from '@/components/ui/button'
|
|
5
|
+
import { Input } from '@/components/ui/input'
|
|
6
|
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
|
7
|
+
import { CheckCircle2, Circle, Loader2, Key, Package, ExternalLink } from 'lucide-react'
|
|
8
|
+
import { cn } from '@/lib/utils'
|
|
9
|
+
|
|
10
|
+
interface ProjectInfo {
|
|
11
|
+
id: string
|
|
12
|
+
name: string
|
|
13
|
+
needsMigration: boolean
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface MigrationResult {
|
|
17
|
+
file: string
|
|
18
|
+
success: boolean
|
|
19
|
+
error?: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
type Status = 'checking' | 'needs-key' | 'needs-migration' | 'migrating' | 'ready'
|
|
23
|
+
|
|
24
|
+
interface MigrationGateProps {
|
|
25
|
+
children: React.ReactNode
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function MigrationGate({ children }: MigrationGateProps) {
|
|
29
|
+
const [status, setStatus] = useState<Status>('checking')
|
|
30
|
+
const [projects, setProjects] = useState<ProjectInfo[]>([])
|
|
31
|
+
const [apiKey, setApiKey] = useState('')
|
|
32
|
+
const [savingKey, setSavingKey] = useState(false)
|
|
33
|
+
const [migratingProject, setMigratingProject] = useState<string | null>(null)
|
|
34
|
+
const [migrationResults, setMigrationResults] = useState<Record<string, MigrationResult[]>>({})
|
|
35
|
+
const [error, setError] = useState<string | null>(null)
|
|
36
|
+
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
checkStatus()
|
|
39
|
+
}, [])
|
|
40
|
+
|
|
41
|
+
async function checkStatus() {
|
|
42
|
+
try {
|
|
43
|
+
// 1. Check API key
|
|
44
|
+
const settingsRes = await fetch('/api/settings')
|
|
45
|
+
const settings = await settingsRes.json()
|
|
46
|
+
|
|
47
|
+
if (!settings.data?.hasApiKey) {
|
|
48
|
+
setStatus('needs-key')
|
|
49
|
+
return
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// 2. Check projects needing migration
|
|
53
|
+
const migrateRes = await fetch('/api/migrate')
|
|
54
|
+
const migrate = await migrateRes.json()
|
|
55
|
+
|
|
56
|
+
if (migrate.data?.projects?.length > 0) {
|
|
57
|
+
setProjects(migrate.data.projects.map((p: { id: string; name: string }) => ({
|
|
58
|
+
...p,
|
|
59
|
+
needsMigration: true
|
|
60
|
+
})))
|
|
61
|
+
setStatus('needs-migration')
|
|
62
|
+
return
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
setStatus('ready')
|
|
66
|
+
} catch (err) {
|
|
67
|
+
console.error('Error checking migration status:', err)
|
|
68
|
+
// On error, just show the dashboard
|
|
69
|
+
setStatus('ready')
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function saveApiKey() {
|
|
74
|
+
if (!apiKey.trim()) return
|
|
75
|
+
|
|
76
|
+
setSavingKey(true)
|
|
77
|
+
setError(null)
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
const res = await fetch('/api/settings', {
|
|
81
|
+
method: 'POST',
|
|
82
|
+
headers: { 'Content-Type': 'application/json' },
|
|
83
|
+
body: JSON.stringify({ openRouterApiKey: apiKey.trim() })
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
const data = await res.json()
|
|
87
|
+
|
|
88
|
+
if (data.success) {
|
|
89
|
+
setApiKey('')
|
|
90
|
+
await checkStatus() // Re-check, might need migration now
|
|
91
|
+
} else {
|
|
92
|
+
setError(data.error || 'Failed to save API key')
|
|
93
|
+
}
|
|
94
|
+
} catch (err) {
|
|
95
|
+
setError('Failed to save API key')
|
|
96
|
+
} finally {
|
|
97
|
+
setSavingKey(false)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function migrateProject(projectId: string) {
|
|
102
|
+
setMigratingProject(projectId)
|
|
103
|
+
setError(null)
|
|
104
|
+
|
|
105
|
+
try {
|
|
106
|
+
const res = await fetch('/api/migrate', {
|
|
107
|
+
method: 'POST',
|
|
108
|
+
headers: { 'Content-Type': 'application/json' },
|
|
109
|
+
body: JSON.stringify({ projectId })
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
const data = await res.json()
|
|
113
|
+
|
|
114
|
+
if (data.success && data.data?.success) {
|
|
115
|
+
// Mark project as migrated
|
|
116
|
+
setProjects(prev => prev.map(p =>
|
|
117
|
+
p.id === projectId ? { ...p, needsMigration: false } : p
|
|
118
|
+
))
|
|
119
|
+
setMigrationResults(prev => ({
|
|
120
|
+
...prev,
|
|
121
|
+
[projectId]: data.data.results
|
|
122
|
+
}))
|
|
123
|
+
|
|
124
|
+
// Check if all done
|
|
125
|
+
const remaining = projects.filter(p => p.id !== projectId && p.needsMigration)
|
|
126
|
+
if (remaining.length === 0) {
|
|
127
|
+
setTimeout(() => setStatus('ready'), 1000)
|
|
128
|
+
}
|
|
129
|
+
} else {
|
|
130
|
+
setError(data.error || data.data?.error || 'Migration failed')
|
|
131
|
+
setMigrationResults(prev => ({
|
|
132
|
+
...prev,
|
|
133
|
+
[projectId]: data.data?.results || []
|
|
134
|
+
}))
|
|
135
|
+
}
|
|
136
|
+
} catch (err) {
|
|
137
|
+
setError('Migration request failed')
|
|
138
|
+
} finally {
|
|
139
|
+
setMigratingProject(null)
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function migrateAll() {
|
|
144
|
+
const toMigrate = projects.filter(p => p.needsMigration)
|
|
145
|
+
for (const project of toMigrate) {
|
|
146
|
+
await migrateProject(project.id)
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Ready - show dashboard
|
|
151
|
+
if (status === 'ready') {
|
|
152
|
+
return <>{children}</>
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Loading
|
|
156
|
+
if (status === 'checking') {
|
|
157
|
+
return (
|
|
158
|
+
<div className="flex items-center justify-center min-h-[60vh]">
|
|
159
|
+
<div className="text-center space-y-4">
|
|
160
|
+
<Loader2 className="h-8 w-8 animate-spin mx-auto text-muted-foreground" />
|
|
161
|
+
<p className="text-muted-foreground">Checking migration status...</p>
|
|
162
|
+
</div>
|
|
163
|
+
</div>
|
|
164
|
+
)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Needs API Key
|
|
168
|
+
if (status === 'needs-key') {
|
|
169
|
+
return (
|
|
170
|
+
<div className="flex items-center justify-center min-h-[60vh] p-4">
|
|
171
|
+
<Card className="w-full max-w-md">
|
|
172
|
+
<CardHeader className="text-center">
|
|
173
|
+
<div className="mx-auto mb-4 h-12 w-12 rounded-full bg-primary/10 flex items-center justify-center">
|
|
174
|
+
<Key className="h-6 w-6 text-primary" />
|
|
175
|
+
</div>
|
|
176
|
+
<CardTitle>OpenRouter API Key Required</CardTitle>
|
|
177
|
+
<CardDescription>
|
|
178
|
+
prjct needs an OpenRouter API key to migrate your projects to the new JSON format.
|
|
179
|
+
</CardDescription>
|
|
180
|
+
</CardHeader>
|
|
181
|
+
<CardContent className="space-y-4">
|
|
182
|
+
<div className="space-y-2">
|
|
183
|
+
<Input
|
|
184
|
+
type="password"
|
|
185
|
+
placeholder="sk-or-v1-..."
|
|
186
|
+
value={apiKey}
|
|
187
|
+
onChange={(e) => setApiKey(e.target.value)}
|
|
188
|
+
onKeyDown={(e) => e.key === 'Enter' && saveApiKey()}
|
|
189
|
+
/>
|
|
190
|
+
<a
|
|
191
|
+
href="https://openrouter.ai/keys"
|
|
192
|
+
target="_blank"
|
|
193
|
+
rel="noopener noreferrer"
|
|
194
|
+
className="text-sm text-muted-foreground hover:text-primary flex items-center gap-1"
|
|
195
|
+
>
|
|
196
|
+
Get your key at openrouter.ai/keys
|
|
197
|
+
<ExternalLink className="h-3 w-3" />
|
|
198
|
+
</a>
|
|
199
|
+
</div>
|
|
200
|
+
|
|
201
|
+
{error && (
|
|
202
|
+
<p className="text-sm text-destructive">{error}</p>
|
|
203
|
+
)}
|
|
204
|
+
|
|
205
|
+
<Button
|
|
206
|
+
onClick={saveApiKey}
|
|
207
|
+
disabled={!apiKey.trim() || savingKey}
|
|
208
|
+
className="w-full"
|
|
209
|
+
>
|
|
210
|
+
{savingKey ? (
|
|
211
|
+
<>
|
|
212
|
+
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
|
213
|
+
Saving...
|
|
214
|
+
</>
|
|
215
|
+
) : (
|
|
216
|
+
'Save Key'
|
|
217
|
+
)}
|
|
218
|
+
</Button>
|
|
219
|
+
</CardContent>
|
|
220
|
+
</Card>
|
|
221
|
+
</div>
|
|
222
|
+
)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Needs Migration
|
|
226
|
+
return (
|
|
227
|
+
<div className="flex items-center justify-center min-h-[60vh] p-4">
|
|
228
|
+
<Card className="w-full max-w-lg">
|
|
229
|
+
<CardHeader className="text-center">
|
|
230
|
+
<div className="mx-auto mb-4 h-12 w-12 rounded-full bg-primary/10 flex items-center justify-center">
|
|
231
|
+
<Package className="h-6 w-6 text-primary" />
|
|
232
|
+
</div>
|
|
233
|
+
<CardTitle>Migration Required</CardTitle>
|
|
234
|
+
<CardDescription>
|
|
235
|
+
The following projects need to be migrated to the new JSON format.
|
|
236
|
+
</CardDescription>
|
|
237
|
+
</CardHeader>
|
|
238
|
+
<CardContent className="space-y-4">
|
|
239
|
+
<div className="border rounded-lg divide-y">
|
|
240
|
+
{projects.map((project) => {
|
|
241
|
+
const isMigrating = migratingProject === project.id
|
|
242
|
+
const results = migrationResults[project.id]
|
|
243
|
+
const isMigrated = !project.needsMigration
|
|
244
|
+
|
|
245
|
+
return (
|
|
246
|
+
<div key={project.id} className="p-3 flex items-center justify-between gap-3">
|
|
247
|
+
<div className="flex items-center gap-3 min-w-0">
|
|
248
|
+
{isMigrated ? (
|
|
249
|
+
<CheckCircle2 className="h-5 w-5 text-green-500 shrink-0" />
|
|
250
|
+
) : isMigrating ? (
|
|
251
|
+
<Loader2 className="h-5 w-5 animate-spin text-primary shrink-0" />
|
|
252
|
+
) : (
|
|
253
|
+
<Circle className="h-5 w-5 text-muted-foreground shrink-0" />
|
|
254
|
+
)}
|
|
255
|
+
<span className={cn(
|
|
256
|
+
"truncate",
|
|
257
|
+
isMigrated && "text-muted-foreground"
|
|
258
|
+
)}>
|
|
259
|
+
{project.name}
|
|
260
|
+
</span>
|
|
261
|
+
</div>
|
|
262
|
+
|
|
263
|
+
{isMigrated ? (
|
|
264
|
+
<span className="text-sm text-green-600">Migrated</span>
|
|
265
|
+
) : (
|
|
266
|
+
<Button
|
|
267
|
+
size="sm"
|
|
268
|
+
variant="outline"
|
|
269
|
+
onClick={() => migrateProject(project.id)}
|
|
270
|
+
disabled={!!migratingProject}
|
|
271
|
+
>
|
|
272
|
+
{isMigrating ? 'Migrating...' : 'Migrate'}
|
|
273
|
+
</Button>
|
|
274
|
+
)}
|
|
275
|
+
</div>
|
|
276
|
+
)
|
|
277
|
+
})}
|
|
278
|
+
</div>
|
|
279
|
+
|
|
280
|
+
{error && (
|
|
281
|
+
<p className="text-sm text-destructive">{error}</p>
|
|
282
|
+
)}
|
|
283
|
+
|
|
284
|
+
{projects.some(p => p.needsMigration) && (
|
|
285
|
+
<Button
|
|
286
|
+
onClick={migrateAll}
|
|
287
|
+
disabled={!!migratingProject}
|
|
288
|
+
className="w-full"
|
|
289
|
+
>
|
|
290
|
+
{migratingProject ? (
|
|
291
|
+
<>
|
|
292
|
+
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
|
293
|
+
Migrating...
|
|
294
|
+
</>
|
|
295
|
+
) : (
|
|
296
|
+
'Migrate All'
|
|
297
|
+
)}
|
|
298
|
+
</Button>
|
|
299
|
+
)}
|
|
300
|
+
</CardContent>
|
|
301
|
+
</Card>
|
|
302
|
+
</div>
|
|
303
|
+
)
|
|
304
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { MigrationGate } from './MigrationGate'
|
|
@@ -3,13 +3,9 @@ import { generateText } from 'ai'
|
|
|
3
3
|
import { promises as fs } from 'fs'
|
|
4
4
|
import { join } from 'path'
|
|
5
5
|
import { homedir } from 'os'
|
|
6
|
-
import { exec } from 'child_process'
|
|
7
|
-
import { promisify } from 'util'
|
|
8
6
|
|
|
9
|
-
const execAsync = promisify(exec)
|
|
10
7
|
const SETTINGS_PATH = join(homedir(), '.prjct-cli', 'settings.json')
|
|
11
8
|
const GLOBAL_STORAGE = join(homedir(), '.prjct-cli', 'projects')
|
|
12
|
-
const PRJCT_CLI_PATH = join(__dirname, '..', '..', '..', '..')
|
|
13
9
|
|
|
14
10
|
// Complete JSON Schema definitions for new architecture
|
|
15
11
|
// JSON is source of truth, MD is generated for Claude
|
|
@@ -473,25 +469,11 @@ DESCRIPTION EXTRACTION:
|
|
|
473
469
|
deletedFiles = await deleteLegacyFiles(projectId)
|
|
474
470
|
}
|
|
475
471
|
|
|
476
|
-
//
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
try {
|
|
480
|
-
// Try to run the generate-views CLI command
|
|
481
|
-
await execAsync(`bun ${join(PRJCT_CLI_PATH, 'bin', 'generate-views.js')} --project=${projectId}`)
|
|
482
|
-
viewsGenerated = true
|
|
483
|
-
results.push({ file: 'views', success: true })
|
|
484
|
-
} catch (viewError) {
|
|
485
|
-
// Views generation failed but migration still succeeded
|
|
486
|
-
results.push({
|
|
487
|
-
file: 'views',
|
|
488
|
-
success: false,
|
|
489
|
-
error: viewError instanceof Error ? viewError.message : 'Failed to generate views'
|
|
490
|
-
})
|
|
491
|
-
}
|
|
492
|
-
}
|
|
472
|
+
// NOTE: View generation removed from migration to prevent Bun crashes
|
|
473
|
+
// Views are generated on-demand by the view-generator when needed
|
|
474
|
+
// The JSON files in data/ are the source of truth now
|
|
493
475
|
|
|
494
|
-
return { success: allSuccess, results, deletedFiles, viewsGenerated }
|
|
476
|
+
return { success: allSuccess, results, deletedFiles, viewsGenerated: false }
|
|
495
477
|
}
|
|
496
478
|
|
|
497
479
|
export type ProjectInfo = {
|
package/packages/web/server.ts
CHANGED
|
@@ -100,7 +100,20 @@ app.prepare().then(() => {
|
|
|
100
100
|
// Handle session creation directly in server (bypasses API route isolation)
|
|
101
101
|
if (url.pathname === '/api/claude/sessions' && req.method === 'POST') {
|
|
102
102
|
let body = ''
|
|
103
|
-
|
|
103
|
+
let bodySize = 0
|
|
104
|
+
const MAX_BODY_SIZE = 1024 * 1024 // 1MB limit
|
|
105
|
+
|
|
106
|
+
req.on('data', chunk => {
|
|
107
|
+
bodySize += chunk.length
|
|
108
|
+
if (bodySize > MAX_BODY_SIZE) {
|
|
109
|
+
res.statusCode = 413
|
|
110
|
+
res.setHeader('Content-Type', 'application/json')
|
|
111
|
+
res.end(JSON.stringify({ success: false, error: 'Request body too large' }))
|
|
112
|
+
req.destroy()
|
|
113
|
+
return
|
|
114
|
+
}
|
|
115
|
+
body += chunk
|
|
116
|
+
})
|
|
104
117
|
req.on('end', () => {
|
|
105
118
|
try {
|
|
106
119
|
const { sessionId, projectDir } = JSON.parse(body)
|
|
@@ -171,11 +184,18 @@ app.prepare().then(() => {
|
|
|
171
184
|
})
|
|
172
185
|
}, HEARTBEAT_INTERVAL)
|
|
173
186
|
|
|
174
|
-
// Cleanup on
|
|
187
|
+
// Cleanup on WSS close
|
|
175
188
|
wss.on('close', () => {
|
|
176
189
|
clearInterval(heartbeatInterval)
|
|
177
190
|
})
|
|
178
191
|
|
|
192
|
+
// Cleanup on server close
|
|
193
|
+
server.on('close', () => {
|
|
194
|
+
clearInterval(heartbeatInterval)
|
|
195
|
+
// Kill all active sessions
|
|
196
|
+
sessions.forEach((_, sessionId) => killSession(sessionId))
|
|
197
|
+
})
|
|
198
|
+
|
|
179
199
|
server.on('upgrade', (request, socket, head) => {
|
|
180
200
|
const url = new URL(request.url || '', `http://${request.headers.host}`)
|
|
181
201
|
|