@myrialabs/clopen 0.2.2 → 0.2.4

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 (62) 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/claude/stream.ts +107 -0
  9. package/backend/engine/adapters/opencode/server.ts +7 -1
  10. package/backend/engine/adapters/opencode/stream.ts +81 -1
  11. package/backend/engine/types.ts +17 -0
  12. package/backend/git/git-executor.ts +2 -1
  13. package/backend/git/git-service.ts +2 -1
  14. package/backend/index.ts +10 -10
  15. package/backend/snapshot/blob-store.ts +2 -2
  16. package/backend/utils/env.ts +13 -15
  17. package/backend/utils/index.ts +4 -1
  18. package/backend/utils/paths.ts +11 -0
  19. package/backend/utils/port-utils.ts +19 -6
  20. package/backend/ws/git/commit-message.ts +108 -0
  21. package/backend/ws/git/index.ts +3 -1
  22. package/backend/ws/messages/crud.ts +52 -0
  23. package/backend/ws/system/index.ts +7 -1
  24. package/backend/ws/system/operations.ts +28 -2
  25. package/bin/clopen.ts +15 -15
  26. package/docker-compose.yml +31 -0
  27. package/frontend/App.svelte +3 -0
  28. package/frontend/components/auth/SetupPage.svelte +45 -13
  29. package/frontend/components/chat/input/ChatInput.svelte +1 -1
  30. package/frontend/components/chat/message/ChatMessage.svelte +64 -16
  31. package/frontend/components/chat/widgets/FloatingTodoList.svelte +124 -10
  32. package/frontend/components/checkpoint/ConflictResolutionModal.svelte +189 -0
  33. package/frontend/components/checkpoint/TimelineModal.svelte +7 -162
  34. package/frontend/components/common/feedback/RestartRequiredModal.svelte +53 -0
  35. package/frontend/components/common/feedback/UpdateBanner.svelte +19 -8
  36. package/frontend/components/git/BranchManager.svelte +143 -155
  37. package/frontend/components/git/CommitForm.svelte +61 -11
  38. package/frontend/components/history/HistoryModal.svelte +30 -78
  39. package/frontend/components/history/HistoryView.svelte +45 -92
  40. package/frontend/components/settings/SettingsModal.svelte +1 -1
  41. package/frontend/components/settings/SettingsView.svelte +1 -1
  42. package/frontend/components/settings/appearance/AppearanceSettings.svelte +2 -2
  43. package/frontend/components/settings/engines/AIEnginesSettings.svelte +2 -2
  44. package/frontend/components/settings/general/UpdateSettings.svelte +10 -3
  45. package/frontend/components/settings/git/GitSettings.svelte +392 -0
  46. package/frontend/components/settings/model/EngineModelPicker.svelte +275 -0
  47. package/frontend/components/settings/model/ModelSettings.svelte +172 -289
  48. package/frontend/components/workspace/PanelHeader.svelte +1 -3
  49. package/frontend/components/workspace/WorkspaceLayout.svelte +11 -1
  50. package/frontend/components/workspace/panels/FilesPanel.svelte +41 -3
  51. package/frontend/components/workspace/panels/GitPanel.svelte +53 -8
  52. package/frontend/main.ts +4 -0
  53. package/frontend/stores/features/auth.svelte.ts +28 -0
  54. package/frontend/stores/features/settings.svelte.ts +13 -2
  55. package/frontend/stores/ui/settings-modal.svelte.ts +9 -9
  56. package/frontend/stores/ui/update.svelte.ts +51 -4
  57. package/package.json +2 -2
  58. package/scripts/dev.ts +3 -2
  59. package/scripts/start.ts +24 -0
  60. package/shared/types/git.ts +15 -0
  61. package/shared/types/stores/settings.ts +12 -0
  62. 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
  /**
@@ -8,6 +8,7 @@
8
8
 
9
9
  import { query, type SDKMessage, type EngineSDKMessage, type Options, type Query, type SDKUserMessage } from '$shared/types/messaging';
10
10
  import type { PermissionMode, PermissionResult } from "@anthropic-ai/claude-agent-sdk";
11
+ import type { StructuredGenerationOptions } from '../../types';
11
12
  import { normalizePath } from './path-utils';
12
13
  import { setupEnvironmentOnce, getEngineEnv } from './environment';
13
14
  import { handleStreamError } from './error-handler';
@@ -238,4 +239,110 @@ export class ClaudeCodeEngine implements AIEngine {
238
239
  this.pendingUserAnswers.delete(toolUseId);
239
240
  return true;
240
241
  }
242
+
243
+ /**
244
+ * One-shot structured JSON generation.
245
+ * Uses query() with no tools, outputFormat, and maxTurns: 1.
246
+ */
247
+ async generateStructured<T = unknown>(options: StructuredGenerationOptions): Promise<T> {
248
+ const {
249
+ prompt,
250
+ model = 'haiku',
251
+ schema,
252
+ projectPath,
253
+ abortController,
254
+ claudeAccountId
255
+ } = options;
256
+
257
+ if (!this._isInitialized) {
258
+ await this.initialize();
259
+ }
260
+
261
+ const controller = abortController || new AbortController();
262
+ const normalizedPath = normalizePath(projectPath);
263
+
264
+ // Optimized for one-shot structured generation:
265
+ // - tools: [] prevents tool use (no agentic loops)
266
+ // - persistSession: false skips writing session to disk
267
+ // - effort: 'low' reduces processing overhead for simple tasks
268
+ // - thinking disabled removes reasoning overhead
269
+ // - minimal systemPrompt avoids loading heavy defaults
270
+ // - no maxTurns: structured output has its own retry limit
271
+ const sdkOptions: Options = {
272
+ permissionMode: 'bypassPermissions' as PermissionMode,
273
+ allowDangerouslySkipPermissions: true,
274
+ cwd: normalizedPath,
275
+ env: getEngineEnv(claudeAccountId),
276
+ systemPrompt: 'You are a structured data generator. Return JSON matching the provided schema.',
277
+ tools: [],
278
+ outputFormat: {
279
+ type: 'json_schema',
280
+ schema
281
+ },
282
+ persistSession: false,
283
+ effort: 'low',
284
+ thinking: { type: 'disabled' },
285
+ ...(model && { model }),
286
+ abortController: controller
287
+ };
288
+
289
+ // Use plain string prompt — simpler and faster than AsyncIterable
290
+ const queryInstance = query({
291
+ prompt,
292
+ options: sdkOptions
293
+ });
294
+
295
+ let structuredOutput: unknown = null;
296
+ let resultText = '';
297
+ let lastError = '';
298
+
299
+ try {
300
+ for await (const message of queryInstance) {
301
+ debug.log('engine', `[structured] message type=${message.type}, subtype=${'subtype' in message ? message.subtype : 'n/a'}`);
302
+
303
+ if (message.type === 'result') {
304
+ if (message.subtype === 'success') {
305
+ const result = message as any;
306
+ structuredOutput = result.structured_output;
307
+ resultText = result.result || '';
308
+ debug.log('engine', `[structured] success: structured_output=${!!structuredOutput}, resultLen=${resultText.length}`);
309
+ } else {
310
+ const errResult = message as any;
311
+ lastError = errResult.errors?.join('; ') || '';
312
+ const subtype = errResult.subtype || '';
313
+
314
+ // Map SDK error subtypes to user-friendly messages
315
+ if (subtype === 'error_max_structured_output_retries') {
316
+ lastError = 'Failed to generate valid structured output after multiple attempts';
317
+ } else if (subtype === 'error_max_turns') {
318
+ lastError = 'Generation exceeded turn limit';
319
+ } else if (!lastError) {
320
+ lastError = subtype || 'unknown error';
321
+ }
322
+
323
+ debug.error('engine', `[structured] result error: ${lastError}`);
324
+ }
325
+ }
326
+ }
327
+ } catch (error) {
328
+ handleStreamError(error);
329
+ // handleStreamError swallows AbortError — if we reach here without throw, it was cancelled
330
+ throw new Error('Generation was cancelled');
331
+ }
332
+
333
+ if (structuredOutput) {
334
+ return structuredOutput as T;
335
+ }
336
+
337
+ // Fallback: parse the text result as JSON
338
+ if (resultText) {
339
+ try {
340
+ return JSON.parse(resultText) as T;
341
+ } catch {
342
+ debug.warn('engine', `[structured] result text is not valid JSON: ${resultText.slice(0, 200)}`);
343
+ }
344
+ }
345
+
346
+ throw new Error(lastError || 'Claude Code did not return valid structured output');
347
+ }
241
348
  }
