@myrialabs/clopen 0.1.9 → 0.2.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.
Files changed (94) hide show
  1. package/README.md +23 -1
  2. package/backend/index.ts +25 -1
  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/chat/stream-manager.ts +4 -1
  9. package/backend/lib/database/migrations/024_create_users_table.ts +29 -0
  10. package/backend/lib/database/migrations/025_create_auth_sessions_table.ts +38 -0
  11. package/backend/lib/database/migrations/026_create_invite_tokens_table.ts +31 -0
  12. package/backend/lib/database/migrations/index.ts +21 -0
  13. package/backend/lib/database/queries/auth-queries.ts +201 -0
  14. package/backend/lib/database/queries/index.ts +2 -1
  15. package/backend/lib/database/queries/session-queries.ts +13 -0
  16. package/backend/lib/database/queries/snapshot-queries.ts +1 -1
  17. package/backend/lib/engine/adapters/opencode/server.ts +9 -1
  18. package/backend/lib/engine/adapters/opencode/stream.ts +175 -1
  19. package/backend/lib/mcp/config.ts +13 -18
  20. package/backend/lib/mcp/index.ts +9 -0
  21. package/backend/lib/mcp/remote-server.ts +132 -0
  22. package/backend/lib/mcp/servers/helper.ts +49 -3
  23. package/backend/lib/mcp/servers/index.ts +3 -2
  24. package/backend/lib/preview/browser/browser-audio-capture.ts +20 -3
  25. package/backend/lib/preview/browser/browser-navigation-tracker.ts +3 -0
  26. package/backend/lib/preview/browser/browser-pool.ts +73 -176
  27. package/backend/lib/preview/browser/browser-preview-service.ts +3 -2
  28. package/backend/lib/preview/browser/browser-tab-manager.ts +261 -23
  29. package/backend/lib/preview/browser/browser-video-capture.ts +36 -1
  30. package/backend/lib/snapshot/helpers.ts +22 -49
  31. package/backend/lib/snapshot/snapshot-service.ts +148 -83
  32. package/backend/lib/utils/ws.ts +65 -1
  33. package/backend/ws/auth/index.ts +17 -0
  34. package/backend/ws/auth/invites.ts +84 -0
  35. package/backend/ws/auth/login.ts +269 -0
  36. package/backend/ws/auth/status.ts +41 -0
  37. package/backend/ws/auth/users.ts +32 -0
  38. package/backend/ws/chat/stream.ts +13 -0
  39. package/backend/ws/engine/claude/accounts.ts +3 -1
  40. package/backend/ws/engine/utils.ts +38 -6
  41. package/backend/ws/index.ts +4 -4
  42. package/backend/ws/preview/browser/interact.ts +27 -5
  43. package/backend/ws/snapshot/restore.ts +111 -12
  44. package/backend/ws/snapshot/timeline.ts +56 -29
  45. package/bin/clopen.ts +56 -1
  46. package/bun.lock +113 -51
  47. package/frontend/App.svelte +47 -29
  48. package/frontend/lib/components/auth/InvitePage.svelte +215 -0
  49. package/frontend/lib/components/auth/LoginPage.svelte +129 -0
  50. package/frontend/lib/components/auth/SetupPage.svelte +1022 -0
  51. package/frontend/lib/components/chat/input/ChatInput.svelte +1 -2
  52. package/frontend/lib/components/chat/input/components/EngineModelPicker.svelte +2 -2
  53. package/frontend/lib/components/chat/input/composables/use-chat-actions.svelte.ts +4 -4
  54. package/frontend/lib/components/chat/input/composables/use-textarea-resize.svelte.ts +11 -19
  55. package/frontend/lib/components/checkpoint/TimelineModal.svelte +15 -3
  56. package/frontend/lib/components/checkpoint/timeline/TimelineNode.svelte +30 -19
  57. package/frontend/lib/components/checkpoint/timeline/types.ts +4 -0
  58. package/frontend/lib/components/common/FolderBrowser.svelte +9 -9
  59. package/frontend/lib/components/common/UpdateBanner.svelte +2 -2
  60. package/frontend/lib/components/git/CommitForm.svelte +6 -4
  61. package/frontend/lib/components/history/HistoryModal.svelte +1 -1
  62. package/frontend/lib/components/history/HistoryView.svelte +1 -1
  63. package/frontend/lib/components/preview/browser/BrowserPreview.svelte +1 -1
  64. package/frontend/lib/components/preview/browser/core/mcp-handlers.svelte.ts +12 -4
  65. package/frontend/lib/components/settings/SettingsModal.svelte +50 -15
  66. package/frontend/lib/components/settings/SettingsView.svelte +21 -7
  67. package/frontend/lib/components/settings/account/AccountSettings.svelte +5 -0
  68. package/frontend/lib/components/settings/admin/InviteManagement.svelte +239 -0
  69. package/frontend/lib/components/settings/admin/UserManagement.svelte +127 -0
  70. package/frontend/lib/components/settings/general/AdvancedSettings.svelte +10 -4
  71. package/frontend/lib/components/settings/general/AuthModeSettings.svelte +229 -0
  72. package/frontend/lib/components/settings/general/GeneralSettings.svelte +6 -1
  73. package/frontend/lib/components/settings/general/UpdateSettings.svelte +5 -5
  74. package/frontend/lib/components/settings/security/SecuritySettings.svelte +10 -0
  75. package/frontend/lib/components/settings/system/SystemSettings.svelte +10 -0
  76. package/frontend/lib/components/settings/user/UserSettings.svelte +147 -74
  77. package/frontend/lib/components/workspace/PanelHeader.svelte +1 -1
  78. package/frontend/lib/components/workspace/WorkspaceLayout.svelte +5 -10
  79. package/frontend/lib/components/workspace/panels/ChatPanel.svelte +3 -2
  80. package/frontend/lib/services/preview/browser/browser-webcodecs.service.ts +31 -8
  81. package/frontend/lib/stores/core/sessions.svelte.ts +15 -1
  82. package/frontend/lib/stores/features/auth.svelte.ts +296 -0
  83. package/frontend/lib/stores/features/settings.svelte.ts +53 -9
  84. package/frontend/lib/stores/features/user.svelte.ts +26 -68
  85. package/frontend/lib/stores/ui/settings-modal.svelte.ts +42 -21
  86. package/frontend/lib/stores/ui/update.svelte.ts +2 -14
  87. package/frontend/lib/stores/ui/workspace.svelte.ts +4 -4
  88. package/package.json +8 -6
  89. package/shared/types/stores/settings.ts +16 -2
  90. package/shared/utils/logger.ts +1 -0
  91. package/shared/utils/ws-client.ts +30 -13
  92. package/shared/utils/ws-server.ts +42 -4
  93. package/backend/lib/mcp/stdio-server.ts +0 -103
  94. package/backend/ws/mcp/index.ts +0 -61
