@myrialabs/clopen 0.1.10 → 0.2.1
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/README.md +23 -1
- package/backend/index.ts +20 -0
- package/backend/lib/auth/auth-service.ts +484 -0
- package/backend/lib/auth/index.ts +4 -0
- package/backend/lib/auth/permissions.ts +63 -0
- package/backend/lib/auth/rate-limiter.ts +145 -0
- package/backend/lib/auth/tokens.ts +53 -0
- package/backend/lib/database/migrations/024_create_users_table.ts +29 -0
- package/backend/lib/database/migrations/025_create_auth_sessions_table.ts +38 -0
- package/backend/lib/database/migrations/026_create_invite_tokens_table.ts +31 -0
- package/backend/lib/database/migrations/index.ts +21 -0
- package/backend/lib/database/queries/auth-queries.ts +201 -0
- package/backend/lib/database/queries/index.ts +2 -1
- package/backend/lib/engine/adapters/opencode/server.ts +1 -1
- package/backend/lib/mcp/config.ts +13 -18
- package/backend/lib/mcp/index.ts +9 -0
- package/backend/lib/mcp/remote-server.ts +132 -0
- package/backend/lib/mcp/servers/helper.ts +49 -3
- package/backend/lib/mcp/servers/index.ts +3 -2
- package/backend/lib/preview/browser/browser-audio-capture.ts +20 -3
- package/backend/lib/preview/browser/browser-navigation-tracker.ts +3 -0
- package/backend/lib/preview/browser/browser-pool.ts +73 -176
- package/backend/lib/preview/browser/browser-preview-service.ts +3 -2
- package/backend/lib/preview/browser/browser-tab-manager.ts +261 -23
- package/backend/lib/preview/browser/browser-video-capture.ts +36 -1
- package/backend/lib/utils/ws.ts +87 -1
- package/backend/ws/auth/index.ts +21 -0
- package/backend/ws/auth/invites.ts +84 -0
- package/backend/ws/auth/login.ts +283 -0
- package/backend/ws/auth/status.ts +41 -0
- package/backend/ws/auth/users.ts +32 -0
- package/backend/ws/engine/claude/accounts.ts +3 -1
- package/backend/ws/engine/utils.ts +38 -6
- package/backend/ws/index.ts +4 -4
- package/backend/ws/preview/browser/interact.ts +27 -5
- package/bin/clopen.ts +39 -0
- package/bun.lock +113 -51
- package/frontend/App.svelte +47 -29
- package/frontend/lib/components/auth/InvitePage.svelte +215 -0
- package/frontend/lib/components/auth/LoginPage.svelte +129 -0
- package/frontend/lib/components/auth/SetupPage.svelte +1022 -0
- package/frontend/lib/components/common/FolderBrowser.svelte +9 -9
- package/frontend/lib/components/common/UpdateBanner.svelte +2 -2
- package/frontend/lib/components/preview/browser/BrowserPreview.svelte +1 -1
- package/frontend/lib/components/preview/browser/core/mcp-handlers.svelte.ts +12 -4
- package/frontend/lib/components/settings/SettingsModal.svelte +50 -15
- package/frontend/lib/components/settings/SettingsView.svelte +21 -7
- package/frontend/lib/components/settings/account/AccountSettings.svelte +5 -0
- package/frontend/lib/components/settings/admin/InviteManagement.svelte +239 -0
- package/frontend/lib/components/settings/admin/UserManagement.svelte +127 -0
- package/frontend/lib/components/settings/general/AdvancedSettings.svelte +10 -4
- package/frontend/lib/components/settings/general/AuthModeSettings.svelte +229 -0
- package/frontend/lib/components/settings/general/GeneralSettings.svelte +6 -1
- package/frontend/lib/components/settings/general/UpdateSettings.svelte +5 -5
- package/frontend/lib/components/settings/security/SecuritySettings.svelte +10 -0
- package/frontend/lib/components/settings/system/SystemSettings.svelte +10 -0
- package/frontend/lib/components/settings/user/UserSettings.svelte +147 -74
- package/frontend/lib/components/workspace/WorkspaceLayout.svelte +5 -10
- package/frontend/lib/services/preview/browser/browser-webcodecs.service.ts +31 -8
- package/frontend/lib/stores/features/auth.svelte.ts +308 -0
- package/frontend/lib/stores/features/settings.svelte.ts +53 -9
- package/frontend/lib/stores/features/user.svelte.ts +26 -68
- package/frontend/lib/stores/ui/settings-modal.svelte.ts +42 -21
- package/frontend/lib/stores/ui/update.svelte.ts +2 -2
- package/package.json +8 -6
- package/shared/types/stores/settings.ts +16 -2
- package/shared/utils/logger.ts +1 -0
- package/shared/utils/ws-client.ts +30 -13
- package/shared/utils/ws-server.ts +42 -4
- package/backend/lib/mcp/stdio-server.ts +0 -103
- package/backend/ws/mcp/index.ts +0 -61
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import { t } from 'elysia';
|
|
2
|
+
import { createRouter } from '$shared/utils/ws-server';
|
|
3
|
+
import {
|
|
4
|
+
createAdmin,
|
|
5
|
+
getAuthMode,
|
|
6
|
+
loginWithToken,
|
|
7
|
+
createUserFromInvite,
|
|
8
|
+
logout,
|
|
9
|
+
logoutAllSessions,
|
|
10
|
+
validateInviteToken,
|
|
11
|
+
regeneratePAT,
|
|
12
|
+
updateUserName,
|
|
13
|
+
createOrGetNoAuthAdmin,
|
|
14
|
+
needsSetup
|
|
15
|
+
} from '$backend/lib/auth/auth-service';
|
|
16
|
+
import { settingsQueries } from '$backend/lib/database/queries';
|
|
17
|
+
import { getTokenType } from '$backend/lib/auth/tokens';
|
|
18
|
+
import { authRateLimiter } from '$backend/lib/auth/rate-limiter';
|
|
19
|
+
import { ws } from '$backend/lib/utils/ws';
|
|
20
|
+
|
|
21
|
+
const authUserSchema = t.Object({
|
|
22
|
+
id: t.String(),
|
|
23
|
+
name: t.String(),
|
|
24
|
+
role: t.Union([t.Literal('admin'), t.Literal('member')]),
|
|
25
|
+
color: t.String(),
|
|
26
|
+
avatar: t.String(),
|
|
27
|
+
createdAt: t.String()
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
export const loginHandler = createRouter()
|
|
31
|
+
// Setup — create first admin (only works when no users exist)
|
|
32
|
+
.http('auth:setup', {
|
|
33
|
+
data: t.Object({
|
|
34
|
+
name: t.String({ minLength: 1 })
|
|
35
|
+
}),
|
|
36
|
+
response: t.Object({
|
|
37
|
+
user: authUserSchema,
|
|
38
|
+
sessionToken: t.String(),
|
|
39
|
+
personalAccessToken: t.String(),
|
|
40
|
+
expiresAt: t.String()
|
|
41
|
+
})
|
|
42
|
+
}, async ({ data, conn }) => {
|
|
43
|
+
const result = createAdmin(data.name);
|
|
44
|
+
|
|
45
|
+
// Save authMode to system settings
|
|
46
|
+
const currentSettings = settingsQueries.get('system:settings');
|
|
47
|
+
const parsed = currentSettings?.value
|
|
48
|
+
? (typeof currentSettings.value === 'string' ? JSON.parse(currentSettings.value) : currentSettings.value)
|
|
49
|
+
: {};
|
|
50
|
+
parsed.authMode = 'required';
|
|
51
|
+
settingsQueries.set('system:settings', JSON.stringify(parsed));
|
|
52
|
+
|
|
53
|
+
// Set auth on connection
|
|
54
|
+
const tokenHash = (await import('$backend/lib/auth/tokens')).hashToken(result.sessionToken);
|
|
55
|
+
ws.setAuth(conn, result.user.id, result.user.role, tokenHash);
|
|
56
|
+
|
|
57
|
+
return result;
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
// Setup no-auth mode — create default admin, save authMode setting
|
|
61
|
+
.http('auth:setup-no-auth', {
|
|
62
|
+
data: t.Object({}),
|
|
63
|
+
response: t.Object({
|
|
64
|
+
user: authUserSchema,
|
|
65
|
+
sessionToken: t.String(),
|
|
66
|
+
expiresAt: t.String()
|
|
67
|
+
})
|
|
68
|
+
}, async ({ conn }) => {
|
|
69
|
+
if (!needsSetup()) {
|
|
70
|
+
throw new Error('Setup already completed.');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Save authMode to system settings
|
|
74
|
+
const currentSettings = settingsQueries.get('system:settings');
|
|
75
|
+
const parsed = currentSettings?.value
|
|
76
|
+
? (typeof currentSettings.value === 'string' ? JSON.parse(currentSettings.value) : currentSettings.value)
|
|
77
|
+
: {};
|
|
78
|
+
parsed.authMode = 'none';
|
|
79
|
+
settingsQueries.set('system:settings', JSON.stringify(parsed));
|
|
80
|
+
|
|
81
|
+
// Create or get default admin
|
|
82
|
+
const result = createOrGetNoAuthAdmin();
|
|
83
|
+
|
|
84
|
+
// Set auth on connection
|
|
85
|
+
const tokenHash = (await import('$backend/lib/auth/tokens')).hashToken(result.sessionToken);
|
|
86
|
+
ws.setAuth(conn, result.user.id, result.user.role, tokenHash);
|
|
87
|
+
|
|
88
|
+
return result;
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
// Auto-login for no-auth mode (returning visitors)
|
|
92
|
+
.http('auth:auto-login-no-auth', {
|
|
93
|
+
data: t.Object({}),
|
|
94
|
+
response: t.Object({
|
|
95
|
+
user: authUserSchema,
|
|
96
|
+
sessionToken: t.String(),
|
|
97
|
+
expiresAt: t.String()
|
|
98
|
+
})
|
|
99
|
+
}, async ({ conn }) => {
|
|
100
|
+
if (getAuthMode() !== 'none') {
|
|
101
|
+
throw new Error('Auto-login is only available in no-auth mode');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const result = createOrGetNoAuthAdmin();
|
|
105
|
+
|
|
106
|
+
// Set auth on connection
|
|
107
|
+
const tokenHash = (await import('$backend/lib/auth/tokens')).hashToken(result.sessionToken);
|
|
108
|
+
ws.setAuth(conn, result.user.id, result.user.role, tokenHash);
|
|
109
|
+
|
|
110
|
+
return result;
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
// Login with token (PAT or session token)
|
|
114
|
+
.http('auth:login', {
|
|
115
|
+
data: t.Object({
|
|
116
|
+
token: t.String({ minLength: 1 })
|
|
117
|
+
}),
|
|
118
|
+
response: t.Object({
|
|
119
|
+
user: authUserSchema,
|
|
120
|
+
sessionToken: t.String(),
|
|
121
|
+
expiresAt: t.String()
|
|
122
|
+
})
|
|
123
|
+
}, async ({ data, conn }) => {
|
|
124
|
+
const ip = ws.getRemoteAddress(conn);
|
|
125
|
+
const tokenType = getTokenType(data.token);
|
|
126
|
+
|
|
127
|
+
// Session tokens are re-authentication (reconnect) — skip rate limit entirely.
|
|
128
|
+
// Only rate-limit PAT and unknown tokens (brute-force targets).
|
|
129
|
+
const isRateLimited = tokenType !== 'session';
|
|
130
|
+
|
|
131
|
+
if (isRateLimited) {
|
|
132
|
+
const rateLimitError = authRateLimiter.check(ip, 'auth:login');
|
|
133
|
+
if (rateLimitError) {
|
|
134
|
+
throw new Error(rateLimitError);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
try {
|
|
139
|
+
const result = loginWithToken(data.token);
|
|
140
|
+
|
|
141
|
+
// Success — clear any rate limit record for this IP
|
|
142
|
+
if (isRateLimited) {
|
|
143
|
+
authRateLimiter.recordSuccess(ip);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Set auth on connection
|
|
147
|
+
ws.setAuth(conn, result.user.id, result.user.role, result.tokenHash);
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
user: result.user,
|
|
151
|
+
sessionToken: result.sessionToken,
|
|
152
|
+
expiresAt: result.expiresAt
|
|
153
|
+
};
|
|
154
|
+
} catch (err) {
|
|
155
|
+
// Record failure for rate limiting (only non-session tokens)
|
|
156
|
+
if (isRateLimited) {
|
|
157
|
+
authRateLimiter.recordFailure(ip, 'auth:login');
|
|
158
|
+
}
|
|
159
|
+
throw err;
|
|
160
|
+
}
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
// Accept invite — create user from invite token
|
|
164
|
+
.http('auth:accept-invite', {
|
|
165
|
+
data: t.Object({
|
|
166
|
+
inviteToken: t.String({ minLength: 1 }),
|
|
167
|
+
name: t.String({ minLength: 1 })
|
|
168
|
+
}),
|
|
169
|
+
response: t.Object({
|
|
170
|
+
user: authUserSchema,
|
|
171
|
+
sessionToken: t.String(),
|
|
172
|
+
personalAccessToken: t.String(),
|
|
173
|
+
expiresAt: t.String()
|
|
174
|
+
})
|
|
175
|
+
}, async ({ data, conn }) => {
|
|
176
|
+
const ip = ws.getRemoteAddress(conn);
|
|
177
|
+
|
|
178
|
+
// Rate limit check
|
|
179
|
+
const rateLimitError = authRateLimiter.check(ip, 'auth:accept-invite');
|
|
180
|
+
if (rateLimitError) {
|
|
181
|
+
throw new Error(rateLimitError);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
try {
|
|
185
|
+
const result = createUserFromInvite(data.inviteToken, data.name);
|
|
186
|
+
|
|
187
|
+
authRateLimiter.recordSuccess(ip);
|
|
188
|
+
|
|
189
|
+
// Set auth on connection
|
|
190
|
+
const tokenHash = (await import('$backend/lib/auth/tokens')).hashToken(result.sessionToken);
|
|
191
|
+
ws.setAuth(conn, result.user.id, result.user.role, tokenHash);
|
|
192
|
+
|
|
193
|
+
return result;
|
|
194
|
+
} catch (err) {
|
|
195
|
+
authRateLimiter.recordFailure(ip, 'auth:accept-invite');
|
|
196
|
+
throw err;
|
|
197
|
+
}
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
// Validate invite token (for UI, doesn't consume the invite)
|
|
201
|
+
.http('auth:validate-invite', {
|
|
202
|
+
data: t.Object({
|
|
203
|
+
inviteToken: t.String({ minLength: 1 })
|
|
204
|
+
}),
|
|
205
|
+
response: t.Object({
|
|
206
|
+
valid: t.Boolean(),
|
|
207
|
+
error: t.Optional(t.String())
|
|
208
|
+
})
|
|
209
|
+
}, async ({ data, conn }) => {
|
|
210
|
+
const ip = ws.getRemoteAddress(conn);
|
|
211
|
+
|
|
212
|
+
const rateLimitError = authRateLimiter.check(ip, 'auth:validate-invite');
|
|
213
|
+
if (rateLimitError) {
|
|
214
|
+
return { valid: false, error: rateLimitError };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const result = validateInviteToken(data.inviteToken);
|
|
218
|
+
|
|
219
|
+
// Record failure if invalid token (probing)
|
|
220
|
+
if (!result.valid) {
|
|
221
|
+
authRateLimiter.recordFailure(ip, 'auth:validate-invite');
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return { valid: result.valid, error: result.error };
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
// Logout — clear session
|
|
228
|
+
.http('auth:logout', {
|
|
229
|
+
data: t.Object({}),
|
|
230
|
+
response: t.Object({
|
|
231
|
+
success: t.Boolean()
|
|
232
|
+
})
|
|
233
|
+
}, async ({ conn }) => {
|
|
234
|
+
const state = ws.getConnectionState(conn);
|
|
235
|
+
if (state?.sessionTokenHash) {
|
|
236
|
+
logout(state.sessionTokenHash);
|
|
237
|
+
}
|
|
238
|
+
ws.clearAuth(conn);
|
|
239
|
+
return { success: true };
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
// Regenerate Personal Access Token
|
|
243
|
+
.http('auth:regenerate-pat', {
|
|
244
|
+
data: t.Object({}),
|
|
245
|
+
response: t.Object({
|
|
246
|
+
personalAccessToken: t.String()
|
|
247
|
+
})
|
|
248
|
+
}, async ({ conn }) => {
|
|
249
|
+
const userId = ws.getUserId(conn);
|
|
250
|
+
const pat = regeneratePAT(userId);
|
|
251
|
+
return { personalAccessToken: pat };
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
// Logout all sessions (admin only — used when switching auth mode)
|
|
255
|
+
.http('auth:logout-all', {
|
|
256
|
+
data: t.Object({}),
|
|
257
|
+
response: t.Object({
|
|
258
|
+
count: t.Number()
|
|
259
|
+
})
|
|
260
|
+
}, async () => {
|
|
261
|
+
const count = logoutAllSessions();
|
|
262
|
+
|
|
263
|
+
// Broadcast force-logout to ALL connected clients before clearing auth.
|
|
264
|
+
// This ensures clients receive the event while still connected.
|
|
265
|
+
ws.emit.global('auth:force-logout', { reason: 'Auth mode changed' });
|
|
266
|
+
|
|
267
|
+
// Clear in-memory auth state on all active WebSocket connections.
|
|
268
|
+
// After this, the auth gate will block any further messages.
|
|
269
|
+
ws.clearAllAuth();
|
|
270
|
+
|
|
271
|
+
return { count };
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
// Update display name (authenticated user)
|
|
275
|
+
.http('auth:update-name', {
|
|
276
|
+
data: t.Object({
|
|
277
|
+
newName: t.String({ minLength: 1 })
|
|
278
|
+
}),
|
|
279
|
+
response: authUserSchema
|
|
280
|
+
}, async ({ data, conn }) => {
|
|
281
|
+
const userId = ws.getUserId(conn);
|
|
282
|
+
return updateUserName(userId, data.newName);
|
|
283
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { t } from 'elysia';
|
|
2
|
+
import { createRouter } from '$shared/utils/ws-server';
|
|
3
|
+
import { needsSetup, getUserById, getAuthMode, isOnboardingComplete } from '$backend/lib/auth/auth-service';
|
|
4
|
+
import { ws } from '$backend/lib/utils/ws';
|
|
5
|
+
|
|
6
|
+
export const statusHandler = createRouter()
|
|
7
|
+
.http('auth:status', {
|
|
8
|
+
data: t.Object({}),
|
|
9
|
+
response: t.Object({
|
|
10
|
+
needsSetup: t.Boolean(),
|
|
11
|
+
onboardingComplete: t.Boolean(),
|
|
12
|
+
authenticated: t.Boolean(),
|
|
13
|
+
authMode: t.Union([t.Literal('none'), t.Literal('required')]),
|
|
14
|
+
user: t.Optional(t.Object({
|
|
15
|
+
id: t.String(),
|
|
16
|
+
name: t.String(),
|
|
17
|
+
role: t.Union([t.Literal('admin'), t.Literal('member')]),
|
|
18
|
+
color: t.String(),
|
|
19
|
+
avatar: t.String(),
|
|
20
|
+
createdAt: t.String()
|
|
21
|
+
}))
|
|
22
|
+
})
|
|
23
|
+
}, async ({ conn }) => {
|
|
24
|
+
const setup = needsSetup();
|
|
25
|
+
const onboardingDone = isOnboardingComplete();
|
|
26
|
+
const authMode = getAuthMode();
|
|
27
|
+
const authenticated = ws.isAuthenticated(conn);
|
|
28
|
+
|
|
29
|
+
let user = undefined;
|
|
30
|
+
if (authenticated) {
|
|
31
|
+
const state = ws.getConnectionState(conn);
|
|
32
|
+
if (state?.userId) {
|
|
33
|
+
const dbUser = getUserById(state.userId);
|
|
34
|
+
if (dbUser) {
|
|
35
|
+
user = dbUser;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return { needsSetup: setup, onboardingComplete: onboardingDone, authenticated, authMode, user };
|
|
41
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { t } from 'elysia';
|
|
2
|
+
import { createRouter } from '$shared/utils/ws-server';
|
|
3
|
+
import { listUsers, removeUser } from '$backend/lib/auth/auth-service';
|
|
4
|
+
|
|
5
|
+
export const usersHandler = createRouter()
|
|
6
|
+
// List all users (admin only — enforced by auth gate)
|
|
7
|
+
.http('auth:list-users', {
|
|
8
|
+
data: t.Object({}),
|
|
9
|
+
response: t.Array(t.Object({
|
|
10
|
+
id: t.String(),
|
|
11
|
+
name: t.String(),
|
|
12
|
+
role: t.Union([t.Literal('admin'), t.Literal('member')]),
|
|
13
|
+
color: t.String(),
|
|
14
|
+
avatar: t.String(),
|
|
15
|
+
createdAt: t.String()
|
|
16
|
+
}))
|
|
17
|
+
}, async () => {
|
|
18
|
+
return listUsers();
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
// Remove user (admin only)
|
|
22
|
+
.http('auth:remove-user', {
|
|
23
|
+
data: t.Object({
|
|
24
|
+
userId: t.String({ minLength: 1 })
|
|
25
|
+
}),
|
|
26
|
+
response: t.Object({
|
|
27
|
+
success: t.Boolean()
|
|
28
|
+
})
|
|
29
|
+
}, async ({ data }) => {
|
|
30
|
+
removeUser(data.userId);
|
|
31
|
+
return { success: true };
|
|
32
|
+
});
|
|
@@ -21,6 +21,7 @@ import { engineQueries } from '../../../lib/database/queries';
|
|
|
21
21
|
import { resetEnvironment, getClaudeUserConfigDir } from '../../../lib/engine/adapters/claude/environment';
|
|
22
22
|
import { debug } from '$shared/utils/logger';
|
|
23
23
|
import { getCleanSpawnEnv } from '../../../lib/shared/env';
|
|
24
|
+
import { resolveCommand } from '../utils';
|
|
24
25
|
|
|
25
26
|
// ── Helpers ──
|
|
26
27
|
|
|
@@ -190,9 +191,10 @@ export const accountsHandler = createRouter()
|
|
|
190
191
|
ptyEnv['CLAUDE_CONFIG_DIR'] = getClaudeUserConfigDir();
|
|
191
192
|
ptyEnv['BROWSER'] = 'false';
|
|
192
193
|
|
|
194
|
+
const claudeCmd = await resolveCommand('claude');
|
|
193
195
|
let pty: ReturnType<typeof spawn>;
|
|
194
196
|
try {
|
|
195
|
-
pty = spawn(
|
|
197
|
+
pty = spawn(claudeCmd, ['setup-token'], {
|
|
196
198
|
name: 'xterm-256color',
|
|
197
199
|
cols: 1000,
|
|
198
200
|
rows: 30,
|
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
* Shared helpers used by engine status handlers.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
type EngineCommand = 'claude' | 'opencode';
|
|
8
|
+
|
|
7
9
|
export function getBackendOS(): 'windows' | 'macos' | 'linux' {
|
|
8
10
|
switch (process.platform) {
|
|
9
11
|
case 'win32': return 'windows';
|
|
@@ -12,22 +14,52 @@ export function getBackendOS(): 'windows' | 'macos' | 'linux' {
|
|
|
12
14
|
}
|
|
13
15
|
}
|
|
14
16
|
|
|
15
|
-
|
|
17
|
+
const resolvedCommands = new Map<EngineCommand, string>();
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Resolves the correct CLI command for the current platform.
|
|
21
|
+
* On Windows, tries the base command first, then falls back to
|
|
22
|
+
* command.cmd for older CLI installations. Result is cached.
|
|
23
|
+
*/
|
|
24
|
+
export async function resolveCommand(command: EngineCommand): Promise<string> {
|
|
25
|
+
const cached = resolvedCommands.get(command);
|
|
26
|
+
if (cached) return cached;
|
|
27
|
+
|
|
28
|
+
let resolved: string = command;
|
|
29
|
+
|
|
30
|
+
if (!await trySpawn(command) && process.platform === 'win32') {
|
|
31
|
+
const fallback = command + '.cmd';
|
|
32
|
+
if (await trySpawn(fallback)) resolved = fallback;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
resolvedCommands.set(command, resolved);
|
|
36
|
+
return resolved;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function trySpawn(command: string): Promise<boolean> {
|
|
40
|
+
try {
|
|
41
|
+
const proc = Bun.spawn([command, '--version'], { stdout: 'pipe', stderr: 'pipe' });
|
|
42
|
+
return (await proc.exited) === 0;
|
|
43
|
+
} catch {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function detectCLI(command: EngineCommand): Promise<{ installed: boolean; version: string | null }> {
|
|
49
|
+
const resolved = await resolveCommand(command);
|
|
50
|
+
|
|
16
51
|
try {
|
|
17
|
-
const proc = Bun.spawn([
|
|
52
|
+
const proc = Bun.spawn([resolved, '--version'], {
|
|
18
53
|
stdout: 'pipe',
|
|
19
54
|
stderr: 'pipe'
|
|
20
55
|
});
|
|
21
56
|
|
|
22
57
|
const exitCode = await proc.exited;
|
|
23
|
-
if (exitCode !== 0) {
|
|
24
|
-
return { installed: false, version: null };
|
|
25
|
-
}
|
|
58
|
+
if (exitCode !== 0) return { installed: false, version: null };
|
|
26
59
|
|
|
27
60
|
const stdout = await new Response(proc.stdout).text();
|
|
28
61
|
const raw = stdout.trim();
|
|
29
62
|
// Extract only the version token (e.g. "2.1.52" from "2.1.52 (Claude Code)")
|
|
30
|
-
// Takes everything before the first whitespace or parenthesis
|
|
31
63
|
const version = raw.split(/[\s(]/)[0] || raw || null;
|
|
32
64
|
return { installed: true, version };
|
|
33
65
|
} catch {
|
package/backend/ws/index.ts
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
import { createRouter } from '$shared/utils/ws-server';
|
|
14
14
|
|
|
15
15
|
// Import all module routers
|
|
16
|
+
import { authRouter } from './auth';
|
|
16
17
|
import { chatRouter } from './chat';
|
|
17
18
|
import { terminalRouter } from './terminal';
|
|
18
19
|
import { previewRouter } from './preview';
|
|
@@ -26,7 +27,6 @@ import { filesRouter } from './files';
|
|
|
26
27
|
import { systemRouter } from './system';
|
|
27
28
|
import { tunnelRouter } from './tunnel';
|
|
28
29
|
import { gitRouter } from './git';
|
|
29
|
-
import { mcpRouter } from './mcp';
|
|
30
30
|
import { engineRouter } from './engine';
|
|
31
31
|
|
|
32
32
|
// ============================================
|
|
@@ -34,6 +34,9 @@ import { engineRouter } from './engine';
|
|
|
34
34
|
// ============================================
|
|
35
35
|
|
|
36
36
|
export const wsRouter = createRouter()
|
|
37
|
+
// Authentication
|
|
38
|
+
.merge(authRouter)
|
|
39
|
+
|
|
37
40
|
// Terminal System
|
|
38
41
|
.merge(terminalRouter)
|
|
39
42
|
|
|
@@ -59,9 +62,6 @@ export const wsRouter = createRouter()
|
|
|
59
62
|
// Git Source Control
|
|
60
63
|
.merge(gitRouter)
|
|
61
64
|
|
|
62
|
-
// MCP (bridge for Open Code stdio server)
|
|
63
|
-
.merge(mcpRouter)
|
|
64
|
-
|
|
65
65
|
// AI Engine Management
|
|
66
66
|
.merge(engineRouter);
|
|
67
67
|
|
|
@@ -243,13 +243,35 @@ export const interactPreviewHandler = createRouter()
|
|
|
243
243
|
await session.page.mouse.move(action.x!, action.y!, {
|
|
244
244
|
steps: action.steps || 1 // Reduced from 5 to 1 for faster response
|
|
245
245
|
});
|
|
246
|
-
// Update cursor position in browser context (fire-and-forget, don't await)
|
|
246
|
+
// Update cursor position and detect cursor type in browser context (fire-and-forget, don't await)
|
|
247
|
+
// This replaces the disabled cursor-tracking script (blocked by CloudFlare)
|
|
247
248
|
session.page.evaluate((data) => {
|
|
248
249
|
const { x, y } = data;
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
250
|
+
// Detect cursor type from element under mouse
|
|
251
|
+
let cursor = 'default';
|
|
252
|
+
try {
|
|
253
|
+
const el = document.elementFromPoint(x, y);
|
|
254
|
+
if (el) {
|
|
255
|
+
cursor = window.getComputedStyle(el).cursor || 'default';
|
|
256
|
+
}
|
|
257
|
+
} catch {}
|
|
258
|
+
|
|
259
|
+
// Initialize or update __cursorInfo
|
|
260
|
+
const existing = (window as any).__cursorInfo;
|
|
261
|
+
if (existing) {
|
|
262
|
+
existing.cursor = cursor;
|
|
263
|
+
existing.x = x;
|
|
264
|
+
existing.y = y;
|
|
265
|
+
existing.timestamp = Date.now();
|
|
266
|
+
existing.hasRecentInteraction = true;
|
|
267
|
+
} else {
|
|
268
|
+
(window as any).__cursorInfo = {
|
|
269
|
+
cursor,
|
|
270
|
+
x,
|
|
271
|
+
y,
|
|
272
|
+
timestamp: Date.now(),
|
|
273
|
+
hasRecentInteraction: true
|
|
274
|
+
};
|
|
253
275
|
}
|
|
254
276
|
}, { x: action.x!, y: action.y! }).catch(() => { /* Ignore evaluation errors */ });
|
|
255
277
|
} catch (error) {
|
package/bin/clopen.ts
CHANGED
|
@@ -31,6 +31,7 @@ interface CLIOptions {
|
|
|
31
31
|
help?: boolean;
|
|
32
32
|
version?: boolean;
|
|
33
33
|
update?: boolean;
|
|
34
|
+
resetPat?: boolean;
|
|
34
35
|
}
|
|
35
36
|
|
|
36
37
|
// Get version from package.json
|
|
@@ -95,6 +96,7 @@ USAGE:
|
|
|
95
96
|
|
|
96
97
|
COMMANDS:
|
|
97
98
|
update Update clopen to the latest version
|
|
99
|
+
reset-pat Regenerate admin Personal Access Token
|
|
98
100
|
|
|
99
101
|
OPTIONS:
|
|
100
102
|
-p, --port <number> Port to run the server on (default: ${DEFAULT_PORT})
|
|
@@ -107,6 +109,7 @@ EXAMPLES:
|
|
|
107
109
|
clopen --port 9150 # Start on port 9150
|
|
108
110
|
clopen --host 0.0.0.0 # Bind to all network interfaces
|
|
109
111
|
clopen update # Update to the latest version
|
|
112
|
+
clopen reset-pat # Regenerate admin login token
|
|
110
113
|
clopen --version # Show version
|
|
111
114
|
|
|
112
115
|
For more information, visit: https://github.com/myrialabs/clopen
|
|
@@ -167,6 +170,10 @@ function parseArguments(): CLIOptions {
|
|
|
167
170
|
options.update = true;
|
|
168
171
|
break;
|
|
169
172
|
|
|
173
|
+
case 'reset-pat':
|
|
174
|
+
options.resetPat = true;
|
|
175
|
+
break;
|
|
176
|
+
|
|
170
177
|
default:
|
|
171
178
|
console.error(`❌ Error: Unknown option "${arg}"`);
|
|
172
179
|
console.log('Run "clopen --help" for usage information');
|
|
@@ -250,6 +257,31 @@ async function runUpdate() {
|
|
|
250
257
|
console.log('\n Restart clopen to apply the update.');
|
|
251
258
|
}
|
|
252
259
|
|
|
260
|
+
async function recoverAdminToken() {
|
|
261
|
+
const version = getVersion();
|
|
262
|
+
console.log(`\x1b[36mClopen\x1b[0m v${version} — Admin Token Recovery\n`);
|
|
263
|
+
|
|
264
|
+
// Initialize database (import dynamically to avoid loading full backend)
|
|
265
|
+
const { initializeDatabase } = await import('../backend/lib/database/index');
|
|
266
|
+
const { listUsers, regeneratePAT } = await import('../backend/lib/auth/auth-service');
|
|
267
|
+
|
|
268
|
+
await initializeDatabase();
|
|
269
|
+
|
|
270
|
+
const users = listUsers();
|
|
271
|
+
const admin = users.find(u => u.role === 'admin');
|
|
272
|
+
|
|
273
|
+
if (!admin) {
|
|
274
|
+
console.error('❌ No admin user found. Start clopen first to complete setup.');
|
|
275
|
+
process.exit(1);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const newPAT = regeneratePAT(admin.id);
|
|
279
|
+
|
|
280
|
+
console.log(` Admin : ${admin.name}`);
|
|
281
|
+
console.log(` New PAT: \x1b[32m${newPAT}\x1b[0m`);
|
|
282
|
+
console.log(`\n Use this token to log in. Keep it safe — it won't be shown again.`);
|
|
283
|
+
}
|
|
284
|
+
|
|
253
285
|
async function setupEnvironment() {
|
|
254
286
|
// Check if .env exists, if not copy from .env.example
|
|
255
287
|
if (!existsSync(ENV_FILE)) {
|
|
@@ -400,6 +432,13 @@ async function main() {
|
|
|
400
432
|
process.exit(0);
|
|
401
433
|
}
|
|
402
434
|
|
|
435
|
+
// Recover admin token if requested
|
|
436
|
+
if (options.resetPat) {
|
|
437
|
+
await setupEnvironment();
|
|
438
|
+
await recoverAdminToken();
|
|
439
|
+
process.exit(0);
|
|
440
|
+
}
|
|
441
|
+
|
|
403
442
|
// 1. Setup environment variables
|
|
404
443
|
await setupEnvironment();
|
|
405
444
|
|