@myrialabs/clopen 0.2.2 → 0.2.3

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 (32) hide show
  1. package/.dockerignore +5 -0
  2. package/.env.example +2 -5
  3. package/CONTRIBUTING.md +4 -0
  4. package/README.md +4 -2
  5. package/backend/database/queries/message-queries.ts +42 -0
  6. package/backend/database/utils/connection.ts +5 -5
  7. package/backend/engine/adapters/claude/environment.ts +3 -4
  8. package/backend/engine/adapters/opencode/server.ts +7 -1
  9. package/backend/git/git-executor.ts +2 -1
  10. package/backend/index.ts +10 -10
  11. package/backend/snapshot/blob-store.ts +2 -2
  12. package/backend/utils/env.ts +13 -15
  13. package/backend/utils/index.ts +4 -1
  14. package/backend/utils/paths.ts +11 -0
  15. package/backend/utils/port-utils.ts +19 -6
  16. package/backend/ws/messages/crud.ts +52 -0
  17. package/bin/clopen.ts +15 -15
  18. package/docker-compose.yml +31 -0
  19. package/frontend/components/auth/SetupPage.svelte +43 -11
  20. package/frontend/components/chat/widgets/FloatingTodoList.svelte +124 -10
  21. package/frontend/components/common/feedback/UpdateBanner.svelte +2 -2
  22. package/frontend/components/history/HistoryModal.svelte +30 -78
  23. package/frontend/components/history/HistoryView.svelte +45 -92
  24. package/frontend/components/settings/appearance/AppearanceSettings.svelte +2 -2
  25. package/frontend/components/workspace/panels/FilesPanel.svelte +41 -3
  26. package/frontend/components/workspace/panels/GitPanel.svelte +41 -3
  27. package/frontend/stores/features/auth.svelte.ts +28 -0
  28. package/frontend/stores/ui/update.svelte.ts +6 -0
  29. package/package.json +2 -2
  30. package/scripts/dev.ts +3 -2
  31. package/scripts/start.ts +24 -0
  32. package/vite.config.ts +2 -2
package/.dockerignore ADDED
@@ -0,0 +1,5 @@
1
+ node_modules
2
+ dist
3
+ logs
4
+ .env
5
+ .git
package/.env.example CHANGED
@@ -1,6 +1,3 @@
1
- # Node Environment (development | production)
2
- NODE_ENV=production
3
-
4
1
  # Server configuration
5
2
  HOST=localhost
6
3
 
@@ -8,5 +5,5 @@ HOST=localhost
8
5
  PORT=9141
9
6
 
10
7
  # Development ports (two ports — Vite proxies to Elysia)
11
- PORT_BACKEND=9151
12
- PORT_FRONTEND=9141
8
+ PORT_FRONTEND=9151
9
+ PORT_BACKEND=9161
package/CONTRIBUTING.md CHANGED
@@ -22,6 +22,10 @@ bun install
22
22
  bun run check
23
23
  ```
24
24
 
25
+ ### Data Directory
26
+
27
+ When running `bun run dev`, Clopen stores data in `~/.clopen-dev` instead of `~/.clopen`. This keeps development data separate from any production instance — especially important since Clopen can be used to develop itself.
28
+
25
29
  ### Keep Updated
26
30
 
27
31
  ```bash
package/README.md CHANGED
@@ -9,7 +9,7 @@
9
9
 
10
10
  ## Features
11
11
 
12
- - **Multi-Account Claude Code** - Manage multiple accounts (personal, work, team) and switch instantly per session, isolated under `~/.clopen/claude/user/` without touching system-level Claude config
12
+ - **Multi-Account Claude Code** - Manage multiple accounts (personal, work, team) and switch instantly per session, isolated under `~/.clopen/claude/user/` (or `~/.clopen-dev/` in development) without touching system-level Claude config
13
13
  - **Multi-Engine Support** - Switch between Claude Code and OpenCode
14
14
  - **AI Chat Interface** - Streaming responses with tool use visualization
