perf: harden sync architecture and modularize runtimes (#803)
* fix: added desktop app background throttling
* perf: add streaming debug metrics panel
- Show streaming performance metrics in the debug panel
- Auto-enable stream profiling while the panel is open
- Add JSON export for sharing UI and VS Code metrics
* perf: batch streaming updates more aggressively
- Buffer message deltas and metadata updates to cut render churn
- Skip no-op part updates before they touch the message store
- Fix the desktop debug panel shortcut binding
* perf: split streaming event handling and coalesce deltas
- Move streaming content events onto a dedicated fast path
- Defer non-critical stream side effects off the hot path
- Merge repeated message delta events before they reach the UI
* perf: isolate streaming rows from chat rerenders
- Memoize chat rows against render-relevant message changes only
- Read live assistant text directly from store to narrow streaming updates
- Split the active streaming entry from the stable message list path
* perf: streamline chat streaming and SSE proxying
- Reduce chat rerenders around the active streaming path
- Simplify server SSE forwarding to avoid duplicate proxy work
* fix: preserve the first streaming text chunk
- Show the initial text chunk immediately before batched deltas arrive
- Bypass batching for the first text or reasoning part update
- Keep later streaming updates buffered for performance
* perf: align streaming/render hot paths with opencode parity
* perf: harden turn/cache stability and stale delta suppression
* fix: stabilize chat rendering and disable timeline interactions
- Disabled timeline dialog access from shortcuts, commands, and chat input
- Reduced chat render churn by simplifying message list and turn staging behavior
- Improved session-switch stability to prevent update-depth crashes
* perf: track static message rerenders during streaming
* perf: reduce sorted-mode activity rerender fanout
* perf: reduce chat rerender fanout and add active-turn metrics
- Reduced sorted-mode rerender coupling by tightening turn context propagation
- Added a metric for static rerenders outside the active turn during streaming
- Exposed new chat render counters in the debug panel for parity tracking
* fix: keep sorted activity mounted while stream grows
* fix: stabilize session and history scroll rendering
* refactor: decouple server routes from index
* refactor: extract fs module from server index
* refactor: move opencode route ownership into module
* refactor: extract notification route registration
* refactor: extract opencode and notification runtimes from index
* refactor: extract settings runtime and complete server modularization pass
* refactor: modularize server config, skills, icons, and tunnel routes
* refactor: extract server modules from monolithic index.js
Split proxy, routes, runtime helpers, and notification emitter
into dedicated modules under packages/web/server/lib/.
* refactor: replace session/message stores with SSE-driven sync layer
Delete ~9200 lines of old architecture (useEventStream, messageStore,
sessionStore, useSessionStore, questionStore, useTodoStore, client SSE).
New sync layer: event pipeline with coalescing + 16ms flush, pure event
reducer, per-directory child stores with LRU eviction, cursor pagination,
optimistic updates, deferred timeline staging, text throttle.
Migrate all UI consumers to sync hooks (useSessionMessages,
useSessionMessageRecords, useSessionStatus, useSessionPermissions, etc).
Strip session-ui-store to UI-only state, delegate SDK ops to
session-actions with abort-if-busy, optimistic store updates, and
response merging for revert/fork/archive/delete.
Add notification-store for SSE-driven session attention tracking,
cross-directory GlobalSessionStatusStore for sidebar indicators,
client-side diff snapshot sanitization to prevent memory bloat,
and revert message filtering via useVisibleSessionMessages.
* feat: notification store, session actions, activity detection
Add notification-store.ts for SSE-driven attention tracking.
Add sanitize.ts to strip diff snapshot memory bloat.
Add session-actions.ts with optimistic revert/fork/archive/delete.
Improve useSessionActivity with incomplete-message fallback.
Delete useServerSessionStatus polling hook.
* fix: add directory param to all SDK calls, fix command/shell/abort routing
All SDK calls in session-actions.ts now pass directory parameter —
required by OpenCode server to scope session operations. Without it,
abort, commands, revert, fork, and other operations returned 500.
Add routeMessage() in session-ui-store for shell mode (session.shell),
slash commands (session.command), and normal prompts. Command lookup
checks both sync child store and useCommandsStore. Handle /compact
locally via session.summarize().
Implement getContextUsage() to restore header context usage display —
reads token counts from last assistant message in sync store.
* refactor: replace custom API proxy with http-proxy-middleware
Remove ~280 lines of custom proxy code: forwardSseRequest,
forwardGenericApiRequest, collectRequestBodyBuffer, header
manipulation, hop-by-hop filtering, SSE block buffering.
Replace with single createProxyMiddleware() call that handles
SSE streaming, large bodies, and timeouts out of the box.
Dynamic router for OpenCode port changes after restarts.
Auth headers injected via proxyReq hook.
Keep: readiness gate, Windows session merge, API prefix detection.
* perf: targeted event draft cloning to fix streaming render cascade
Event handler was eagerly cloning all state slices on every event,
breaking Zustand selector referential equality. During streaming
(~60 events/sec), this caused every subscriber to re-render regardless
of which slice actually changed.
Now only clones fields the specific event type mutates. Also extracts
StatusRowContainer to isolate high-frequency useAssistantStatus
subscription, removes dead messageStreamStatesMap subscription from
ChatContainer, and narrows useAssistantStatus to only track last
assistant message parts.
MessageList renders: 1972 → 296 per streaming session (-85%).
* fix: null safety for sync state slices
Add defensive ?? {} guards on permission, question, session_status,
and message record access. Prevents crashes when child store state
is partially initialized during bootstrap.
* perf: dedup inflight SDK calls, extract concurrency util, delay PR tracking
Extract mapWithConcurrency to shared lib/concurrency.ts. Add in-flight
dedup for loadProviders/loadAgents to prevent concurrent duplicate SDK
calls. Delay initial PR background tracking by 5s to reduce startup
CPU burst.
* fix: header session lookup across all child stores
Session title and context panel click failed when session belonged to
a different directory than the current child store. Fall back to
getAllSyncSessions() to search all initialized stores.
* chore: bump @opencode-ai/sdk to 1.3.5
* docs: add sync event handling guide
* Optimize session prefetch and improve delete/archive UX
- Add settlement delay to session prefetch to avoid race conditions on
rapid session switches
- Reduce git diff prefetch and session cache limits for better performance
- Implement optimistic UI updates for session delete/archive operations
with proper rollback on failure
- Wire session prefetch hook into SessionSidebar with sync integration
* Add file content cache and sync optimizations
- Wrap FilesAPI with in-memory LRU cache for file content with dual
constraints (entry count and byte size)
- Optimize chat timeline scroll restoration using useLayoutEffect
- Preserve React references in message and part arrays to prevent
unnecessary re-renders when prepending history
- Add session prefetch TTL cache to prevent redundant fetches
- Integrate session prefetch cache clearing with eviction flow
* Improve session sidebar error handling and add diff prefetch filtering
Load active and archived sessions independently using Promise.allSettled
to prevent one failure from blocking the other. Add retry logic to session
API calls and skip large files during diff prefetch to improve performance.
* Replace sendMessage with optimisticSend wrapper
Introduces optimistic UI updates for normal chat messages to provide
instant feedback. Messages appear immediately in the UI while the API
call executes in the background, with automatic rollback on errors.
* perf: split stores, proper optimistic send, fix revert/directory bugs
- split session-ui-store into voice/input/selection/viewport stores
to reduce subscriber re-evaluation during streaming
- wire optimisticSend through useSync shadow Map infrastructure
matching OpenCode's pattern (no heuristic part detection)
- port OpenCode Identifier.ascending ID format for correct sorting
- pass messageID to promptAsync to prevent duplicate messages
- fix worktree directory not propagating to session actions
(dynamic dir() via opencodeClient.getDirectory)
- fix setCurrentSession accepting directoryHint for new sessions
- fix revert not hiding messages (session limit was 5, bumped to match loaded count)
- fix revert optimistic message removal from store
- fix load-more flicker (useLayoutEffect scroll compensation)
- add prefetch TTL cache, file content LRU cache
- add session prefetch for adjacent sessions
- add instant archive/delete (optimistic before SDK call)
- migrate legacy window.__zustand_session_store__ to session-ui-store
- add retry + independent error handling for archived sessions
- add AGENTS.md performance rules
* perf: startup optimization — dedup, caching, light git status, diff rendering gates
- defer diff prefetch to git tab open, reduce concurrency 4→2, skip >500 changed lines
- cap project git checks concurrency (2), directory status probe (3)
- dedup provider/agent loading, github auth, worktree list (in-flight + TTL caches)
- delay PR tracking 5s, cache 403 search failures per-repo
- coalesce settings PUT (200ms debounce), cache settings GET (2s TTL)
- cache canonical directory resolution (60s TTL)
- persist missing directory status to localStorage (10min TTL)
- light/heavy git status: polling skips numstat+line counting+rev-list
- large diff rendering gate (>500 lines → "render anyway" button)
- tokenization degradation for >500KB files in Pierre
- parallelize main.tsx pre-render awaits
- batch sidebar file tree expanded paths restoration (3 at a time)
- remove bare useConfigStore() subscription in AgentsPage
- sync worktree sandboxes to OpenCode SQLite DB
- fix RightSidebarTabs ternary → explicit tab matching
- defensive guards on sync state (session_status, permission, question, message)
* fix: add defensive guards on remaining sync state field accesses
guard session_status, permission, message, todo, part, config with ?? {}
in useDirectorySync selectors, session-cache, and bootstrap
* fix: add missing directory dep to useCallback in use-sync.ts
* fix: preserve diffStats when light-mode polling overwrites status
* perf: optimize startup git status polling and diff rendering
Preserves diff stats when lightweight polling updates repository status
Reduces startup overhead with smarter git polling and store updates
Adds detailed optimization and migration docs for next performance steps
* fix: keep chat diff stats stable during git status updates
Prevents lightweight git polling from dropping diff statistics
Keeps MessageList diff indicators consistent while status refreshes
Improves reliability of git-aware chat rendering
* fix: user animation replay, queued message variant, startup provider loading
- consume animation ID after first play to prevent re-animation
on neighbor assistant message completion
- capture send config (model/agent/variant) at queue time matching
OpenCode's FollowupDraft pattern instead of re-resolving at send time
- replace one-shot startup recovery effect with polling interval
that retries every 2s until providers and agents load
- fix optimistic bridge to avoid re-render loop (stable ref wrappers)
* chore: update tauri to 2.10.3 and all plugins to latest
- tauri 2.9.4 → 2.10.3
- tauri-build 2.5.3 → 2.5.6
- tauri-plugin-dialog 2.4.2 → 2.6.0
- tauri-plugin-log 2.7.1 → 2.8.0
- tauri-plugin-shell 2.3.3 → 2.3.5
- tauri-plugin-updater 2 (floating) → 2.10.0 (pinned)
- @tauri-apps/api ^2.9.0 → ^2.10.1
- wry 0.53.5 → 0.54.4 (transitive)
* refactor: decouple web server index orchestration runtimes
* fix: align VS Code runtime behavior with web and reduce draft view CPU load
- Queue VS Code bridge and SSE startup requests until API readiness to avoid false bootstrap failures
- Make agent manager actions directory-aware and remove real worktrees with safer partial-failure handling
- Replace heavy logo animation path with a lightweight pulse to cut draft-session CPU usage
* fix: restore auto-selected file sending in chat input
- Send server-selected files as proper file URLs in the message payload
- Include server-backed attachments in submit flow instead of dropping them
- Restore queued-message attachments through the refactored input store
* fix: restore session model selection consistently on session switch
- Restore agent, model, and variant from the latest loaded user message for each session
- Wait for session messages before applying restored selections to avoid stale or missing state
- Remove legacy session-choice inference paths that caused overlap and instability
* fix: restore permission replies and auto-accept across sessions
- Scope permission and question replies to the target session directory so answers take effect reliably
- Make permission auto-accept immediately handle pending requests and react to new permission prompts
- Keep parent-session handling working for child-session requests through the shared response path
* feat: add reusable fuzzy branch fuzzy-search helper and dialog integration (#798)
* feat: add reusable fuzzy branch search for worktrees
* chore: drop planning docs from feature branch
* feat: make worktree branch refresh manual
* feat: add configurable session retention action
* refactor: centralize global session state in ui store
* fix: cancel debounced permission push after reply
* docs: clarify global and directory session store architecture
* docs: refine agent development rules and session activity guidance
- Clarify agent code of conduct and durable development patterns
- Add explicit shared-store rerender and live-state guidance
- Narrow session activity fallback to avoid stale working state
* chore: updated .gitignore
---------
Co-authored-by: Iuliia Ivashko <yulia.ivashko@gmail.com>
This commit is contained in:
committed by
GitHub
co-authored by
Iuliia Ivashko
parent
8dfe833faf
commit
c9e31a0e6c
@@ -6,6 +6,39 @@ This module provides OpenCode server integration utilities for the web server ru
|
||||
## Entrypoints and structure
|
||||
- `packages/web/server/lib/opencode/index.js`: public entrypoint (currently baseline placeholder).
|
||||
- `packages/web/server/lib/opencode/auth.js`: provider authentication file operations.
|
||||
- `packages/web/server/lib/opencode/auth-state-runtime.js`: managed OpenCode server auth password/header runtime.
|
||||
- `packages/web/server/lib/opencode/cli-options.js`: CLI/environment option parsing for server startup arguments.
|
||||
- `packages/web/server/lib/opencode/cli-entry-runtime.js`: CLI entrypoint runtime that detects direct execution, parses CLI options, and starts server bootstrap.
|
||||
- `packages/web/server/lib/opencode/routes.js`: OpenCode/provider settings and auth-related route registration.
|
||||
- `packages/web/server/lib/opencode/lifecycle.js`: OpenCode process lifecycle runtime (startup, restart, readiness, health monitoring).
|
||||
- `packages/web/server/lib/opencode/env-runtime.js`: OpenCode CLI/binary resolution and shell environment runtime.
|
||||
- `packages/web/server/lib/opencode/env-config.js`: OpenCode-related environment variable parsing and validation (host/port/hostname).
|
||||
- `packages/web/server/lib/opencode/hmr-state-runtime.js`: HMR-persistent runtime state initialization, auth-state bootstrap, and HMR sync helpers.
|
||||
- `packages/web/server/lib/opencode/bootstrap-runtime.js`: base app bootstrap runtime for status/auth/tts/notification/OpenChamber route wiring.
|
||||
- `packages/web/server/lib/opencode/network-runtime.js`: OpenCode URL construction, health-probe readiness checks, and API prefix runtime.
|
||||
- `packages/web/server/lib/opencode/project-directory-runtime.js`: request-scoped and settings-backed project directory resolution/validation runtime.
|
||||
- `packages/web/server/lib/opencode/config-entity-routes.js`: route registration for agent/command/MCP config orchestration and reload semantics.
|
||||
- `packages/web/server/lib/opencode/cli-options.js`: CLI/environment option parsing for server startup arguments.
|
||||
- `packages/web/server/lib/opencode/core-routes.js`: server status/system routes, auth/access guard routes, and settings utility route registration.
|
||||
- `packages/web/server/lib/opencode/shutdown-runtime.js`: graceful shutdown orchestration runtime for watcher/session/terminal/process/server teardown.
|
||||
- `packages/web/server/lib/opencode/server-startup-runtime.js`: server listen/startup tunnel flow and process/signal handler orchestration runtime.
|
||||
- `packages/web/server/lib/opencode/static-routes-runtime.js`: static asset/SPA fallback route registration and manifest route wiring.
|
||||
- `packages/web/server/lib/opencode/feature-routes-runtime.js`: feature route composition runtime for dynamic import-backed config/skill/provider route registration.
|
||||
- `packages/web/server/lib/opencode/opencode-resolution-runtime.js`: OpenCode binary resolution snapshot runtime for settings routes and diagnostics.
|
||||
- `packages/web/server/lib/opencode/tunnel-wiring-runtime.js`: tunnel service/routes composition runtime and active-port wiring for main server startup.
|
||||
- `packages/web/server/lib/opencode/startup-pipeline-runtime.js`: server startup tail orchestration runtime for terminal/proxy/static/start-listen flow.
|
||||
- `packages/web/server/lib/opencode/server-utils-runtime.js`: shared server runtime utilities for OpenCode proxy wiring, OpenCode port/readiness helpers, and snapshot fetchers.
|
||||
- `packages/web/server/lib/opencode/openchamber-routes.js`: OpenChamber update and models metadata route registration.
|
||||
- `packages/web/server/lib/opencode/pwa-manifest-routes.js`: PWA manifest route registration with recent-session shortcut resolution and short-lived caching.
|
||||
- `packages/web/server/lib/opencode/project-icon-routes.js`: project icon upload/read/discovery route registration and icon storage orchestration.
|
||||
- `packages/web/server/lib/opencode/skill-routes.js`: route registration for skill config CRUD, supporting files, and skills catalog scan/install flows.
|
||||
- `packages/web/server/lib/opencode/settings-runtime.js`: Settings persistence runtime (disk IO, migrations, normalization, project validation, and persisted update serialization).
|
||||
- `packages/web/server/lib/opencode/settings-helpers.js`: Settings payload sanitization/format helpers runtime for response shaping and persisted merge prep.
|
||||
- `packages/web/server/lib/opencode/settings-normalization-runtime.js`: path/settings/tunnel normalization and sanitization helpers runtime used by settings/routes/config wiring.
|
||||
- `packages/web/server/lib/opencode/theme-runtime.js`: custom theme JSON validation and theme directory loading runtime for settings utility routes.
|
||||
- `packages/web/server/lib/opencode/proxy.js`: OpenCode API/SSE forwarding and readiness-gate route registration.
|
||||
- `packages/web/server/lib/opencode/session-runtime.js`: session status/attention/activity runtime for OpenCode SSE events.
|
||||
- `packages/web/server/lib/opencode/watcher.js`: global SSE watcher runtime for push/session event fanout.
|
||||
- `packages/web/server/lib/opencode/shared.js`: shared utilities for config, markdown, skills, and git helpers.
|
||||
- `packages/web/server/lib/opencode/ui-auth.js`: UI session authentication with rate limiting.
|
||||
|
||||
@@ -43,6 +76,269 @@ This module provides OpenCode server integration utilities for the web server ru
|
||||
- `ensureSessionToken(req, res)`: Returns or creates session token.
|
||||
- `dispose()`: Cleans up timers and state.
|
||||
|
||||
## Public exports (routes.js)
|
||||
- `registerOpenCodeRoutes(app, dependencies)`: Registers OpenCode-owned HTTP routes and internal module runtime:
|
||||
- `GET /api/config/settings`
|
||||
- `PUT /api/config/settings`
|
||||
- `GET /api/config/opencode-resolution`
|
||||
- `POST /api/opencode/directory`
|
||||
- `GET /api/provider/:providerId/source`
|
||||
- `DELETE /api/provider/:providerId/auth`
|
||||
- Owns lazy auth library loading for provider auth checks/removal.
|
||||
- Keeps route behavior independent from composition root; `index.js` now supplies dependencies only.
|
||||
|
||||
## Public exports (session-runtime.js)
|
||||
- `createSessionRuntime({ writeSseEvent, getNotificationClients })`: creates runtime-owned state machine and APIs for session status.
|
||||
- Returned API:
|
||||
- `processOpenCodeSsePayload(payload)`
|
||||
- `getSessionActivitySnapshot()`
|
||||
- `getSessionStateSnapshot()`
|
||||
- `getSessionAttentionSnapshot()`
|
||||
- `getSessionState(sessionId)`
|
||||
- `getSessionAttentionState(sessionId)`
|
||||
- `markSessionViewed(sessionId, clientId)`
|
||||
- `markSessionUnviewed(sessionId, clientId)`
|
||||
- `markUserMessageSent(sessionId)`
|
||||
- `resetAllSessionActivityToIdle()`
|
||||
- `dispose()`
|
||||
|
||||
## Public exports (lifecycle.js)
|
||||
- `createOpenCodeLifecycleRuntime(dependencies)`: creates lifecycle runtime for managed/external OpenCode process orchestration.
|
||||
- Returned API:
|
||||
- `startOpenCode()`
|
||||
- `restartOpenCode()`
|
||||
- `waitForOpenCodeReady(timeoutMs?, intervalMs?)`
|
||||
- `waitForAgentPresence(agentName, timeoutMs?, intervalMs?)`
|
||||
- `refreshOpenCodeAfterConfigChange(reason, options?)`
|
||||
- `bootstrapOpenCodeAtStartup()`
|
||||
- `startHealthMonitoring(healthCheckIntervalMs)`
|
||||
- `killProcessOnPort(port)`
|
||||
|
||||
## Public exports (env-runtime.js)
|
||||
- `createOpenCodeEnvRuntime(dependencies)`: creates runtime that owns OpenCode CLI environment and binary discovery state.
|
||||
- Returned API:
|
||||
- `applyLoginShellEnvSnapshot()`
|
||||
- `getLoginShellEnvSnapshot()`
|
||||
- `ensureOpencodeCliEnv()`
|
||||
- `applyOpencodeBinaryFromSettings()`
|
||||
- `resolveOpencodeCliPath()`
|
||||
- `resolveGitBinaryForSpawn()`
|
||||
- `resolveWslExecutablePath()`
|
||||
- `buildWslExecArgs(execArgs, distroOverride?)`
|
||||
- `opencodeShimInterpreter(opencodePath)`
|
||||
- `isExecutable(filePath)`
|
||||
- `searchPathFor(binaryName)`
|
||||
- `clearResolvedOpenCodeBinary()`
|
||||
|
||||
## Public exports (env-config.js)
|
||||
- `resolveOpenCodeEnvConfig(options?)`: resolves and validates OpenCode host/port/hostname environment configuration.
|
||||
- Returned object fields:
|
||||
- `configuredOpenCodePort`
|
||||
- `configuredOpenCodeHost`
|
||||
- `effectivePort`
|
||||
- `configuredOpenCodeHostname`
|
||||
|
||||
## Public exports (hmr-state-runtime.js)
|
||||
- `createHmrStateRuntime(dependencies)`: creates runtime for HMR state container initialization and runtime<->HMR state synchronization.
|
||||
- Returned API:
|
||||
- `getOrCreateHmrState()`
|
||||
- `ensureUserProvidedOpenCodePassword(hmrState)`
|
||||
- `getUserProvidedOpenCodePassword(hmrState)`
|
||||
- `resolveOpenCodeAuthFromState({ hmrState, userProvidedOpenCodePassword })`
|
||||
- `syncStateFromRuntime(hmrState, runtime)`
|
||||
- `restoreRuntimeFromState({ hmrState, userProvidedOpenCodePassword })`
|
||||
|
||||
## Public exports (bootstrap-runtime.js)
|
||||
- `createBootstrapRuntime(dependencies)`: creates runtime for base app route bootstrap and UI auth controller initialization.
|
||||
- Returned API:
|
||||
- `setupBaseRoutes(app, options)`
|
||||
|
||||
## Public exports (network-runtime.js)
|
||||
- `createOpenCodeNetworkRuntime(dependencies)`: creates runtime for OpenCode network and URL concerns.
|
||||
- Returned API:
|
||||
- `waitForReady(url, timeoutMs?)`
|
||||
- `normalizeApiPrefix(prefix)`
|
||||
- `setDetectedOpenCodeApiPrefix()`
|
||||
- `buildOpenCodeUrl(path, prefixOverride?)`
|
||||
- `ensureOpenCodeApiPrefix()`
|
||||
- `scheduleOpenCodeApiDetection()`
|
||||
|
||||
## Public exports (settings-runtime.js)
|
||||
- `createSettingsRuntime(dependencies)`: creates settings lifecycle runtime for read/migrate/persist concerns.
|
||||
- Returned API:
|
||||
- `readSettingsFromDisk()`
|
||||
- `readSettingsFromDiskMigrated()`
|
||||
- `writeSettingsToDisk(settings)`
|
||||
- `persistSettings(changes)`
|
||||
|
||||
## Public exports (settings-helpers.js)
|
||||
- `createSettingsHelpers(dependencies)`: creates settings helper runtime for settings request/response shaping.
|
||||
- Returned API:
|
||||
- `normalizePwaAppName(value, fallback?)`
|
||||
- `sanitizeSettingsUpdate(payload)`
|
||||
- `mergePersistedSettings(current, changes)`
|
||||
- `formatSettingsResponse(settings)`
|
||||
|
||||
## Public exports (settings-normalization-runtime.js)
|
||||
- `createSettingsNormalizationRuntime(dependencies)`: creates normalization/sanitization runtime for shared settings and tunnel helper logic.
|
||||
- Returned API:
|
||||
- `normalizeDirectoryPath(value)`
|
||||
- `normalizePathForPersistence(value)`
|
||||
- `normalizeSettingsPaths(input)`
|
||||
- `normalizeTunnelBootstrapTtlMs(value)`
|
||||
- `normalizeTunnelSessionTtlMs(value)`
|
||||
- `normalizeManagedRemoteTunnelHostname(value)`
|
||||
- `normalizeManagedRemoteTunnelPresets(value)`
|
||||
- `normalizeManagedRemoteTunnelPresetTokens(value)`
|
||||
- `isUnsafeSkillRelativePath(value)`
|
||||
- `sanitizeTypographySizesPartial(input)`
|
||||
- `normalizeStringArray(input)`
|
||||
- `sanitizeModelRefs(input, limit)`
|
||||
- `sanitizeSkillCatalogs(input)`
|
||||
- `sanitizeProjects(input)`
|
||||
|
||||
## Public exports (theme-runtime.js)
|
||||
- `createThemeRuntime(dependencies)`: creates custom theme runtime for on-disk theme discovery and JSON normalization/validation.
|
||||
- Returned API:
|
||||
- `normalizeThemeJson(raw)`
|
||||
- `readCustomThemesFromDisk()`
|
||||
|
||||
## Public exports (project-directory-runtime.js)
|
||||
- `createProjectDirectoryRuntime(dependencies)`: creates runtime for request/project directory candidate normalization and validation.
|
||||
- Returned API:
|
||||
- `resolveDirectoryCandidate(value)`
|
||||
- `validateDirectoryPath(candidate)`
|
||||
- `resolveProjectDirectory(req)`
|
||||
- `resolveOptionalProjectDirectory(req)`
|
||||
|
||||
## Public exports (config-entity-routes.js)
|
||||
- `registerConfigEntityRoutes(app, dependencies)`: registers configuration entity routes:
|
||||
- Agents: `/api/config/agents/:name` and `/api/config/agents/:name/config`
|
||||
- Commands: `/api/config/commands/:name`
|
||||
- MCP servers: `/api/config/mcp` and `/api/config/mcp/:name`
|
||||
|
||||
## Public exports (auth-state-runtime.js)
|
||||
- `createOpenCodeAuthStateRuntime(dependencies)`: creates runtime for managed OpenCode auth password state and request headers.
|
||||
- Returned API:
|
||||
- `getOpenCodeAuthHeaders()`
|
||||
- `isOpenCodeConnectionSecure()`
|
||||
- `ensureLocalOpenCodeServerPassword(options?)`
|
||||
|
||||
## Public exports (core-routes.js)
|
||||
- `registerServerStatusRoutes(app, dependencies)`: registers status/system endpoints:
|
||||
- `GET /health`
|
||||
- `POST /api/system/shutdown`
|
||||
- `GET /api/system/info`
|
||||
- `registerAuthAndAccessRoutes(app, dependencies)`: registers browser auth/session exchange and API access middleware:
|
||||
- `GET /auth/session`
|
||||
- `POST /auth/session`
|
||||
- `GET /connect`
|
||||
- `app.use('/api', ...)` auth/tunnel guard
|
||||
- `registerSettingsUtilityRoutes(app, dependencies)`: registers small settings utility endpoints:
|
||||
- `GET /api/config/themes`
|
||||
- `POST /api/config/reload`
|
||||
- `registerCommonRequestMiddleware(app, dependencies)`: registers shared request middleware stack:
|
||||
- conditional JSON body parser behavior for `/api/*` vs non-API requests
|
||||
- URL-encoded parser setup
|
||||
- request logging middleware
|
||||
|
||||
## Public exports (cli-options.js)
|
||||
- `parseServeCliOptions(options)`: parses serve CLI flags and environment-derived defaults:
|
||||
- Port/host/ui-password
|
||||
- Tunnel provider/mode/config/token/hostname
|
||||
- Legacy `--tunnel` shorthand normalization
|
||||
|
||||
## Public exports (cli-entry-runtime.js)
|
||||
- `runCliEntryIfMain(dependencies)`: detects direct CLI execution and runs server startup with parsed CLI options.
|
||||
|
||||
## Public exports (server-utils-runtime.js)
|
||||
- `createServerUtilsRuntime(dependencies)`: creates server utility runtime for OpenCode orchestration helpers.
|
||||
- Returned API:
|
||||
- `setOpenCodePort(port)`
|
||||
- `waitForOpenCodePort(timeoutMs?)`
|
||||
- `buildAugmentedPath()`
|
||||
- `parseSseDataPayload(block)`
|
||||
- `fetchAgentsSnapshot()`
|
||||
- `fetchProvidersSnapshot()`
|
||||
- `fetchModelsSnapshot()`
|
||||
- `setupProxy(app)`
|
||||
|
||||
## Public exports (shutdown-runtime.js)
|
||||
- `createGracefulShutdownRuntime(dependencies)`: creates graceful shutdown runtime for managed OpenCode and web server teardown sequencing.
|
||||
- Returned API:
|
||||
- `gracefulShutdown(options?)`
|
||||
|
||||
## Public exports (server-startup-runtime.js)
|
||||
- `createServerStartupRuntime(dependencies)`: creates runtime for server bind/startup tunnel and process handler wiring.
|
||||
- Returned API:
|
||||
- `resolveBindHost(host)`
|
||||
- `startListeningAndMaybeTunnel(options)`
|
||||
- `attachProcessHandlers(options)`
|
||||
|
||||
## Public exports (static-routes-runtime.js)
|
||||
- `createStaticRoutesRuntime(dependencies)`: creates runtime for static dist resolution and static route registration.
|
||||
- Returned API:
|
||||
- `registerStaticRoutes(app)`
|
||||
|
||||
## Public exports (feature-routes-runtime.js)
|
||||
- `createFeatureRoutesRuntime(dependencies)`: creates runtime for main feature route registration orchestration.
|
||||
- Returned API:
|
||||
- `registerRoutes(app, routeDependencies)`
|
||||
|
||||
## Public exports (opencode-resolution-runtime.js)
|
||||
- `createOpenCodeResolutionRuntime(dependencies)`: creates runtime for OpenCode binary/source snapshot resolution.
|
||||
- Returned API:
|
||||
- `getOpenCodeResolutionSnapshot(settings)`
|
||||
|
||||
## Public exports (tunnel-wiring-runtime.js)
|
||||
- `createTunnelWiringRuntime(dependencies)`: creates runtime for tunnel service construction and tunnel route registration.
|
||||
- Returned API:
|
||||
- `initialize(app, initialPort)`
|
||||
|
||||
## Public exports (startup-pipeline-runtime.js)
|
||||
- `createStartupPipelineRuntime(dependencies)`: creates runtime for terminal wiring, proxy/bootstrap scheduling, static route registration, and server startup/listen flow.
|
||||
- Returned API:
|
||||
- `run(options)`
|
||||
|
||||
## Public exports (openchamber-routes.js)
|
||||
- `registerOpenChamberRoutes(app, dependencies)`: registers OpenChamber endpoints:
|
||||
- `GET /api/openchamber/update-check`
|
||||
- `POST /api/openchamber/update-install`
|
||||
- `GET /api/openchamber/models-metadata`
|
||||
- `GET /api/zen/models`
|
||||
|
||||
## Public exports (pwa-manifest-routes.js)
|
||||
- `registerPwaManifestRoute(app, dependencies)`: registers PWA manifest endpoint with dynamic app-name resolution and recent-session shortcuts:
|
||||
- `GET /manifest.webmanifest`
|
||||
|
||||
## Public exports (project-icon-routes.js)
|
||||
- `registerProjectIconRoutes(app, dependencies)`: registers project icon routes and owns icon storage/discovery flow:
|
||||
- `GET /api/projects/:projectId/icon`
|
||||
- `PUT /api/projects/:projectId/icon`
|
||||
- `DELETE /api/projects/:projectId/icon`
|
||||
- `POST /api/projects/:projectId/icon/discover`
|
||||
|
||||
## Public exports (skill-routes.js)
|
||||
- `registerSkillRoutes(app, dependencies)`: registers skills-related routes:
|
||||
- Skills config CRUD and metadata under `/api/config/skills*`
|
||||
- Skills catalog listing/source pagination, scan, and install routes
|
||||
- Supporting skill file read/write/delete routes
|
||||
|
||||
## Public exports (proxy.js)
|
||||
- `registerOpenCodeProxy(app, dependencies)`: registers OpenCode proxy routes and middleware.
|
||||
- Owns:
|
||||
- SSE forwarders: `GET /api/global/event`, `GET /api/event`
|
||||
- Session message forwarder: `POST /api/session/:sessionId/message`
|
||||
- Generic `/api/*` forwarding with hop-by-hop header filtering
|
||||
- Windows `/session` merge fallback path behavior
|
||||
- OpenCode readiness gate for proxied `/api` requests
|
||||
|
||||
## Public exports (watcher.js)
|
||||
- `createOpenCodeWatcherRuntime(dependencies)`: creates global event watcher runtime.
|
||||
- Returned API:
|
||||
- `start()`
|
||||
- `stop()`
|
||||
|
||||
## Storage and configuration
|
||||
- Provider auth: `~/.local/share/opencode/auth.json`.
|
||||
- User config: `~/.config/opencode/opencode.json`.
|
||||
@@ -52,7 +348,7 @@ This module provides OpenCode server integration utilities for the web server ru
|
||||
|
||||
## Notes for contributors
|
||||
- This module serves as foundation for OpenCode-related server utilities.
|
||||
- Index.js is currently a baseline placeholder; direct imports use submodule paths.
|
||||
- Route ownership moved to module-level `routes.js`; `index.js` wires dependencies only.
|
||||
- All file writes include automatic backup before modification.
|
||||
- Config merging follows priority: custom > project > user.
|
||||
- UI auth uses scrypt for password hashing with constant-time comparison.
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
export const createOpenCodeAuthStateRuntime = (dependencies) => {
|
||||
const {
|
||||
crypto,
|
||||
process,
|
||||
getAuthPassword,
|
||||
setAuthPassword,
|
||||
getAuthSource,
|
||||
setAuthSource,
|
||||
getUserProvidedPassword,
|
||||
syncToHmrState,
|
||||
} = dependencies;
|
||||
|
||||
const normalizeOpenCodePassword = (value) => {
|
||||
if (typeof value !== 'string') {
|
||||
return '';
|
||||
}
|
||||
return value.trim();
|
||||
};
|
||||
|
||||
const isValidOpenCodePassword = (password) => typeof password === 'string' && password.trim().length > 0;
|
||||
|
||||
const generateSecureOpenCodePassword = () =>
|
||||
crypto
|
||||
.randomBytes(32)
|
||||
.toString('base64')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/g, '');
|
||||
|
||||
const setOpenCodeAuthState = (password, source) => {
|
||||
const normalized = normalizeOpenCodePassword(password);
|
||||
if (!isValidOpenCodePassword(normalized)) {
|
||||
setAuthPassword(null);
|
||||
setAuthSource(null);
|
||||
delete process.env.OPENCODE_SERVER_PASSWORD;
|
||||
syncToHmrState();
|
||||
return null;
|
||||
}
|
||||
|
||||
setAuthPassword(normalized);
|
||||
setAuthSource(source);
|
||||
process.env.OPENCODE_SERVER_PASSWORD = normalized;
|
||||
syncToHmrState();
|
||||
return normalized;
|
||||
};
|
||||
|
||||
const getOpenCodeAuthHeaders = () => {
|
||||
const password = normalizeOpenCodePassword(getAuthPassword() || process.env.OPENCODE_SERVER_PASSWORD || '');
|
||||
|
||||
if (!password) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const credentials = Buffer.from(`opencode:${password}`).toString('base64');
|
||||
return { Authorization: `Basic ${credentials}` };
|
||||
};
|
||||
|
||||
const isOpenCodeConnectionSecure = () => Object.prototype.hasOwnProperty.call(getOpenCodeAuthHeaders(), 'Authorization');
|
||||
|
||||
const ensureLocalOpenCodeServerPassword = async ({ rotateManaged = false } = {}) => {
|
||||
const userProvidedPassword = getUserProvidedPassword();
|
||||
if (isValidOpenCodePassword(userProvidedPassword)) {
|
||||
return setOpenCodeAuthState(userProvidedPassword, 'user-env');
|
||||
}
|
||||
|
||||
if (rotateManaged) {
|
||||
const rotatedPassword = setOpenCodeAuthState(generateSecureOpenCodePassword(), 'rotated');
|
||||
console.log('Rotated secure password for managed local OpenCode instance');
|
||||
return rotatedPassword;
|
||||
}
|
||||
|
||||
const currentPassword = getAuthPassword();
|
||||
const currentSource = getAuthSource();
|
||||
if (isValidOpenCodePassword(currentPassword)) {
|
||||
return setOpenCodeAuthState(currentPassword, currentSource || 'generated');
|
||||
}
|
||||
|
||||
const generatedPassword = setOpenCodeAuthState(generateSecureOpenCodePassword(), 'generated');
|
||||
console.log('Generated secure password for managed local OpenCode instance');
|
||||
return generatedPassword;
|
||||
};
|
||||
|
||||
return {
|
||||
getOpenCodeAuthHeaders,
|
||||
isOpenCodeConnectionSecure,
|
||||
ensureLocalOpenCodeServerPassword,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,119 @@
|
||||
export const createBootstrapRuntime = (dependencies) => {
|
||||
const {
|
||||
createUiAuth,
|
||||
registerServerStatusRoutes,
|
||||
registerCommonRequestMiddleware,
|
||||
registerAuthAndAccessRoutes,
|
||||
registerTtsRoutes,
|
||||
registerNotificationRoutes,
|
||||
registerOpenChamberRoutes,
|
||||
express,
|
||||
} = dependencies;
|
||||
|
||||
const setupBaseRoutes = (app, options) => {
|
||||
const {
|
||||
process,
|
||||
openchamberVersion,
|
||||
runtimeName,
|
||||
serverStartedAt,
|
||||
gracefulShutdown,
|
||||
getHealthSnapshot,
|
||||
uiPassword,
|
||||
tunnelAuthController,
|
||||
readSettingsFromDiskMigrated,
|
||||
normalizeTunnelSessionTtlMs,
|
||||
resolveZenModel,
|
||||
sayTTSCapability,
|
||||
ensurePushInitialized,
|
||||
getOrCreateVapidKeys,
|
||||
getUiSessionTokenFromRequest,
|
||||
writeSettingsToDisk,
|
||||
addOrUpdatePushSubscription,
|
||||
removePushSubscription,
|
||||
updateUiVisibility,
|
||||
isUiVisible,
|
||||
sessionRuntime,
|
||||
setPushInitialized,
|
||||
fs,
|
||||
os,
|
||||
path,
|
||||
server,
|
||||
__dirname,
|
||||
openchamberDataDir,
|
||||
modelsDevApiUrl,
|
||||
modelsMetadataCacheTtl,
|
||||
fetchFreeZenModels,
|
||||
getCachedZenModels,
|
||||
} = options;
|
||||
|
||||
registerServerStatusRoutes(app, {
|
||||
process,
|
||||
openchamberVersion,
|
||||
runtimeName,
|
||||
serverStartedAt,
|
||||
gracefulShutdown,
|
||||
getHealthSnapshot,
|
||||
});
|
||||
|
||||
registerCommonRequestMiddleware(app, { express });
|
||||
|
||||
const uiAuthController = createUiAuth({ password: uiPassword });
|
||||
if (uiAuthController.enabled) {
|
||||
console.log('UI password protection enabled for browser sessions');
|
||||
}
|
||||
|
||||
registerAuthAndAccessRoutes(app, {
|
||||
tunnelAuthController,
|
||||
uiAuthController,
|
||||
readSettingsFromDiskMigrated,
|
||||
normalizeTunnelSessionTtlMs,
|
||||
});
|
||||
|
||||
registerTtsRoutes(app, { resolveZenModel, sayTTSCapability });
|
||||
|
||||
registerNotificationRoutes(app, {
|
||||
uiAuthController,
|
||||
ensurePushInitialized,
|
||||
getOrCreateVapidKeys,
|
||||
getUiSessionTokenFromRequest,
|
||||
readSettingsFromDiskMigrated,
|
||||
writeSettingsToDisk,
|
||||
addOrUpdatePushSubscription,
|
||||
removePushSubscription,
|
||||
updateUiVisibility,
|
||||
isUiVisible,
|
||||
getSessionActivitySnapshot: sessionRuntime.getSessionActivitySnapshot,
|
||||
getSessionStateSnapshot: sessionRuntime.getSessionStateSnapshot,
|
||||
getSessionAttentionSnapshot: sessionRuntime.getSessionAttentionSnapshot,
|
||||
getSessionState: sessionRuntime.getSessionState,
|
||||
getSessionAttentionState: sessionRuntime.getSessionAttentionState,
|
||||
markSessionViewed: sessionRuntime.markSessionViewed,
|
||||
markSessionUnviewed: sessionRuntime.markSessionUnviewed,
|
||||
markUserMessageSent: sessionRuntime.markUserMessageSent,
|
||||
setPushInitialized,
|
||||
});
|
||||
|
||||
registerOpenChamberRoutes(app, {
|
||||
fs,
|
||||
os,
|
||||
path,
|
||||
process,
|
||||
server,
|
||||
__dirname,
|
||||
openchamberDataDir,
|
||||
modelsDevApiUrl,
|
||||
modelsMetadataCacheTtl,
|
||||
readSettingsFromDiskMigrated,
|
||||
fetchFreeZenModels,
|
||||
getCachedZenModels,
|
||||
});
|
||||
|
||||
return {
|
||||
uiAuthController,
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
setupBaseRoutes,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
export const runCliEntryIfMain = (dependencies) => {
|
||||
const {
|
||||
process,
|
||||
currentFilename,
|
||||
parseServeCliOptions,
|
||||
defaultPort,
|
||||
cloudflareProvider,
|
||||
managedLocalMode,
|
||||
setExitOnShutdown,
|
||||
startServer,
|
||||
} = dependencies;
|
||||
|
||||
const isCliExecution = process.argv[1] === currentFilename;
|
||||
if (!isCliExecution) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cliOptions = parseServeCliOptions({
|
||||
argv: process.argv.slice(2),
|
||||
env: process.env,
|
||||
defaultPort,
|
||||
cloudflareProvider,
|
||||
managedLocalMode,
|
||||
});
|
||||
|
||||
setExitOnShutdown(true);
|
||||
startServer({
|
||||
port: cliOptions.port,
|
||||
host: cliOptions.host,
|
||||
tryCfTunnel: cliOptions.tryCfTunnel,
|
||||
tunnelProvider: cliOptions.tunnelProvider,
|
||||
tunnelMode: cliOptions.tunnelMode,
|
||||
tunnelConfigPath: cliOptions.tunnelConfigPath,
|
||||
tunnelToken: cliOptions.tunnelToken,
|
||||
tunnelHostname: cliOptions.tunnelHostname,
|
||||
attachSignals: true,
|
||||
exitOnShutdown: true,
|
||||
uiPassword: cliOptions.uiPassword,
|
||||
}).catch((error) => {
|
||||
console.error('Failed to start server:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,128 @@
|
||||
export const parseServeCliOptions = ({
|
||||
argv = [],
|
||||
env = {},
|
||||
defaultPort,
|
||||
cloudflareProvider,
|
||||
managedLocalMode,
|
||||
}) => {
|
||||
const args = Array.isArray(argv) ? [...argv] : [];
|
||||
const envPassword =
|
||||
env.OPENCHAMBER_UI_PASSWORD ||
|
||||
env.OPENCODE_UI_PASSWORD ||
|
||||
null;
|
||||
const envCfTunnel = env.OPENCHAMBER_TRY_CF_TUNNEL === 'true';
|
||||
const envTunnelProvider = env.OPENCHAMBER_TUNNEL_PROVIDER || undefined;
|
||||
const envTunnelMode = env.OPENCHAMBER_TUNNEL_MODE || undefined;
|
||||
const envTunnelConfigRaw = env.OPENCHAMBER_TUNNEL_CONFIG;
|
||||
const envTunnelConfig = typeof envTunnelConfigRaw === 'string'
|
||||
? (envTunnelConfigRaw.trim().length > 0 ? envTunnelConfigRaw.trim() : null)
|
||||
: undefined;
|
||||
const envTunnelToken = env.OPENCHAMBER_TUNNEL_TOKEN || undefined;
|
||||
const envTunnelHostname = env.OPENCHAMBER_TUNNEL_HOSTNAME || undefined;
|
||||
|
||||
const options = {
|
||||
port: defaultPort,
|
||||
host: undefined,
|
||||
uiPassword: envPassword,
|
||||
tryCfTunnel: envCfTunnel,
|
||||
tunnelProvider: envTunnelProvider,
|
||||
tunnelMode: envTunnelMode,
|
||||
tunnelConfigPath: envTunnelConfig,
|
||||
tunnelToken: envTunnelToken,
|
||||
tunnelHostname: envTunnelHostname,
|
||||
};
|
||||
|
||||
const consumeValue = (currentIndex, inlineValue) => {
|
||||
if (typeof inlineValue === 'string') {
|
||||
return { value: inlineValue, nextIndex: currentIndex };
|
||||
}
|
||||
const nextArg = args[currentIndex + 1];
|
||||
if (typeof nextArg === 'string' && !nextArg.startsWith('--')) {
|
||||
return { value: nextArg, nextIndex: currentIndex + 1 };
|
||||
}
|
||||
return { value: undefined, nextIndex: currentIndex };
|
||||
};
|
||||
|
||||
for (let i = 0; i < args.length; i += 1) {
|
||||
const arg = args[i];
|
||||
if (!arg.startsWith('--')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const eqIndex = arg.indexOf('=');
|
||||
const optionName = eqIndex >= 0 ? arg.slice(2, eqIndex) : arg.slice(2);
|
||||
const inlineValue = eqIndex >= 0 ? arg.slice(eqIndex + 1) : undefined;
|
||||
|
||||
if (optionName === 'port' || optionName === 'p') {
|
||||
const { value, nextIndex } = consumeValue(i, inlineValue);
|
||||
i = nextIndex;
|
||||
const parsedPort = parseInt(value ?? '', 10);
|
||||
options.port = Number.isFinite(parsedPort) ? parsedPort : defaultPort;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (optionName === 'host') {
|
||||
const { value, nextIndex } = consumeValue(i, inlineValue);
|
||||
i = nextIndex;
|
||||
options.host = typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (optionName === 'ui-password') {
|
||||
const { value, nextIndex } = consumeValue(i, inlineValue);
|
||||
i = nextIndex;
|
||||
options.uiPassword = typeof value === 'string' ? value : '';
|
||||
continue;
|
||||
}
|
||||
|
||||
if (optionName === 'try-cf-tunnel') {
|
||||
options.tryCfTunnel = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (optionName === 'tunnel-provider') {
|
||||
const { value, nextIndex } = consumeValue(i, inlineValue);
|
||||
i = nextIndex;
|
||||
options.tunnelProvider = typeof value === 'string' ? value : options.tunnelProvider;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (optionName === 'tunnel-mode') {
|
||||
const { value, nextIndex } = consumeValue(i, inlineValue);
|
||||
i = nextIndex;
|
||||
options.tunnelMode = typeof value === 'string' ? value : options.tunnelMode;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (optionName === 'tunnel-config') {
|
||||
const { value, nextIndex } = consumeValue(i, inlineValue);
|
||||
i = nextIndex;
|
||||
options.tunnelConfigPath = typeof value === 'string' ? value : null;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (optionName === 'tunnel-token') {
|
||||
const { value, nextIndex } = consumeValue(i, inlineValue);
|
||||
i = nextIndex;
|
||||
options.tunnelToken = typeof value === 'string' ? value : options.tunnelToken;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (optionName === 'tunnel-hostname') {
|
||||
const { value, nextIndex } = consumeValue(i, inlineValue);
|
||||
i = nextIndex;
|
||||
options.tunnelHostname = typeof value === 'string' ? value : options.tunnelHostname;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (optionName === 'tunnel') {
|
||||
const { value, nextIndex } = consumeValue(i, inlineValue);
|
||||
i = nextIndex;
|
||||
options.tunnelProvider = cloudflareProvider;
|
||||
options.tunnelMode = managedLocalMode;
|
||||
options.tunnelConfigPath = typeof value === 'string' ? value : null;
|
||||
}
|
||||
}
|
||||
|
||||
return options;
|
||||
};
|
||||
@@ -0,0 +1,362 @@
|
||||
export const registerConfigEntityRoutes = (app, dependencies) => {
|
||||
const {
|
||||
resolveProjectDirectory,
|
||||
resolveOptionalProjectDirectory,
|
||||
refreshOpenCodeAfterConfigChange,
|
||||
clientReloadDelayMs,
|
||||
getAgentSources,
|
||||
getAgentConfig,
|
||||
createAgent,
|
||||
updateAgent,
|
||||
deleteAgent,
|
||||
getCommandSources,
|
||||
createCommand,
|
||||
updateCommand,
|
||||
deleteCommand,
|
||||
listMcpConfigs,
|
||||
getMcpConfig,
|
||||
createMcpConfig,
|
||||
updateMcpConfig,
|
||||
deleteMcpConfig,
|
||||
} = dependencies;
|
||||
|
||||
app.get('/api/config/agents/:name', async (req, res) => {
|
||||
try {
|
||||
const agentName = req.params.name;
|
||||
const { directory, error } = await resolveProjectDirectory(req);
|
||||
if (!directory) {
|
||||
return res.status(400).json({ error });
|
||||
}
|
||||
const sources = getAgentSources(agentName, directory);
|
||||
|
||||
const scope = sources.md.exists
|
||||
? sources.md.scope
|
||||
: (sources.json.exists ? sources.json.scope : null);
|
||||
|
||||
res.json({
|
||||
name: agentName,
|
||||
sources: sources,
|
||||
scope,
|
||||
isBuiltIn: !sources.md.exists && !sources.json.exists
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to get agent sources:', error);
|
||||
res.status(500).json({ error: 'Failed to get agent configuration metadata' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/config/agents/:name/config', async (req, res) => {
|
||||
try {
|
||||
const agentName = req.params.name;
|
||||
const { directory, error } = await resolveProjectDirectory(req);
|
||||
if (!directory) {
|
||||
return res.status(400).json({ error });
|
||||
}
|
||||
|
||||
const configInfo = getAgentConfig(agentName, directory);
|
||||
res.json(configInfo);
|
||||
} catch (error) {
|
||||
console.error('Failed to get agent config:', error);
|
||||
res.status(500).json({ error: 'Failed to get agent configuration' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/config/agents/:name', async (req, res) => {
|
||||
try {
|
||||
const agentName = req.params.name;
|
||||
const { scope, ...config } = req.body;
|
||||
const { directory, error } = await resolveProjectDirectory(req);
|
||||
if (!directory) {
|
||||
return res.status(400).json({ error });
|
||||
}
|
||||
|
||||
console.log('[Server] Creating agent:', agentName);
|
||||
console.log('[Server] Config received:', JSON.stringify(config, null, 2));
|
||||
console.log('[Server] Scope:', scope, 'Working directory:', directory);
|
||||
|
||||
createAgent(agentName, config, directory, scope);
|
||||
await refreshOpenCodeAfterConfigChange('agent creation', {
|
||||
agentName
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
requiresReload: true,
|
||||
message: `Agent ${agentName} created successfully. Reloading interface…`,
|
||||
reloadDelayMs: clientReloadDelayMs,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to create agent:', error);
|
||||
res.status(500).json({ error: error.message || 'Failed to create agent' });
|
||||
}
|
||||
});
|
||||
|
||||
app.patch('/api/config/agents/:name', async (req, res) => {
|
||||
try {
|
||||
const agentName = req.params.name;
|
||||
const updates = req.body;
|
||||
const { directory, error } = await resolveProjectDirectory(req);
|
||||
if (!directory) {
|
||||
return res.status(400).json({ error });
|
||||
}
|
||||
|
||||
console.log(`[Server] Updating agent: ${agentName}`);
|
||||
console.log('[Server] Updates:', JSON.stringify(updates, null, 2));
|
||||
console.log('[Server] Working directory:', directory);
|
||||
|
||||
updateAgent(agentName, updates, directory);
|
||||
await refreshOpenCodeAfterConfigChange('agent update');
|
||||
|
||||
console.log(`[Server] Agent ${agentName} updated successfully`);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
requiresReload: true,
|
||||
message: `Agent ${agentName} updated successfully. Reloading interface…`,
|
||||
reloadDelayMs: clientReloadDelayMs,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Server] Failed to update agent:', error);
|
||||
console.error('[Server] Error stack:', error.stack);
|
||||
res.status(500).json({ error: error.message || 'Failed to update agent' });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/config/agents/:name', async (req, res) => {
|
||||
try {
|
||||
const agentName = req.params.name;
|
||||
const { directory, error } = await resolveProjectDirectory(req);
|
||||
if (!directory) {
|
||||
return res.status(400).json({ error });
|
||||
}
|
||||
|
||||
deleteAgent(agentName, directory);
|
||||
await refreshOpenCodeAfterConfigChange('agent deletion');
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
requiresReload: true,
|
||||
message: `Agent ${agentName} deleted successfully. Reloading interface…`,
|
||||
reloadDelayMs: clientReloadDelayMs,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to delete agent:', error);
|
||||
res.status(500).json({ error: error.message || 'Failed to delete agent' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/config/mcp', async (req, res) => {
|
||||
try {
|
||||
const { directory, error } = await resolveOptionalProjectDirectory(req);
|
||||
if (error) {
|
||||
return res.status(400).json({ error });
|
||||
}
|
||||
const configs = listMcpConfigs(directory);
|
||||
res.json(configs);
|
||||
} catch (error) {
|
||||
console.error('[API:GET /api/config/mcp] Failed:', error);
|
||||
res.status(500).json({ error: error.message || 'Failed to list MCP configs' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/config/mcp/:name', async (req, res) => {
|
||||
try {
|
||||
const name = req.params.name;
|
||||
const { directory, error } = await resolveOptionalProjectDirectory(req);
|
||||
if (error) {
|
||||
return res.status(400).json({ error });
|
||||
}
|
||||
const config = getMcpConfig(name, directory);
|
||||
if (!config) {
|
||||
return res.status(404).json({ error: `MCP server "${name}" not found` });
|
||||
}
|
||||
res.json(config);
|
||||
} catch (error) {
|
||||
console.error('[API:GET /api/config/mcp/:name] Failed:', error);
|
||||
res.status(500).json({ error: error.message || 'Failed to get MCP config' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/config/mcp/:name', async (req, res) => {
|
||||
try {
|
||||
const name = req.params.name;
|
||||
const { scope, ...config } = req.body || {};
|
||||
const { directory, error } = await resolveOptionalProjectDirectory(req);
|
||||
if (error) {
|
||||
return res.status(400).json({ error });
|
||||
}
|
||||
console.log(`[API:POST /api/config/mcp] Creating MCP server: ${name}`);
|
||||
|
||||
createMcpConfig(name, config, directory, scope);
|
||||
await refreshOpenCodeAfterConfigChange('mcp creation', { mcpName: name });
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
requiresReload: true,
|
||||
message: `MCP server "${name}" created. Reloading interface…`,
|
||||
reloadDelayMs: clientReloadDelayMs,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[API:POST /api/config/mcp/:name] Failed:', error);
|
||||
res.status(500).json({ error: error.message || 'Failed to create MCP server' });
|
||||
}
|
||||
});
|
||||
|
||||
app.patch('/api/config/mcp/:name', async (req, res) => {
|
||||
try {
|
||||
const name = req.params.name;
|
||||
const updates = req.body;
|
||||
const { directory, error } = await resolveOptionalProjectDirectory(req);
|
||||
if (error) {
|
||||
return res.status(400).json({ error });
|
||||
}
|
||||
console.log(`[API:PATCH /api/config/mcp] Updating MCP server: ${name}`);
|
||||
|
||||
updateMcpConfig(name, updates, directory);
|
||||
await refreshOpenCodeAfterConfigChange('mcp update');
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
requiresReload: true,
|
||||
message: `MCP server "${name}" updated. Reloading interface…`,
|
||||
reloadDelayMs: clientReloadDelayMs,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[API:PATCH /api/config/mcp/:name] Failed:', error);
|
||||
res.status(500).json({ error: error.message || 'Failed to update MCP server' });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/config/mcp/:name', async (req, res) => {
|
||||
try {
|
||||
const name = req.params.name;
|
||||
const { directory, error } = await resolveOptionalProjectDirectory(req);
|
||||
if (error) {
|
||||
return res.status(400).json({ error });
|
||||
}
|
||||
console.log(`[API:DELETE /api/config/mcp] Deleting MCP server: ${name}`);
|
||||
|
||||
deleteMcpConfig(name, directory);
|
||||
await refreshOpenCodeAfterConfigChange('mcp deletion');
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
requiresReload: true,
|
||||
message: `MCP server "${name}" deleted. Reloading interface…`,
|
||||
reloadDelayMs: clientReloadDelayMs,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[API:DELETE /api/config/mcp/:name] Failed:', error);
|
||||
res.status(500).json({ error: error.message || 'Failed to delete MCP server' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/config/commands/:name', async (req, res) => {
|
||||
try {
|
||||
const commandName = req.params.name;
|
||||
const { directory, error } = await resolveProjectDirectory(req);
|
||||
if (!directory) {
|
||||
return res.status(400).json({ error });
|
||||
}
|
||||
const sources = getCommandSources(commandName, directory);
|
||||
|
||||
const scope = sources.md.exists
|
||||
? sources.md.scope
|
||||
: (sources.json.exists ? sources.json.scope : null);
|
||||
|
||||
res.json({
|
||||
name: commandName,
|
||||
sources: sources,
|
||||
scope,
|
||||
isBuiltIn: !sources.md.exists && !sources.json.exists
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to get command sources:', error);
|
||||
res.status(500).json({ error: 'Failed to get command configuration metadata' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/config/commands/:name', async (req, res) => {
|
||||
try {
|
||||
const commandName = req.params.name;
|
||||
const { scope, ...config } = req.body;
|
||||
const { directory, error } = await resolveProjectDirectory(req);
|
||||
if (!directory) {
|
||||
return res.status(400).json({ error });
|
||||
}
|
||||
|
||||
console.log('[Server] Creating command:', commandName);
|
||||
console.log('[Server] Config received:', JSON.stringify(config, null, 2));
|
||||
console.log('[Server] Scope:', scope, 'Working directory:', directory);
|
||||
|
||||
createCommand(commandName, config, directory, scope);
|
||||
await refreshOpenCodeAfterConfigChange('command creation', {
|
||||
commandName
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
requiresReload: true,
|
||||
message: `Command ${commandName} created successfully. Reloading interface…`,
|
||||
reloadDelayMs: clientReloadDelayMs,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to create command:', error);
|
||||
res.status(500).json({ error: error.message || 'Failed to create command' });
|
||||
}
|
||||
});
|
||||
|
||||
app.patch('/api/config/commands/:name', async (req, res) => {
|
||||
try {
|
||||
const commandName = req.params.name;
|
||||
const updates = req.body;
|
||||
const { directory, error } = await resolveProjectDirectory(req);
|
||||
if (!directory) {
|
||||
return res.status(400).json({ error });
|
||||
}
|
||||
|
||||
console.log(`[Server] Updating command: ${commandName}`);
|
||||
console.log('[Server] Updates:', JSON.stringify(updates, null, 2));
|
||||
console.log('[Server] Working directory:', directory);
|
||||
|
||||
updateCommand(commandName, updates, directory);
|
||||
await refreshOpenCodeAfterConfigChange('command update');
|
||||
|
||||
console.log(`[Server] Command ${commandName} updated successfully`);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
requiresReload: true,
|
||||
message: `Command ${commandName} updated successfully. Reloading interface…`,
|
||||
reloadDelayMs: clientReloadDelayMs,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Server] Failed to update command:', error);
|
||||
console.error('[Server] Error stack:', error.stack);
|
||||
res.status(500).json({ error: error.message || 'Failed to update command' });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/config/commands/:name', async (req, res) => {
|
||||
try {
|
||||
const commandName = req.params.name;
|
||||
const { directory, error } = await resolveProjectDirectory(req);
|
||||
if (!directory) {
|
||||
return res.status(400).json({ error });
|
||||
}
|
||||
|
||||
deleteCommand(commandName, directory);
|
||||
await refreshOpenCodeAfterConfigChange('command deletion');
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
requiresReload: true,
|
||||
message: `Command ${commandName} deleted successfully. Reloading interface…`,
|
||||
reloadDelayMs: clientReloadDelayMs,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to delete command:', error);
|
||||
res.status(500).json({ error: error.message || 'Failed to delete command' });
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,186 @@
|
||||
export const registerServerStatusRoutes = (app, dependencies) => {
|
||||
const {
|
||||
process,
|
||||
openchamberVersion,
|
||||
runtimeName,
|
||||
serverStartedAt,
|
||||
gracefulShutdown,
|
||||
getHealthSnapshot,
|
||||
} = dependencies;
|
||||
|
||||
app.get('/health', (_req, res) => {
|
||||
res.json({
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
...getHealthSnapshot(),
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/api/system/shutdown', (_req, res) => {
|
||||
res.json({ ok: true });
|
||||
gracefulShutdown({ exitProcess: false }).catch((error) => {
|
||||
console.error('Shutdown request failed:', error?.message || error);
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/api/system/info', (_req, res) => {
|
||||
res.json({
|
||||
openchamberVersion,
|
||||
runtime: runtimeName,
|
||||
pid: process.pid,
|
||||
startedAt: serverStartedAt,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
export const registerAuthAndAccessRoutes = (app, dependencies) => {
|
||||
const {
|
||||
tunnelAuthController,
|
||||
uiAuthController,
|
||||
readSettingsFromDiskMigrated,
|
||||
normalizeTunnelSessionTtlMs,
|
||||
} = dependencies;
|
||||
|
||||
app.get('/auth/session', async (req, res) => {
|
||||
const requestScope = tunnelAuthController.classifyRequestScope(req);
|
||||
if (requestScope === 'tunnel' || requestScope === 'unknown-public') {
|
||||
const tunnelSession = tunnelAuthController.getTunnelSessionFromRequest(req);
|
||||
if (tunnelSession) {
|
||||
return res.json({ authenticated: true, scope: 'tunnel' });
|
||||
}
|
||||
tunnelAuthController.clearTunnelSessionCookie(req, res);
|
||||
return res.status(401).json({ authenticated: false, locked: true, tunnelLocked: true });
|
||||
}
|
||||
|
||||
try {
|
||||
await uiAuthController.handleSessionStatus(req, res);
|
||||
} catch {
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/auth/session', (req, res) => {
|
||||
const requestScope = tunnelAuthController.classifyRequestScope(req);
|
||||
if (requestScope === 'tunnel' || requestScope === 'unknown-public') {
|
||||
return res.status(403).json({ error: 'Password login is disabled for tunnel scope', tunnelLocked: true });
|
||||
}
|
||||
return uiAuthController.handleSessionCreate(req, res);
|
||||
});
|
||||
|
||||
app.get('/connect', async (req, res) => {
|
||||
try {
|
||||
const token = typeof req.query?.t === 'string' ? req.query.t : '';
|
||||
const settings = await readSettingsFromDiskMigrated();
|
||||
const tunnelSessionTtlMs = normalizeTunnelSessionTtlMs(settings?.tunnelSessionTtlMs);
|
||||
|
||||
const exchange = tunnelAuthController.exchangeBootstrapToken({
|
||||
req,
|
||||
res,
|
||||
token,
|
||||
sessionTtlMs: tunnelSessionTtlMs,
|
||||
});
|
||||
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
|
||||
if (!exchange.ok) {
|
||||
if (exchange.reason === 'rate-limited') {
|
||||
res.setHeader('Retry-After', String(exchange.retryAfter || 60));
|
||||
return res.status(429).type('text/plain').send('Too many attempts. Please try again later.');
|
||||
}
|
||||
return res.status(401).type('text/plain').send('Connection link is invalid or expired.');
|
||||
}
|
||||
|
||||
return res.redirect(302, '/');
|
||||
} catch {
|
||||
return res.status(500).type('text/plain').send('Failed to process connect request.');
|
||||
}
|
||||
});
|
||||
|
||||
app.use('/api', async (req, res, next) => {
|
||||
try {
|
||||
const requestScope = tunnelAuthController.classifyRequestScope(req);
|
||||
if (requestScope === 'tunnel' || requestScope === 'unknown-public') {
|
||||
return tunnelAuthController.requireTunnelSession(req, res, next);
|
||||
}
|
||||
await uiAuthController.requireAuth(req, res, next);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const registerSettingsUtilityRoutes = (app, dependencies) => {
|
||||
const {
|
||||
readCustomThemesFromDisk,
|
||||
refreshOpenCodeAfterConfigChange,
|
||||
clientReloadDelayMs,
|
||||
} = dependencies;
|
||||
|
||||
app.get('/api/config/themes', async (_req, res) => {
|
||||
try {
|
||||
const customThemes = await readCustomThemesFromDisk();
|
||||
res.json({ themes: customThemes });
|
||||
} catch (error) {
|
||||
console.error('Failed to load custom themes:', error);
|
||||
res.status(500).json({ error: error instanceof Error ? error.message : 'Failed to load custom themes' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/config/reload', async (_req, res) => {
|
||||
try {
|
||||
console.log('[Server] Manual configuration reload requested');
|
||||
|
||||
await refreshOpenCodeAfterConfigChange('manual configuration reload');
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
requiresReload: true,
|
||||
message: 'Configuration reloaded successfully. Refreshing interface…',
|
||||
reloadDelayMs: clientReloadDelayMs,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Server] Failed to reload configuration:', error);
|
||||
res.status(500).json({
|
||||
error: error.message || 'Failed to reload configuration',
|
||||
success: false,
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const registerCommonRequestMiddleware = (app, dependencies) => {
|
||||
const { express } = dependencies;
|
||||
|
||||
app.use((req, res, next) => {
|
||||
if (
|
||||
req.path.startsWith('/api/config/agents') ||
|
||||
req.path.startsWith('/api/config/commands') ||
|
||||
req.path.startsWith('/api/config/mcp') ||
|
||||
req.path.startsWith('/api/config/settings') ||
|
||||
req.path.startsWith('/api/config/skills') ||
|
||||
req.path.startsWith('/api/projects') ||
|
||||
req.path.startsWith('/api/fs') ||
|
||||
req.path.startsWith('/api/git') ||
|
||||
req.path.startsWith('/api/prompts') ||
|
||||
req.path.startsWith('/api/terminal') ||
|
||||
req.path.startsWith('/api/opencode') ||
|
||||
req.path.startsWith('/api/push') ||
|
||||
req.path.startsWith('/api/voice') ||
|
||||
req.path.startsWith('/api/tts') ||
|
||||
req.path.startsWith('/api/openchamber/tunnel')
|
||||
) {
|
||||
express.json({ limit: '50mb' })(req, res, next);
|
||||
} else if (req.path.startsWith('/api')) {
|
||||
next();
|
||||
} else {
|
||||
express.json({ limit: '50mb' })(req, res, next);
|
||||
}
|
||||
});
|
||||
|
||||
app.use(express.urlencoded({ extended: true, limit: '50mb' }));
|
||||
|
||||
app.use((req, _res, next) => {
|
||||
console.log(`${new Date().toISOString()} - ${req.method} ${req.path}`);
|
||||
next();
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,72 @@
|
||||
export const resolveOpenCodeEnvConfig = (options = {}) => {
|
||||
const env = options.env && typeof options.env === 'object' ? options.env : {};
|
||||
const logger = options.logger ?? console;
|
||||
|
||||
const configuredOpenCodePort = (() => {
|
||||
const raw =
|
||||
env.OPENCODE_PORT ||
|
||||
env.OPENCHAMBER_OPENCODE_PORT ||
|
||||
env.OPENCHAMBER_INTERNAL_PORT;
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
const parsed = parseInt(raw, 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
|
||||
})();
|
||||
|
||||
const configuredOpenCodeHost = (() => {
|
||||
const raw = typeof env.OPENCODE_HOST === 'string' ? env.OPENCODE_HOST.trim() : '';
|
||||
if (!raw) return null;
|
||||
|
||||
const warnInvalidHost = (reason) => {
|
||||
logger.warn(`[config] Ignoring OPENCODE_HOST=${JSON.stringify(raw)}: ${reason}`);
|
||||
};
|
||||
|
||||
let url;
|
||||
try {
|
||||
url = new URL(raw);
|
||||
} catch {
|
||||
warnInvalidHost('not a valid URL');
|
||||
return null;
|
||||
}
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
||||
warnInvalidHost(`must use http or https scheme (got ${JSON.stringify(url.protocol)})`);
|
||||
return null;
|
||||
}
|
||||
const port = parseInt(url.port, 10);
|
||||
if (!Number.isFinite(port) || port <= 0) {
|
||||
warnInvalidHost('must include an explicit port (example: http://hostname:4096)');
|
||||
return null;
|
||||
}
|
||||
if (url.pathname !== '/' || url.search || url.hash) {
|
||||
warnInvalidHost('must not include path, query, or hash');
|
||||
return null;
|
||||
}
|
||||
return { origin: url.origin, port };
|
||||
})();
|
||||
|
||||
// OPENCODE_HOST takes precedence over OPENCODE_PORT when both are set
|
||||
const effectivePort = configuredOpenCodeHost?.port ?? configuredOpenCodePort;
|
||||
|
||||
const configuredOpenCodeHostname = (() => {
|
||||
const raw = env.OPENCHAMBER_OPENCODE_HOSTNAME;
|
||||
if (typeof raw !== 'string') {
|
||||
return '127.0.0.1';
|
||||
}
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
logger.warn(
|
||||
`[config] Ignoring OPENCHAMBER_OPENCODE_HOSTNAME=${JSON.stringify(raw)}: empty after trimming`,
|
||||
);
|
||||
return '127.0.0.1';
|
||||
}
|
||||
return trimmed;
|
||||
})();
|
||||
|
||||
return {
|
||||
configuredOpenCodePort,
|
||||
configuredOpenCodeHost,
|
||||
effectivePort,
|
||||
configuredOpenCodeHostname,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,908 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
export const createOpenCodeEnvRuntime = (deps) => {
|
||||
const {
|
||||
state,
|
||||
normalizeDirectoryPath,
|
||||
readSettingsFromDiskMigrated,
|
||||
ENV_CONFIGURED_OPENCODE_WSL_DISTRO,
|
||||
} = deps;
|
||||
|
||||
const parseNullSeparatedEnvSnapshot = (raw) => {
|
||||
if (typeof raw !== 'string' || raw.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const result = {};
|
||||
const entries = raw.split('\0');
|
||||
for (const entry of entries) {
|
||||
if (!entry) {
|
||||
continue;
|
||||
}
|
||||
const idx = entry.indexOf('=');
|
||||
if (idx <= 0) {
|
||||
continue;
|
||||
}
|
||||
const key = entry.slice(0, idx);
|
||||
const value = entry.slice(idx + 1);
|
||||
result[key] = value;
|
||||
}
|
||||
|
||||
return Object.keys(result).length > 0 ? result : null;
|
||||
};
|
||||
|
||||
const isExecutable = (filePath) => {
|
||||
try {
|
||||
const stat = fs.statSync(filePath);
|
||||
if (!stat.isFile()) return false;
|
||||
if (process.platform === 'win32') {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
if (!ext) return true;
|
||||
return ['.exe', '.cmd', '.bat', '.com'].includes(ext);
|
||||
}
|
||||
fs.accessSync(filePath, fs.constants.X_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const searchPathFor = (binaryName) => {
|
||||
const current = process.env.PATH || '';
|
||||
const parts = current.split(path.delimiter).filter(Boolean);
|
||||
for (const dir of parts) {
|
||||
const candidate = path.join(dir, binaryName);
|
||||
if (isExecutable(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const prependToPath = (dir) => {
|
||||
const trimmed = typeof dir === 'string' ? dir.trim() : '';
|
||||
if (!trimmed) return;
|
||||
const current = process.env.PATH || '';
|
||||
const parts = current.split(path.delimiter).filter(Boolean);
|
||||
if (parts.includes(trimmed)) return;
|
||||
process.env.PATH = [trimmed, ...parts].join(path.delimiter);
|
||||
};
|
||||
|
||||
const getWindowsShellEnvSnapshot = () => {
|
||||
const parseResult = (stdout) => parseNullSeparatedEnvSnapshot(typeof stdout === 'string' ? stdout : '');
|
||||
|
||||
const psScript =
|
||||
"Get-ChildItem Env: | ForEach-Object { [Console]::Out.Write($_.Name); [Console]::Out.Write('='); [Console]::Out.Write($_.Value); [Console]::Out.Write([char]0) }";
|
||||
|
||||
const powershellCandidates = [
|
||||
'pwsh.exe',
|
||||
'powershell.exe',
|
||||
path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'),
|
||||
];
|
||||
|
||||
for (const shellPath of powershellCandidates) {
|
||||
try {
|
||||
const result = spawnSync(shellPath, ['-NoLogo', '-Command', psScript], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
windowsHide: true,
|
||||
});
|
||||
if (result.status !== 0) {
|
||||
continue;
|
||||
}
|
||||
const parsed = parseResult(result.stdout);
|
||||
if (parsed) {
|
||||
return parsed;
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
const comspec = process.env.ComSpec || 'cmd.exe';
|
||||
try {
|
||||
const result = spawnSync(comspec, ['/d', '/s', '/c', 'set'], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
windowsHide: true,
|
||||
});
|
||||
if (result.status === 0 && typeof result.stdout === 'string' && result.stdout.length > 0) {
|
||||
return parseNullSeparatedEnvSnapshot(result.stdout.replace(/\r?\n/g, '\0'));
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const getLoginShellEnvSnapshot = () => {
|
||||
if (state.cachedLoginShellEnvSnapshot !== undefined) {
|
||||
return state.cachedLoginShellEnvSnapshot;
|
||||
}
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
const windowsSnapshot = getWindowsShellEnvSnapshot();
|
||||
state.cachedLoginShellEnvSnapshot = windowsSnapshot;
|
||||
return windowsSnapshot;
|
||||
}
|
||||
|
||||
const shellCandidates = [process.env.SHELL, '/bin/zsh', '/bin/bash', '/bin/sh'].filter(Boolean);
|
||||
|
||||
for (const shellPath of shellCandidates) {
|
||||
if (!isExecutable(shellPath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = spawnSync(shellPath, ['-lic', 'env -0'], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
windowsHide: true,
|
||||
});
|
||||
|
||||
if (result.status !== 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const parsed = parseNullSeparatedEnvSnapshot(result.stdout || '');
|
||||
if (parsed) {
|
||||
state.cachedLoginShellEnvSnapshot = parsed;
|
||||
return parsed;
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
state.cachedLoginShellEnvSnapshot = null;
|
||||
return null;
|
||||
};
|
||||
|
||||
const mergePathValues = (preferred, fallback) => {
|
||||
const merged = new Set();
|
||||
|
||||
const addSegments = (value) => {
|
||||
if (typeof value !== 'string' || !value) {
|
||||
return;
|
||||
}
|
||||
for (const segment of value.split(path.delimiter)) {
|
||||
if (segment) {
|
||||
merged.add(segment);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
addSegments(preferred);
|
||||
addSegments(fallback);
|
||||
|
||||
return Array.from(merged).join(path.delimiter);
|
||||
};
|
||||
|
||||
const applyLoginShellEnvSnapshot = () => {
|
||||
const snapshot = getLoginShellEnvSnapshot();
|
||||
if (!snapshot) {
|
||||
return;
|
||||
}
|
||||
|
||||
const skipKeys = new Set(['PWD', 'OLDPWD', 'SHLVL', '_']);
|
||||
for (const [key, value] of Object.entries(snapshot)) {
|
||||
if (skipKeys.has(key)) {
|
||||
continue;
|
||||
}
|
||||
const existing = process.env[key];
|
||||
if (typeof existing === 'string' && existing.length > 0) {
|
||||
continue;
|
||||
}
|
||||
process.env[key] = value;
|
||||
}
|
||||
|
||||
process.env.PATH = mergePathValues(snapshot.PATH || '', process.env.PATH || '');
|
||||
};
|
||||
|
||||
const isWslExecutableValue = (value) => {
|
||||
if (typeof value !== 'string') return false;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return false;
|
||||
return /(^|[\\/])wsl(\.exe)?$/i.test(trimmed);
|
||||
};
|
||||
|
||||
const clearWslOpencodeResolution = () => {
|
||||
state.useWslForOpencode = false;
|
||||
state.resolvedWslBinary = null;
|
||||
state.resolvedWslOpencodePath = null;
|
||||
state.resolvedWslDistro = null;
|
||||
};
|
||||
|
||||
const resolveWslExecutablePath = () => {
|
||||
if (process.platform !== 'win32') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const explicit = [process.env.WSL_BINARY, process.env.OPENCHAMBER_WSL_BINARY]
|
||||
.map((v) => (typeof v === 'string' ? v.trim() : ''))
|
||||
.filter(Boolean);
|
||||
|
||||
for (const candidate of explicit) {
|
||||
if (isExecutable(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const result = spawnSync('where', ['wsl'], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
});
|
||||
if (result.status === 0) {
|
||||
const lines = (result.stdout || '')
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
const found = lines.find((line) => isExecutable(line));
|
||||
if (found) {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
|
||||
const systemRoot = process.env.SystemRoot || 'C:\\Windows';
|
||||
const fallback = path.join(systemRoot, 'System32', 'wsl.exe');
|
||||
if (isExecutable(fallback)) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const buildWslExecArgs = (execArgs, distroOverride = null) => {
|
||||
const distro = typeof distroOverride === 'string' && distroOverride.trim().length > 0
|
||||
? distroOverride.trim()
|
||||
: ENV_CONFIGURED_OPENCODE_WSL_DISTRO;
|
||||
|
||||
const prefix = distro ? ['-d', distro] : [];
|
||||
return [...prefix, '--exec', ...execArgs];
|
||||
};
|
||||
|
||||
const probeWslForOpencode = () => {
|
||||
if (process.platform !== 'win32') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const wslBinary = resolveWslExecutablePath();
|
||||
if (!wslBinary) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = spawnSync(
|
||||
wslBinary,
|
||||
buildWslExecArgs(['sh', '-lc', 'command -v opencode']),
|
||||
{
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
timeout: 6000,
|
||||
windowsHide: true,
|
||||
}
|
||||
);
|
||||
|
||||
if (result.status !== 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lines = (result.stdout || '')
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
const found = lines[0] || '';
|
||||
if (!found) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
wslBinary,
|
||||
opencodePath: found,
|
||||
distro: ENV_CONFIGURED_OPENCODE_WSL_DISTRO,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const applyWslOpencodeResolution = ({ wslBinary, opencodePath, source = 'wsl', distro = null } = {}) => {
|
||||
const resolvedWsl = wslBinary || resolveWslExecutablePath();
|
||||
if (!resolvedWsl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
state.useWslForOpencode = true;
|
||||
state.resolvedWslBinary = resolvedWsl;
|
||||
state.resolvedWslOpencodePath = typeof opencodePath === 'string' && opencodePath.trim().length > 0
|
||||
? opencodePath.trim()
|
||||
: 'opencode';
|
||||
state.resolvedWslDistro = typeof distro === 'string' && distro.trim().length > 0 ? distro.trim() : ENV_CONFIGURED_OPENCODE_WSL_DISTRO;
|
||||
state.resolvedOpencodeBinary = `wsl:${state.resolvedWslOpencodePath}`;
|
||||
state.resolvedOpencodeBinarySource = source;
|
||||
|
||||
delete process.env.OPENCODE_BINARY;
|
||||
return state.resolvedOpencodeBinary;
|
||||
};
|
||||
|
||||
const resolveOpencodeCliPath = () => {
|
||||
const explicit = [
|
||||
process.env.OPENCODE_BINARY,
|
||||
process.env.OPENCODE_PATH,
|
||||
process.env.OPENCHAMBER_OPENCODE_PATH,
|
||||
process.env.OPENCHAMBER_OPENCODE_BIN,
|
||||
]
|
||||
.map((v) => (typeof v === 'string' ? v.trim() : ''))
|
||||
.filter(Boolean);
|
||||
|
||||
for (const candidate of explicit) {
|
||||
if (isExecutable(candidate)) {
|
||||
clearWslOpencodeResolution();
|
||||
state.resolvedOpencodeBinarySource = 'env';
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
const resolvedFromPath = searchPathFor('opencode');
|
||||
if (resolvedFromPath) {
|
||||
clearWslOpencodeResolution();
|
||||
state.resolvedOpencodeBinarySource = 'path';
|
||||
return resolvedFromPath;
|
||||
}
|
||||
|
||||
const home = os.homedir();
|
||||
const unixFallbacks = [
|
||||
path.join(home, '.opencode', 'bin', 'opencode'),
|
||||
path.join(home, '.bun', 'bin', 'opencode'),
|
||||
path.join(home, '.local', 'bin', 'opencode'),
|
||||
path.join(home, 'bin', 'opencode'),
|
||||
'/opt/homebrew/bin/opencode',
|
||||
'/usr/local/bin/opencode',
|
||||
'/usr/bin/opencode',
|
||||
'/bin/opencode',
|
||||
];
|
||||
|
||||
const winFallbacks = (() => {
|
||||
const userProfile = process.env.USERPROFILE || home;
|
||||
const appData = process.env.APPDATA || '';
|
||||
const localAppData = process.env.LOCALAPPDATA || '';
|
||||
const programData = process.env.ProgramData || 'C:\\ProgramData';
|
||||
|
||||
return [
|
||||
path.join(userProfile, '.opencode', 'bin', 'opencode.exe'),
|
||||
path.join(userProfile, '.opencode', 'bin', 'opencode.cmd'),
|
||||
path.join(appData, 'npm', 'opencode.cmd'),
|
||||
path.join(userProfile, 'scoop', 'shims', 'opencode.cmd'),
|
||||
path.join(programData, 'chocolatey', 'bin', 'opencode.exe'),
|
||||
path.join(programData, 'chocolatey', 'bin', 'opencode.cmd'),
|
||||
path.join(userProfile, '.bun', 'bin', 'opencode.exe'),
|
||||
path.join(userProfile, '.bun', 'bin', 'opencode.cmd'),
|
||||
localAppData ? path.join(localAppData, 'Programs', 'opencode', 'opencode.exe') : '',
|
||||
].filter(Boolean);
|
||||
})();
|
||||
|
||||
const fallbacks = process.platform === 'win32' ? winFallbacks : unixFallbacks;
|
||||
for (const candidate of fallbacks) {
|
||||
if (isExecutable(candidate)) {
|
||||
clearWslOpencodeResolution();
|
||||
state.resolvedOpencodeBinarySource = 'fallback';
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
try {
|
||||
const result = spawnSync('where', ['opencode'], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
});
|
||||
if (result.status === 0) {
|
||||
const lines = (result.stdout || '')
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
const found = lines.find((line) => isExecutable(line));
|
||||
if (found) {
|
||||
clearWslOpencodeResolution();
|
||||
state.resolvedOpencodeBinarySource = 'where';
|
||||
return found;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
const wsl = probeWslForOpencode();
|
||||
if (wsl) {
|
||||
return applyWslOpencodeResolution({
|
||||
wslBinary: wsl.wslBinary,
|
||||
opencodePath: wsl.opencodePath,
|
||||
source: 'wsl',
|
||||
distro: wsl.distro,
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const shells = [process.env.SHELL, '/bin/zsh', '/bin/bash', '/bin/sh'].filter(Boolean);
|
||||
for (const shell of shells) {
|
||||
if (!isExecutable(shell)) continue;
|
||||
try {
|
||||
const result = spawnSync(shell, ['-lic', 'command -v opencode'], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
});
|
||||
if (result.status === 0) {
|
||||
const found = (result.stdout || '').trim().split(/\s+/).pop() || '';
|
||||
if (found && isExecutable(found)) {
|
||||
clearWslOpencodeResolution();
|
||||
state.resolvedOpencodeBinarySource = 'shell';
|
||||
return found;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const resolveNodeCliPath = () => {
|
||||
const explicit = [process.env.NODE_BINARY, process.env.OPENCHAMBER_NODE_BINARY]
|
||||
.map((v) => (typeof v === 'string' ? v.trim() : ''))
|
||||
.filter(Boolean);
|
||||
|
||||
for (const candidate of explicit) {
|
||||
if (isExecutable(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
const resolvedFromPath = searchPathFor('node');
|
||||
if (resolvedFromPath) {
|
||||
return resolvedFromPath;
|
||||
}
|
||||
|
||||
const unixFallbacks = ['/opt/homebrew/bin/node', '/usr/local/bin/node', '/usr/bin/node', '/bin/node'];
|
||||
for (const candidate of unixFallbacks) {
|
||||
if (isExecutable(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
try {
|
||||
const result = spawnSync('where', ['node'], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
});
|
||||
if (result.status === 0) {
|
||||
const lines = (result.stdout || '')
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
const found = lines.find((line) => isExecutable(line));
|
||||
if (found) return found;
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const shells = [process.env.SHELL, '/bin/zsh', '/bin/bash', '/bin/sh'].filter(Boolean);
|
||||
for (const shell of shells) {
|
||||
if (!isExecutable(shell)) continue;
|
||||
try {
|
||||
const result = spawnSync(shell, ['-lic', 'command -v node'], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
});
|
||||
if (result.status === 0) {
|
||||
const found = (result.stdout || '').trim().split(/\s+/).pop() || '';
|
||||
if (found && isExecutable(found)) {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const resolveBunCliPath = () => {
|
||||
const explicit = [process.env.BUN_BINARY, process.env.OPENCHAMBER_BUN_BINARY]
|
||||
.map((v) => (typeof v === 'string' ? v.trim() : ''))
|
||||
.filter(Boolean);
|
||||
|
||||
for (const candidate of explicit) {
|
||||
if (isExecutable(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
const resolvedFromPath = searchPathFor('bun');
|
||||
if (resolvedFromPath) {
|
||||
return resolvedFromPath;
|
||||
}
|
||||
|
||||
const home = os.homedir();
|
||||
const unixFallbacks = [
|
||||
path.join(home, '.bun', 'bin', 'bun'),
|
||||
'/opt/homebrew/bin/bun',
|
||||
'/usr/local/bin/bun',
|
||||
'/usr/bin/bun',
|
||||
'/bin/bun',
|
||||
];
|
||||
for (const candidate of unixFallbacks) {
|
||||
if (isExecutable(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
const userProfile = process.env.USERPROFILE || home;
|
||||
const winFallbacks = [
|
||||
path.join(userProfile, '.bun', 'bin', 'bun.exe'),
|
||||
path.join(userProfile, '.bun', 'bin', 'bun.cmd'),
|
||||
];
|
||||
for (const candidate of winFallbacks) {
|
||||
if (isExecutable(candidate)) return candidate;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = spawnSync('where', ['bun'], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
});
|
||||
if (result.status === 0) {
|
||||
const lines = (result.stdout || '')
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
const found = lines.find((line) => isExecutable(line));
|
||||
if (found) return found;
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const shells = [process.env.SHELL, '/bin/zsh', '/bin/bash', '/bin/sh'].filter(Boolean);
|
||||
for (const shell of shells) {
|
||||
if (!isExecutable(shell)) continue;
|
||||
try {
|
||||
const result = spawnSync(shell, ['-lic', 'command -v bun'], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
});
|
||||
if (result.status === 0) {
|
||||
const found = (result.stdout || '').trim().split(/\s+/).pop() || '';
|
||||
if (found && isExecutable(found)) {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const ensureBunCliEnv = () => {
|
||||
if (state.resolvedBunBinary) {
|
||||
return state.resolvedBunBinary;
|
||||
}
|
||||
|
||||
const resolved = resolveBunCliPath();
|
||||
if (resolved) {
|
||||
prependToPath(path.dirname(resolved));
|
||||
state.resolvedBunBinary = resolved;
|
||||
return resolved;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const ensureNodeCliEnv = () => {
|
||||
if (state.resolvedNodeBinary) {
|
||||
return state.resolvedNodeBinary;
|
||||
}
|
||||
|
||||
const resolved = resolveNodeCliPath();
|
||||
if (resolved) {
|
||||
prependToPath(path.dirname(resolved));
|
||||
state.resolvedNodeBinary = resolved;
|
||||
return resolved;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const readShebang = (opencodePath) => {
|
||||
if (!opencodePath || typeof opencodePath !== 'string') {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const fd = fs.openSync(opencodePath, 'r');
|
||||
try {
|
||||
const buf = Buffer.alloc(256);
|
||||
const bytes = fs.readSync(fd, buf, 0, buf.length, 0);
|
||||
const head = buf.subarray(0, bytes).toString('utf8');
|
||||
const firstLine = head.split(/\r?\n/, 1)[0] || '';
|
||||
if (!firstLine.startsWith('#!')) {
|
||||
return null;
|
||||
}
|
||||
const shebang = firstLine.slice(2).trim();
|
||||
if (!shebang) {
|
||||
return null;
|
||||
}
|
||||
return shebang;
|
||||
} finally {
|
||||
try {
|
||||
fs.closeSync(fd);
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const opencodeShimInterpreter = (opencodePath) => {
|
||||
const shebang = readShebang(opencodePath);
|
||||
if (!shebang) return null;
|
||||
if (/\bnode\b/i.test(shebang)) return 'node';
|
||||
if (/\bbun\b/i.test(shebang)) return 'bun';
|
||||
return null;
|
||||
};
|
||||
|
||||
const ensureOpencodeShimRuntime = (opencodePath) => {
|
||||
const runtime = opencodeShimInterpreter(opencodePath);
|
||||
if (runtime === 'node') {
|
||||
ensureNodeCliEnv();
|
||||
}
|
||||
if (runtime === 'bun') {
|
||||
ensureBunCliEnv();
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeOpencodeBinarySetting = (raw) => {
|
||||
if (typeof raw !== 'string') {
|
||||
return null;
|
||||
}
|
||||
const trimmed = normalizeDirectoryPath(raw).trim();
|
||||
if (!trimmed) {
|
||||
return '';
|
||||
}
|
||||
|
||||
try {
|
||||
const stat = fs.statSync(trimmed);
|
||||
if (stat.isDirectory()) {
|
||||
const bin = process.platform === 'win32' ? 'opencode.exe' : 'opencode';
|
||||
return path.join(trimmed, bin);
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
};
|
||||
|
||||
const applyOpencodeBinaryFromSettings = async () => {
|
||||
try {
|
||||
const settings = await readSettingsFromDiskMigrated();
|
||||
if (!settings || typeof settings !== 'object') {
|
||||
return null;
|
||||
}
|
||||
if (!Object.prototype.hasOwnProperty.call(settings, 'opencodeBinary')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalized = normalizeOpencodeBinarySetting(settings.opencodeBinary);
|
||||
|
||||
if (normalized === '') {
|
||||
delete process.env.OPENCODE_BINARY;
|
||||
state.resolvedOpencodeBinary = null;
|
||||
state.resolvedOpencodeBinarySource = null;
|
||||
clearWslOpencodeResolution();
|
||||
return null;
|
||||
}
|
||||
|
||||
const raw = typeof settings.opencodeBinary === 'string' ? settings.opencodeBinary.trim() : '';
|
||||
const explicitWslPath = process.platform === 'win32' && typeof raw === 'string'
|
||||
? raw.match(/^wsl:\s*(.+)$/i)
|
||||
: null;
|
||||
|
||||
if (explicitWslPath && explicitWslPath[1] && explicitWslPath[1].trim().length > 0) {
|
||||
const probe = probeWslForOpencode();
|
||||
const applied = applyWslOpencodeResolution({
|
||||
wslBinary: probe?.wslBinary || resolveWslExecutablePath(),
|
||||
opencodePath: explicitWslPath[1].trim(),
|
||||
source: 'settings-wsl-path',
|
||||
distro: probe?.distro || ENV_CONFIGURED_OPENCODE_WSL_DISTRO,
|
||||
});
|
||||
if (applied) {
|
||||
return applied;
|
||||
}
|
||||
}
|
||||
|
||||
if (process.platform === 'win32' && (isWslExecutableValue(raw) || isWslExecutableValue(normalized || ''))) {
|
||||
const probe = probeWslForOpencode();
|
||||
const applied = applyWslOpencodeResolution({
|
||||
wslBinary: probe?.wslBinary || normalized || raw || null,
|
||||
opencodePath: probe?.opencodePath || 'opencode',
|
||||
source: 'settings-wsl',
|
||||
distro: probe?.distro || ENV_CONFIGURED_OPENCODE_WSL_DISTRO,
|
||||
});
|
||||
if (applied) {
|
||||
return applied;
|
||||
}
|
||||
}
|
||||
|
||||
if (normalized && isExecutable(normalized)) {
|
||||
clearWslOpencodeResolution();
|
||||
process.env.OPENCODE_BINARY = normalized;
|
||||
prependToPath(path.dirname(normalized));
|
||||
state.resolvedOpencodeBinary = normalized;
|
||||
state.resolvedOpencodeBinarySource = 'settings';
|
||||
ensureOpencodeShimRuntime(normalized);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
if (raw) {
|
||||
console.warn(`Configured settings.opencodeBinary is not executable: ${raw}`);
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const ensureOpencodeCliEnv = () => {
|
||||
if (state.resolvedOpencodeBinary) {
|
||||
if (state.useWslForOpencode) {
|
||||
return state.resolvedOpencodeBinary;
|
||||
}
|
||||
ensureOpencodeShimRuntime(state.resolvedOpencodeBinary);
|
||||
return state.resolvedOpencodeBinary;
|
||||
}
|
||||
|
||||
const existing = typeof process.env.OPENCODE_BINARY === 'string' ? process.env.OPENCODE_BINARY.trim() : '';
|
||||
if (existing && isExecutable(existing)) {
|
||||
clearWslOpencodeResolution();
|
||||
state.resolvedOpencodeBinary = existing;
|
||||
state.resolvedOpencodeBinarySource = state.resolvedOpencodeBinarySource || 'env';
|
||||
prependToPath(path.dirname(existing));
|
||||
ensureOpencodeShimRuntime(existing);
|
||||
return state.resolvedOpencodeBinary;
|
||||
}
|
||||
|
||||
const resolved = resolveOpencodeCliPath();
|
||||
if (resolved) {
|
||||
if (state.useWslForOpencode) {
|
||||
state.resolvedOpencodeBinary = resolved;
|
||||
state.resolvedOpencodeBinarySource = state.resolvedOpencodeBinarySource || 'wsl';
|
||||
console.log(`Resolved opencode CLI via WSL: ${state.resolvedWslOpencodePath || 'opencode'}`);
|
||||
return resolved;
|
||||
}
|
||||
|
||||
process.env.OPENCODE_BINARY = resolved;
|
||||
prependToPath(path.dirname(resolved));
|
||||
ensureOpencodeShimRuntime(resolved);
|
||||
state.resolvedOpencodeBinary = resolved;
|
||||
state.resolvedOpencodeBinarySource = state.resolvedOpencodeBinarySource || 'unknown';
|
||||
console.log(`Resolved opencode CLI: ${resolved}`);
|
||||
return resolved;
|
||||
}
|
||||
|
||||
clearWslOpencodeResolution();
|
||||
return null;
|
||||
};
|
||||
|
||||
const resolveGitBinaryForSpawn = () => {
|
||||
if (process.platform !== 'win32') {
|
||||
return 'git';
|
||||
}
|
||||
|
||||
if (state.resolvedGitBinary) {
|
||||
return state.resolvedGitBinary;
|
||||
}
|
||||
|
||||
const explicit = [process.env.GIT_BINARY, process.env.OPENCHAMBER_GIT_BINARY]
|
||||
.map((value) => (typeof value === 'string' ? value.trim() : ''))
|
||||
.filter(Boolean);
|
||||
for (const candidate of explicit) {
|
||||
if (isExecutable(candidate)) {
|
||||
state.resolvedGitBinary = candidate;
|
||||
return state.resolvedGitBinary;
|
||||
}
|
||||
}
|
||||
|
||||
const candidates = [];
|
||||
const normalizeGitCandidate = (candidate) => {
|
||||
if (typeof candidate !== 'string') {
|
||||
return '';
|
||||
}
|
||||
const trimmed = candidate.trim();
|
||||
if (!trimmed) {
|
||||
return '';
|
||||
}
|
||||
const ext = path.extname(trimmed).toLowerCase();
|
||||
if (ext === '.cmd' || ext === '.bat' || ext === '.com') {
|
||||
const exeCandidate = trimmed.slice(0, -ext.length) + '.exe';
|
||||
if (isExecutable(exeCandidate)) {
|
||||
return exeCandidate;
|
||||
}
|
||||
}
|
||||
return trimmed;
|
||||
};
|
||||
|
||||
const pathCandidate = normalizeGitCandidate(searchPathFor('git'));
|
||||
if (pathCandidate && isExecutable(pathCandidate)) {
|
||||
candidates.push(pathCandidate);
|
||||
}
|
||||
|
||||
const pathExeCandidate = normalizeGitCandidate(searchPathFor('git.exe'));
|
||||
if (pathExeCandidate && isExecutable(pathExeCandidate)) {
|
||||
candidates.push(pathExeCandidate);
|
||||
}
|
||||
|
||||
const programRoots = [
|
||||
process.env.ProgramFiles,
|
||||
process.env['ProgramFiles(x86)'],
|
||||
process.env.LocalAppData,
|
||||
]
|
||||
.map((value) => (typeof value === 'string' ? value.trim() : ''))
|
||||
.filter(Boolean);
|
||||
for (const root of programRoots) {
|
||||
const installCandidates = [
|
||||
path.join(root, 'Git', 'cmd', 'git.exe'),
|
||||
path.join(root, 'Git', 'bin', 'git.exe'),
|
||||
path.join(root, 'Git', 'mingw64', 'bin', 'git.exe'),
|
||||
path.join(root, 'Programs', 'Git', 'cmd', 'git.exe'),
|
||||
path.join(root, 'Programs', 'Git', 'bin', 'git.exe'),
|
||||
];
|
||||
for (const candidate of installCandidates) {
|
||||
const normalized = normalizeGitCandidate(candidate);
|
||||
if (normalized && isExecutable(normalized)) {
|
||||
candidates.push(normalized);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const preferredExe = candidates.find((candidate) => candidate.toLowerCase().endsWith('.exe'));
|
||||
state.resolvedGitBinary = preferredExe || candidates[0] || 'git.exe';
|
||||
return state.resolvedGitBinary;
|
||||
};
|
||||
|
||||
const clearResolvedOpenCodeBinary = () => {
|
||||
state.resolvedOpencodeBinary = null;
|
||||
};
|
||||
|
||||
return {
|
||||
applyLoginShellEnvSnapshot,
|
||||
ensureOpencodeCliEnv,
|
||||
applyOpencodeBinaryFromSettings,
|
||||
getLoginShellEnvSnapshot,
|
||||
resolveOpencodeCliPath,
|
||||
isExecutable,
|
||||
searchPathFor,
|
||||
resolveGitBinaryForSpawn,
|
||||
resolveWslExecutablePath,
|
||||
buildWslExecArgs,
|
||||
opencodeShimInterpreter,
|
||||
clearResolvedOpenCodeBinary,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,216 @@
|
||||
import { registerFsRoutes } from '../fs/routes.js';
|
||||
import { registerQuotaRoutes } from '../quota/routes.js';
|
||||
import { registerGitHubRoutes } from '../github/routes.js';
|
||||
import { registerGitRoutes } from '../git/routes.js';
|
||||
import { registerConfigEntityRoutes } from './config-entity-routes.js';
|
||||
import { registerSettingsUtilityRoutes } from './core-routes.js';
|
||||
import { registerProjectIconRoutes } from './project-icon-routes.js';
|
||||
import { registerSkillRoutes } from './skill-routes.js';
|
||||
import { registerOpenCodeRoutes } from './routes.js';
|
||||
|
||||
export const createFeatureRoutesRuntime = (dependencies) => {
|
||||
const {
|
||||
clientReloadDelayMs,
|
||||
} = dependencies;
|
||||
|
||||
let quotaProviders = null;
|
||||
const getQuotaProviders = async () => {
|
||||
if (!quotaProviders) {
|
||||
quotaProviders = await import('../quota/index.js');
|
||||
}
|
||||
return quotaProviders;
|
||||
};
|
||||
|
||||
const registerRoutes = async (app, routeDependencies) => {
|
||||
const {
|
||||
crypto,
|
||||
fs,
|
||||
os,
|
||||
path,
|
||||
fsPromises,
|
||||
spawn,
|
||||
resolveGitBinaryForSpawn,
|
||||
createFsSearchRuntime,
|
||||
openchamberDataDir,
|
||||
openchamberUserConfigRoot,
|
||||
normalizeDirectoryPath,
|
||||
resolveProjectDirectory,
|
||||
resolveOptionalProjectDirectory,
|
||||
validateDirectoryPath,
|
||||
readCustomThemesFromDisk,
|
||||
refreshOpenCodeAfterConfigChange,
|
||||
getOpenCodeResolutionSnapshot,
|
||||
formatSettingsResponse,
|
||||
readSettingsFromDisk,
|
||||
readSettingsFromDiskMigrated,
|
||||
persistSettings,
|
||||
sanitizeProjects,
|
||||
sanitizeSkillCatalogs,
|
||||
isUnsafeSkillRelativePath,
|
||||
buildOpenCodeUrl,
|
||||
getOpenCodeAuthHeaders,
|
||||
getOpenCodePort,
|
||||
buildAugmentedPath,
|
||||
} = routeDependencies;
|
||||
|
||||
const { getProviderSources, removeProviderConfig } = await import('./index.js');
|
||||
|
||||
registerSettingsUtilityRoutes(app, {
|
||||
readCustomThemesFromDisk,
|
||||
refreshOpenCodeAfterConfigChange,
|
||||
clientReloadDelayMs,
|
||||
});
|
||||
|
||||
registerOpenCodeRoutes(app, {
|
||||
crypto,
|
||||
clientReloadDelayMs,
|
||||
getOpenCodeResolutionSnapshot,
|
||||
formatSettingsResponse,
|
||||
readSettingsFromDisk,
|
||||
readSettingsFromDiskMigrated,
|
||||
persistSettings,
|
||||
sanitizeProjects,
|
||||
validateDirectoryPath,
|
||||
resolveProjectDirectory,
|
||||
getProviderSources,
|
||||
removeProviderConfig,
|
||||
refreshOpenCodeAfterConfigChange,
|
||||
});
|
||||
|
||||
registerProjectIconRoutes(app, {
|
||||
fsPromises,
|
||||
path,
|
||||
crypto,
|
||||
openchamberDataDir,
|
||||
sanitizeProjects,
|
||||
readSettingsFromDiskMigrated,
|
||||
persistSettings,
|
||||
createFsSearchRuntime,
|
||||
spawn,
|
||||
resolveGitBinaryForSpawn,
|
||||
});
|
||||
|
||||
const {
|
||||
getAgentSources,
|
||||
getAgentConfig,
|
||||
createAgent,
|
||||
updateAgent,
|
||||
deleteAgent,
|
||||
getCommandSources,
|
||||
createCommand,
|
||||
updateCommand,
|
||||
deleteCommand,
|
||||
listMcpConfigs,
|
||||
getMcpConfig,
|
||||
createMcpConfig,
|
||||
updateMcpConfig,
|
||||
deleteMcpConfig,
|
||||
} = await import('./index.js');
|
||||
|
||||
registerConfigEntityRoutes(app, {
|
||||
resolveProjectDirectory,
|
||||
resolveOptionalProjectDirectory,
|
||||
refreshOpenCodeAfterConfigChange,
|
||||
clientReloadDelayMs,
|
||||
getAgentSources,
|
||||
getAgentConfig,
|
||||
createAgent,
|
||||
updateAgent,
|
||||
deleteAgent,
|
||||
getCommandSources,
|
||||
createCommand,
|
||||
updateCommand,
|
||||
deleteCommand,
|
||||
listMcpConfigs,
|
||||
getMcpConfig,
|
||||
createMcpConfig,
|
||||
updateMcpConfig,
|
||||
deleteMcpConfig,
|
||||
});
|
||||
|
||||
const {
|
||||
getSkillSources,
|
||||
discoverSkills,
|
||||
createSkill,
|
||||
updateSkill,
|
||||
deleteSkill,
|
||||
readSkillSupportingFile,
|
||||
writeSkillSupportingFile,
|
||||
deleteSkillSupportingFile,
|
||||
SKILL_SCOPE,
|
||||
SKILL_DIR,
|
||||
} = await import('./index.js');
|
||||
|
||||
const {
|
||||
getCuratedSkillsSources,
|
||||
getCacheKey,
|
||||
getCachedScan,
|
||||
setCachedScan,
|
||||
parseSkillRepoSource,
|
||||
scanSkillsRepository,
|
||||
installSkillsFromRepository,
|
||||
scanClawdHubPage,
|
||||
installSkillsFromClawdHub,
|
||||
isClawdHubSource,
|
||||
} = await import('../skills-catalog/index.js');
|
||||
const { getProfiles, getProfile } = await import('../git/index.js');
|
||||
|
||||
registerSkillRoutes(app, {
|
||||
fs,
|
||||
path,
|
||||
os,
|
||||
resolveProjectDirectory,
|
||||
resolveOptionalProjectDirectory,
|
||||
readSettingsFromDisk,
|
||||
sanitizeSkillCatalogs,
|
||||
isUnsafeSkillRelativePath,
|
||||
refreshOpenCodeAfterConfigChange,
|
||||
clientReloadDelayMs,
|
||||
buildOpenCodeUrl,
|
||||
getOpenCodeAuthHeaders,
|
||||
getOpenCodePort,
|
||||
getSkillSources,
|
||||
discoverSkills,
|
||||
createSkill,
|
||||
updateSkill,
|
||||
deleteSkill,
|
||||
readSkillSupportingFile,
|
||||
writeSkillSupportingFile,
|
||||
deleteSkillSupportingFile,
|
||||
SKILL_SCOPE,
|
||||
SKILL_DIR,
|
||||
getCuratedSkillsSources,
|
||||
getCacheKey,
|
||||
getCachedScan,
|
||||
setCachedScan,
|
||||
parseSkillRepoSource,
|
||||
scanSkillsRepository,
|
||||
installSkillsFromRepository,
|
||||
scanClawdHubPage,
|
||||
installSkillsFromClawdHub,
|
||||
isClawdHubSource,
|
||||
getProfiles,
|
||||
getProfile,
|
||||
});
|
||||
|
||||
registerQuotaRoutes(app, { getQuotaProviders });
|
||||
registerGitHubRoutes(app);
|
||||
registerGitRoutes(app);
|
||||
registerFsRoutes(app, {
|
||||
os,
|
||||
path,
|
||||
fsPromises,
|
||||
spawn,
|
||||
crypto,
|
||||
normalizeDirectoryPath,
|
||||
resolveProjectDirectory,
|
||||
buildAugmentedPath,
|
||||
resolveGitBinaryForSpawn,
|
||||
openchamberUserConfigRoot,
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
registerRoutes,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,85 @@
|
||||
export const createHmrStateRuntime = (dependencies) => {
|
||||
const {
|
||||
globalThisLike,
|
||||
os,
|
||||
processLike,
|
||||
stateKey,
|
||||
} = dependencies;
|
||||
|
||||
const getOrCreateHmrState = () => {
|
||||
if (!globalThisLike[stateKey]) {
|
||||
globalThisLike[stateKey] = {
|
||||
openCodeProcess: null,
|
||||
openCodePort: null,
|
||||
openCodeWorkingDirectory: os.homedir(),
|
||||
isShuttingDown: false,
|
||||
signalsAttached: false,
|
||||
userProvidedOpenCodePassword: undefined,
|
||||
openCodeAuthPassword: null,
|
||||
openCodeAuthSource: null,
|
||||
};
|
||||
}
|
||||
return globalThisLike[stateKey];
|
||||
};
|
||||
|
||||
const ensureUserProvidedOpenCodePassword = (hmrState) => {
|
||||
if (typeof hmrState.userProvidedOpenCodePassword !== 'undefined') {
|
||||
return;
|
||||
}
|
||||
const initialPassword = typeof processLike.env.OPENCODE_SERVER_PASSWORD === 'string'
|
||||
? processLike.env.OPENCODE_SERVER_PASSWORD.trim()
|
||||
: '';
|
||||
hmrState.userProvidedOpenCodePassword = initialPassword || null;
|
||||
};
|
||||
|
||||
const getUserProvidedOpenCodePassword = (hmrState) => (
|
||||
typeof hmrState.userProvidedOpenCodePassword === 'string' && hmrState.userProvidedOpenCodePassword.length > 0
|
||||
? hmrState.userProvidedOpenCodePassword
|
||||
: null
|
||||
);
|
||||
|
||||
const resolveOpenCodeAuthFromState = ({ hmrState, userProvidedOpenCodePassword }) => ({
|
||||
openCodeAuthPassword:
|
||||
typeof hmrState.openCodeAuthPassword === 'string' && hmrState.openCodeAuthPassword.length > 0
|
||||
? hmrState.openCodeAuthPassword
|
||||
: userProvidedOpenCodePassword,
|
||||
openCodeAuthSource:
|
||||
typeof hmrState.openCodeAuthSource === 'string' && hmrState.openCodeAuthSource.length > 0
|
||||
? hmrState.openCodeAuthSource
|
||||
: (userProvidedOpenCodePassword ? 'user-env' : null),
|
||||
});
|
||||
|
||||
const syncStateFromRuntime = (hmrState, runtime) => {
|
||||
hmrState.openCodeProcess = runtime.openCodeProcess;
|
||||
hmrState.openCodePort = runtime.openCodePort;
|
||||
hmrState.openCodeBaseUrl = runtime.openCodeBaseUrl;
|
||||
hmrState.isShuttingDown = runtime.isShuttingDown;
|
||||
hmrState.signalsAttached = runtime.signalsAttached;
|
||||
hmrState.openCodeWorkingDirectory = runtime.openCodeWorkingDirectory;
|
||||
hmrState.openCodeAuthPassword = runtime.openCodeAuthPassword;
|
||||
hmrState.openCodeAuthSource = runtime.openCodeAuthSource;
|
||||
};
|
||||
|
||||
const restoreRuntimeFromState = ({ hmrState, userProvidedOpenCodePassword }) => {
|
||||
const auth = resolveOpenCodeAuthFromState({ hmrState, userProvidedOpenCodePassword });
|
||||
return {
|
||||
openCodeProcess: hmrState.openCodeProcess,
|
||||
openCodePort: hmrState.openCodePort,
|
||||
openCodeBaseUrl: hmrState.openCodeBaseUrl ?? null,
|
||||
isShuttingDown: hmrState.isShuttingDown,
|
||||
signalsAttached: hmrState.signalsAttached,
|
||||
openCodeWorkingDirectory: hmrState.openCodeWorkingDirectory,
|
||||
openCodeAuthPassword: auth.openCodeAuthPassword,
|
||||
openCodeAuthSource: auth.openCodeAuthSource,
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
getOrCreateHmrState,
|
||||
ensureUserProvidedOpenCodePassword,
|
||||
getUserProvidedOpenCodePassword,
|
||||
resolveOpenCodeAuthFromState,
|
||||
syncStateFromRuntime,
|
||||
restoreRuntimeFromState,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,630 @@
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import net from 'node:net';
|
||||
import path from 'node:path';
|
||||
|
||||
export const createOpenCodeLifecycleRuntime = (deps) => {
|
||||
const {
|
||||
state,
|
||||
env,
|
||||
syncToHmrState,
|
||||
syncFromHmrState,
|
||||
getOpenCodeAuthHeaders,
|
||||
buildOpenCodeUrl,
|
||||
waitForReady,
|
||||
normalizeApiPrefix,
|
||||
applyOpencodeBinaryFromSettings,
|
||||
ensureOpencodeCliEnv,
|
||||
ensureLocalOpenCodeServerPassword,
|
||||
buildWslExecArgs,
|
||||
resolveWslExecutablePath,
|
||||
opencodeShimInterpreter,
|
||||
setOpenCodePort,
|
||||
setDetectedOpenCodeApiPrefix,
|
||||
setupProxy,
|
||||
ensureOpenCodeApiPrefix,
|
||||
clearResolvedOpenCodeBinary,
|
||||
} = deps;
|
||||
|
||||
const killProcessOnPort = (port) => {
|
||||
if (!port) return;
|
||||
try {
|
||||
const result = spawnSync('lsof', ['-ti', `:${port}`], { encoding: 'utf8', timeout: 5000, windowsHide: true });
|
||||
const output = result.stdout || '';
|
||||
const myPid = process.pid;
|
||||
for (const pidStr of output.split(/\s+/)) {
|
||||
const pid = parseInt(pidStr.trim(), 10);
|
||||
if (pid && pid !== myPid) {
|
||||
try {
|
||||
spawnSync('kill', ['-9', String(pid)], { stdio: 'ignore', timeout: 2000 });
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
};
|
||||
|
||||
const createManagedOpenCodeServerProcess = async ({ hostname, port, timeout, cwd, env: processEnv }) => {
|
||||
let binary = (process.env.OPENCODE_BINARY || 'opencode').trim() || 'opencode';
|
||||
let args = ['serve', '--hostname', hostname, '--port', String(port)];
|
||||
|
||||
if (process.platform === 'win32' && state.useWslForOpencode) {
|
||||
const wslBinary = state.resolvedWslBinary || resolveWslExecutablePath();
|
||||
if (!wslBinary) {
|
||||
throw new Error('WSL executable not found while attempting to launch OpenCode from WSL');
|
||||
}
|
||||
|
||||
const wslOpencode = state.resolvedWslOpencodePath && state.resolvedWslOpencodePath.trim().length > 0
|
||||
? state.resolvedWslOpencodePath.trim()
|
||||
: 'opencode';
|
||||
const serveHost = hostname === '127.0.0.1' ? '0.0.0.0' : hostname;
|
||||
|
||||
binary = wslBinary;
|
||||
args = buildWslExecArgs([
|
||||
wslOpencode,
|
||||
'serve',
|
||||
'--hostname',
|
||||
serveHost,
|
||||
'--port',
|
||||
String(port),
|
||||
], state.resolvedWslDistro);
|
||||
}
|
||||
|
||||
if (process.platform === 'win32' && !state.useWslForOpencode) {
|
||||
const interpreter = opencodeShimInterpreter(binary);
|
||||
if (interpreter) {
|
||||
args.unshift(binary);
|
||||
binary = interpreter;
|
||||
} else {
|
||||
try {
|
||||
const shimContent = fs.readFileSync(binary, 'utf8');
|
||||
const jsMatch = shimContent.match(/node_modules[\\/]opencode[^\s"']*/);
|
||||
if (jsMatch) {
|
||||
const candidate = path.resolve(path.dirname(binary), jsMatch[0]);
|
||||
if (fs.existsSync(candidate)) {
|
||||
const realInterp = opencodeShimInterpreter(candidate);
|
||||
if (realInterp) {
|
||||
args.unshift(candidate);
|
||||
binary = realInterp;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const child = spawn(binary, args, {
|
||||
cwd,
|
||||
env: processEnv,
|
||||
windowsHide: true,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
const url = await new Promise((resolve, reject) => {
|
||||
let output = '';
|
||||
let done = false;
|
||||
const finish = (handler, value) => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
clearTimeout(timer);
|
||||
child.stdout?.off('data', onStdout);
|
||||
child.stderr?.off('data', onStderr);
|
||||
child.off('exit', onExit);
|
||||
child.off('error', onError);
|
||||
handler(value);
|
||||
};
|
||||
|
||||
const onStdout = (chunk) => {
|
||||
output += chunk.toString();
|
||||
const lines = output.split('\n');
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith('opencode server listening')) continue;
|
||||
const match = line.match(/on\s+(https?:\/\/[^\s]+)/);
|
||||
if (!match) {
|
||||
finish(reject, new Error(`Failed to parse server url from output: ${line}`));
|
||||
return;
|
||||
}
|
||||
finish(resolve, match[1]);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const onStderr = (chunk) => {
|
||||
output += chunk.toString();
|
||||
};
|
||||
|
||||
const onExit = (code) => {
|
||||
finish(reject, new Error(`OpenCode exited with code ${code}. Output: ${output}`));
|
||||
};
|
||||
|
||||
const onError = (error) => {
|
||||
finish(reject, error);
|
||||
};
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
finish(reject, new Error(`Timeout waiting for OpenCode to start after ${timeout}ms`));
|
||||
}, timeout);
|
||||
|
||||
child.stdout?.on('data', onStdout);
|
||||
child.stderr?.on('data', onStderr);
|
||||
child.on('exit', onExit);
|
||||
child.on('error', onError);
|
||||
});
|
||||
|
||||
return {
|
||||
url,
|
||||
close() {
|
||||
try {
|
||||
child.kill('SIGTERM');
|
||||
} catch {
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const resolveManagedOpenCodePort = async (requestedPort, hostname = '127.0.0.1') => {
|
||||
if (typeof requestedPort === 'number' && Number.isFinite(requestedPort) && requestedPort > 0) {
|
||||
return requestedPort;
|
||||
}
|
||||
|
||||
return await new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
const cleanup = () => {
|
||||
server.removeAllListeners('error');
|
||||
server.removeAllListeners('listening');
|
||||
};
|
||||
|
||||
server.once('error', (error) => {
|
||||
cleanup();
|
||||
reject(error);
|
||||
});
|
||||
|
||||
server.once('listening', () => {
|
||||
const address = server.address();
|
||||
const port = address && typeof address === 'object' ? address.port : 0;
|
||||
server.close(() => {
|
||||
cleanup();
|
||||
if (port > 0) {
|
||||
resolve(port);
|
||||
return;
|
||||
}
|
||||
reject(new Error('Failed to allocate OpenCode port'));
|
||||
});
|
||||
});
|
||||
|
||||
server.listen(0, hostname);
|
||||
});
|
||||
};
|
||||
|
||||
const isOpenCodeProcessHealthy = async () => {
|
||||
if (!state.openCodeProcess || !state.openCodePort) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`http://127.0.0.1:${state.openCodePort}/session`, {
|
||||
method: 'GET',
|
||||
headers: getOpenCodeAuthHeaders(),
|
||||
signal: AbortSignal.timeout(2000),
|
||||
});
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const probeExternalOpenCode = async (port, origin) => {
|
||||
if (!port || port <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 3000);
|
||||
const base = origin ?? `http://127.0.0.1:${port}`;
|
||||
const response = await fetch(`${base}/global/health`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
...getOpenCodeAuthHeaders(),
|
||||
},
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timeout);
|
||||
if (!response.ok) return false;
|
||||
const body = await response.json().catch(() => null);
|
||||
return body?.healthy === true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const waitForOpenCodePort = async (timeoutMs = 15000) => {
|
||||
if (state.openCodePort !== null) {
|
||||
return state.openCodePort;
|
||||
}
|
||||
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
if (state.openCodePort !== null) {
|
||||
return state.openCodePort;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Timed out waiting for OpenCode port');
|
||||
};
|
||||
|
||||
const startOpenCode = async () => {
|
||||
const desiredPort = env.ENV_CONFIGURED_OPENCODE_PORT ?? 0;
|
||||
const spawnPort = await resolveManagedOpenCodePort(desiredPort, env.ENV_CONFIGURED_OPENCODE_HOSTNAME);
|
||||
console.log(
|
||||
desiredPort > 0
|
||||
? `Starting OpenCode on requested port ${desiredPort}...`
|
||||
: `Starting OpenCode on allocated port ${spawnPort}...`
|
||||
);
|
||||
|
||||
await applyOpencodeBinaryFromSettings();
|
||||
ensureOpencodeCliEnv();
|
||||
const openCodePassword = await ensureLocalOpenCodeServerPassword({ rotateManaged: true });
|
||||
|
||||
try {
|
||||
const serverInstance = await createManagedOpenCodeServerProcess({
|
||||
hostname: env.ENV_CONFIGURED_OPENCODE_HOSTNAME,
|
||||
port: spawnPort,
|
||||
timeout: 30000,
|
||||
cwd: state.openCodeWorkingDirectory,
|
||||
env: {
|
||||
...process.env,
|
||||
OPENCODE_SERVER_PASSWORD: openCodePassword,
|
||||
},
|
||||
});
|
||||
|
||||
if (!serverInstance || !serverInstance.url) {
|
||||
throw new Error('OpenCode server started but URL is missing');
|
||||
}
|
||||
|
||||
const url = new URL(serverInstance.url);
|
||||
const port = parseInt(url.port, 10);
|
||||
const prefix = normalizeApiPrefix(url.pathname);
|
||||
|
||||
if (await waitForReady(serverInstance.url, 10000)) {
|
||||
setOpenCodePort(port);
|
||||
setDetectedOpenCodeApiPrefix(prefix);
|
||||
|
||||
state.isOpenCodeReady = true;
|
||||
state.lastOpenCodeError = null;
|
||||
state.openCodeNotReadySince = 0;
|
||||
|
||||
return serverInstance;
|
||||
}
|
||||
|
||||
try {
|
||||
serverInstance.close();
|
||||
} catch {
|
||||
}
|
||||
throw new Error('Server started but health check failed (timeout)');
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
state.lastOpenCodeError = message;
|
||||
state.openCodePort = null;
|
||||
syncToHmrState();
|
||||
console.error(`Failed to start OpenCode: ${message}`);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const restartOpenCode = async () => {
|
||||
if (state.isShuttingDown) return;
|
||||
if (state.currentRestartPromise) {
|
||||
await state.currentRestartPromise;
|
||||
return;
|
||||
}
|
||||
|
||||
state.currentRestartPromise = (async () => {
|
||||
state.isRestartingOpenCode = true;
|
||||
state.isOpenCodeReady = false;
|
||||
state.openCodeNotReadySince = Date.now();
|
||||
console.log('Restarting OpenCode process...');
|
||||
|
||||
if (state.isExternalOpenCode) {
|
||||
console.log('Re-probing external OpenCode server...');
|
||||
const probePort = state.openCodePort || env.ENV_CONFIGURED_OPENCODE_PORT || 4096;
|
||||
const probeOrigin = state.openCodeBaseUrl ?? env.ENV_CONFIGURED_OPENCODE_HOST?.origin;
|
||||
const healthy = await probeExternalOpenCode(probePort, probeOrigin);
|
||||
if (healthy) {
|
||||
console.log(`External OpenCode server on port ${probePort} is healthy`);
|
||||
setOpenCodePort(probePort);
|
||||
state.isOpenCodeReady = true;
|
||||
state.lastOpenCodeError = null;
|
||||
state.openCodeNotReadySince = 0;
|
||||
syncToHmrState();
|
||||
} else {
|
||||
state.lastOpenCodeError = `External OpenCode server on port ${probePort} is not responding`;
|
||||
console.error(state.lastOpenCodeError);
|
||||
throw new Error(state.lastOpenCodeError);
|
||||
}
|
||||
|
||||
if (state.expressApp) {
|
||||
setupProxy(state.expressApp);
|
||||
ensureOpenCodeApiPrefix();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const portToKill = state.openCodePort;
|
||||
|
||||
if (state.openCodeProcess) {
|
||||
console.log('Stopping existing OpenCode process...');
|
||||
try {
|
||||
state.openCodeProcess.close();
|
||||
} catch (error) {
|
||||
console.warn('Error closing OpenCode process:', error);
|
||||
}
|
||||
state.openCodeProcess = null;
|
||||
syncToHmrState();
|
||||
}
|
||||
|
||||
killProcessOnPort(portToKill);
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
|
||||
if (env.ENV_CONFIGURED_OPENCODE_PORT) {
|
||||
console.log(`Using OpenCode port from environment: ${env.ENV_CONFIGURED_OPENCODE_PORT}`);
|
||||
setOpenCodePort(env.ENV_CONFIGURED_OPENCODE_PORT);
|
||||
} else {
|
||||
state.openCodePort = null;
|
||||
syncToHmrState();
|
||||
}
|
||||
|
||||
state.openCodeApiPrefixDetected = true;
|
||||
state.openCodeApiPrefix = '';
|
||||
if (state.openCodeApiDetectionTimer) {
|
||||
clearTimeout(state.openCodeApiDetectionTimer);
|
||||
state.openCodeApiDetectionTimer = null;
|
||||
}
|
||||
|
||||
state.lastOpenCodeError = null;
|
||||
state.openCodeProcess = await startOpenCode();
|
||||
syncToHmrState();
|
||||
|
||||
if (state.expressApp) {
|
||||
setupProxy(state.expressApp);
|
||||
ensureOpenCodeApiPrefix();
|
||||
}
|
||||
})();
|
||||
|
||||
try {
|
||||
await state.currentRestartPromise;
|
||||
} catch (error) {
|
||||
console.error(`Failed to restart OpenCode: ${error.message}`);
|
||||
state.lastOpenCodeError = error.message;
|
||||
if (!env.ENV_CONFIGURED_OPENCODE_PORT) {
|
||||
state.openCodePort = null;
|
||||
syncToHmrState();
|
||||
}
|
||||
state.openCodeApiPrefixDetected = true;
|
||||
state.openCodeApiPrefix = '';
|
||||
throw error;
|
||||
} finally {
|
||||
state.currentRestartPromise = null;
|
||||
state.isRestartingOpenCode = false;
|
||||
}
|
||||
};
|
||||
|
||||
const waitForOpenCodeReady = async (timeoutMs = 20000, intervalMs = 400) => {
|
||||
if (!state.openCodePort) {
|
||||
throw new Error('OpenCode port is not available');
|
||||
}
|
||||
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let lastError = null;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const [configResult, agentResult] = await Promise.all([
|
||||
fetch(buildOpenCodeUrl('/config', ''), {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json', ...getOpenCodeAuthHeaders() },
|
||||
}).catch((error) => error),
|
||||
fetch(buildOpenCodeUrl('/agent', ''), {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json', ...getOpenCodeAuthHeaders() },
|
||||
}).catch((error) => error),
|
||||
]);
|
||||
|
||||
if (configResult instanceof Error) {
|
||||
lastError = configResult;
|
||||
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!configResult.ok) {
|
||||
lastError = new Error(`OpenCode config endpoint responded with status ${configResult.status}`);
|
||||
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
||||
continue;
|
||||
}
|
||||
|
||||
await configResult.json().catch(() => null);
|
||||
|
||||
if (agentResult instanceof Error) {
|
||||
lastError = agentResult;
|
||||
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!agentResult.ok) {
|
||||
lastError = new Error(`Agent endpoint responded with status ${agentResult.status}`);
|
||||
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
||||
continue;
|
||||
}
|
||||
|
||||
await agentResult.json().catch(() => []);
|
||||
|
||||
state.isOpenCodeReady = true;
|
||||
state.lastOpenCodeError = null;
|
||||
return;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
||||
}
|
||||
|
||||
if (lastError) {
|
||||
state.lastOpenCodeError = lastError.message || String(lastError);
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
const timeoutError = new Error('Timed out waiting for OpenCode to become ready');
|
||||
state.lastOpenCodeError = timeoutError.message;
|
||||
throw timeoutError;
|
||||
};
|
||||
|
||||
const waitForAgentPresence = async (agentName, timeoutMs = 15000, intervalMs = 300) => {
|
||||
if (!state.openCodePort) {
|
||||
throw new Error('OpenCode port is not available');
|
||||
}
|
||||
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const response = await fetch(buildOpenCodeUrl('/agent'), {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json', ...getOpenCodeAuthHeaders() },
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const agents = await response.json();
|
||||
if (Array.isArray(agents) && agents.some((agent) => agent?.name === agentName)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
||||
}
|
||||
|
||||
throw new Error(`Agent "${agentName}" not available after OpenCode restart`);
|
||||
};
|
||||
|
||||
const refreshOpenCodeAfterConfigChange = async (reason, options = {}) => {
|
||||
const { agentName } = options;
|
||||
|
||||
console.log(`Refreshing OpenCode after ${reason}`);
|
||||
clearResolvedOpenCodeBinary();
|
||||
await applyOpencodeBinaryFromSettings();
|
||||
|
||||
await restartOpenCode();
|
||||
|
||||
try {
|
||||
await waitForOpenCodeReady();
|
||||
state.isOpenCodeReady = true;
|
||||
state.openCodeNotReadySince = 0;
|
||||
|
||||
if (agentName) {
|
||||
await waitForAgentPresence(agentName);
|
||||
}
|
||||
|
||||
state.isOpenCodeReady = true;
|
||||
state.openCodeNotReadySince = 0;
|
||||
} catch (error) {
|
||||
state.isOpenCodeReady = false;
|
||||
state.openCodeNotReadySince = Date.now();
|
||||
console.error(`Failed to refresh OpenCode after ${reason}:`, error.message);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const bootstrapOpenCodeAtStartup = async () => {
|
||||
try {
|
||||
syncFromHmrState();
|
||||
if (await isOpenCodeProcessHealthy()) {
|
||||
console.log(`[HMR] Reusing existing OpenCode process on port ${state.openCodePort}`);
|
||||
} else if (env.ENV_SKIP_OPENCODE_START && env.ENV_EFFECTIVE_PORT) {
|
||||
const label = env.ENV_CONFIGURED_OPENCODE_HOST ? env.ENV_CONFIGURED_OPENCODE_HOST.origin : `http://localhost:${env.ENV_EFFECTIVE_PORT}`;
|
||||
console.log(`Using external OpenCode server at ${label} (skip-start mode)`);
|
||||
state.openCodeBaseUrl = env.ENV_CONFIGURED_OPENCODE_HOST?.origin ?? null;
|
||||
setOpenCodePort(env.ENV_EFFECTIVE_PORT);
|
||||
state.isOpenCodeReady = true;
|
||||
state.isExternalOpenCode = true;
|
||||
state.lastOpenCodeError = null;
|
||||
state.openCodeNotReadySince = 0;
|
||||
syncToHmrState();
|
||||
} else if (env.ENV_EFFECTIVE_PORT && await probeExternalOpenCode(env.ENV_EFFECTIVE_PORT, env.ENV_CONFIGURED_OPENCODE_HOST?.origin)) {
|
||||
const label = env.ENV_CONFIGURED_OPENCODE_HOST ? env.ENV_CONFIGURED_OPENCODE_HOST.origin : `http://localhost:${env.ENV_EFFECTIVE_PORT}`;
|
||||
console.log(`Auto-detected existing OpenCode server at ${label}`);
|
||||
state.openCodeBaseUrl = env.ENV_CONFIGURED_OPENCODE_HOST?.origin ?? null;
|
||||
setOpenCodePort(env.ENV_EFFECTIVE_PORT);
|
||||
state.isOpenCodeReady = true;
|
||||
state.isExternalOpenCode = true;
|
||||
state.lastOpenCodeError = null;
|
||||
state.openCodeNotReadySince = 0;
|
||||
syncToHmrState();
|
||||
} else if (!env.ENV_EFFECTIVE_PORT && await probeExternalOpenCode(4096)) {
|
||||
console.log('Auto-detected existing OpenCode server on default port 4096');
|
||||
setOpenCodePort(4096);
|
||||
state.isOpenCodeReady = true;
|
||||
state.isExternalOpenCode = true;
|
||||
state.lastOpenCodeError = null;
|
||||
state.openCodeNotReadySince = 0;
|
||||
syncToHmrState();
|
||||
} else {
|
||||
if (env.ENV_EFFECTIVE_PORT) {
|
||||
console.log(`Using OpenCode port from environment: ${env.ENV_EFFECTIVE_PORT}`);
|
||||
setOpenCodePort(env.ENV_EFFECTIVE_PORT);
|
||||
} else {
|
||||
state.openCodePort = null;
|
||||
syncToHmrState();
|
||||
}
|
||||
|
||||
state.lastOpenCodeError = null;
|
||||
state.openCodeProcess = await startOpenCode();
|
||||
syncToHmrState();
|
||||
}
|
||||
await waitForOpenCodePort();
|
||||
try {
|
||||
await waitForOpenCodeReady();
|
||||
} catch (error) {
|
||||
console.error(`OpenCode readiness check failed: ${error.message}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to start OpenCode: ${error.message}`);
|
||||
console.log('Continuing without OpenCode integration...');
|
||||
state.lastOpenCodeError = error.message;
|
||||
}
|
||||
};
|
||||
|
||||
const startHealthMonitoring = (healthCheckIntervalMs) => {
|
||||
if (state.healthCheckInterval) {
|
||||
clearInterval(state.healthCheckInterval);
|
||||
}
|
||||
|
||||
state.healthCheckInterval = setInterval(async () => {
|
||||
if (!state.openCodeProcess || state.isShuttingDown || state.isRestartingOpenCode) return;
|
||||
|
||||
try {
|
||||
const healthy = await isOpenCodeProcessHealthy();
|
||||
if (!healthy) {
|
||||
console.log('OpenCode process not running, restarting...');
|
||||
await restartOpenCode();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Health check error: ${error.message}`);
|
||||
}
|
||||
}, healthCheckIntervalMs);
|
||||
};
|
||||
|
||||
return {
|
||||
killProcessOnPort,
|
||||
startOpenCode,
|
||||
restartOpenCode,
|
||||
waitForOpenCodeReady,
|
||||
waitForAgentPresence,
|
||||
refreshOpenCodeAfterConfigChange,
|
||||
bootstrapOpenCodeAtStartup,
|
||||
startHealthMonitoring,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,98 @@
|
||||
export const createOpenCodeNetworkRuntime = (deps) => {
|
||||
const {
|
||||
state,
|
||||
getOpenCodeAuthHeaders,
|
||||
} = deps;
|
||||
|
||||
const normalizeApiPrefix = (prefix) => {
|
||||
if (!prefix) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (prefix.includes('://')) {
|
||||
try {
|
||||
const parsed = new URL(prefix);
|
||||
return normalizeApiPrefix(parsed.pathname);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
const trimmed = prefix.trim();
|
||||
if (!trimmed || trimmed === '/') {
|
||||
return '';
|
||||
}
|
||||
const withLeading = trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
|
||||
return withLeading.endsWith('/') ? withLeading.slice(0, -1) : withLeading;
|
||||
};
|
||||
|
||||
const waitForReady = async (url, timeoutMs = 10000) => {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 3000);
|
||||
const response = await fetch(`${url.replace(/\/+$/, '')}/global/health`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
...getOpenCodeAuthHeaders(),
|
||||
},
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timeout);
|
||||
|
||||
if (response.ok) {
|
||||
const body = await response.json().catch(() => null);
|
||||
if (body?.healthy === true) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const setDetectedOpenCodeApiPrefix = () => {
|
||||
state.openCodeApiPrefix = '';
|
||||
state.openCodeApiPrefixDetected = true;
|
||||
if (state.openCodeApiDetectionTimer) {
|
||||
clearTimeout(state.openCodeApiDetectionTimer);
|
||||
state.openCodeApiDetectionTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const buildOpenCodeUrl = (path, prefixOverride) => {
|
||||
if (!state.openCodePort) {
|
||||
throw new Error('OpenCode port is not available');
|
||||
}
|
||||
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
|
||||
const prefix = normalizeApiPrefix(prefixOverride !== undefined ? prefixOverride : '');
|
||||
const fullPath = `${prefix}${normalizedPath}`;
|
||||
const base = state.openCodeBaseUrl ?? `http://localhost:${state.openCodePort}`;
|
||||
return `${base}${fullPath}`;
|
||||
};
|
||||
|
||||
const detectOpenCodeApiPrefix = () => {
|
||||
state.openCodeApiPrefixDetected = true;
|
||||
state.openCodeApiPrefix = '';
|
||||
return true;
|
||||
};
|
||||
|
||||
const ensureOpenCodeApiPrefix = () => detectOpenCodeApiPrefix();
|
||||
|
||||
const scheduleOpenCodeApiDetection = () => {
|
||||
return;
|
||||
};
|
||||
|
||||
return {
|
||||
waitForReady,
|
||||
normalizeApiPrefix,
|
||||
setDetectedOpenCodeApiPrefix,
|
||||
buildOpenCodeUrl,
|
||||
ensureOpenCodeApiPrefix,
|
||||
scheduleOpenCodeApiDetection,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,284 @@
|
||||
export const registerOpenChamberRoutes = (app, dependencies) => {
|
||||
const {
|
||||
fs,
|
||||
os,
|
||||
path,
|
||||
process,
|
||||
server,
|
||||
__dirname,
|
||||
openchamberDataDir,
|
||||
modelsDevApiUrl,
|
||||
modelsMetadataCacheTtl,
|
||||
readSettingsFromDiskMigrated,
|
||||
fetchFreeZenModels,
|
||||
getCachedZenModels,
|
||||
} = dependencies;
|
||||
|
||||
let cachedModelsMetadata = null;
|
||||
let cachedModelsMetadataTimestamp = 0;
|
||||
|
||||
app.get('/api/openchamber/update-check', async (req, res) => {
|
||||
try {
|
||||
const { checkForUpdates } = await import('../package-manager.js');
|
||||
const parseString = (value) => (typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined);
|
||||
const parseReportUsage = (value) => {
|
||||
if (typeof value !== 'string') return true;
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (normalized === 'false' || normalized === '0' || normalized === 'no') return false;
|
||||
return true;
|
||||
};
|
||||
const inferDeviceClass = (ua) => {
|
||||
const value = (ua || '').toLowerCase();
|
||||
if (!value) return 'unknown';
|
||||
if (value.includes('ipad') || value.includes('tablet')) return 'tablet';
|
||||
if (value.includes('mobi') || value.includes('android') || value.includes('iphone')) return 'mobile';
|
||||
return 'desktop';
|
||||
};
|
||||
const userAgent = typeof req.headers['user-agent'] === 'string' ? req.headers['user-agent'] : '';
|
||||
|
||||
const updateInfo = await checkForUpdates({
|
||||
appType: parseString(req.query.appType),
|
||||
deviceClass: parseString(req.query.deviceClass) || inferDeviceClass(userAgent),
|
||||
platform: parseString(req.query.platform),
|
||||
arch: parseString(req.query.arch),
|
||||
instanceMode: parseString(req.query.instanceMode),
|
||||
currentVersion: parseString(req.query.currentVersion),
|
||||
reportUsage: parseReportUsage(parseString(req.query.reportUsage)),
|
||||
});
|
||||
res.json(updateInfo);
|
||||
} catch (error) {
|
||||
console.error('Failed to check for updates:', error);
|
||||
res.status(500).json({
|
||||
available: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to check for updates',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/openchamber/update-install', async (_req, res) => {
|
||||
try {
|
||||
const { spawn: spawnChild } = await import('child_process');
|
||||
const {
|
||||
checkForUpdates,
|
||||
getUpdateCommand,
|
||||
detectPackageManager,
|
||||
} = await import('../package-manager.js');
|
||||
|
||||
const updateInfo = await checkForUpdates();
|
||||
if (!updateInfo.available) {
|
||||
return res.status(400).json({ error: 'No update available' });
|
||||
}
|
||||
|
||||
const pm = detectPackageManager();
|
||||
const updateCmd = getUpdateCommand(pm);
|
||||
const isContainer =
|
||||
fs.existsSync('/.dockerenv') ||
|
||||
Boolean(process.env.CONTAINER) ||
|
||||
process.env.container === 'docker';
|
||||
|
||||
if (isContainer) {
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Update starting, server will stay online',
|
||||
version: updateInfo.version,
|
||||
packageManager: pm,
|
||||
autoRestart: false,
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
console.log(`\nInstalling update using ${pm} (container mode)...`);
|
||||
console.log(`Running: ${updateCmd}`);
|
||||
|
||||
const shell = process.platform === 'win32' ? (process.env.ComSpec || 'cmd.exe') : 'sh';
|
||||
const shellFlag = process.platform === 'win32' ? '/c' : '-c';
|
||||
const child = spawnChild(shell, [shellFlag, updateCmd], {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
env: process.env,
|
||||
});
|
||||
child.unref();
|
||||
}, 500);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const currentPort = server.address()?.port || 3000;
|
||||
const tmpDir = os.tmpdir();
|
||||
const instanceFilePath = path.join(tmpDir, `openchamber-${currentPort}.json`);
|
||||
let storedOptions = { port: currentPort, daemon: true };
|
||||
try {
|
||||
const content = await fs.promises.readFile(instanceFilePath, 'utf8');
|
||||
storedOptions = JSON.parse(content);
|
||||
} catch {
|
||||
}
|
||||
|
||||
const isWindows = process.platform === 'win32';
|
||||
const quotePosix = (value) => `'${String(value).replace(/'/g, "'\\''")}'`;
|
||||
const quoteCmd = (value) => {
|
||||
const stringValue = String(value);
|
||||
return `"${stringValue.replace(/"/g, '""')}"`;
|
||||
};
|
||||
|
||||
const cliPath = path.resolve(__dirname, '..', '..', 'bin', 'cli.js');
|
||||
const restartParts = [
|
||||
isWindows ? quoteCmd(process.execPath) : quotePosix(process.execPath),
|
||||
isWindows ? quoteCmd(cliPath) : quotePosix(cliPath),
|
||||
'serve',
|
||||
'--port',
|
||||
String(storedOptions.port),
|
||||
'--daemon',
|
||||
];
|
||||
let restartCmdPrimary = restartParts.join(' ');
|
||||
let restartCmdFallback = `openchamber serve --port ${storedOptions.port} --daemon`;
|
||||
if (storedOptions.uiPassword) {
|
||||
if (isWindows) {
|
||||
const escapedPw = storedOptions.uiPassword.replace(/"/g, '""');
|
||||
restartCmdPrimary += ` --ui-password "${escapedPw}"`;
|
||||
restartCmdFallback += ` --ui-password "${escapedPw}"`;
|
||||
} else {
|
||||
const escapedPw = storedOptions.uiPassword.replace(/'/g, "'\\''");
|
||||
restartCmdPrimary += ` --ui-password '${escapedPw}'`;
|
||||
restartCmdFallback += ` --ui-password '${escapedPw}'`;
|
||||
}
|
||||
}
|
||||
const restartCmd = `(${restartCmdPrimary}) || (${restartCmdFallback})`;
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Update starting, server will restart shortly',
|
||||
version: updateInfo.version,
|
||||
packageManager: pm,
|
||||
autoRestart: true,
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
console.log(`\nInstalling update using ${pm}...`);
|
||||
console.log(`Running: ${updateCmd}`);
|
||||
|
||||
const shell = isWindows ? (process.env.ComSpec || 'cmd.exe') : 'sh';
|
||||
const shellFlag = isWindows ? '/c' : '-c';
|
||||
const script = isWindows
|
||||
? `
|
||||
timeout /t 2 /nobreak >nul
|
||||
${updateCmd}
|
||||
if %ERRORLEVEL% EQU 0 (
|
||||
echo Update successful, restarting OpenChamber...
|
||||
${restartCmd}
|
||||
) else (
|
||||
echo Update failed
|
||||
exit /b 1
|
||||
)
|
||||
`
|
||||
: `
|
||||
sleep 2
|
||||
${updateCmd}
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "Update successful, restarting OpenChamber..."
|
||||
${restartCmd}
|
||||
else
|
||||
echo "Update failed"
|
||||
exit 1
|
||||
fi
|
||||
`;
|
||||
|
||||
const updateLogPath = path.join(openchamberDataDir, 'update-install.log');
|
||||
let logFd = null;
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(updateLogPath), { recursive: true });
|
||||
logFd = fs.openSync(updateLogPath, 'a');
|
||||
} catch (logError) {
|
||||
console.warn('Failed to open update log file, continuing without log capture:', logError);
|
||||
}
|
||||
|
||||
const child = spawnChild(shell, [shellFlag, script], {
|
||||
detached: true,
|
||||
stdio: logFd !== null ? ['ignore', logFd, logFd] : 'ignore',
|
||||
env: process.env,
|
||||
});
|
||||
child.unref();
|
||||
|
||||
if (logFd !== null) {
|
||||
try {
|
||||
fs.closeSync(logFd);
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Update process spawned, shutting down server...');
|
||||
|
||||
setTimeout(() => {
|
||||
process.exit(0);
|
||||
}, 500);
|
||||
}, 500);
|
||||
} catch (error) {
|
||||
console.error('Failed to install update:', error);
|
||||
res.status(500).json({
|
||||
error: error instanceof Error ? error.message : 'Failed to install update',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/openchamber/models-metadata', async (_req, res) => {
|
||||
const now = Date.now();
|
||||
|
||||
if (cachedModelsMetadata && now - cachedModelsMetadataTimestamp < modelsMetadataCacheTtl) {
|
||||
res.setHeader('Cache-Control', 'public, max-age=60');
|
||||
return res.json(cachedModelsMetadata);
|
||||
}
|
||||
|
||||
const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
|
||||
const timeout = controller ? setTimeout(() => controller.abort(), 8000) : null;
|
||||
|
||||
try {
|
||||
const response = await fetch(modelsDevApiUrl, {
|
||||
signal: controller?.signal,
|
||||
headers: {
|
||||
Accept: 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`models.dev responded with status ${response.status}`);
|
||||
}
|
||||
|
||||
const metadata = await response.json();
|
||||
cachedModelsMetadata = metadata;
|
||||
cachedModelsMetadataTimestamp = Date.now();
|
||||
|
||||
res.setHeader('Cache-Control', 'public, max-age=300');
|
||||
res.json(metadata);
|
||||
} catch (error) {
|
||||
console.warn('Failed to fetch models.dev metadata via server:', error);
|
||||
|
||||
if (cachedModelsMetadata) {
|
||||
res.setHeader('Cache-Control', 'public, max-age=60');
|
||||
res.json(cachedModelsMetadata);
|
||||
} else {
|
||||
const statusCode = error?.name === 'AbortError' ? 504 : 502;
|
||||
res.status(statusCode).json({ error: 'Failed to retrieve model metadata' });
|
||||
}
|
||||
} finally {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/zen/models', async (_req, res) => {
|
||||
try {
|
||||
const models = await fetchFreeZenModels();
|
||||
res.setHeader('Cache-Control', 'public, max-age=300');
|
||||
res.json({ models });
|
||||
} catch (error) {
|
||||
console.warn('Failed to fetch zen models:', error);
|
||||
const cachedZenModels = getCachedZenModels();
|
||||
if (cachedZenModels) {
|
||||
res.setHeader('Cache-Control', 'public, max-age=60');
|
||||
res.json(cachedZenModels);
|
||||
} else {
|
||||
const statusCode = error?.name === 'AbortError' ? 504 : 502;
|
||||
res.status(statusCode).json({ error: 'Failed to retrieve zen models' });
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
export const createOpenCodeResolutionRuntime = (dependencies) => {
|
||||
const {
|
||||
path,
|
||||
resolveOpencodeCliPath,
|
||||
applyOpencodeBinaryFromSettings,
|
||||
ensureOpencodeCliEnv,
|
||||
opencodeShimInterpreter,
|
||||
getResolvedState,
|
||||
setResolvedOpencodeBinarySource,
|
||||
} = dependencies;
|
||||
|
||||
const getOpenCodeResolutionSnapshot = async (settings) => {
|
||||
const configured = typeof settings?.opencodeBinary === 'string' ? settings.opencodeBinary : null;
|
||||
|
||||
const { resolvedOpencodeBinarySource: previousSource } = getResolvedState();
|
||||
const detectedNow = resolveOpencodeCliPath();
|
||||
const { resolvedOpencodeBinarySource: rawDetectedSourceNow } = getResolvedState();
|
||||
setResolvedOpencodeBinarySource(previousSource);
|
||||
|
||||
await applyOpencodeBinaryFromSettings();
|
||||
ensureOpencodeCliEnv();
|
||||
|
||||
const {
|
||||
resolvedOpencodeBinary,
|
||||
resolvedOpencodeBinarySource,
|
||||
useWslForOpencode,
|
||||
resolvedWslBinary,
|
||||
resolvedWslOpencodePath,
|
||||
resolvedWslDistro,
|
||||
resolvedNodeBinary,
|
||||
resolvedBunBinary,
|
||||
} = getResolvedState();
|
||||
|
||||
const resolved = resolvedOpencodeBinary || null;
|
||||
const source = resolvedOpencodeBinarySource || null;
|
||||
const detectedSourceNow =
|
||||
detectedNow &&
|
||||
resolved &&
|
||||
detectedNow === resolved &&
|
||||
rawDetectedSourceNow === 'env' &&
|
||||
source &&
|
||||
source !== 'env'
|
||||
? source
|
||||
: rawDetectedSourceNow;
|
||||
const shim = resolved ? opencodeShimInterpreter(resolved) : null;
|
||||
|
||||
return {
|
||||
configured,
|
||||
resolved,
|
||||
resolvedDir: resolved ? path.dirname(resolved) : null,
|
||||
source,
|
||||
detectedNow,
|
||||
detectedSourceNow,
|
||||
shim,
|
||||
viaWsl: useWslForOpencode,
|
||||
wslBinary: resolvedWslBinary || null,
|
||||
wslPath: resolvedWslOpencodePath || null,
|
||||
wslDistro: resolvedWslDistro || null,
|
||||
node: resolvedNodeBinary || null,
|
||||
bun: resolvedBunBinary || null,
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
getOpenCodeResolutionSnapshot,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,109 @@
|
||||
export const createProjectDirectoryRuntime = (dependencies) => {
|
||||
const {
|
||||
fsPromises,
|
||||
path,
|
||||
normalizeDirectoryPath,
|
||||
readSettingsFromDiskMigrated,
|
||||
getReadSettingsFromDiskMigrated,
|
||||
sanitizeProjects,
|
||||
} = dependencies;
|
||||
|
||||
const resolveDirectoryCandidate = (value) => {
|
||||
if (typeof value !== 'string') {
|
||||
return null;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
const normalized = normalizeDirectoryPath(trimmed);
|
||||
return path.resolve(normalized);
|
||||
};
|
||||
|
||||
const validateDirectoryPath = async (candidate) => {
|
||||
const resolved = resolveDirectoryCandidate(candidate);
|
||||
if (!resolved) {
|
||||
return { ok: false, error: 'Directory parameter is required' };
|
||||
}
|
||||
try {
|
||||
const stats = await fsPromises.stat(resolved);
|
||||
if (!stats.isDirectory()) {
|
||||
return { ok: false, error: 'Specified path is not a directory' };
|
||||
}
|
||||
return { ok: true, directory: resolved };
|
||||
} catch (error) {
|
||||
const err = error;
|
||||
if (err && typeof err === 'object' && err.code === 'ENOENT') {
|
||||
return { ok: false, error: 'Directory not found' };
|
||||
}
|
||||
if (err && typeof err === 'object' && err.code === 'EACCES') {
|
||||
return { ok: false, error: 'Access to directory denied' };
|
||||
}
|
||||
return { ok: false, error: 'Failed to validate directory' };
|
||||
}
|
||||
};
|
||||
|
||||
const resolveProjectDirectory = async (req) => {
|
||||
const headerDirectory = typeof req.get === 'function' ? req.get('x-opencode-directory') : null;
|
||||
const queryDirectory = Array.isArray(req.query?.directory)
|
||||
? req.query.directory[0]
|
||||
: req.query?.directory;
|
||||
const requested = headerDirectory || queryDirectory || null;
|
||||
|
||||
if (requested) {
|
||||
const validated = await validateDirectoryPath(requested);
|
||||
if (!validated.ok) {
|
||||
return { directory: null, error: validated.error };
|
||||
}
|
||||
return { directory: validated.directory, error: null };
|
||||
}
|
||||
|
||||
const readSettings = typeof getReadSettingsFromDiskMigrated === 'function'
|
||||
? getReadSettingsFromDiskMigrated()
|
||||
: readSettingsFromDiskMigrated;
|
||||
const settings = await readSettings();
|
||||
const projects = sanitizeProjects(settings.projects) || [];
|
||||
if (projects.length === 0) {
|
||||
return { directory: null, error: 'Directory parameter or active project is required' };
|
||||
}
|
||||
|
||||
const activeId = typeof settings.activeProjectId === 'string' ? settings.activeProjectId : '';
|
||||
const active = projects.find((project) => project.id === activeId) || projects[0];
|
||||
if (!active || !active.path) {
|
||||
return { directory: null, error: 'Directory parameter or active project is required' };
|
||||
}
|
||||
|
||||
const validated = await validateDirectoryPath(active.path);
|
||||
if (!validated.ok) {
|
||||
return { directory: null, error: validated.error };
|
||||
}
|
||||
|
||||
return { directory: validated.directory, error: null };
|
||||
};
|
||||
|
||||
const resolveOptionalProjectDirectory = async (req) => {
|
||||
const headerDirectory = typeof req.get === 'function' ? req.get('x-opencode-directory') : null;
|
||||
const queryDirectory = Array.isArray(req.query?.directory)
|
||||
? req.query.directory[0]
|
||||
: req.query?.directory;
|
||||
const requested = headerDirectory || queryDirectory || null;
|
||||
|
||||
if (!requested) {
|
||||
return { directory: null, error: null };
|
||||
}
|
||||
|
||||
const validated = await validateDirectoryPath(requested);
|
||||
if (!validated.ok) {
|
||||
return { directory: null, error: validated.error };
|
||||
}
|
||||
|
||||
return { directory: validated.directory, error: null };
|
||||
};
|
||||
|
||||
return {
|
||||
resolveDirectoryCandidate,
|
||||
validateDirectoryPath,
|
||||
resolveProjectDirectory,
|
||||
resolveOptionalProjectDirectory,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,397 @@
|
||||
export const registerProjectIconRoutes = (app, dependencies) => {
|
||||
const {
|
||||
fsPromises,
|
||||
path,
|
||||
crypto,
|
||||
openchamberDataDir,
|
||||
sanitizeProjects,
|
||||
readSettingsFromDiskMigrated,
|
||||
persistSettings,
|
||||
createFsSearchRuntime,
|
||||
spawn,
|
||||
resolveGitBinaryForSpawn,
|
||||
} = dependencies;
|
||||
|
||||
const projectIconsDirPath = path.join(openchamberDataDir, 'project-icons');
|
||||
const projectIconMimeToExtension = {
|
||||
'image/png': 'png',
|
||||
'image/jpeg': 'jpg',
|
||||
'image/svg+xml': 'svg',
|
||||
'image/webp': 'webp',
|
||||
'image/x-icon': 'ico',
|
||||
};
|
||||
const projectIconExtensionToMime = Object.fromEntries(
|
||||
Object.entries(projectIconMimeToExtension).map(([mime, ext]) => [ext, mime])
|
||||
);
|
||||
const projectIconSupportedMimes = new Set(Object.keys(projectIconMimeToExtension));
|
||||
const projectIconMaxBytes = 5 * 1024 * 1024;
|
||||
const projectIconThemeColors = {
|
||||
light: '#111111',
|
||||
dark: '#f5f5f5',
|
||||
};
|
||||
const projectIconHexColorPattern = /^#(?:[\da-fA-F]{3}|[\da-fA-F]{4}|[\da-fA-F]{6}|[\da-fA-F]{8})$/;
|
||||
|
||||
const normalizeProjectIconMime = (value) => {
|
||||
if (typeof value !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (normalized === 'image/jpg') {
|
||||
return 'image/jpeg';
|
||||
}
|
||||
if (projectIconSupportedMimes.has(normalized)) {
|
||||
return normalized;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const projectIconBaseName = (projectId) => {
|
||||
const hash = crypto.createHash('sha1').update(projectId).digest('hex');
|
||||
return `project-${hash}`;
|
||||
};
|
||||
|
||||
const projectIconPathForMime = (projectId, mime) => {
|
||||
const normalizedMime = normalizeProjectIconMime(mime);
|
||||
if (!normalizedMime) {
|
||||
return null;
|
||||
}
|
||||
const ext = projectIconMimeToExtension[normalizedMime];
|
||||
return path.join(projectIconsDirPath, `${projectIconBaseName(projectId)}.${ext}`);
|
||||
};
|
||||
|
||||
const projectIconPathCandidates = (projectId) => {
|
||||
const base = projectIconBaseName(projectId);
|
||||
return Object.values(projectIconMimeToExtension).map((ext) => path.join(projectIconsDirPath, `${base}.${ext}`));
|
||||
};
|
||||
|
||||
const removeProjectIconFiles = async (projectId, keepPath) => {
|
||||
const candidates = projectIconPathCandidates(projectId);
|
||||
await Promise.all(candidates.map(async (candidatePath) => {
|
||||
if (keepPath && candidatePath === keepPath) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await fsPromises.unlink(candidatePath);
|
||||
} catch (error) {
|
||||
if (!error || typeof error !== 'object' || error.code !== 'ENOENT') {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}));
|
||||
};
|
||||
|
||||
const parseProjectIconDataUrl = (value) => {
|
||||
if (typeof value !== 'string') {
|
||||
return { ok: false, error: 'dataUrl is required' };
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
const match = trimmed.match(/^data:([^;,]+);base64,([A-Za-z0-9+/=\s]+)$/i);
|
||||
if (!match) {
|
||||
return { ok: false, error: 'Invalid dataUrl format' };
|
||||
}
|
||||
|
||||
const mime = normalizeProjectIconMime(match[1]);
|
||||
if (!mime || !['image/png', 'image/jpeg', 'image/svg+xml'].includes(mime)) {
|
||||
return { ok: false, error: 'Icon must be PNG, JPEG, or SVG' };
|
||||
}
|
||||
|
||||
try {
|
||||
const base64 = match[2].replace(/\s+/g, '');
|
||||
const bytes = Buffer.from(base64, 'base64');
|
||||
if (bytes.length === 0) {
|
||||
return { ok: false, error: 'Icon content is empty' };
|
||||
}
|
||||
if (bytes.length > projectIconMaxBytes) {
|
||||
return { ok: false, error: 'Icon exceeds size limit (5 MB)' };
|
||||
}
|
||||
return { ok: true, mime, bytes };
|
||||
} catch {
|
||||
return { ok: false, error: 'Failed to decode icon data' };
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeProjectIconThemeVariant = (value) => {
|
||||
if (typeof value !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (normalized === 'light' || normalized === 'dark') {
|
||||
return normalized;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const normalizeProjectIconColor = (value) => {
|
||||
if (typeof value !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalized = value.trim();
|
||||
if (!projectIconHexColorPattern.test(normalized)) {
|
||||
return null;
|
||||
}
|
||||
return normalized;
|
||||
};
|
||||
|
||||
const applyProjectIconSvgTheme = (svgMarkup, themeVariant, iconColor) => {
|
||||
if (typeof svgMarkup !== 'string') {
|
||||
return svgMarkup;
|
||||
}
|
||||
|
||||
const color = iconColor || projectIconThemeColors[themeVariant];
|
||||
if (!color) {
|
||||
return svgMarkup;
|
||||
}
|
||||
|
||||
const svgTagIndex = svgMarkup.search(/<svg\b/i);
|
||||
if (svgTagIndex === -1) {
|
||||
return svgMarkup;
|
||||
}
|
||||
|
||||
const svgOpenTagEndIndex = svgMarkup.indexOf('>', svgTagIndex);
|
||||
if (svgOpenTagEndIndex === -1) {
|
||||
return svgMarkup;
|
||||
}
|
||||
|
||||
const overrideStyle = `<style data-openchamber-theme-icon="1">:root{color:${color}!important;}</style>`;
|
||||
return `${svgMarkup.slice(0, svgOpenTagEndIndex + 1)}${overrideStyle}${svgMarkup.slice(svgOpenTagEndIndex + 1)}`;
|
||||
};
|
||||
|
||||
const findProjectById = (settings, projectId) => {
|
||||
const projects = sanitizeProjects(settings?.projects) || [];
|
||||
const index = projects.findIndex((project) => project.id === projectId);
|
||||
if (index === -1) {
|
||||
return { projects, index: -1, project: null };
|
||||
}
|
||||
return { projects, index, project: projects[index] };
|
||||
};
|
||||
|
||||
const fsSearchRuntime = createFsSearchRuntime({
|
||||
fsPromises,
|
||||
path,
|
||||
spawn,
|
||||
resolveGitBinaryForSpawn,
|
||||
});
|
||||
|
||||
app.get('/api/projects/:projectId/icon', async (req, res) => {
|
||||
const projectId = typeof req.params.projectId === 'string' ? req.params.projectId.trim() : '';
|
||||
if (!projectId) {
|
||||
return res.status(400).json({ error: 'projectId is required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const settings = await readSettingsFromDiskMigrated();
|
||||
const { project } = findProjectById(settings, projectId);
|
||||
if (!project) {
|
||||
return res.status(404).json({ error: 'Project not found' });
|
||||
}
|
||||
|
||||
const metadataMime = normalizeProjectIconMime(project.iconImage?.mime);
|
||||
const preferredPath = metadataMime ? projectIconPathForMime(projectId, metadataMime) : null;
|
||||
const candidates = preferredPath
|
||||
? [preferredPath, ...projectIconPathCandidates(projectId).filter((candidate) => candidate !== preferredPath)]
|
||||
: projectIconPathCandidates(projectId);
|
||||
|
||||
const themeQuery = Array.isArray(req.query?.theme) ? req.query.theme[0] : req.query?.theme;
|
||||
const requestedThemeVariant = normalizeProjectIconThemeVariant(themeQuery);
|
||||
const iconColorQuery = Array.isArray(req.query?.iconColor) ? req.query.iconColor[0] : req.query?.iconColor;
|
||||
const requestedIconColor = normalizeProjectIconColor(iconColorQuery);
|
||||
|
||||
for (const iconPath of candidates) {
|
||||
try {
|
||||
const data = await fsPromises.readFile(iconPath);
|
||||
const ext = path.extname(iconPath).slice(1).toLowerCase();
|
||||
const resolvedMime = metadataMime || projectIconExtensionToMime[ext] || 'application/octet-stream';
|
||||
const contentType = resolvedMime === 'image/svg+xml' ? 'image/svg+xml; charset=utf-8' : resolvedMime;
|
||||
|
||||
if (resolvedMime === 'image/svg+xml' && requestedThemeVariant) {
|
||||
const svgMarkup = data.toString('utf8');
|
||||
const themedSvgMarkup = applyProjectIconSvgTheme(svgMarkup, requestedThemeVariant, requestedIconColor);
|
||||
res.setHeader('Content-Type', contentType);
|
||||
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
|
||||
return res.send(themedSvgMarkup);
|
||||
}
|
||||
|
||||
if (resolvedMime === 'image/svg+xml' && requestedIconColor) {
|
||||
const svgMarkup = data.toString('utf8');
|
||||
const themedSvgMarkup = applyProjectIconSvgTheme(svgMarkup, requestedThemeVariant, requestedIconColor);
|
||||
res.setHeader('Content-Type', contentType);
|
||||
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
|
||||
return res.send(themedSvgMarkup);
|
||||
}
|
||||
|
||||
res.setHeader('Content-Type', contentType);
|
||||
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
|
||||
return res.send(data);
|
||||
} catch (error) {
|
||||
if (!error || typeof error !== 'object' || error.code !== 'ENOENT') {
|
||||
console.warn('Failed to read project icon:', error);
|
||||
return res.status(500).json({ error: 'Failed to read project icon' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return res.status(404).json({ error: 'Project icon not found' });
|
||||
} catch (error) {
|
||||
console.warn('Failed to load project icon:', error);
|
||||
return res.status(500).json({ error: 'Failed to load project icon' });
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/projects/:projectId/icon', async (req, res) => {
|
||||
const projectId = typeof req.params.projectId === 'string' ? req.params.projectId.trim() : '';
|
||||
if (!projectId) {
|
||||
return res.status(400).json({ error: 'projectId is required' });
|
||||
}
|
||||
|
||||
const parsed = parseProjectIconDataUrl(req.body?.dataUrl);
|
||||
if (!parsed.ok) {
|
||||
return res.status(400).json({ error: parsed.error });
|
||||
}
|
||||
|
||||
try {
|
||||
const settings = await readSettingsFromDiskMigrated();
|
||||
const { projects, project } = findProjectById(settings, projectId);
|
||||
if (!project) {
|
||||
return res.status(404).json({ error: 'Project not found' });
|
||||
}
|
||||
|
||||
const iconPath = projectIconPathForMime(projectId, parsed.mime);
|
||||
if (!iconPath) {
|
||||
return res.status(400).json({ error: 'Unsupported icon format' });
|
||||
}
|
||||
|
||||
await fsPromises.mkdir(projectIconsDirPath, { recursive: true });
|
||||
await fsPromises.writeFile(iconPath, parsed.bytes);
|
||||
await removeProjectIconFiles(projectId, iconPath);
|
||||
|
||||
const updatedAt = Date.now();
|
||||
const nextProjects = projects.map((entry) => (
|
||||
entry.id === projectId
|
||||
? { ...entry, iconImage: { mime: parsed.mime, updatedAt, source: 'custom' } }
|
||||
: entry
|
||||
));
|
||||
const updatedSettings = await persistSettings({ projects: nextProjects });
|
||||
const updatedProject = (updatedSettings.projects || []).find((entry) => entry.id === projectId) || null;
|
||||
|
||||
return res.json({ project: updatedProject, settings: updatedSettings });
|
||||
} catch (error) {
|
||||
console.warn('Failed to upload project icon:', error);
|
||||
return res.status(500).json({ error: 'Failed to upload project icon' });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/projects/:projectId/icon', async (req, res) => {
|
||||
const projectId = typeof req.params.projectId === 'string' ? req.params.projectId.trim() : '';
|
||||
if (!projectId) {
|
||||
return res.status(400).json({ error: 'projectId is required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const settings = await readSettingsFromDiskMigrated();
|
||||
const { projects, project } = findProjectById(settings, projectId);
|
||||
if (!project) {
|
||||
return res.status(404).json({ error: 'Project not found' });
|
||||
}
|
||||
|
||||
await removeProjectIconFiles(projectId);
|
||||
|
||||
const nextProjects = projects.map((entry) => (
|
||||
entry.id === projectId
|
||||
? { ...entry, iconImage: null }
|
||||
: entry
|
||||
));
|
||||
const updatedSettings = await persistSettings({ projects: nextProjects });
|
||||
const updatedProject = (updatedSettings.projects || []).find((entry) => entry.id === projectId) || null;
|
||||
|
||||
return res.json({ project: updatedProject, settings: updatedSettings });
|
||||
} catch (error) {
|
||||
console.warn('Failed to remove project icon:', error);
|
||||
return res.status(500).json({ error: 'Failed to remove project icon' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/projects/:projectId/icon/discover', async (req, res) => {
|
||||
const projectId = typeof req.params.projectId === 'string' ? req.params.projectId.trim() : '';
|
||||
if (!projectId) {
|
||||
return res.status(400).json({ error: 'projectId is required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const settings = await readSettingsFromDiskMigrated();
|
||||
const { projects, project } = findProjectById(settings, projectId);
|
||||
if (!project) {
|
||||
return res.status(404).json({ error: 'Project not found' });
|
||||
}
|
||||
|
||||
const force = req.body?.force === true;
|
||||
if (project.iconImage?.source === 'custom' && !force) {
|
||||
return res.json({
|
||||
project,
|
||||
skipped: true,
|
||||
reason: 'custom-icon-present',
|
||||
});
|
||||
}
|
||||
|
||||
const faviconCandidates = await fsSearchRuntime.searchFilesystemFiles(project.path, {
|
||||
limit: 200,
|
||||
query: 'favicon',
|
||||
includeHidden: true,
|
||||
respectGitignore: false,
|
||||
});
|
||||
|
||||
const filtered = faviconCandidates
|
||||
.filter((entry) => /(^|\/)favicon\.(ico|png|svg|jpg|jpeg|webp)$/i.test(entry.path))
|
||||
.sort((a, b) => a.path.length - b.path.length);
|
||||
|
||||
const selected = filtered[0];
|
||||
if (!selected) {
|
||||
return res.status(404).json({ error: 'No favicon found in project' });
|
||||
}
|
||||
|
||||
const ext = path.extname(selected.path).slice(1).toLowerCase();
|
||||
const mime = projectIconExtensionToMime[ext] || null;
|
||||
if (!mime) {
|
||||
return res.status(415).json({ error: 'Unsupported favicon format' });
|
||||
}
|
||||
|
||||
const bytes = await fsPromises.readFile(selected.path);
|
||||
if (bytes.length === 0) {
|
||||
return res.status(400).json({ error: 'Discovered icon is empty' });
|
||||
}
|
||||
if (bytes.length > projectIconMaxBytes) {
|
||||
return res.status(400).json({ error: 'Discovered icon exceeds size limit (5 MB)' });
|
||||
}
|
||||
|
||||
const iconPath = projectIconPathForMime(projectId, mime);
|
||||
if (!iconPath) {
|
||||
return res.status(415).json({ error: 'Unsupported favicon format' });
|
||||
}
|
||||
|
||||
await fsPromises.mkdir(projectIconsDirPath, { recursive: true });
|
||||
await fsPromises.writeFile(iconPath, bytes);
|
||||
await removeProjectIconFiles(projectId, iconPath);
|
||||
|
||||
const updatedAt = Date.now();
|
||||
const nextProjects = projects.map((entry) => (
|
||||
entry.id === projectId
|
||||
? { ...entry, iconImage: { mime, updatedAt, source: 'auto' } }
|
||||
: entry
|
||||
));
|
||||
const updatedSettings = await persistSettings({ projects: nextProjects });
|
||||
const updatedProject = (updatedSettings.projects || []).find((entry) => entry.id === projectId) || null;
|
||||
|
||||
return res.json({
|
||||
project: updatedProject,
|
||||
settings: updatedSettings,
|
||||
discoveredPath: selected.path,
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn('Failed to discover project icon:', error);
|
||||
return res.status(500).json({ error: 'Failed to discover project icon' });
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,169 @@
|
||||
import express from 'express';
|
||||
import { createProxyMiddleware } from 'http-proxy-middleware';
|
||||
|
||||
export const registerOpenCodeProxy = (app, deps) => {
|
||||
const {
|
||||
fs,
|
||||
os,
|
||||
path,
|
||||
OPEN_CODE_READY_GRACE_MS,
|
||||
getRuntime,
|
||||
getOpenCodeAuthHeaders,
|
||||
buildOpenCodeUrl,
|
||||
ensureOpenCodeApiPrefix,
|
||||
} = deps;
|
||||
|
||||
if (app.get('opencodeProxyConfigured')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const runtime = getRuntime();
|
||||
if (runtime.openCodePort) {
|
||||
console.log(`Setting up proxy to OpenCode on port ${runtime.openCodePort}`);
|
||||
} else {
|
||||
console.log('Setting up OpenCode API gate (OpenCode not started yet)');
|
||||
}
|
||||
app.set('opencodeProxyConfigured', true);
|
||||
|
||||
// Ensure API prefix is detected before proxying
|
||||
app.use('/api', (_req, _res, next) => {
|
||||
ensureOpenCodeApiPrefix();
|
||||
next();
|
||||
});
|
||||
|
||||
// Readiness gate — return 503 while OpenCode is starting/restarting
|
||||
app.use('/api', (req, res, next) => {
|
||||
if (
|
||||
req.path.startsWith('/themes/custom') ||
|
||||
req.path.startsWith('/push') ||
|
||||
req.path.startsWith('/config/agents') ||
|
||||
req.path.startsWith('/config/opencode-resolution') ||
|
||||
req.path.startsWith('/config/settings') ||
|
||||
req.path.startsWith('/config/skills') ||
|
||||
req.path === '/config/reload' ||
|
||||
req.path === '/health'
|
||||
) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const runtimeState = getRuntime();
|
||||
const waitElapsed = runtimeState.openCodeNotReadySince === 0 ? 0 : Date.now() - runtimeState.openCodeNotReadySince;
|
||||
const stillWaiting =
|
||||
(!runtimeState.isOpenCodeReady && (runtimeState.openCodeNotReadySince === 0 || waitElapsed < OPEN_CODE_READY_GRACE_MS)) ||
|
||||
runtimeState.isRestartingOpenCode ||
|
||||
!runtimeState.openCodePort;
|
||||
|
||||
if (stillWaiting) {
|
||||
return res.status(503).json({
|
||||
error: 'OpenCode is restarting',
|
||||
restarting: true,
|
||||
});
|
||||
}
|
||||
|
||||
next();
|
||||
});
|
||||
|
||||
// Windows: session merge for cross-directory session listing
|
||||
if (process.platform === 'win32') {
|
||||
app.get('/api/session', async (req, res, next) => {
|
||||
const rawUrl = req.originalUrl || req.url || '';
|
||||
if (rawUrl.includes('directory=')) return next();
|
||||
|
||||
try {
|
||||
const authHeaders = getOpenCodeAuthHeaders();
|
||||
const fetchOpts = {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json', ...authHeaders },
|
||||
signal: AbortSignal.timeout(10000),
|
||||
};
|
||||
const globalRes = await fetch(buildOpenCodeUrl('/session', ''), fetchOpts);
|
||||
const globalPayload = globalRes.ok ? await globalRes.json().catch(() => []) : [];
|
||||
const globalSessions = Array.isArray(globalPayload) ? globalPayload : [];
|
||||
|
||||
const settingsPath = path.join(os.homedir(), '.config', 'openchamber', 'settings.json');
|
||||
let projectDirs = [];
|
||||
try {
|
||||
const settingsRaw = fs.readFileSync(settingsPath, 'utf8');
|
||||
const settings = JSON.parse(settingsRaw);
|
||||
projectDirs = (settings.projects || [])
|
||||
.map((project) => (typeof project?.path === 'string' ? project.path.trim() : ''))
|
||||
.filter(Boolean);
|
||||
} catch {
|
||||
}
|
||||
|
||||
const seen = new Set(
|
||||
globalSessions
|
||||
.map((session) => (session && typeof session.id === 'string' ? session.id : null))
|
||||
.filter((id) => typeof id === 'string')
|
||||
);
|
||||
const extraSessions = [];
|
||||
for (const dir of projectDirs) {
|
||||
const candidates = Array.from(new Set([
|
||||
dir,
|
||||
dir.replace(/\\/g, '/'),
|
||||
dir.replace(/\//g, '\\'),
|
||||
]));
|
||||
for (const candidateDir of candidates) {
|
||||
const encoded = encodeURIComponent(candidateDir);
|
||||
try {
|
||||
const dirRes = await fetch(buildOpenCodeUrl(`/session?directory=${encoded}`, ''), fetchOpts);
|
||||
if (dirRes.ok) {
|
||||
const dirPayload = await dirRes.json().catch(() => []);
|
||||
const dirSessions = Array.isArray(dirPayload) ? dirPayload : [];
|
||||
for (const session of dirSessions) {
|
||||
const id = session && typeof session.id === 'string' ? session.id : null;
|
||||
if (id && !seen.has(id)) {
|
||||
seen.add(id);
|
||||
extraSessions.push(session);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const merged = [...globalSessions, ...extraSessions];
|
||||
merged.sort((a, b) => {
|
||||
const aTime = a && typeof a.time_updated === 'number' ? a.time_updated : 0;
|
||||
const bTime = b && typeof b.time_updated === 'number' ? b.time_updated : 0;
|
||||
return bTime - aTime;
|
||||
});
|
||||
console.log(`[SessionMerge] ${globalSessions.length} global + ${extraSessions.length} extra = ${merged.length} total`);
|
||||
return res.json(merged);
|
||||
} catch (error) {
|
||||
console.log(`[SessionMerge] Error: ${error.message}, falling through`);
|
||||
next();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// http-proxy-middleware handles SSE, large bodies, timeouts correctly
|
||||
const apiProxy = createProxyMiddleware({
|
||||
target: `http://127.0.0.1:${runtime.openCodePort || 3902}`,
|
||||
changeOrigin: true,
|
||||
pathRewrite: { '^/api': '' },
|
||||
// Dynamic target — port can change after restart
|
||||
router: () => {
|
||||
const rt = getRuntime();
|
||||
return `http://127.0.0.1:${rt.openCodePort || 3902}`;
|
||||
},
|
||||
on: {
|
||||
proxyReq: (proxyReq) => {
|
||||
// Inject OpenCode auth headers
|
||||
const authHeaders = getOpenCodeAuthHeaders();
|
||||
if (authHeaders.Authorization) {
|
||||
proxyReq.setHeader('Authorization', authHeaders.Authorization);
|
||||
}
|
||||
},
|
||||
error: (err, _req, res) => {
|
||||
console.error('[proxy] OpenCode proxy error:', err.message);
|
||||
if (res && !res.headersSent && typeof res.status === 'function') {
|
||||
res.status(503).json({ error: 'OpenCode service unavailable' });
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
app.use('/api', apiProxy);
|
||||
};
|
||||
@@ -0,0 +1,238 @@
|
||||
const DEFAULT_PWA_APP_NAME = 'OpenChamber - AI Coding Assistant';
|
||||
|
||||
export const registerPwaManifestRoute = (app, dependencies) => {
|
||||
const {
|
||||
process,
|
||||
resolveProjectDirectory,
|
||||
buildOpenCodeUrl,
|
||||
getOpenCodeAuthHeaders,
|
||||
readSettingsFromDiskMigrated,
|
||||
normalizePwaAppName,
|
||||
} = dependencies;
|
||||
|
||||
const recentPwaSessionsCache = new Map();
|
||||
|
||||
const getRecentPwaSessionShortcuts = async (req) => {
|
||||
const now = Date.now();
|
||||
|
||||
const resolvedDirectoryResult = await resolveProjectDirectory(req).catch(() => ({ directory: null }));
|
||||
const preferredDirectory = typeof resolvedDirectoryResult?.directory === 'string'
|
||||
? resolvedDirectoryResult.directory
|
||||
: null;
|
||||
|
||||
const cacheKey = preferredDirectory ? `dir:${preferredDirectory}` : 'global';
|
||||
const cached = recentPwaSessionsCache.get(cacheKey);
|
||||
if (cached && now - cached.at < 5000) {
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
const normalizeShortcutTitle = (value, fallback) => {
|
||||
const normalized = normalizePwaAppName(value, fallback);
|
||||
return normalized.length > 48 ? normalized.slice(0, 48) : normalized;
|
||||
};
|
||||
|
||||
const toFiniteNumber = (value) => {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'string' && value.trim().length > 0) {
|
||||
const parsed = Number(value);
|
||||
if (Number.isFinite(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const normalizeDirectory = (value) => {
|
||||
if (typeof value !== 'string') {
|
||||
return '';
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return '';
|
||||
}
|
||||
const normalized = trimmed.replace(/\\/g, '/');
|
||||
if (normalized === '/') {
|
||||
return '/';
|
||||
}
|
||||
return normalized.length > 1 ? normalized.replace(/\/+$/, '') : normalized;
|
||||
};
|
||||
|
||||
const sessionUpdatedAt = (session) => {
|
||||
const time = session && typeof session.time === 'object' ? session.time : null;
|
||||
return toFiniteNumber(time?.updated) ?? toFiniteNumber(time?.created) ?? 0;
|
||||
};
|
||||
|
||||
const filterSessionsByDirectory = (sessions, directory) => {
|
||||
const normalizedDirectory = normalizeDirectory(directory);
|
||||
if (!normalizedDirectory) {
|
||||
return sessions;
|
||||
}
|
||||
|
||||
const prefix = normalizedDirectory === '/' ? '/' : `${normalizedDirectory}/`;
|
||||
return sessions.filter((session) => {
|
||||
const sessionDirectory = normalizeDirectory(session?.directory);
|
||||
if (!sessionDirectory) {
|
||||
return false;
|
||||
}
|
||||
return sessionDirectory === normalizedDirectory || (prefix !== '/' && sessionDirectory.startsWith(prefix));
|
||||
});
|
||||
};
|
||||
|
||||
const listSessions = async (directory) => {
|
||||
const query = (() => {
|
||||
if (typeof directory !== 'string' || directory.length === 0) {
|
||||
return '';
|
||||
}
|
||||
const preparedDirectory = process.platform === 'win32'
|
||||
? directory.replace(/\//g, '\\\\')
|
||||
: directory;
|
||||
return `?directory=${encodeURIComponent(preparedDirectory)}`;
|
||||
})();
|
||||
|
||||
const response = await fetch(buildOpenCodeUrl(`/session${query}`, ''), {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
...getOpenCodeAuthHeaders(),
|
||||
},
|
||||
signal: AbortSignal.timeout(2500),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const payload = await response.json().catch(() => null);
|
||||
return Array.isArray(payload) ? payload : [];
|
||||
};
|
||||
|
||||
try {
|
||||
let payload = [];
|
||||
|
||||
if (preferredDirectory) {
|
||||
const scopedPayload = await listSessions(preferredDirectory);
|
||||
const filteredScopedPayload = filterSessionsByDirectory(scopedPayload, preferredDirectory);
|
||||
|
||||
if (filteredScopedPayload.length > 0) {
|
||||
payload = filteredScopedPayload;
|
||||
} else {
|
||||
const globalPayload = await listSessions(null);
|
||||
const filteredGlobalPayload = filterSessionsByDirectory(globalPayload, preferredDirectory);
|
||||
payload = filteredGlobalPayload.length > 0 ? filteredGlobalPayload : globalPayload;
|
||||
}
|
||||
} else {
|
||||
payload = await listSessions(null);
|
||||
}
|
||||
|
||||
const seen = new Set();
|
||||
const rows = [];
|
||||
|
||||
for (const item of payload) {
|
||||
if (!item || typeof item !== 'object') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const id = typeof item.id === 'string' ? item.id.trim().slice(0, 160) : '';
|
||||
if (!id || seen.has(id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seen.add(id);
|
||||
const title = normalizeShortcutTitle(item.title, `Session ${rows.length + 1}`);
|
||||
const updatedAt = sessionUpdatedAt(item);
|
||||
|
||||
rows.push({ id, title, updatedAt });
|
||||
}
|
||||
|
||||
rows.sort((a, b) => b.updatedAt - a.updatedAt);
|
||||
|
||||
const shortcuts = rows.slice(0, 3).map((session) => ({
|
||||
name: session.title,
|
||||
short_name: session.title.length > 32 ? session.title.slice(0, 32) : session.title,
|
||||
description: 'Open recent session',
|
||||
url: `/?session=${encodeURIComponent(session.id)}`,
|
||||
icons: [{ src: '/pwa-192.png', sizes: '192x192', type: 'image/png' }],
|
||||
}));
|
||||
|
||||
recentPwaSessionsCache.set(cacheKey, { at: now, data: shortcuts });
|
||||
return shortcuts;
|
||||
} catch {
|
||||
recentPwaSessionsCache.set(cacheKey, { at: now, data: [] });
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
app.get('/manifest.webmanifest', async (req, res) => {
|
||||
const hasQueryOverride =
|
||||
typeof req.query?.pwa_name === 'string'
|
||||
|| typeof req.query?.app_name === 'string'
|
||||
|| typeof req.query?.appName === 'string';
|
||||
|
||||
let queryValueRaw = '';
|
||||
if (typeof req.query?.pwa_name === 'string') {
|
||||
queryValueRaw = req.query.pwa_name;
|
||||
} else if (typeof req.query?.app_name === 'string') {
|
||||
queryValueRaw = req.query.app_name;
|
||||
} else if (typeof req.query?.appName === 'string') {
|
||||
queryValueRaw = req.query.appName;
|
||||
}
|
||||
|
||||
const queryOverrideName = normalizePwaAppName(queryValueRaw, '');
|
||||
|
||||
let storedName = '';
|
||||
try {
|
||||
const settings = await readSettingsFromDiskMigrated();
|
||||
storedName = normalizePwaAppName(settings?.pwaAppName, '');
|
||||
} catch {
|
||||
storedName = '';
|
||||
}
|
||||
|
||||
const appName = hasQueryOverride
|
||||
? (queryOverrideName || DEFAULT_PWA_APP_NAME)
|
||||
: (storedName || DEFAULT_PWA_APP_NAME);
|
||||
|
||||
const shortName = appName.length > 30 ? appName.slice(0, 30) : appName;
|
||||
const recentSessionShortcuts = await getRecentPwaSessionShortcuts(req);
|
||||
|
||||
const manifest = {
|
||||
name: appName,
|
||||
short_name: shortName,
|
||||
description: 'Web interface companion for OpenCode AI coding agent',
|
||||
id: '/',
|
||||
start_url: '/',
|
||||
scope: '/',
|
||||
display: 'standalone',
|
||||
background_color: '#151313',
|
||||
theme_color: '#edb449',
|
||||
orientation: 'any',
|
||||
icons: [
|
||||
{ src: '/pwa-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
|
||||
{ src: '/pwa-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
|
||||
{ src: '/pwa-maskable-192.png', sizes: '192x192', type: 'image/png', purpose: 'any maskable' },
|
||||
{ src: '/pwa-maskable-512.png', sizes: '512x512', type: 'image/png', purpose: 'any maskable' },
|
||||
{ src: '/apple-touch-icon-180x180.png', sizes: '180x180', type: 'image/png', purpose: 'any' },
|
||||
{ src: '/apple-touch-icon-152x152.png', sizes: '152x152', type: 'image/png', purpose: 'any' },
|
||||
{ src: '/favicon-32.png', sizes: '32x32', type: 'image/png' },
|
||||
{ src: '/favicon-16.png', sizes: '16x16', type: 'image/png' },
|
||||
],
|
||||
shortcuts: [
|
||||
{
|
||||
name: 'Appearance Settings',
|
||||
short_name: 'Settings',
|
||||
description: 'Open appearance settings',
|
||||
url: '/?settings=appearance',
|
||||
icons: [{ src: '/pwa-192.png', sizes: '192x192', type: 'image/png' }],
|
||||
},
|
||||
...recentSessionShortcuts,
|
||||
],
|
||||
categories: ['developer', 'tools', 'productivity'],
|
||||
lang: 'en',
|
||||
};
|
||||
|
||||
res.setHeader('Cache-Control', 'no-store, must-revalidate');
|
||||
res.type('application/manifest+json');
|
||||
res.send(JSON.stringify(manifest));
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,206 @@
|
||||
export const registerOpenCodeRoutes = (app, dependencies) => {
|
||||
const {
|
||||
crypto,
|
||||
clientReloadDelayMs,
|
||||
getOpenCodeResolutionSnapshot,
|
||||
formatSettingsResponse,
|
||||
readSettingsFromDisk,
|
||||
readSettingsFromDiskMigrated,
|
||||
persistSettings,
|
||||
sanitizeProjects,
|
||||
validateDirectoryPath,
|
||||
resolveProjectDirectory,
|
||||
getProviderSources,
|
||||
removeProviderConfig,
|
||||
refreshOpenCodeAfterConfigChange,
|
||||
} = dependencies;
|
||||
|
||||
let authLibrary = null;
|
||||
const getAuthLibrary = async () => {
|
||||
if (!authLibrary) {
|
||||
authLibrary = await import('./auth.js');
|
||||
}
|
||||
return authLibrary;
|
||||
};
|
||||
|
||||
app.get('/api/config/settings', async (_req, res) => {
|
||||
try {
|
||||
const settings = await readSettingsFromDiskMigrated();
|
||||
res.json(formatSettingsResponse(settings));
|
||||
} catch (error) {
|
||||
console.error('Failed to read settings:', error);
|
||||
res.status(500).json({ error: 'Failed to read settings' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/config/opencode-resolution', async (_req, res) => {
|
||||
try {
|
||||
const settings = await readSettingsFromDiskMigrated();
|
||||
const resolution = await getOpenCodeResolutionSnapshot(settings);
|
||||
res.json(resolution);
|
||||
} catch (error) {
|
||||
console.error('Failed to resolve OpenCode binary:', error);
|
||||
res.status(500).json({ error: 'Failed to resolve OpenCode binary' });
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/config/settings', async (req, res) => {
|
||||
console.log('[API:PUT /api/config/settings] Received request');
|
||||
try {
|
||||
const updated = await persistSettings(req.body ?? {});
|
||||
console.log(`[API:PUT /api/config/settings] Success, returning ${updated.projects?.length || 0} projects`);
|
||||
res.json(updated);
|
||||
} catch (error) {
|
||||
console.error('[API:PUT /api/config/settings] Failed to save settings:', error);
|
||||
console.error('[API:PUT /api/config/settings] Error stack:', error.stack);
|
||||
res.status(500).json({ error: 'Failed to save settings' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/provider/:providerId/source', async (req, res) => {
|
||||
try {
|
||||
const { providerId } = req.params;
|
||||
if (!providerId) {
|
||||
return res.status(400).json({ error: 'Provider ID is required' });
|
||||
}
|
||||
|
||||
const headerDirectory = typeof req.get === 'function' ? req.get('x-opencode-directory') : null;
|
||||
const queryDirectory = Array.isArray(req.query?.directory)
|
||||
? req.query.directory[0]
|
||||
: req.query?.directory;
|
||||
const requestedDirectory = headerDirectory || queryDirectory || null;
|
||||
|
||||
let directory = null;
|
||||
const resolved = await resolveProjectDirectory(req);
|
||||
if (resolved.directory) {
|
||||
directory = resolved.directory;
|
||||
} else if (requestedDirectory) {
|
||||
return res.status(400).json({ error: resolved.error });
|
||||
}
|
||||
|
||||
const sources = getProviderSources(providerId, directory);
|
||||
const { getProviderAuth } = await getAuthLibrary();
|
||||
const auth = getProviderAuth(providerId);
|
||||
sources.sources.auth.exists = Boolean(auth);
|
||||
|
||||
return res.json({
|
||||
providerId,
|
||||
sources: sources.sources,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to get provider sources:', error);
|
||||
return res.status(500).json({ error: error.message || 'Failed to get provider sources' });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/provider/:providerId/auth', async (req, res) => {
|
||||
try {
|
||||
const { providerId } = req.params;
|
||||
if (!providerId) {
|
||||
return res.status(400).json({ error: 'Provider ID is required' });
|
||||
}
|
||||
|
||||
const scope = typeof req.query?.scope === 'string' ? req.query.scope : 'auth';
|
||||
const headerDirectory = typeof req.get === 'function' ? req.get('x-opencode-directory') : null;
|
||||
const queryDirectory = Array.isArray(req.query?.directory)
|
||||
? req.query.directory[0]
|
||||
: req.query?.directory;
|
||||
const requestedDirectory = headerDirectory || queryDirectory || null;
|
||||
let directory = null;
|
||||
|
||||
if (scope === 'project' || requestedDirectory) {
|
||||
const resolved = await resolveProjectDirectory(req);
|
||||
if (!resolved.directory) {
|
||||
return res.status(400).json({ error: resolved.error });
|
||||
}
|
||||
directory = resolved.directory;
|
||||
} else {
|
||||
const resolved = await resolveProjectDirectory(req);
|
||||
if (resolved.directory) {
|
||||
directory = resolved.directory;
|
||||
}
|
||||
}
|
||||
|
||||
let removed = false;
|
||||
if (scope === 'auth') {
|
||||
const { removeProviderAuth } = await getAuthLibrary();
|
||||
removed = removeProviderAuth(providerId);
|
||||
} else if (scope === 'user' || scope === 'project' || scope === 'custom') {
|
||||
removed = removeProviderConfig(providerId, directory, scope);
|
||||
} else if (scope === 'all') {
|
||||
const { removeProviderAuth } = await getAuthLibrary();
|
||||
const authRemoved = removeProviderAuth(providerId);
|
||||
const userRemoved = removeProviderConfig(providerId, directory, 'user');
|
||||
const projectRemoved = directory ? removeProviderConfig(providerId, directory, 'project') : false;
|
||||
const customRemoved = removeProviderConfig(providerId, directory, 'custom');
|
||||
removed = authRemoved || userRemoved || projectRemoved || customRemoved;
|
||||
} else {
|
||||
return res.status(400).json({ error: 'Invalid scope' });
|
||||
}
|
||||
|
||||
if (removed) {
|
||||
await refreshOpenCodeAfterConfigChange(`provider ${providerId} disconnected (${scope})`);
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
removed,
|
||||
requiresReload: removed,
|
||||
message: removed ? 'Provider disconnected successfully' : 'Provider was not connected',
|
||||
reloadDelayMs: removed ? clientReloadDelayMs : undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to disconnect provider:', error);
|
||||
return res.status(500).json({ error: error.message || 'Failed to disconnect provider' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/opencode/directory', async (req, res) => {
|
||||
try {
|
||||
const requestedPath = typeof req.body?.path === 'string' ? req.body.path.trim() : '';
|
||||
if (!requestedPath) {
|
||||
return res.status(400).json({ error: 'Path is required' });
|
||||
}
|
||||
|
||||
const validated = await validateDirectoryPath(requestedPath);
|
||||
if (!validated.ok) {
|
||||
return res.status(400).json({ error: validated.error });
|
||||
}
|
||||
|
||||
const resolvedPath = validated.directory;
|
||||
const currentSettings = await readSettingsFromDisk();
|
||||
const existingProjects = sanitizeProjects(currentSettings.projects) || [];
|
||||
const existing = existingProjects.find((project) => project.path === resolvedPath) || null;
|
||||
|
||||
const nextProjects = existing
|
||||
? existingProjects
|
||||
: [
|
||||
...existingProjects,
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
path: resolvedPath,
|
||||
addedAt: Date.now(),
|
||||
lastOpenedAt: Date.now(),
|
||||
},
|
||||
];
|
||||
|
||||
const activeProjectId = existing ? existing.id : nextProjects[nextProjects.length - 1].id;
|
||||
|
||||
const updated = await persistSettings({
|
||||
projects: nextProjects,
|
||||
activeProjectId,
|
||||
lastDirectory: resolvedPath,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
restarted: false,
|
||||
path: resolvedPath,
|
||||
settings: updated,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to update OpenCode working directory:', error);
|
||||
return res.status(500).json({ error: error.message || 'Failed to update working directory' });
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,138 @@
|
||||
export const createServerStartupRuntime = (dependencies) => {
|
||||
const {
|
||||
process,
|
||||
crypto,
|
||||
server,
|
||||
normalizeTunnelBootstrapTtlMs,
|
||||
readSettingsFromDiskMigrated,
|
||||
tunnelAuthController,
|
||||
startTunnelWithNormalizedRequest,
|
||||
gracefulShutdown,
|
||||
getSignalsAttached,
|
||||
setSignalsAttached,
|
||||
syncToHmrState,
|
||||
TUNNEL_MODE_QUICK,
|
||||
TUNNEL_MODE_MANAGED_LOCAL,
|
||||
TUNNEL_MODE_MANAGED_REMOTE,
|
||||
} = dependencies;
|
||||
|
||||
const resolveBindHost = (host) =>
|
||||
host
|
||||
|| (typeof process.env.OPENCHAMBER_HOST === 'string' && process.env.OPENCHAMBER_HOST.trim().length > 0
|
||||
? process.env.OPENCHAMBER_HOST.trim()
|
||||
: '127.0.0.1');
|
||||
|
||||
const startListeningAndMaybeTunnel = async ({
|
||||
port,
|
||||
bindHost,
|
||||
startupTunnelRequest,
|
||||
onTunnelReady,
|
||||
}) => {
|
||||
let activePort = port;
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
const onError = (error) => {
|
||||
server.off('error', onError);
|
||||
reject(error);
|
||||
};
|
||||
server.once('error', onError);
|
||||
const onListening = async () => {
|
||||
server.off('error', onError);
|
||||
const addressInfo = server.address();
|
||||
activePort = typeof addressInfo === 'object' && addressInfo ? addressInfo.port : port;
|
||||
|
||||
try {
|
||||
process.send?.({ type: 'openchamber:ready', port: activePort });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
const displayHost = (bindHost === '0.0.0.0' || bindHost === '::' || bindHost === '[::]')
|
||||
? 'localhost'
|
||||
: (bindHost.includes(':') ? `[${bindHost}]` : bindHost);
|
||||
console.log(`OpenChamber server listening on ${bindHost}:${activePort}`);
|
||||
console.log(`Health check: http://${displayHost}:${activePort}/health`);
|
||||
console.log(`Web interface: http://${displayHost}:${activePort}`);
|
||||
|
||||
if (startupTunnelRequest) {
|
||||
const startupModeLabel = startupTunnelRequest.mode === TUNNEL_MODE_QUICK
|
||||
? 'Quick Tunnel'
|
||||
: (startupTunnelRequest.mode === TUNNEL_MODE_MANAGED_LOCAL
|
||||
? 'Managed Local Tunnel'
|
||||
: (startupTunnelRequest.mode === TUNNEL_MODE_MANAGED_REMOTE ? 'Managed Remote Tunnel' : 'Tunnel'));
|
||||
console.log(`\nInitializing ${startupModeLabel} for provider '${startupTunnelRequest.provider}'...`);
|
||||
try {
|
||||
const { publicUrl, mode } = await startTunnelWithNormalizedRequest({
|
||||
provider: startupTunnelRequest.provider,
|
||||
mode: startupTunnelRequest.mode,
|
||||
intent: startupTunnelRequest.intent,
|
||||
hostname: startupTunnelRequest.hostname,
|
||||
token: startupTunnelRequest.token,
|
||||
configPath: startupTunnelRequest.configPath,
|
||||
selectedPresetId: '',
|
||||
selectedPresetName: '',
|
||||
});
|
||||
if (publicUrl) {
|
||||
tunnelAuthController.setActiveTunnel({
|
||||
tunnelId: crypto.randomUUID(),
|
||||
publicUrl,
|
||||
mode,
|
||||
});
|
||||
const settings = await readSettingsFromDiskMigrated();
|
||||
const bootstrapTtlMs = settings?.tunnelBootstrapTtlMs === null
|
||||
? null
|
||||
: normalizeTunnelBootstrapTtlMs(settings?.tunnelBootstrapTtlMs);
|
||||
const bootstrapToken = tunnelAuthController.issueBootstrapToken({ ttlMs: bootstrapTtlMs });
|
||||
const connectUrl = `${publicUrl.replace(/\/$/, '')}/connect?t=${encodeURIComponent(bootstrapToken.token)}`;
|
||||
if (onTunnelReady) {
|
||||
onTunnelReady(publicUrl, connectUrl);
|
||||
} else {
|
||||
console.log(`\n🌐 Tunnel URL: ${connectUrl}`);
|
||||
console.log('🔑 One-time connect link (expires after first use)\n');
|
||||
}
|
||||
} else if (onTunnelReady) {
|
||||
onTunnelReady(publicUrl, null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to start tunnel: ${error.message}`);
|
||||
console.log('Continuing without tunnel...');
|
||||
}
|
||||
}
|
||||
|
||||
resolve();
|
||||
};
|
||||
|
||||
server.listen(port, bindHost, onListening);
|
||||
});
|
||||
|
||||
return { activePort };
|
||||
};
|
||||
|
||||
const attachProcessHandlers = ({ attachSignals }) => {
|
||||
if (attachSignals && !getSignalsAttached()) {
|
||||
const handleSignal = async () => {
|
||||
await gracefulShutdown();
|
||||
};
|
||||
process.on('SIGTERM', handleSignal);
|
||||
process.on('SIGINT', handleSignal);
|
||||
process.on('SIGQUIT', handleSignal);
|
||||
setSignalsAttached(true);
|
||||
syncToHmrState();
|
||||
}
|
||||
|
||||
process.on('unhandledRejection', (reason, promise) => {
|
||||
console.error('Unhandled Rejection at:', promise, 'reason:', reason);
|
||||
});
|
||||
|
||||
process.on('uncaughtException', (error) => {
|
||||
console.error('Uncaught Exception:', error);
|
||||
gracefulShutdown();
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
resolveBindHost,
|
||||
startListeningAndMaybeTunnel,
|
||||
attachProcessHandlers,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,168 @@
|
||||
import { registerOpenCodeProxy } from './proxy.js';
|
||||
|
||||
export const createServerUtilsRuntime = (dependencies) => {
|
||||
const {
|
||||
fs,
|
||||
os,
|
||||
path,
|
||||
process,
|
||||
openCodeReadyGraceMs,
|
||||
longRequestTimeoutMs,
|
||||
getRuntime,
|
||||
getOpenCodeAuthHeaders,
|
||||
buildOpenCodeUrl,
|
||||
ensureOpenCodeApiPrefix,
|
||||
getUiNotificationClients,
|
||||
getOpenCodePort,
|
||||
setOpenCodePortState,
|
||||
syncToHmrState,
|
||||
markOpenCodeNotReady,
|
||||
setOpenCodeNotReadySince,
|
||||
clearLastOpenCodeError,
|
||||
getLoginShellPath,
|
||||
} = dependencies;
|
||||
|
||||
const setOpenCodePort = (port) => {
|
||||
if (!Number.isFinite(port) || port <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const numericPort = Math.trunc(port);
|
||||
const currentPort = getOpenCodePort();
|
||||
const portChanged = currentPort !== numericPort;
|
||||
|
||||
if (portChanged || currentPort === null) {
|
||||
setOpenCodePortState(numericPort);
|
||||
syncToHmrState();
|
||||
console.log(`Detected OpenCode port: ${numericPort}`);
|
||||
|
||||
if (portChanged) {
|
||||
markOpenCodeNotReady();
|
||||
}
|
||||
setOpenCodeNotReadySince(Date.now());
|
||||
}
|
||||
|
||||
clearLastOpenCodeError();
|
||||
};
|
||||
|
||||
const waitForOpenCodePort = async (timeoutMs = 15000) => {
|
||||
if (getOpenCodePort() !== null) {
|
||||
return getOpenCodePort();
|
||||
}
|
||||
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
if (getOpenCodePort() !== null) {
|
||||
return getOpenCodePort();
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Timed out waiting for OpenCode port');
|
||||
};
|
||||
|
||||
const buildAugmentedPath = () => {
|
||||
const augmented = new Set();
|
||||
|
||||
const loginShellPath = getLoginShellPath();
|
||||
if (loginShellPath) {
|
||||
for (const segment of loginShellPath.split(path.delimiter)) {
|
||||
if (segment) {
|
||||
augmented.add(segment);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const current = (process.env.PATH || '').split(path.delimiter).filter(Boolean);
|
||||
for (const segment of current) {
|
||||
augmented.add(segment);
|
||||
}
|
||||
|
||||
return Array.from(augmented).join(path.delimiter);
|
||||
};
|
||||
|
||||
const parseSseDataPayload = (block) => {
|
||||
if (!block || typeof block !== 'string') {
|
||||
return null;
|
||||
}
|
||||
const dataLines = block
|
||||
.split('\n')
|
||||
.filter((line) => line.startsWith('data:'))
|
||||
.map((line) => line.slice(5).replace(/^\s/, ''));
|
||||
|
||||
if (dataLines.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const payloadText = dataLines.join('\n').trim();
|
||||
if (!payloadText) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(payloadText);
|
||||
if (
|
||||
parsed &&
|
||||
typeof parsed === 'object' &&
|
||||
typeof parsed.payload === 'object' &&
|
||||
parsed.payload !== null
|
||||
) {
|
||||
return parsed.payload;
|
||||
}
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const fetchArraySnapshot = async (route, invalidMessage) => {
|
||||
if (!getOpenCodePort()) {
|
||||
throw new Error('OpenCode port is not available');
|
||||
}
|
||||
|
||||
const response = await fetch(buildOpenCodeUrl(route), {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json', ...getOpenCodeAuthHeaders() },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch ${invalidMessage} (status ${response.status})`);
|
||||
}
|
||||
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!Array.isArray(payload)) {
|
||||
throw new Error(`Invalid ${invalidMessage} payload from OpenCode`);
|
||||
}
|
||||
return payload;
|
||||
};
|
||||
|
||||
const fetchAgentsSnapshot = () => fetchArraySnapshot('/agent', 'agents snapshot');
|
||||
const fetchProvidersSnapshot = () => fetchArraySnapshot('/provider', 'providers snapshot');
|
||||
const fetchModelsSnapshot = () => fetchArraySnapshot('/model', 'models snapshot');
|
||||
|
||||
const setupProxy = (app) => {
|
||||
registerOpenCodeProxy(app, {
|
||||
fs,
|
||||
os,
|
||||
path,
|
||||
OPEN_CODE_READY_GRACE_MS: openCodeReadyGraceMs,
|
||||
LONG_REQUEST_TIMEOUT_MS: longRequestTimeoutMs,
|
||||
getRuntime,
|
||||
getOpenCodeAuthHeaders,
|
||||
buildOpenCodeUrl,
|
||||
ensureOpenCodeApiPrefix,
|
||||
getUiNotificationClients,
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
setOpenCodePort,
|
||||
waitForOpenCodePort,
|
||||
buildAugmentedPath,
|
||||
parseSseDataPayload,
|
||||
fetchAgentsSnapshot,
|
||||
fetchProvidersSnapshot,
|
||||
fetchModelsSnapshot,
|
||||
setupProxy,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,320 @@
|
||||
const SESSION_COOLDOWN_DURATION_MS = 2000;
|
||||
const SESSION_STATE_MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
||||
const SESSION_ATTENTION_MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
||||
const SESSION_STATE_CLEANUP_INTERVAL_MS = 60 * 60 * 1000;
|
||||
|
||||
const extractSessionStatusUpdate = (payload) => {
|
||||
if (!payload || payload.type !== 'session.status') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const properties = payload.properties && typeof payload.properties === 'object' ? payload.properties : {};
|
||||
const info = properties.info && typeof properties.info === 'object' ? properties.info : {};
|
||||
const sessionId = typeof properties.sessionID === 'string' ? properties.sessionID.trim() : '';
|
||||
const type = typeof info.type === 'string' ? info.type.trim() : '';
|
||||
|
||||
if (!sessionId || !type) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
type,
|
||||
eventId: typeof payload.id === 'string' ? payload.id : '',
|
||||
attempt: typeof info.attempt === 'number' ? info.attempt : undefined,
|
||||
message: typeof info.message === 'string' ? info.message : undefined,
|
||||
next: typeof info.next === 'number' ? info.next : undefined,
|
||||
};
|
||||
};
|
||||
|
||||
const deriveSessionActivityTransitions = (payload) => {
|
||||
const update = extractSessionStatusUpdate(payload);
|
||||
if (!update) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (update.type === 'busy' || update.type === 'retry') {
|
||||
return [{ sessionId: update.sessionId, phase: 'busy' }];
|
||||
}
|
||||
if (update.type === 'idle') {
|
||||
return [{ sessionId: update.sessionId, phase: 'cooldown' }];
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
export const createSessionRuntime = ({ writeSseEvent, getNotificationClients }) => {
|
||||
const sessionActivityPhases = new Map();
|
||||
const sessionActivityCooldowns = new Map();
|
||||
const sessionStates = new Map();
|
||||
const sessionAttentionStates = new Map();
|
||||
|
||||
const getOrCreateAttentionState = (sessionId) => {
|
||||
if (!sessionId || typeof sessionId !== 'string') return null;
|
||||
|
||||
let state = sessionAttentionStates.get(sessionId);
|
||||
if (!state) {
|
||||
state = {
|
||||
needsAttention: false,
|
||||
lastUserMessageAt: null,
|
||||
lastStatusChangeAt: Date.now(),
|
||||
viewedByClients: new Set(),
|
||||
status: 'idle',
|
||||
};
|
||||
sessionAttentionStates.set(sessionId, state);
|
||||
}
|
||||
return state;
|
||||
};
|
||||
|
||||
const setSessionActivityPhase = (sessionId, phase) => {
|
||||
if (!sessionId || typeof sessionId !== 'string') return false;
|
||||
|
||||
const current = sessionActivityPhases.get(sessionId);
|
||||
if (current?.phase === phase) return false;
|
||||
if (phase === 'cooldown' && current?.phase !== 'busy') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const existingTimer = sessionActivityCooldowns.get(sessionId);
|
||||
if (existingTimer) {
|
||||
clearTimeout(existingTimer);
|
||||
sessionActivityCooldowns.delete(sessionId);
|
||||
}
|
||||
|
||||
sessionActivityPhases.set(sessionId, { phase, updatedAt: Date.now() });
|
||||
|
||||
if (phase === 'cooldown') {
|
||||
const timer = setTimeout(() => {
|
||||
const now = sessionActivityPhases.get(sessionId);
|
||||
if (now?.phase === 'cooldown') {
|
||||
sessionActivityPhases.set(sessionId, { phase: 'idle', updatedAt: Date.now() });
|
||||
}
|
||||
sessionActivityCooldowns.delete(sessionId);
|
||||
}, SESSION_COOLDOWN_DURATION_MS);
|
||||
sessionActivityCooldowns.set(sessionId, timer);
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const updateSessionAttentionStatus = (sessionId, status) => {
|
||||
const state = getOrCreateAttentionState(sessionId);
|
||||
if (!state) return;
|
||||
|
||||
const prevStatus = state.status;
|
||||
state.status = status;
|
||||
state.lastStatusChangeAt = Date.now();
|
||||
|
||||
if ((prevStatus === 'busy' || prevStatus === 'retry') && status === 'idle') {
|
||||
if (state.lastUserMessageAt && state.viewedByClients.size === 0) {
|
||||
state.needsAttention = true;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const updateSessionState = (sessionId, status, eventId, metadata = {}) => {
|
||||
if (!sessionId || typeof sessionId !== 'string') return;
|
||||
|
||||
const now = Date.now();
|
||||
const existing = sessionStates.get(sessionId);
|
||||
const existingAttentionState = sessionAttentionStates.get(sessionId);
|
||||
if (existing && existing.lastUpdateAt > now - 5000 && status === existing.status) {
|
||||
return;
|
||||
}
|
||||
|
||||
sessionStates.set(sessionId, {
|
||||
status,
|
||||
lastUpdateAt: now,
|
||||
lastEventId: eventId || `server-${now}`,
|
||||
metadata: { ...existing?.metadata, ...metadata },
|
||||
});
|
||||
|
||||
updateSessionAttentionStatus(sessionId, status);
|
||||
const attentionState = sessionAttentionStates.get(sessionId);
|
||||
const attentionChanged = !!attentionState && existingAttentionState?.needsAttention !== attentionState.needsAttention;
|
||||
const clients = getNotificationClients();
|
||||
if (clients.size > 0 && (!existing || existing.status !== status || attentionChanged)) {
|
||||
const state = sessionStates.get(sessionId);
|
||||
for (const res of clients) {
|
||||
try {
|
||||
writeSseEvent(res, {
|
||||
type: 'openchamber:session-status',
|
||||
properties: {
|
||||
sessionId,
|
||||
status: state.status,
|
||||
timestamp: state.lastUpdateAt,
|
||||
metadata: state.metadata,
|
||||
needsAttention: attentionState?.needsAttention ?? false,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const phase = status === 'busy' || status === 'retry' ? 'busy' : 'idle';
|
||||
setSessionActivityPhase(sessionId, phase);
|
||||
};
|
||||
|
||||
const getSessionStateSnapshot = () => {
|
||||
const result = {};
|
||||
const now = Date.now();
|
||||
for (const [sessionId, data] of sessionStates) {
|
||||
if (now - data.lastUpdateAt > SESSION_STATE_MAX_AGE_MS) continue;
|
||||
result[sessionId] = {
|
||||
status: data.status,
|
||||
lastUpdateAt: data.lastUpdateAt,
|
||||
metadata: data.metadata,
|
||||
};
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const getSessionState = (sessionId) => {
|
||||
if (!sessionId) return null;
|
||||
return sessionStates.get(sessionId) || null;
|
||||
};
|
||||
|
||||
const markSessionViewed = (sessionId, clientId) => {
|
||||
const state = getOrCreateAttentionState(sessionId);
|
||||
if (!state) return;
|
||||
|
||||
const wasNeedsAttention = state.needsAttention;
|
||||
state.viewedByClients.add(clientId);
|
||||
|
||||
if (wasNeedsAttention) {
|
||||
state.needsAttention = false;
|
||||
const clients = getNotificationClients();
|
||||
for (const res of clients) {
|
||||
try {
|
||||
writeSseEvent(res, {
|
||||
type: 'openchamber:session-status',
|
||||
properties: {
|
||||
sessionId,
|
||||
status: state.status,
|
||||
timestamp: Date.now(),
|
||||
metadata: {},
|
||||
needsAttention: false,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const markSessionUnviewed = (sessionId, clientId) => {
|
||||
const state = sessionAttentionStates.get(sessionId);
|
||||
if (!state) return;
|
||||
state.viewedByClients.delete(clientId);
|
||||
};
|
||||
|
||||
const markUserMessageSent = (sessionId) => {
|
||||
const state = getOrCreateAttentionState(sessionId);
|
||||
if (!state) return;
|
||||
state.lastUserMessageAt = Date.now();
|
||||
};
|
||||
|
||||
const getSessionAttentionSnapshot = () => {
|
||||
const result = {};
|
||||
const now = Date.now();
|
||||
for (const [sessionId, state] of sessionAttentionStates) {
|
||||
if (now - state.lastStatusChangeAt > SESSION_ATTENTION_MAX_AGE_MS) continue;
|
||||
result[sessionId] = {
|
||||
needsAttention: state.needsAttention,
|
||||
lastUserMessageAt: state.lastUserMessageAt,
|
||||
lastStatusChangeAt: state.lastStatusChangeAt,
|
||||
status: state.status,
|
||||
isViewed: state.viewedByClients.size > 0,
|
||||
};
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const getSessionAttentionState = (sessionId) => {
|
||||
if (!sessionId) return null;
|
||||
const state = sessionAttentionStates.get(sessionId);
|
||||
if (!state) return null;
|
||||
return {
|
||||
needsAttention: state.needsAttention,
|
||||
lastUserMessageAt: state.lastUserMessageAt,
|
||||
lastStatusChangeAt: state.lastStatusChangeAt,
|
||||
status: state.status,
|
||||
isViewed: state.viewedByClients.size > 0,
|
||||
};
|
||||
};
|
||||
|
||||
const getSessionActivitySnapshot = () => {
|
||||
const result = {};
|
||||
for (const [sessionId, data] of sessionActivityPhases) {
|
||||
result[sessionId] = { type: data.phase };
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const resetAllSessionActivityToIdle = () => {
|
||||
for (const timer of sessionActivityCooldowns.values()) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
sessionActivityCooldowns.clear();
|
||||
const now = Date.now();
|
||||
for (const [sessionId] of sessionActivityPhases) {
|
||||
sessionActivityPhases.set(sessionId, { phase: 'idle', updatedAt: now });
|
||||
}
|
||||
};
|
||||
|
||||
const cleanupOldSessionStates = () => {
|
||||
const now = Date.now();
|
||||
for (const [sessionId, data] of sessionStates) {
|
||||
if (now - data.lastUpdateAt > SESSION_STATE_MAX_AGE_MS) {
|
||||
sessionStates.delete(sessionId);
|
||||
}
|
||||
}
|
||||
for (const [sessionId, state] of sessionAttentionStates) {
|
||||
if (now - state.lastStatusChangeAt > SESSION_ATTENTION_MAX_AGE_MS) {
|
||||
sessionAttentionStates.delete(sessionId);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const cleanupInterval = setInterval(cleanupOldSessionStates, SESSION_STATE_CLEANUP_INTERVAL_MS);
|
||||
|
||||
const processOpenCodeSsePayload = (payload) => {
|
||||
const transitions = deriveSessionActivityTransitions(payload);
|
||||
for (const activity of transitions) {
|
||||
setSessionActivityPhase(activity.sessionId, activity.phase);
|
||||
}
|
||||
|
||||
if (payload && payload.type === 'session.status') {
|
||||
const update = extractSessionStatusUpdate(payload);
|
||||
if (update) {
|
||||
updateSessionState(update.sessionId, update.type, update.eventId || `sse-${Date.now()}`, {
|
||||
attempt: update.attempt,
|
||||
message: update.message,
|
||||
next: update.next,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const dispose = () => {
|
||||
clearInterval(cleanupInterval);
|
||||
for (const timer of sessionActivityCooldowns.values()) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
sessionActivityCooldowns.clear();
|
||||
};
|
||||
|
||||
return {
|
||||
processOpenCodeSsePayload,
|
||||
getSessionActivitySnapshot,
|
||||
getSessionStateSnapshot,
|
||||
getSessionAttentionSnapshot,
|
||||
getSessionState,
|
||||
getSessionAttentionState,
|
||||
markSessionViewed,
|
||||
markSessionUnviewed,
|
||||
markUserMessageSent,
|
||||
resetAllSessionActivityToIdle,
|
||||
dispose,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,602 @@
|
||||
export const createSettingsHelpers = (dependencies) => {
|
||||
const {
|
||||
normalizePathForPersistence,
|
||||
normalizeDirectoryPath,
|
||||
normalizeTunnelBootstrapTtlMs,
|
||||
normalizeTunnelSessionTtlMs,
|
||||
normalizeTunnelProvider,
|
||||
normalizeTunnelMode,
|
||||
normalizeOptionalPath,
|
||||
normalizeManagedRemoteTunnelHostname,
|
||||
normalizeManagedRemoteTunnelPresets,
|
||||
normalizeManagedRemoteTunnelPresetTokens,
|
||||
sanitizeTypographySizesPartial,
|
||||
normalizeStringArray,
|
||||
sanitizeModelRefs,
|
||||
sanitizeSkillCatalogs,
|
||||
sanitizeProjects,
|
||||
} = dependencies;
|
||||
|
||||
const PWA_APP_NAME_MAX_LENGTH = 64;
|
||||
|
||||
const normalizePwaAppName = (value, fallback = '') => {
|
||||
if (typeof value !== 'string') {
|
||||
return fallback;
|
||||
}
|
||||
const normalized = value.trim().replace(/\s+/g, ' ');
|
||||
if (!normalized) {
|
||||
return fallback;
|
||||
}
|
||||
return normalized.slice(0, PWA_APP_NAME_MAX_LENGTH);
|
||||
};
|
||||
|
||||
const sanitizeSettingsUpdate = (payload) => {
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
return {};
|
||||
}
|
||||
|
||||
const candidate = payload;
|
||||
const result = {};
|
||||
|
||||
if (typeof candidate.themeId === 'string' && candidate.themeId.length > 0) {
|
||||
result.themeId = candidate.themeId;
|
||||
}
|
||||
if (typeof candidate.themeVariant === 'string' && (candidate.themeVariant === 'light' || candidate.themeVariant === 'dark')) {
|
||||
result.themeVariant = candidate.themeVariant;
|
||||
}
|
||||
if (typeof candidate.useSystemTheme === 'boolean') {
|
||||
result.useSystemTheme = candidate.useSystemTheme;
|
||||
}
|
||||
if (typeof candidate.lightThemeId === 'string' && candidate.lightThemeId.length > 0) {
|
||||
result.lightThemeId = candidate.lightThemeId;
|
||||
}
|
||||
if (typeof candidate.darkThemeId === 'string' && candidate.darkThemeId.length > 0) {
|
||||
result.darkThemeId = candidate.darkThemeId;
|
||||
}
|
||||
if (typeof candidate.splashBgLight === 'string' && candidate.splashBgLight.trim().length > 0) {
|
||||
result.splashBgLight = candidate.splashBgLight.trim();
|
||||
}
|
||||
if (typeof candidate.splashFgLight === 'string' && candidate.splashFgLight.trim().length > 0) {
|
||||
result.splashFgLight = candidate.splashFgLight.trim();
|
||||
}
|
||||
if (typeof candidate.splashBgDark === 'string' && candidate.splashBgDark.trim().length > 0) {
|
||||
result.splashBgDark = candidate.splashBgDark.trim();
|
||||
}
|
||||
if (typeof candidate.splashFgDark === 'string' && candidate.splashFgDark.trim().length > 0) {
|
||||
result.splashFgDark = candidate.splashFgDark.trim();
|
||||
}
|
||||
if (typeof candidate.lastDirectory === 'string' && candidate.lastDirectory.length > 0) {
|
||||
const normalized = normalizePathForPersistence(candidate.lastDirectory);
|
||||
if (typeof normalized === 'string' && normalized.length > 0) {
|
||||
result.lastDirectory = normalized;
|
||||
}
|
||||
}
|
||||
if (typeof candidate.homeDirectory === 'string' && candidate.homeDirectory.length > 0) {
|
||||
const normalized = normalizePathForPersistence(candidate.homeDirectory);
|
||||
if (typeof normalized === 'string' && normalized.length > 0) {
|
||||
result.homeDirectory = normalized;
|
||||
}
|
||||
}
|
||||
|
||||
// Absolute path to the opencode CLI binary (optional override).
|
||||
// Accept empty-string to clear (we persist an empty string sentinel so the running
|
||||
// process can reliably drop a previously applied OPENCODE_BINARY override).
|
||||
if (typeof candidate.opencodeBinary === 'string') {
|
||||
const normalized = normalizeDirectoryPath(candidate.opencodeBinary).trim();
|
||||
result.opencodeBinary = normalized;
|
||||
}
|
||||
if (Array.isArray(candidate.projects)) {
|
||||
const projects = sanitizeProjects(candidate.projects);
|
||||
if (projects) {
|
||||
result.projects = projects;
|
||||
}
|
||||
}
|
||||
if (typeof candidate.activeProjectId === 'string' && candidate.activeProjectId.length > 0) {
|
||||
result.activeProjectId = candidate.activeProjectId;
|
||||
}
|
||||
|
||||
if (Array.isArray(candidate.approvedDirectories)) {
|
||||
result.approvedDirectories = normalizeStringArray(
|
||||
candidate.approvedDirectories
|
||||
.map((entry) => (typeof entry === 'string' ? normalizePathForPersistence(entry) : entry))
|
||||
.filter((entry) => typeof entry === 'string' && entry.length > 0)
|
||||
);
|
||||
}
|
||||
if (Array.isArray(candidate.securityScopedBookmarks)) {
|
||||
result.securityScopedBookmarks = normalizeStringArray(candidate.securityScopedBookmarks);
|
||||
}
|
||||
if (Array.isArray(candidate.pinnedDirectories)) {
|
||||
result.pinnedDirectories = normalizeStringArray(
|
||||
candidate.pinnedDirectories
|
||||
.map((entry) => (typeof entry === 'string' ? normalizePathForPersistence(entry) : entry))
|
||||
.filter((entry) => typeof entry === 'string' && entry.length > 0)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
if (typeof candidate.uiFont === 'string' && candidate.uiFont.length > 0) {
|
||||
result.uiFont = candidate.uiFont;
|
||||
}
|
||||
if (typeof candidate.monoFont === 'string' && candidate.monoFont.length > 0) {
|
||||
result.monoFont = candidate.monoFont;
|
||||
}
|
||||
if (typeof candidate.markdownDisplayMode === 'string' && candidate.markdownDisplayMode.length > 0) {
|
||||
result.markdownDisplayMode = candidate.markdownDisplayMode;
|
||||
}
|
||||
if (typeof candidate.githubClientId === 'string') {
|
||||
const trimmed = candidate.githubClientId.trim();
|
||||
if (trimmed.length > 0) {
|
||||
result.githubClientId = trimmed;
|
||||
}
|
||||
}
|
||||
if (typeof candidate.githubScopes === 'string') {
|
||||
const trimmed = candidate.githubScopes.trim();
|
||||
if (trimmed.length > 0) {
|
||||
result.githubScopes = trimmed;
|
||||
}
|
||||
}
|
||||
if (typeof candidate.showReasoningTraces === 'boolean') {
|
||||
result.showReasoningTraces = candidate.showReasoningTraces;
|
||||
}
|
||||
if (typeof candidate.showTextJustificationActivity === 'boolean') {
|
||||
result.showTextJustificationActivity = candidate.showTextJustificationActivity;
|
||||
}
|
||||
if (typeof candidate.showDeletionDialog === 'boolean') {
|
||||
result.showDeletionDialog = candidate.showDeletionDialog;
|
||||
}
|
||||
if (typeof candidate.nativeNotificationsEnabled === 'boolean') {
|
||||
result.nativeNotificationsEnabled = candidate.nativeNotificationsEnabled;
|
||||
}
|
||||
if (typeof candidate.notificationMode === 'string') {
|
||||
const mode = candidate.notificationMode.trim();
|
||||
if (mode === 'always' || mode === 'hidden-only') {
|
||||
result.notificationMode = mode;
|
||||
}
|
||||
}
|
||||
if (typeof candidate.notifyOnSubtasks === 'boolean') {
|
||||
result.notifyOnSubtasks = candidate.notifyOnSubtasks;
|
||||
}
|
||||
if (typeof candidate.notifyOnCompletion === 'boolean') {
|
||||
result.notifyOnCompletion = candidate.notifyOnCompletion;
|
||||
}
|
||||
if (typeof candidate.notifyOnError === 'boolean') {
|
||||
result.notifyOnError = candidate.notifyOnError;
|
||||
}
|
||||
if (typeof candidate.notifyOnQuestion === 'boolean') {
|
||||
result.notifyOnQuestion = candidate.notifyOnQuestion;
|
||||
}
|
||||
if (candidate.notificationTemplates && typeof candidate.notificationTemplates === 'object') {
|
||||
result.notificationTemplates = candidate.notificationTemplates;
|
||||
}
|
||||
if (typeof candidate.summarizeLastMessage === 'boolean') {
|
||||
result.summarizeLastMessage = candidate.summarizeLastMessage;
|
||||
}
|
||||
if (typeof candidate.summaryThreshold === 'number' && Number.isFinite(candidate.summaryThreshold)) {
|
||||
result.summaryThreshold = Math.max(0, Math.round(candidate.summaryThreshold));
|
||||
}
|
||||
if (typeof candidate.summaryLength === 'number' && Number.isFinite(candidate.summaryLength)) {
|
||||
result.summaryLength = Math.max(10, Math.round(candidate.summaryLength));
|
||||
}
|
||||
if (typeof candidate.maxLastMessageLength === 'number' && Number.isFinite(candidate.maxLastMessageLength)) {
|
||||
result.maxLastMessageLength = Math.max(10, Math.round(candidate.maxLastMessageLength));
|
||||
}
|
||||
if (typeof candidate.usageAutoRefresh === 'boolean') {
|
||||
result.usageAutoRefresh = candidate.usageAutoRefresh;
|
||||
}
|
||||
if (typeof candidate.usageRefreshIntervalMs === 'number' && Number.isFinite(candidate.usageRefreshIntervalMs)) {
|
||||
result.usageRefreshIntervalMs = Math.max(30000, Math.min(300000, Math.round(candidate.usageRefreshIntervalMs)));
|
||||
}
|
||||
if (candidate.usageDisplayMode === 'usage' || candidate.usageDisplayMode === 'remaining') {
|
||||
result.usageDisplayMode = candidate.usageDisplayMode;
|
||||
}
|
||||
if (Array.isArray(candidate.usageDropdownProviders)) {
|
||||
result.usageDropdownProviders = normalizeStringArray(candidate.usageDropdownProviders);
|
||||
}
|
||||
if (typeof candidate.autoDeleteEnabled === 'boolean') {
|
||||
result.autoDeleteEnabled = candidate.autoDeleteEnabled;
|
||||
}
|
||||
if (typeof candidate.autoDeleteAfterDays === 'number' && Number.isFinite(candidate.autoDeleteAfterDays)) {
|
||||
const normalizedDays = Math.max(1, Math.min(365, Math.round(candidate.autoDeleteAfterDays)));
|
||||
result.autoDeleteAfterDays = normalizedDays;
|
||||
}
|
||||
if (candidate.tunnelBootstrapTtlMs === null) {
|
||||
result.tunnelBootstrapTtlMs = null;
|
||||
} else if (typeof candidate.tunnelBootstrapTtlMs === 'number' && Number.isFinite(candidate.tunnelBootstrapTtlMs)) {
|
||||
result.tunnelBootstrapTtlMs = normalizeTunnelBootstrapTtlMs(candidate.tunnelBootstrapTtlMs);
|
||||
}
|
||||
if (typeof candidate.tunnelSessionTtlMs === 'number' && Number.isFinite(candidate.tunnelSessionTtlMs)) {
|
||||
result.tunnelSessionTtlMs = normalizeTunnelSessionTtlMs(candidate.tunnelSessionTtlMs);
|
||||
}
|
||||
if (typeof candidate.tunnelProvider === 'string') {
|
||||
const provider = normalizeTunnelProvider(candidate.tunnelProvider);
|
||||
if (provider) {
|
||||
result.tunnelProvider = provider;
|
||||
}
|
||||
}
|
||||
if (typeof candidate.tunnelMode === 'string') {
|
||||
result.tunnelMode = normalizeTunnelMode(candidate.tunnelMode);
|
||||
}
|
||||
if (candidate.managedLocalTunnelConfigPath === null) {
|
||||
result.managedLocalTunnelConfigPath = null;
|
||||
} else if (typeof candidate.managedLocalTunnelConfigPath === 'string') {
|
||||
const trimmed = candidate.managedLocalTunnelConfigPath.trim();
|
||||
result.managedLocalTunnelConfigPath = trimmed.length > 0 ? normalizeOptionalPath(trimmed) : null;
|
||||
}
|
||||
if (typeof candidate.managedRemoteTunnelHostname === 'string') {
|
||||
const hostname = normalizeManagedRemoteTunnelHostname(candidate.managedRemoteTunnelHostname);
|
||||
result.managedRemoteTunnelHostname = hostname;
|
||||
}
|
||||
if (candidate.managedRemoteTunnelToken === null) {
|
||||
result.managedRemoteTunnelToken = null;
|
||||
} else if (typeof candidate.managedRemoteTunnelToken === 'string') {
|
||||
result.managedRemoteTunnelToken = candidate.managedRemoteTunnelToken.trim();
|
||||
}
|
||||
const managedRemoteTunnelPresets = normalizeManagedRemoteTunnelPresets(candidate.managedRemoteTunnelPresets);
|
||||
if (managedRemoteTunnelPresets) {
|
||||
result.managedRemoteTunnelPresets = managedRemoteTunnelPresets;
|
||||
}
|
||||
const managedRemoteTunnelPresetTokens = normalizeManagedRemoteTunnelPresetTokens(candidate.managedRemoteTunnelPresetTokens);
|
||||
if (managedRemoteTunnelPresetTokens) {
|
||||
result.managedRemoteTunnelPresetTokens = managedRemoteTunnelPresetTokens;
|
||||
}
|
||||
if (typeof candidate.managedRemoteTunnelSelectedPresetId === 'string') {
|
||||
const id = candidate.managedRemoteTunnelSelectedPresetId.trim();
|
||||
result.managedRemoteTunnelSelectedPresetId = id || undefined;
|
||||
}
|
||||
|
||||
const typography = sanitizeTypographySizesPartial(candidate.typographySizes);
|
||||
if (typography) {
|
||||
result.typographySizes = typography;
|
||||
}
|
||||
|
||||
if (typeof candidate.defaultModel === 'string') {
|
||||
const trimmed = candidate.defaultModel.trim();
|
||||
result.defaultModel = trimmed.length > 0 ? trimmed : undefined;
|
||||
}
|
||||
if (typeof candidate.defaultVariant === 'string') {
|
||||
const trimmed = candidate.defaultVariant.trim();
|
||||
result.defaultVariant = trimmed.length > 0 ? trimmed : undefined;
|
||||
}
|
||||
if (typeof candidate.defaultAgent === 'string') {
|
||||
const trimmed = candidate.defaultAgent.trim();
|
||||
result.defaultAgent = trimmed.length > 0 ? trimmed : undefined;
|
||||
}
|
||||
if (typeof candidate.defaultGitIdentityId === 'string') {
|
||||
const trimmed = candidate.defaultGitIdentityId.trim();
|
||||
result.defaultGitIdentityId = trimmed.length > 0 ? trimmed : undefined;
|
||||
}
|
||||
if (typeof candidate.queueModeEnabled === 'boolean') {
|
||||
result.queueModeEnabled = candidate.queueModeEnabled;
|
||||
}
|
||||
if (typeof candidate.autoCreateWorktree === 'boolean') {
|
||||
result.autoCreateWorktree = candidate.autoCreateWorktree;
|
||||
}
|
||||
if (typeof candidate.gitmojiEnabled === 'boolean') {
|
||||
result.gitmojiEnabled = candidate.gitmojiEnabled;
|
||||
}
|
||||
if (typeof candidate.zenModel === 'string') {
|
||||
const trimmed = candidate.zenModel.trim();
|
||||
result.zenModel = trimmed.length > 0 ? trimmed : undefined;
|
||||
}
|
||||
if (typeof candidate.gitProviderId === 'string') {
|
||||
const trimmed = candidate.gitProviderId.trim();
|
||||
result.gitProviderId = trimmed.length > 0 ? trimmed : undefined;
|
||||
}
|
||||
if (typeof candidate.gitModelId === 'string') {
|
||||
const trimmed = candidate.gitModelId.trim();
|
||||
result.gitModelId = trimmed.length > 0 ? trimmed : undefined;
|
||||
}
|
||||
if (typeof candidate.pwaAppName === 'string') {
|
||||
result.pwaAppName = normalizePwaAppName(candidate.pwaAppName, undefined);
|
||||
}
|
||||
if (typeof candidate.toolCallExpansion === 'string') {
|
||||
const mode = candidate.toolCallExpansion.trim();
|
||||
if (mode === 'collapsed' || mode === 'activity' || mode === 'detailed' || mode === 'changes') {
|
||||
result.toolCallExpansion = mode;
|
||||
}
|
||||
}
|
||||
if (typeof candidate.inputSpellcheckEnabled === 'boolean') {
|
||||
result.inputSpellcheckEnabled = candidate.inputSpellcheckEnabled;
|
||||
}
|
||||
if (typeof candidate.showToolFileIcons === 'boolean') {
|
||||
result.showToolFileIcons = candidate.showToolFileIcons;
|
||||
}
|
||||
if (typeof candidate.showExpandedBashTools === 'boolean') {
|
||||
result.showExpandedBashTools = candidate.showExpandedBashTools;
|
||||
}
|
||||
if (typeof candidate.showExpandedEditTools === 'boolean') {
|
||||
result.showExpandedEditTools = candidate.showExpandedEditTools;
|
||||
}
|
||||
if (typeof candidate.chatRenderMode === 'string') {
|
||||
const mode = candidate.chatRenderMode.trim();
|
||||
if (mode === 'sorted' || mode === 'live') {
|
||||
result.chatRenderMode = mode;
|
||||
}
|
||||
}
|
||||
if (typeof candidate.activityRenderMode === 'string') {
|
||||
const mode = candidate.activityRenderMode.trim();
|
||||
if (mode === 'collapsed' || mode === 'summary') {
|
||||
result.activityRenderMode = mode;
|
||||
}
|
||||
}
|
||||
if (typeof candidate.mermaidRenderingMode === 'string') {
|
||||
const mode = candidate.mermaidRenderingMode.trim();
|
||||
if (mode === 'svg' || mode === 'ascii') {
|
||||
result.mermaidRenderingMode = mode;
|
||||
}
|
||||
}
|
||||
if (typeof candidate.userMessageRenderingMode === 'string') {
|
||||
const mode = candidate.userMessageRenderingMode.trim();
|
||||
if (mode === 'markdown' || mode === 'plain') {
|
||||
result.userMessageRenderingMode = mode;
|
||||
}
|
||||
}
|
||||
if (typeof candidate.stickyUserHeader === 'boolean') {
|
||||
result.stickyUserHeader = candidate.stickyUserHeader;
|
||||
}
|
||||
if (typeof candidate.fontSize === 'number' && Number.isFinite(candidate.fontSize)) {
|
||||
result.fontSize = Math.max(50, Math.min(200, Math.round(candidate.fontSize)));
|
||||
}
|
||||
if (typeof candidate.terminalFontSize === 'number' && Number.isFinite(candidate.terminalFontSize)) {
|
||||
result.terminalFontSize = Math.max(9, Math.min(52, Math.round(candidate.terminalFontSize)));
|
||||
}
|
||||
if (typeof candidate.padding === 'number' && Number.isFinite(candidate.padding)) {
|
||||
result.padding = Math.max(50, Math.min(200, Math.round(candidate.padding)));
|
||||
}
|
||||
if (typeof candidate.cornerRadius === 'number' && Number.isFinite(candidate.cornerRadius)) {
|
||||
result.cornerRadius = Math.max(0, Math.min(32, Math.round(candidate.cornerRadius)));
|
||||
}
|
||||
if (typeof candidate.inputBarOffset === 'number' && Number.isFinite(candidate.inputBarOffset)) {
|
||||
result.inputBarOffset = Math.max(0, Math.min(100, Math.round(candidate.inputBarOffset)));
|
||||
}
|
||||
|
||||
const favoriteModels = sanitizeModelRefs(candidate.favoriteModels, 64);
|
||||
if (favoriteModels) {
|
||||
result.favoriteModels = favoriteModels;
|
||||
}
|
||||
|
||||
const recentModels = sanitizeModelRefs(candidate.recentModels, 16);
|
||||
if (recentModels) {
|
||||
result.recentModels = recentModels;
|
||||
}
|
||||
if (typeof candidate.diffLayoutPreference === 'string') {
|
||||
const mode = candidate.diffLayoutPreference.trim();
|
||||
if (mode === 'dynamic' || mode === 'inline' || mode === 'side-by-side') {
|
||||
result.diffLayoutPreference = mode;
|
||||
}
|
||||
}
|
||||
if (typeof candidate.diffViewMode === 'string') {
|
||||
const mode = candidate.diffViewMode.trim();
|
||||
if (mode === 'single' || mode === 'stacked') {
|
||||
result.diffViewMode = mode;
|
||||
}
|
||||
}
|
||||
if (typeof candidate.directoryShowHidden === 'boolean') {
|
||||
result.directoryShowHidden = candidate.directoryShowHidden;
|
||||
}
|
||||
if (typeof candidate.filesViewShowGitignored === 'boolean') {
|
||||
result.filesViewShowGitignored = candidate.filesViewShowGitignored;
|
||||
}
|
||||
if (typeof candidate.openInAppId === 'string') {
|
||||
const trimmed = candidate.openInAppId.trim();
|
||||
if (trimmed.length > 0) {
|
||||
result.openInAppId = trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
// Message limit — single setting for fetch / trim / Load More chunk
|
||||
if (typeof candidate.messageLimit === 'number' && Number.isFinite(candidate.messageLimit)) {
|
||||
result.messageLimit = Math.max(10, Math.min(500, Math.round(candidate.messageLimit)));
|
||||
}
|
||||
|
||||
const skillCatalogs = sanitizeSkillCatalogs(candidate.skillCatalogs);
|
||||
if (skillCatalogs) {
|
||||
result.skillCatalogs = skillCatalogs;
|
||||
}
|
||||
|
||||
// Usage model selections - which models appear in dropdown
|
||||
if (candidate.usageSelectedModels && typeof candidate.usageSelectedModels === 'object') {
|
||||
const sanitized = {};
|
||||
for (const [providerId, models] of Object.entries(candidate.usageSelectedModels)) {
|
||||
if (typeof providerId === 'string' && Array.isArray(models)) {
|
||||
const validModels = models.filter((m) => typeof m === 'string' && m.length > 0);
|
||||
if (validModels.length > 0) {
|
||||
sanitized[providerId] = validModels;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (Object.keys(sanitized).length > 0) {
|
||||
result.usageSelectedModels = sanitized;
|
||||
}
|
||||
}
|
||||
|
||||
// Usage page collapsed families - for "Other Models" section
|
||||
if (candidate.usageCollapsedFamilies && typeof candidate.usageCollapsedFamilies === 'object') {
|
||||
const sanitized = {};
|
||||
for (const [providerId, families] of Object.entries(candidate.usageCollapsedFamilies)) {
|
||||
if (typeof providerId === 'string' && Array.isArray(families)) {
|
||||
const validFamilies = families.filter((f) => typeof f === 'string' && f.length > 0);
|
||||
if (validFamilies.length > 0) {
|
||||
sanitized[providerId] = validFamilies;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (Object.keys(sanitized).length > 0) {
|
||||
result.usageCollapsedFamilies = sanitized;
|
||||
}
|
||||
}
|
||||
|
||||
// Header dropdown expanded families (inverted - stores EXPANDED, default all collapsed)
|
||||
if (candidate.usageExpandedFamilies && typeof candidate.usageExpandedFamilies === 'object') {
|
||||
const sanitized = {};
|
||||
for (const [providerId, families] of Object.entries(candidate.usageExpandedFamilies)) {
|
||||
if (typeof providerId === 'string' && Array.isArray(families)) {
|
||||
const validFamilies = families.filter((f) => typeof f === 'string' && f.length > 0);
|
||||
if (validFamilies.length > 0) {
|
||||
sanitized[providerId] = validFamilies;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (Object.keys(sanitized).length > 0) {
|
||||
result.usageExpandedFamilies = sanitized;
|
||||
}
|
||||
}
|
||||
|
||||
// Custom model groups configuration
|
||||
if (candidate.usageModelGroups && typeof candidate.usageModelGroups === 'object') {
|
||||
const sanitized = {};
|
||||
for (const [providerId, config] of Object.entries(candidate.usageModelGroups)) {
|
||||
if (typeof providerId !== 'string') continue;
|
||||
|
||||
const providerConfig = {};
|
||||
|
||||
// customGroups: array of {id, label, models, order}
|
||||
if (Array.isArray(config.customGroups)) {
|
||||
const validGroups = config.customGroups
|
||||
.filter((g) => g && typeof g.id === 'string' && typeof g.label === 'string')
|
||||
.map((g) => ({
|
||||
id: g.id.slice(0, 64),
|
||||
label: g.label.slice(0, 128),
|
||||
models: Array.isArray(g.models)
|
||||
? g.models.filter((m) => typeof m === 'string').slice(0, 500)
|
||||
: [],
|
||||
order: typeof g.order === 'number' ? g.order : 0,
|
||||
}));
|
||||
if (validGroups.length > 0) {
|
||||
providerConfig.customGroups = validGroups;
|
||||
}
|
||||
}
|
||||
|
||||
// modelAssignments: Record<modelName, groupId>
|
||||
if (config.modelAssignments && typeof config.modelAssignments === 'object') {
|
||||
const assignments = {};
|
||||
for (const [model, groupId] of Object.entries(config.modelAssignments)) {
|
||||
if (typeof model === 'string' && typeof groupId === 'string') {
|
||||
assignments[model] = groupId;
|
||||
}
|
||||
}
|
||||
if (Object.keys(assignments).length > 0) {
|
||||
providerConfig.modelAssignments = assignments;
|
||||
}
|
||||
}
|
||||
|
||||
// renamedGroups: Record<groupId, label>
|
||||
if (config.renamedGroups && typeof config.renamedGroups === 'object') {
|
||||
const renamed = {};
|
||||
for (const [groupId, label] of Object.entries(config.renamedGroups)) {
|
||||
if (typeof groupId === 'string' && typeof label === 'string') {
|
||||
renamed[groupId] = label.slice(0, 128);
|
||||
}
|
||||
}
|
||||
if (Object.keys(renamed).length > 0) {
|
||||
providerConfig.renamedGroups = renamed;
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(providerConfig).length > 0) {
|
||||
sanitized[providerId] = providerConfig;
|
||||
}
|
||||
}
|
||||
if (Object.keys(sanitized).length > 0) {
|
||||
result.usageModelGroups = sanitized;
|
||||
}
|
||||
}
|
||||
|
||||
// Usage reporting opt-out (default: true/enabled)
|
||||
if (typeof candidate.reportUsage === 'boolean') {
|
||||
result.reportUsage = candidate.reportUsage;
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const mergePersistedSettings = (current, changes) => {
|
||||
const baseApproved = Array.isArray(changes.approvedDirectories)
|
||||
? changes.approvedDirectories
|
||||
: Array.isArray(current.approvedDirectories)
|
||||
? current.approvedDirectories
|
||||
: [];
|
||||
|
||||
const additionalApproved = [];
|
||||
if (typeof changes.lastDirectory === 'string' && changes.lastDirectory.length > 0) {
|
||||
additionalApproved.push(changes.lastDirectory);
|
||||
}
|
||||
if (typeof changes.homeDirectory === 'string' && changes.homeDirectory.length > 0) {
|
||||
additionalApproved.push(changes.homeDirectory);
|
||||
}
|
||||
const projectEntries = Array.isArray(changes.projects)
|
||||
? changes.projects
|
||||
: Array.isArray(current.projects)
|
||||
? current.projects
|
||||
: [];
|
||||
projectEntries.forEach((project) => {
|
||||
if (project && typeof project.path === 'string' && project.path.length > 0) {
|
||||
additionalApproved.push(project.path);
|
||||
}
|
||||
});
|
||||
const approvedSource = [...baseApproved, ...additionalApproved];
|
||||
|
||||
const baseBookmarks = Array.isArray(changes.securityScopedBookmarks)
|
||||
? changes.securityScopedBookmarks
|
||||
: Array.isArray(current.securityScopedBookmarks)
|
||||
? current.securityScopedBookmarks
|
||||
: [];
|
||||
|
||||
const nextTypographySizes = changes.typographySizes
|
||||
? {
|
||||
...(current.typographySizes || {}),
|
||||
...changes.typographySizes
|
||||
}
|
||||
: current.typographySizes;
|
||||
|
||||
const next = {
|
||||
...current,
|
||||
...changes,
|
||||
approvedDirectories: Array.from(
|
||||
new Set(
|
||||
approvedSource.filter((entry) => typeof entry === 'string' && entry.length > 0)
|
||||
)
|
||||
),
|
||||
securityScopedBookmarks: Array.from(
|
||||
new Set(
|
||||
baseBookmarks.filter((entry) => typeof entry === 'string' && entry.length > 0)
|
||||
)
|
||||
),
|
||||
typographySizes: nextTypographySizes
|
||||
};
|
||||
|
||||
return next;
|
||||
};
|
||||
|
||||
const formatSettingsResponse = (settings) => {
|
||||
const sanitized = sanitizeSettingsUpdate(settings);
|
||||
delete sanitized.managedRemoteTunnelToken;
|
||||
const approved = normalizeStringArray(settings.approvedDirectories);
|
||||
const bookmarks = normalizeStringArray(settings.securityScopedBookmarks);
|
||||
const hasManagedRemoteTunnelToken = typeof settings?.managedRemoteTunnelToken === 'string' && settings.managedRemoteTunnelToken.trim().length > 0;
|
||||
const pwaAppName = normalizePwaAppName(settings?.pwaAppName, '');
|
||||
|
||||
return {
|
||||
...sanitized,
|
||||
hasManagedRemoteTunnelToken,
|
||||
...(pwaAppName ? { pwaAppName } : {}),
|
||||
approvedDirectories: approved,
|
||||
securityScopedBookmarks: bookmarks,
|
||||
pinnedDirectories: normalizeStringArray(settings.pinnedDirectories),
|
||||
typographySizes: sanitizeTypographySizesPartial(settings.typographySizes),
|
||||
showReasoningTraces:
|
||||
typeof settings.showReasoningTraces === 'boolean'
|
||||
? settings.showReasoningTraces
|
||||
: typeof sanitized.showReasoningTraces === 'boolean'
|
||||
? sanitized.showReasoningTraces
|
||||
: false
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
normalizePwaAppName,
|
||||
sanitizeSettingsUpdate,
|
||||
mergePersistedSettings,
|
||||
formatSettingsResponse,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,428 @@
|
||||
export const createSettingsNormalizationRuntime = (dependencies) => {
|
||||
const {
|
||||
os,
|
||||
path,
|
||||
processLike,
|
||||
tunnelBootstrapTtlDefaultMs,
|
||||
tunnelBootstrapTtlMinMs,
|
||||
tunnelBootstrapTtlMaxMs,
|
||||
tunnelSessionTtlDefaultMs,
|
||||
tunnelSessionTtlMinMs,
|
||||
tunnelSessionTtlMaxMs,
|
||||
} = dependencies;
|
||||
|
||||
const normalizeDirectoryPath = (value) => {
|
||||
if (typeof value !== 'string') {
|
||||
return value;
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
if (trimmed === '~') {
|
||||
return os.homedir();
|
||||
}
|
||||
|
||||
if (trimmed.startsWith('~/') || trimmed.startsWith('~\\')) {
|
||||
return path.join(os.homedir(), trimmed.slice(2));
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
};
|
||||
|
||||
const normalizePathForPersistence = (value) => {
|
||||
if (typeof value !== 'string') {
|
||||
return value;
|
||||
}
|
||||
|
||||
const normalized = normalizeDirectoryPath(value);
|
||||
if (typeof normalized !== 'string') {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
const trimmed = normalized.trim();
|
||||
if (!trimmed) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
if (processLike.platform !== 'win32') {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
return trimmed.replace(/\//g, '\\');
|
||||
};
|
||||
|
||||
const areStringArraysEqual = (a, b) => {
|
||||
if (!Array.isArray(a) || !Array.isArray(b)) {
|
||||
return false;
|
||||
}
|
||||
if (a.length !== b.length) {
|
||||
return false;
|
||||
}
|
||||
for (let i = 0; i < a.length; i += 1) {
|
||||
if (a[i] !== b[i]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const normalizeStringArray = (input) => {
|
||||
if (!Array.isArray(input)) {
|
||||
return [];
|
||||
}
|
||||
return Array.from(
|
||||
new Set(
|
||||
input.filter((entry) => typeof entry === 'string' && entry.length > 0)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const sanitizeProjects = (input) => {
|
||||
if (!Array.isArray(input)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const hexColorPattern = /^#(?:[\da-fA-F]{3}|[\da-fA-F]{6})$/;
|
||||
const normalizeIconBackground = (value) => {
|
||||
if (typeof value !== 'string') {
|
||||
return null;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
return hexColorPattern.test(trimmed) ? trimmed.toLowerCase() : null;
|
||||
};
|
||||
|
||||
const result = [];
|
||||
const seenIds = new Set();
|
||||
const seenPaths = new Set();
|
||||
|
||||
for (const entry of input) {
|
||||
if (!entry || typeof entry !== 'object') continue;
|
||||
|
||||
const candidate = entry;
|
||||
const id = typeof candidate.id === 'string' ? candidate.id.trim() : '';
|
||||
const rawPath = typeof candidate.path === 'string' ? candidate.path.trim() : '';
|
||||
const resolvedPath = rawPath ? path.resolve(normalizeDirectoryPath(rawPath)) : '';
|
||||
const normalizedPath = resolvedPath ? normalizePathForPersistence(resolvedPath) : '';
|
||||
const label = typeof candidate.label === 'string' ? candidate.label.trim() : '';
|
||||
const icon = typeof candidate.icon === 'string' ? candidate.icon.trim() : '';
|
||||
const iconImage = candidate.iconImage && typeof candidate.iconImage === 'object'
|
||||
? candidate.iconImage
|
||||
: null;
|
||||
const iconBackground = normalizeIconBackground(candidate.iconBackground);
|
||||
const color = typeof candidate.color === 'string' ? candidate.color.trim() : '';
|
||||
const addedAt = Number.isFinite(candidate.addedAt) ? Number(candidate.addedAt) : null;
|
||||
const lastOpenedAt = Number.isFinite(candidate.lastOpenedAt)
|
||||
? Number(candidate.lastOpenedAt)
|
||||
: null;
|
||||
|
||||
if (!id || !normalizedPath) continue;
|
||||
if (seenIds.has(id)) continue;
|
||||
if (seenPaths.has(normalizedPath)) continue;
|
||||
|
||||
seenIds.add(id);
|
||||
seenPaths.add(normalizedPath);
|
||||
|
||||
const project = {
|
||||
id,
|
||||
path: normalizedPath,
|
||||
...(label ? { label } : {}),
|
||||
...(icon ? { icon } : {}),
|
||||
...(iconBackground ? { iconBackground } : {}),
|
||||
...(color ? { color } : {}),
|
||||
...(Number.isFinite(addedAt) && addedAt >= 0 ? { addedAt } : {}),
|
||||
...(Number.isFinite(lastOpenedAt) && lastOpenedAt >= 0 ? { lastOpenedAt } : {}),
|
||||
};
|
||||
|
||||
if (candidate.iconImage === null) {
|
||||
project.iconImage = null;
|
||||
} else if (iconImage) {
|
||||
const mime = typeof iconImage.mime === 'string' ? iconImage.mime.trim() : '';
|
||||
const updatedAt = typeof iconImage.updatedAt === 'number' && Number.isFinite(iconImage.updatedAt)
|
||||
? Math.max(0, Math.round(iconImage.updatedAt))
|
||||
: 0;
|
||||
const source = iconImage.source === 'custom' || iconImage.source === 'auto'
|
||||
? iconImage.source
|
||||
: null;
|
||||
if (mime && updatedAt > 0 && source) {
|
||||
project.iconImage = { mime, updatedAt, source };
|
||||
}
|
||||
}
|
||||
|
||||
if (candidate.iconBackground === null) {
|
||||
project.iconBackground = null;
|
||||
}
|
||||
|
||||
if (typeof candidate.sidebarCollapsed === 'boolean') {
|
||||
project.sidebarCollapsed = candidate.sidebarCollapsed;
|
||||
}
|
||||
|
||||
result.push(project);
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const normalizeSettingsPaths = (input) => {
|
||||
const settings = input && typeof input === 'object' ? input : {};
|
||||
let next = settings;
|
||||
let changed = false;
|
||||
|
||||
const ensureNext = () => {
|
||||
if (next === settings) {
|
||||
next = { ...settings };
|
||||
}
|
||||
};
|
||||
|
||||
const normalizePathField = (key) => {
|
||||
if (typeof settings[key] !== 'string' || settings[key].length === 0) {
|
||||
return;
|
||||
}
|
||||
const normalized = normalizePathForPersistence(settings[key]);
|
||||
if (normalized !== settings[key]) {
|
||||
ensureNext();
|
||||
next[key] = normalized;
|
||||
changed = true;
|
||||
}
|
||||
};
|
||||
|
||||
const normalizePathArrayField = (key) => {
|
||||
if (!Array.isArray(settings[key])) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalized = normalizeStringArray(
|
||||
settings[key]
|
||||
.map((entry) => (typeof entry === 'string' ? normalizePathForPersistence(entry) : entry))
|
||||
.filter((entry) => typeof entry === 'string' && entry.length > 0)
|
||||
);
|
||||
|
||||
if (!areStringArraysEqual(normalized, settings[key])) {
|
||||
ensureNext();
|
||||
next[key] = normalized;
|
||||
changed = true;
|
||||
}
|
||||
};
|
||||
|
||||
normalizePathField('lastDirectory');
|
||||
normalizePathField('homeDirectory');
|
||||
normalizePathArrayField('approvedDirectories');
|
||||
normalizePathArrayField('pinnedDirectories');
|
||||
|
||||
if (Array.isArray(settings.projects)) {
|
||||
const normalizedProjects = sanitizeProjects(settings.projects) || [];
|
||||
if (JSON.stringify(normalizedProjects) !== JSON.stringify(settings.projects)) {
|
||||
ensureNext();
|
||||
next.projects = normalizedProjects;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
return { settings: next, changed };
|
||||
};
|
||||
|
||||
const clampNumber = (value, min, max) => Math.max(min, Math.min(max, value));
|
||||
|
||||
const normalizeTunnelBootstrapTtlMs = (value) => {
|
||||
if (value === null) {
|
||||
return null;
|
||||
}
|
||||
if (!Number.isFinite(value)) {
|
||||
return tunnelBootstrapTtlDefaultMs;
|
||||
}
|
||||
return clampNumber(Math.round(value), tunnelBootstrapTtlMinMs, tunnelBootstrapTtlMaxMs);
|
||||
};
|
||||
|
||||
const normalizeTunnelSessionTtlMs = (value) => {
|
||||
if (!Number.isFinite(value)) {
|
||||
return tunnelSessionTtlDefaultMs;
|
||||
}
|
||||
return clampNumber(Math.round(value), tunnelSessionTtlMinMs, tunnelSessionTtlMaxMs);
|
||||
};
|
||||
|
||||
const normalizeManagedRemoteTunnelHostname = (value) => {
|
||||
if (typeof value !== 'string') {
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const parsed = (() => {
|
||||
try {
|
||||
if (trimmed.includes('://')) {
|
||||
return new URL(trimmed);
|
||||
}
|
||||
return new URL(`https://${trimmed}`);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
const hostname = parsed?.hostname?.trim().toLowerCase() || '';
|
||||
if (!hostname) {
|
||||
return undefined;
|
||||
}
|
||||
return hostname;
|
||||
};
|
||||
|
||||
const normalizeManagedRemoteTunnelPresets = (value) => {
|
||||
if (!Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const result = [];
|
||||
const seenIds = new Set();
|
||||
const seenHostnames = new Set();
|
||||
|
||||
for (const entry of value) {
|
||||
if (!entry || typeof entry !== 'object') continue;
|
||||
const candidate = entry;
|
||||
const id = typeof candidate.id === 'string' ? candidate.id.trim() : '';
|
||||
const name = typeof candidate.name === 'string' ? candidate.name.trim() : '';
|
||||
const hostname = normalizeManagedRemoteTunnelHostname(candidate.hostname);
|
||||
if (!id || !name || !hostname) continue;
|
||||
if (seenIds.has(id) || seenHostnames.has(hostname)) continue;
|
||||
seenIds.add(id);
|
||||
seenHostnames.add(hostname);
|
||||
result.push({ id, name, hostname });
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const normalizeManagedRemoteTunnelPresetTokens = (value) => {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const result = {};
|
||||
for (const [rawId, rawToken] of Object.entries(value)) {
|
||||
const id = typeof rawId === 'string' ? rawId.trim() : '';
|
||||
const token = typeof rawToken === 'string' ? rawToken.trim() : '';
|
||||
if (!id || !token) {
|
||||
continue;
|
||||
}
|
||||
result[id] = token;
|
||||
}
|
||||
|
||||
return Object.keys(result).length > 0 ? result : undefined;
|
||||
};
|
||||
|
||||
const isUnsafeSkillRelativePath = (value) => {
|
||||
if (typeof value !== 'string' || value.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const normalized = value.replace(/\\/g, '/');
|
||||
if (path.posix.isAbsolute(normalized)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return normalized.split('/').some((segment) => segment === '..');
|
||||
};
|
||||
|
||||
const sanitizeTypographySizesPartial = (input) => {
|
||||
if (!input || typeof input !== 'object') {
|
||||
return undefined;
|
||||
}
|
||||
const candidate = input;
|
||||
const result = {};
|
||||
let populated = false;
|
||||
|
||||
const assign = (key) => {
|
||||
if (typeof candidate[key] === 'string' && candidate[key].length > 0) {
|
||||
result[key] = candidate[key];
|
||||
populated = true;
|
||||
}
|
||||
};
|
||||
|
||||
assign('markdown');
|
||||
assign('code');
|
||||
assign('uiHeader');
|
||||
assign('uiLabel');
|
||||
assign('meta');
|
||||
assign('micro');
|
||||
|
||||
return populated ? result : undefined;
|
||||
};
|
||||
|
||||
const sanitizeModelRefs = (input, limit) => {
|
||||
if (!Array.isArray(input)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const result = [];
|
||||
const seen = new Set();
|
||||
|
||||
for (const entry of input) {
|
||||
if (!entry || typeof entry !== 'object') continue;
|
||||
const providerID = typeof entry.providerID === 'string' ? entry.providerID.trim() : '';
|
||||
const modelID = typeof entry.modelID === 'string' ? entry.modelID.trim() : '';
|
||||
if (!providerID || !modelID) continue;
|
||||
const key = `${providerID}/${modelID}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
result.push({ providerID, modelID });
|
||||
if (result.length >= limit) break;
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const sanitizeSkillCatalogs = (input) => {
|
||||
if (!Array.isArray(input)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const result = [];
|
||||
const seen = new Set();
|
||||
|
||||
for (const entry of input) {
|
||||
if (!entry || typeof entry !== 'object') continue;
|
||||
|
||||
const id = typeof entry.id === 'string' ? entry.id.trim() : '';
|
||||
const label = typeof entry.label === 'string' ? entry.label.trim() : '';
|
||||
const source = typeof entry.source === 'string' ? entry.source.trim() : '';
|
||||
const subpath = typeof entry.subpath === 'string' ? entry.subpath.trim() : '';
|
||||
const gitIdentityId = typeof entry.gitIdentityId === 'string' ? entry.gitIdentityId.trim() : '';
|
||||
|
||||
if (!id || !label || !source) continue;
|
||||
if (seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
|
||||
result.push({
|
||||
id,
|
||||
label,
|
||||
source,
|
||||
...(subpath ? { subpath } : {}),
|
||||
...(gitIdentityId ? { gitIdentityId } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
return {
|
||||
normalizeDirectoryPath,
|
||||
normalizePathForPersistence,
|
||||
normalizeSettingsPaths,
|
||||
normalizeTunnelBootstrapTtlMs,
|
||||
normalizeTunnelSessionTtlMs,
|
||||
normalizeManagedRemoteTunnelHostname,
|
||||
normalizeManagedRemoteTunnelPresets,
|
||||
normalizeManagedRemoteTunnelPresetTokens,
|
||||
isUnsafeSkillRelativePath,
|
||||
sanitizeTypographySizesPartial,
|
||||
normalizeStringArray,
|
||||
sanitizeModelRefs,
|
||||
sanitizeSkillCatalogs,
|
||||
sanitizeProjects,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,439 @@
|
||||
const DEFAULT_NOTIFICATION_TEMPLATES = {
|
||||
completion: { title: '{agent_name} is ready', message: '{model_name} completed the task' },
|
||||
error: { title: 'Tool error', message: '{last_message}' },
|
||||
question: { title: 'Input needed', message: '{last_message}' },
|
||||
subtask: { title: '{agent_name} is ready', message: '{model_name} completed the task' },
|
||||
};
|
||||
|
||||
const ensureNotificationTemplateShape = (templates) => {
|
||||
const input = templates && typeof templates === 'object' ? templates : {};
|
||||
let changed = false;
|
||||
const next = {};
|
||||
|
||||
for (const event of Object.keys(DEFAULT_NOTIFICATION_TEMPLATES)) {
|
||||
const currentEntry = input[event];
|
||||
const base = DEFAULT_NOTIFICATION_TEMPLATES[event];
|
||||
const currentTitle = typeof currentEntry?.title === 'string' ? currentEntry.title : base.title;
|
||||
const currentMessage = typeof currentEntry?.message === 'string' ? currentEntry.message : base.message;
|
||||
if (!currentEntry || typeof currentEntry.title !== 'string' || typeof currentEntry.message !== 'string') {
|
||||
changed = true;
|
||||
}
|
||||
next[event] = { title: currentTitle, message: currentMessage };
|
||||
}
|
||||
|
||||
return { templates: next, changed };
|
||||
};
|
||||
|
||||
export const createSettingsRuntime = (deps) => {
|
||||
const {
|
||||
fsPromises,
|
||||
path,
|
||||
crypto,
|
||||
SETTINGS_FILE_PATH,
|
||||
sanitizeProjects,
|
||||
sanitizeSettingsUpdate,
|
||||
mergePersistedSettings,
|
||||
normalizeSettingsPaths,
|
||||
normalizeStringArray,
|
||||
formatSettingsResponse,
|
||||
resolveDirectoryCandidate,
|
||||
normalizeManagedRemoteTunnelHostname,
|
||||
normalizeManagedRemoteTunnelPresets,
|
||||
normalizeManagedRemoteTunnelPresetTokens,
|
||||
syncManagedRemoteTunnelConfigWithPresets,
|
||||
upsertManagedRemoteTunnelToken,
|
||||
} = deps;
|
||||
|
||||
let persistSettingsLock = Promise.resolve();
|
||||
|
||||
const readSettingsFromDisk = async () => {
|
||||
try {
|
||||
const raw = await fsPromises.readFile(SETTINGS_FILE_PATH, 'utf8');
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
return parsed;
|
||||
}
|
||||
return {};
|
||||
} catch (error) {
|
||||
if (error && typeof error === 'object' && error.code === 'ENOENT') {
|
||||
return {};
|
||||
}
|
||||
console.warn('Failed to read settings file:', error);
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
const writeSettingsToDisk = async (settings) => {
|
||||
try {
|
||||
await fsPromises.mkdir(path.dirname(SETTINGS_FILE_PATH), { recursive: true });
|
||||
await fsPromises.writeFile(SETTINGS_FILE_PATH, JSON.stringify(settings, null, 2), 'utf8');
|
||||
} catch (error) {
|
||||
console.warn('Failed to write settings file:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const validateProjectEntries = async (projects) => {
|
||||
console.log(`[validateProjectEntries] Starting validation for ${projects.length} projects`);
|
||||
|
||||
if (!Array.isArray(projects)) {
|
||||
console.warn('[validateProjectEntries] Input is not an array, returning empty');
|
||||
return [];
|
||||
}
|
||||
|
||||
const validations = projects.map(async (project) => {
|
||||
if (!project || typeof project.path !== 'string' || project.path.length === 0) {
|
||||
console.error('[validateProjectEntries] Invalid project entry: missing or empty path', project);
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const stats = await fsPromises.stat(project.path);
|
||||
if (!stats.isDirectory()) {
|
||||
console.error(`[validateProjectEntries] Project path is not a directory: ${project.path}`);
|
||||
return null;
|
||||
}
|
||||
return project;
|
||||
} catch (error) {
|
||||
const err = error;
|
||||
console.error(`[validateProjectEntries] Failed to validate project "${project.path}": ${err.code || err.message || err}`);
|
||||
if (err && typeof err === 'object' && err.code === 'ENOENT') {
|
||||
console.log(`[validateProjectEntries] Removing project with ENOENT: ${project.path}`);
|
||||
return null;
|
||||
}
|
||||
console.log(`[validateProjectEntries] Keeping project despite non-ENOENT error: ${project.path}`);
|
||||
return project;
|
||||
}
|
||||
});
|
||||
|
||||
const results = (await Promise.all(validations)).filter((p) => p !== null);
|
||||
|
||||
console.log(`[validateProjectEntries] Validation complete: ${results.length}/${projects.length} projects valid`);
|
||||
return results;
|
||||
};
|
||||
|
||||
const migrateSettingsFromLegacyLastDirectory = async (current) => {
|
||||
const settings = current && typeof current === 'object' ? current : {};
|
||||
const now = Date.now();
|
||||
|
||||
const sanitizedProjects = sanitizeProjects(settings.projects) || [];
|
||||
let nextProjects = sanitizedProjects;
|
||||
let nextActiveProjectId =
|
||||
typeof settings.activeProjectId === 'string' ? settings.activeProjectId : undefined;
|
||||
|
||||
let changed = false;
|
||||
|
||||
if (nextProjects.length === 0) {
|
||||
const legacy = typeof settings.lastDirectory === 'string' ? settings.lastDirectory.trim() : '';
|
||||
const candidate = legacy ? resolveDirectoryCandidate(legacy) : null;
|
||||
|
||||
if (candidate) {
|
||||
try {
|
||||
const stats = await fsPromises.stat(candidate);
|
||||
if (stats.isDirectory()) {
|
||||
const id = crypto.randomUUID();
|
||||
nextProjects = [
|
||||
{
|
||||
id,
|
||||
path: candidate,
|
||||
addedAt: now,
|
||||
lastOpenedAt: now,
|
||||
},
|
||||
];
|
||||
nextActiveProjectId = id;
|
||||
changed = true;
|
||||
}
|
||||
} catch {
|
||||
// ignore invalid lastDirectory
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (nextProjects.length > 0) {
|
||||
const active = nextProjects.find((project) => project.id === nextActiveProjectId) || null;
|
||||
if (!active) {
|
||||
nextActiveProjectId = nextProjects[0].id;
|
||||
changed = true;
|
||||
}
|
||||
} else if (nextActiveProjectId) {
|
||||
nextActiveProjectId = undefined;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (!changed) {
|
||||
return { settings, changed: false };
|
||||
}
|
||||
|
||||
const merged = mergePersistedSettings(settings, {
|
||||
...settings,
|
||||
projects: nextProjects,
|
||||
...(nextActiveProjectId ? { activeProjectId: nextActiveProjectId } : { activeProjectId: undefined }),
|
||||
});
|
||||
|
||||
return { settings: merged, changed: true };
|
||||
};
|
||||
|
||||
const migrateSettingsFromLegacyThemePreferences = async (current) => {
|
||||
const settings = current && typeof current === 'object' ? current : {};
|
||||
|
||||
const themeId = typeof settings.themeId === 'string' ? settings.themeId.trim() : '';
|
||||
const themeVariant = typeof settings.themeVariant === 'string' ? settings.themeVariant.trim() : '';
|
||||
|
||||
const hasLight = typeof settings.lightThemeId === 'string' && settings.lightThemeId.trim().length > 0;
|
||||
const hasDark = typeof settings.darkThemeId === 'string' && settings.darkThemeId.trim().length > 0;
|
||||
|
||||
if (hasLight && hasDark) {
|
||||
return { settings, changed: false };
|
||||
}
|
||||
|
||||
const defaultLight = 'flexoki-light';
|
||||
const defaultDark = 'flexoki-dark';
|
||||
|
||||
let nextLightThemeId = hasLight ? settings.lightThemeId : undefined;
|
||||
let nextDarkThemeId = hasDark ? settings.darkThemeId : undefined;
|
||||
|
||||
if (!hasLight) {
|
||||
if (themeId && themeVariant === 'light') {
|
||||
nextLightThemeId = themeId;
|
||||
} else {
|
||||
nextLightThemeId = defaultLight;
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasDark) {
|
||||
if (themeId && themeVariant === 'dark') {
|
||||
nextDarkThemeId = themeId;
|
||||
} else {
|
||||
nextDarkThemeId = defaultDark;
|
||||
}
|
||||
}
|
||||
|
||||
const merged = mergePersistedSettings(settings, {
|
||||
...settings,
|
||||
...(nextLightThemeId ? { lightThemeId: nextLightThemeId } : {}),
|
||||
...(nextDarkThemeId ? { darkThemeId: nextDarkThemeId } : {}),
|
||||
});
|
||||
|
||||
return { settings: merged, changed: true };
|
||||
};
|
||||
|
||||
const migrateSettingsFromLegacyCollapsedProjects = async (current) => {
|
||||
const settings = current && typeof current === 'object' ? current : {};
|
||||
const collapsed = Array.isArray(settings.collapsedProjects)
|
||||
? normalizeStringArray(settings.collapsedProjects)
|
||||
: [];
|
||||
|
||||
if (collapsed.length === 0 || !Array.isArray(settings.projects)) {
|
||||
if (collapsed.length === 0) {
|
||||
return { settings, changed: false };
|
||||
}
|
||||
const next = { ...settings };
|
||||
delete next.collapsedProjects;
|
||||
return { settings: next, changed: true };
|
||||
}
|
||||
|
||||
const set = new Set(collapsed);
|
||||
const projects = sanitizeProjects(settings.projects) || [];
|
||||
let changed = false;
|
||||
|
||||
const nextProjects = projects.map((project) => {
|
||||
const shouldCollapse = set.has(project.id);
|
||||
if (project.sidebarCollapsed !== shouldCollapse) {
|
||||
changed = true;
|
||||
return { ...project, sidebarCollapsed: shouldCollapse };
|
||||
}
|
||||
return project;
|
||||
});
|
||||
|
||||
if (!changed) {
|
||||
if (Object.prototype.hasOwnProperty.call(settings, 'collapsedProjects')) {
|
||||
const next = { ...settings };
|
||||
delete next.collapsedProjects;
|
||||
return { settings: next, changed: true };
|
||||
}
|
||||
return { settings, changed: false };
|
||||
}
|
||||
|
||||
const next = { ...settings, projects: nextProjects };
|
||||
delete next.collapsedProjects;
|
||||
return { settings: next, changed: true };
|
||||
};
|
||||
|
||||
const migrateSettingsNotificationDefaults = async (current) => {
|
||||
const settings = current && typeof current === 'object' ? current : {};
|
||||
let changed = false;
|
||||
const next = { ...settings };
|
||||
|
||||
if (typeof settings.notifyOnSubtasks !== 'boolean') {
|
||||
next.notifyOnSubtasks = true;
|
||||
changed = true;
|
||||
}
|
||||
if (typeof settings.notifyOnCompletion !== 'boolean') {
|
||||
next.notifyOnCompletion = true;
|
||||
changed = true;
|
||||
}
|
||||
if (typeof settings.notifyOnError !== 'boolean') {
|
||||
next.notifyOnError = true;
|
||||
changed = true;
|
||||
}
|
||||
if (typeof settings.notifyOnQuestion !== 'boolean') {
|
||||
next.notifyOnQuestion = true;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
const { templates, changed: templatesChanged } = ensureNotificationTemplateShape(settings.notificationTemplates);
|
||||
if (templatesChanged || !settings.notificationTemplates || typeof settings.notificationTemplates !== 'object') {
|
||||
next.notificationTemplates = templates;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
return { settings: changed ? next : settings, changed };
|
||||
};
|
||||
|
||||
const migrateSettingsFromLegacyNamedTunnelKeys = async (current) => {
|
||||
const settings = current && typeof current === 'object' ? current : {};
|
||||
const next = { ...settings };
|
||||
let changed = false;
|
||||
|
||||
if (!Object.prototype.hasOwnProperty.call(next, 'managedRemoteTunnelHostname')
|
||||
&& Object.prototype.hasOwnProperty.call(next, 'namedTunnelHostname')) {
|
||||
next.managedRemoteTunnelHostname = normalizeManagedRemoteTunnelHostname(next.namedTunnelHostname);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (!Object.prototype.hasOwnProperty.call(next, 'managedRemoteTunnelToken')
|
||||
&& Object.prototype.hasOwnProperty.call(next, 'namedTunnelToken')) {
|
||||
if (next.namedTunnelToken === null) {
|
||||
next.managedRemoteTunnelToken = null;
|
||||
} else if (typeof next.namedTunnelToken === 'string') {
|
||||
next.managedRemoteTunnelToken = next.namedTunnelToken.trim();
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (!Object.prototype.hasOwnProperty.call(next, 'managedRemoteTunnelPresets')
|
||||
&& Object.prototype.hasOwnProperty.call(next, 'namedTunnelPresets')) {
|
||||
next.managedRemoteTunnelPresets = normalizeManagedRemoteTunnelPresets(next.namedTunnelPresets);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (!Object.prototype.hasOwnProperty.call(next, 'managedRemoteTunnelPresetTokens')
|
||||
&& Object.prototype.hasOwnProperty.call(next, 'namedTunnelPresetTokens')) {
|
||||
next.managedRemoteTunnelPresetTokens = normalizeManagedRemoteTunnelPresetTokens(next.namedTunnelPresetTokens);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (!Object.prototype.hasOwnProperty.call(next, 'managedRemoteTunnelSelectedPresetId')
|
||||
&& Object.prototype.hasOwnProperty.call(next, 'namedTunnelSelectedPresetId')) {
|
||||
const selectedPresetId = typeof next.namedTunnelSelectedPresetId === 'string'
|
||||
? next.namedTunnelSelectedPresetId.trim()
|
||||
: '';
|
||||
if (selectedPresetId) {
|
||||
next.managedRemoteTunnelSelectedPresetId = selectedPresetId;
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
|
||||
const legacyKeys = [
|
||||
'namedTunnelHostname',
|
||||
'namedTunnelToken',
|
||||
'namedTunnelPresets',
|
||||
'namedTunnelPresetTokens',
|
||||
'namedTunnelSelectedPresetId',
|
||||
];
|
||||
for (const key of legacyKeys) {
|
||||
if (Object.prototype.hasOwnProperty.call(next, key)) {
|
||||
delete next[key];
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
return { settings: changed ? next : settings, changed };
|
||||
};
|
||||
|
||||
const readSettingsFromDiskMigrated = async () => {
|
||||
const current = await readSettingsFromDisk();
|
||||
const migration1 = await migrateSettingsFromLegacyLastDirectory(current);
|
||||
const migration2 = await migrateSettingsFromLegacyThemePreferences(migration1.settings);
|
||||
const migration3 = await migrateSettingsFromLegacyCollapsedProjects(migration2.settings);
|
||||
const migration4 = await migrateSettingsNotificationDefaults(migration3.settings);
|
||||
const migration5 = await migrateSettingsFromLegacyNamedTunnelKeys(migration4.settings);
|
||||
const migration6 = normalizeSettingsPaths(migration5.settings);
|
||||
if (migration1.changed || migration2.changed || migration3.changed || migration4.changed || migration5.changed || migration6.changed) {
|
||||
await writeSettingsToDisk(migration6.settings);
|
||||
}
|
||||
return migration6.settings;
|
||||
};
|
||||
|
||||
const persistSettings = async (changes) => {
|
||||
persistSettingsLock = persistSettingsLock.then(async () => {
|
||||
console.log('[persistSettings] Called with changes:', JSON.stringify(changes, null, 2));
|
||||
const current = await readSettingsFromDisk();
|
||||
console.log('[persistSettings] Current projects count:', Array.isArray(current.projects) ? current.projects.length : 'N/A');
|
||||
const sanitized = sanitizeSettingsUpdate(changes);
|
||||
let next = mergePersistedSettings(current, sanitized);
|
||||
|
||||
const normalizedState = normalizeSettingsPaths(next);
|
||||
if (normalizedState.changed) {
|
||||
next = normalizedState.settings;
|
||||
}
|
||||
|
||||
if (Array.isArray(next.projects)) {
|
||||
console.log(`[persistSettings] Validating ${next.projects.length} projects...`);
|
||||
const validated = await validateProjectEntries(next.projects);
|
||||
console.log(`[persistSettings] After validation: ${validated.length} projects remain`);
|
||||
next = { ...next, projects: validated };
|
||||
}
|
||||
|
||||
if (Array.isArray(next.projects) && next.projects.length > 0) {
|
||||
const activeId = typeof next.activeProjectId === 'string' ? next.activeProjectId : '';
|
||||
const active = next.projects.find((project) => project.id === activeId) || null;
|
||||
if (!active) {
|
||||
console.log(`[persistSettings] Active project ID ${activeId} not found, switching to ${next.projects[0].id}`);
|
||||
next = { ...next, activeProjectId: next.projects[0].id };
|
||||
}
|
||||
} else if (next.activeProjectId) {
|
||||
console.log(`[persistSettings] No projects found, clearing activeProjectId ${next.activeProjectId}`);
|
||||
next = { ...next, activeProjectId: undefined };
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(sanitized, 'managedRemoteTunnelPresets')) {
|
||||
await syncManagedRemoteTunnelConfigWithPresets(next.managedRemoteTunnelPresets);
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(sanitized, 'managedRemoteTunnelPresetTokens') && sanitized.managedRemoteTunnelPresetTokens) {
|
||||
const presetsById = new Map((next.managedRemoteTunnelPresets || []).map((entry) => [entry.id, entry]));
|
||||
const updates = Object.entries(sanitized.managedRemoteTunnelPresetTokens)
|
||||
.map(([presetId, token]) => {
|
||||
const preset = presetsById.get(presetId);
|
||||
if (!preset || typeof token !== 'string' || token.trim().length === 0) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id: preset.id,
|
||||
name: preset.name,
|
||||
hostname: preset.hostname,
|
||||
token: token.trim(),
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
for (const update of updates) {
|
||||
await upsertManagedRemoteTunnelToken(update);
|
||||
}
|
||||
}
|
||||
|
||||
await writeSettingsToDisk(next);
|
||||
console.log(`[persistSettings] Successfully saved ${next.projects?.length || 0} projects to disk`);
|
||||
return formatSettingsResponse(next);
|
||||
});
|
||||
|
||||
return persistSettingsLock;
|
||||
};
|
||||
|
||||
return {
|
||||
readSettingsFromDisk,
|
||||
readSettingsFromDiskMigrated,
|
||||
writeSettingsToDisk,
|
||||
persistSettings,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,114 @@
|
||||
export const createGracefulShutdownRuntime = (dependencies) => {
|
||||
const {
|
||||
process,
|
||||
shutdownTimeoutMs,
|
||||
getExitOnShutdown,
|
||||
getIsShuttingDown,
|
||||
setIsShuttingDown,
|
||||
syncToHmrState,
|
||||
openCodeWatcherRuntime,
|
||||
sessionRuntime,
|
||||
getHealthCheckInterval,
|
||||
clearHealthCheckInterval,
|
||||
getTerminalRuntime,
|
||||
setTerminalRuntime,
|
||||
shouldSkipOpenCodeStop,
|
||||
getOpenCodePort,
|
||||
getOpenCodeProcess,
|
||||
setOpenCodeProcess,
|
||||
killProcessOnPort,
|
||||
getServer,
|
||||
getUiAuthController,
|
||||
setUiAuthController,
|
||||
getActiveTunnelController,
|
||||
setActiveTunnelController,
|
||||
tunnelAuthController,
|
||||
} = dependencies;
|
||||
|
||||
const gracefulShutdown = async (options = {}) => {
|
||||
if (getIsShuttingDown()) return;
|
||||
|
||||
setIsShuttingDown(true);
|
||||
syncToHmrState();
|
||||
console.log('Starting graceful shutdown...');
|
||||
const exitProcess = typeof options.exitProcess === 'boolean' ? options.exitProcess : getExitOnShutdown();
|
||||
|
||||
openCodeWatcherRuntime.stop();
|
||||
sessionRuntime.dispose();
|
||||
|
||||
const healthCheckInterval = getHealthCheckInterval();
|
||||
if (healthCheckInterval) {
|
||||
clearHealthCheckInterval(healthCheckInterval);
|
||||
}
|
||||
|
||||
const terminalRuntime = getTerminalRuntime();
|
||||
if (terminalRuntime) {
|
||||
try {
|
||||
await terminalRuntime.shutdown();
|
||||
} catch {
|
||||
} finally {
|
||||
setTerminalRuntime(null);
|
||||
}
|
||||
}
|
||||
|
||||
if (!shouldSkipOpenCodeStop()) {
|
||||
const portToKill = getOpenCodePort();
|
||||
const openCodeProcess = getOpenCodeProcess();
|
||||
|
||||
if (openCodeProcess) {
|
||||
console.log('Stopping OpenCode process...');
|
||||
try {
|
||||
openCodeProcess.close();
|
||||
} catch (error) {
|
||||
console.warn('Error closing OpenCode process:', error);
|
||||
}
|
||||
setOpenCodeProcess(null);
|
||||
}
|
||||
|
||||
killProcessOnPort(portToKill);
|
||||
} else {
|
||||
console.log('Skipping OpenCode shutdown (external server)');
|
||||
}
|
||||
|
||||
const server = getServer();
|
||||
if (server) {
|
||||
await Promise.race([
|
||||
new Promise((resolve) => {
|
||||
server.close(() => {
|
||||
console.log('HTTP server closed');
|
||||
resolve();
|
||||
});
|
||||
}),
|
||||
new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
console.warn('Server close timeout reached, forcing shutdown');
|
||||
resolve();
|
||||
}, shutdownTimeoutMs);
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
const uiAuthController = getUiAuthController();
|
||||
if (uiAuthController) {
|
||||
uiAuthController.dispose();
|
||||
setUiAuthController(null);
|
||||
}
|
||||
|
||||
const activeTunnelController = getActiveTunnelController();
|
||||
if (activeTunnelController) {
|
||||
console.log('Stopping active tunnel...');
|
||||
activeTunnelController.stop();
|
||||
setActiveTunnelController(null);
|
||||
tunnelAuthController.clearActiveTunnel();
|
||||
}
|
||||
|
||||
console.log('Graceful shutdown complete');
|
||||
if (exitProcess) {
|
||||
process.exit(0);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
gracefulShutdown,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,707 @@
|
||||
export const registerSkillRoutes = (app, dependencies) => {
|
||||
const {
|
||||
fs,
|
||||
path,
|
||||
os,
|
||||
resolveProjectDirectory,
|
||||
resolveOptionalProjectDirectory,
|
||||
readSettingsFromDisk,
|
||||
sanitizeSkillCatalogs,
|
||||
isUnsafeSkillRelativePath,
|
||||
refreshOpenCodeAfterConfigChange,
|
||||
clientReloadDelayMs,
|
||||
buildOpenCodeUrl,
|
||||
getOpenCodeAuthHeaders,
|
||||
getOpenCodePort,
|
||||
getSkillSources,
|
||||
discoverSkills,
|
||||
createSkill,
|
||||
updateSkill,
|
||||
deleteSkill,
|
||||
readSkillSupportingFile,
|
||||
writeSkillSupportingFile,
|
||||
deleteSkillSupportingFile,
|
||||
SKILL_SCOPE,
|
||||
SKILL_DIR,
|
||||
getCuratedSkillsSources,
|
||||
getCacheKey,
|
||||
getCachedScan,
|
||||
setCachedScan,
|
||||
parseSkillRepoSource,
|
||||
scanSkillsRepository,
|
||||
installSkillsFromRepository,
|
||||
scanClawdHubPage,
|
||||
installSkillsFromClawdHub,
|
||||
isClawdHubSource,
|
||||
getProfiles,
|
||||
getProfile,
|
||||
} = dependencies;
|
||||
|
||||
const findWorktreeRootForSkills = (workingDirectory) => {
|
||||
if (!workingDirectory) return null;
|
||||
let current = path.resolve(workingDirectory);
|
||||
while (true) {
|
||||
if (fs.existsSync(path.join(current, '.git'))) {
|
||||
return current;
|
||||
}
|
||||
const parent = path.dirname(current);
|
||||
if (parent === current) {
|
||||
return null;
|
||||
}
|
||||
current = parent;
|
||||
}
|
||||
};
|
||||
|
||||
const getSkillProjectAncestors = (workingDirectory) => {
|
||||
if (!workingDirectory) return [];
|
||||
const result = [];
|
||||
let current = path.resolve(workingDirectory);
|
||||
const stop = findWorktreeRootForSkills(workingDirectory) || current;
|
||||
while (true) {
|
||||
result.push(current);
|
||||
if (current === stop) break;
|
||||
const parent = path.dirname(current);
|
||||
if (parent === current) break;
|
||||
current = parent;
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const isPathInside = (candidatePath, parentPath) => {
|
||||
if (!candidatePath || !parentPath) return false;
|
||||
const normalizedCandidate = path.resolve(candidatePath);
|
||||
const normalizedParent = path.resolve(parentPath);
|
||||
return normalizedCandidate === normalizedParent || normalizedCandidate.startsWith(`${normalizedParent}${path.sep}`);
|
||||
};
|
||||
|
||||
const inferSkillScopeAndSourceFromPath = (skillPath, workingDirectory) => {
|
||||
const resolvedPath = typeof skillPath === 'string' ? path.resolve(skillPath) : '';
|
||||
const home = os.homedir();
|
||||
const source = resolvedPath.includes(`${path.sep}.agents${path.sep}skills${path.sep}`)
|
||||
? 'agents'
|
||||
: resolvedPath.includes(`${path.sep}.claude${path.sep}skills${path.sep}`)
|
||||
? 'claude'
|
||||
: 'opencode';
|
||||
|
||||
const projectAncestors = getSkillProjectAncestors(workingDirectory);
|
||||
const isProjectScoped = projectAncestors.some((ancestor) => {
|
||||
const candidates = [
|
||||
path.join(ancestor, '.opencode'),
|
||||
path.join(ancestor, '.claude', 'skills'),
|
||||
path.join(ancestor, '.agents', 'skills'),
|
||||
];
|
||||
return candidates.some((candidate) => isPathInside(resolvedPath, candidate));
|
||||
});
|
||||
|
||||
if (isProjectScoped) {
|
||||
return { scope: SKILL_SCOPE.PROJECT, source };
|
||||
}
|
||||
|
||||
const userRoots = [
|
||||
path.join(home, '.config', 'opencode'),
|
||||
path.join(home, '.opencode'),
|
||||
path.join(home, '.claude', 'skills'),
|
||||
path.join(home, '.agents', 'skills'),
|
||||
process.env.OPENCODE_CONFIG_DIR ? path.resolve(process.env.OPENCODE_CONFIG_DIR) : null,
|
||||
].filter(Boolean);
|
||||
|
||||
if (userRoots.some((root) => isPathInside(resolvedPath, root))) {
|
||||
return { scope: SKILL_SCOPE.USER, source };
|
||||
}
|
||||
|
||||
return { scope: SKILL_SCOPE.USER, source };
|
||||
};
|
||||
|
||||
const fetchOpenCodeDiscoveredSkills = async (workingDirectory) => {
|
||||
if (!getOpenCodePort()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(buildOpenCodeUrl('/skill', ''));
|
||||
if (workingDirectory) {
|
||||
url.searchParams.set('directory', workingDirectory);
|
||||
}
|
||||
|
||||
const response = await fetch(url.toString(), {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
...getOpenCodeAuthHeaders(),
|
||||
},
|
||||
signal: AbortSignal.timeout(8_000),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
if (!Array.isArray(payload)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return payload
|
||||
.map((item) => {
|
||||
const name = typeof item?.name === 'string' ? item.name.trim() : '';
|
||||
const location = typeof item?.location === 'string' ? item.location : '';
|
||||
const description = typeof item?.description === 'string' ? item.description : '';
|
||||
if (!name || !location) {
|
||||
return null;
|
||||
}
|
||||
const inferred = inferSkillScopeAndSourceFromPath(location, workingDirectory);
|
||||
return {
|
||||
name,
|
||||
path: location,
|
||||
scope: inferred.scope,
|
||||
source: inferred.source,
|
||||
description,
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const listGitIdentitiesForResponse = () => {
|
||||
try {
|
||||
const profiles = getProfiles();
|
||||
return profiles.map((p) => ({ id: p.id, name: p.name }));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const resolveGitIdentity = (profileId) => {
|
||||
if (!profileId) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const profile = getProfile(profileId);
|
||||
const sshKey = profile?.sshKey;
|
||||
if (typeof sshKey === 'string' && sshKey.trim()) {
|
||||
return { sshKey: sshKey.trim() };
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
app.get('/api/config/skills', async (req, res) => {
|
||||
try {
|
||||
const { directory, error } = await resolveProjectDirectory(req);
|
||||
if (!directory) {
|
||||
return res.status(400).json({ error });
|
||||
}
|
||||
const skills = (await fetchOpenCodeDiscoveredSkills(directory)) || discoverSkills(directory);
|
||||
|
||||
const enrichedSkills = skills.map((skill) => {
|
||||
const sources = getSkillSources(skill.name, directory, skill);
|
||||
return {
|
||||
...skill,
|
||||
sources
|
||||
};
|
||||
});
|
||||
|
||||
res.json({ skills: enrichedSkills });
|
||||
} catch (error) {
|
||||
console.error('Failed to list skills:', error);
|
||||
res.status(500).json({ error: 'Failed to list skills' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/config/skills/catalog', async (req, res) => {
|
||||
try {
|
||||
const { error } = await resolveOptionalProjectDirectory(req);
|
||||
if (error) {
|
||||
return res.status(400).json({ error });
|
||||
}
|
||||
|
||||
const curatedSources = getCuratedSkillsSources();
|
||||
const settings = await readSettingsFromDisk();
|
||||
const customSourcesRaw = sanitizeSkillCatalogs(settings.skillCatalogs) || [];
|
||||
|
||||
const customSources = customSourcesRaw.map((entry) => ({
|
||||
id: entry.id,
|
||||
label: entry.label,
|
||||
description: entry.source,
|
||||
source: entry.source,
|
||||
defaultSubpath: entry.subpath,
|
||||
gitIdentityId: entry.gitIdentityId,
|
||||
}));
|
||||
|
||||
const sources = [...curatedSources, ...customSources];
|
||||
const sourcesForUi = sources.map(({ gitIdentityId, ...rest }) => rest);
|
||||
|
||||
res.json({ ok: true, sources: sourcesForUi, itemsBySource: {}, pageInfoBySource: {} });
|
||||
} catch (error) {
|
||||
console.error('Failed to load skills catalog:', error);
|
||||
res.status(500).json({ ok: false, error: { kind: 'unknown', message: error.message || 'Failed to load catalog' } });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/config/skills/catalog/source', async (req, res) => {
|
||||
try {
|
||||
const { directory, error } = await resolveOptionalProjectDirectory(req);
|
||||
if (error) {
|
||||
return res.status(400).json({ ok: false, error: { kind: 'invalidSource', message: error } });
|
||||
}
|
||||
|
||||
const sourceId = typeof req.query.sourceId === 'string' ? req.query.sourceId : null;
|
||||
if (!sourceId) {
|
||||
return res.status(400).json({ ok: false, error: { kind: 'invalidSource', message: 'Missing sourceId' } });
|
||||
}
|
||||
|
||||
const refresh = String(req.query.refresh || '').toLowerCase() === 'true';
|
||||
const cursor = typeof req.query.cursor === 'string' ? req.query.cursor : null;
|
||||
|
||||
const curatedSources = getCuratedSkillsSources();
|
||||
const settings = await readSettingsFromDisk();
|
||||
const customSourcesRaw = sanitizeSkillCatalogs(settings.skillCatalogs) || [];
|
||||
|
||||
const customSources = customSourcesRaw.map((entry) => ({
|
||||
id: entry.id,
|
||||
label: entry.label,
|
||||
description: entry.source,
|
||||
source: entry.source,
|
||||
defaultSubpath: entry.subpath,
|
||||
gitIdentityId: entry.gitIdentityId,
|
||||
}));
|
||||
|
||||
const sources = [...curatedSources, ...customSources];
|
||||
const src = sources.find((entry) => entry.id === sourceId);
|
||||
|
||||
if (!src) {
|
||||
return res.status(404).json({ ok: false, error: { kind: 'invalidSource', message: 'Unknown source' } });
|
||||
}
|
||||
|
||||
const discovered = directory
|
||||
? ((await fetchOpenCodeDiscoveredSkills(directory)) || discoverSkills(directory))
|
||||
: [];
|
||||
const installedByName = new Map(discovered.map((s) => [s.name, s]));
|
||||
|
||||
if (src.sourceType === 'clawdhub' || isClawdHubSource(src.source)) {
|
||||
const scanned = await scanClawdHubPage({ cursor: cursor || null });
|
||||
if (!scanned.ok) {
|
||||
return res.status(500).json({ ok: false, error: scanned.error });
|
||||
}
|
||||
|
||||
const items = (scanned.items || []).map((item) => {
|
||||
const installed = installedByName.get(item.skillName);
|
||||
return {
|
||||
...item,
|
||||
sourceId: src.id,
|
||||
installed: installed
|
||||
? { isInstalled: true, scope: installed.scope, source: installed.source }
|
||||
: { isInstalled: false },
|
||||
};
|
||||
});
|
||||
|
||||
return res.json({ ok: true, items, nextCursor: scanned.nextCursor || null });
|
||||
}
|
||||
|
||||
const parsed = parseSkillRepoSource(src.source);
|
||||
if (!parsed.ok) {
|
||||
return res.status(400).json({ ok: false, error: parsed.error });
|
||||
}
|
||||
|
||||
const effectiveSubpath = src.defaultSubpath || parsed.effectiveSubpath || null;
|
||||
const cacheKey = getCacheKey({
|
||||
normalizedRepo: parsed.normalizedRepo,
|
||||
subpath: effectiveSubpath || '',
|
||||
identityId: src.gitIdentityId || '',
|
||||
});
|
||||
|
||||
let scanResult = !refresh ? getCachedScan(cacheKey) : null;
|
||||
if (!scanResult) {
|
||||
const scanned = await scanSkillsRepository({
|
||||
source: src.source,
|
||||
subpath: src.defaultSubpath,
|
||||
defaultSubpath: src.defaultSubpath,
|
||||
identity: resolveGitIdentity(src.gitIdentityId),
|
||||
});
|
||||
|
||||
if (!scanned.ok) {
|
||||
return res.status(500).json({ ok: false, error: scanned.error });
|
||||
}
|
||||
|
||||
scanResult = scanned;
|
||||
setCachedScan(cacheKey, scanResult);
|
||||
}
|
||||
|
||||
const items = (scanResult.items || []).map((item) => {
|
||||
const installed = installedByName.get(item.skillName);
|
||||
return {
|
||||
sourceId: src.id,
|
||||
...item,
|
||||
gitIdentityId: src.gitIdentityId,
|
||||
installed: installed
|
||||
? { isInstalled: true, scope: installed.scope, source: installed.source }
|
||||
: { isInstalled: false },
|
||||
};
|
||||
});
|
||||
|
||||
return res.json({ ok: true, items });
|
||||
} catch (error) {
|
||||
console.error('Failed to load catalog source:', error);
|
||||
return res.status(500).json({
|
||||
ok: false,
|
||||
error: { kind: 'unknown', message: error.message || 'Failed to load catalog source' },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/config/skills/scan', async (req, res) => {
|
||||
try {
|
||||
const { source, subpath, gitIdentityId } = req.body || {};
|
||||
const identity = resolveGitIdentity(gitIdentityId);
|
||||
|
||||
const result = await scanSkillsRepository({
|
||||
source,
|
||||
subpath,
|
||||
identity,
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
if (result.error?.kind === 'authRequired') {
|
||||
return res.status(401).json({
|
||||
ok: false,
|
||||
error: {
|
||||
...result.error,
|
||||
identities: listGitIdentitiesForResponse(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(400).json({ ok: false, error: result.error });
|
||||
}
|
||||
|
||||
res.json({ ok: true, items: result.items });
|
||||
} catch (error) {
|
||||
console.error('Failed to scan skills repository:', error);
|
||||
res.status(500).json({ ok: false, error: { kind: 'unknown', message: error.message || 'Failed to scan repository' } });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/config/skills/install', async (req, res) => {
|
||||
try {
|
||||
const {
|
||||
source,
|
||||
subpath,
|
||||
gitIdentityId,
|
||||
scope,
|
||||
targetSource,
|
||||
selections,
|
||||
conflictPolicy,
|
||||
conflictDecisions,
|
||||
} = req.body || {};
|
||||
|
||||
let workingDirectory = null;
|
||||
if (scope === 'project') {
|
||||
const resolved = await resolveProjectDirectory(req);
|
||||
if (!resolved.directory) {
|
||||
return res.status(400).json({
|
||||
ok: false,
|
||||
error: { kind: 'invalidSource', message: resolved.error || 'Project installs require a directory parameter' },
|
||||
});
|
||||
}
|
||||
workingDirectory = resolved.directory;
|
||||
}
|
||||
|
||||
if (isClawdHubSource(source)) {
|
||||
const result = await installSkillsFromClawdHub({
|
||||
scope,
|
||||
targetSource,
|
||||
workingDirectory,
|
||||
userSkillDir: SKILL_DIR,
|
||||
selections,
|
||||
conflictPolicy,
|
||||
conflictDecisions,
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
if (result.error?.kind === 'conflicts') {
|
||||
return res.status(409).json({ ok: false, error: result.error });
|
||||
}
|
||||
return res.status(400).json({ ok: false, error: result.error });
|
||||
}
|
||||
|
||||
const installed = result.installed || [];
|
||||
const skipped = result.skipped || [];
|
||||
const requiresReload = installed.length > 0;
|
||||
|
||||
if (requiresReload) {
|
||||
await refreshOpenCodeAfterConfigChange('skills install');
|
||||
}
|
||||
|
||||
return res.json({
|
||||
ok: true,
|
||||
installed,
|
||||
skipped,
|
||||
requiresReload,
|
||||
message: requiresReload ? 'Skills installed successfully. Reloading interface…' : 'No skills were installed',
|
||||
reloadDelayMs: requiresReload ? clientReloadDelayMs : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
const identity = resolveGitIdentity(gitIdentityId);
|
||||
|
||||
const result = await installSkillsFromRepository({
|
||||
source,
|
||||
subpath,
|
||||
identity,
|
||||
scope,
|
||||
targetSource,
|
||||
workingDirectory,
|
||||
userSkillDir: SKILL_DIR,
|
||||
selections,
|
||||
conflictPolicy,
|
||||
conflictDecisions,
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
if (result.error?.kind === 'conflicts') {
|
||||
return res.status(409).json({ ok: false, error: result.error });
|
||||
}
|
||||
|
||||
if (result.error?.kind === 'authRequired') {
|
||||
return res.status(401).json({
|
||||
ok: false,
|
||||
error: {
|
||||
...result.error,
|
||||
identities: listGitIdentitiesForResponse(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(400).json({ ok: false, error: result.error });
|
||||
}
|
||||
|
||||
const installed = result.installed || [];
|
||||
const skipped = result.skipped || [];
|
||||
const requiresReload = installed.length > 0;
|
||||
|
||||
if (requiresReload) {
|
||||
await refreshOpenCodeAfterConfigChange('skills install');
|
||||
}
|
||||
|
||||
res.json({
|
||||
ok: true,
|
||||
installed,
|
||||
skipped,
|
||||
requiresReload,
|
||||
message: requiresReload ? 'Skills installed successfully. Reloading interface…' : 'No skills were installed',
|
||||
reloadDelayMs: requiresReload ? clientReloadDelayMs : undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to install skills:', error);
|
||||
res.status(500).json({ ok: false, error: { kind: 'unknown', message: error.message || 'Failed to install skills' } });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/config/skills/:name', async (req, res) => {
|
||||
try {
|
||||
const skillName = req.params.name;
|
||||
const { directory, error } = await resolveProjectDirectory(req);
|
||||
if (!directory) {
|
||||
return res.status(400).json({ error });
|
||||
}
|
||||
const discoveredSkill = ((await fetchOpenCodeDiscoveredSkills(directory)) || [])
|
||||
.find((skill) => skill.name === skillName) || null;
|
||||
const sources = getSkillSources(skillName, directory, discoveredSkill);
|
||||
|
||||
res.json({
|
||||
name: skillName,
|
||||
sources: sources,
|
||||
scope: sources.md.scope,
|
||||
source: sources.md.source,
|
||||
exists: sources.md.exists
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to get skill sources:', error);
|
||||
res.status(500).json({ error: 'Failed to get skill configuration metadata' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/config/skills/:name/files/*filePath', async (req, res) => {
|
||||
try {
|
||||
const skillName = req.params.name;
|
||||
const filePath = decodeURIComponent(req.params.filePath);
|
||||
if (isUnsafeSkillRelativePath(filePath)) {
|
||||
return res.status(400).json({ error: 'Invalid file path' });
|
||||
}
|
||||
const { directory, error } = await resolveProjectDirectory(req);
|
||||
if (!directory) {
|
||||
return res.status(400).json({ error });
|
||||
}
|
||||
|
||||
const discoveredSkill = ((await fetchOpenCodeDiscoveredSkills(directory)) || [])
|
||||
.find((skill) => skill.name === skillName) || null;
|
||||
const sources = getSkillSources(skillName, directory, discoveredSkill);
|
||||
if (!sources.md.exists || !sources.md.dir) {
|
||||
return res.status(404).json({ error: 'Skill not found' });
|
||||
}
|
||||
|
||||
const content = readSkillSupportingFile(sources.md.dir, filePath);
|
||||
if (content === null) {
|
||||
return res.status(404).json({ error: 'File not found' });
|
||||
}
|
||||
|
||||
res.json({ path: filePath, content });
|
||||
} catch (error) {
|
||||
if (error && typeof error === 'object' && (error.code === 'EACCES' || error.code === 'EPERM')) {
|
||||
return res.status(403).json({ error: 'Access to file denied' });
|
||||
}
|
||||
console.error('Failed to read skill file:', error);
|
||||
res.status(500).json({ error: 'Failed to read skill file' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/config/skills/:name', async (req, res) => {
|
||||
try {
|
||||
const skillName = req.params.name;
|
||||
const { scope, source: skillSource, ...config } = req.body;
|
||||
const { directory, error } = await resolveProjectDirectory(req);
|
||||
if (!directory) {
|
||||
return res.status(400).json({ error });
|
||||
}
|
||||
|
||||
console.log('[Server] Creating skill:', skillName);
|
||||
console.log('[Server] Scope:', scope, 'Working directory:', directory);
|
||||
|
||||
createSkill(skillName, { ...config, source: skillSource }, directory, scope);
|
||||
await refreshOpenCodeAfterConfigChange('skill creation');
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
requiresReload: true,
|
||||
message: `Skill ${skillName} created successfully. Reloading interface…`,
|
||||
reloadDelayMs: clientReloadDelayMs,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to create skill:', error);
|
||||
res.status(500).json({ error: error.message || 'Failed to create skill' });
|
||||
}
|
||||
});
|
||||
|
||||
app.patch('/api/config/skills/:name', async (req, res) => {
|
||||
try {
|
||||
const skillName = req.params.name;
|
||||
const updates = req.body;
|
||||
const { directory, error } = await resolveProjectDirectory(req);
|
||||
if (!directory) {
|
||||
return res.status(400).json({ error });
|
||||
}
|
||||
|
||||
console.log(`[Server] Updating skill: ${skillName}`);
|
||||
console.log('[Server] Working directory:', directory);
|
||||
|
||||
updateSkill(skillName, updates, directory);
|
||||
await refreshOpenCodeAfterConfigChange('skill update');
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
requiresReload: true,
|
||||
message: `Skill ${skillName} updated successfully. Reloading interface…`,
|
||||
reloadDelayMs: clientReloadDelayMs,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Server] Failed to update skill:', error);
|
||||
res.status(500).json({ error: error.message || 'Failed to update skill' });
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/config/skills/:name/files/*filePath', async (req, res) => {
|
||||
try {
|
||||
const skillName = req.params.name;
|
||||
const filePath = decodeURIComponent(req.params.filePath);
|
||||
if (isUnsafeSkillRelativePath(filePath)) {
|
||||
return res.status(400).json({ error: 'Invalid file path' });
|
||||
}
|
||||
const { content } = req.body;
|
||||
const { directory, error } = await resolveProjectDirectory(req);
|
||||
if (!directory) {
|
||||
return res.status(400).json({ error });
|
||||
}
|
||||
|
||||
const discoveredSkill = ((await fetchOpenCodeDiscoveredSkills(directory)) || [])
|
||||
.find((skill) => skill.name === skillName) || null;
|
||||
const sources = getSkillSources(skillName, directory, discoveredSkill);
|
||||
if (!sources.md.exists || !sources.md.dir) {
|
||||
return res.status(404).json({ error: 'Skill not found' });
|
||||
}
|
||||
|
||||
writeSkillSupportingFile(sources.md.dir, filePath, content || '');
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `File ${filePath} saved successfully`,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error && typeof error === 'object' && (error.code === 'EACCES' || error.code === 'EPERM')) {
|
||||
return res.status(403).json({ error: 'Access to file denied' });
|
||||
}
|
||||
console.error('Failed to write skill file:', error);
|
||||
res.status(500).json({ error: error.message || 'Failed to write skill file' });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/config/skills/:name/files/*filePath', async (req, res) => {
|
||||
try {
|
||||
const skillName = req.params.name;
|
||||
const filePath = decodeURIComponent(req.params.filePath);
|
||||
if (isUnsafeSkillRelativePath(filePath)) {
|
||||
return res.status(400).json({ error: 'Invalid file path' });
|
||||
}
|
||||
const { directory, error } = await resolveProjectDirectory(req);
|
||||
if (!directory) {
|
||||
return res.status(400).json({ error });
|
||||
}
|
||||
|
||||
const discoveredSkill = ((await fetchOpenCodeDiscoveredSkills(directory)) || [])
|
||||
.find((skill) => skill.name === skillName) || null;
|
||||
const sources = getSkillSources(skillName, directory, discoveredSkill);
|
||||
if (!sources.md.exists || !sources.md.dir) {
|
||||
return res.status(404).json({ error: 'Skill not found' });
|
||||
}
|
||||
|
||||
deleteSkillSupportingFile(sources.md.dir, filePath);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `File ${filePath} deleted successfully`,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error && typeof error === 'object' && (error.code === 'EACCES' || error.code === 'EPERM')) {
|
||||
return res.status(403).json({ error: 'Access to file denied' });
|
||||
}
|
||||
console.error('Failed to delete skill file:', error);
|
||||
res.status(500).json({ error: error.message || 'Failed to delete skill file' });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/config/skills/:name', async (req, res) => {
|
||||
try {
|
||||
const skillName = req.params.name;
|
||||
const { directory, error } = await resolveProjectDirectory(req);
|
||||
if (!directory) {
|
||||
return res.status(400).json({ error });
|
||||
}
|
||||
|
||||
deleteSkill(skillName, directory);
|
||||
await refreshOpenCodeAfterConfigChange('skill deletion');
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
requiresReload: true,
|
||||
message: `Skill ${skillName} deleted successfully. Reloading interface…`,
|
||||
reloadDelayMs: clientReloadDelayMs,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to delete skill:', error);
|
||||
res.status(500).json({ error: error.message || 'Failed to delete skill' });
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,107 @@
|
||||
export const createStartupPipelineRuntime = (dependencies) => {
|
||||
const {
|
||||
createTerminalRuntime,
|
||||
createServerStartupRuntime,
|
||||
} = dependencies;
|
||||
|
||||
const run = async (options) => {
|
||||
const {
|
||||
app,
|
||||
server,
|
||||
express,
|
||||
fs,
|
||||
path,
|
||||
uiAuthController,
|
||||
buildAugmentedPath,
|
||||
searchPathFor,
|
||||
isExecutable,
|
||||
isRequestOriginAllowed,
|
||||
rejectWebSocketUpgrade,
|
||||
terminalHeartbeatIntervalMs,
|
||||
terminalRebindWindowMs,
|
||||
terminalMaxRebindsPerWindow,
|
||||
setupProxy,
|
||||
scheduleOpenCodeApiDetection,
|
||||
bootstrapOpenCodeAtStartup,
|
||||
staticRoutesRuntime,
|
||||
process,
|
||||
crypto,
|
||||
normalizeTunnelBootstrapTtlMs,
|
||||
readSettingsFromDiskMigrated,
|
||||
tunnelAuthController,
|
||||
startTunnelWithNormalizedRequest,
|
||||
gracefulShutdown,
|
||||
getSignalsAttached,
|
||||
setSignalsAttached,
|
||||
syncToHmrState,
|
||||
TUNNEL_MODE_QUICK,
|
||||
TUNNEL_MODE_MANAGED_LOCAL,
|
||||
TUNNEL_MODE_MANAGED_REMOTE,
|
||||
host,
|
||||
port,
|
||||
startupTunnelRequest,
|
||||
onTunnelReady,
|
||||
tunnelRuntimeContext,
|
||||
attachSignals,
|
||||
} = options;
|
||||
|
||||
const terminalRuntime = createTerminalRuntime({
|
||||
app,
|
||||
server,
|
||||
express,
|
||||
fs,
|
||||
path,
|
||||
uiAuthController,
|
||||
buildAugmentedPath,
|
||||
searchPathFor,
|
||||
isExecutable,
|
||||
isRequestOriginAllowed,
|
||||
rejectWebSocketUpgrade,
|
||||
TERMINAL_INPUT_WS_HEARTBEAT_INTERVAL_MS: terminalHeartbeatIntervalMs,
|
||||
TERMINAL_INPUT_WS_REBIND_WINDOW_MS: terminalRebindWindowMs,
|
||||
TERMINAL_INPUT_WS_MAX_REBINDS_PER_WINDOW: terminalMaxRebindsPerWindow,
|
||||
});
|
||||
|
||||
setupProxy(app);
|
||||
scheduleOpenCodeApiDetection();
|
||||
void bootstrapOpenCodeAtStartup();
|
||||
|
||||
staticRoutesRuntime.registerStaticRoutes(app);
|
||||
|
||||
const serverStartupRuntime = createServerStartupRuntime({
|
||||
process,
|
||||
crypto,
|
||||
server,
|
||||
normalizeTunnelBootstrapTtlMs,
|
||||
readSettingsFromDiskMigrated,
|
||||
tunnelAuthController,
|
||||
startTunnelWithNormalizedRequest,
|
||||
gracefulShutdown,
|
||||
getSignalsAttached,
|
||||
setSignalsAttached,
|
||||
syncToHmrState,
|
||||
TUNNEL_MODE_QUICK,
|
||||
TUNNEL_MODE_MANAGED_LOCAL,
|
||||
TUNNEL_MODE_MANAGED_REMOTE,
|
||||
});
|
||||
|
||||
const bindHost = serverStartupRuntime.resolveBindHost(host);
|
||||
const startupResult = await serverStartupRuntime.startListeningAndMaybeTunnel({
|
||||
port,
|
||||
bindHost,
|
||||
startupTunnelRequest,
|
||||
onTunnelReady,
|
||||
});
|
||||
tunnelRuntimeContext.setActivePort(startupResult.activePort);
|
||||
|
||||
serverStartupRuntime.attachProcessHandlers({ attachSignals });
|
||||
|
||||
return {
|
||||
terminalRuntime,
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
run,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
import { registerPwaManifestRoute } from './pwa-manifest-routes.js';
|
||||
|
||||
export const createStaticRoutesRuntime = (dependencies) => {
|
||||
const {
|
||||
fs,
|
||||
path,
|
||||
process,
|
||||
__dirname,
|
||||
express,
|
||||
resolveProjectDirectory,
|
||||
buildOpenCodeUrl,
|
||||
getOpenCodeAuthHeaders,
|
||||
readSettingsFromDiskMigrated,
|
||||
normalizePwaAppName,
|
||||
} = dependencies;
|
||||
|
||||
const resolveDistPath = () => {
|
||||
const env = typeof process.env.OPENCHAMBER_DIST_DIR === 'string' ? process.env.OPENCHAMBER_DIST_DIR.trim() : '';
|
||||
if (env) {
|
||||
return path.resolve(env);
|
||||
}
|
||||
return path.join(__dirname, '..', 'dist');
|
||||
};
|
||||
|
||||
const registerStaticRoutes = (app) => {
|
||||
const distPath = resolveDistPath();
|
||||
|
||||
if (fs.existsSync(distPath)) {
|
||||
console.log(`Serving static files from ${distPath}`);
|
||||
app.use(express.static(distPath, {
|
||||
setHeaders(res, filePath) {
|
||||
// Service workers should never be long-cached; iOS is especially sensitive.
|
||||
if (typeof filePath === 'string' && filePath.endsWith(`${path.sep}sw.js`)) {
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
registerPwaManifestRoute(app, {
|
||||
process,
|
||||
resolveProjectDirectory,
|
||||
buildOpenCodeUrl,
|
||||
getOpenCodeAuthHeaders,
|
||||
readSettingsFromDiskMigrated,
|
||||
normalizePwaAppName,
|
||||
});
|
||||
|
||||
app.get(/^(?!\/api|.*\.(js|css|svg|png|jpg|jpeg|gif|ico|woff|woff2|ttf|eot|map)).*$/, (_req, res) => {
|
||||
res.sendFile(path.join(distPath, 'index.html'));
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
console.warn(`Warning: ${distPath} not found, static files will not be served`);
|
||||
app.get(/^(?!\/api|.*\.(js|css|svg|png|jpg|jpeg|gif|ico|woff|woff2|ttf|eot|map)).*$/, (_req, res) => {
|
||||
res.status(404).send('Static files not found. Please build the application first.');
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
registerStaticRoutes,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,167 @@
|
||||
export const createThemeRuntime = (dependencies) => {
|
||||
const {
|
||||
fsPromises,
|
||||
path,
|
||||
themesDir,
|
||||
maxThemeJsonBytes,
|
||||
logger,
|
||||
} = dependencies;
|
||||
|
||||
const isNonEmptyString = (value) => typeof value === 'string' && value.trim().length > 0;
|
||||
const isValidThemeColor = (value) => isNonEmptyString(value);
|
||||
|
||||
const normalizeThemeJson = (raw) => {
|
||||
if (!raw || typeof raw !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const metadata = raw.metadata && typeof raw.metadata === 'object' ? raw.metadata : null;
|
||||
const colors = raw.colors && typeof raw.colors === 'object' ? raw.colors : null;
|
||||
if (!metadata || !colors) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const id = metadata.id;
|
||||
const name = metadata.name;
|
||||
const variant = metadata.variant;
|
||||
if (!isNonEmptyString(id) || !isNonEmptyString(name) || (variant !== 'light' && variant !== 'dark')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const primary = colors.primary;
|
||||
const surface = colors.surface;
|
||||
const interactive = colors.interactive;
|
||||
const status = colors.status;
|
||||
const syntax = colors.syntax;
|
||||
const syntaxBase = syntax && typeof syntax === 'object' ? syntax.base : null;
|
||||
const syntaxHighlights = syntax && typeof syntax === 'object' ? syntax.highlights : null;
|
||||
|
||||
if (!primary || !surface || !interactive || !status || !syntaxBase || !syntaxHighlights) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Minimal fields required by CSSVariableGenerator and diff/syntax rendering.
|
||||
const required = [
|
||||
primary.base,
|
||||
primary.foreground,
|
||||
surface.background,
|
||||
surface.foreground,
|
||||
surface.muted,
|
||||
surface.mutedForeground,
|
||||
surface.elevated,
|
||||
surface.elevatedForeground,
|
||||
surface.subtle,
|
||||
interactive.border,
|
||||
interactive.selection,
|
||||
interactive.selectionForeground,
|
||||
interactive.focusRing,
|
||||
interactive.hover,
|
||||
status.error,
|
||||
status.errorForeground,
|
||||
status.errorBackground,
|
||||
status.errorBorder,
|
||||
status.warning,
|
||||
status.warningForeground,
|
||||
status.warningBackground,
|
||||
status.warningBorder,
|
||||
status.success,
|
||||
status.successForeground,
|
||||
status.successBackground,
|
||||
status.successBorder,
|
||||
status.info,
|
||||
status.infoForeground,
|
||||
status.infoBackground,
|
||||
status.infoBorder,
|
||||
syntaxBase.background,
|
||||
syntaxBase.foreground,
|
||||
syntaxBase.keyword,
|
||||
syntaxBase.string,
|
||||
syntaxBase.number,
|
||||
syntaxBase.function,
|
||||
syntaxBase.variable,
|
||||
syntaxBase.type,
|
||||
syntaxBase.comment,
|
||||
syntaxBase.operator,
|
||||
syntaxHighlights.diffAdded,
|
||||
syntaxHighlights.diffRemoved,
|
||||
syntaxHighlights.lineNumber,
|
||||
];
|
||||
|
||||
if (!required.every(isValidThemeColor)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tags = Array.isArray(metadata.tags)
|
||||
? metadata.tags.filter((tag) => typeof tag === 'string' && tag.trim().length > 0)
|
||||
: [];
|
||||
|
||||
return {
|
||||
...raw,
|
||||
metadata: {
|
||||
...metadata,
|
||||
id: id.trim(),
|
||||
name: name.trim(),
|
||||
description: typeof metadata.description === 'string' ? metadata.description : '',
|
||||
version: typeof metadata.version === 'string' && metadata.version.trim().length > 0 ? metadata.version : '1.0.0',
|
||||
variant,
|
||||
tags,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const readCustomThemesFromDisk = async () => {
|
||||
try {
|
||||
const entries = await fsPromises.readdir(themesDir, { withFileTypes: true });
|
||||
const themes = [];
|
||||
const seen = new Set();
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile()) continue;
|
||||
if (!entry.name.toLowerCase().endsWith('.json')) continue;
|
||||
|
||||
const filePath = path.join(themesDir, entry.name);
|
||||
try {
|
||||
const stat = await fsPromises.stat(filePath);
|
||||
if (!stat.isFile()) continue;
|
||||
if (stat.size > maxThemeJsonBytes) {
|
||||
logger.warn(`[themes] Skip ${entry.name}: too large (${stat.size} bytes)`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const rawText = await fsPromises.readFile(filePath, 'utf8');
|
||||
const parsed = JSON.parse(rawText);
|
||||
const normalized = normalizeThemeJson(parsed);
|
||||
if (!normalized) {
|
||||
logger.warn(`[themes] Skip ${entry.name}: invalid theme JSON`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const id = normalized.metadata.id;
|
||||
if (seen.has(id)) {
|
||||
logger.warn(`[themes] Skip ${entry.name}: duplicate theme id "${id}"`);
|
||||
continue;
|
||||
}
|
||||
|
||||
seen.add(id);
|
||||
themes.push(normalized);
|
||||
} catch (error) {
|
||||
logger.warn(`[themes] Failed to read ${entry.name}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
return themes;
|
||||
} catch (error) {
|
||||
// Missing dir is fine.
|
||||
if (error && typeof error === 'object' && error.code === 'ENOENT') {
|
||||
return [];
|
||||
}
|
||||
logger.warn('[themes] Failed to list custom themes dir:', error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
normalizeThemeJson,
|
||||
readCustomThemesFromDisk,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,94 @@
|
||||
import { printTunnelWarning } from '../cloudflare-tunnel.js';
|
||||
import { createTunnelService } from '../tunnels/index.js';
|
||||
import { createTunnelRoutesRuntime } from '../tunnels/routes.js';
|
||||
|
||||
export const createTunnelWiringRuntime = (dependencies) => {
|
||||
const {
|
||||
crypto,
|
||||
URL,
|
||||
tunnelProviderRegistry,
|
||||
tunnelAuthController,
|
||||
readSettingsFromDiskMigrated,
|
||||
readManagedRemoteTunnelConfigFromDisk,
|
||||
normalizeTunnelProvider,
|
||||
normalizeTunnelMode,
|
||||
normalizeOptionalPath,
|
||||
normalizeManagedRemoteTunnelHostname,
|
||||
normalizeTunnelBootstrapTtlMs,
|
||||
normalizeTunnelSessionTtlMs,
|
||||
isSupportedTunnelMode,
|
||||
upsertManagedRemoteTunnelToken,
|
||||
resolveManagedRemoteTunnelToken,
|
||||
TUNNEL_MODE_QUICK,
|
||||
TUNNEL_MODE_MANAGED_LOCAL,
|
||||
TUNNEL_MODE_MANAGED_REMOTE,
|
||||
TUNNEL_PROVIDER_CLOUDFLARE,
|
||||
TunnelServiceError,
|
||||
getActiveTunnelController,
|
||||
setActiveTunnelController,
|
||||
getRuntimeManagedRemoteTunnelHostname,
|
||||
setRuntimeManagedRemoteTunnelHostname,
|
||||
getRuntimeManagedRemoteTunnelToken,
|
||||
setRuntimeManagedRemoteTunnelToken,
|
||||
} = dependencies;
|
||||
|
||||
const initialize = (app, initialPort) => {
|
||||
let activePort = initialPort;
|
||||
|
||||
const tunnelService = createTunnelService({
|
||||
registry: tunnelProviderRegistry,
|
||||
getController: getActiveTunnelController,
|
||||
setController: setActiveTunnelController,
|
||||
getActivePort: () => activePort,
|
||||
onQuickTunnelWarning: () => {
|
||||
printTunnelWarning();
|
||||
},
|
||||
});
|
||||
|
||||
const tunnelRoutesRuntime = createTunnelRoutesRuntime({
|
||||
crypto,
|
||||
URL,
|
||||
tunnelService,
|
||||
tunnelProviderRegistry,
|
||||
tunnelAuthController,
|
||||
readSettingsFromDiskMigrated,
|
||||
readManagedRemoteTunnelConfigFromDisk,
|
||||
normalizeTunnelProvider,
|
||||
normalizeTunnelMode,
|
||||
normalizeOptionalPath,
|
||||
normalizeManagedRemoteTunnelHostname,
|
||||
normalizeTunnelBootstrapTtlMs,
|
||||
normalizeTunnelSessionTtlMs,
|
||||
isSupportedTunnelMode,
|
||||
upsertManagedRemoteTunnelToken,
|
||||
resolveManagedRemoteTunnelToken,
|
||||
TUNNEL_MODE_QUICK,
|
||||
TUNNEL_MODE_MANAGED_LOCAL,
|
||||
TUNNEL_MODE_MANAGED_REMOTE,
|
||||
TUNNEL_PROVIDER_CLOUDFLARE,
|
||||
TunnelServiceError,
|
||||
getActivePort: () => activePort,
|
||||
getRuntimeManagedRemoteTunnelHostname,
|
||||
setRuntimeManagedRemoteTunnelHostname,
|
||||
getRuntimeManagedRemoteTunnelToken,
|
||||
setRuntimeManagedRemoteTunnelToken,
|
||||
getActiveTunnelController,
|
||||
setActiveTunnelController,
|
||||
});
|
||||
|
||||
tunnelRoutesRuntime.registerRoutes(app);
|
||||
|
||||
return {
|
||||
tunnelService,
|
||||
startTunnelWithNormalizedRequest: (...args) => tunnelRoutesRuntime.startTunnelWithNormalizedRequest(...args),
|
||||
getActivePort: () => activePort,
|
||||
setActivePort: (value) => {
|
||||
activePort = value;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
initialize,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,107 @@
|
||||
export const createOpenCodeWatcherRuntime = (deps) => {
|
||||
const {
|
||||
waitForOpenCodePort,
|
||||
buildOpenCodeUrl,
|
||||
getOpenCodeAuthHeaders,
|
||||
parseSseDataPayload,
|
||||
onPayload,
|
||||
} = deps;
|
||||
|
||||
let abortController = null;
|
||||
|
||||
const start = async () => {
|
||||
if (abortController) {
|
||||
return;
|
||||
}
|
||||
|
||||
await waitForOpenCodePort();
|
||||
|
||||
abortController = new AbortController();
|
||||
const signal = abortController.signal;
|
||||
|
||||
let attempt = 0;
|
||||
const run = async () => {
|
||||
while (!signal.aborted) {
|
||||
attempt += 1;
|
||||
let upstream;
|
||||
let reader;
|
||||
try {
|
||||
const url = buildOpenCodeUrl('/global/event', '');
|
||||
upstream = await fetch(url, {
|
||||
headers: {
|
||||
Accept: 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
...getOpenCodeAuthHeaders(),
|
||||
},
|
||||
signal,
|
||||
});
|
||||
|
||||
if (!upstream.ok || !upstream.body) {
|
||||
throw new Error(`bad status ${upstream.status}`);
|
||||
}
|
||||
|
||||
console.log('[PushWatcher] connected');
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
reader = upstream.body.getReader();
|
||||
let buffer = '';
|
||||
|
||||
while (!signal.aborted) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
|
||||
buffer += decoder.decode(value, { stream: true }).replace(/\r\n/g, '\n');
|
||||
|
||||
let separatorIndex = buffer.indexOf('\n\n');
|
||||
while (separatorIndex !== -1) {
|
||||
const block = buffer.slice(0, separatorIndex);
|
||||
buffer = buffer.slice(separatorIndex + 2);
|
||||
separatorIndex = buffer.indexOf('\n\n');
|
||||
const payload = parseSseDataPayload(block);
|
||||
onPayload(payload);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (signal.aborted) {
|
||||
return;
|
||||
}
|
||||
console.warn('[PushWatcher] disconnected', error?.message ?? error);
|
||||
} finally {
|
||||
try {
|
||||
if (reader) {
|
||||
await reader.cancel();
|
||||
reader.releaseLock();
|
||||
} else if (upstream?.body && !upstream.body.locked) {
|
||||
await upstream.body.cancel();
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
const backoffMs = Math.min(1000 * Math.pow(2, Math.min(attempt, 5)), 30000);
|
||||
await new Promise((resolve) => setTimeout(resolve, backoffMs));
|
||||
}
|
||||
};
|
||||
|
||||
void run();
|
||||
};
|
||||
|
||||
const stop = () => {
|
||||
if (!abortController) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
abortController.abort();
|
||||
} catch {
|
||||
}
|
||||
abortController = null;
|
||||
};
|
||||
|
||||
return {
|
||||
start,
|
||||
stop,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user