@@ -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
  }),
@@ -10,7 +10,7 @@
10
10
  */
11
11
 
12
12
  import type { SDKMessage, SDKUserMessage, EngineSDKMessage } from '$shared/types/messaging';
13
- import type { AIEngine, EngineQueryOptions } from '../../types';
13
+ import type { AIEngine, EngineQueryOptions, StructuredGenerationOptions } from '../../types';
14
14
  import type { EngineModel } from '$shared/types/engine';
15
15
  import type {
16
16
  Provider,
@@ -1010,4 +1010,84 @@ export class OpenCodeEngine implements AIEngine {
1010
1010
 
1011
1011
  return [{ type: 'text', text: '' }];
1012
1012
  }
1013
+
1014
+ /**
1015
+ * One-shot structured JSON generation.
1016
+ * Uses the v1 SDK client.session.prompt() (synchronous) with prompt
1017
+ * engineering for JSON output since v1 doesn't support format option.
1018
+ */
1019
+ async generateStructured<T = unknown>(options: StructuredGenerationOptions): Promise<T> {
1020
+ const {
1021
+ prompt,
1022
+ model = 'claude-sonnet',
1023
+ schema,
1024
+ projectPath,
1025
+ abortController
1026
+ } = options;
1027
+
1028
+ if (!this._isInitialized) {
1029
+ await this.initialize();
1030
+ }
1031
+
1032
+ const client = await ensureClient();
1033
+
1034
+ // Create a temporary session for this one-shot request
1035
+ const sessionResult = await client.session.create({
1036
+ query: { directory: projectPath }
1037
+ });
1038
+ const sessionId = sessionResult.data?.id;
1039
+ if (!sessionId) {
1040
+ throw new Error('Failed to create OpenCode session');
1041
+ }
1042
+
1043
+ // Parse model into providerID/modelID
1044
+ const [providerID, modelID] = model.includes('/') ? model.split('/', 2) : ['', model];
1045
+
1046
+ // Wrap prompt with JSON instruction since v1 doesn't support format option
1047
+ const jsonPrompt = `${prompt}
1048
+
1049
+ IMPORTANT: You MUST respond with ONLY a valid JSON object matching this schema, no other text:
1050
+ ${JSON.stringify(schema, null, 2)}`;
1051
+
1052
+ debug.log('engine', `[OC structured] Sending prompt to session ${sessionId}, model=${model}`);
1053
+
1054
+ // Use v1 SDK synchronous prompt method — waits for completion
1055
+ const response = await client.session.prompt({
1056
+ path: { id: sessionId },
1057
+ body: {
1058
+ parts: [{ type: 'text', text: jsonPrompt }],
1059
+ ...(providerID && modelID ? { model: { providerID, modelID } } : {}),
1060
+ tools: {}
1061
+ },
1062
+ query: { directory: projectPath },
1063
+ ...(abortController?.signal && { signal: abortController.signal })
1064
+ });
1065
+
1066
+ const data = response.data;
1067
+ if (!data) {
1068
+ throw new Error('OpenCode returned empty response');
1069
+ }
1070
+
1071
+ debug.log('engine', `[OC structured] Got response with ${data.parts?.length || 0} parts`);
1072
+
1073
+ // Extract text content from response parts and parse as JSON
1074
+ const textParts = (data.parts || []).filter((p: any) => p.type === 'text');
1075
+ const fullText = textParts.map((p: any) => p.text || '').join('');
1076
+
1077
+ if (!fullText) {
1078
+ throw new Error('OpenCode returned no text content');
1079
+ }
1080
+
1081
+ debug.log('engine', `[OC structured] Raw text: ${fullText.slice(0, 200)}`);
1082
+
1083
+ // Try to extract JSON from the response (may include markdown fences)
1084
+ const jsonMatch = fullText.match(/```(?:json)?\s*([\s\S]*?)```/) || fullText.match(/(\{[\s\S]*\})/);
1085
+ const jsonText = jsonMatch ? jsonMatch[1].trim() : fullText.trim();
1086
+
1087
+ try {
1088
+ return JSON.parse(jsonText) as T;
1089
+ } catch {
1090
+ throw new Error(`OpenCode did not return valid JSON: ${jsonText.slice(0, 200)}`);
1091
+ }
1092
+ }
1013
1093
  }