15
15
  - **Background Processing** - Chat, terminal, and other processes continue running even when you close the browser — come back later and pick up where you left off
@@ -92,6 +92,8 @@ bun run dev # Start development server
92
92
  bun run check # Type checking
93
93
  ```
94
94
 
95
+ When running in development mode, Clopen uses `~/.clopen-dev` instead of `~/.clopen`, keeping dev data separate from any production instance.
96
+
95
97
  ---
96
98
 
97
99
  ## Architecture
@@ -123,7 +125,7 @@ Clopen uses an engine-agnostic adapter pattern — both engines normalize output
123
125
  ### Port 9141 Already in Use
124
126
 
125
127
  ```bash
126
- clopen --port 9150
128
+ clopen --port 9145
127
129
  ```
128
130
 
129
131
  Or kill the existing process:
@@ -42,6 +42,48 @@ export const messageQueries = {
42
42
  });
43
43
  },
44
44
 
45
+ /**
46
+ * Get minimal preview data for a session: first user msg, last assistant msg, and counts.
47
+ * Used by the Sessions/History modal to avoid loading all messages.
48
+ */
49
+ getSessionPreview(sessionId: string): {
50
+ firstUserMessage: DatabaseMessage | null;
51
+ lastAssistantMessage: DatabaseMessage | null;
52
+ userCount: number;
53
+ assistantCount: number;
54
+ } {
55
+ const db = getDatabase();
56
+
57
+ const firstUserMessage = db.prepare(`
58
+ SELECT * FROM messages
59
+ WHERE session_id = ? AND json_extract(sdk_message, '$.type') = 'user'
60
+ ORDER BY timestamp ASC
61
+ LIMIT 1
62
+ `).get(sessionId) as DatabaseMessage | null;
63
+
64
+ const lastAssistantMessage = db.prepare(`
65
+ SELECT * FROM messages
66
+ WHERE session_id = ? AND json_extract(sdk_message, '$.type') = 'assistant'
67
+ ORDER BY timestamp DESC
68
+ LIMIT 1
69
+ `).get(sessionId) as DatabaseMessage | null;
70
+
71
+ const counts = db.prepare(`
72
+ SELECT
73
+ SUM(CASE WHEN json_extract(sdk_message, '$.type') = 'user' THEN 1 ELSE 0 END) AS user_count,
74
+ SUM(CASE WHEN json_extract(sdk_message, '$.type') = 'assistant' THEN 1 ELSE 0 END) AS assistant_count
75
+ FROM messages
76
+ WHERE session_id = ?
77
+ `).get(sessionId) as { user_count: number; assistant_count: number } | null;
78
+
79
+ return {
80
+ firstUserMessage,
81
+ lastAssistantMessage,
82
+ userCount: counts?.user_count ?? 0,
83
+ assistantCount: counts?.assistant_count ?? 0
84
+ };
85
+ },
86
+
45
87
  /**
46
88
  * Get all messages for a session including deleted ones (for timeline view)
47
89
  */
@@ -1,17 +1,17 @@
1
1
  import { join } from 'path';
2
- import { homedir } from 'os';
3
2
  import { Database } from 'bun:sqlite';
4
3
  import type { DatabaseConnection } from '$shared/types/database/connection';
5
4
 
6
5
  import { debug } from '$shared/utils/logger';
6
+ import { getClopenDir } from '../../utils/index.js';
7
+
7
8
  export class DatabaseManager {
8
9
  private static instance: DatabaseManager | null = null;
9
10
  private db: DatabaseConnection | null = null;
10
11
  private readonly dbPath: string;
11
12
 
12
13
  private constructor() {
13
- const clopenDir = join(homedir(), '.clopen');
14
- this.dbPath = join(clopenDir, 'app.db');
14
+ this.dbPath = join(getClopenDir(), 'app.db');
15
15
  }
16
16
 
17
17
  static getInstance(): DatabaseManager {
@@ -29,8 +29,8 @@ export class DatabaseManager {
29
29
  debug.log('database', '🔗 Connecting to database...');
30
30
 
31
31
  try {
32
- // Create ~/.clopen directory if it doesn't exist
33
- const clopenDir = join(homedir(), '.clopen');
32
+ // Create clopen directory if it doesn't exist
33
+ const clopenDir = getClopenDir();
34
34
  const dirFile = Bun.file(clopenDir);
35
35
 
36
36
  // Check if directory exists, if not create it
@@ -7,22 +7,21 @@
7
7
  * stream concurrently.
8
8
  */
9
9
 
10
- import { homedir } from 'os';
11
10
  import { join } from 'path';
12
11
  import { isWindows, findGitBash } from '../../../terminal/shell-utils.js';
13
12
  import { engineQueries } from '../../../database/queries';
14
13
  import { debug } from '$shared/utils/logger';
15
- import { getCleanSpawnEnv } from '../../../utils/env';
14
+ import { getCleanSpawnEnv, getClopenDir } from '../../../utils/index.js';
16
15
 
17
16
  let _ready = false;
18
17
  let _initPromise: Promise<void> | null = null;
19
18
  let _envOverrides: Record<string, string> = {};
20
19
 
21
20
  /**
22
- * Returns the isolated Claude config directory under ~/.clopen/claude/user/
21
+ * Returns the isolated Claude config directory under {clopenDir}/claude/user/
23
22
  */
24
23
  export function getClaudeUserConfigDir(): string {
25
- return join(homedir(), '.clopen', 'claude', 'user');
24
+ return join(getClopenDir(), 'claude', 'user');
26
25
  }
27
26
 
28
27
  /**
@@ -11,6 +11,7 @@
11
11
  import type { OpencodeClient } from '@opencode-ai/sdk';
12
12
  import { getOpenCodeMcpConfig } from '../../../mcp';
13
13
  import { debug } from '$shared/utils/logger';
14
+ import { findAvailablePort } from '../../../utils/port-utils';
14
15
 
15
16
  const OPENCODE_PORT = 4096;
16
17
  const OPENCODE_HOST = '127.0.0.1';
@@ -58,9 +59,14 @@ async function init(): Promise<void> {
58
59
  }
59
60
  }
60
61
 
62
+ const actualPort = await findAvailablePort(OPENCODE_PORT);
63
+ if (actualPort !== OPENCODE_PORT) {
64
+ debug.log('engine', `Open Code port ${OPENCODE_PORT} in use, using ${actualPort} instead`);
65
+ }
66
+
61
67
  const result = await createOpencode({
62
68
  hostname: OPENCODE_HOST,
63
- port: OPENCODE_PORT,
69
+ port: actualPort,
64
70
  ...(Object.keys(mcpConfig).length > 0 && {
65
71
  config: { mcp: mcpConfig },
66
72
  }),
@@ -22,7 +22,8 @@ export async function execGit(
22
22
  ): Promise<GitExecResult> {
23
23
  debug.log('git', `Executing: git ${args.join(' ')} in ${cwd}`);
24
24
 
25
- const proc = Bun.spawn(['git', ...args], {
25
+ const safeCwd = cwd.replace(/\\/g, '/');
26
+ const proc = Bun.spawn(['git', '-c', `safe.directory=${safeCwd}`, ...args], {
26
27
  cwd,
27
28
  stdout: 'pipe',
28
29
  stderr: 'pipe',
package/backend/index.ts CHANGED
@@ -20,7 +20,6 @@ import { loggerMiddleware } from './middleware/logger';
20
20
  import { initializeDatabase, closeDatabase } from './database';
21
21
  import { disposeAllEngines } from './engine';
22
22
  import { debug } from '$shared/utils/logger';
23
- import { findAvailablePort } from './utils/port-utils';
24
23
  import { networkInterfaces } from 'os';
25
24
  import { resolve } from 'node:path';
26
25
  import { statSync } from 'node:fs';
@@ -45,7 +44,7 @@ wsRouter.setAuthMiddleware(async (conn, action) => {
45
44
  /**
46
45
  * Clopen - Elysia Backend Server
47
46
  *
48
- * Development: Elysia runs on port 9151, Vite dev server proxies /api and /ws from port 9141
47
+ * Development: Elysia runs on port 9161, Vite dev server proxies /api and /ws from port 9151
49
48
  * Production: Elysia runs on port 9141, serves static files from dist/ + API + WebSocket
50
49
  */
51
50
 
@@ -117,11 +116,12 @@ if (!isDevelopment) {
117
116
 
118
117
  // Start server with proper initialization sequence
119
118
  async function startServer() {
120
- // Find available port auto-increment if desired port is in use
121
- const actualPort = await findAvailablePort(PORT);
122
- if (actualPort !== PORT) {
123
- debug.log('server', `⚠️ Port ${PORT} in use, using ${actualPort} instead`);
124
- }
119
+ // Port resolution is handled by the caller:
120
+ // - Development: scripts/dev.ts resolves ports and passes via PORT_BACKEND env
121
+ // - Production: scripts/start.ts resolves port and passes via PORT env
122
+ // - CLI: bin/clopen.ts resolves port and passes via PORT env
123
+ // This avoids double port-check race conditions (e.g. zombie processes on
124
+ // Windows causing silent desync between Vite proxy and backend).
125
125
 
126
126
  // Initialize database first before accepting connections
127
127
  try {
@@ -133,18 +133,18 @@ async function startServer() {
133
133
 
134
134
  // Start listening after database is ready
135
135
  app.listen({
136
- port: actualPort,
136
+ port: PORT,
137
137
  hostname: HOST
138
138
  }, () => {
139
139
  if (isDevelopment) {
140
140
  console.log('🚀 Backend ready — waiting for frontend...');
141
141
  } else {
142
- console.log(`🚀 Clopen running at http://localhost:${actualPort}`);
142
+ console.log(`🚀 Clopen running at http://localhost:${PORT}`);
143
143
  }