@@ -14,7 +14,9 @@ import { debug } from '$shared/utils/logger';
14
14
  import {
15
15
  buildCheckpointTree,
16
16
  getCheckpointPathToRoot,
17
- findSessionEnd
17
+ findCheckpointForHead,
18
+ findSessionEnd,
19
+ INITIAL_NODE_ID
18
20
  } from '../../lib/snapshot/helpers';
19
21
  import { ws } from '$backend/lib/utils/ws';
20
22
 
@@ -53,7 +55,31 @@ export const restoreHandler = createRouter()
53
55
  if (project) projectPath = project.path;
54
56
  }
55
57
 
56
- const result = await snapshotService.checkRestoreConflicts(sessionId, messageId, projectPath);
58
+ // Build checkpoint path for branch-aware conflict detection
59
+ let targetPath: string[] | undefined;
60
+ let resolvedMessageId: string | null = messageId === INITIAL_NODE_ID ? null : messageId;
61
+
62
+ if (messageId !== INITIAL_NODE_ID) {
63
+ const allMessages = messageQueries.getAllBySessionId(sessionId);
64
+ const { checkpoints, parentMap } = buildCheckpointTree(allMessages);
65
+ const checkpointIdSet = new Set(checkpoints.map(c => c.id));
66
+
67
+ const resolvedId = checkpointIdSet.has(messageId)
68
+ ? messageId
69
+ : findCheckpointForHead(messageId, allMessages, checkpointIdSet);
70
+
71
+ if (resolvedId) {
72
+ resolvedMessageId = resolvedId;
73
+ targetPath = getCheckpointPathToRoot(resolvedId, parentMap);
74
+ }
75
+ }
76
+
77
+ const result = await snapshotService.checkRestoreConflicts(
78
+ sessionId,
79
+ resolvedMessageId,
80
+ projectPath,
81
+ targetPath
82
+ );
57
83
 
