upfynai-code 0.1.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/LICENSE +22 -0
- package/bin/cli.js +86 -0
- package/dist/assets/CanvasPanel-B48gAKVY.js +538 -0
- package/dist/assets/CanvasPanel-B48gAKVY.js.map +1 -0
- package/dist/assets/CanvasPanel-BsOG3EVs.css +1 -0
- package/dist/assets/index-CEhTwG68.css +1 -0
- package/dist/assets/index-GqAGWpJI.js +70 -0
- package/dist/assets/index-GqAGWpJI.js.map +1 -0
- package/dist/index.html +18 -0
- package/index.html +17 -0
- package/package.json +67 -0
- package/src/App.tsx +226 -0
- package/src/components/canvas/CanvasPanel.tsx +62 -0
- package/src/components/canvas/layout/graph-builder.ts +136 -0
- package/src/components/canvas/shapes/CompactionNodeShape.tsx +76 -0
- package/src/components/canvas/shapes/SessionNodeShape.tsx +93 -0
- package/src/components/canvas/shapes/StatuslineWidgetShape.tsx +125 -0
- package/src/components/canvas/shapes/TextResponseNodeShape.tsx +86 -0
- package/src/components/canvas/shapes/ToolCallNodeShape.tsx +107 -0
- package/src/components/canvas/shapes/ToolResultNodeShape.tsx +87 -0
- package/src/components/canvas/shapes/shared-styles.ts +35 -0
- package/src/components/chat/ChatPanel.tsx +96 -0
- package/src/components/chat/InputBar.tsx +81 -0
- package/src/components/chat/MessageList.tsx +130 -0
- package/src/components/chat/PermissionDialog.tsx +70 -0
- package/src/components/layout/FolderSelector.tsx +152 -0
- package/src/components/layout/ModelSelector.tsx +65 -0
- package/src/components/layout/SessionManager.tsx +115 -0
- package/src/components/statusline/StatuslineBar.tsx +114 -0
- package/src/main.tsx +10 -0
- package/src/server/claude-session.ts +156 -0
- package/src/server/index.ts +149 -0
- package/src/services/stream-consumer.ts +330 -0
- package/src/statusline-core/bin/statusline.sh +121 -0
- package/src/statusline-core/commands/sls-config.md +42 -0
- package/src/statusline-core/commands/sls-doctor.md +35 -0
- package/src/statusline-core/commands/sls-help.md +48 -0
- package/src/statusline-core/commands/sls-layout.md +38 -0
- package/src/statusline-core/commands/sls-preview.md +34 -0
- package/src/statusline-core/commands/sls-theme.md +40 -0
- package/src/statusline-core/installer.js +228 -0
- package/src/statusline-core/layouts/compact.sh +21 -0
- package/src/statusline-core/layouts/full.sh +62 -0
- package/src/statusline-core/layouts/standard.sh +39 -0
- package/src/statusline-core/lib/core.sh +389 -0
- package/src/statusline-core/lib/helpers.sh +81 -0
- package/src/statusline-core/lib/json-parser.sh +71 -0
- package/src/statusline-core/themes/catppuccin.sh +32 -0
- package/src/statusline-core/themes/default.sh +37 -0
- package/src/statusline-core/themes/gruvbox.sh +32 -0
- package/src/statusline-core/themes/nord.sh +32 -0
- package/src/statusline-core/themes/tokyo-night.sh +32 -0
- package/src/store/canvas-store.ts +50 -0
- package/src/store/chat-store.ts +60 -0
- package/src/store/permission-store.ts +29 -0
- package/src/store/session-store.ts +52 -0
- package/src/store/statusline-store.ts +160 -0
- package/src/styles/global.css +117 -0
- package/src/themes/index.ts +149 -0
- package/src/types/canvas-graph.ts +24 -0
- package/src/types/sdk-messages.ts +156 -0
- package/src/types/statusline-fields.ts +67 -0
- package/src/vite-env.d.ts +1 -0
- package/tsconfig.json +26 -0
- package/vite.config.ts +24 -0
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { useStatuslineStore, fmtTok } from '../../store/statusline-store';
|
|
2
|
+
import { useThemeStore } from '../../themes';
|
|
3
|
+
|
|
4
|
+
// StatuslineBar is a CORE non-removable component of UC UpfynAI-Code.
|
|
5
|
+
// It MUST always be rendered in App.tsx — do not add a close/dismiss/toggle button.
|
|
6
|
+
// Proprietary software by Thinqmesh Technologies, Developed by Anit Chaudhary.
|
|
7
|
+
|
|
8
|
+
export function StatuslineBar() {
|
|
9
|
+
const theme = useThemeStore((s) => s.activeTheme);
|
|
10
|
+
const sl = useStatuslineStore();
|
|
11
|
+
const c = theme.colors;
|
|
12
|
+
|
|
13
|
+
const ctxColor = sl.contextPct > 90 ? c.ctxCrit
|
|
14
|
+
: sl.contextPct > 75 ? c.ctxHigh
|
|
15
|
+
: sl.contextPct > 40 ? c.ctxMed
|
|
16
|
+
: c.ctxLow;
|
|
17
|
+
|
|
18
|
+
const barWidth = 200;
|
|
19
|
+
const filled = Math.round((sl.contextPct / 100) * barWidth);
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<div style={{
|
|
23
|
+
display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 0,
|
|
24
|
+
padding: '6px 16px', borderTop: `1px solid ${c.border}`,
|
|
25
|
+
background: c.bgSurface, fontFamily: 'var(--font-mono)',
|
|
26
|
+
fontSize: 11, flexShrink: 0, lineHeight: 1.6,
|
|
27
|
+
}}>
|
|
28
|
+
{/* Row 1: Skill | GitHub */}
|
|
29
|
+
<div style={{ width: '100%', display: 'flex', gap: 4 }}>
|
|
30
|
+
<Field label="Skill" value={sl.skill} color={c.skill} width="45%" />
|
|
31
|
+
<Sep color={c.separator} />
|
|
32
|
+
<Field label="GitHub" value={sl.github} color={c.github} width="50%"
|
|
33
|
+
suffix={
|
|
34
|
+
<>
|
|
35
|
+
{sl.gitDirty.staged && <span style={{ color: c.gitStaged }}>+</span>}
|
|
36
|
+
{sl.gitDirty.unstaged && <span style={{ color: c.gitUnstaged }}>~</span>}
|
|
37
|
+
</>
|
|
38
|
+
}
|
|
39
|
+
/>
|
|
40
|
+
</div>
|
|
41
|
+
|
|
42
|
+
{/* Row 2: Model | Dir */}
|
|
43
|
+
<div style={{ width: '100%', display: 'flex', gap: 4 }}>
|
|
44
|
+
<Field label="Model" value={sl.model} color={c.model} width="45%" bold />
|
|
45
|
+
<Sep color={c.separator} />
|
|
46
|
+
<Field label="Dir" value={sl.dirShort} color={c.dir} width="50%" />
|
|
47
|
+
</div>
|
|
48
|
+
|
|
49
|
+
{/* Row 3: Tokens | Cost */}
|
|
50
|
+
<div style={{ width: '100%', display: 'flex', gap: 4 }}>
|
|
51
|
+
<Field label="Tokens" value={`${fmtTok(sl.tokensWinIn)} + ${fmtTok(sl.tokensWinOut)}`} color={c.tokens} width="45%" />
|
|
52
|
+
<Sep color={c.separator} />
|
|
53
|
+
<Field label="Cost" value={sl.costFormatted} color={c.cost} width="50%" />
|
|
54
|
+
</div>
|
|
55
|
+
|
|
56
|
+
{/* Row 4: Context bar */}
|
|
57
|
+
<div style={{ width: '100%', display: 'flex', alignItems: 'center', gap: 6, marginTop: 2 }}>
|
|
58
|
+
<span style={{ color: ctxColor, minWidth: 55 }}>Context:</span>
|
|
59
|
+
<div style={{
|
|
60
|
+
flex: 1, maxWidth: barWidth, height: 8, borderRadius: 4,
|
|
61
|
+
background: c.barEmpty, overflow: 'hidden',
|
|
62
|
+
}}>
|
|
63
|
+
<div style={{
|
|
64
|
+
width: `${sl.contextPct}%`, height: '100%', borderRadius: 4,
|
|
65
|
+
background: ctxColor, transition: 'width 0.3s ease',
|
|
66
|
+
}} />
|
|
67
|
+
</div>
|
|
68
|
+
<span style={{ color: ctxColor, minWidth: 32 }}>{sl.contextPct}%</span>
|
|
69
|
+
{sl.compactionWarning && (
|
|
70
|
+
<span style={{
|
|
71
|
+
color: sl.isCompacting ? c.ctxCrit : c.ctxHigh,
|
|
72
|
+
fontWeight: sl.isCompacting ? 700 : 400, fontSize: 10,
|
|
73
|
+
}}>
|
|
74
|
+
{sl.compactionWarning}
|
|
75
|
+
</span>
|
|
76
|
+
)}
|
|
77
|
+
</div>
|
|
78
|
+
|
|
79
|
+
{/* Proprietary branding — non-removable */}
|
|
80
|
+
<div style={{
|
|
81
|
+
width: '100%', display: 'flex', justifyContent: 'space-between',
|
|
82
|
+
alignItems: 'center', marginTop: 2, paddingTop: 2,
|
|
83
|
+
borderTop: `1px solid ${c.border}`,
|
|
84
|
+
}}>
|
|
85
|
+
<span style={{ color: c.textDim, fontSize: 9, letterSpacing: 0.3 }}>
|
|
86
|
+
Thinqmesh Technologies
|
|
87
|
+
</span>
|
|
88
|
+
<span style={{ color: c.textDim, fontSize: 9, letterSpacing: 0.3 }}>
|
|
89
|
+
Developed by Anit Chaudhary
|
|
90
|
+
</span>
|
|
91
|
+
</div>
|
|
92
|
+
</div>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function Field({ label, value, color, width, bold, suffix }: {
|
|
97
|
+
label: string; value: string; color: string; width: string;
|
|
98
|
+
bold?: boolean; suffix?: React.ReactNode;
|
|
99
|
+
}) {
|
|
100
|
+
const theme = useThemeStore((s) => s.activeTheme);
|
|
101
|
+
return (
|
|
102
|
+
<div style={{ width, display: 'flex', gap: 4, overflow: 'hidden' }}>
|
|
103
|
+
<span style={{ color: theme.colors.label }}>{label}:</span>
|
|
104
|
+
<span style={{ color, fontWeight: bold ? 700 : 400, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
|
105
|
+
{value}
|
|
106
|
+
</span>
|
|
107
|
+
{suffix}
|
|
108
|
+
</div>
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function Sep({ color }: { color: string }) {
|
|
113
|
+
return <span style={{ color, margin: '0 4px' }}>│</span>;
|
|
114
|
+
}
|
package/src/main.tsx
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { spawn, type ChildProcess } from 'node:child_process';
|
|
2
|
+
import { createInterface } from 'node:readline';
|
|
3
|
+
import type { SDKMessage } from '../types/sdk-messages.js';
|
|
4
|
+
|
|
5
|
+
interface SessionOptions {
|
|
6
|
+
cwd: string;
|
|
7
|
+
model?: string;
|
|
8
|
+
permissionMode?: string;
|
|
9
|
+
onMessage: (msg: SDKMessage) => void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface PendingPermission {
|
|
13
|
+
resolve: (approved: boolean) => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export class ClaudeSession {
|
|
17
|
+
private process: ChildProcess | null = null;
|
|
18
|
+
private options: SessionOptions;
|
|
19
|
+
private pendingPermissions = new Map<string, PendingPermission>();
|
|
20
|
+
private sessionId: string | null = null;
|
|
21
|
+
private isRunning = false;
|
|
22
|
+
|
|
23
|
+
constructor(options: SessionOptions) {
|
|
24
|
+
this.options = options;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async sendPrompt(prompt: string, resumeSessionId?: string): Promise<void> {
|
|
28
|
+
if (this.isRunning) {
|
|
29
|
+
// If already running with stream-json input, send as user message
|
|
30
|
+
if (this.process?.stdin?.writable) {
|
|
31
|
+
const msg = JSON.stringify({
|
|
32
|
+
type: 'user',
|
|
33
|
+
message: { role: 'user', content: prompt },
|
|
34
|
+
});
|
|
35
|
+
this.process.stdin.write(msg + '\n');
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
throw new Error('Session is busy');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
this.isRunning = true;
|
|
42
|
+
|
|
43
|
+
const args = [
|
|
44
|
+
'--print',
|
|
45
|
+
'--verbose',
|
|
46
|
+
'--output-format', 'stream-json',
|
|
47
|
+
'--input-format', 'stream-json',
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
if (this.options.model) {
|
|
51
|
+
args.push('--model', this.options.model);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (this.options.permissionMode && this.options.permissionMode !== 'default') {
|
|
55
|
+
args.push('--permission-mode', this.options.permissionMode);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (resumeSessionId) {
|
|
59
|
+
args.push('--resume', resumeSessionId);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
this.process = spawn('claude', args, {
|
|
63
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
64
|
+
cwd: this.options.cwd,
|
|
65
|
+
shell: true,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// Send the initial prompt
|
|
69
|
+
if (this.process.stdin) {
|
|
70
|
+
const msg = JSON.stringify({
|
|
71
|
+
type: 'user',
|
|
72
|
+
message: { role: 'user', content: prompt },
|
|
73
|
+
});
|
|
74
|
+
this.process.stdin.write(msg + '\n');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Read NDJSON from stdout
|
|
78
|
+
if (this.process.stdout) {
|
|
79
|
+
const rl = createInterface({ input: this.process.stdout });
|
|
80
|
+
rl.on('line', (line: string) => {
|
|
81
|
+
if (!line.trim()) return;
|
|
82
|
+
try {
|
|
83
|
+
const event = JSON.parse(line) as SDKMessage;
|
|
84
|
+
|
|
85
|
+
// Track session ID
|
|
86
|
+
if (event.type === 'system' && 'subtype' in event && event.subtype === 'init') {
|
|
87
|
+
this.sessionId = event.session_id;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Handle result — mark as not running
|
|
91
|
+
if (event.type === 'result') {
|
|
92
|
+
this.isRunning = false;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
this.options.onMessage(event);
|
|
96
|
+
} catch {
|
|
97
|
+
// Non-JSON line (debug output)
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Log stderr
|
|
103
|
+
if (this.process.stderr) {
|
|
104
|
+
const rl = createInterface({ input: this.process.stderr });
|
|
105
|
+
rl.on('line', (line: string) => {
|
|
106
|
+
if (line.trim()) {
|
|
107
|
+
console.error(` [claude stderr] ${line}`);
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
this.process.on('exit', (code) => {
|
|
113
|
+
this.isRunning = false;
|
|
114
|
+
this.process = null;
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
this.process.on('error', (err) => {
|
|
118
|
+
this.isRunning = false;
|
|
119
|
+
console.error(' Claude process error:', err.message);
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
resolvePermission(requestId: string, approved: boolean): void {
|
|
124
|
+
const pending = this.pendingPermissions.get(requestId);
|
|
125
|
+
if (pending) {
|
|
126
|
+
pending.resolve(approved);
|
|
127
|
+
this.pendingPermissions.delete(requestId);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Also send permission response through stdin if process is running
|
|
131
|
+
if (this.process?.stdin?.writable) {
|
|
132
|
+
const response = JSON.stringify({
|
|
133
|
+
type: 'permission_response',
|
|
134
|
+
tool_use_id: requestId,
|
|
135
|
+
allowed: approved,
|
|
136
|
+
});
|
|
137
|
+
this.process.stdin.write(response + '\n');
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
getSessionId(): string | null {
|
|
142
|
+
return this.sessionId;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
isActive(): boolean {
|
|
146
|
+
return this.isRunning;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
kill(): void {
|
|
150
|
+
if (this.process) {
|
|
151
|
+
this.process.kill('SIGTERM');
|
|
152
|
+
this.process = null;
|
|
153
|
+
this.isRunning = false;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
import { serve } from '@hono/node-server';
|
|
3
|
+
import { streamSSE } from 'hono/streaming';
|
|
4
|
+
import { cors } from 'hono/cors';
|
|
5
|
+
import { ClaudeSession } from './claude-session.js';
|
|
6
|
+
import type { SDKMessage } from '../types/sdk-messages.js';
|
|
7
|
+
|
|
8
|
+
const app = new Hono();
|
|
9
|
+
app.use('*', cors());
|
|
10
|
+
|
|
11
|
+
let session: ClaudeSession | null = null;
|
|
12
|
+
const sseClients: Set<(msg: SDKMessage) => void> = new Set();
|
|
13
|
+
|
|
14
|
+
const PROJECT_CWD = process.env.UPFYN_CWD || process.cwd();
|
|
15
|
+
|
|
16
|
+
// Broadcast to all SSE clients
|
|
17
|
+
function broadcast(msg: SDKMessage) {
|
|
18
|
+
for (const send of sseClients) {
|
|
19
|
+
send(msg);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// SSE stream endpoint
|
|
24
|
+
app.get('/api/stream', (c) => {
|
|
25
|
+
return streamSSE(c, async (stream) => {
|
|
26
|
+
const send = (msg: SDKMessage) => {
|
|
27
|
+
stream.writeSSE({ data: JSON.stringify(msg), event: 'message' });
|
|
28
|
+
};
|
|
29
|
+
sseClients.add(send);
|
|
30
|
+
|
|
31
|
+
// Keep alive
|
|
32
|
+
const keepAlive = setInterval(() => {
|
|
33
|
+
stream.writeSSE({ data: '', event: 'ping' });
|
|
34
|
+
}, 15000);
|
|
35
|
+
|
|
36
|
+
stream.onAbort(() => {
|
|
37
|
+
sseClients.delete(send);
|
|
38
|
+
clearInterval(keepAlive);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// Block until aborted
|
|
42
|
+
await new Promise(() => {});
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// Send a prompt
|
|
47
|
+
app.post('/api/prompt', async (c) => {
|
|
48
|
+
const body = await c.req.json<{ prompt: string; sessionId?: string; cwd?: string }>();
|
|
49
|
+
|
|
50
|
+
if (!session) {
|
|
51
|
+
session = new ClaudeSession({
|
|
52
|
+
cwd: body.cwd || PROJECT_CWD,
|
|
53
|
+
onMessage: broadcast,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
await session.sendPrompt(body.prompt, body.sessionId);
|
|
59
|
+
return c.json({ ok: true });
|
|
60
|
+
} catch (err: any) {
|
|
61
|
+
return c.json({ ok: false, error: err.message }, 500);
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// Respond to permission request
|
|
66
|
+
app.post('/api/permission/respond', async (c) => {
|
|
67
|
+
const body = await c.req.json<{ requestId: string; approved: boolean }>();
|
|
68
|
+
|
|
69
|
+
if (session) {
|
|
70
|
+
session.resolvePermission(body.requestId, body.approved);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return c.json({ ok: true });
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// Get session info
|
|
77
|
+
app.get('/api/session', (c) => {
|
|
78
|
+
return c.json({
|
|
79
|
+
active: session !== null,
|
|
80
|
+
cwd: PROJECT_CWD,
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// Git status endpoint (for statusline)
|
|
85
|
+
app.get('/api/git-status', async (c) => {
|
|
86
|
+
const cwd = c.req.query('cwd') || PROJECT_CWD;
|
|
87
|
+
const { execSync } = await import('child_process');
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
const branch = execSync('git symbolic-ref --short HEAD 2>/dev/null || git rev-parse --short HEAD', { cwd, encoding: 'utf-8' }).trim();
|
|
91
|
+
let remoteUrl = '';
|
|
92
|
+
try { remoteUrl = execSync('git remote get-url origin', { cwd, encoding: 'utf-8' }).trim(); } catch {}
|
|
93
|
+
|
|
94
|
+
let ghUser = '', ghRepo = '';
|
|
95
|
+
const ghMatch = remoteUrl.match(/github\.com[:/]([^/]+)\/([^/.]+)/);
|
|
96
|
+
if (ghMatch) { ghUser = ghMatch[1]; ghRepo = ghMatch[2]; }
|
|
97
|
+
|
|
98
|
+
let staged = false, unstaged = false;
|
|
99
|
+
try { execSync('git diff --cached --quiet', { cwd }); } catch { staged = true; }
|
|
100
|
+
try { execSync('git diff --quiet', { cwd }); } catch { unstaged = true; }
|
|
101
|
+
|
|
102
|
+
return c.json({ branch, ghUser, ghRepo, staged, unstaged });
|
|
103
|
+
} catch {
|
|
104
|
+
return c.json({ branch: 'no-git', ghUser: '', ghRepo: '', staged: false, unstaged: false });
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// Discover Claude Code projects from ~/.claude/projects/
|
|
109
|
+
app.get('/api/projects', async (c) => {
|
|
110
|
+
const { readdirSync, statSync } = await import('fs');
|
|
111
|
+
const { join } = await import('path');
|
|
112
|
+
const home = process.env.HOME || process.env.USERPROFILE || '';
|
|
113
|
+
const projectsDir = join(home, '.claude', 'projects');
|
|
114
|
+
|
|
115
|
+
try {
|
|
116
|
+
const entries = readdirSync(projectsDir);
|
|
117
|
+
const projects = entries
|
|
118
|
+
.map((name) => {
|
|
119
|
+
const fullPath = join(projectsDir, name);
|
|
120
|
+
try {
|
|
121
|
+
const stat = statSync(fullPath);
|
|
122
|
+
if (!stat.isDirectory()) return null;
|
|
123
|
+
// Decode the project hash back to a path
|
|
124
|
+
let decoded = name
|
|
125
|
+
.replace(/^([A-Z])--/, '$1:/')
|
|
126
|
+
.replace(/-/g, '/');
|
|
127
|
+
return {
|
|
128
|
+
path: decoded,
|
|
129
|
+
name: decoded.split('/').pop() || name,
|
|
130
|
+
lastUsed: stat.mtimeMs,
|
|
131
|
+
};
|
|
132
|
+
} catch { return null; }
|
|
133
|
+
})
|
|
134
|
+
.filter(Boolean)
|
|
135
|
+
.sort((a: any, b: any) => b.lastUsed - a.lastUsed)
|
|
136
|
+
.slice(0, 20);
|
|
137
|
+
|
|
138
|
+
return c.json({ projects });
|
|
139
|
+
} catch {
|
|
140
|
+
return c.json({ projects: [] });
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// Health check
|
|
145
|
+
app.get('/api/health', (c) => c.json({ status: 'ok', version: '0.1.0' }));
|
|
146
|
+
|
|
147
|
+
const port = parseInt(process.env.UPFYN_PORT || '3210');
|
|
148
|
+
console.log(` \x1b[38;2;34;197;94m✓\x1b[0m API server running on http://localhost:${port}`);
|
|
149
|
+
serve({ fetch: app.fetch, port });
|