@myrialabs/clopen 0.2.13 → 0.2.14
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/backend/snapshot/snapshot-service.ts +9 -7
- package/backend/ws/snapshot/timeline.ts +6 -2
- package/bin/clopen.ts +376 -99
- package/frontend/components/chat/input/components/LoadingIndicator.svelte +2 -2
- package/frontend/components/checkpoint/TimelineModal.svelte +3 -1
- package/frontend/components/common/overlay/Dialog.svelte +2 -2
- package/frontend/components/preview/browser/BrowserPreview.svelte +7 -0
- package/frontend/components/preview/browser/components/Canvas.svelte +8 -0
- package/frontend/components/settings/engines/AIEnginesSettings.svelte +1 -1
- package/frontend/components/settings/general/AuthModeSettings.svelte +2 -2
- package/frontend/components/tunnel/TunnelInactive.svelte +4 -4
- package/frontend/services/chat/chat.service.ts +44 -1
- package/frontend/stores/ui/settings-modal.svelte.ts +1 -1
- package/frontend/stores/ui/theme.svelte.ts +11 -11
- package/frontend/stores/ui/workspace.svelte.ts +1 -1
- package/index.html +2 -2
- package/package.json +1 -1
- package/shared/utils/anonymous-user.ts +4 -4
|
@@ -479,9 +479,6 @@ export class SnapshotService {
|
|
|
479
479
|
let restoredFiles = 0;
|
|
480
480
|
let skippedFiles = 0;
|
|
481
481
|
|
|
482
|
-
// Update in-memory baseline as we restore
|
|
483
|
-
const baseline = this.sessionBaselines.get(sessionId) || {};
|
|
484
|
-
|
|
485
482
|
for (const [filepath, expectedHash] of expectedState) {
|
|
486
483
|
// Check conflict resolution
|
|
487
484
|
if (conflictResolutions && conflictResolutions[filepath] === 'keep') {
|
|
@@ -509,7 +506,6 @@ export class SnapshotService {
|
|
|
509
506
|
// File should not exist at the target → delete it
|
|
510
507
|
try {
|
|
511
508
|
await fs.unlink(fullPath);
|
|
512
|
-
delete baseline[filepath];
|
|
513
509
|
debug.log('snapshot', `Deleted: ${filepath}`);
|
|
514
510
|
restoredFiles++;
|
|
515
511
|
} catch {
|
|
@@ -522,7 +518,6 @@ export class SnapshotService {
|
|
|
522
518
|
const dir = path.dirname(fullPath);
|
|
523
519
|
await fs.mkdir(dir, { recursive: true });
|
|
524
520
|
await fs.writeFile(fullPath, content);
|
|
525
|
-
baseline[filepath] = expectedHash;
|
|
526
521
|
debug.log('snapshot', `Restored: ${filepath}`);
|
|
527
522
|
restoredFiles++;
|
|
528
523
|
} catch (err) {
|
|
@@ -532,8 +527,15 @@ export class SnapshotService {
|
|
|
532
527
|
}
|
|
533
528
|
}
|
|
534
529
|
|
|
535
|
-
//
|
|
536
|
-
|
|
530
|
+
// Force re-initialize baseline from actual disk state.
|
|
531
|
+
// This is critical because:
|
|
532
|
+
// 1. Files already at expected state were skipped (baseline not updated for them)
|
|
533
|
+
// 2. After server restart, baseline starts empty — only restored files get entries
|
|
534
|
+
// 3. Files not mentioned in any snapshot are missing from the partial baseline
|
|
535
|
+
// Without this, subsequent captures would compute oldHash='' for files missing
|
|
536
|
+
// from the baseline, causing future restores to incorrectly delete those files.
|
|
537
|
+
this.sessionBaselines.delete(sessionId);
|
|
538
|
+
await this.initializeSessionBaseline(projectPath, sessionId);
|
|
537
539
|
|
|
538
540
|
debug.log('snapshot', `Restore complete: ${restoredFiles} restored, ${skippedFiles} skipped`);
|
|
539
541
|
return { restoredFiles, skippedFiles };
|
|
@@ -126,9 +126,13 @@ export const timelineHandler = createRouter()
|
|
|
126
126
|
const isOnActivePath = activePathIds.has(cp.id);
|
|
127
127
|
const isCurrent = cp.id === activeCheckpointId;
|
|
128
128
|
|
|
129
|
-
// Orphaned =
|
|
129
|
+
// Orphaned = in the "future" relative to the current checkpoint.
|
|
130
|
+
// At initial state: ALL checkpoints are orphaned (we've gone back before any messages).
|
|
131
|
+
// Otherwise: only descendants of the active checkpoint that are not on the active path.
|
|
130
132
|
let isOrphaned = false;
|
|
131
|
-
if (
|
|
133
|
+
if (isAtInitialState) {
|
|
134
|
+
isOrphaned = true;
|
|
135
|
+
} else if (activeCheckpointId && !isOnActivePath) {
|
|
132
136
|
isOrphaned = isDescendant(cp.id, activeCheckpointId, childrenMap);
|
|
133
137
|
}
|
|
134
138
|
|
package/bin/clopen.ts
CHANGED
|
@@ -33,6 +33,7 @@ interface CLIOptions {
|
|
|
33
33
|
version?: boolean;
|
|
34
34
|
update?: boolean;
|
|
35
35
|
resetPat?: boolean;
|
|
36
|
+
clearData?: boolean;
|
|
36
37
|
}
|
|
37
38
|
|
|
38
39
|
// Get version from package.json
|
|
@@ -46,47 +47,274 @@ function getVersion(): string {
|
|
|
46
47
|
}
|
|
47
48
|
}
|
|
48
49
|
|
|
49
|
-
//
|
|
50
|
-
|
|
51
|
-
|
|
50
|
+
// ============================================================
|
|
51
|
+
// Simple single-line spinner (used for update / clear-data)
|
|
52
|
+
// ============================================================
|
|
52
53
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
}
|
|
54
|
+
let spinnerInterval: Timer | null = null;
|
|
55
|
+
let spinnerMessage = '';
|
|
56
56
|
|
|
57
|
-
function
|
|
58
|
-
|
|
59
|
-
if (!
|
|
57
|
+
function startSpinner(message: string) {
|
|
58
|
+
spinnerMessage = message;
|
|
59
|
+
if (!spinnerInterval) {
|
|
60
60
|
const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
61
61
|
let i = 0;
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
process.stdout.write(`\r\x1b[K${frames[i]} ${currentMessage}`);
|
|
62
|
+
spinnerInterval = setInterval(() => {
|
|
63
|
+
process.stdout.write(`\r\x1b[K${frames[i]} ${spinnerMessage}`);
|
|
65
64
|
i = (i + 1) % frames.length;
|
|
66
65
|
}, 80);
|
|
67
66
|
}
|
|
68
67
|
}
|
|
69
68
|
|
|
70
|
-
function
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
69
|
+
function updateSpinner(message: string) {
|
|
70
|
+
spinnerMessage = message;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function stopSpinner() {
|
|
74
|
+
if (spinnerInterval) {
|
|
75
|
+
clearInterval(spinnerInterval);
|
|
76
|
+
spinnerInterval = null;
|
|
77
|
+
process.stdout.write('\r\x1b[K');
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ============================================================
|
|
82
|
+
// Multi-step progress display (used for startup sequence)
|
|
83
|
+
// ============================================================
|
|
84
|
+
|
|
85
|
+
const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
86
|
+
const MAX_OUTPUT_LINES = 5;
|
|
87
|
+
const OUTPUT_TRUNCATE = 72;
|
|
88
|
+
|
|
89
|
+
type StepState = 'pending' | 'running' | 'done' | 'skipped' | 'error';
|
|
90
|
+
|
|
91
|
+
interface ProgressStep {
|
|
92
|
+
id: string;
|
|
93
|
+
label: string;
|
|
94
|
+
activeLabel: string;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
interface StepStatus {
|
|
98
|
+
state: StepState;
|
|
99
|
+
startTime?: number;
|
|
100
|
+
outputLines: string[];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const isTTY = process.stdout.isTTY === true;
|
|
104
|
+
let progressSteps: ProgressStep[] = [];
|
|
105
|
+
const stepStatuses = new Map<string, StepStatus>();
|
|
106
|
+
let frameIdx = 0;
|
|
107
|
+
let renderTimer: Timer | null = null;
|
|
108
|
+
let drawnLines = 0;
|
|
109
|
+
|
|
110
|
+
function stripAnsi(str: string): string {
|
|
111
|
+
return str.replace(/\x1b\[[0-9;]*[mGKHFABCDJhls]/g, '');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function trunc(str: string, max: number): string {
|
|
115
|
+
if (str.length <= max) return str;
|
|
116
|
+
return str.slice(0, max - 1) + '…';
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function initProgress(steps: ProgressStep[]) {
|
|
120
|
+
progressSteps = steps;
|
|
121
|
+
stepStatuses.clear();
|
|
122
|
+
drawnLines = 0;
|
|
123
|
+
frameIdx = 0;
|
|
124
|
+
for (const s of steps) {
|
|
125
|
+
stepStatuses.set(s.id, { state: 'pending', outputLines: [] });
|
|
126
|
+
}
|
|
127
|
+
if (isTTY) {
|
|
128
|
+
process.stdout.write('\x1b[?25l'); // hide cursor
|
|
129
|
+
renderTimer = setInterval(renderProgress, 80);
|
|
130
|
+
renderProgress();
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function renderProgress() {
|
|
135
|
+
if (!isTTY) return;
|
|
136
|
+
|
|
137
|
+
if (drawnLines > 0) {
|
|
138
|
+
process.stdout.write(`\x1b[${drawnLines}A\x1b[J`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
let output = '';
|
|
142
|
+
let lines = 0;
|
|
143
|
+
|
|
144
|
+
for (const step of progressSteps) {
|
|
145
|
+
const status = stepStatuses.get(step.id)!;
|
|
146
|
+
let icon: string;
|
|
147
|
+
let label: string;
|
|
148
|
+
|
|
149
|
+
switch (status.state) {
|
|
150
|
+
case 'pending':
|
|
151
|
+
icon = '\x1b[90m·\x1b[0m';
|
|
152
|
+
label = `\x1b[90m${step.label}\x1b[0m`;
|
|
153
|
+
break;
|
|
154
|
+
case 'running': {
|
|
155
|
+
const elapsed = Math.floor((Date.now() - (status.startTime ?? Date.now())) / 1000);
|
|
156
|
+
icon = `\x1b[36m${SPINNER_FRAMES[frameIdx]}\x1b[0m`;
|
|
157
|
+
const timer = elapsed >= 3 ? ` \x1b[90m(${elapsed}s)\x1b[0m` : '';
|
|
158
|
+
label = `${step.activeLabel}${timer}`;
|
|
159
|
+
break;
|
|
160
|
+
}
|
|
161
|
+
case 'done':
|
|
162
|
+
icon = '\x1b[32m✓\x1b[0m';
|
|
163
|
+
label = step.label;
|
|
164
|
+
break;
|
|
165
|
+
case 'skipped':
|
|
166
|
+
icon = '\x1b[90m✓\x1b[0m';
|
|
167
|
+
label = `\x1b[90m${step.label} (up to date)\x1b[0m`;
|
|
168
|
+
break;
|
|
169
|
+
case 'error':
|
|
170
|
+
icon = '\x1b[31m✗\x1b[0m';
|
|
171
|
+
label = `\x1b[31m${step.label}\x1b[0m`;
|
|
172
|
+
break;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
output += `\x1b[K ${icon} ${label}\n`;
|
|
176
|
+
lines++;
|
|
177
|
+
|
|
178
|
+
// Show last N output lines for the currently running step
|
|
179
|
+
if (status.state === 'running' && status.outputLines.length > 0) {
|
|
180
|
+
const visible = status.outputLines.slice(-MAX_OUTPUT_LINES);
|
|
181
|
+
for (const line of visible) {
|
|
182
|
+
output += `\x1b[K \x1b[90m│ ${trunc(line, OUTPUT_TRUNCATE)}\x1b[0m\n`;
|
|
183
|
+
lines++;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
process.stdout.write(output);
|
|
189
|
+
drawnLines = lines;
|
|
190
|
+
frameIdx = (frameIdx + 1) % SPINNER_FRAMES.length;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function stopProgress() {
|
|
194
|
+
if (renderTimer) {
|
|
195
|
+
clearInterval(renderTimer);
|
|
196
|
+
renderTimer = null;
|
|
197
|
+
}
|
|
198
|
+
if (isTTY) {
|
|
199
|
+
renderProgress(); // final render
|
|
200
|
+
process.stdout.write('\x1b[?25h'); // show cursor
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function clearProgress() {
|
|
205
|
+
if (renderTimer) {
|
|
206
|
+
clearInterval(renderTimer);
|
|
207
|
+
renderTimer = null;
|
|
208
|
+
}
|
|
209
|
+
if (isTTY) {
|
|
210
|
+
if (drawnLines > 0) {
|
|
211
|
+
process.stdout.write(`\x1b[${drawnLines}A\x1b[J`);
|
|
212
|
+
drawnLines = 0;
|
|
213
|
+
}
|
|
214
|
+
process.stdout.write('\x1b[?25h');
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function progressStepStart(id: string) {
|
|
219
|
+
const s = stepStatuses.get(id);
|
|
220
|
+
if (!s) return;
|
|
221
|
+
s.state = 'running';
|
|
222
|
+
s.startTime = Date.now();
|
|
223
|
+
s.outputLines = [];
|
|
224
|
+
if (!isTTY) {
|
|
225
|
+
const step = progressSteps.find(p => p.id === id);
|
|
226
|
+
process.stdout.write(` ${step?.activeLabel ?? id}...\n`);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function progressStepDone(id: string) {
|
|
231
|
+
const s = stepStatuses.get(id);
|
|
232
|
+
if (!s) return;
|
|
233
|
+
s.state = 'done';
|
|
234
|
+
if (!isTTY) {
|
|
235
|
+
const step = progressSteps.find(p => p.id === id);
|
|
236
|
+
process.stdout.write(` ✓ ${step?.label ?? id}\n`);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function progressStepSkip(id: string) {
|
|
241
|
+
const s = stepStatuses.get(id);
|
|
242
|
+
if (!s) return;
|
|
243
|
+
s.state = 'skipped';
|
|
244
|
+
if (!isTTY) {
|
|
245
|
+
const step = progressSteps.find(p => p.id === id);
|
|
246
|
+
process.stdout.write(` ✓ ${step?.label ?? id} (up to date)\n`);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function progressStepFail(id: string) {
|
|
251
|
+
const s = stepStatuses.get(id);
|
|
252
|
+
if (!s) return;
|
|
253
|
+
s.state = 'error';
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function progressAppendOutput(id: string, line: string) {
|
|
257
|
+
const s = stepStatuses.get(id);
|
|
258
|
+
if (!s || s.state !== 'running') return;
|
|
259
|
+
s.outputLines.push(stripAnsi(line).trim());
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async function streamSubprocess(stream: ReadableStream<Uint8Array>, stepId: string): Promise<void> {
|
|
263
|
+
const reader = stream.getReader();
|
|
264
|
+
const decoder = new TextDecoder();
|
|
265
|
+
let buffer = '';
|
|
266
|
+
try {
|
|
267
|
+
for (;;) {
|
|
268
|
+
const { done, value } = await reader.read();
|
|
269
|
+
if (done) break;
|
|
270
|
+
buffer += decoder.decode(value, { stream: true });
|
|
271
|
+
const parts = buffer.split('\n');
|
|
272
|
+
buffer = parts.pop() ?? '';
|
|
273
|
+
for (const part of parts) {
|
|
274
|
+
const line = part.trim();
|
|
275
|
+
if (line) progressAppendOutput(stepId, line);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
if (buffer.trim()) progressAppendOutput(stepId, buffer.trim());
|
|
279
|
+
} catch {
|
|
280
|
+
// stream closed or error — fine
|
|
281
|
+
} finally {
|
|
282
|
+
reader.releaseLock();
|
|
75
283
|
}
|
|
76
284
|
}
|
|
77
285
|
|
|
286
|
+
// Restore cursor on unexpected exit
|
|
287
|
+
process.on('exit', () => {
|
|
288
|
+
if (renderTimer) clearInterval(renderTimer);
|
|
289
|
+
if (spinnerInterval) clearInterval(spinnerInterval);
|
|
290
|
+
if (isTTY) process.stdout.write('\x1b[?25h');
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
// ============================================================
|
|
294
|
+
// Constants
|
|
295
|
+
// ============================================================
|
|
296
|
+
|
|
78
297
|
const __dirname = join(import.meta.dir, '..');
|
|
79
298
|
const ENV_EXAMPLE = join(__dirname, '.env.example');
|
|
80
299
|
const ENV_FILE = join(__dirname, '.env');
|
|
81
300
|
const DIST_DIR = join(__dirname, 'dist');
|
|
82
301
|
const BUILD_VERSION_FILE = join(DIST_DIR, '.build-version');
|
|
83
302
|
|
|
84
|
-
// Default values
|
|
85
303
|
const DEFAULT_PORT = 9141;
|
|
86
304
|
const DEFAULT_HOST = 'localhost';
|
|
87
305
|
const MIN_PORT = 1024;
|
|
88
306
|
const MAX_PORT = 65535;
|
|
89
307
|
|
|
308
|
+
const STARTUP_STEPS: ProgressStep[] = [
|
|
309
|
+
{ id: 'deps', label: 'Dependencies', activeLabel: 'Installing dependencies' },
|
|
310
|
+
{ id: 'build', label: 'Build', activeLabel: 'Building' },
|
|
311
|
+
{ id: 'server', label: 'Server', activeLabel: 'Starting server' },
|
|
312
|
+
];
|
|
313
|
+
|
|
314
|
+
// ============================================================
|
|
315
|
+
// CLI helpers
|
|
316
|
+
// ============================================================
|
|
317
|
+
|
|
90
318
|
function showHelp() {
|
|
91
319
|
console.log(`
|
|
92
320
|
Clopen - All-in-one web workspace for Claude Code & OpenCode
|
|
@@ -98,6 +326,7 @@ USAGE:
|
|
|
98
326
|
COMMANDS:
|
|
99
327
|
update Update clopen to the latest version
|
|
100
328
|
reset-pat Regenerate admin Personal Access Token
|
|
329
|
+
clear-data Delete all projects, sessions, and settings
|
|
101
330
|
|
|
102
331
|
OPTIONS:
|
|
103
332
|
-p, --port <number> Port to run the server on (default: ${DEFAULT_PORT})
|
|
@@ -106,12 +335,10 @@ OPTIONS:
|
|
|
106
335
|
-h, --help Show this help message
|
|
107
336
|
|
|
108
337
|
EXAMPLES:
|
|
109
|
-
clopen # Start with default settings
|
|
110
|
-
clopen --port 9145 # Start on port
|
|
111
|
-
clopen
|
|
112
|
-
clopen update # Update to
|
|
113
|
-
clopen reset-pat # Regenerate admin login token
|
|
114
|
-
clopen --version # Show version
|
|
338
|
+
clopen # Start with default settings
|
|
339
|
+
clopen --port 9145 # Start on custom port
|
|
340
|
+
clopen -v # Show version
|
|
341
|
+
clopen update # Update to latest version
|
|
115
342
|
|
|
116
343
|
For more information, visit: https://github.com/myrialabs/clopen
|
|
117
344
|
`);
|
|
@@ -175,6 +402,10 @@ function parseArguments(): CLIOptions {
|
|
|
175
402
|
options.resetPat = true;
|
|
176
403
|
break;
|
|
177
404
|
|
|
405
|
+
case 'clear-data':
|
|
406
|
+
options.clearData = true;
|
|
407
|
+
break;
|
|
408
|
+
|
|
178
409
|
default:
|
|
179
410
|
console.error(`❌ Error: Unknown option "${arg}"`);
|
|
180
411
|
console.log('Run "clopen --help" for usage information');
|
|
@@ -199,30 +430,33 @@ function isNewerVersion(current: string, latest: string): boolean {
|
|
|
199
430
|
return false;
|
|
200
431
|
}
|
|
201
432
|
|
|
433
|
+
// ============================================================
|
|
434
|
+
// Commands
|
|
435
|
+
// ============================================================
|
|
436
|
+
|
|
202
437
|
async function runUpdate() {
|
|
203
438
|
const currentVersion = getVersion();
|
|
204
439
|
console.log(`\x1b[36mClopen\x1b[0m v${currentVersion}\n`);
|
|
205
440
|
|
|
206
|
-
|
|
207
|
-
updateLoading('Checking for updates...');
|
|
441
|
+
startSpinner('Checking for updates...');
|
|
208
442
|
|
|
209
443
|
let latestVersion: string;
|
|
210
444
|
try {
|
|
211
445
|
const response = await fetch('https://registry.npmjs.org/@myrialabs/clopen/latest');
|
|
212
446
|
if (!response.ok) {
|
|
213
|
-
|
|
447
|
+
stopSpinner();
|
|
214
448
|
console.error(`❌ Failed to check for updates (HTTP ${response.status})`);
|
|
215
449
|
process.exit(1);
|
|
216
450
|
}
|
|
217
451
|
const data = await response.json() as { version: string };
|
|
218
452
|
latestVersion = data.version;
|
|
219
453
|
} catch (err) {
|
|
220
|
-
|
|
454
|
+
stopSpinner();
|
|
221
455
|
console.error('❌ Failed to reach npm registry:', err instanceof Error ? err.message : err);
|
|
222
456
|
process.exit(1);
|
|
223
457
|
}
|
|
224
458
|
|
|
225
|
-
|
|
459
|
+
stopSpinner();
|
|
226
460
|
|
|
227
461
|
if (!isNewerVersion(currentVersion, latestVersion)) {
|
|
228
462
|
console.log(`✓ Already up to date (v${currentVersion})`);
|
|
@@ -231,8 +465,7 @@ async function runUpdate() {
|
|
|
231
465
|
|
|
232
466
|
console.log(` New version available: v${currentVersion} → \x1b[32mv${latestVersion}\x1b[0m\n`);
|
|
233
467
|
|
|
234
|
-
|
|
235
|
-
updateLoading(`Updating to v${latestVersion}...`);
|
|
468
|
+
startSpinner(`Updating to v${latestVersion}...`);
|
|
236
469
|
|
|
237
470
|
const proc = Bun.spawn(['bun', 'add', '-g', '@myrialabs/clopen@latest'], {
|
|
238
471
|
stdout: 'pipe',
|
|
@@ -245,7 +478,7 @@ async function runUpdate() {
|
|
|
245
478
|
]);
|
|
246
479
|
|
|
247
480
|
const exitCode = await proc.exited;
|
|
248
|
-
|
|
481
|
+
stopSpinner();
|
|
249
482
|
|
|
250
483
|
if (exitCode !== 0) {
|
|
251
484
|
const output = (stdout + '\n' + stderr).trim();
|
|
@@ -262,7 +495,6 @@ async function recoverAdminToken() {
|
|
|
262
495
|
const version = getVersion();
|
|
263
496
|
console.log(`\x1b[36mClopen\x1b[0m v${version} — Admin Token Recovery\n`);
|
|
264
497
|
|
|
265
|
-
// Initialize database (import dynamically to avoid loading full backend)
|
|
266
498
|
const { initializeDatabase } = await import('../backend/database/index');
|
|
267
499
|
const { listUsers, regeneratePAT } = await import('../backend/auth/auth-service');
|
|
268
500
|
|
|
@@ -283,8 +515,56 @@ async function recoverAdminToken() {
|
|
|
283
515
|
console.log(`\n Use this token to log in. Keep it safe — it won't be shown again.`);
|
|
284
516
|
}
|
|
285
517
|
|
|
518
|
+
async function clearAllData() {
|
|
519
|
+
const version = getVersion();
|
|
520
|
+
console.log(`\x1b[36mClopen\x1b[0m v${version} — Clear All Data\n`);
|
|
521
|
+
|
|
522
|
+
console.log('\x1b[31m⚠ WARNING: This will permanently delete all projects, sessions, and settings.\x1b[0m');
|
|
523
|
+
console.log(' This action cannot be undone.\n');
|
|
524
|
+
|
|
525
|
+
process.stdout.write('Are you sure? Type "yes" to confirm: ');
|
|
526
|
+
|
|
527
|
+
const response = await new Promise<string>(resolve => {
|
|
528
|
+
const chunks: Buffer[] = [];
|
|
529
|
+
process.stdin.once('data', (data: Buffer) => {
|
|
530
|
+
chunks.push(data);
|
|
531
|
+
resolve(Buffer.concat(chunks).toString().trim());
|
|
532
|
+
});
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
if (response !== 'yes') {
|
|
536
|
+
console.log('\nAborted. No data was deleted.');
|
|
537
|
+
process.exit(0);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
console.log('');
|
|
541
|
+
startSpinner('Clearing all data...');
|
|
542
|
+
|
|
543
|
+
const { initializeDatabase, closeDatabase } = await import('../backend/database/index');
|
|
544
|
+
const { getClopenDir } = await import('../backend/utils/paths');
|
|
545
|
+
const { resetEnvironment } = await import('../backend/engine/adapters/claude/environment');
|
|
546
|
+
const fs = await import('node:fs/promises');
|
|
547
|
+
|
|
548
|
+
await initializeDatabase();
|
|
549
|
+
closeDatabase();
|
|
550
|
+
|
|
551
|
+
const clopenDir = getClopenDir();
|
|
552
|
+
await fs.rm(clopenDir, { recursive: true, force: true });
|
|
553
|
+
|
|
554
|
+
resetEnvironment();
|
|
555
|
+
|
|
556
|
+
await initializeDatabase();
|
|
557
|
+
|
|
558
|
+
stopSpinner();
|
|
559
|
+
console.log('✓ All data cleared successfully.');
|
|
560
|
+
console.log(' Database has been reinitialized with a fresh state.');
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// ============================================================
|
|
564
|
+
// Startup sequence
|
|
565
|
+
// ============================================================
|
|
566
|
+
|
|
286
567
|
async function setupEnvironment() {
|
|
287
|
-
// Check if .env exists, if not copy from .env.example
|
|
288
568
|
if (!existsSync(ENV_FILE)) {
|
|
289
569
|
if (existsSync(ENV_EXAMPLE)) {
|
|
290
570
|
copyFileSync(ENV_EXAMPLE, ENV_FILE);
|
|
@@ -293,43 +573,35 @@ async function setupEnvironment() {
|
|
|
293
573
|
}
|
|
294
574
|
|
|
295
575
|
async function installDependencies() {
|
|
296
|
-
|
|
297
|
-
// Bun is fast and will skip if nothing changed
|
|
298
|
-
updateLoading('Checking dependencies...');
|
|
299
|
-
await delay();
|
|
576
|
+
progressStepStart('deps');
|
|
300
577
|
|
|
301
|
-
const
|
|
578
|
+
const proc = Bun.spawn(['bun', 'install'], {
|
|
302
579
|
cwd: __dirname,
|
|
303
580
|
stdout: 'pipe',
|
|
304
|
-
stderr: 'pipe'
|
|
581
|
+
stderr: 'pipe',
|
|
305
582
|
});
|
|
306
583
|
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
const exitCode = await installProc.exited;
|
|
313
|
-
clearTimeout(updateMessageTimeout);
|
|
584
|
+
const [exitCode] = await Promise.all([
|
|
585
|
+
proc.exited,
|
|
586
|
+
streamSubprocess(proc.stdout as ReadableStream<Uint8Array>, 'deps'),
|
|
587
|
+
streamSubprocess(proc.stderr as ReadableStream<Uint8Array>, 'deps'),
|
|
588
|
+
]);
|
|
314
589
|
|
|
315
590
|
if (exitCode !== 0) {
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
const
|
|
591
|
+
progressStepFail('deps');
|
|
592
|
+
clearProgress();
|
|
593
|
+
const status = stepStatuses.get('deps');
|
|
319
594
|
console.error('❌ Dependency installation failed:');
|
|
320
|
-
console.error(
|
|
595
|
+
if (status?.outputLines.length) console.error(status.outputLines.join('\n'));
|
|
321
596
|
process.exit(exitCode);
|
|
322
597
|
}
|
|
598
|
+
|
|
599
|
+
progressStepDone('deps');
|
|
323
600
|
}
|
|
324
601
|
|
|
325
602
|
function needsBuild(): boolean {
|
|
326
|
-
// No dist directory — must build
|
|
327
603
|
if (!existsSync(DIST_DIR)) return true;
|
|
328
|
-
|
|
329
|
-
// No build version file — must build
|
|
330
604
|
if (!existsSync(BUILD_VERSION_FILE)) return true;
|
|
331
|
-
|
|
332
|
-
// Compare built version with current version
|
|
333
605
|
try {
|
|
334
606
|
const builtVersion = readFileSync(BUILD_VERSION_FILE, 'utf-8').trim();
|
|
335
607
|
return builtVersion !== getVersion();
|
|
@@ -339,44 +611,46 @@ function needsBuild(): boolean {
|
|
|
339
611
|
}
|
|
340
612
|
|
|
341
613
|
async function verifyBuild() {
|
|
342
|
-
if (needsBuild()) {
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
const buildProc = Bun.spawn(['bun', 'run', 'build'], {
|
|
347
|
-
cwd: __dirname,
|
|
348
|
-
stdout: 'pipe',
|
|
349
|
-
stderr: 'pipe'
|
|
350
|
-
});
|
|
614
|
+
if (!needsBuild()) {
|
|
615
|
+
progressStepSkip('build');
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
351
618
|
|
|
352
|
-
|
|
619
|
+
progressStepStart('build');
|
|
353
620
|
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
process.exit(exitCode);
|
|
360
|
-
}
|
|
621
|
+
const proc = Bun.spawn(['bun', 'run', 'build'], {
|
|
622
|
+
cwd: __dirname,
|
|
623
|
+
stdout: 'pipe',
|
|
624
|
+
stderr: 'pipe',
|
|
625
|
+
});
|
|
361
626
|
|
|
362
|
-
|
|
363
|
-
|
|
627
|
+
const [exitCode] = await Promise.all([
|
|
628
|
+
proc.exited,
|
|
629
|
+
streamSubprocess(proc.stdout as ReadableStream<Uint8Array>, 'build'),
|
|
630
|
+
streamSubprocess(proc.stderr as ReadableStream<Uint8Array>, 'build'),
|
|
631
|
+
]);
|
|
632
|
+
|
|
633
|
+
if (exitCode !== 0) {
|
|
634
|
+
progressStepFail('build');
|
|
635
|
+
clearProgress();
|
|
636
|
+
const status = stepStatuses.get('build');
|
|
637
|
+
console.error('❌ Build failed:');
|
|
638
|
+
if (status?.outputLines.length) console.error(status.outputLines.join('\n'));
|
|
639
|
+
process.exit(exitCode);
|
|
364
640
|
}
|
|
641
|
+
|
|
642
|
+
writeFileSync(BUILD_VERSION_FILE, getVersion());
|
|
643
|
+
progressStepDone('build');
|
|
365
644
|
}
|
|
366
645
|
|
|
367
646
|
async function startServer(options: CLIOptions) {
|
|
368
|
-
|
|
369
|
-
|
|
647
|
+
progressStepStart('server');
|
|
648
|
+
progressStepDone('server');
|
|
649
|
+
stopProgress();
|
|
650
|
+
console.log('');
|
|
370
651
|
|
|
371
|
-
// Delegate to scripts/start.ts — handles port resolution (IPv4 + IPv6
|
|
372
|
-
// zombie detection) and starts backend in a single consistent path.
|
|
373
652
|
const startScript = join(__dirname, 'scripts/start.ts');
|
|
374
653
|
|
|
375
|
-
stopLoading();
|
|
376
|
-
|
|
377
|
-
// Overlay clopen's own .env on top of process.env to override any
|
|
378
|
-
// pollution from a .env file in the directory where `clopen` was invoked.
|
|
379
|
-
// CLI args take highest priority on top of that.
|
|
380
654
|
const env = { ...process.env, ...loadEnvFile(ENV_FILE) };
|
|
381
655
|
if (options.port) env.PORT = options.port.toString();
|
|
382
656
|
if (options.host) env.HOST = options.host;
|
|
@@ -386,19 +660,20 @@ async function startServer(options: CLIOptions) {
|
|
|
386
660
|
stdout: 'inherit',
|
|
387
661
|
stderr: 'inherit',
|
|
388
662
|
stdin: 'inherit',
|
|
389
|
-
env
|
|
663
|
+
env,
|
|
390
664
|
});
|
|
391
665
|
|
|
392
|
-
// Wait for server process
|
|
393
666
|
await serverProc.exited;
|
|
394
667
|
}
|
|
395
668
|
|
|
669
|
+
// ============================================================
|
|
670
|
+
// Main
|
|
671
|
+
// ============================================================
|
|
672
|
+
|
|
396
673
|
async function main() {
|
|
397
674
|
try {
|
|
398
|
-
// Parse CLI arguments
|
|
399
675
|
const options = parseArguments();
|
|
400
676
|
|
|
401
|
-
// Show version if requested
|
|
402
677
|
if (options.version) {
|
|
403
678
|
const currentVersion = getVersion();
|
|
404
679
|
console.log(`v${currentVersion}`);
|
|
@@ -420,42 +695,44 @@ async function main() {
|
|
|
420
695
|
process.exit(0);
|
|
421
696
|
}
|
|
422
697
|
|
|
423
|
-
// Show help if requested
|
|
424
698
|
if (options.help) {
|
|
425
699
|
showHelp();
|
|
426
700
|
process.exit(0);
|
|
427
701
|
}
|
|
428
702
|
|
|
429
|
-
// Run update if requested
|
|
430
703
|
if (options.update) {
|
|
431
704
|
await runUpdate();
|
|
432
705
|
process.exit(0);
|
|
433
706
|
}
|
|
434
707
|
|
|
435
|
-
// Recover admin token if requested
|
|
436
708
|
if (options.resetPat) {
|
|
437
709
|
await setupEnvironment();
|
|
438
710
|
await recoverAdminToken();
|
|
439
711
|
process.exit(0);
|
|
440
712
|
}
|
|
441
713
|
|
|
442
|
-
|
|
443
|
-
|
|
714
|
+
if (options.clearData) {
|
|
715
|
+
await setupEnvironment();
|
|
716
|
+
await clearAllData();
|
|
717
|
+
process.exit(0);
|
|
718
|
+
}
|
|
444
719
|
|
|
445
|
-
//
|
|
446
|
-
|
|
720
|
+
// Print header
|
|
721
|
+
console.log(`\n\x1b[36mClopen\x1b[0m v${getVersion()}\n`);
|
|
447
722
|
|
|
448
|
-
//
|
|
449
|
-
|
|
723
|
+
// Init multi-step progress
|
|
724
|
+
initProgress(STARTUP_STEPS);
|
|
450
725
|
|
|
451
|
-
|
|
726
|
+
await setupEnvironment();
|
|
727
|
+
await installDependencies();
|
|
728
|
+
await verifyBuild();
|
|
452
729
|
await startServer(options);
|
|
453
730
|
|
|
454
731
|
} catch (error) {
|
|
732
|
+
clearProgress();
|
|
455
733
|
console.error('❌ Failed to start Clopen:', error);
|
|
456
734
|
process.exit(1);
|
|
457
735
|
}
|
|
458
736
|
}
|
|
459
737
|
|
|
460
|
-
// Run CLI
|
|
461
738
|
main();
|
|
@@ -13,12 +13,12 @@
|
|
|
13
13
|
|
|
14
14
|
{#if appState.isLoading}
|
|
15
15
|
<div
|
|
16
|
-
class="absolute z-20 {isWelcomeState ? '-top-16' : '-top-14'} left-0 right-0 flex justify-center pointer-events-none"
|
|
16
|
+
class="absolute z-20 h-9 {isWelcomeState ? '-top-16' : '-top-14'} left-0 right-0 flex justify-center pointer-events-none"
|
|
17
17
|
transition:fly={{ y: 100, duration: 300 }}
|
|
18
18
|
>
|
|
19
19
|
{#if appState.isWaitingInput}
|
|
20
20
|
<!-- Waiting for user input state -->
|
|
21
|
-
<div class="flex items-center gap-2.5 px-4 py-2 bg-amber-50 dark:bg-amber-
|
|
21
|
+
<div class="flex items-center gap-2.5 px-4 py-2 bg-amber-50 dark:bg-amber-950 rounded-full border border-amber-200 dark:border-amber-900 shadow-sm">
|
|
22
22
|
<Icon name="lucide:message-circle-question-mark" class="w-4 h-4 text-amber-600 dark:text-amber-400" />
|
|
23
23
|
<span class="text-sm font-medium text-amber-700 dark:text-amber-300">
|
|
24
24
|
Waiting for your input...
|
|
@@ -247,9 +247,11 @@
|
|
|
247
247
|
const previousCurrentId = timelineData?.currentHeadId;
|
|
248
248
|
if (timelineData) {
|
|
249
249
|
timelineData.currentHeadId = node.id;
|
|
250
|
+
const isInitialRestore = !!node.checkpoint.isInitial;
|
|
250
251
|
graphNodes = graphNodes.map(n => ({
|
|
251
252
|
...n,
|
|
252
|
-
isCurrent: n.id === node.id
|
|
253
|
+
isCurrent: n.id === node.id,
|
|
254
|
+
isOrphaned: isInitialRestore ? n.id !== node.id : n.isOrphaned
|
|
253
255
|
}));
|
|
254
256
|
}
|
|
255
257
|
|
|
@@ -201,7 +201,7 @@
|
|
|
201
201
|
</div>
|
|
202
202
|
{/if}
|
|
203
203
|
|
|
204
|
-
<div class="flex-1 space-y-
|
|
204
|
+
<div class="flex-1 space-y-1">
|
|
205
205
|
<h3 id="dialog-title" class="text-lg font-semibold {colors.text}">
|
|
206
206
|
{title}
|
|
207
207
|
</h3>
|
|
@@ -227,7 +227,7 @@
|
|
|
227
227
|
</div>
|
|
228
228
|
{/if}
|
|
229
229
|
|
|
230
|
-
{#if !children}
|
|
230
|
+
{#if !children || onConfirm}
|
|
231
231
|
<div class="flex justify-end gap-3 pt-2">
|
|
232
232
|
{#if showCancel}
|
|
233
233
|
<button
|
|
@@ -299,6 +299,13 @@
|
|
|
299
299
|
}
|
|
300
300
|
});
|
|
301
301
|
|
|
302
|
+
// Sync isNavigating back to active tab (Canvas resets this on first frame after navigation)
|
|
303
|
+
$effect(() => {
|
|
304
|
+
if (activeTabId && activeTab && activeTab.isNavigating !== isNavigating) {
|
|
305
|
+
tabManager.updateTab(activeTabId, { isNavigating });
|
|
306
|
+
}
|
|
307
|
+
});
|
|
308
|
+
|
|
302
309
|
// Watch scale changes and send to backend
|
|
303
310
|
let lastSentScale = 1;
|
|
304
311
|
$effect(() => {
|
|
@@ -573,6 +573,14 @@
|
|
|
573
573
|
isReconnecting = false;
|
|
574
574
|
}, 300);
|
|
575
575
|
}
|
|
576
|
+
|
|
577
|
+
// Reset navigation state when first frame arrives after navigation.
|
|
578
|
+
// The preview:browser-navigation event that normally resets this can be
|
|
579
|
+
// missed during stream reconnect (listeners are removed/re-registered),
|
|
580
|
+
// so use the first rendered frame as definitive signal that navigation completed.
|
|
581
|
+
if (isNavigating) {
|
|
582
|
+
isNavigating = false;
|
|
583
|
+
}
|
|
576
584
|
});
|
|
577
585
|
|
|
578
586
|
// Setup cursor change handler
|
|
@@ -73,7 +73,7 @@
|
|
|
73
73
|
let openCodeCommandCopiedTimer: ReturnType<typeof setTimeout> | null = null;
|
|
74
74
|
|
|
75
75
|
// Debug PTY (xterm.js)
|
|
76
|
-
const showDebug = $state(
|
|
76
|
+
const showDebug = $state(false);
|
|
77
77
|
let debugTermContainer = $state<HTMLDivElement>();
|
|
78
78
|
let debugTerminal: Terminal | null = null;
|
|
79
79
|
let debugFitAddon: FitAddon | null = null;
|
|
@@ -188,7 +188,7 @@
|
|
|
188
188
|
<Icon name="lucide:triangle-alert" class="w-6 h-6 text-amber-600 dark:text-amber-400" />
|
|
189
189
|
</div>
|
|
190
190
|
|
|
191
|
-
<div class="flex-1 space-y-
|
|
191
|
+
<div class="flex-1 space-y-1">
|
|
192
192
|
<h3 class="text-lg font-semibold text-slate-900 dark:text-slate-100">
|
|
193
193
|
Change Authentication Mode
|
|
194
194
|
</h3>
|
|
@@ -197,7 +197,7 @@
|
|
|
197
197
|
</p>
|
|
198
198
|
|
|
199
199
|
<!-- PAT Display -->
|
|
200
|
-
<div class="p-3.5 rounded-lg bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-800">
|
|
200
|
+
<div class="mt-3 p-3.5 rounded-lg bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-800">
|
|
201
201
|
<div class="flex items-center gap-2 text-sm font-semibold text-amber-800 dark:text-amber-200 mb-2">
|
|
202
202
|
<Icon name="lucide:key-round" class="w-4 h-4" />
|
|
203
203
|
<span>Your Personal Access Token</span>
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
// Info box visibility - load from localStorage
|
|
16
16
|
let showInfoBox = $state(
|
|
17
17
|
typeof window !== 'undefined'
|
|
18
|
-
? localStorage.getItem('tunnel-info-dismissed') !== 'true'
|
|
18
|
+
? localStorage.getItem('clopen-tunnel-info-dismissed') !== 'true'
|
|
19
19
|
: true
|
|
20
20
|
);
|
|
21
21
|
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
|
|
33
33
|
// Save "don't show again" preference
|
|
34
34
|
if (dontShowWarningAgain) {
|
|
35
|
-
localStorage.setItem('tunnel-warning-dismissed', 'true');
|
|
35
|
+
localStorage.setItem('clopen-tunnel-warning-dismissed', 'true');
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
// Close modal immediately
|
|
@@ -59,7 +59,7 @@
|
|
|
59
59
|
warningDismissed = false;
|
|
60
60
|
|
|
61
61
|
// Check if user has dismissed warning permanently
|
|
62
|
-
const securityWarningDismissed = localStorage.getItem('tunnel-warning-dismissed') === 'true';
|
|
62
|
+
const securityWarningDismissed = localStorage.getItem('clopen-tunnel-warning-dismissed') === 'true';
|
|
63
63
|
if (securityWarningDismissed) {
|
|
64
64
|
// Skip warning and start tunnel directly
|
|
65
65
|
handleStartTunnel();
|
|
@@ -70,7 +70,7 @@
|
|
|
70
70
|
|
|
71
71
|
function closeInfoBox() {
|
|
72
72
|
showInfoBox = false;
|
|
73
|
-
localStorage.setItem('tunnel-info-dismissed', 'true');
|
|
73
|
+
localStorage.setItem('clopen-tunnel-info-dismissed', 'true');
|
|
74
74
|
}
|
|
75
75
|
|
|
76
76
|
function dismissError() {
|
|
@@ -59,7 +59,50 @@ class ChatService {
|
|
|
59
59
|
'Create a full-stack e-commerce platform with Next.js, Stripe, and PostgreSQL',
|
|
60
60
|
'Build a real-time chat application using Socket.io with room support and typing indicators',
|
|
61
61
|
'Create a SaaS dashboard with user management, billing, and analytics',
|
|
62
|
-
|
|
62
|
+
'Build a REST API with authentication, rate limiting, and Swagger documentation',
|
|
63
|
+
'Create a CLI tool in TypeScript that scaffolds new projects with custom templates',
|
|
64
|
+
// Debugging & fixing
|
|
65
|
+
'Debug a memory leak in a Node.js service causing it to crash every 24 hours',
|
|
66
|
+
'Fix race conditions in a concurrent queue processor causing duplicate jobs',
|
|
67
|
+
'Fix a CORS issue blocking requests between a frontend and backend on different origins',
|
|
68
|
+
'Fix broken JWT refresh logic that logs users out unexpectedly',
|
|
69
|
+
'Fix flaky tests that pass locally but fail randomly in CI',
|
|
70
|
+
// Code review & refactoring
|
|
71
|
+
'Refactor a 1000-line monolithic function into clean, testable modules',
|
|
72
|
+
'Convert a class-based React codebase to functional components with hooks',
|
|
73
|
+
'Migrate a JavaScript project to TypeScript with strict mode enabled',
|
|
74
|
+
'Refactor database queries to use an ORM with proper migrations',
|
|
75
|
+
'Clean up and standardize error handling across an entire Express application',
|
|
76
|
+
// Writing tests
|
|
77
|
+
'Write unit tests for a payment processing module with 100% coverage',
|
|
78
|
+
'Add end-to-end tests using Playwright for a multi-step checkout flow',
|
|
79
|
+
'Set up integration tests for a REST API using a real test database',
|
|
80
|
+
'Write property-based tests to find edge cases in a data validation library',
|
|
81
|
+
'Set up test coverage reporting and enforce a minimum threshold in CI',
|
|
82
|
+
// Performance & optimization
|
|
83
|
+
'Optimize a slow PostgreSQL query that runs 10 seconds on a 5M-row table',
|
|
84
|
+
'Implement Redis caching to reduce database load by 80%',
|
|
85
|
+
'Reduce bundle size of a React app from 4MB to under 500KB',
|
|
86
|
+
'Profile and optimize a Python data pipeline processing 1M records per hour',
|
|
87
|
+
'Add lazy loading and virtualization to a list rendering 10,000 items',
|
|
88
|
+
// Architecture & design
|
|
89
|
+
'Design a scalable event-driven architecture using Kafka for a high-traffic app',
|
|
90
|
+
'Plan a migration from a monolith to microservices without downtime',
|
|
91
|
+
'Design a multi-tenant SaaS architecture with data isolation per customer',
|
|
92
|
+
'Create an authentication system supporting SSO, OAuth, and MFA',
|
|
93
|
+
'Architect a real-time notification system using WebSockets and a message queue',
|
|
94
|
+
// DevOps & infrastructure
|
|
95
|
+
'Write a Dockerfile and docker-compose setup for a full-stack app with hot reload',
|
|
96
|
+
'Set up a GitHub Actions CI/CD pipeline with testing, linting, and auto-deploy',
|
|
97
|
+
'Configure Nginx as a reverse proxy with SSL termination and load balancing',
|
|
98
|
+
'Create Terraform scripts to provision a production-ready AWS infrastructure',
|
|
99
|
+
'Set up monitoring and alerting using Prometheus and Grafana',
|
|
100
|
+
// AI & data
|
|
101
|
+
'Build a RAG pipeline using LangChain, embeddings, and a vector database',
|
|
102
|
+
'Create a sentiment analysis API using a fine-tuned transformer model',
|
|
103
|
+
'Build a data scraper that extracts and structures product data at scale',
|
|
104
|
+
'Implement a recommendation engine using collaborative filtering',
|
|
105
|
+
'Create a real-time data dashboard ingesting from multiple streaming sources',
|
|
63
106
|
];
|
|
64
107
|
|
|
65
108
|
constructor() {
|
|
@@ -3,7 +3,7 @@ import type { Theme } from '$shared/types/ui';
|
|
|
3
3
|
// Theme store using Svelte 5 runes
|
|
4
4
|
export const themeStore = $state({
|
|
5
5
|
current: {
|
|
6
|
-
name: '
|
|
6
|
+
name: 'clopen-modern',
|
|
7
7
|
primary: '#D97757',
|
|
8
8
|
secondary: '#4F46E5',
|
|
9
9
|
background: '#F9FAFB',
|
|
@@ -26,7 +26,7 @@ export function isDarkMode() {
|
|
|
26
26
|
// Theme presets
|
|
27
27
|
export const themes: Theme[] = [
|
|
28
28
|
{
|
|
29
|
-
name: '
|
|
29
|
+
name: 'clopen-modern',
|
|
30
30
|
primary: '#D97757',
|
|
31
31
|
secondary: '#4F46E5',
|
|
32
32
|
background: '#F9FAFB',
|
|
@@ -34,7 +34,7 @@ export const themes: Theme[] = [
|
|
|
34
34
|
mode: 'light'
|
|
35
35
|
},
|
|
36
36
|
{
|
|
37
|
-
name: '
|
|
37
|
+
name: 'clopen-dark',
|
|
38
38
|
primary: '#D97757',
|
|
39
39
|
secondary: '#4F46E5',
|
|
40
40
|
background: '#111827',
|
|
@@ -83,7 +83,7 @@ export function setTheme(theme: Theme) {
|
|
|
83
83
|
}
|
|
84
84
|
|
|
85
85
|
// Save to localStorage
|
|
86
|
-
localStorage.setItem('
|
|
86
|
+
localStorage.setItem('clopen-theme', JSON.stringify(theme));
|
|
87
87
|
}
|
|
88
88
|
|
|
89
89
|
// Helper function to update theme color meta tag
|
|
@@ -105,12 +105,12 @@ export function toggleDarkMode() {
|
|
|
105
105
|
const newMode: 'light' | 'dark' = themeStore.isDark ? 'light' : 'dark';
|
|
106
106
|
|
|
107
107
|
// Use predefined themes for consistency
|
|
108
|
-
const newTheme = newMode === 'dark' ? themes[1] : themes[0]; //
|
|
108
|
+
const newTheme = newMode === 'dark' ? themes[1] : themes[0]; // clopen-dark or clopen-modern
|
|
109
109
|
|
|
110
110
|
setTheme(newTheme);
|
|
111
111
|
|
|
112
112
|
// Mark as manual theme choice
|
|
113
|
-
localStorage.setItem('
|
|
113
|
+
localStorage.setItem('clopen-theme-manual', 'true');
|
|
114
114
|
}
|
|
115
115
|
|
|
116
116
|
export function initializeTheme() {
|
|
@@ -121,8 +121,8 @@ export function initializeTheme() {
|
|
|
121
121
|
themeStore.isSystemDark = isSystemDark;
|
|
122
122
|
|
|
123
123
|
// Check for saved theme preference
|
|
124
|
-
const savedTheme = localStorage.getItem('
|
|
125
|
-
const isManualTheme = localStorage.getItem('
|
|
124
|
+
const savedTheme = localStorage.getItem('clopen-theme');
|
|
125
|
+
const isManualTheme = localStorage.getItem('clopen-theme-manual') === 'true';
|
|
126
126
|
|
|
127
127
|
let initialTheme: Theme;
|
|
128
128
|
|
|
@@ -160,7 +160,7 @@ export function initializeTheme() {
|
|
|
160
160
|
themeStore.isSystemDark = e.matches;
|
|
161
161
|
|
|
162
162
|
// Only follow system if no manual theme was set
|
|
163
|
-
if (!localStorage.getItem('
|
|
163
|
+
if (!localStorage.getItem('clopen-theme-manual')) {
|
|
164
164
|
const newTheme = e.matches ? themes[1] : themes[0];
|
|
165
165
|
setTheme(newTheme);
|
|
166
166
|
}
|
|
@@ -169,11 +169,11 @@ export function initializeTheme() {
|
|
|
169
169
|
|
|
170
170
|
export function setManualTheme(theme: Theme) {
|
|
171
171
|
setTheme(theme);
|
|
172
|
-
localStorage.setItem('
|
|
172
|
+
localStorage.setItem('clopen-theme-manual', 'true');
|
|
173
173
|
}
|
|
174
174
|
|
|
175
175
|
export function useSystemTheme() {
|
|
176
|
-
localStorage.removeItem('
|
|
176
|
+
localStorage.removeItem('clopen-theme-manual');
|
|
177
177
|
const defaultTheme = themeStore.isSystemDark ? themes[1] : themes[0];
|
|
178
178
|
setTheme(defaultTheme);
|
|
179
179
|
}
|
|
@@ -751,7 +751,7 @@ export function setActiveMobilePanel(panelId: PanelId): void {
|
|
|
751
751
|
// PERSISTENCE
|
|
752
752
|
// ============================================
|
|
753
753
|
|
|
754
|
-
const STORAGE_KEY = '
|
|
754
|
+
const STORAGE_KEY = 'clopen-workspace-layout';
|
|
755
755
|
|
|
756
756
|
export function saveWorkspaceState(): void {
|
|
757
757
|
try {
|
package/index.html
CHANGED
|
@@ -20,8 +20,8 @@
|
|
|
20
20
|
(function() {
|
|
21
21
|
// Check for saved theme preference
|
|
22
22
|
try {
|
|
23
|
-
const savedTheme = localStorage.getItem('
|
|
24
|
-
const isManualTheme = localStorage.getItem('
|
|
23
|
+
const savedTheme = localStorage.getItem('clopen-theme');
|
|
24
|
+
const isManualTheme = localStorage.getItem('clopen-theme-manual') === 'true';
|
|
25
25
|
|
|
26
26
|
let isDark = false;
|
|
27
27
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@myrialabs/clopen",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.14",
|
|
4
4
|
"description": "All-in-one web workspace for Claude Code & OpenCode — chat, terminal, git, browser preview, checkpoints, and real-time collaboration",
|
|
5
5
|
"author": "Myria Labs",
|
|
6
6
|
"license": "MIT",
|
|
@@ -56,7 +56,7 @@ export async function getOrCreateAnonymousUser(): Promise<AnonymousUser | null>
|
|
|
56
56
|
return null;
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
-
const stored = localStorage.getItem('
|
|
59
|
+
const stored = localStorage.getItem('clopen-anonymous-user');
|
|
60
60
|
|
|
61
61
|
if (stored) {
|
|
62
62
|
try {
|
|
@@ -76,7 +76,7 @@ export async function getOrCreateAnonymousUser(): Promise<AnonymousUser | null>
|
|
|
76
76
|
const newUser = await generateAnonymousUserFromServer();
|
|
77
77
|
|
|
78
78
|
if (newUser) {
|
|
79
|
-
localStorage.setItem('
|
|
79
|
+
localStorage.setItem('clopen-anonymous-user', JSON.stringify(newUser));
|
|
80
80
|
return newUser;
|
|
81
81
|
}
|
|
82
82
|
|
|
@@ -93,7 +93,7 @@ export function getCurrentAnonymousUser(): AnonymousUser | null {
|
|
|
93
93
|
return null;
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
-
const stored = localStorage.getItem('
|
|
96
|
+
const stored = localStorage.getItem('clopen-anonymous-user');
|
|
97
97
|
|
|
98
98
|
if (stored) {
|
|
99
99
|
try {
|
|
@@ -146,7 +146,7 @@ export async function updateAnonymousUserName(newName: string): Promise<Anonymou
|
|
|
146
146
|
});
|
|
147
147
|
|
|
148
148
|
// Save to localStorage
|
|
149
|
-
localStorage.setItem('
|
|
149
|
+
localStorage.setItem('clopen-anonymous-user', JSON.stringify(response));
|
|
150
150
|
debug.log('user', '✅ Updated user name:', response.name);
|
|
151
151
|
return response;
|
|
152
152
|
} catch (error) {
|