58
84
  debug.log('snapshot', `Conflict check: ${result.conflicts.length} conflicts, ${result.checkpointsToUndo.length} checkpoints to undo`);
59
85
 
@@ -82,12 +108,67 @@ export const restoreHandler = createRouter()
82
108
  })
83
109
  }, async ({ data }) => {
84
110
  const { messageId, sessionId, conflictResolutions } = data;
111
+ const isInitialRestore = messageId === INITIAL_NODE_ID;
85
112
 
86
- debug.log('snapshot', 'RESTORE - Moving HEAD to checkpoint');
87
- debug.log('snapshot', `Target checkpoint: ${messageId}`);
113
+ debug.log('snapshot', `RESTORE - ${isInitialRestore ? 'Restoring to initial state' : 'Moving HEAD to checkpoint'}`);
114
+ debug.log('snapshot', `Target: ${messageId}`);
88
115
  debug.log('snapshot', `Session: ${sessionId}`);
89
116
 
90
- // 1. Get the checkpoint message
117
+ // Handle restore to initial state (before any messages)
118
+ if (isInitialRestore) {
119
+ // Clear HEAD (no messages active)
120
+ sessionQueries.clearHead(sessionId);
121
+ debug.log('snapshot', 'HEAD cleared (initial state)');
122
+
123
+ // Clear latest_sdk_session_id so next chat starts fresh
124
+ const db = (await import('../../lib/database')).getDatabase();
125
+ db.prepare(`UPDATE chat_sessions SET latest_sdk_session_id = NULL WHERE id = ?`).run(sessionId);
126
+
127
+ // Clear checkpoint_tree_state
128
+ checkpointQueries.deleteForSession(sessionId);
129
+
130
+ // Restore file system: revert ALL session changes
131
+ let filesRestored = 0;
132
+ let filesSkipped = 0;
133
+
134
+ const session = sessionQueries.getById(sessionId);
135
+ if (session) {
136
+ const project = projectQueries.getById(session.project_id);
137
+ if (project) {
138
+ const result = await snapshotService.restoreSessionScoped(
139
+ project.path,
140
+ sessionId,
141
+ null, // null = restore to initial (before all snapshots)
142
+ conflictResolutions
143
+ );
144
+ filesRestored = result.restoredFiles;
145
+ filesSkipped = result.skippedFiles;
146
+ }
147
+ }
148
+
149
+ // Broadcast messages-changed
150
+ try {
151
+ ws.emit.chatSession(sessionId, 'chat:messages-changed', {
152
+ sessionId,
153
+ reason: 'restore',
154
+ timestamp: new Date().toISOString()
155
+ });
156
+ } catch (err) {
157
+ debug.error('snapshot', 'Failed to broadcast messages-changed:', err);
158
+ }
159
+
160
+ return {
161
+ restoredTo: {
162
+ messageId: INITIAL_NODE_ID,
163
+ timestamp: new Date().toISOString()
164
+ },
165
+ filesRestored,
166
+ filesSkipped
167
+ };
168
+ }
169
+
170
+ // Regular checkpoint restore
171
+ // 1. Get the target message
91
172
  const checkpointMessage = messageQueries.getById(messageId);
92
173
  if (!checkpointMessage) {
93
174
  throw new Error('Checkpoint message not found');
@@ -99,9 +180,19 @@ export const restoreHandler = createRouter()
99
180
 
100
181
  // 3. Get all messages and build checkpoint tree
101
182
  const allMessages = messageQueries.getAllBySessionId(sessionId);
102
- const { parentMap } = buildCheckpointTree(allMessages);
183
+ const { checkpoints, parentMap } = buildCheckpointTree(allMessages);
184
+
185
+ // 3b. Resolve the correct checkpoint for snapshot/tree operations
186
+ // The target message may be a non-checkpoint (e.g., assistant response)
187
+ // when called from edit mode. Walk back to find the nearest ancestor checkpoint.
188
+ const checkpointIdSet = new Set(checkpoints.map(c => c.id));
189
+ const resolvedCheckpointId = checkpointIdSet.has(messageId)
190
+ ? messageId
191
+ : findCheckpointForHead(messageId, allMessages, checkpointIdSet);
103
192
 
104
- // 4. Find session end (last message of checkpoint's session)
193
+ debug.log('snapshot', `Resolved checkpoint: ${resolvedCheckpointId} (target was ${messageId})`);
194
+
195
+ // 4. Find session end (last message of target's session)
105
196
  const sessionEnd = findSessionEnd(checkpointMessage, allMessages);
106
197
  debug.log('snapshot', `Session end: ${sessionEnd.id}`);
107
198
 
@@ -137,12 +228,19 @@ export const restoreHandler = createRouter()
137
228
  }
138
229
 
139
230
  // 6. Update checkpoint_tree_state for ancestors
140
- const checkpointPath = getCheckpointPathToRoot(messageId, parentMap);
141
- if (checkpointPath.length > 1) {
142
- checkpointQueries.updateActiveChildrenAlongPath(sessionId, checkpointPath);
231
+ // Use resolved checkpoint ID (not raw messageId which may be a non-checkpoint)
232
+ // Also compute checkpointPath for branch-aware file restore
233
+ let checkpointPath: string[] = [];
234
+ if (resolvedCheckpointId) {
235
+ checkpointPath = getCheckpointPathToRoot(resolvedCheckpointId, parentMap);
236
+ if (checkpointPath.length > 1) {
237
+ checkpointQueries.updateActiveChildrenAlongPath(sessionId, checkpointPath);
238
+ }
143
239
  }
144
240
 
145
241
  // 7. Restore file system state using session-scoped restore
242
+ // Use resolved checkpoint ID so the snapshot lookup matches correctly
243
+ // (snapshots are keyed by checkpoint user message IDs, not assistant messages)
146
244
  let filesRestored = 0;
147
245
  let filesSkipped = 0;
148
246
 
@@ -153,8 +251,9 @@ export const restoreHandler = createRouter()
153
251
  const result = await snapshotService.restoreSessionScoped(
154
252
  project.path,
155
253
  sessionId,
156
- messageId,
157
- conflictResolutions
254
+ resolvedCheckpointId,
255
+ conflictResolutions,
256
+ checkpointPath.length > 0 ? checkpointPath : undefined
158
257
  );
159
258
  filesRestored = result.restoredFiles;
160
259
  filesSkipped = result.skippedFiles;
@@ -15,7 +15,8 @@ import {
15
15
  getCheckpointPathToRoot,
16
16
  findCheckpointForHead,
17
17
  isDescendant,
18
- getCheckpointFileStats
18
+ getCheckpointFileStats,
19
+ INITIAL_NODE_ID
19
20
  } from '../../lib/snapshot/helpers';
20
21
  import type { CheckpointNode, TimelineResponse } from '../../lib/snapshot/helpers';
21
22
  import type { SDKMessage } from '$shared/types/messaging';
@@ -36,11 +37,8 @@ export const timelineHandler = createRouter()
36
37
 
37
38
  // 1. Get current HEAD
38
39
  const currentHead = sessionQueries.getHead(sessionId);
39
- debug.log('snapshot', `Current HEAD: ${currentHead || 'null'}`);
40
-
41
- if (!currentHead) {
42
- return { nodes: [], currentHeadId: null };
43
- }
40
+ const isAtInitialState = !currentHead;
41
+ debug.log('snapshot', `Current HEAD: ${currentHead || 'null (initial state)'}`);
44
42
 
45
43
  // 2. Get all messages
46
44
  const allMessages = messageQueries.getAllBySessionId(sessionId);
@@ -61,8 +59,10 @@ export const timelineHandler = createRouter()
61
59
  const checkpointIdSet = new Set(checkpoints.map(c => c.id));
62
60
 
63
61
  // 4. Find which checkpoint HEAD belongs to
64
- const activeCheckpointId = findCheckpointForHead(currentHead, allMessages, checkpointIdSet);
65
- debug.log('snapshot', `Active checkpoint: ${activeCheckpointId}`);
62
+ const activeCheckpointId = isAtInitialState
63
+ ? null
64
+ : findCheckpointForHead(currentHead, allMessages, checkpointIdSet);
65
+ debug.log('snapshot', `Active checkpoint: ${activeCheckpointId || '(initial)'}`);
66
66
 
67
67
  // 5. Build active path (from root to active checkpoint)
68
68
  const activePathIds = new Set<string>();
@@ -76,22 +76,47 @@ export const timelineHandler = createRouter()
76
76
  // 6. Get active children map from database
77
77
  const activeChildrenMap = checkpointQueries.getAllActiveChildren(sessionId);
78
78
 
79
- // 7. Sort checkpoints by timestamp for file stats calculation
80
- const sortedCheckpoints = [...checkpoints].sort(
81
- (a, b) => a.timestamp.localeCompare(b.timestamp)
82
- );
83
-
84
- // Build next-checkpoint timestamp map for stats
85
- const nextTimestampMap = new Map<string, string>();
86
- for (let i = 0; i < sortedCheckpoints.length; i++) {
87
- const next = sortedCheckpoints[i + 1];
88
- if (next) {
89
- nextTimestampMap.set(sortedCheckpoints[i].id, next.timestamp);
90
- }
79
+ // 7. Build response nodes
80
+ const nodes: CheckpointNode[] = [];
81
+
82
+ // Find root checkpoints (those with no parent)
83
+ const rootCheckpointIds = checkpoints
84
+ .filter(cp => !parentMap.has(cp.id))
85
+ .map(cp => cp.id);
86
+
87
+ // Get session started_at for the initial node timestamp
88
+ const session = sessionQueries.getById(sessionId);
89
+ const sessionStartedAt = session?.started_at || new Date().toISOString();
90
+
91
+ // Add the "Initial State" node at the beginning
92
+ // Its activeChildId points to the first root checkpoint on the active path,
93
+ // or the first root checkpoint if we're at initial state
94
+ let initialActiveChildId: string | null = null;
95
+ if (isAtInitialState) {
96
+ // At initial state: the first root checkpoint (by timestamp) is the active child
97
+ initialActiveChildId = rootCheckpointIds[0] || null;
98
+ } else {
99
+ // Find root checkpoint on active path
100
+ initialActiveChildId = rootCheckpointIds.find(id => activePathIds.has(id)) || rootCheckpointIds[0] || null;
91
101
  }
92
102
 
93
- // 8. Build response nodes
94
- const nodes: CheckpointNode[] = [];
103
+ nodes.push({
104
+ id: INITIAL_NODE_ID,
105
+ messageId: INITIAL_NODE_ID,
106
+ parentId: null,
107
+ activeChildId: initialActiveChildId,
108
+ timestamp: sessionStartedAt,
109
+ messageText: 'Session Start',
110
+ isOnActivePath: isAtInitialState || activePathIds.size > 0,
111
+ isOrphaned: false,
112
+ isCurrent: isAtInitialState,
113
+ hasSnapshot: false,
114
+ isInitial: true,
115
+ senderName: null,
116
+ filesChanged: 0,
117
+ insertions: 0,
118
+ deletions: 0
119
+ });
95
120
 
96
121
  for (const cp of checkpoints) {
97
122
  const sdk = JSON.parse(cp.sdk_message) as SDKMessage;
@@ -107,16 +132,16 @@ export const timelineHandler = createRouter()
107
132
  isOrphaned = isDescendant(cp.id, activeCheckpointId, childrenMap);
108
133
  }
109
134
 
110
- // File stats
111
- const nextTimestamp = nextTimestampMap.get(cp.id);
112
- const stats = getCheckpointFileStats(cp, allMessages, nextTimestamp);
135
+ // File stats from checkpoint's own snapshot
136
+ const stats = getCheckpointFileStats(cp);
113
137
 
114
138
  const snapshot = snapshotQueries.getByMessageId(cp.id);
115
139
 
116
140
  nodes.push({
117
141
  id: cp.id,
118
142
  messageId: cp.id,
119
- parentId: parentCpId,
143
+ // Root checkpoints have initial node as parent
144
+ parentId: parentCpId || INITIAL_NODE_ID,
120
145
  activeChildId,
121
146
  timestamp: cp.timestamp,
122
147
  messageText,
@@ -131,11 +156,13 @@ export const timelineHandler = createRouter()
131
156
  });
132
157
  }
133
158
 
134
- debug.log('snapshot', `Timeline nodes: ${nodes.length}`);
135
- debug.log('snapshot', `Active path: ${activePathIds.size} nodes`);
159
+ const currentHeadId = isAtInitialState ? INITIAL_NODE_ID : activeCheckpointId;
160
+
161
+ debug.log('snapshot', `Timeline nodes: ${nodes.length} (including initial)`);
162
+ debug.log('snapshot', `Active path: ${activePathIds.size} nodes, current: ${currentHeadId}`);
136
163
 
137
164
  return {
138
165
  nodes,
139
- currentHeadId: activeCheckpointId
166
+ currentHeadId
140
167
  };
141
168
  });
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)) {
@@ -368,7 +400,23 @@ async function main() {
368
400
 
369
401
  // Show version if requested
370
402
  if (options.version) {
371
- console.log(getVersion());
403
+ const currentVersion = getVersion();
404
+ console.log(`v${currentVersion}`);
405
+
406
+ try {
407
+ const response = await fetch('https://registry.npmjs.org/@myrialabs/clopen/latest');
408
+ if (response.ok) {
409
+ const data = await response.json() as { version: string };
410
+ if (isNewerVersion(currentVersion, data.version)) {
411
+ console.log(`\x1b[33mUpdate available: v${data.version}\x1b[0m — run \x1b[36mclopen update\x1b[0m to update`);
412
+ } else {
413
+ console.log('\x1b[32m(latest)\x1b[0m');
414
+ }
415
+ }
416
+ } catch {
417
+ // Silent fail — network unavailable
418
+ }
419
+
372
420
  process.exit(0);
373
421
  }
374
422
 
@@ -384,6 +432,13 @@ async function main() {
384
432
  process.exit(0);
385
433
  }
386
434
 
435
+ // Recover admin token if requested
436
+ if (options.resetPat) {
437
+ await setupEnvironment();
438
+ await recoverAdminToken();
439
+ process.exit(0);
440
+ }
441
+
387
442
  // 1. Setup environment variables
388
443
  await setupEnvironment();
389
444