@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.
Files changed (71) hide show
  1. package/README.md +23 -1
  2. package/backend/index.ts +20 -0
  3. package/backend/lib/auth/auth-service.ts +484 -0
  4. package/backend/lib/auth/index.ts +4 -0
  5. package/backend/lib/auth/permissions.ts +63 -0
  6. package/backend/lib/auth/rate-limiter.ts +145 -0
  7. package/backend/lib/auth/tokens.ts +53 -0
  8. package/backend/lib/database/migrations/024_create_users_table.ts +29 -0
  9. package/backend/lib/database/migrations/025_create_auth_sessions_table.ts +38 -0
  10. package/backend/lib/database/migrations/026_create_invite_tokens_table.ts +31 -0
  11. package/backend/lib/database/migrations/index.ts +21 -0
  12. package/backend/lib/database/queries/auth-queries.ts +201 -0
  13. package/backend/lib/database/queries/index.ts +2 -1
  14. package/backend/lib/engine/adapters/opencode/server.ts +1 -1
  15. package/backend/lib/mcp/config.ts +13 -18
  16. package/backend/lib/mcp/index.ts +9 -0
  17. package/backend/lib/mcp/remote-server.ts +132 -0
  18. package/backend/lib/mcp/servers/helper.ts +49 -3
  19. package/backend/lib/mcp/servers/index.ts +3 -2
  20. package/backend/lib/preview/browser/browser-audio-capture.ts +20 -3
  21. package/backend/lib/preview/browser/browser-navigation-tracker.ts +3 -0
  22. package/backend/lib/preview/browser/browser-pool.ts +73 -176
  23. package/backend/lib/preview/browser/browser-preview-service.ts +3 -2
  24. package/backend/lib/preview/browser/browser-tab-manager.ts +261 -23
  25. package/backend/lib/preview/browser/browser-video-capture.ts +36 -1
  26. package/backend/lib/utils/ws.ts +87 -1
  27. package/backend/ws/auth/index.ts +21 -0
  28. package/backend/ws/auth/invites.ts +84 -0
  29. package/backend/ws/auth/login.ts +283 -0
  30. package/backend/ws/auth/status.ts +41 -0
  31. package/backend/ws/auth/users.ts +32 -0
  32. package/backend/ws/engine/claude/accounts.ts +3 -1
  33. package/backend/ws/engine/utils.ts +38 -6
  34. package/backend/ws/index.ts +4 -4
  35. package/backend/ws/preview/browser/interact.ts +27 -5
  36. package/bin/clopen.ts +39 -0
  37. package/bun.lock +113 -51
  38. package/frontend/App.svelte +47 -29
  39. package/frontend/lib/components/auth/InvitePage.svelte +215 -0
  40. package/frontend/lib/components/auth/LoginPage.svelte +129 -0
  41. package/frontend/lib/components/auth/SetupPage.svelte +1022 -0
  42. package/frontend/lib/components/common/FolderBrowser.svelte +9 -9
  43. package/frontend/lib/components/common/UpdateBanner.svelte +2 -2
  44. package/frontend/lib/components/preview/browser/BrowserPreview.svelte +1 -1
  45. package/frontend/lib/components/preview/browser/core/mcp-handlers.svelte.ts +12 -4
  46. package/frontend/lib/components/settings/SettingsModal.svelte +50 -15
  47. package/frontend/lib/components/settings/SettingsView.svelte +21 -7
  48. package/frontend/lib/components/settings/account/AccountSettings.svelte +5 -0
  49. package/frontend/lib/components/settings/admin/InviteManagement.svelte +239 -0
  50. package/frontend/lib/components/settings/admin/UserManagement.svelte +127 -0
  51. package/frontend/lib/components/settings/general/AdvancedSettings.svelte +10 -4
  52. package/frontend/lib/components/settings/general/AuthModeSettings.svelte +229 -0
  53. package/frontend/lib/components/settings/general/GeneralSettings.svelte +6 -1
  54. package/frontend/lib/components/settings/general/UpdateSettings.svelte +5 -5
  55. package/frontend/lib/components/settings/security/SecuritySettings.svelte +10 -0
  56. package/frontend/lib/components/settings/system/SystemSettings.svelte +10 -0
  57. package/frontend/lib/components/settings/user/UserSettings.svelte +147 -74
  58. package/frontend/lib/components/workspace/WorkspaceLayout.svelte +5 -10
  59. package/frontend/lib/services/preview/browser/browser-webcodecs.service.ts +31 -8
  60. package/frontend/lib/stores/features/auth.svelte.ts +308 -0
  61. package/frontend/lib/stores/features/settings.svelte.ts +53 -9
  62. package/frontend/lib/stores/features/user.svelte.ts +26 -68
  63. package/frontend/lib/stores/ui/settings-modal.svelte.ts +42 -21
  64. package/frontend/lib/stores/ui/update.svelte.ts +2 -2
  65. package/package.json +8 -6
  66. package/shared/types/stores/settings.ts +16 -2
  67. package/shared/utils/logger.ts +1 -0
  68. package/shared/utils/ws-client.ts +30 -13
  69. package/shared/utils/ws-server.ts +42 -4
  70. package/backend/lib/mcp/stdio-server.ts +0 -103
  71. 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('claude', ['setup-token'], {
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
- export async function detectCLI(command: string): Promise<{ installed: boolean; version: string | null }> {
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([command, '--version'], {
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 {
@@ -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
- if ((window as any).__cursorInfo) {
250
- (window as any).__cursorInfo.x = x;
251
- (window as any).__cursorInfo.y = y;
252
- (window as any).__cursorInfo.timestamp = Date.now();
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