@@ -24,6 +24,16 @@ export interface EngineQueryOptions {
24
24
  claudeAccountId?: number;
25
25
  }
26
26
 
27
+ /** Options for one-shot structured generation (no tools, no streaming) */
28
+ export interface StructuredGenerationOptions {
29
+ prompt: string;
30
+ model?: string;
31
+ schema: Record<string, unknown>;
32
+ projectPath: string;
33
+ abortController?: AbortController;
34
+ claudeAccountId?: number;
35
+ }
36
+
27
37
  /** The contract every engine adapter must fulfil */
28
38
  export interface AIEngine {
29
39
  /** Engine identifier */
@@ -61,4 +71,11 @@ export interface AIEngine {
61
71
  * Unblocks the canUseTool callback so the SDK can continue.
62
72
  */
63
73
  resolveUserAnswer?(toolUseId: string, answers: Record<string, string>): boolean;
74
+
75
+ /**
76
+ * One-shot structured JSON generation (no tools, no streaming).
77
+ * Returns parsed JSON matching the provided schema.
78
+ * Optional — engines that don't support it leave undefined.
79
+ */
80
+ generateStructured?<T = unknown>(options: StructuredGenerationOptions): Promise<T>;
64
81
  }
@@ -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',
@@ -317,7 +317,8 @@ export class GitService {
317
317
  }
318
318
 
319
319
  async fetch(cwd: string, remote = 'origin'): Promise<string> {
320
- const result = await execGit(['fetch', remote, '--prune'], cwd, 60000);
320
+ // Use explicit refspec to ensure all branches are fetched regardless of clone config
321
+ const result = await execGit(['fetch', remote, `+refs/heads/*:refs/remotes/${remote}/*`, '--prune'], cwd, 60000);
321
322
  if (result.exitCode !== 0) {
322
323
  throw new Error(`git fetch failed: ${result.stderr}`);
323
324
  }
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;