144
144
  if (HOST === '0.0.0.0') {
145
145
  const ips = getLocalIps();
146
146
  for (const ip of ips) {
147
- console.log(`🌐 Network access: http://${ip}:${actualPort}`);
147
+ console.log(`🌐 Network access: http://${ip}:${PORT}`);
148
148
  }
149
149
  }
150
150
  });
@@ -11,12 +11,12 @@
11
11
  */
12
12
 
13
13
  import { join } from 'path';
14
- import { homedir } from 'os';
15
14
  import fs from 'fs/promises';
16
15
  import { gzipSync, gunzipSync } from 'zlib';
17
16
  import { debug } from '$shared/utils/logger';
17
+ import { getClopenDir } from '../utils/index.js';
18
18
 
19
- const SNAPSHOTS_DIR = join(homedir(), '.clopen', 'snapshots');
19
+ const SNAPSHOTS_DIR = join(getClopenDir(), 'snapshots');
20
20
  const BLOBS_DIR = join(SNAPSHOTS_DIR, 'blobs');
21
21
  const TREES_DIR = join(SNAPSHOTS_DIR, 'trees');
22
22
 
@@ -22,12 +22,12 @@ const isDev = process.env.NODE_ENV !== 'production';
22
22
 
23
23
  export const SERVER_ENV = {
24
24
  NODE_ENV: (process.env.NODE_ENV || 'development') as string,
25
- /** Backend port — dev: PORT_BACKEND (default 9151), prod: PORT (default 9141) */
25
+ /** Backend port — dev: PORT_BACKEND (default 9161), prod: PORT (default 9141) */
26
26
  PORT: isDev
27
- ? (process.env.PORT_BACKEND ? parseInt(process.env.PORT_BACKEND) : 9151)
27
+ ? (process.env.PORT_BACKEND ? parseInt(process.env.PORT_BACKEND) : 9161)
28
28
  : (process.env.PORT ? parseInt(process.env.PORT) : 9141),
29
29
  /** Frontend port — only used in dev for Vite proxy coordination */
30
- PORT_FRONTEND: process.env.PORT_FRONTEND ? parseInt(process.env.PORT_FRONTEND) : 9141,
30
+ PORT_FRONTEND: process.env.PORT_FRONTEND ? parseInt(process.env.PORT_FRONTEND) : 9151,
31
31
  HOST: (process.env.HOST || 'localhost') as string,
32
32
  isDevelopment: isDev,
33
33
  } as const;
@@ -35,15 +35,13 @@ export const SERVER_ENV = {
35
35
  // ── .env parsing ────────────────────────────────────────────────────
36
36
 
37
37
  /**
38
- * Parse .env file into key-value map.
39
- * Knowing both key AND value lets us compare against process.env
40
- * to determine if Bun's auto-load is still in effect or if the
41
- * system/runtime changed the value after loading.
38
+ * Parse a .env file at the given path into a key-value record.
39
+ * Returns an empty object if the file doesn't exist or can't be read.
42
40
  */
43
- function parseDotEnv(): Map<string, string> {
44
- const entries = new Map<string, string>();
41
+ export function loadEnvFile(envPath: string): Record<string, string> {
42
+ const result: Record<string, string> = {};
45
43
  try {
46
- const content = readFileSync(join(process.cwd(), '.env'), 'utf-8');
44
+ const content = readFileSync(envPath, 'utf-8');
47
45
  for (const line of content.split('\n')) {
48
46
  let trimmed = line.trim();
49
47
  if (!trimmed || trimmed.startsWith('#')) continue;
@@ -51,22 +49,22 @@ function parseDotEnv(): Map<string, string> {
51
49
  const eqIdx = trimmed.indexOf('=');
52
50
  if (eqIdx <= 0) continue;
53
51
  const key = trimmed.substring(0, eqIdx).trim();
54
- // Strip surrounding quotes from value
55
52
  let value = trimmed.substring(eqIdx + 1).trim();
56
53
  if ((value.startsWith('"') && value.endsWith('"')) ||
57
54
  (value.startsWith("'") && value.endsWith("'"))) {
58
55
  value = value.slice(1, -1);
59
56
  }
60
- entries.set(key, value);
57
+ result[key] = value;
61
58
  }
62
59
  } catch {
63
60
  // .env doesn't exist or can't be read
64
61
  }
65
- return entries;
62
+ return result;
66
63
  }
67
64
 
68
- // Capture once at import time
69
- const dotEnv = parseDotEnv();
65
+ // Capture once at import time — read from CWD which is set to the clopen
66
+ // installation directory when spawned via bin/clopen.ts (cwd: __dirname).
67
+ const dotEnv = new Map(Object.entries(loadEnvFile(join(process.cwd(), '.env'))));
70
68
 
71
69
  // ── Filter definitions ──────────────────────────────────────────────
72
70
 
@@ -2,4 +2,7 @@
2
2
  export * from './process-manager.js';
3
3
 
4
4
  // Export environment sanitizer
5
- export * from './env.js';
5
+ export * from './env.js';
6
+
7
+ // Export path utilities
8
+ export * from './paths.js';
@@ -0,0 +1,11 @@
1
+ import { join } from 'path';
2
+ import { homedir } from 'os';
3
+
4
+ /**
5
+ * Returns the Clopen data directory.
6
+ * - development: ~/.clopen-dev
7
+ * - everything else (production, undefined): ~/.clopen
8
+ */
9
+ export function getClopenDir(): string {
10
+ return join(homedir(), process.env.NODE_ENV === 'development' ? '.clopen-dev' : '.clopen');
11
+ }
@@ -1,14 +1,18 @@
1
1
  /**
2
2
  * Port utilities for checking ports before server start.
3
3
  * Bun-optimized: uses Bun.connect for fast cross-platform port check.
4
+ *
5
+ * Checks BOTH IPv4 (127.0.0.1) and IPv6 (::1) — on Windows, 'localhost'
6
+ * may resolve to either address. A zombie process listening on [::1] would
7
+ * go undetected by an IPv4-only check, causing the new server to bind to a
8
+ * port that can't actually serve traffic (connections hang indefinitely).
4
9
  */
5
10
 
6
- /** Check if a port is currently in use */
7
- export async function isPortInUse(port: number): Promise<boolean> {
11
+ /** Try to connect to a specific host:port */
12
+ async function tryConnect(hostname: string, port: number): Promise<boolean> {
8
13
  try {
9
- // Bun-native TCP connect — fast cross-platform check
10
14
  const socket = await Bun.connect({
11
- hostname: '127.0.0.1',
15
+ hostname,
12
16
  port,
13
17
  socket: {
14
18
  data() {},
@@ -18,12 +22,21 @@ export async function isPortInUse(port: number): Promise<boolean> {
18
22
  }
19
23
  });
20
24
  socket.end();
21
- return true; // Connection succeeded = port in use
25
+ return true;
22
26
  } catch {
23
- return false; // Connection refused = port is free
27
+ return false;
24
28
  }
25
29
  }
26
30
 
31
+ /** Check if a port is currently in use on any localhost address (IPv4 + IPv6) */
32
+ export async function isPortInUse(port: number): Promise<boolean> {
33
+ const [v4, v6] = await Promise.all([
34
+ tryConnect('127.0.0.1', port),
35
+ tryConnect('::1', port),
36
+ ]);
37
+ return v4 || v6;
38
+ }
39
+
27
40
  /** Find an available port starting from the given port, incrementing on collision */
28
41
  export async function findAvailablePort(startPort: number, maxAttempts = 8): Promise<number> {
29
42
  let port = startPort;
@@ -12,6 +12,15 @@ import { createRouter } from '$shared/utils/ws-server';
12
12
  import { messageQueries } from '../../database/queries';
13
13
  import { formatDatabaseMessage } from '$shared/utils/message-formatter';
14
14
 
15
+ function extractTextContent(content: unknown): string {
16
+ if (typeof content === 'string') return content;
17
+ if (!Array.isArray(content)) return '';
18
+ return content
19
+ .filter((c: any) => c.type === 'text')
20
+ .map((b: any) => b.text || '')
21
+ .join(' ');
22
+ }
23
+
15
24
  export const crudHandler = createRouter()
16
25
  // List messages
17
26
  .http('messages:list', {
@@ -33,6 +42,49 @@ export const crudHandler = createRouter()
33
42
  }
34
43
  })
35
44
 
45
+ // Bulk session preview — returns title, summary, and message counts for multiple sessions
46
+ // without loading all messages. Used by the Sessions/History modal.
47
+ .http('sessions:preview', {
48
+ data: t.Object({
49
+ session_ids: t.Array(t.String())
50
+ }),
51
+ response: t.Array(t.Any())
52
+ }, ({ data }) => {
53
+ return data.session_ids.map(sessionId => {
54
+ const preview = messageQueries.getSessionPreview(sessionId);
55
+
56
+ // Title from first user message
57
+ let title = 'New Conversation';
58
+ if (preview.firstUserMessage) {
59
+ const sdk = JSON.parse(preview.firstUserMessage.sdk_message);
60
+ const textContent = extractTextContent(sdk.message?.content).trim();
61
+ if (textContent) {
62
+ title = textContent.slice(0, 60) + (textContent.length > 60 ? '...' : '');
63
+ }
64
+ }
65
+
66
+ // Summary from last assistant message
67
+ let summary = 'No messages yet';
68
+ if (preview.lastAssistantMessage) {
69
+ const sdk = JSON.parse(preview.lastAssistantMessage.sdk_message);
70
+ const rawText = extractTextContent(sdk.message?.content);
71
+ const cleanText = rawText.replace(/```[\s\S]*?```/g, '').trim();
72
+ if (cleanText) {
73
+ summary = cleanText.slice(0, 100) + (cleanText.length > 100 ? '...' : '');
74
+ }
75
+ }
76
+
77
+ return {
78
+ session_id: sessionId,
79
+ title,
80
+ summary,
81
+ userCount: preview.userCount,
82
+ assistantCount: preview.assistantCount,
83
+ count: preview.userCount + preview.assistantCount
84
+ };
85
+ });
86
+ })
87
+
36
88
  // Get message by ID
37
89
  .http('messages:get', {
38
90
  data: t.Object({
package/bin/clopen.ts CHANGED
@@ -23,6 +23,7 @@ if (typeof globalThis.Bun === 'undefined') {
23
23
 
24
24
  import { existsSync, copyFileSync, readFileSync, writeFileSync } from 'fs';
25
25
  import { join } from 'path';
26
+ import { loadEnvFile } from '../backend/utils/env';
26
27
 
27
28
  // CLI Options interface
28
29
  interface CLIOptions {
@@ -88,7 +89,7 @@ const MAX_PORT = 65535;
88
89
 
89
90
  function showHelp() {
90
91
  console.log(`
91
- Clopen - Modern web UI for Claude Code
92
+ Clopen - All-in-one web workspace for Claude Code & OpenCode
92
93
 
93
94
  USAGE:
94
95
  clopen [OPTIONS]
@@ -106,7 +107,7 @@ OPTIONS:
106
107
 
107
108
  EXAMPLES:
108
109
  clopen # Start with default settings (port ${DEFAULT_PORT})
109
- clopen --port 9150 # Start on port 9150
110
+ clopen --port 9145 # Start on port 9145
110
111
  clopen --host 0.0.0.0 # Bind to all network interfaces
111
112
  clopen update # Update to the latest version
112
113
  clopen reset-pat # Regenerate admin login token
@@ -262,8 +263,8 @@ async function recoverAdminToken() {
262
263
  console.log(`\x1b[36mClopen\x1b[0m v${version} — Admin Token Recovery\n`);
263
264
 
264
265
  // 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');
266
+ const { initializeDatabase } = await import('../backend/database/index');
267
+ const { listUsers, regeneratePAT } = await import('../backend/auth/auth-service');
267
268
 
268
269
  await initializeDatabase();
269
270
 
@@ -367,21 +368,20 @@ async function startServer(options: CLIOptions) {
367
368
  updateLoading('Starting server...');
368
369
  await delay();
369
370
 
370
- // Run server as subprocess to ensure it uses local node_modules
371
- const serverPath = join(__dirname, 'backend/index.ts');
371
+ // Delegate to scripts/start.ts handles port resolution (IPv4 + IPv6
372
+ // zombie detection) and starts backend in a single consistent path.
373
+ const startScript = join(__dirname, 'scripts/start.ts');
372
374
 
373
375
  stopLoading();
374
376
 
375
- // Prepare environment variables
376
- const env = { ...process.env };
377
- if (options.port) {
378
- env.PORT = options.port.toString();
379
- }
380
- if (options.host) {
381
- env.HOST = options.host;
382
- }
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
+ const env = { ...process.env, ...loadEnvFile(ENV_FILE) };
381
+ if (options.port) env.PORT = options.port.toString();
382
+ if (options.host) env.HOST = options.host;
383
383
 
384
- const serverProc = Bun.spawn(['bun', serverPath], {
384
+ const serverProc = Bun.spawn(['bun', startScript], {
385
385
  cwd: __dirname,
386
386
  stdout: 'inherit',
387
387
  stderr: 'inherit',
@@ -0,0 +1,31 @@
1
+ services:
2
+ clopen:
3
+ build:
4
+ context: .
5
+ dockerfile_inline: |
6
+ FROM oven/bun:latest
7
+ USER root
8
+ RUN apt-get update && apt-get install -y --no-install-recommends \
9
+ git \
10
+ curl \
11
+ chromium \
12
+ && rm -rf /var/lib/apt/lists/*
13
+ ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true
14
+ ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
15
+ RUN curl -fsSL https://claude.ai/install.sh | bash
16
+ ENV PATH="/root/.claude/local:$PATH"
17
+ RUN curl -fsSL https://opencode.ai/install | bash
18
+ RUN bun add -g @myrialabs/clopen
19
+ EXPOSE 9141
20
+ CMD ["clopen", "--host", "0.0.0.0"]
21
+ restart: unless-stopped
22
+ ports:
23
+ - "${PORT:-9141}:9141"
24
+ volumes:
25
+ - clopen_data:/root/.clopen
26
+ - ${PROJECTS_DIR:-./projects}:/root/projects
27
+ environment:
28
+ - NODE_ENV=production
29
+
30
+ volumes:
31
+ clopen_data:
@@ -100,14 +100,41 @@
100
100
  authModeLoading = true;
101
101
 
102
102
  try {
103
- if (selectedAuthMode === 'none') {
104
- await authStore.setupNoAuth();
105
- completedSteps.add('auth-mode');
106
- completedSteps.add('admin-account');
107
- completedSteps = new Set(completedSteps);
108
- currentStep = 'engines';
103
+ if (!isExistingUser) {
104
+ // Fresh setup — existing behavior
105
+ if (selectedAuthMode === 'none') {
106
+ await authStore.setupNoAuth();
107
+ completedSteps.add('auth-mode');
108
+ completedSteps.add('admin-account');
109
+ completedSteps = new Set(completedSteps);
110
+ currentStep = 'engines';
111
+ } else {
112
+ goToNextStep();
113
+ }
109
114
  } else {
110
- goToNextStep();
115
+ // Returning user (wizard shown again after refresh) — apply selected mode
116
+ const previousMode = authStore.authMode;
117
+ if (selectedAuthMode === 'none' && previousMode !== 'none') {
118
+ // with-auth → no-auth: update mode, skip admin-account
119
+ await authStore.switchToNoAuth();
120
+ completedSteps.add('auth-mode');
121
+ completedSteps.add('admin-account');
122
+ completedSteps = new Set(completedSteps);
123
+ currentStep = 'engines';
124
+ } else if (selectedAuthMode === 'required' && previousMode !== 'required') {
125
+ // no-auth → with-auth: update mode, regenerate PAT, go to admin-account
126
+ await authStore.switchToWithAuth();
127
+ goToNextStep();
128
+ } else if (selectedAuthMode === 'none') {
129
+ // Same mode (none) — skip admin-account, go to engines
130
+ completedSteps.add('auth-mode');
131
+ completedSteps.add('admin-account');
132
+ completedSteps = new Set(completedSteps);
133
+ currentStep = 'engines';
134
+ } else {
135
+ // Same mode (required) — advance to admin-account
136
+ goToNextStep();
137
+ }
111
138
  }
112
139
  } catch (err) {
113
140
  authModeError = err instanceof Error ? err.message : 'Setup failed';
@@ -133,11 +160,16 @@
133
160
  adminLoading = true;
134
161
  try {
135
162
  if (isExistingUser) {
136
- // Existing user — just update name if changed and proceed
163
+ // Existing user — update name if changed
137
164
  if (adminName.trim() !== existingUserName) {
138
165
  await authStore.updateName(adminName.trim());
139
166
  }
140
- goToNextStep();
167
+ // If a PAT was just generated (e.g. switched from no-auth to with-auth), show it
168
+ if (authStore.personalAccessToken) {
169
+ showPAT = true;
170
+ } else {
171
+ goToNextStep();
172
+ }
141
173
  } else {
142
174
  await authStore.setup(adminName.trim());
143
175
  showPAT = true;
@@ -324,8 +356,8 @@
324
356
  });
325
357
 
326
358
  // ─── Step 4: Preferences ───
327
- const FONT_SIZE_MIN = 10;
328
- const FONT_SIZE_MAX = 20;
359
+ const FONT_SIZE_MIN = 8;
360
+ const FONT_SIZE_MAX = 24;
329
361
 
330
362
  function handleFontSizeChange(e: Event) {
331
363
  const value = Number((e.target as HTMLInputElement).value);