Compare commits

...

222 Commits

Author SHA1 Message Date
Shantur Rathore
dcbe3475ed chore(proxy): trace upstream requests
Log the exact upstream OpenCode target URL, redacted headers, and JSON body (best-effort for streams) when trace logging is enabled.
2026-02-08 17:54:12 +00:00
Shantur Rathore
338a88fb5a feat(server): add HTTPS with self-signed certs
Default to HTTPS with optional loopback HTTP, generate/rotate self-signed certs via node-forge, and surface Local/Remote connection URLs. Update /api/meta schema, UI remote access overlay, and desktop shells to follow the new startup output.
2026-02-08 15:48:00 +00:00
Shantur Rathore
7eb1551e4b Min server 0.10.2 2026-02-07 23:40:14 +00:00
Shantur Rathore
0414f924e6 Bump version to 0.10.1 2026-02-07 23:39:39 +00:00
Shantur Rathore
9456871271 chore(deps): install tauri keepawake api 2026-02-07 22:58:35 +00:00
Shantur Rathore
5b4edef785 feat(desktop): prevent sleep while instances busy 2026-02-07 22:53:46 +00:00
Shantur Rathore
6b81d0d703 fix(ui): keep command picker highlight in sync 2026-02-07 22:38:17 +00:00
Shantur Rathore
4097637169 fix(ui): preserve question custom input on refocus 2026-02-07 22:08:38 +00:00
Shantur Rathore
9bd66e7297 Merge branch 'dev' of github.com:NeuralNomadsAI/CodeNomad into dev 2026-02-07 21:37:50 +00:00
Shantur Rathore
883b0724e0 Merge pull request #121 from jderehag/dev
feat(ui): add PWA support with vite-plugin-pwa
2026-02-07 21:34:29 +00:00
Shantur Rathore
7b6ed88be4 fix(ui): integrate PWA build and avoid api caching
Move PWA config into the default Vite build, ensure the PWA icon source is generated, and restrict Workbox caching to static assets only. Update server UI build wiring and clarify TLS requirements in docs.
2026-02-07 21:33:14 +00:00
Shantur Rathore
e0bb867948 feat(ui): add enter-to-submit toggle for prompt input 2026-02-07 19:18:39 +00:00
Shantur Rathore
ca28f503b7 chore(ui): refine thinking command palette copy 2026-02-07 18:58:23 +00:00
Shantur Rathore
c83028abc2 feat(ui): label root worktree as workspace
Display the root checkout as 'Workspace' in the worktree selector to avoid confusing 'root' terminology.
2026-02-07 16:17:34 +00:00
Shantur Rathore
60406ca8fb feat(ui): show worktree badge in session list
Render a worktree pill on parent sessions using the session status chip styling, with a distinct icon and selection-aware colors.
2026-02-07 16:15:16 +00:00
Shantur Rathore
e878c3c83b fix(instance-events): unwrap payload-only SSE events
Accept OpenCode SSE chunks shaped as { payload: { type, ... } } even when no directory is present, and attach directory when available to avoid dropping heartbeat events as malformed.
2026-02-07 16:00:28 +00:00
Shantur Rathore
bdd3fe8899 fix(worktrees): prune stale worktree mappings
Fall back to root when a mapped worktree slug is missing and persistently remove missing slugs from the worktree map to prevent proxy 404s.
2026-02-07 15:55:35 +00:00
Shantur Rathore
3cfaf689e7 fix(worktrees): disable selector outside git repos
Expose isGitRepo on worktree listing and show Worktree: Unavailable while disabling the dropdown when a workspace folder is not a Git repository.
2026-02-07 15:23:27 +00:00
Shantur Rathore
b41da03e8a feat(worktrees): refine worktree selector UX 2026-02-07 14:57:34 +00:00
Shantur Rathore
ef14b9acb6 worktrees - Implementation 2026-02-07 11:46:56 +00:00
Jesper Derehag
99474955af feat(ui): add PWA support with vite-plugin-pwa
- Add vite.config.pwa.ts extending the base config with VitePWA plugin
- Generate PWA icons at build time from source logo via @vite-pwa/assets-generator
- Add web app manifest with name, theme color, display overrides
- Add Workbox runtime caching: NetworkFirst for API, CacheFirst for assets
- Set navigateFallback to null to preserve server-side auth redirects
- Server build uses build:pwa for PWA-enabled output; Electron/Tauri use
  the base build without PWA

Signed-off-by: Jesper Derehag <jderehag@hotmail.com>
2026-02-07 00:18:28 +01:00
Shantur Rathore
6f73adaef6 feat(ui): move context usage pills to right drawer header 2026-02-06 10:34:44 +00:00
Shantur Rathore
e2ff758003 feat(ui): add toggleable session search in left drawer 2026-02-06 10:25:37 +00:00
Shantur Rathore
748a99c9c4 fix(ui): split left drawer header into two rows 2026-02-06 10:18:12 +00:00
Shantur Rathore
db2d764cce fix(ui): refine instance drawer layout and controls 2026-02-06 10:10:42 +00:00
Shantur Rathore
157fe9d6b4 feat(ui): switch message actions to icon buttons 2026-02-05 23:42:48 +00:00
Shantur Rathore
6c42b64466 feat(ui): copy tool call header title 2026-02-05 23:30:38 +00:00
Shantur Rathore
88605a4617 feat(ui): add copy option for selected text 2026-02-05 23:20:13 +00:00
Shantur Rathore
e8f8e7bd65 fix(ui): avoid trailing blank line after quote insert 2026-02-05 23:17:22 +00:00
Shantur Rathore
750a87ef45 fix(ui): render task steps from child session 2026-02-05 23:08:59 +00:00
Shantur Rathore
8fda9aed71 fix(ui): focus prompt on session activate 2026-02-04 14:20:50 +00:00
Shantur Rathore
7e1dab8384 fix(electron): stop server process tree on quit 2026-02-04 10:28:51 +00:00
Shantur Rathore
5b24f0cd40 fix(ui): tighten question tool layout
Remove the redundant header row, tighten spacing, and square off question cards. Also adjust answered question container styling to match tool call layout.
2026-02-04 00:34:40 +00:00
Shantur Rathore
a6b1f4ba19 fix(ui): improve question tool contrast
Make question tool prompt, labels, and the type pill use primary text color for readability in light mode, and bump the Q header line to text-sm.
2026-02-04 00:20:19 +00:00
Shantur Rathore
df02b7cdca fix(ui): repair question tool styling
Use token-backed surface/background classes for the question tool cards and ensure radio/checkbox inputs use accent-color so the view renders correctly in both light and dark themes.
2026-02-04 00:14:50 +00:00
Shantur Rathore
06b0d03c31 fix(ui): align stop button icon contrast
Use --text-inverted for stop button icon color in dark mode so it matches send button styling, with a safe fallback in CSS.
2026-02-03 22:22:47 +00:00
Shantur Rathore
fd22a5ed9d fix(ui): restore stop button styling
Avoid color-mix for the stop button danger palette so it renders consistently across runtimes; add safe rgba fallbacks for the background colors.
2026-02-03 22:15:03 +00:00
Shantur Rathore
86db407c0b fix(ui): restore tool call colors in dark mode
Use a dedicated --text-on-accent token for accent chips/checkmarks and tweak task list item surfaces so task/todo renderers keep contrast in dark mode.
2026-02-03 22:09:02 +00:00
Shantur Rathore
f1520be777 Bump version to 0.9.5 2026-02-03 22:01:41 +00:00
Shantur Rathore
8a91e04ff9 Bump to v0.9.4 2026-02-03 20:22:17 +00:00
Shantur Rathore
76b1134c95 fix(ui): apply theme before initial render 2026-02-03 20:12:02 +00:00
Shantur Rathore
d98d519fd3 feat(ui): persist theme preference
Persist system/light/dark theme mode in app config and default new installs to system so the UI follows OS theme unless overridden.
2026-02-03 19:42:24 +00:00
Shantur Rathore
02407e0f7a fix(ui): restore dark tab and tool output styling
Use tokenized border contrast so dark mode borders stay subtle, keep instance tab status dots vivid in dark themes, and adjust tool-call code block header background via a dedicated token.
2026-02-03 19:02:47 +00:00
Shantur Rathore
0261154a5e feat(ui): add delete action for message parts 2026-02-03 18:32:54 +00:00
Shantur Rathore
d2b68159be chore(opencode-config): bump @opencode-ai/plugin 2026-02-03 17:37:02 +00:00
Shantur Rathore
aab0692403 fix(ui): tune light mode contrast 2026-02-03 17:37:02 +00:00
Shantur Rathore
17a3e43ac7 feat(ui): add system/light/dark theme toggle
Add a 3-state theme toggle in folder selection and instance tabs, and update tokens/styles so light mode has readable contrast. Sync MUI surfaces and Shiki highlighting to CSS variables to prevent stale colors when switching themes.
2026-02-03 16:49:42 +00:00
Shantur Rathore
a2127a11ac fix(server): include symlink directories in listings
Fixes https://github.com/NeuralNomadsAI/CodeNomad/issues/106
2026-02-03 15:22:49 +00:00
Shantur Rathore
ea4c687125 chore: add MIT License 2026-02-03 15:08:24 +00:00
Shantur Rathore
de20b3adf3 fix(ui): allow collapsing active parent thread 2026-02-03 15:07:05 +00:00
Shantur Rathore
929e79befd chore(license): add MIT license
Clarifies usage and redistribution terms across the monorepo.
2026-02-02 11:22:49 +00:00
Shantur Rathore
3522d3dff5 fix(electron): quit on last window close 2026-01-31 11:24:56 +00:00
Shantur Rathore
1af01680ee feat(ui): add session sidebar search and bulk selection
Adds an optional session filter bar to the left sidebar with title search across parent/child sessions and a scoped Select All. Introduces multi-select checkboxes, bulk delete with clear selection controls, and confirmation dialogs for both single and bulk deletions using the existing alert dialog flow. Updates session i18n strings across supported locales.
2026-01-30 17:34:25 +00:00
Shantur Rathore
67f5f830a3 Bump to v0.9.3 2026-01-29 22:37:34 +00:00
Shantur Rathore
81102cc6bf fix(ui): rename forked session to parent title 2026-01-29 22:34:30 +00:00
Shantur Rathore
afa7243eab feat(server): allow skipping internal auth
Add --dangerously-skip-auth / CODENOMAD_SKIP_AUTH for trusted-perimeter deployments so users behind SSO/VPN don't need a second login.
2026-01-29 20:38:05 +00:00
Shantur Rathore
37b7c1e53c fix(server): enforce workspace directory via x-opencode-directory 2026-01-28 23:41:32 +00:00
Shantur Rathore
ba61ab79e2 fix(tauri): prevent quit deadlock and exit loop 2026-01-28 20:19:57 +00:00
Shantur Rathore
37d075fbb3 fix(tauri): allow tauri.localhost internal navigation 2026-01-28 19:41:39 +00:00
Shantur Rathore
2961d41be3 fix(ui): open external toast links via system browser 2026-01-28 19:24:33 +00:00
Shantur Rathore
1bb5aedfdb chore(ui): widen left sidebar width limits 2026-01-28 18:50:05 +00:00
Shantur Rathore
0a793fb1c6 refactor(ui): consolidate sidebar selector shortcut hints 2026-01-28 18:03:20 +00:00
Shantur Rathore
a401eeec11 fix(ui): stabilize streaming message/tool rendering
Avoid remounting message blocks on part updates so tool call UI state persists. Render tool/message content from store and stabilize tool output scrolling during streaming.
2026-01-28 17:55:44 +00:00
Shantur Rathore
d9bcc66930 Merge pull request #102 from bizzkoot/fix/question-tool-ux-improvements
fix(ui): Improve Question Tool UX (Enter Key & Auto-focus)
2026-01-28 15:50:57 +00:00
bizzkoot
01921e3454 fix(ui): improve question tool UX (enter key & autofocus) 2026-01-28 21:01:49 +08:00
Shantur Rathore
158f6e25cf feat(ui): add favorite models to selector 2026-01-26 20:24:05 +00:00
Shantur Rathore
562c4b2637 feat(ui): add dismiss button to toasts 2026-01-26 13:42:58 +00:00
Shantur Rathore
51fd5d87f7 feat(ui): toast when UI updates 2026-01-26 13:36:36 +00:00
Shantur Rathore
28fb56bfa1 Minimum server 0.9.2 2026-01-26 13:23:14 +00:00
Shantur Rathore
c1052b36dc bump version to 0.9.2 2026-01-26 13:15:02 +00:00
Shantur Rathore
c62c9b1c78 feat(ui): add language selector
Adds a language dropdown to the folder picker using the shared selector UI and persists selection to preferences.locale.
2026-01-26 13:11:05 +00:00
Shantur Rathore
feccbd13bd feat(ui): add locales and split catalogs
Adds Spanish, French, Russian, Japanese, and Simplified Chinese catalogs and wires supported locales into the i18n layer.
2026-01-26 12:56:26 +00:00
Shantur Rathore
5b1e21345f feat(ui): localize UI strings
Converts hardcoded UI copy to i18n keys across the app, adds global translation for non-component modules, and splits the English catalog into feature modules with duplicate-key detection.
2026-01-26 12:26:12 +00:00
Shantur Rathore
33939f4096 feat(ui): add i18n scaffolding
Adds a minimal i18n provider with locale preference support and migrates folder selection copy to message keys.
2026-01-26 10:22:03 +00:00
Shantur Rathore
96f5a0ab44 Update min Server version to 0.9.1 2026-01-25 18:05:37 +00:00
Shantur Rathore
d9f7735c94 ui: show selector shortcuts inline 2026-01-25 17:55:46 +00:00
Shantur Rathore
4aae8ab720 feat(ui): add model thinking selector 2026-01-25 17:39:38 +00:00
Shantur Rathore
b83c69f002 chore(shutdown): log CLI kill timeout
Log when Electron/Tauri force-kill the CLI during shutdown so orphaned instance reports are easier to diagnose.
2026-01-25 11:03:16 +00:00
Shantur Rathore
c74e0b89f7 fix(shutdown): stop instances before app exit
Prevent desktop wrappers from SIGKILLing the CLI during shutdown, which could orphan OpenCode workspace processes. Shut down workspaces earlier/in parallel and increase the quit grace period.
2026-01-25 11:01:50 +00:00
Shantur Rathore
9ee7ff9509 feat(ui): move folder picker subtitle 2026-01-25 10:35:01 +00:00
Shantur Rathore
74a21d6418 Bump version to 0.9.1 for UI release 2026-01-25 00:27:37 +00:00
Shantur Rathore
15f390ade7 ci: allow manual release-ui on main/dev 2026-01-25 00:23:33 +00:00
Shantur Rathore
bb4e3815d1 feat(ui): show GitHub stars 2026-01-25 00:21:06 +00:00
Shantur Rathore
8fa0175b98 feat(ui): improve folder picker layout 2026-01-25 00:09:22 +00:00
Shantur Rathore
ee59622b98 Upgrade min version to 0.9.0 2026-01-24 19:23:01 +00:00
Shantur Rathore
a1452ad353 Add release notes command 2026-01-24 19:21:56 +00:00
Shantur Rathore
0c9284e57e Bump version to 0.9.0 2026-01-24 16:17:14 +00:00
Shantur Rathore
0766185ff6 fix(server): stop workspace process groups 2026-01-24 14:41:09 +00:00
Shantur Rathore
effb30d98e feat(ui): polish task steps section
Rename Tasks to Steps and remove list padding for a flush, compact steps view.
2026-01-24 10:35:15 +00:00
Shantur Rathore
4da69b5a20 feat(ui): show task model in headers
Task prompt/output headers now include provider/model metadata when available, alongside agent.
2026-01-24 10:29:02 +00:00
Shantur Rathore
3d3337c7b8 feat(ui): render task prompt/output panes
Task tool calls now show prompt, summary, and output with independent scroll; markdown rendering supports cache keys to avoid collisions.
2026-01-23 22:39:04 +00:00
Shantur Rathore
f0b43dbc68 feat(filesystem): add create-folder API for workspace picker
Adds a secure endpoint for creating a single subfolder in the current filesystem listing, and wires the non-native directory browser UI to create + enter the new folder.
2026-01-23 12:33:15 +00:00
Shantur Rathore
b0eb9aec64 Min server to 0.8.1 2026-01-22 23:05:49 +00:00
Shantur Rathore
8c48455ae5 fix(server): prefer highest available UI version
Selects the newest UI across bundled/current/previous with a tie-break for current, and only downloads remote UI when it is strictly newer. This prevents stale cached UIs from overriding a newer bundled release.
2026-01-22 23:04:53 +00:00
Shantur Rathore
292f695395 Bump version to 0.8.1 2026-01-22 22:32:52 +00:00
Shantur Rathore
4ea710c735 feat(ui): render apply_patch multi-file diffs 2026-01-22 22:32:03 +00:00
Shantur Rathore
f5d4cb6917 refactor(ui): split ToolCall into focused modules 2026-01-22 21:54:18 +00:00
Shantur Rathore
1e53e06424 Change minVersion to 0.8.0 2026-01-22 19:16:25 +00:00
Shantur Rathore
2530cd4fc8 Bump to v0.8.0 2026-01-22 18:17:23 +00:00
Shantur Rathore
b25fb0073e fix(cloudflare): serve version.json as static asset
Avoid Workers billing for /version.json by removing worker-first routing and generating static _headers rules during manifest build.
2026-01-22 18:05:01 +00:00
Shantur Rathore
c01846f7fd ci: run release-ui in release pipeline 2026-01-22 17:29:49 +00:00
Shantur Rathore
dfd397803f Bump version to 0.7.6 2026-01-22 17:14:28 +00:00
Shantur Rathore
267f1592c4 chore: ignore local artifacts and add cloudflare lockfile 2026-01-22 16:42:47 +00:00
Shantur Rathore
668ac7fa88 ci: publish remote UI on main 2026-01-22 16:40:20 +00:00
Shantur Rathore
43a476e967 fix(cloudflare): use custom domain and remote R2 uploads 2026-01-22 16:29:23 +00:00
Shantur Rathore
adbfab5c25 feat(cloudflare): worker-hosted version.json for UI updates 2026-01-22 16:16:36 +00:00
Shantur Rathore
02f1284f7f fix(ui): emit ui-version.json and show UI source 2026-01-22 15:17:09 +00:00
Shantur Rathore
a014ce555a feat(server): auto-update UI via remote manifest 2026-01-22 15:12:32 +00:00
Shantur Rathore
db3c13c463 fix(ui): allow spaces in question custom answers
Stop trimming custom answer input on each keystroke and instead normalize answers on submit so multi-word custom responses work.
2026-01-22 09:38:38 +00:00
Shantur Rathore
7c0bf382ba fix(ui): add permission actions for unresolved requests
Render Allow/Deny buttons in the permissions control center fallback when a permission request cannot be linked to a tool-call, enabling responses for global permissions like doom_loop.
2026-01-21 14:17:08 +00:00
Shantur Rathore
6e9c5a88b4 fix(ui): allow out-of-order permission clicks
Show permission action buttons for queued tool calls while keeping keyboard shortcuts bound to the first active request. Prevent permission center list clicks from overriding keyboard-active ordering.
2026-01-21 13:26:37 +00:00
Shantur Rathore
0bf22a323f Bump version to 0.7.5 2026-01-21 12:26:22 +00:00
Shantur Rathore
cc997576cf fix(ui): stabilize question tool selection and custom answers 2026-01-21 12:25:51 +00:00
Shantur Rathore
05f193df7b fix(ui): auto-select first ready instance after refresh 2026-01-20 19:28:56 +00:00
Shantur Rathore
c9b5bb1b7a Release 0.7.4 2026-01-20 19:20:41 +00:00
Shantur Rathore
ba1013cd35 fix(ui): re-link pending question tool parts (#74) 2026-01-20 19:20:18 +00:00
Shantur Rathore
ec6428702b Bump version to 0.7.3 2026-01-20 18:49:18 +00:00
Shantur Rathore
e08ebb2057 fix(server): honor --host binding
Fixes #75
2026-01-20 18:47:40 +00:00
Shantur Rathore
9683f90f7e fix(ui): insert full paths for @file mentions 2026-01-20 18:47:40 +00:00
Shantur Rathore
06cb986aa6 fix(ui): allow Tab to select from picker
Fixes #77
2026-01-20 18:47:40 +00:00
Shantur Rathore
a85c2f1700 fix(ui): collapse prompt input after send
Fixes #76
2026-01-20 18:47:40 +00:00
Shantur Rathore
bd2a0d1bec Bump version to 0.7.2 2026-01-15 20:55:59 +00:00
Shantur Rathore
df9722cd16 fix(server): run background processes via cmd.exe on Windows 2026-01-15 20:53:13 +00:00
Shantur Rathore
dffa4907ec fix(server): validate OpenCode binary by spawning --version 2026-01-15 20:47:30 +00:00
Shantur Rathore
e567d35438 fix(server): prefer .exe/.cmd candidates on Windows 2026-01-15 20:45:14 +00:00
Shantur Rathore
62f52fc534 fix(server): spawn opencode shims via Windows shells 2026-01-15 20:43:40 +00:00
Shantur Rathore
69f221942c Bump version to 0.7.1 2026-01-15 08:39:06 +00:00
Shantur Rathore
7749225f71 fix(ui): restore pasted text expand controls\n\nFixes #67 2026-01-15 08:36:56 +00:00
Shantur Rathore
ae322c53cc fix(ui): correct Go to Session navigation across instances 2026-01-15 08:26:49 +00:00
Shantur Rathore
37da426ab4 Bump version to 0.7.0 2026-01-14 21:36:45 +00:00
Shantur Rathore
591f55bef9 fix(ui): render prompt attachments above input 2026-01-14 21:35:18 +00:00
Shantur Rathore
aabaadbe1d fix(ui): expand prompt via rows, keep placeholder padding 2026-01-14 21:28:04 +00:00
Shantur Rathore
3ab14e8de6 Merge pull request #62 from bizzkoot/feat/expand-chat-input
feat: Implement expandable chat input with double-click detection and gradient tooltip
2026-01-14 18:22:56 +00:00
Shantur Rathore
40634138bc feat(server): add authenticated remote access and desktop bootstrap
Adds cookie-based login with a bootstrap token flow for desktop apps, secures OpenCode instance traffic with per-instance Basic auth, and updates UI/plugin clients to use credentials.
2026-01-14 18:18:14 +00:00
bizzkoot
b17087b610 refactor: remove mobile-specific placeholder text for simplicity
- Remove isMobileWidth signal and updateMobileWidth resize listener
- Use same placeholder text for all devices/platforms
- "Type your message, @file, @agent, or paste images and text..."

Simplifies implementation per dev feedback - one approach for all
2026-01-13 06:48:33 +08:00
bizzkoot
71f58e7c5f refactor: simplify expand chat input to 2-state with optimized button layout
- Remove 3-state logic (normal/50%/80%) - now only normal/expanded
- Remove double-click detection and tooltips for simplicity
- Remove platform-specific behavior (same UX for Electron and web)
- Optimize button layout: reduce from 36px to 28px to fit 3 buttons
- Position expand button above history buttons in vertical stack
- Keep 15-line expanded height (360px, capped to available space)

Per upstream dev feedback to keep it simple with one approach
2026-01-13 06:45:56 +08:00
Shantur Rathore
927e4e1281 perf(ui): reduce session list churn and message block invalidation 2026-01-12 16:37:09 +00:00
bizzkoot
2e56a5e9f4 feat: implement platform-specific expand chat input with mobile optimizations
- Add platform detection (Electron vs Web) for expand behavior
  - Electron: 3-state (normal → 50% → 80%) with double-click
  - Web/Mobile: 2-state (normal → expanded) with instant single tap
- Implement fixed 15-line height for web/mobile (360px, capped)
- Add orientation-aware height calculation (landscape vs portrait)
- Remove tooltip on web/mobile, keep for Electron desktop
- Add responsive placeholder text to prevent overlap on mobile
  - Desktop: "Type your message, @file, @agent, or paste images and text..."
  - Mobile (≤640px): "Type message, @file, @agent..."
- Delete dev-docs/expand-chat-input.md per upstream feedback

Addresses PR feedback to simplify from 3-state to 2-state for web/mobile
while maintaining rich desktop experience in Electron app.
2026-01-12 20:40:19 +08:00
bizzkoot
296d07a0d6 Move expand chat input doc to dev-docs and remove empty plans folder 2026-01-12 05:24:24 +08:00
bizzkoot
0d8a844af8 feat: implement expandable chat input with double-click detection and gradient tooltip
- Add expand button with Maximize2/Minimize2 icons
- Implement 3-state height management (normal/50%/80%)
- Smart double-click detection with 300ms delay
- Height calculation based on session-view - 200px message space
- Custom CSS tooltip with dark gradient background and backdrop blur
- Send button anchored at bottom via margin-top: auto
- Smooth CSS transitions throughout
- Double-click at 80% now reduces to 50% (not normal)
- Removed all debug console.log statements
2026-01-11 21:59:28 +08:00
bizzkoot
bf9cef4cd5 docs: add expand chat input implementation plan 2026-01-11 20:17:19 +08:00
bizzkoot
9dde33aba7 style: add expand button positioning and styles 2026-01-11 20:09:13 +08:00
bizzkoot
0fefff3b0a feat: integrate ExpandButton and apply dynamic height to textarea 2026-01-11 20:07:48 +08:00
bizzkoot
1122c19648 feat: create ExpandButton component with click/double-click logic 2026-01-11 20:05:16 +08:00
bizzkoot
f06359a1fc feat: add expand state signal and height calculation helpers 2026-01-11 20:04:25 +08:00
Shantur Rathore
72f420b6f6 feat(ui): support question tool requests
Add question queue hydration, inline answering UI, and unify pending requests with permissions.
2026-01-10 09:46:23 +00:00
Shantur Rathore
147c9e3e4b Bump version to 0.6.0 2026-01-09 21:46:14 +00:00
Shantur Rathore
ab38cdccac fix(ui): simplify prompt input hints 2026-01-09 21:10:00 +00:00
Shantur Rathore
8168d52295 chore(config): bump @opencode-ai/plugin
Update opencode config plugin dependency to 1.1.8.
2026-01-09 18:56:51 +00:00
Shantur Rathore
1081bfb276 fix(ui): keep session view after delete
When deleting the active session from the sidebar list, automatically select a nearby visible session instead of falling back to the info view.
2026-01-09 18:53:00 +00:00
Shantur Rathore
38064b229c ui: move instance info to drawer header
Remove the Instance section from the session list and replace it with an info icon button in the left drawer header.
2026-01-09 17:30:53 +00:00
Shantur Rathore
1a7aefcbae feat(ui): session nav follows visible list
Cmd+Shift+[ and Cmd+Shift+] now cycle through visible sessions only (parents + expanded children) and no longer include Instance Info. Sidebar session list auto-scrolls to keep the active session row in view.
2026-01-09 16:34:44 +00:00
Shantur Rathore
e50d9f461a feat(ui): thread sessions in sidebar list
Show sessions as parent/child threads with expand/collapse and improved agent row styling. Keep a 5-session cache to avoid refetching messages when switching between recently visited sessions.
2026-01-09 16:02:53 +00:00
Shantur Rathore
d76cf8a3f7 Merge pull request #56 from bizzkoot/feat/centralized-permission-notifications
feat: Add Centralized Permission Notification System
2026-01-08 23:19:29 +00:00
bizzkoot
c7370fe7bc fix(ui): resolve multi-line border issue in permission notification cards
- Removed box-shadow from active permission items to eliminate double-border effect
- Added CSS rule to remove borders from nested ToolCall components within permission items
- Ensures consistent single-border styling aligned with existing design system
2026-01-09 06:02:58 +08:00
Shantur Rathore
3dfbe2a5b2 docs: add Linux NVIDIA Wayland workaround for Tauri AppImage 2026-01-08 20:43:11 +00:00
Shantur Rathore
e30c8b0253 fix(ui): auto-close permission center when queue empty 2026-01-08 20:25:54 +00:00
Shantur Rathore
df9fc529f9 feat(ui): rework permission center to reuse tool call view 2026-01-08 20:15:09 +00:00
Shantur Rathore
2e9f5b916c Merge remote-tracking branch 'origin/dev' into feat/centralized-permission-notifications 2026-01-08 20:09:42 +00:00
Shantur Rathore
fd464f349a ui: show permission-blocked tool calls in timeline 2026-01-08 19:16:39 +00:00
Shantur Rathore
ff6d6f4f76 Remove custom commands from palette 2026-01-08 17:52:55 +00:00
Shantur Rathore
cb2966fb08 Add slash command prompt support 2026-01-08 17:41:29 +00:00
bizzkoot
888e365d72 feat: enhance permission modal with tool details, queue nav, session nav, and responsive design
Modal Enhancements:
- Add accurate tool name extraction from message store (same method as inline chat)
- Display 'Tool Call [name]' badge (e.g., 'Tool Call read', 'Tool Call write')
- Add 'Go to Session ↗' button to navigate to originating session
- Add Prev/Next buttons for queue navigation with keyboard shortcuts (←/→)
- Add queue counter showing current position

Responsive Web Design:
- Portrait phones: 90vh max-height with safe margins (avoids browser URL bar)
- Landscape phones: 95vw with 50vh body scroll
- Tablets: adaptive layout
- Touch devices: 44-48px touch targets

Technical Changes:
- Import messageStoreBus for tool part lookup
- Query linked part.tool via permission messageId/callId
- Export setActivePermissionIdForInstance for queue navigation
- Add tool badge CSS styling
2026-01-08 06:38:59 +08:00
Shantur Rathore
e9241a1b93 Ensure child processes are stopped 2026-01-07 19:35:33 +00:00
bizzkoot
f01a06d85b feat: add centralized permission notification system for agent/subagent requests
Implements a unified permission notification UI that adapts to different runtime
environments (Electron desktop vs web browser) with distinct visual presentations.

## What Changed

### New Components
- `permission-notification-banner.tsx`: Adaptive notification component
  * Electron (desktop): Full banner with "⚠️ Approval Required" text and count badge
  * Web browser (portrait): Circular indicator badge showing pending count
- `permission-approval-modal.tsx`: Interactive modal for reviewing/approving permissions
  * Displays permission type, detailed message, and diff viewer for file changes
  * Keyboard shortcuts: Enter (allow once), A (always), D (deny), Esc (close)
  * Queue management with "X of Y" counter for multiple pending permissions
- `permission-notification.css`: Comprehensive styling with pulsing animations

### Integration
- Updated `instance-shell2.tsx`:
  * Added banner to desktop center toolbar (next to Command Palette)
  * Added banner to mobile/phone layout center section
  * Added modal component for permission approval workflow
- Updated `controls.css`: Imported new permission notification styles

## Why This Change

**Before**: Permission requests had no visual indicator in the UI, making it
difficult for users to know when agent/subagent actions required approval.

**After**: Users receive clear, persistent visual notifications with:
- Pulsing animation to draw attention
- Environment-appropriate UI (full banner on desktop, compact badge on web)
- Click-to-review workflow with full permission details

## Benefits

1. **Better UX**: Users immediately see when permissions need approval
2. **Responsive Design**: Adapts to desktop (Electron) and web browser contexts
3. **Accessible**: Proper ARIA labels, keyboard shortcuts, and focus management
4. **Queue Management**: Handles multiple pending permissions gracefully
5. **Contextual Information**: Shows diffs for file changes, permission types, etc.

## Impact

- **No Breaking Changes**: Purely additive feature
- **Build**:  Verified successful build
- **Testing**:  Tested in Electron app and web browser
2026-01-07 21:44:43 +08:00
Shantur Rathore
a68285da68 Merge pull request #54 from NeuralNomadsAI/dev
Release v0.5.1 - Build fixes
2026-01-07 09:48:46 +00:00
Shantur Rathore
c825ff066e bump version to 0.5.1 2026-01-07 08:56:19 +00:00
Shantur Rathore
f7ded37ea3 Fix macOS tauri cli package name 2026-01-07 06:35:59 +00:00
Shantur Rathore
847faf1214 Fix Tauri builds and Windows opencode-config loop 2026-01-07 06:25:19 +00:00
Shantur Rathore
b1691add1c Stabilize Tauri CLI install in CI 2026-01-07 06:10:30 +00:00
Shantur Rathore
3b9a44779a Exclude opencode-config from npm workspaces 2026-01-06 23:20:45 +00:00
Shantur Rathore
62fd88cd3f Install @tauri-apps/cli alongside platform bindings 2026-01-06 23:10:45 +00:00
Shantur Rathore
ce2273fe57 Sync package-lock for opencode-config workspace 2026-01-06 23:03:54 +00:00
Shantur Rathore
0eee325777 Stabilize Windows opencode-config install and pin tauri bindings 2026-01-06 22:57:38 +00:00
Shantur Rathore
f7c9db44ad Fix Windows builds by tracking opencode-config package.json 2026-01-06 22:43:46 +00:00
Shantur Rathore
1fcf89b945 Isolate opencode-config install from workspace 2026-01-06 20:58:01 +00:00
Shantur Rathore
f5682ea246 Fix dev CI build tool resolution and Windows npm spawning 2026-01-06 20:45:40 +00:00
Shantur Rathore
fa308696b4 Allow callers to control workflow permissions 2026-01-06 20:32:29 +00:00
Shantur Rathore
ac8dfcc607 Improve opencode-config install on Windows 2026-01-06 20:30:40 +00:00
Shantur Rathore
ac04d5daf7 Run build-only CI on dev pushes 2026-01-06 20:30:37 +00:00
Shantur Rathore
7fe8fee295 Fix Tauri CLI native dependency installs 2026-01-06 20:30:33 +00:00
Shantur Rathore
31940f972f Merge pull request #53 from NeuralNomadsAI/dev
Release v0.5.0
2026-01-06 19:40:17 +00:00
Shantur Rathore
5954b332d5 Update dependencies 2026-01-06 19:38:08 +00:00
Shantur Rathore
eb89dfaf89 Fix iOS input auto-zoom (fixes #49, thanks @xpcmdshell) 2026-01-06 18:51:39 +00:00
Shantur Rathore
25bf313338 Show compaction indicator in message stream and timeline 2026-01-06 18:48:00 +00:00
Shantur Rathore
315abf21e6 Fix session status hydration and compaction transitions 2026-01-06 18:03:42 +00:00
Shantur Rathore
f24e360d78 Optimize session status updates
Reduce per-token store churn by updating status on transitions, caching instance-level indicators, and avoiding O(n) session-map cloning.
2026-01-06 09:58:55 +00:00
Shantur Rathore
1a6f1fdbae Bump to v0.5.0 2026-01-05 22:39:02 +00:00
Shantur Rathore
e09ce0780e Reconcile permissions after message hydration
After loadMessages hydrates tool parts, reattach pending permissions to the correct tool-call part ids so ToolCall permission UI renders reliably.
2026-01-05 20:39:51 +00:00
Shantur Rathore
95fdad7523 Use shield icon for permission status
Replace permission dots with a shield indicator and adjust permission colors to stand out from working/compacting.
2026-01-05 20:18:07 +00:00
Shantur Rathore
06416a9eb3 Add instance tab session status indicator
Aggregate session states per instance so tabs reflect permission, compaction, and working activity.
2026-01-05 20:09:13 +00:00
Shantur Rathore
2db62b1d17 Make UI global cache version-aware
Store one cached value per cacheId and overwrite when version changes to prevent unbounded growth from per-version keys.
2026-01-05 19:45:33 +00:00
Shantur Rathore
1377bc6b91 Migrate UI to v2 SDK client
Use v2 OpencodeClient with normalized request handling and rehydrate pending permissions via GET /permission on instance hydration.
2026-01-04 22:02:30 +00:00
Shantur Rathore
fcb5998474 Update UI permissions for SDK 1.0.166
Handle permission.asked events and requestID replies while keeping legacy compatibility.
2026-01-04 22:02:30 +00:00
Shantur Rathore
c2df32ec8b Stream ANSI tool output rendering 2026-01-04 22:02:30 +00:00
Shantur Rathore
f01149ee9e Stream ANSI tool output rendering 2026-01-04 22:02:29 +00:00
Shantur Rathore
eebfcb5628 Unify ANSI rendering with sequence parser 2026-01-04 22:02:29 +00:00
Shantur Rathore
4571a1dcf9 Render ANSI background output 2026-01-04 22:02:29 +00:00
Shantur Rathore
a041e1c6c3 Track session status via SSE updates 2026-01-04 22:02:29 +00:00
Shantur Rathore
abb8a9df19 Merge pull request #51 from bizzkoot/fix/copy-button-web
fix: copy button functionality in web browsers
2026-01-04 13:52:41 +00:00
bizzkoot
3c450c076a fix: copy button functionality in web browsers
- Add clipboard utility with fallback for non-secure contexts
- Implement modern Clipboard API with document.execCommand fallback
- Update copy buttons in code blocks, markdown, messages, and session list
- Add proper error handling and user feedback for copy operations

Fixes issue where copy buttons did not work in web browsers served over HTTP or without Clipboard API support
2026-01-04 20:00:22 +08:00
Shantur Rathore
4b05e698f8 Require tool part ids for tool-call rendering and caching
Rebind permissions from callID to part id when parts arrive.
2026-01-02 16:21:24 +00:00
Shantur Rathore
a9524b3e30 Load complete background process output and fix dialog layout 2025-12-30 22:03:04 +00:00
Shantur Rathore
154c5208b4 Show timeline icons at all widths 2025-12-29 16:19:11 +00:00
Shantur Rathore
71479a59a7 Add ANSI rendering for bash tool output 2025-12-26 10:47:53 +00:00
Shantur Rathore
3606d9aa50 Enforce workspace-only paths for background processes 2025-12-25 23:15:43 +00:00
Shantur Rathore
3e4d51c9f2 Surface runtime output in launch errors 2025-12-25 20:44:21 +00:00
Shantur Rathore
2603b1d260 Handle revert removals locally and retarget prompt input 2025-12-25 15:12:44 +00:00
Shantur Rathore
94aa469e90 Stop workspace port warning timer after allocation 2025-12-24 20:29:11 +00:00
Shantur Rathore
dab1e0fa7a Bundle opencode-config dependencies 2025-12-24 20:25:19 +00:00
Shantur Rathore
a14247f049 Sync package-lock 2025-12-24 19:10:32 +00:00
Shantur Rathore
695a890e0a Normalize plugin file URLs 2025-12-24 13:37:39 +00:00
Shantur Rathore
402d72d038 Remove session idle plugin wiring 2025-12-24 13:34:46 +00:00
Shantur Rathore
d32ec73c63 Resolve bundled opencode config from resources 2025-12-24 13:30:00 +00:00
Shantur Rathore
d0eac1e610 Use bundled opencode config at runtime 2025-12-24 12:01:03 +00:00
Shantur Rathore
e947691aae Consolidate plugins under CodeNomad entry 2025-12-24 01:07:56 +00:00
Shantur Rathore
575f987b8f Add background process manager and UI panel 2025-12-24 00:59:41 +00:00
Shantur Rathore
28b66ed0af Add CodeNomad plugin bridge for opencode 2025-12-23 23:06:33 +00:00
Shantur Rathore
4060c4f60b Show configured plugins in status panels 2025-12-23 18:24:09 +00:00
Shantur Rathore
8334e27294 Show error if opencode fails to launch 2025-12-17 22:59:05 +00:00
Shantur Rathore
722b523f92 Add packages/opencode-config and use it 2025-12-17 22:58:41 +00:00
Shantur Rathore
b4663fb250 Merge pull request #44 from NeuralNomadsAI/dev
Release 0.4.0
2025-12-15 16:46:29 +00:00
325 changed files with 32272 additions and 5276 deletions

View File

@@ -4,21 +4,33 @@ on:
workflow_call:
inputs:
version:
description: "Version to apply to workspace packages"
required: true
description: "Version to apply to workspace packages (release builds)"
required: false
default: ""
type: string
tag:
description: "Git tag to upload assets to"
required: true
description: "Git tag to upload assets to (release builds)"
required: false
default: ""
type: string
release_name:
description: "Release name (unused here, for context)"
required: true
required: false
default: ""
type: string
upload:
description: "Upload built artifacts to the GitHub release"
required: false
default: true
type: boolean
set_versions:
description: "Run npm version to set workspace versions"
required: false
default: true
type: boolean
permissions:
id-token: write
contents: write
# Permissions are intentionally omitted here so callers can choose
# least-privilege (e.g. dev CI uses read-only; releases grant write).
env:
NODE_VERSION: 20
@@ -41,10 +53,11 @@ jobs:
cache: npm
- name: Set workspace versions
if: ${{ inputs.set_versions && inputs.version != '' }}
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
- name: Install dependencies
run: npm ci --workspaces
run: npm ci --workspaces --include=optional
- name: Ensure rollup native binary
run: npm install @rollup/rollup-darwin-x64 --no-save
@@ -53,6 +66,7 @@ jobs:
run: npm run build:mac --workspace @neuralnomads/codenomad-electron-app
- name: Upload release assets
if: ${{ inputs.upload && inputs.tag != '' }}
run: |
set -euo pipefail
shopt -s nullglob
@@ -79,11 +93,12 @@ jobs:
cache: npm
- name: Set workspace versions
if: ${{ inputs.set_versions && inputs.version != '' }}
run: npm version ${{ env.VERSION }} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
shell: bash
- name: Install dependencies
run: npm ci --workspaces
run: npm ci --workspaces --include=optional
- name: Ensure rollup native binary
run: npm install @rollup/rollup-win32-x64-msvc --no-save
@@ -92,6 +107,7 @@ jobs:
run: npm run build:win --workspace @neuralnomads/codenomad-electron-app
- name: Upload release assets
if: ${{ inputs.upload && inputs.tag != '' }}
shell: pwsh
run: |
Get-ChildItem -Path "packages/electron-app/release" -Filter *.zip -File | ForEach-Object {
@@ -116,10 +132,11 @@ jobs:
cache: npm
- name: Set workspace versions
if: ${{ inputs.set_versions && inputs.version != '' }}
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
- name: Install dependencies
run: npm ci --workspaces
run: npm ci --workspaces --include=optional
- name: Ensure rollup native binary
run: npm install @rollup/rollup-linux-x64-gnu --no-save
@@ -128,6 +145,7 @@ jobs:
run: npm run build:linux --workspace @neuralnomads/codenomad-electron-app
- name: Upload release assets
if: ${{ inputs.upload && inputs.tag != '' }}
run: |
set -euo pipefail
shopt -s nullglob
@@ -157,18 +175,38 @@ jobs:
uses: dtolnay/rust-toolchain@stable
- name: Set workspace versions
if: ${{ inputs.set_versions && inputs.version != '' }}
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
- name: Install dependencies
run: npm ci --workspaces
run: npm ci --workspaces --include=optional
- name: Ensure rollup native binary
run: npm install @rollup/rollup-darwin-x64 --no-save
- name: Prebuild (Tauri)
run: npm run prebuild --workspace @codenomad/tauri-app
- name: Ensure tauri native binary
working-directory: packages/tauri-app
run: |
set -euo pipefail
for attempt in 1 2 3; do
if [ "$attempt" -gt 1 ]; then
echo "Retrying Tauri CLI install (attempt $attempt)..."
fi
npm install @tauri-apps/cli@2.9.4 @tauri-apps/cli-darwin-x64@2.9.4 --no-save --no-audit --no-fund --workspaces=false
node -e "require('@tauri-apps/cli'); console.log('Tauri CLI loaded')" && exit 0
done
echo "Tauri CLI failed to load after retries" >&2
exit 1
- name: Build macOS bundle (Tauri)
run: npm run build --workspace @codenomad/tauri-app
working-directory: packages/tauri-app
run: npm exec -- tauri build
- name: Package Tauri artifacts (macOS)
if: ${{ inputs.upload }}
run: |
set -euo pipefail
BUNDLE_ROOT="packages/tauri-app/target/release/bundle"
@@ -180,6 +218,7 @@ jobs:
fi
- name: Upload Tauri release assets (macOS)
if: ${{ inputs.upload && inputs.tag != '' }}
run: |
set -euo pipefail
shopt -s nullglob
@@ -209,18 +248,38 @@ jobs:
uses: dtolnay/rust-toolchain@stable
- name: Set workspace versions
if: ${{ inputs.set_versions && inputs.version != '' }}
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
- name: Install dependencies
run: npm ci --workspaces
run: npm ci --workspaces --include=optional
- name: Ensure rollup native binary
run: npm install @rollup/rollup-darwin-arm64 --no-save
- name: Prebuild (Tauri)
run: npm run prebuild --workspace @codenomad/tauri-app
- name: Ensure tauri native binary
working-directory: packages/tauri-app
run: |
set -euo pipefail
for attempt in 1 2 3; do
if [ "$attempt" -gt 1 ]; then
echo "Retrying Tauri CLI install (attempt $attempt)..."
fi
npm install @tauri-apps/cli@2.9.4 @tauri-apps/cli-darwin-arm64@2.9.4 --no-save --no-audit --no-fund --workspaces=false
node -e "require('@tauri-apps/cli'); console.log('Tauri CLI loaded')" && exit 0
done
echo "Tauri CLI failed to load after retries" >&2
exit 1
- name: Build macOS bundle (Tauri, arm64)
run: npm run build --workspace @codenomad/tauri-app
working-directory: packages/tauri-app
run: npm exec -- tauri build
- name: Package Tauri artifacts (macOS arm64)
if: ${{ inputs.upload }}
run: |
set -euo pipefail
BUNDLE_ROOT="packages/tauri-app/target/release/bundle"
@@ -232,6 +291,7 @@ jobs:
fi
- name: Upload Tauri release assets (macOS arm64)
if: ${{ inputs.upload && inputs.tag != '' }}
run: |
set -euo pipefail
shopt -s nullglob
@@ -261,19 +321,41 @@ jobs:
uses: dtolnay/rust-toolchain@stable
- name: Set workspace versions
if: ${{ inputs.set_versions && inputs.version != '' }}
run: npm version ${{ env.VERSION }} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
shell: bash
- name: Install dependencies
run: npm ci --workspaces
run: npm ci --workspaces --include=optional
- name: Ensure rollup native binary
run: npm install @rollup/rollup-win32-x64-msvc --no-save
- name: Prebuild (Tauri)
run: npm run prebuild --workspace @codenomad/tauri-app
- name: Ensure tauri native binary
shell: bash
working-directory: packages/tauri-app
run: |
set -euo pipefail
for attempt in 1 2 3; do
if [ "$attempt" -gt 1 ]; then
echo "Retrying Tauri CLI install (attempt $attempt)..."
fi
npm install @tauri-apps/cli@2.9.4 @tauri-apps/cli-win32-x64-msvc@2.9.4 --no-save --no-audit --no-fund --workspaces=false
node -e "require('@tauri-apps/cli'); console.log('Tauri CLI loaded')" && exit 0
done
echo "Tauri CLI failed to load after retries" >&2
exit 1
- name: Build Windows bundle (Tauri)
run: npm run build --workspace @codenomad/tauri-app
shell: bash
working-directory: packages/tauri-app
run: npm exec -- tauri build
- name: Package Tauri artifacts (Windows)
if: ${{ inputs.upload }}
shell: pwsh
run: |
$bundleRoot = "packages/tauri-app/target/release/bundle"
@@ -287,6 +369,7 @@ jobs:
}
- name: Upload Tauri release assets (Windows)
if: ${{ inputs.upload && inputs.tag != '' }}
shell: pwsh
run: |
if (Test-Path "packages/tauri-app/release-tauri") {
@@ -329,18 +412,38 @@ jobs:
librsvg2-dev
- name: Set workspace versions
if: ${{ inputs.set_versions && inputs.version != '' }}
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
- name: Install dependencies
run: npm ci --workspaces
run: npm ci --workspaces --include=optional
- name: Ensure rollup native binary
run: npm install @rollup/rollup-linux-x64-gnu --no-save
- name: Prebuild (Tauri)
run: npm run prebuild --workspace @codenomad/tauri-app
- name: Ensure tauri native binary
working-directory: packages/tauri-app
run: |
set -euo pipefail
for attempt in 1 2 3; do
if [ "$attempt" -gt 1 ]; then
echo "Retrying Tauri CLI install (attempt $attempt)..."
fi
npm install @tauri-apps/cli@2.9.4 @tauri-apps/cli-linux-x64-gnu@2.9.4 --no-save --no-audit --no-fund --workspaces=false
node -e "require('@tauri-apps/cli'); console.log('Tauri CLI loaded')" && exit 0
done
echo "Tauri CLI failed to load after retries" >&2
exit 1
- name: Build Linux bundle (Tauri)
run: npm run build --workspace @codenomad/tauri-app
working-directory: packages/tauri-app
run: npm exec -- tauri build
- name: Package Tauri artifacts (Linux)
if: ${{ inputs.upload }}
run: |
set -euo pipefail
SEARCH_ROOT="packages/tauri-app/target"
@@ -367,6 +470,7 @@ jobs:
cp "$rpm" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-linux-x64.rpm"
- name: Upload Tauri release assets (Linux)
if: ${{ inputs.upload && inputs.tag != '' }}
run: |
set -euo pipefail
shopt -s nullglob
@@ -429,7 +533,7 @@ jobs:
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
- name: Install dependencies
run: npm ci --workspaces
run: npm ci --workspaces --include=optional
- name: Ensure rollup native binary
run: npm install @rollup/rollup-linux-arm64-gnu --no-save
@@ -497,10 +601,11 @@ jobs:
sudo gem install --no-document fpm
- name: Set workspace versions
if: ${{ inputs.set_versions && inputs.version != '' }}
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
- name: Install project dependencies
run: npm ci --workspaces
run: npm ci --workspaces --include=optional
- name: Ensure rollup native binary
run: npm install @rollup/rollup-linux-x64-gnu --no-save
@@ -509,6 +614,7 @@ jobs:
run: npm run build:linux-rpm --workspace @neuralnomads/codenomad-electron-app
- name: Upload RPM release assets
if: ${{ inputs.upload && inputs.tag != '' }}
run: |
set -euo pipefail
shopt -s nullglob

View File

@@ -1,16 +1,18 @@
name: Dev Release
name: Dev CI
on:
push:
branches:
- dev
workflow_dispatch:
permissions:
id-token: write
contents: write
contents: read
jobs:
dev-release:
uses: ./.github/workflows/reusable-release.yml
dev-ci:
uses: ./.github/workflows/build-and-upload.yml
with:
version_suffix: -dev
dist_tag: dev
upload: false
set_versions: false
secrets: inherit

47
.github/workflows/release-ui.yml vendored Normal file
View File

@@ -0,0 +1,47 @@
name: Release UI
on:
workflow_call: {}
workflow_dispatch: {}
permissions:
contents: read
env:
NODE_VERSION: 20
jobs:
release-ui:
# Automated via reusable call (main releases); manual runs allowed on dev/main.
if: ${{ github.event_name == 'workflow_call' || github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' }}
runs-on: ubuntu-24.04
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm
- name: Install dependencies
run: npm ci --workspaces --include=optional
- name: Ensure rollup native binary
run: npm install @rollup/rollup-linux-x64-gnu --no-save
- name: Install Cloudflare worker deps
run: npm ci
working-directory: packages/cloudflare
- name: Build UI
run: npm run build --workspace @codenomad/ui
- name: Publish UI zip + update manifest
working-directory: packages/cloudflare
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
CODENOMAD_R2_BUCKET: ${{ vars.CODENOMAD_R2_BUCKET }}
run: npm run release:ui

View File

@@ -69,6 +69,13 @@ jobs:
release_name: ${{ needs.prepare-release.outputs.release_name }}
secrets: inherit
release-ui:
needs: prepare-release
permissions:
contents: read
uses: ./.github/workflows/release-ui.yml
secrets: inherit
publish-server:
needs:
- prepare-release

8
.gitignore vendored
View File

@@ -6,4 +6,10 @@ release/
.vite/
.electron-vite/
out/
.dir-locals.el
.dir-locals.el
.opencode/bashOutputs/
# Local runtime artifacts
.codenomad/
.tmp/
packages/cloudflare/.wrangler/

View File

@@ -0,0 +1,7 @@
---
description: Creates release notes
agent: build
---
Check how I do prepare release notes here - https://github.com/NeuralNomadsAI/CodeNomad/releases/tag/v0.7.0
Use the same format to create release notes from users perspective for new release by looking at changes from last tagged release to tip of branch

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Neural Nomads
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -76,6 +76,29 @@ xattr -dr com.apple.quarantine /Applications/CodeNomad.app
After removing the quarantine attribute, launch the app normally. On Intel Macs you may also need to approve CodeNomad from **System Settings → Privacy & Security** the first time you run it.
### Linux (Wayland + NVIDIA): Tauri AppImage closes immediately
On some Wayland compositor + NVIDIA driver setups, WebKitGTK can fail to initialize its DMA-BUF/GBM path and the Tauri build may exit right away.
Try running with one of these environment variables:
```bash
# Most reliable workaround (can reduce rendering performance)
WEBKIT_DISABLE_DMABUF_RENDERER=1 codenomad
# Alternative for some Wayland setups
__NV_DISABLE_EXPLICIT_SYNC=1 codenomad
```
If you're running the Tauri AppImage and want the workaround applied every time, create a tiny wrapper script on your `PATH`:
```bash
#!/bin/bash
export WEBKIT_DISABLE_DMABUF_RENDERER=1
exec ~/.local/share/bauh/appimage/installed/codenomad/CodeNomad-Tauri-0.4.0-linux-x64.AppImage "$@"
```
Upstream tracking: https://github.com/tauri-apps/tauri/issues/10702
## Architecture & Development
CodeNomad is a monorepo split into specialized packages. If you want to contribute or build from source, check out the individual package documentation:

6329
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,15 @@
{
"name": "codenomad-workspace",
"version": "0.4.0",
"version": "0.10.1",
"private": true,
"description": "CodeNomad monorepo workspace",
"license": "MIT",
"workspaces": {
"packages": [
"packages/*"
"packages/server",
"packages/ui",
"packages/electron-app",
"packages/tauri-app"
]
},
"scripts": {
@@ -23,5 +27,8 @@
"dependencies": {
"7zip-bin": "^5.2.0",
"google-auth-library": "^10.5.0"
},
"devDependencies": {
"baseline-browser-mapping": "^2.9.11"
}
}

1
packages/cloudflare/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
dist/

1515
packages/cloudflare/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,15 @@
{
"name": "@codenomad/ui-host-worker",
"private": true,
"license": "MIT",
"type": "module",
"scripts": {
"build:manifest": "node ./scripts/build-manifest.mjs",
"release:ui": "node ./scripts/release-ui.mjs",
"dev": "wrangler dev",
"deploy": "wrangler deploy"
},
"devDependencies": {
"wrangler": "^4.0.0"
}
}

View File

@@ -0,0 +1,4 @@
{
"minServerVersion": "0.10.1",
"latestServerUrl": "https://github.com/NeuralNomadsAI/CodeNomad/releases/latest"
}

View File

@@ -0,0 +1,83 @@
import { createHash } from "crypto"
import fs from "fs"
import path from "path"
import { fileURLToPath } from "url"
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const root = path.resolve(__dirname, "..")
const repoRoot = path.resolve(root, "..", "..")
const releaseConfigPath = path.join(root, "release-config.json")
const uiPackageJsonPath = path.join(repoRoot, "packages/ui/package.json")
const serverPackageJsonPath = path.join(repoRoot, "packages/server/package.json")
const distDir = path.join(root, "dist")
const manifestPath = path.join(distDir, "version.json")
const args = new Set(process.argv.slice(2))
function getArgValue(flag) {
const idx = process.argv.indexOf(flag)
if (idx === -1) return null
return process.argv[idx + 1] ?? null
}
const zipPath = getArgValue("--zip")
if (!zipPath) {
console.error("Usage: node scripts/build-manifest.mjs --zip <path-to-ui-zip>")
process.exit(1)
}
const resolvedZipPath = path.resolve(process.cwd(), zipPath)
if (!fs.existsSync(resolvedZipPath)) {
console.error(`Zip not found: ${resolvedZipPath}`)
process.exit(1)
}
const releaseConfig = JSON.parse(fs.readFileSync(releaseConfigPath, "utf-8"))
const uiPackageJson = JSON.parse(fs.readFileSync(uiPackageJsonPath, "utf-8"))
const serverPackageJson = JSON.parse(fs.readFileSync(serverPackageJsonPath, "utf-8"))
const bucket = process.env.CODENOMAD_R2_BUCKET
if (!bucket) {
console.error("Missing env var: CODENOMAD_R2_BUCKET")
process.exit(1)
}
const uiVersion = uiPackageJson.version
const serverVersion = serverPackageJson.version
if (!uiVersion || !serverVersion) {
console.error("Missing version fields in package.json")
process.exit(1)
}
const sha256 = createHash("sha256").update(fs.readFileSync(resolvedZipPath)).digest("hex")
const uiPackageURL = `https://download.codenomad.neuralnomads.ai/ui/ui-${uiVersion}.zip`
const manifest = {
minServerVersion: releaseConfig.minServerVersion,
latestUIVersion: uiVersion,
uiPackageURL,
sha256,
latestServerVersion: serverVersion,
latestServerUrl: releaseConfig.latestServerUrl,
}
fs.mkdirSync(distDir, { recursive: true })
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + "\n", "utf-8")
const headersPath = path.join(distDir, "_headers")
fs.writeFileSync(
headersPath,
"/version.json\n Cache-Control: no-cache\n Content-Type: application/json; charset=utf-8\n",
"utf-8",
)
console.log(`Wrote ${manifestPath}`)
console.log(`Wrote ${headersPath}`)

View File

@@ -0,0 +1,81 @@
import { execFileSync } from "child_process"
import fs from "fs"
import os from "os"
import path from "path"
import { fileURLToPath } from "url"
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const root = path.resolve(__dirname, "..")
const repoRoot = path.resolve(root, "..", "..")
const r2Bucket = process.env.CODENOMAD_R2_BUCKET
if (!r2Bucket) {
console.error("Missing env var: CODENOMAD_R2_BUCKET")
process.exit(1)
}
const uiPackageJsonPath = path.join(repoRoot, "packages/ui/package.json")
const uiPackageJson = JSON.parse(fs.readFileSync(uiPackageJsonPath, "utf-8"))
const uiVersion = uiPackageJson.version
if (!uiVersion) {
console.error("Missing packages/ui/package.json version")
process.exit(1)
}
const uiBuildDir = path.join(repoRoot, "packages/ui/src/renderer/dist")
if (!fs.existsSync(uiBuildDir)) {
console.error(`Missing UI build dir: ${uiBuildDir}. Run UI build first.`)
process.exit(1)
}
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "codenomad-ui-release-"))
const zipPath = path.join(tmpDir, `ui-${uiVersion}.zip`)
try {
// Zip the CONTENTS of the dist dir (so index.html is at zip root).
execFileSync("/usr/bin/zip", ["-q", "-r", zipPath, "."], { cwd: uiBuildDir, stdio: "inherit" })
// Upload to R2.
const objectKey = `ui/ui-${uiVersion}.zip`
console.log(`[release-ui] Uploading ${zipPath} -> r2://${r2Bucket}/${objectKey}`)
execFileSync(
"npx",
["wrangler", "r2", "object", "put", "--remote", `${r2Bucket}/${objectKey}`, "--file", zipPath],
{ cwd: root, stdio: "inherit" },
)
// Generate version.json into packages/cloudflare/dist
console.log("[release-ui] Generating version.json")
execFileSync(
process.execPath,
[path.join(root, "scripts/build-manifest.mjs"), "--zip", zipPath],
{
cwd: root,
stdio: "inherit",
env: {
...process.env,
CODENOMAD_R2_BUCKET: r2Bucket,
},
},
)
console.log("[release-ui] Deploying worker")
execFileSync("npx", ["wrangler", "deploy"], {
cwd: root,
stdio: "inherit",
env: {
...process.env,
CLOUDFLARE_API_TOKEN: process.env.CLOUDFLARE_API_TOKEN,
CLOUDFLARE_ACCOUNT_ID: process.env.CLOUDFLARE_ACCOUNT_ID,
},
})
console.log("[release-ui] Done")
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true })
}

View File

@@ -0,0 +1,9 @@
export interface Env {
ASSETS: { fetch: (request: Request) => Promise<Response> }
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
return env.ASSETS.fetch(request)
},
}

View File

@@ -0,0 +1,14 @@
name = "codenomad-ui-host"
main = "src/index.ts"
compatibility_date = "2026-01-22"
# Custom domain for the manifest host.
# Note: Custom domains apply to all paths on the hostname.
[[routes]]
pattern = "ui.codenomad.neuralnomads.ai"
custom_domain = true
[assets]
directory = "./dist"
binding = "ASSETS"
not_found_handling = "404-page"

View File

@@ -1,6 +1,8 @@
import { BrowserWindow, dialog, ipcMain, type OpenDialogOptions } from "electron"
import { BrowserWindow, dialog, ipcMain, powerSaveBlocker, type OpenDialogOptions } from "electron"
import type { CliProcessManager, CliStatus } from "./process-manager"
let wakeLockId: number | null = null
interface DialogOpenRequest {
mode: "directory" | "file"
title?: string
@@ -62,4 +64,31 @@ export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessMan
return { canceled: result.canceled, paths: result.filePaths }
})
ipcMain.handle("power:setWakeLock", async (_event, enabled: boolean): Promise<{ enabled: boolean }> => {
const next = Boolean(enabled)
if (next) {
if (wakeLockId !== null && powerSaveBlocker.isStarted(wakeLockId)) {
return { enabled: true }
}
try {
wakeLockId = powerSaveBlocker.start("prevent-display-sleep")
} catch {
wakeLockId = null
return { enabled: false }
}
return { enabled: true }
}
if (wakeLockId !== null) {
try {
if (powerSaveBlocker.isStarted(wakeLockId)) {
powerSaveBlocker.stop(wakeLockId)
}
} finally {
wakeLockId = null
}
}
return { enabled: false }
})
}

View File

@@ -1,4 +1,6 @@
import { app, BrowserView, BrowserWindow, nativeImage, session, shell } from "electron"
import http from "node:http"
import https from "node:https"
import { existsSync } from "fs"
import { dirname, join } from "path"
import { fileURLToPath } from "url"
@@ -15,6 +17,7 @@ const cliManager = new CliProcessManager()
let mainWindow: BrowserWindow | null = null
let currentCliUrl: string | null = null
let pendingCliUrl: string | null = null
let pendingBootstrapToken: string | null = null
let showingLoadingScreen = false
let preloadingView: BrowserView | null = null
@@ -251,6 +254,15 @@ function showLoadingScreen(force = false) {
loadLoadingScreen(mainWindow)
}
function isBootstrapTokenUrl(url: string): boolean {
try {
const parsed = new URL(url)
return parsed.pathname === "/auth/token" && parsed.hash.length > 1
} catch {
return false
}
}
function startCliPreload(url: string) {
if (!mainWindow || mainWindow.isDestroyed()) {
pendingCliUrl = url
@@ -268,6 +280,13 @@ function startCliPreload(url: string) {
showLoadingScreen(true)
}
// Important: /auth/token#... is one-time. Preloading + swapping would load it twice,
// consuming the token in the hidden view and then failing in the main window.
if (isBootstrapTokenUrl(url)) {
finalizeCliSwap(url)
return
}
const view = new BrowserView({
webPreferences: {
contextIsolation: true,
@@ -308,6 +327,75 @@ function finalizeCliSwap(url: string) {
mainWindow.loadURL(url).catch((error) => console.error("[cli] failed to load CLI view:", error))
}
const SESSION_COOKIE_NAME = "codenomad_session"
let bootstrapExchangeInFlight = false
function extractCookieValue(setCookieHeader: string | string[] | undefined, name: string): string | null {
const raw = Array.isArray(setCookieHeader) ? setCookieHeader[0] : setCookieHeader
if (!raw) return null
const first = raw.split(";")[0] ?? ""
const index = first.indexOf("=")
if (index < 0) return null
const key = first.slice(0, index).trim()
const value = first.slice(index + 1).trim()
if (key !== name || !value) return null
try {
return decodeURIComponent(value)
} catch {
return value
}
}
async function exchangeBootstrapToken(baseUrl: string, token: string): Promise<boolean> {
const target = new URL("/api/auth/token", baseUrl)
const body = JSON.stringify({ token })
const transport = target.protocol === "https:" ? https : http
const result = await new Promise<{ statusCode: number; setCookie: string | string[] | undefined }>((resolve, reject) => {
const req = transport.request(
target,
{
method: "POST",
headers: {
"Content-Type": "application/json",
"Content-Length": Buffer.byteLength(body),
},
},
(res) => {
res.resume()
resolve({ statusCode: res.statusCode ?? 0, setCookie: res.headers["set-cookie"] })
},
)
req.on("error", reject)
req.write(body)
req.end()
})
if (result.statusCode !== 200) {
return false
}
const sessionId = extractCookieValue(result.setCookie, SESSION_COOKIE_NAME)
if (!sessionId) {
return false
}
await session.defaultSession.cookies.set({
url: baseUrl,
name: SESSION_COOKIE_NAME,
value: sessionId,
httpOnly: true,
path: "/",
sameSite: "lax",
})
return true
}
async function startCli() {
try {
@@ -323,11 +411,53 @@ async function startCli() {
}
}
async function maybeExchangeAndNavigate(baseUrl: string) {
if (bootstrapExchangeInFlight) {
return
}
const token = pendingBootstrapToken
if (!token) {
startCliPreload(baseUrl)
return
}
bootstrapExchangeInFlight = true
try {
const ok = await exchangeBootstrapToken(baseUrl, token)
pendingBootstrapToken = null
if (!ok) {
startCliPreload(`${baseUrl}/login`)
return
}
startCliPreload(baseUrl)
} catch (error) {
console.error("[cli] bootstrap token exchange failed:", error)
pendingBootstrapToken = null
startCliPreload(`${baseUrl}/login`)
} finally {
bootstrapExchangeInFlight = false
}
}
cliManager.on("bootstrapToken", (token) => {
pendingBootstrapToken = token
const status = cliManager.getStatus()
if (status.url) {
void maybeExchangeAndNavigate(status.url)
}
})
cliManager.on("ready", (status) => {
if (!status.url) {
return
}
startCliPreload(status.url)
void maybeExchangeAndNavigate(status.url)
})
cliManager.on("status", (status) => {
@@ -375,7 +505,6 @@ app.on("before-quit", async (event) => {
})
app.on("window-all-closed", () => {
if (process.platform !== "darwin") {
app.quit()
}
// CodeNomad supports a single window; closing it should quit the app on all platforms.
app.quit()
})

View File

@@ -1,4 +1,4 @@
import { spawn, type ChildProcess } from "child_process"
import { spawn, spawnSync, type ChildProcess } from "child_process"
import { app } from "electron"
import { createRequire } from "module"
import { EventEmitter } from "events"
@@ -9,6 +9,7 @@ import { buildUserShellCommand, getUserShellEnv, supportsUserShell } from "./use
const nodeRequire = createRequire(import.meta.url)
const BOOTSTRAP_TOKEN_PREFIX = "CODENOMAD_BOOTSTRAP_TOKEN:"
type CliState = "starting" | "ready" | "error" | "stopped"
type ListeningMode = "local" | "all"
@@ -69,6 +70,7 @@ function readListeningModeFromConfig(): ListeningMode {
export declare interface CliProcessManager {
on(event: "status", listener: (status: CliStatus) => void): this
on(event: "ready", listener: (status: CliStatus) => void): this
on(event: "bootstrapToken", listener: (token: string) => void): this
on(event: "log", listener: (entry: CliLogEntry) => void): this
on(event: "exit", listener: (status: CliStatus) => void): this
on(event: "error", listener: (error: Error) => void): this
@@ -79,6 +81,8 @@ export class CliProcessManager extends EventEmitter {
private status: CliStatus = { state: "stopped" }
private stdoutBuffer = ""
private stderrBuffer = ""
private bootstrapToken: string | null = null
private requestedStop = false
async start(options: StartOptions): Promise<CliStatus> {
if (this.child) {
@@ -87,6 +91,8 @@ export class CliProcessManager extends EventEmitter {
this.stdoutBuffer = ""
this.stderrBuffer = ""
this.bootstrapToken = null
this.requestedStop = false
this.updateStatus({ state: "starting", port: undefined, pid: undefined, url: undefined, error: undefined })
const cliEntry = this.resolveCliEntry(options)
@@ -105,11 +111,13 @@ export class CliProcessManager extends EventEmitter {
? buildUserShellCommand(`ELECTRON_RUN_AS_NODE=1 exec ${this.buildCommand(cliEntry, args)}`)
: this.buildDirectSpawn(cliEntry, args)
const detached = process.platform !== "win32"
const child = spawn(spawnDetails.command, spawnDetails.args, {
cwd: process.cwd(),
stdio: ["ignore", "pipe", "pipe"],
env,
shell: false,
detached,
})
console.info(`[cli] spawn command: ${spawnDetails.command} ${spawnDetails.args.join(" ")}`)
@@ -171,10 +179,90 @@ export class CliProcessManager extends EventEmitter {
return
}
this.requestedStop = true
const pid = child.pid
if (!pid) {
this.child = undefined
this.updateStatus({ state: "stopped" })
return
}
const isAlreadyExited = () => child.exitCode !== null || child.signalCode !== null
const tryKillPosixGroup = (signal: NodeJS.Signals) => {
try {
// Negative PID targets the process group (POSIX).
process.kill(-pid, signal)
return true
} catch (error) {
const err = error as NodeJS.ErrnoException
if (err?.code === "ESRCH") {
return true
}
return false
}
}
const tryKillSinglePid = (signal: NodeJS.Signals) => {
try {
process.kill(pid, signal)
return true
} catch (error) {
const err = error as NodeJS.ErrnoException
if (err?.code === "ESRCH") {
return true
}
return false
}
}
const tryTaskkill = (force: boolean) => {
const args = ["/PID", String(pid), "/T"]
if (force) {
args.push("/F")
}
try {
const result = spawnSync("taskkill", args, { encoding: "utf8" })
const exitCode = result.status
if (exitCode === 0) {
return true
}
// If the PID is already gone, treat it as success.
const stderr = (result.stderr ?? "").toString().toLowerCase()
const stdout = (result.stdout ?? "").toString().toLowerCase()
const combined = `${stdout}\n${stderr}`
if (combined.includes("not found") || combined.includes("no running instance")) {
return true
}
return false
} catch {
return false
}
}
const sendStopSignal = (signal: NodeJS.Signals) => {
if (process.platform === "win32") {
tryTaskkill(signal === "SIGKILL")
return
}
// Prefer process-group signaling so wrapper launchers (shell/tsx) don't outlive Electron.
const groupOk = tryKillPosixGroup(signal)
if (!groupOk) {
tryKillSinglePid(signal)
}
}
return new Promise((resolve) => {
const killTimeout = setTimeout(() => {
child.kill("SIGKILL")
}, 4000)
console.warn(
`[cli] stop timed out after 30000ms; sending SIGKILL (pid=${child.pid ?? "unknown"})`,
)
sendStopSignal("SIGKILL")
}, 30000)
child.on("exit", () => {
clearTimeout(killTimeout)
@@ -184,7 +272,15 @@ export class CliProcessManager extends EventEmitter {
resolve()
})
child.kill("SIGTERM")
if (isAlreadyExited()) {
clearTimeout(killTimeout)
this.child = undefined
this.updateStatus({ state: "stopped" })
resolve()
return
}
sendStopSignal("SIGTERM")
})
}
@@ -198,7 +294,16 @@ export class CliProcessManager extends EventEmitter {
private handleTimeout() {
if (this.child) {
this.child.kill("SIGKILL")
const pid = this.child.pid
if (pid && process.platform !== "win32") {
try {
process.kill(-pid, "SIGKILL")
} catch {
this.child.kill("SIGKILL")
}
} else {
this.child.kill("SIGKILL")
}
this.child = undefined
}
this.updateStatus({ state: "error", error: "CLI did not start in time" })
@@ -227,42 +332,42 @@ export class CliProcessManager extends EventEmitter {
}
for (const line of lines) {
if (!line.trim()) continue
console.info(`[cli][${stream}] ${line}`)
this.emit("log", { stream, message: line })
const trimmed = line.trim()
if (!trimmed) continue
const port = this.extractPort(line)
if (port && this.status.state === "starting") {
const url = `http://127.0.0.1:${port}`
console.info(`[cli] ready on ${url}`)
this.updateStatus({ state: "ready", port, url })
if (trimmed.startsWith(BOOTSTRAP_TOKEN_PREFIX)) {
const token = trimmed.slice(BOOTSTRAP_TOKEN_PREFIX.length).trim()
if (token && !this.bootstrapToken) {
this.bootstrapToken = token
this.emit("bootstrapToken", token)
}
continue
}
console.info(`[cli][${stream}] ${trimmed}`)
this.emit("log", { stream, message: trimmed })
const localUrl = this.extractLocalUrl(trimmed)
if (localUrl && this.status.state === "starting") {
let port: number | undefined
try {
port = Number(new URL(localUrl).port) || undefined
} catch {
port = undefined
}
console.info(`[cli] ready on ${localUrl}`)
this.updateStatus({ state: "ready", port, url: localUrl })
this.emit("ready", this.status)
}
}
}
private extractPort(line: string): number | null {
const readyMatch = line.match(/CodeNomad Server is ready at http:\/\/[^:]+:(\d+)/i)
if (readyMatch) {
return parseInt(readyMatch[1], 10)
private extractLocalUrl(line: string): string | null {
const match = line.match(/^Local\s+Connection\s+URL\s*:\s*(https?:\/\/\S+)\s*$/i)
if (!match) {
return null
}
if (line.toLowerCase().includes("http server listening")) {
const httpMatch = line.match(/:(\d{2,5})(?!.*:\d)/)
if (httpMatch) {
return parseInt(httpMatch[1], 10)
}
try {
const parsed = JSON.parse(line)
if (typeof parsed.port === "number") {
return parsed.port
}
} catch {
// not JSON, ignore
}
}
return null
return match[1] ?? null
}
private updateStatus(patch: Partial<CliStatus>) {
@@ -271,7 +376,15 @@ export class CliProcessManager extends EventEmitter {
}
private buildCliArgs(options: StartOptions, host: string): string[] {
const args = ["serve", "--host", host, "--port", "0"]
const args = ["serve", "--host", host, "--generate-token"]
if (options.dev) {
// Dev: run plain HTTP + Vite dev server proxy.
args.push("--https", "false", "--http", "true")
} else {
// Prod desktop: always keep loopback HTTP enabled.
args.push("--https", "true", "--http", "true")
}
if (options.dev) {
args.push("--ui-dev-server", "http://localhost:3000", "--log-level", "debug")
@@ -361,4 +474,3 @@ export class CliProcessManager extends EventEmitter {
throw new Error("Unable to locate CodeNomad CLI build (dist/bin.js). Run npm run build --workspace @neuralnomads/codenomad.")
}
}

View File

@@ -12,6 +12,7 @@ const electronAPI = {
getCliStatus: () => ipcRenderer.invoke("cli:getStatus"),
restartCli: () => ipcRenderer.invoke("cli:restart"),
openDialog: (options) => ipcRenderer.invoke("dialog:open", options),
setWakeLock: (enabled) => ipcRenderer.invoke("power:setWakeLock", Boolean(enabled)),
}
contextBridge.exposeInMainWorld("electronAPI", electronAPI)

View File

@@ -1,7 +1,8 @@
{
"name": "@neuralnomads/codenomad-electron-app",
"version": "0.4.0",
"version": "0.10.1",
"description": "CodeNomad - AI coding assistant",
"license": "MIT",
"author": {
"name": "Neural Nomads",
"email": "codenomad@neuralnomads.ai"
@@ -69,6 +70,10 @@
"!icon.icns",
"!icon.ico"
]
},
{
"from": "../server/dist/opencode-config",
"to": "opencode-config"
}
],
"mac": {

View File

@@ -2,7 +2,7 @@
import { spawn } from "child_process"
import { existsSync } from "fs"
import { join } from "path"
import path, { join } from "path"
import { fileURLToPath } from "url"
const __dirname = fileURLToPath(new URL(".", import.meta.url))
@@ -55,12 +55,22 @@ const platforms = {
function run(command, args, options = {}) {
return new Promise((resolve, reject) => {
const env = { ...process.env, NODE_PATH: nodeModulesPath, ...(options.env || {}) }
const pathKey = Object.keys(env).find((key) => key.toLowerCase() === "path") ?? "PATH"
const binPaths = [
join(nodeModulesPath, ".bin"),
join(workspaceNodeModulesPath, ".bin"),
]
env[pathKey] = `${binPaths.join(path.delimiter)}${path.delimiter}${env[pathKey] ?? ""}`
const spawnOptions = {
cwd: appDir,
stdio: "inherit",
shell: process.platform === "win32",
...options,
env: { ...process.env, NODE_PATH: nodeModulesPath, ...(options.env || {}) },
env,
}
const child = spawn(command, args, spawnOptions)

View File

@@ -0,0 +1,32 @@
# opencode-config
## TLDR
Template config + plugins injected into every OpenCode instance that CodeNomad launches. It provides a CodeNomad bridge plugin for local event exchange between the CLI server and opencode.
## What it is
A packaged config directory that CodeNomad copies into `~/.config/codenomad/opencode-config` for production builds or uses directly in dev. OpenCode autoloads any `plugin/*.ts` or `plugin/*.js` from this directory.
## How it works
- CodeNomad sets `OPENCODE_CONFIG_DIR` when spawning each opencode instance (`packages/server/src/workspaces/manager.ts`).
- This template is synced from `packages/opencode-config` (`packages/server/src/opencode-config.ts`, `packages/server/scripts/copy-opencode-config.mjs`).
- OpenCode autoloads plugins from `plugin/` (`packages/opencode-config/plugin/codenomad.ts`).
- The `CodeNomadPlugin` reads `CODENOMAD_INSTANCE_ID` + `CODENOMAD_BASE_URL`, connects to `GET /workspaces/:id/plugin/events`, and posts to `POST /workspaces/:id/plugin/event` (`packages/opencode-config/plugin/lib/client.ts`).
- The server exposes the plugin routes and maps events into the UI SSE pipeline (`packages/server/src/server/routes/plugin.ts`, `packages/server/src/plugins/handlers.ts`).
## Expectations
- Local-only bridge (no auth/token yet).
- Plugin must fail startup if it cannot connect after 3 retries.
- Keep plugin entrypoints thin; put shared logic under `plugin/lib/` to avoid autoloaded helpers.
- Keep event shapes small and explicit; use `type` + `properties` only.
## Ideas
- Add feature modules under `plugin/lib/features/` (tool lifecycle, permission prompts, custom commands).
- Expand `/workspaces/:id/plugin/*` with dedicated endpoints as needed.
- Promote stable event shapes and version tags once the protocol settles.
## Pointers
- Plugin entry: `packages/opencode-config/plugin/codenomad.ts`
- Plugin client: `packages/opencode-config/plugin/lib/client.ts`
- Plugin server routes: `packages/server/src/server/routes/plugin.ts`
- Plugin event handling: `packages/server/src/plugins/handlers.ts`
- Workspace env injection: `packages/server/src/workspaces/manager.ts`

View File

@@ -0,0 +1,3 @@
{
"$schema": "https://opencode.ai/config.json"
}

View File

@@ -0,0 +1,9 @@
{
"name": "@codenomad/opencode-config",
"version": "0.5.0",
"private": true,
"license": "MIT",
"dependencies": {
"@opencode-ai/plugin": "1.1.53"
}
}

View File

@@ -0,0 +1,32 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { createCodeNomadClient, getCodeNomadConfig } from "./lib/client"
import { createBackgroundProcessTools } from "./lib/background-process"
export async function CodeNomadPlugin(input: PluginInput) {
const config = getCodeNomadConfig()
const client = createCodeNomadClient(config)
const backgroundProcessTools = createBackgroundProcessTools(config, { baseDir: input.directory })
await client.startEvents((event) => {
if (event.type === "codenomad.ping") {
void client.postEvent({
type: "codenomad.pong",
properties: {
ts: Date.now(),
pingTs: (event.properties as any)?.ts,
},
}).catch(() => {})
}
})
return {
tool: {
...backgroundProcessTools,
},
async event(input: { event: any }) {
const opencodeEvent = input?.event
if (!opencodeEvent || typeof opencodeEvent !== "object") return
},
}
}

View File

@@ -0,0 +1,253 @@
import path from "path"
import { tool } from "@opencode-ai/plugin/tool"
import { createCodeNomadRequester, type CodeNomadConfig } from "./request"
type BackgroundProcess = {
id: string
title: string
command: string
status: "running" | "stopped" | "error"
startedAt: string
stoppedAt?: string
exitCode?: number
outputSizeBytes?: number
}
type BackgroundProcessOptions = {
baseDir: string
}
type ParsedCommand = {
head: string
args: string[]
}
export function createBackgroundProcessTools(config: CodeNomadConfig, options: BackgroundProcessOptions) {
const requester = createCodeNomadRequester(config)
const request = async <T>(path: string, init?: RequestInit): Promise<T> => {
return requester.requestJson<T>(`/background-processes${path}`, init)
}
return {
run_background_process: tool({
description:
"Run a long-lived background process (dev servers, DBs, watchers) so it keeps running while you do other tasks. Use it for running processes that timeout otherwise or produce a lot of output.",
args: {
title: tool.schema.string().describe("Short label for the process (e.g. Dev server, DB server)"),
command: tool.schema.string().describe("Shell command to run in the workspace"),
},
async execute(args) {
assertCommandWithinBase(args.command, options.baseDir)
const process = await request<BackgroundProcess>("", {
method: "POST",
body: JSON.stringify({ title: args.title, command: args.command }),
})
return `Started background process ${process.id} (${process.title})\nStatus: ${process.status}\nCommand: ${process.command}`
},
}),
list_background_processes: tool({
description: "List background processes running for this workspace.",
args: {},
async execute() {
const response = await request<{ processes: BackgroundProcess[] }>("")
if (response.processes.length === 0) {
return "No background processes running."
}
return response.processes
.map((process) => {
const status = process.status === "running" ? "running" : process.status
const exit = process.exitCode !== undefined ? ` (exit ${process.exitCode})` : ""
const size =
typeof process.outputSizeBytes === "number" ? ` | ${Math.round(process.outputSizeBytes / 1024)}KB` : ""
return `- ${process.id} | ${process.title} | ${status}${exit}${size}\n ${process.command}`
})
.join("\n")
},
}),
read_background_process_output: tool({
description: "Read output from a background process. Use full, grep, head, or tail.",
args: {
id: tool.schema.string().describe("Background process ID"),
method: tool.schema
.enum(["full", "grep", "head", "tail"])
.default("full")
.describe("Method to read output"),
pattern: tool.schema.string().optional().describe("Pattern for grep method"),
lines: tool.schema.number().optional().describe("Number of lines for head/tail methods"),
},
async execute(args) {
if (args.method === "grep" && !args.pattern) {
return "Pattern is required for grep method."
}
const params = new URLSearchParams({ method: args.method })
if (args.pattern) {
params.set("pattern", args.pattern)
}
if (args.lines) {
params.set("lines", String(args.lines))
}
const response = await request<{ id: string; content: string; truncated: boolean; sizeBytes: number }>(
`/${args.id}/output?${params.toString()}`,
)
const header = response.truncated
? `Output (truncated, ${Math.round(response.sizeBytes / 1024)}KB):`
: `Output (${Math.round(response.sizeBytes / 1024)}KB):`
return `${header}\n\n${response.content}`
},
}),
stop_background_process: tool({
description: "Stop a background process (SIGTERM) but keep its output and entry.",
args: {
id: tool.schema.string().describe("Background process ID"),
},
async execute(args) {
const process = await request<BackgroundProcess>(`/${args.id}/stop`, { method: "POST" })
return `Stopped background process ${process.id} (${process.title}). Status: ${process.status}`
},
}),
terminate_background_process: tool({
description: "Terminate a background process and delete its output + entry.",
args: {
id: tool.schema.string().describe("Background process ID"),
},
async execute(args) {
await request<void>(`/${args.id}/terminate`, { method: "POST" })
return `Terminated background process ${args.id} and removed its output.`
},
}),
}
}
const FILE_COMMANDS = new Set(["cd", "rm", "cp", "mv", "mkdir", "touch", "chmod", "chown"])
const EXPANSION_CHARS = /[~*$?\[\]`$]/
function assertCommandWithinBase(command: string, baseDir: string) {
const normalizedBase = path.resolve(baseDir)
const commands = splitCommands(command)
for (const item of commands) {
if (!FILE_COMMANDS.has(item.head)) {
continue
}
for (const arg of item.args) {
if (!arg) continue
if (arg.startsWith("-") || (item.head === "chmod" && arg.startsWith("+"))) continue
const literalArg = unquote(arg)
if (EXPANSION_CHARS.test(literalArg)) {
throw new Error(`Background process commands may only reference paths within ${normalizedBase}.`)
}
const resolved = path.isAbsolute(literalArg) ? path.normalize(literalArg) : path.resolve(normalizedBase, literalArg)
if (!isWithinBase(normalizedBase, resolved)) {
throw new Error(`Background process commands may only reference paths within ${normalizedBase}.`)
}
}
}
}
function splitCommands(command: string): ParsedCommand[] {
const tokens = tokenize(command)
const commands: ParsedCommand[] = []
let current: string[] = []
for (const token of tokens) {
if (isSeparator(token)) {
if (current.length > 0) {
commands.push({ head: current[0], args: current.slice(1) })
current = []
}
continue
}
current.push(token)
}
if (current.length > 0) {
commands.push({ head: current[0], args: current.slice(1) })
}
return commands
}
function tokenize(input: string): string[] {
const tokens: string[] = []
let current = ""
let quote: "'" | '"' | null = null
let escape = false
const flush = () => {
if (current.length > 0) {
tokens.push(current)
current = ""
}
}
for (let index = 0; index < input.length; index += 1) {
const char = input[index]
if (escape) {
current += char
escape = false
continue
}
if (char === "\\" && quote !== "'") {
escape = true
continue
}
if (quote) {
current += char
if (char === quote) {
quote = null
}
continue
}
if (char === "'" || char === '"') {
quote = char
current += char
continue
}
if (char === " " || char === "\n" || char === "\t") {
flush()
continue
}
if (char === "|" || char === "&" || char === ";") {
flush()
tokens.push(char)
continue
}
current += char
}
flush()
return tokens
}
function isSeparator(token: string): boolean {
return token === "|" || token === "&" || token === ";"
}
function unquote(token: string): string {
if ((token.startsWith('"') && token.endsWith('"')) || (token.startsWith("'") && token.endsWith("'"))) {
return token.slice(1, -1)
}
return token
}
function isWithinBase(base: string, candidate: string): boolean {
const relative = path.relative(base, candidate)
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative))
}

View File

@@ -0,0 +1,133 @@
import { createCodeNomadRequester, type CodeNomadConfig, type PluginEvent } from "./request"
export { getCodeNomadConfig, type CodeNomadConfig, type PluginEvent } from "./request"
export function createCodeNomadClient(config: CodeNomadConfig) {
const requester = createCodeNomadRequester(config)
return {
postEvent: (event: PluginEvent) =>
requester.requestVoid("/event", {
method: "POST",
body: JSON.stringify(event),
}),
startEvents: (onEvent: (event: PluginEvent) => void) => startPluginEvents(requester, onEvent),
}
}
function delay(ms: number) {
return new Promise<void>((resolve) => setTimeout(resolve, ms))
}
async function startPluginEvents(
requester: ReturnType<typeof createCodeNomadRequester>,
onEvent: (event: PluginEvent) => void,
) {
// Fail plugin startup if we cannot establish the initial connection.
const initialBody = await connectWithRetries(requester, 3)
// After startup, keep reconnecting; throw after 3 consecutive failures.
void consumeWithReconnect(requester, onEvent, initialBody)
}
async function connectWithRetries(requester: ReturnType<typeof createCodeNomadRequester>, maxAttempts: number) {
let lastError: unknown
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
try {
return await requester.requestSseBody("/events")
} catch (error) {
lastError = error
await delay(500 * attempt)
}
}
const reason = lastError instanceof Error ? lastError.message : String(lastError)
const url = requester.buildUrl("/events")
throw new Error(`[CodeNomadPlugin] Failed to connect to CodeNomad at ${url} after ${maxAttempts} retries: ${reason}`)
}
async function consumeWithReconnect(
requester: ReturnType<typeof createCodeNomadRequester>,
onEvent: (event: PluginEvent) => void,
initialBody: ReadableStream<Uint8Array>,
) {
let consecutiveFailures = 0
let body: ReadableStream<Uint8Array> | null = initialBody
while (true) {
try {
if (!body) {
body = await connectWithRetries(requester, 3)
}
await consumeSseBody(body, onEvent)
body = null
consecutiveFailures = 0
} catch (error) {
body = null
consecutiveFailures += 1
if (consecutiveFailures >= 3) {
const reason = error instanceof Error ? error.message : String(error)
throw new Error(`[CodeNomadPlugin] Plugin event stream failed after 3 retries: ${reason}`)
}
await delay(500 * consecutiveFailures)
}
}
}
async function consumeSseBody(body: ReadableStream<Uint8Array>, onEvent: (event: PluginEvent) => void) {
const reader = body.getReader()
const decoder = new TextDecoder()
let buffer = ""
while (true) {
const { done, value } = await reader.read()
if (done || !value) {
break
}
buffer += decoder.decode(value, { stream: true })
let separatorIndex = buffer.indexOf("\n\n")
while (separatorIndex >= 0) {
const chunk = buffer.slice(0, separatorIndex)
buffer = buffer.slice(separatorIndex + 2)
separatorIndex = buffer.indexOf("\n\n")
const event = parseSseChunk(chunk)
if (event) {
onEvent(event)
}
}
}
throw new Error("SSE stream ended")
}
function parseSseChunk(chunk: string): PluginEvent | null {
const lines = chunk.split(/\r?\n/)
const dataLines: string[] = []
for (const line of lines) {
if (line.startsWith(":")) continue
if (line.startsWith("data:")) {
dataLines.push(line.slice(5).trimStart())
}
}
if (dataLines.length === 0) return null
const payload = dataLines.join("\n").trim()
if (!payload) return null
try {
const parsed = JSON.parse(payload)
if (!parsed || typeof parsed !== "object" || typeof (parsed as any).type !== "string") {
return null
}
return parsed as PluginEvent
} catch {
return null
}
}

View File

@@ -0,0 +1,124 @@
export type PluginEvent = {
type: string
properties?: Record<string, unknown>
}
export type CodeNomadConfig = {
instanceId: string
baseUrl: string
}
export function getCodeNomadConfig(): CodeNomadConfig {
return {
instanceId: requireEnv("CODENOMAD_INSTANCE_ID"),
baseUrl: requireEnv("CODENOMAD_BASE_URL"),
}
}
export function createCodeNomadRequester(config: CodeNomadConfig) {
const baseUrl = config.baseUrl.replace(/\/+$/, "")
const pluginBase = `${baseUrl}/workspaces/${encodeURIComponent(config.instanceId)}/plugin`
const authorization = buildInstanceAuthorizationHeader()
const buildUrl = (path: string) => {
if (path.startsWith("http://") || path.startsWith("https://")) {
return path
}
const normalized = path.startsWith("/") ? path : `/${path}`
return `${pluginBase}${normalized}`
}
const buildHeaders = (headers: HeadersInit | undefined, hasBody: boolean): Record<string, string> => {
const output: Record<string, string> = normalizeHeaders(headers)
output.Authorization = authorization
if (hasBody) {
output["Content-Type"] = output["Content-Type"] ?? "application/json"
}
return output
}
const fetchWithAuth = async (path: string, init?: RequestInit): Promise<Response> => {
const url = buildUrl(path)
const hasBody = init?.body !== undefined
const headers = buildHeaders(init?.headers, hasBody)
return fetch(url, {
...init,
headers,
})
}
const requestJson = async <T>(path: string, init?: RequestInit): Promise<T> => {
const response = await fetchWithAuth(path, init)
if (!response.ok) {
const message = await response.text().catch(() => "")
throw new Error(message || `Request failed with ${response.status}`)
}
if (response.status === 204) {
return undefined as T
}
return (await response.json()) as T
}
const requestVoid = async (path: string, init?: RequestInit): Promise<void> => {
const response = await fetchWithAuth(path, init)
if (!response.ok) {
const message = await response.text().catch(() => "")
throw new Error(message || `Request failed with ${response.status}`)
}
}
const requestSseBody = async (path: string): Promise<ReadableStream<Uint8Array>> => {
const response = await fetchWithAuth(path, { headers: { Accept: "text/event-stream" } })
if (!response.ok || !response.body) {
throw new Error(`SSE unavailable (${response.status})`)
}
return response.body as ReadableStream<Uint8Array>
}
return {
buildUrl,
fetch: fetchWithAuth,
requestJson,
requestVoid,
requestSseBody,
}
}
function requireEnv(key: string): string {
const value = process.env[key]
if (!value || !value.trim()) {
throw new Error(`[CodeNomadPlugin] Missing required env var ${key}`)
}
return value
}
function buildInstanceAuthorizationHeader(): string {
const username = requireEnv("OPENCODE_SERVER_USERNAME")
const password = requireEnv("OPENCODE_SERVER_PASSWORD")
const token = Buffer.from(`${username}:${password}`, "utf8").toString("base64")
return `Basic ${token}`
}
function normalizeHeaders(headers: HeadersInit | undefined): Record<string, string> {
const output: Record<string, string> = {}
if (!headers) return output
if (headers instanceof Headers) {
headers.forEach((value, key) => {
output[key] = value
})
return output
}
if (Array.isArray(headers)) {
for (const [key, value] of headers) {
output[key] = value
}
return output
}
return { ...headers }
}

View File

@@ -31,6 +31,11 @@ You can run CodeNomad directly without installing it:
npx @neuralnomads/codenomad --launch
```
On startup, CodeNomad prints two URLs:
- `Local Connection URL : ...` (used by desktop shells)
- `Remote Connection URL : ...` (used by browsers/other machines when remote access is enabled)
### Install Globally
Or install it globally to use the `codenomad` command:
@@ -44,15 +49,78 @@ You can configure the server using flags or environment variables:
| Flag | Env Variable | Description |
|------|--------------|-------------|
| `--port <number>` | `CLI_PORT` | HTTP port (default 9898) |
| `--https <enabled>` | `CLI_HTTPS` | Enable HTTPS listener (default `true`) |
| `--http <enabled>` | `CLI_HTTP` | Enable HTTP listener (default `false`) |
| `--https-port <number>` | `CLI_HTTPS_PORT` | HTTPS port (default `9898`, use `0` for auto) |
| `--http-port <number>` | `CLI_HTTP_PORT` | HTTP port (default `9899`, use `0` for auto) |
| `--tls-key <path>` | `CLI_TLS_KEY` | TLS private key (PEM). Requires `--tls-cert`. |
| `--tls-cert <path>` | `CLI_TLS_CERT` | TLS certificate (PEM). Requires `--tls-key`. |
| `--tls-ca <path>` | `CLI_TLS_CA` | Optional CA chain/bundle (PEM) |
| `--tlsSANs <list>` | `CLI_TLS_SANS` | Additional TLS SANs (comma-separated) |
| `--host <addr>` | `CLI_HOST` | Interface to bind (default 127.0.0.1) |
| `--workspace-root <path>` | `CLI_WORKSPACE_ROOT` | Default root for new workspaces |
| `--unrestricted-root` | `CLI_UNRESTRICTED_ROOT` | Allow full-filesystem browsing |
| `--config <path>` | `CLI_CONFIG` | Config file location |
| `--launch` | `CLI_LAUNCH` | Open the UI in a Chromium-based browser |
| `--log-level <level>` | `CLI_LOG_LEVEL` | Logging level (trace, debug, info, warn, error) |
| `--username <username>` | `CODENOMAD_SERVER_USERNAME` | Username for CodeNomad's internal auth (default `codenomad`) |
| `--password <password>` | `CODENOMAD_SERVER_PASSWORD` | Password for CodeNomad's internal auth |
| `--generate-token` | `CODENOMAD_GENERATE_TOKEN` | Emit a one-time local bootstrap token for desktop flows |
| `--dangerously-skip-auth` | `CODENOMAD_SKIP_AUTH` | Disable CodeNomad's internal auth (use only behind a trusted perimeter) |
### HTTP vs HTTPS
- Default: `--https=true --http=false` (HTTPS only).
- To run plain HTTP only (useful for development):
```sh
codenomad --https=false --http=true
```
- To run both HTTPS (for remote) and HTTP loopback (for desktop):
```sh
codenomad --https=true --http=true
```
### Remote Access Binding Rules
- When remote access is enabled (bind host is non-loopback, e.g. `--host 0.0.0.0`):
- HTTP listens on `127.0.0.1` only.
- HTTPS listens on `--host` (LAN/all interfaces).
- When remote access is disabled (bind host is loopback, e.g. `--host 127.0.0.1`):
- Both HTTP and HTTPS listen on `127.0.0.1`.
### Self-Signed Certificates
If `--https=true` and you do not provide `--tls-key/--tls-cert`, CodeNomad generates a local certificate automatically under your config directory:
- `~/.config/codenomad/tls/ca-cert.pem`
- `~/.config/codenomad/tls/server-cert.pem`
Certificates are valid for about 30 days and rotate automatically on startup when needed. You can add extra SANs via:
```sh
codenomad --tlsSANs "localhost,127.0.0.1,my-hostname,192.168.1.10"
```
### Authentication
- Default behavior: CodeNomad requires a login (username/password) and stores a session cookie in the browser.
- `--dangerously-skip-auth` / `CODENOMAD_SKIP_AUTH=true` disables the login prompt and treats all requests as authenticated.
Use this only when access is already protected by another layer (SSO proxy, VPN, Coder workspace auth, etc.).
If you bind to `0.0.0.0` while skipping auth, anyone who can reach the port can access the API.
### Progressive Web App (PWA)
When running as a server CodeNomad can also be installed as a PWA from any supported browser, giving you a native app experience just like the Electron installation but executing on the remote server instead.
1. Open the CodeNomad UI in a Chromium-based browser (Chrome, Edge, Brave, etc.).
2. Click the install icon in the address bar, or use the browser menu → "Install CodeNomad".
3. The app will open in a standalone window and appear in your OS app list.
> **TLS requirement**
> Browsers require a secure (`https://`) connection for PWA installation.
> If you host CodeNomad on a remote machine, use HTTPS. Self-signed certificates generally won't work unless they are explicitly trusted by the device/browser (e.g., via a custom CA).
### Data Storage
- **Config**: `~/.config/codenomad/config.json`
- **Instance Data**: `~/.config/codenomad/instances` (chat history, etc.)

View File

@@ -1,20 +1,30 @@
{
"name": "@neuralnomads/codenomad",
"version": "0.4.0",
"version": "0.10.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@neuralnomads/codenomad",
"version": "0.4.0",
"version": "0.10.1",
"dependencies": {
"@fastify/cors": "^8.5.0",
"@fastify/reply-from": "^9.8.0",
"@fastify/static": "^7.0.4",
"commander": "^12.1.0",
"fastify": "^4.28.1",
"fuzzysort": "^2.0.4",
"pino": "^9.4.0",
"undici": "^6.19.8",
"yauzl": "^2.10.0",
"zod": "^3.23.8"
},
"bin": {
"codenomad": "dist/bin.js"
},
"devDependencies": {
"@types/yauzl": "^2.10.0",
"cross-env": "^7.0.3",
"ts-node": "^10.9.2",
"tsx": "^4.20.6",
"typescript": "^5.6.3"
@@ -475,6 +485,15 @@
"node": ">=18"
}
},
"node_modules/@fastify/accept-negotiator": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@fastify/accept-negotiator/-/accept-negotiator-1.1.0.tgz",
"integrity": "sha512-OIHZrb2ImZ7XG85HXOONLcJWGosv7sIvM2ifAPQVhg9Lv7qdmMBNVaai4QTdyuaqbKM5eO6sLSQOYI7wEQeCJQ==",
"license": "MIT",
"engines": {
"node": ">=14"
}
},
"node_modules/@fastify/ajv-compiler": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-3.6.0.tgz",
@@ -486,6 +505,15 @@
"fast-uri": "^2.0.0"
}
},
"node_modules/@fastify/busboy": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz",
"integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==",
"license": "MIT",
"engines": {
"node": ">=14"
}
},
"node_modules/@fastify/cors": {
"version": "8.5.0",
"resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-8.5.0.tgz",
@@ -520,6 +548,77 @@
"fast-deep-equal": "^3.1.3"
}
},
"node_modules/@fastify/reply-from": {
"version": "9.8.0",
"resolved": "https://registry.npmjs.org/@fastify/reply-from/-/reply-from-9.8.0.tgz",
"integrity": "sha512-bPNVaFhEeNI0Lyl6404YZaPFokudCplidE3QoOcr78yOy6H9sYw97p5KPYvY/NJNUHfFtvxOaSAHnK+YSiv/Mg==",
"license": "MIT",
"dependencies": {
"@fastify/error": "^3.0.0",
"end-of-stream": "^1.4.4",
"fast-content-type-parse": "^1.1.0",
"fast-querystring": "^1.0.0",
"fastify-plugin": "^4.0.0",
"toad-cache": "^3.7.0",
"undici": "^5.19.1"
}
},
"node_modules/@fastify/reply-from/node_modules/undici": {
"version": "5.29.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-5.29.0.tgz",
"integrity": "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==",
"license": "MIT",
"dependencies": {
"@fastify/busboy": "^2.0.0"
},
"engines": {
"node": ">=14.0"
}
},
"node_modules/@fastify/send": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@fastify/send/-/send-2.1.0.tgz",
"integrity": "sha512-yNYiY6sDkexoJR0D8IDy3aRP3+L4wdqCpvx5WP+VtEU58sn7USmKynBzDQex5X42Zzvw2gNzzYgP90UfWShLFA==",
"license": "MIT",
"dependencies": {
"@lukeed/ms": "^2.0.1",
"escape-html": "~1.0.3",
"fast-decode-uri-component": "^1.0.1",
"http-errors": "2.0.0",
"mime": "^3.0.0"
}
},
"node_modules/@fastify/static": {
"version": "7.0.4",
"resolved": "https://registry.npmjs.org/@fastify/static/-/static-7.0.4.tgz",
"integrity": "sha512-p2uKtaf8BMOZWLs6wu+Ihg7bWNBdjNgCwDza4MJtTqg+5ovKmcbgbR9Xs5/smZ1YISfzKOCNYmZV8LaCj+eJ1Q==",
"license": "MIT",
"dependencies": {
"@fastify/accept-negotiator": "^1.0.0",
"@fastify/send": "^2.0.0",
"content-disposition": "^0.5.3",
"fastify-plugin": "^4.0.0",
"fastq": "^1.17.0",
"glob": "^10.3.4"
}
},
"node_modules/@isaacs/cliui": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
"integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==",
"license": "ISC",
"dependencies": {
"string-width": "^5.1.2",
"string-width-cjs": "npm:string-width@^4.2.0",
"strip-ansi": "^7.0.1",
"strip-ansi-cjs": "npm:strip-ansi@^6.0.1",
"wrap-ansi": "^8.1.0",
"wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/@jridgewell/resolve-uri": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
@@ -548,12 +647,31 @@
"@jridgewell/sourcemap-codec": "^1.4.10"
}
},
"node_modules/@lukeed/ms": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz",
"integrity": "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/@pinojs/redact": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
"integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==",
"license": "MIT"
},
"node_modules/@pkgjs/parseargs": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
"integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=14"
}
},
"node_modules/@tsconfig/node10": {
"version": "1.0.12",
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz",
@@ -593,6 +711,16 @@
"undici-types": "~7.16.0"
}
},
"node_modules/@types/yauzl": {
"version": "2.10.3",
"resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz",
"integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/abstract-logging": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz",
@@ -674,6 +802,30 @@
],
"license": "BSD-3-Clause"
},
"node_modules/ansi-regex": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
}
},
"node_modules/ansi-styles": {
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/arg": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
@@ -700,6 +852,48 @@
"fastq": "^1.17.1"
}
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"license": "MIT"
},
"node_modules/brace-expansion": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/buffer-crc32": {
"version": "0.2.13",
"resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz",
"integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==",
"license": "MIT",
"engines": {
"node": "*"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
},
"engines": {
"node": ">=7.0.0"
}
},
"node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"license": "MIT"
},
"node_modules/commander": {
"version": "12.1.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz",
@@ -709,6 +903,18 @@
"node": ">=18"
}
},
"node_modules/content-disposition": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
"integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
"license": "MIT",
"dependencies": {
"safe-buffer": "5.2.1"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
@@ -725,6 +931,48 @@
"dev": true,
"license": "MIT"
},
"node_modules/cross-env": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz",
"integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==",
"dev": true,
"license": "MIT",
"dependencies": {
"cross-spawn": "^7.0.1"
},
"bin": {
"cross-env": "src/bin/cross-env.js",
"cross-env-shell": "src/bin/cross-env-shell.js"
},
"engines": {
"node": ">=10.14",
"npm": ">=6",
"yarn": ">=1"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"license": "MIT",
"dependencies": {
"path-key": "^3.1.0",
"shebang-command": "^2.0.0",
"which": "^2.0.1"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/diff": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz",
@@ -735,6 +983,27 @@
"node": ">=0.3.1"
}
},
"node_modules/eastasianwidth": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
"license": "MIT"
},
"node_modules/emoji-regex": {
"version": "9.2.2",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
"integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
"license": "MIT"
},
"node_modules/end-of-stream": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
"license": "MIT",
"dependencies": {
"once": "^1.4.0"
}
},
"node_modules/esbuild": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
@@ -777,6 +1046,12 @@
"@esbuild/win32-x64": "0.25.12"
}
},
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"license": "MIT"
},
"node_modules/fast-content-type-parse": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-1.1.0.tgz",
@@ -891,6 +1166,15 @@
"reusify": "^1.0.4"
}
},
"node_modules/fd-slicer": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz",
"integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==",
"license": "MIT",
"dependencies": {
"pend": "~1.2.0"
}
},
"node_modules/find-my-way": {
"version": "8.2.2",
"resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-8.2.2.tgz",
@@ -905,6 +1189,22 @@
"node": ">=14"
}
},
"node_modules/foreground-child": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
"integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
"license": "ISC",
"dependencies": {
"cross-spawn": "^7.0.6",
"signal-exit": "^4.0.1"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@@ -929,6 +1229,12 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/fuzzysort": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/fuzzysort/-/fuzzysort-2.0.4.tgz",
"integrity": "sha512-Api1mJL+Ad7W7vnDZnWq5pGaXJjyencT+iKGia2PlHUcSsSzWwIQ3S1isiMpwpavjYtGd2FzhUIhnnhOULZgDw==",
"license": "MIT"
},
"node_modules/get-tsconfig": {
"version": "4.13.0",
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz",
@@ -942,6 +1248,48 @@
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
}
},
"node_modules/glob": {
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
"integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
"license": "ISC",
"dependencies": {
"foreground-child": "^3.1.0",
"jackspeak": "^3.1.2",
"minimatch": "^9.0.4",
"minipass": "^7.1.2",
"package-json-from-dist": "^1.0.0",
"path-scurry": "^1.11.1"
},
"bin": {
"glob": "dist/esm/bin.mjs"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/http-errors": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
"integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==",
"license": "MIT",
"dependencies": {
"depd": "2.0.0",
"inherits": "2.0.4",
"setprototypeof": "1.2.0",
"statuses": "2.0.1",
"toidentifier": "1.0.1"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -951,6 +1299,36 @@
"node": ">= 0.10"
}
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"license": "ISC"
},
"node_modules/jackspeak": {
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
"integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==",
"license": "BlueOak-1.0.0",
"dependencies": {
"@isaacs/cliui": "^8.0.2"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
},
"optionalDependencies": {
"@pkgjs/parseargs": "^0.11.0"
}
},
"node_modules/json-schema-ref-resolver": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-1.0.1.tgz",
@@ -977,6 +1355,12 @@
"set-cookie-parser": "^2.4.1"
}
},
"node_modules/lru-cache": {
"version": "10.4.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"license": "ISC"
},
"node_modules/make-error": {
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",
@@ -984,6 +1368,42 @@
"dev": true,
"license": "ISC"
},
"node_modules/mime": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz",
"integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==",
"license": "MIT",
"bin": {
"mime": "cli.js"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/minimatch": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
},
"engines": {
"node": ">=16 || 14 >=14.17"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/minipass": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
"integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==",
"license": "ISC",
"engines": {
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/mnemonist": {
"version": "0.39.6",
"resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.39.6.tgz",
@@ -1008,6 +1428,52 @@
"node": ">=14.0.0"
}
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"license": "ISC",
"dependencies": {
"wrappy": "1"
}
},
"node_modules/package-json-from-dist": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
"license": "BlueOak-1.0.0"
},
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/path-scurry": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz",
"integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==",
"license": "BlueOak-1.0.0",
"dependencies": {
"lru-cache": "^10.2.0",
"minipass": "^5.0.0 || ^6.0.2 || ^7.0.0"
},
"engines": {
"node": ">=16 || 14 >=14.18"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/pend": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
"integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==",
"license": "MIT"
},
"node_modules/pino": {
"version": "9.14.0",
"resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz",
@@ -1139,6 +1605,26 @@
"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
"license": "MIT"
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/safe-regex2": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-3.1.0.tgz",
@@ -1181,6 +1667,45 @@
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT"
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC"
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"license": "MIT",
"dependencies": {
"shebang-regex": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/shebang-regex": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/signal-exit": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
"license": "ISC",
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/sonic-boom": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.0.tgz",
@@ -1199,6 +1724,111 @@
"node": ">= 10.x"
}
},
"node_modules/statuses": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
"integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/string-width": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
"integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
"license": "MIT",
"dependencies": {
"eastasianwidth": "^0.2.0",
"emoji-regex": "^9.2.2",
"strip-ansi": "^7.0.1"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/string-width-cjs": {
"name": "string-width",
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/string-width-cjs/node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/string-width-cjs/node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
},
"node_modules/string-width-cjs/node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-ansi": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz",
"integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^6.0.1"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
}
},
"node_modules/strip-ansi-cjs": {
"name": "strip-ansi",
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-ansi-cjs/node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/thread-stream": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.1.0.tgz",
@@ -1217,6 +1847,15 @@
"node": ">=12"
}
},
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
"license": "MIT",
"engines": {
"node": ">=0.6"
}
},
"node_modules/ts-node": {
"version": "10.9.2",
"resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz",
@@ -1296,6 +1935,15 @@
"node": ">=14.17"
}
},
"node_modules/undici": {
"version": "6.23.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.23.0.tgz",
"integrity": "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g==",
"license": "MIT",
"engines": {
"node": ">=18.17"
}
},
"node_modules/undici-types": {
"version": "7.16.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
@@ -1310,6 +1958,128 @@
"dev": true,
"license": "MIT"
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"license": "ISC",
"dependencies": {
"isexe": "^2.0.0"
},
"bin": {
"node-which": "bin/node-which"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/wrap-ansi": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
"integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^6.1.0",
"string-width": "^5.0.1",
"strip-ansi": "^7.0.1"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/wrap-ansi-cjs": {
"name": "wrap-ansi",
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/wrap-ansi-cjs/node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/wrap-ansi-cjs/node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/wrap-ansi-cjs/node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
},
"node_modules/wrap-ansi-cjs/node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/wrap-ansi-cjs/node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
},
"node_modules/yauzl": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz",
"integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==",
"license": "MIT",
"dependencies": {
"buffer-crc32": "~0.2.3",
"fd-slicer": "~1.1.0"
}
},
"node_modules/yn": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",

View File

@@ -1,7 +1,8 @@
{
"name": "@neuralnomads/codenomad",
"version": "0.4.0",
"version": "0.10.1",
"description": "CodeNomad Server",
"license": "MIT",
"author": {
"name": "Neural Nomads",
"email": "codenomad@neuralnomads.ai"
@@ -16,10 +17,11 @@
"codenomad": "dist/bin.js"
},
"scripts": {
"build": "npm run build:ui && npm run prepare-ui && tsc -p tsconfig.json",
"build": "npm run build:ui && npm run prepare-ui && tsc -p tsconfig.json && node ./scripts/copy-auth-pages.mjs && npm run prepare-config",
"build:ui": "npm run build --prefix ../ui",
"prepare-ui": "node ./scripts/copy-ui-dist.mjs",
"dev": "cross-env CLI_UI_DEV_SERVER=http://localhost:3000 tsx src/index.ts",
"prepare-config": "node ./scripts/copy-opencode-config.mjs",
"dev": "cross-env CODENOMAD_DEV=1 CODENOMAD_SERVER_PASSWORD=codenomad-dev CLI_UI_DEV_SERVER=http://localhost:3000 CLI_HTTPS=false CLI_HTTP=true tsx src/index.ts",
"typecheck": "tsc --noEmit -p tsconfig.json"
},
"dependencies": {
@@ -29,11 +31,15 @@
"commander": "^12.1.0",
"fastify": "^4.28.1",
"fuzzysort": "^2.0.4",
"node-forge": "^1.3.3",
"pino": "^9.4.0",
"undici": "^6.19.8",
"yauzl": "^2.10.0",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/node-forge": "^1.3.14",
"@types/yauzl": "^2.10.0",
"cross-env": "^7.0.3",
"ts-node": "^10.9.2",
"tsx": "^4.20.6",

View File

@@ -0,0 +1,22 @@
#!/usr/bin/env node
import { cpSync, existsSync, mkdirSync, rmSync } from "fs"
import path from "path"
import { fileURLToPath } from "url"
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const cliRoot = path.resolve(__dirname, "..")
const sourceDir = path.resolve(cliRoot, "src/server/routes/auth-pages")
const targetDir = path.resolve(cliRoot, "dist/server/routes/auth-pages")
if (!existsSync(sourceDir)) {
console.error(`[copy-auth-pages] Missing auth pages at ${sourceDir}`)
process.exit(1)
}
rmSync(targetDir, { recursive: true, force: true })
mkdirSync(targetDir, { recursive: true })
cpSync(sourceDir, targetDir, { recursive: true })
console.log(`[copy-auth-pages] Copied ${sourceDir} -> ${targetDir}`)

View File

@@ -0,0 +1,61 @@
#!/usr/bin/env node
import { spawnSync } from "child_process"
import { cpSync, existsSync, mkdirSync, rmSync } from "fs"
import path from "path"
import { fileURLToPath } from "url"
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const cliRoot = path.resolve(__dirname, "..")
const sourceDir = path.resolve(cliRoot, "../opencode-config")
const targetDir = path.resolve(cliRoot, "dist/opencode-config")
const nodeModulesDir = path.resolve(sourceDir, "node_modules")
const selfLinkDir = path.resolve(nodeModulesDir, "@codenomad", "opencode-config")
const npmExecPath = process.env.npm_execpath
const npmNodeExecPath = process.env.npm_node_execpath
if (!existsSync(sourceDir)) {
console.error(`[copy-opencode-config] Missing source directory at ${sourceDir}`)
process.exit(1)
}
if (!existsSync(nodeModulesDir)) {
console.log(`[copy-opencode-config] Installing opencode-config dependencies in ${sourceDir}`)
const npmArgs = [
"install",
"--prefix",
sourceDir,
"--omit=dev",
"--ignore-scripts",
"--fund=false",
"--audit=false",
"--package-lock=false",
"--workspaces=false",
]
const env = { ...process.env, npm_config_workspaces: "false" }
const npmCli = npmExecPath && npmNodeExecPath ? [npmNodeExecPath, [npmExecPath, ...npmArgs]] : null
const result = npmCli
? spawnSync(npmCli[0], npmCli[1], { cwd: sourceDir, stdio: "inherit", env })
: spawnSync("npm", npmArgs, { cwd: sourceDir, stdio: "inherit", env, shell: process.platform === "win32" })
if (result.status !== 0) {
if (result.error) {
console.error("[copy-opencode-config] npm install failed to start", result.error)
}
console.error("[copy-opencode-config] Failed to install opencode-config dependencies")
process.exit(result.status ?? 1)
}
}
// npm can create a self-referential link for scoped packages on Windows.
// That link causes recursive copies (ELOOP) during bundling.
rmSync(selfLinkDir, { recursive: true, force: true })
rmSync(targetDir, { recursive: true, force: true })
mkdirSync(path.dirname(targetDir), { recursive: true })
cpSync(sourceDir, targetDir, { recursive: true })
console.log(`[copy-opencode-config] Copied ${sourceDir} -> ${targetDir}`)

View File

@@ -50,6 +50,38 @@ export interface WorkspaceDeleteResponse {
status: WorkspaceStatus
}
export type WorktreeKind = "root" | "worktree"
export interface WorktreeDescriptor {
/** Stable identifier used by CodeNomad + clients ("root" for repo root). */
slug: string
/** Absolute directory path on the server host. */
directory: string
kind: WorktreeKind
/** Optional VCS branch name when available. */
branch?: string
}
export interface WorktreeListResponse {
worktrees: WorktreeDescriptor[]
/** True when the workspace folder resolves to a Git repository. */
isGitRepo?: boolean
}
export interface WorktreeCreateRequest {
slug: string
/** Optional branch name (defaults to slug). */
branch?: string
}
export interface WorktreeMap {
version: 1
/** Default worktree to use for new sessions and as fallback. */
defaultWorktreeSlug: string
/** Mapping of *parent* session IDs to a worktree slug. */
parentSessionWorktreeSlug: Record<string, string>
}
export type LogLevel = "debug" | "info" | "warn" | "error"
export interface WorkspaceLogEntry {
@@ -95,6 +127,26 @@ export interface FileSystemListResponse {
metadata: FileSystemListingMetadata
}
export interface FileSystemCreateFolderRequest {
/**
* Path identifier for the currently browsed directory.
* Matches the `path` parameter used for `/api/filesystem`.
*/
parentPath?: string
/** Single folder name (no separators). */
name: string
}
export interface FileSystemCreateFolderResponse {
/**
* Path identifier that can be passed back to `/api/filesystem` to browse the new folder.
* Relative for restricted listings, absolute for unrestricted.
*/
path: string
/** Absolute folder path on the server host. */
absolutePath: string
}
export const WINDOWS_DRIVES_ROOT = "__drives__"
export interface WorkspaceFileResponse {
@@ -167,7 +219,6 @@ export type WorkspaceEventType =
| "instance.dataChanged"
| "instance.event"
| "instance.eventStatus"
| "app.releaseAvailable"
export type WorkspaceEventPayload =
| { type: "workspace.created"; workspace: WorkspaceDescriptor }
@@ -180,13 +231,13 @@ export type WorkspaceEventPayload =
| { type: "instance.dataChanged"; instanceId: string; data: InstanceData }
| { type: "instance.event"; instanceId: string; event: InstanceStreamEvent }
| { type: "instance.eventStatus"; instanceId: string; status: InstanceStreamStatus; reason?: string }
| { type: "app.releaseAvailable"; release: LatestReleaseInfo }
export interface NetworkAddress {
ip: string
family: "ipv4" | "ipv6"
scope: "external" | "internal" | "loopback"
url: string
/** Remote URL using the server's remote protocol/port for this IP. */
remoteUrl: string
}
export interface LatestReleaseInfo {
@@ -198,25 +249,70 @@ export interface LatestReleaseInfo {
notes?: string
}
export interface UiMeta {
version?: string
source: "bundled" | "downloaded" | "previous" | "override" | "dev-proxy" | "missing"
}
export interface SupportMeta {
supported: boolean
message?: string
minServerVersion?: string
latestServerVersion?: string
latestServerUrl?: string
}
export interface ServerMeta {
/** Base URL clients should target for REST calls (useful for Electron embedding). */
httpBaseUrl: string
/** URL desktop apps should use to connect (prefers loopback HTTP when enabled). */
localUrl: string
/** URL remote clients should use (prefers HTTPS when enabled). */
remoteUrl?: string
/** SSE endpoint advertised to clients (`/api/events` by default). */
eventsUrl: string
/** Host the server is bound to (e.g., 127.0.0.1 or 0.0.0.0). */
host: string
/** Listening mode derived from host binding. */
listeningMode: "local" | "all"
/** Actual port in use after binding. */
port: number
/** Actual local port in use after binding. */
localPort: number
/** Actual remote port in use after binding (when remoteUrl is set). */
remotePort?: number
/** Display label for the host (e.g., hostname or friendly name). */
hostLabel: string
/** Absolute path of the filesystem root exposed to clients. */
workspaceRoot: string
/** Reachable addresses for this server, external first. */
addresses: NetworkAddress[]
/** Optional metadata about the most recent public release. */
latestRelease?: LatestReleaseInfo
serverVersion?: string
ui?: UiMeta
support?: SupportMeta
}
export type BackgroundProcessStatus = "running" | "stopped" | "error"
export interface BackgroundProcess {
id: string
workspaceId: string
title: string
command: string
cwd: string
status: BackgroundProcessStatus
pid?: number
startedAt: string
stoppedAt?: string
exitCode?: number
outputSizeBytes?: number
}
export interface BackgroundProcessListResponse {
processes: BackgroundProcess[]
}
export interface BackgroundProcessOutputResponse {
id: string
content: string
truncated: boolean
sizeBytes: number
}
export type {

View File

@@ -0,0 +1,175 @@
import fs from "fs"
import path from "path"
import type { Logger } from "../logger"
import { hashPassword, type PasswordHashRecord, verifyPassword } from "./password-hash"
export interface AuthFile {
version: 1
username: string
password: PasswordHashRecord
userProvided: boolean
updatedAt: string
}
export interface AuthStatus {
username: string
passwordUserProvided: boolean
}
export class AuthStore {
private cachedFile: AuthFile | null = null
private overrideAuth: AuthFile | null = null
private bootstrapUsername: string | null = null
constructor(private readonly authFilePath: string, private readonly logger: Logger) {}
getAuthFilePath() {
return this.authFilePath
}
load(): AuthFile | null {
if (this.overrideAuth) {
return this.overrideAuth
}
if (this.cachedFile) {
return this.cachedFile
}
try {
if (!fs.existsSync(this.authFilePath)) {
return null
}
const raw = fs.readFileSync(this.authFilePath, "utf-8")
const parsed = JSON.parse(raw) as AuthFile
if (!parsed || parsed.version !== 1) {
this.logger.warn({ authFilePath: this.authFilePath }, "Auth file has unsupported version")
return null
}
this.cachedFile = parsed
return parsed
} catch (error) {
this.logger.warn({ err: error, authFilePath: this.authFilePath }, "Failed to load auth file")
return null
}
}
ensureInitialized(params: {
username: string
password?: string
allowBootstrapWithoutPassword: boolean
}): void {
const password = params.password?.trim()
if (password) {
const now = new Date().toISOString()
const runtime: AuthFile = {
version: 1,
username: params.username,
password: hashPassword(password),
userProvided: true,
updatedAt: now,
}
this.overrideAuth = runtime
this.cachedFile = null
this.bootstrapUsername = null
this.logger.debug({ authFilePath: this.authFilePath }, "Using runtime auth password override; ignoring auth file")
return
}
const existing = this.load()
if (existing) {
if (existing.username !== params.username) {
// Keep existing username unless explicitly overridden later.
this.logger.debug({ existing: existing.username, requested: params.username }, "Auth username differs from requested")
}
this.bootstrapUsername = null
return
}
if (params.allowBootstrapWithoutPassword) {
this.bootstrapUsername = params.username
this.logger.debug({ authFilePath: this.authFilePath }, "No auth file present; bootstrap-only mode enabled")
return
}
throw new Error(
`No server password configured. Create ${this.authFilePath} or start with --password / CODENOMAD_SERVER_PASSWORD.`,
)
}
validateCredentials(username: string, password: string): boolean {
const auth = this.load()
if (!auth) {
return false
}
if (username !== auth.username) {
return false
}
return verifyPassword(password, auth.password)
}
setPassword(params: { password: string; markUserProvided: boolean }): AuthStatus {
if (this.overrideAuth) {
throw new Error(
"Server password is provided via CLI/env and cannot be changed while running. Restart without --password / CODENOMAD_SERVER_PASSWORD to use auth.json.",
)
}
const current = this.load()
if (!current) {
if (!this.bootstrapUsername) {
throw new Error("Auth is not initialized")
}
const created: AuthFile = {
version: 1,
username: this.bootstrapUsername,
password: hashPassword(params.password),
userProvided: params.markUserProvided,
updatedAt: new Date().toISOString(),
}
this.persist(created)
this.bootstrapUsername = null
return { username: created.username, passwordUserProvided: created.userProvided }
}
const next: AuthFile = {
...current,
password: hashPassword(params.password),
userProvided: params.markUserProvided,
updatedAt: new Date().toISOString(),
}
this.persist(next)
return { username: next.username, passwordUserProvided: next.userProvided }
}
getStatus(): AuthStatus {
const current = this.load()
if (current) {
return { username: current.username, passwordUserProvided: current.userProvided }
}
if (this.bootstrapUsername) {
return { username: this.bootstrapUsername, passwordUserProvided: false }
}
throw new Error("Auth is not initialized")
}
private persist(auth: AuthFile) {
try {
fs.mkdirSync(path.dirname(this.authFilePath), { recursive: true })
fs.writeFileSync(this.authFilePath, JSON.stringify(auth, null, 2), "utf-8")
this.cachedFile = auth
this.logger.debug({ authFilePath: this.authFilePath }, "Persisted auth file")
} catch (error) {
this.logger.error({ err: error, authFilePath: this.authFilePath }, "Failed to persist auth file")
throw error
}
}
}

View File

@@ -0,0 +1,38 @@
import type { FastifyReply, FastifyRequest } from "fastify"
export function parseCookies(header: string | undefined): Record<string, string> {
const result: Record<string, string> = {}
if (!header) return result
const parts = header.split(";")
for (const part of parts) {
const index = part.indexOf("=")
if (index < 0) continue
const key = part.slice(0, index).trim()
const value = part.slice(index + 1).trim()
if (!key) continue
result[key] = decodeURIComponent(value)
}
return result
}
export function isLoopbackAddress(remoteAddress: string | undefined): boolean {
if (!remoteAddress) return false
if (remoteAddress === "127.0.0.1" || remoteAddress === "::1") return true
if (remoteAddress === "::ffff:127.0.0.1") return true
return false
}
export function wantsHtml(request: FastifyRequest): boolean {
const accept = (request.headers["accept"] ?? "").toString().toLowerCase()
return accept.includes("text/html") || accept.includes("application/xhtml")
}
export function sendUnauthorized(request: FastifyRequest, reply: FastifyReply) {
if (request.method === "GET" && !request.url.startsWith("/api/") && wantsHtml(request)) {
reply.redirect("/login")
return
}
reply.code(401).send({ error: "Unauthorized" })
}

View File

@@ -0,0 +1,163 @@
import type { FastifyReply, FastifyRequest } from "fastify"
import path from "path"
import type { Logger } from "../logger"
import { AuthStore } from "./auth-store"
import { TokenManager } from "./token-manager"
import { SessionManager } from "./session-manager"
import { isLoopbackAddress, parseCookies } from "./http-auth"
export const BOOTSTRAP_TOKEN_STDOUT_PREFIX = "CODENOMAD_BOOTSTRAP_TOKEN:" as const
export const DEFAULT_AUTH_USERNAME = "codenomad" as const
export const DEFAULT_AUTH_COOKIE_NAME = "codenomad_session" as const
export interface AuthManagerInit {
configPath: string
username: string
password?: string
generateToken: boolean
dangerouslySkipAuth?: boolean
}
export class AuthManager {
private readonly authStore: AuthStore | null
private readonly tokenManager: TokenManager | null
private readonly sessionManager = new SessionManager()
private readonly cookieName = DEFAULT_AUTH_COOKIE_NAME
private readonly authEnabled: boolean
constructor(private readonly init: AuthManagerInit, private readonly logger: Logger) {
this.authEnabled = !Boolean(init.dangerouslySkipAuth)
if (!this.authEnabled) {
this.authStore = null
this.tokenManager = null
return
}
const authFilePath = resolveAuthFilePath(init.configPath)
this.authStore = new AuthStore(authFilePath, logger.child({ component: "auth" }))
// Startup: password comes from CLI/env, auth.json, or bootstrap-only mode.
this.authStore.ensureInitialized({
username: init.username,
password: init.password,
allowBootstrapWithoutPassword: init.generateToken,
})
this.tokenManager = init.generateToken ? new TokenManager(60_000) : null
}
isAuthEnabled(): boolean {
return this.authEnabled
}
getCookieName(): string {
return this.cookieName
}
isTokenBootstrapEnabled(): boolean {
return Boolean(this.tokenManager)
}
issueBootstrapToken(): string | null {
if (!this.tokenManager) return null
return this.tokenManager.generate()
}
consumeBootstrapToken(token: string): boolean {
if (!this.tokenManager) return false
return this.tokenManager.consume(token)
}
validateLogin(username: string, password: string): boolean {
if (!this.authEnabled) {
return true
}
return this.requireAuthStore().validateCredentials(username, password)
}
createSession(username: string) {
if (!this.authEnabled) {
return { id: "auth-disabled", createdAt: Date.now(), username: this.init.username }
}
return this.sessionManager.createSession(username)
}
getStatus() {
if (!this.authEnabled) {
return { username: this.init.username, passwordUserProvided: false }
}
return this.requireAuthStore().getStatus()
}
setPassword(password: string) {
if (!this.authEnabled) {
throw new Error("Internal authentication is disabled")
}
return this.requireAuthStore().setPassword({ password, markUserProvided: true })
}
isLoopbackRequest(request: FastifyRequest): boolean {
return isLoopbackAddress(request.socket.remoteAddress)
}
getSessionFromRequest(request: FastifyRequest): { username: string; sessionId: string } | null {
if (!this.authEnabled) {
// When auth is disabled, treat all requests as authenticated.
// We still return a stable username so callers can display it.
return { username: this.init.username, sessionId: "auth-disabled" }
}
const cookies = parseCookies(request.headers.cookie)
const sessionId = cookies[this.cookieName]
const session = this.sessionManager.getSession(sessionId)
if (!session) return null
return { username: session.username, sessionId: session.id }
}
setSessionCookie(reply: FastifyReply, sessionId: string) {
reply.header("Set-Cookie", buildSessionCookie(this.cookieName, sessionId))
}
setSessionCookieWithOptions(reply: FastifyReply, sessionId: string, options?: { secure?: boolean }) {
reply.header("Set-Cookie", buildSessionCookie(this.cookieName, sessionId, options))
}
clearSessionCookie(reply: FastifyReply) {
reply.header("Set-Cookie", buildSessionCookie(this.cookieName, "", { maxAgeSeconds: 0 }))
}
clearSessionCookieWithOptions(reply: FastifyReply, options?: { secure?: boolean }) {
reply.header("Set-Cookie", buildSessionCookie(this.cookieName, "", { maxAgeSeconds: 0, ...options }))
}
private requireAuthStore(): AuthStore {
if (!this.authStore) {
throw new Error("Auth store is unavailable")
}
return this.authStore
}
}
function resolveAuthFilePath(configPath: string) {
const resolvedConfigPath = resolvePath(configPath)
return path.join(path.dirname(resolvedConfigPath), "auth.json")
}
function resolvePath(filePath: string) {
if (filePath.startsWith("~/")) {
return path.join(process.env.HOME ?? "", filePath.slice(2))
}
return path.resolve(filePath)
}
function buildSessionCookie(name: string, value: string, options?: { maxAgeSeconds?: number; secure?: boolean }) {
const parts = [`${name}=${encodeURIComponent(value)}`, "HttpOnly", "Path=/", "SameSite=Lax"]
if (options?.secure) {
parts.push("Secure")
}
if (options?.maxAgeSeconds !== undefined) {
parts.push(`Max-Age=${Math.max(0, Math.floor(options.maxAgeSeconds))}`)
}
return parts.join("; ")
}

View File

@@ -0,0 +1,49 @@
import crypto from "crypto"
export interface PasswordHashRecord {
algorithm: "scrypt"
saltBase64: string
hashBase64: string
keyLength: number
params: {
N: number
r: number
p: number
maxmem: number
}
}
const DEFAULT_SCRYPT_PARAMS = {
N: 16384,
r: 8,
p: 1,
maxmem: 32 * 1024 * 1024,
}
export function hashPassword(password: string): PasswordHashRecord {
const salt = crypto.randomBytes(16)
const params = DEFAULT_SCRYPT_PARAMS
const keyLength = 64
const derived = crypto.scryptSync(password, salt, keyLength, params)
return {
algorithm: "scrypt",
saltBase64: salt.toString("base64"),
hashBase64: Buffer.from(derived).toString("base64"),
keyLength,
params,
}
}
export function verifyPassword(password: string, record: PasswordHashRecord): boolean {
if (record.algorithm !== "scrypt") {
return false
}
const salt = Buffer.from(record.saltBase64, "base64")
const expected = Buffer.from(record.hashBase64, "base64")
const derived = crypto.scryptSync(password, salt, record.keyLength, record.params)
if (expected.length !== derived.length) {
return false
}
return crypto.timingSafeEqual(expected, Buffer.from(derived))
}

View File

@@ -0,0 +1,23 @@
import crypto from "crypto"
export interface SessionInfo {
id: string
createdAt: number
username: string
}
export class SessionManager {
private sessions = new Map<string, SessionInfo>()
createSession(username: string): SessionInfo {
const id = crypto.randomBytes(32).toString("base64url")
const info: SessionInfo = { id, createdAt: Date.now(), username }
this.sessions.set(id, info)
return info
}
getSession(id: string | undefined): SessionInfo | undefined {
if (!id) return undefined
return this.sessions.get(id)
}
}

View File

@@ -0,0 +1,32 @@
import crypto from "crypto"
export interface BootstrapToken {
token: string
createdAt: number
consumed: boolean
}
export class TokenManager {
private token: BootstrapToken | null = null
constructor(private readonly ttlMs: number) {}
generate(): string {
const token = crypto.randomBytes(32).toString("base64url")
this.token = { token, createdAt: Date.now(), consumed: false }
return token
}
consume(token: string): boolean {
if (!this.token) return false
if (this.token.consumed) return false
if (Date.now() - this.token.createdAt > this.ttlMs) return false
if (token !== this.token.token) return false
this.token.consumed = true
return true
}
peek(): string | null {
return this.token?.token ?? null
}
}

View File

@@ -0,0 +1,519 @@
import { spawn, spawnSync, type ChildProcess } from "child_process"
import { createWriteStream, existsSync, promises as fs } from "fs"
import path from "path"
import { randomBytes } from "crypto"
import type { EventBus } from "../events/bus"
import type { WorkspaceManager } from "../workspaces/manager"
import type { Logger } from "../logger"
import type { BackgroundProcess, BackgroundProcessStatus } from "../api-types"
const ROOT_DIR = ".codenomad/background_processes"
const INDEX_FILE = "index.json"
const OUTPUT_FILE = "output.txt"
const STOP_TIMEOUT_MS = 2000
const EXIT_WAIT_TIMEOUT_MS = 5000
const MAX_OUTPUT_BYTES = 20 * 1024
const OUTPUT_PUBLISH_INTERVAL_MS = 1000
interface ManagerDeps {
workspaceManager: WorkspaceManager
eventBus: EventBus
logger: Logger
}
interface RunningProcess {
id: string
child: ChildProcess
outputPath: string
exitPromise: Promise<void>
workspaceId: string
}
export class BackgroundProcessManager {
private readonly running = new Map<string, RunningProcess>()
constructor(private readonly deps: ManagerDeps) {
this.deps.eventBus.on("workspace.stopped", (event) => this.cleanupWorkspace(event.workspaceId))
this.deps.eventBus.on("workspace.error", (event) => this.cleanupWorkspace(event.workspace.id))
}
async list(workspaceId: string): Promise<BackgroundProcess[]> {
const records = await this.readIndex(workspaceId)
const enriched = await Promise.all(
records.map(async (record) => ({
...record,
outputSizeBytes: await this.getOutputSize(workspaceId, record.id),
})),
)
return enriched
}
async start(workspaceId: string, title: string, command: string): Promise<BackgroundProcess> {
const workspace = this.deps.workspaceManager.get(workspaceId)
if (!workspace) {
throw new Error("Workspace not found")
}
const id = this.generateId()
const processDir = await this.ensureProcessDir(workspaceId, id)
const outputPath = path.join(processDir, OUTPUT_FILE)
const outputStream = createWriteStream(outputPath, { flags: "a" })
const { shellCommand, shellArgs, spawnOptions } = this.buildShellSpawn(command)
const child = spawn(shellCommand, shellArgs, {
cwd: workspace.path,
stdio: ["ignore", "pipe", "pipe"],
detached: process.platform !== "win32",
...spawnOptions,
})
child.on("exit", () => {
this.killProcessTree(child, "SIGTERM")
})
const record: BackgroundProcess = {
id,
workspaceId,
title,
command,
cwd: workspace.path,
status: "running",
pid: child.pid,
startedAt: new Date().toISOString(),
outputSizeBytes: 0,
}
const exitPromise = new Promise<void>((resolve) => {
child.on("close", async (code) => {
await new Promise<void>((resolve) => outputStream.end(resolve))
this.running.delete(id)
record.status = this.statusFromExit(code)
record.exitCode = code === null ? undefined : code
record.stoppedAt = new Date().toISOString()
await this.upsertIndex(workspaceId, record)
record.outputSizeBytes = await this.getOutputSize(workspaceId, record.id)
this.publishUpdate(workspaceId, record)
resolve()
})
})
this.running.set(id, { id, child, outputPath, exitPromise, workspaceId })
let lastPublishAt = 0
const maybePublishSize = () => {
const now = Date.now()
if (now - lastPublishAt < OUTPUT_PUBLISH_INTERVAL_MS) {
return
}
lastPublishAt = now
this.publishUpdate(workspaceId, record)
}
child.stdout?.on("data", (data) => {
outputStream.write(data)
record.outputSizeBytes = (record.outputSizeBytes ?? 0) + data.length
maybePublishSize()
})
child.stderr?.on("data", (data) => {
outputStream.write(data)
record.outputSizeBytes = (record.outputSizeBytes ?? 0) + data.length
maybePublishSize()
})
await this.upsertIndex(workspaceId, record)
record.outputSizeBytes = await this.getOutputSize(workspaceId, record.id)
this.publishUpdate(workspaceId, record)
return record
}
async stop(workspaceId: string, processId: string): Promise<BackgroundProcess | null> {
const record = await this.findProcess(workspaceId, processId)
if (!record) {
return null
}
const running = this.running.get(processId)
if (running?.child && !running.child.killed) {
this.killProcessTree(running.child, "SIGTERM")
await this.waitForExit(running)
}
if (record.status === "running") {
record.status = "stopped"
record.stoppedAt = new Date().toISOString()
await this.upsertIndex(workspaceId, record)
record.outputSizeBytes = await this.getOutputSize(workspaceId, record.id)
this.publishUpdate(workspaceId, record)
}
return record
}
async terminate(workspaceId: string, processId: string): Promise<void> {
const record = await this.findProcess(workspaceId, processId)
if (!record) return
const running = this.running.get(processId)
if (running?.child && !running.child.killed) {
this.killProcessTree(running.child, "SIGTERM")
await this.waitForExit(running)
}
await this.removeFromIndex(workspaceId, processId)
await this.removeProcessDir(workspaceId, processId)
this.deps.eventBus.publish({
type: "instance.event",
instanceId: workspaceId,
event: { type: "background.process.removed", properties: { processId } },
})
}
async readOutput(
workspaceId: string,
processId: string,
options: { method?: "full" | "tail" | "head" | "grep"; pattern?: string; lines?: number; maxBytes?: number },
) {
const outputPath = this.getOutputPath(workspaceId, processId)
if (!existsSync(outputPath)) {
return { id: processId, content: "", truncated: false, sizeBytes: 0 }
}
const stats = await fs.stat(outputPath)
const sizeBytes = stats.size
const method = options.method ?? "full"
const lineCount = options.lines ?? 10
const raw = await this.readOutputBytes(outputPath, sizeBytes, options.maxBytes)
let content = raw
switch (method) {
case "head":
content = this.headLines(raw, lineCount)
break
case "tail":
content = this.tailLines(raw, lineCount)
break
case "grep":
if (!options.pattern) {
throw new Error("Pattern is required for grep output")
}
content = this.grepLines(raw, options.pattern)
break
default:
content = raw
}
const effectiveMaxBytes = options.maxBytes
return {
id: processId,
content,
truncated: effectiveMaxBytes !== undefined && sizeBytes > effectiveMaxBytes,
sizeBytes,
}
}
async streamOutput(workspaceId: string, processId: string, reply: any) {
const outputPath = this.getOutputPath(workspaceId, processId)
if (!existsSync(outputPath)) {
reply.code(404).send({ error: "Output not found" })
return
}
reply.raw.setHeader("Content-Type", "text/event-stream")
reply.raw.setHeader("Cache-Control", "no-cache")
reply.raw.setHeader("Connection", "keep-alive")
reply.raw.flushHeaders?.()
reply.hijack()
const file = await fs.open(outputPath, "r")
let position = (await file.stat()).size
const tick = async () => {
const stats = await file.stat()
if (stats.size <= position) return
const length = stats.size - position
const buffer = Buffer.alloc(length)
await file.read(buffer, 0, length, position)
position = stats.size
const content = buffer.toString("utf-8")
reply.raw.write(`data: ${JSON.stringify({ type: "chunk", content })}\n\n`)
}
const interval = setInterval(() => {
tick().catch((error) => {
this.deps.logger.warn({ err: error }, "Failed to stream background process output")
})
}, 1000)
const close = () => {
clearInterval(interval)
file.close().catch(() => undefined)
reply.raw.end?.()
}
reply.raw.on("close", close)
reply.raw.on("error", close)
}
private async cleanupWorkspace(workspaceId: string) {
for (const [, running] of this.running.entries()) {
if (running.workspaceId !== workspaceId) continue
this.killProcessTree(running.child, "SIGTERM")
await this.waitForExit(running)
}
await this.removeWorkspaceDir(workspaceId)
}
private killProcessTree(child: ChildProcess, signal: NodeJS.Signals) {
const pid = child.pid
if (!pid) return
if (process.platform === "win32") {
const args = this.buildWindowsTaskkillArgs(pid, signal)
try {
spawnSync("taskkill", args, { stdio: "ignore" })
return
} catch {
// Fall back to killing the direct child.
}
} else {
try {
process.kill(-pid, signal)
return
} catch {
// Fall back to killing the direct child.
}
}
try {
child.kill(signal)
} catch {
// ignore
}
}
private async waitForExit(running: RunningProcess) {
let exited = false
const exitPromise = running.exitPromise.finally(() => {
exited = true
})
const killTimeout = setTimeout(() => {
if (!exited) {
this.killProcessTree(running.child, "SIGKILL")
}
}, STOP_TIMEOUT_MS)
try {
await Promise.race([
exitPromise,
new Promise<void>((resolve) => {
setTimeout(resolve, EXIT_WAIT_TIMEOUT_MS)
}),
])
if (!exited) {
this.killProcessTree(running.child, "SIGKILL")
this.running.delete(running.id)
this.deps.logger.warn({ pid: running.child.pid }, "Timed out waiting for background process to exit")
}
} finally {
clearTimeout(killTimeout)
}
}
private buildShellSpawn(command: string): { shellCommand: string; shellArgs: string[]; spawnOptions?: Record<string, unknown> } {
if (process.platform === "win32") {
const comspec = process.env.ComSpec || "cmd.exe"
return {
shellCommand: comspec,
shellArgs: ["/d", "/s", "/c", command],
spawnOptions: { windowsVerbatimArguments: true },
}
}
// Keep bash for macOS/Linux.
return { shellCommand: "bash", shellArgs: ["-c", command] }
}
private buildWindowsTaskkillArgs(pid: number, signal: NodeJS.Signals): string[] {
// Default to graceful termination (no /F), then force kill when we escalate.
const force = signal === "SIGKILL"
const args = ["/PID", String(pid), "/T"]
if (force) {
args.push("/F")
}
return args
}
private statusFromExit(code: number | null): BackgroundProcessStatus {
if (code === null) return "stopped"
if (code === 0) return "stopped"
return "error"
}
private async readOutputBytes(outputPath: string, sizeBytes: number, maxBytes?: number): Promise<string> {
if (maxBytes === undefined || sizeBytes <= maxBytes) {
return await fs.readFile(outputPath, "utf-8")
}
const start = Math.max(0, sizeBytes - maxBytes)
const file = await fs.open(outputPath, "r")
const buffer = Buffer.alloc(sizeBytes - start)
await file.read(buffer, 0, buffer.length, start)
await file.close()
return buffer.toString("utf-8")
}
private headLines(input: string, lines: number): string {
const parts = input.split(/\r?\n/)
return parts.slice(0, Math.max(0, lines)).join("\n")
}
private tailLines(input: string, lines: number): string {
const parts = input.split(/\r?\n/)
return parts.slice(Math.max(0, parts.length - lines)).join("\n")
}
private grepLines(input: string, pattern: string): string {
let matcher: RegExp
try {
matcher = new RegExp(pattern)
} catch {
throw new Error("Invalid grep pattern")
}
return input
.split(/\r?\n/)
.filter((line) => matcher.test(line))
.join("\n")
}
private async ensureProcessDir(workspaceId: string, processId: string) {
const root = await this.ensureWorkspaceDir(workspaceId)
const processDir = path.join(root, processId)
await fs.mkdir(processDir, { recursive: true })
return processDir
}
private async ensureWorkspaceDir(workspaceId: string) {
const workspace = this.deps.workspaceManager.get(workspaceId)
if (!workspace) {
throw new Error("Workspace not found")
}
const root = path.join(workspace.path, ROOT_DIR, workspaceId)
await fs.mkdir(root, { recursive: true })
return root
}
private getOutputPath(workspaceId: string, processId: string) {
const workspace = this.deps.workspaceManager.get(workspaceId)
if (!workspace) {
throw new Error("Workspace not found")
}
return path.join(workspace.path, ROOT_DIR, workspaceId, processId, OUTPUT_FILE)
}
private async findProcess(workspaceId: string, processId: string): Promise<BackgroundProcess | null> {
const records = await this.readIndex(workspaceId)
return records.find((entry) => entry.id === processId) ?? null
}
private async readIndex(workspaceId: string): Promise<BackgroundProcess[]> {
const indexPath = await this.getIndexPath(workspaceId)
if (!existsSync(indexPath)) return []
try {
const raw = await fs.readFile(indexPath, "utf-8")
const parsed = JSON.parse(raw)
return Array.isArray(parsed) ? (parsed as BackgroundProcess[]) : []
} catch {
return []
}
}
private async upsertIndex(workspaceId: string, record: BackgroundProcess) {
const records = await this.readIndex(workspaceId)
const index = records.findIndex((entry) => entry.id === record.id)
if (index >= 0) {
records[index] = record
} else {
records.push(record)
}
await this.writeIndex(workspaceId, records)
}
private async removeFromIndex(workspaceId: string, processId: string) {
const records = await this.readIndex(workspaceId)
const next = records.filter((entry) => entry.id !== processId)
await this.writeIndex(workspaceId, next)
}
private async writeIndex(workspaceId: string, records: BackgroundProcess[]) {
const indexPath = await this.getIndexPath(workspaceId)
await fs.mkdir(path.dirname(indexPath), { recursive: true })
await fs.writeFile(indexPath, JSON.stringify(records, null, 2))
}
private async getIndexPath(workspaceId: string) {
const workspace = this.deps.workspaceManager.get(workspaceId)
if (!workspace) {
throw new Error("Workspace not found")
}
return path.join(workspace.path, ROOT_DIR, workspaceId, INDEX_FILE)
}
private async removeProcessDir(workspaceId: string, processId: string) {
const workspace = this.deps.workspaceManager.get(workspaceId)
if (!workspace) {
return
}
const processDir = path.join(workspace.path, ROOT_DIR, workspaceId, processId)
await fs.rm(processDir, { recursive: true, force: true })
}
private async removeWorkspaceDir(workspaceId: string) {
const workspace = this.deps.workspaceManager.get(workspaceId)
if (!workspace) {
return
}
const workspaceDir = path.join(workspace.path, ROOT_DIR, workspaceId)
await fs.rm(workspaceDir, { recursive: true, force: true })
}
private async getOutputSize(workspaceId: string, processId: string): Promise<number> {
const outputPath = this.getOutputPath(workspaceId, processId)
if (!existsSync(outputPath)) {
return 0
}
try {
const stats = await fs.stat(outputPath)
return stats.size
} catch {
return 0
}
}
private publishUpdate(workspaceId: string, record: BackgroundProcess) {
this.deps.eventBus.publish({
type: "instance.event",
instanceId: workspaceId,
event: { type: "background.process.updated", properties: { process: record } },
})
}
private generateId(): string {
const timestamp = new Date().toISOString().replace(/[:.]/g, "").slice(0, 15)
const random = randomBytes(3).toString("hex")
return `proc_${timestamp}_${random}`
}
}

View File

@@ -4,10 +4,12 @@ import {
BinaryUpdateRequest,
BinaryValidationResult,
} from "../api-types"
import { spawnSync } from "child_process"
import { ConfigStore } from "./store"
import { EventBus } from "../events/bus"
import type { ConfigFile } from "./schema"
import { Logger } from "../logger"
import { buildSpawnSpec } from "../workspaces/runtime"
export class BinaryRegistry {
constructor(
@@ -135,8 +137,42 @@ export class BinaryRegistry {
}
private validateRecord(record: BinaryRecord): BinaryValidationResult {
// TODO: call actual binary -v check.
return { valid: true, version: record.version }
const inputPath = record.path
if (!inputPath) {
return { valid: false, error: "Missing binary path" }
}
const spec = buildSpawnSpec(inputPath, ["--version"])
try {
const result = spawnSync(spec.command, spec.args, {
encoding: "utf8",
windowsVerbatimArguments: Boolean((spec.options as { windowsVerbatimArguments?: boolean }).windowsVerbatimArguments),
})
if (result.error) {
return { valid: false, error: result.error.message }
}
if (result.status !== 0) {
const stderr = result.stderr?.trim()
const stdout = result.stdout?.trim()
const combined = stderr || stdout
const error = combined ? `Exited with code ${result.status}: ${combined}` : `Exited with code ${result.status}`
return { valid: false, error }
}
const stdout = (result.stdout ?? "").trim()
const firstLine = stdout.split(/\r?\n/).find((line) => line.trim().length > 0)
const normalized = firstLine?.trim()
const versionMatch = normalized?.match(/([0-9]+\.[0-9]+\.[0-9A-Za-z.-]+)/)
const version = versionMatch?.[1]
return { valid: true, version }
} catch (error) {
return { valid: false, error: error instanceof Error ? error.message : String(error) }
}
}
private buildFallbackRecord(path: string): BinaryRecord {

View File

@@ -12,9 +12,13 @@ const PreferencesSchema = z.object({
showThinkingBlocks: z.boolean().default(false),
thinkingBlocksExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
showTimelineTools: z.boolean().default(true),
promptSubmitOnEnter: z.boolean().default(false),
lastUsedBinary: z.string().optional(),
locale: z.string().optional(),
environmentVariables: z.record(z.string()).default({}),
modelRecents: z.array(ModelPreferenceSchema).default([]),
modelFavorites: z.array(ModelPreferenceSchema).default([]),
modelThinkingSelections: z.record(z.string(), z.string()).default({}),
diffViewMode: z.enum(["split", "unified"]).default("split"),
toolOutputExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
diagnosticsExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),

View File

@@ -29,7 +29,6 @@ export class EventBus extends EventEmitter {
this.on("instance.dataChanged", handler)
this.on("instance.event", handler)
this.on("instance.eventStatus", handler)
this.on("app.releaseAvailable", handler)
return () => {
this.off("workspace.created", handler)
this.off("workspace.started", handler)
@@ -41,7 +40,6 @@ export class EventBus extends EventEmitter {
this.off("instance.dataChanged", handler)
this.off("instance.event", handler)
this.off("instance.eventStatus", handler)
this.off("app.releaseAvailable", handler)
}
}
}

View File

@@ -2,6 +2,7 @@ import fs from "fs"
import os from "os"
import path from "path"
import {
FileSystemCreateFolderResponse,
FileSystemEntry,
FileSystemListResponse,
FileSystemListingMetadata,
@@ -56,6 +57,30 @@ export class FileSystemBrowser {
return this.listRestrictedWithMetadata(targetPath, includeFiles)
}
createFolder(parentPath: string | undefined, folderName: string): FileSystemCreateFolderResponse {
const name = this.normalizeFolderName(folderName)
if (this.unrestricted) {
const resolvedParent = this.resolveUnrestrictedPath(parentPath)
if (this.isWindows && resolvedParent === WINDOWS_DRIVES_ROOT) {
throw new Error("Cannot create folders at drive root")
}
this.assertDirectoryExists(resolvedParent)
const absolutePath = this.resolveAbsoluteChild(resolvedParent, name)
fs.mkdirSync(absolutePath)
return { path: absolutePath, absolutePath }
}
const normalizedParent = this.normalizeRelativePath(parentPath)
const parentAbsolute = this.toRestrictedAbsolute(normalizedParent)
this.assertDirectoryExists(parentAbsolute)
const relativePath = this.buildRelativePath(normalizedParent, name)
const absolutePath = this.toRestrictedAbsolute(relativePath)
fs.mkdirSync(absolutePath)
return { path: relativePath, absolutePath }
}
readFile(relativePath: string): string {
if (this.unrestricted) {
throw new Error("readFile is not available in unrestricted mode")
@@ -157,25 +182,58 @@ export class FileSystemBrowser {
return { entries, metadata }
}
private normalizeFolderName(input: string): string {
const name = input.trim()
if (!name) {
throw new Error("Folder name is required")
}
if (name === "." || name === "..") {
throw new Error("Invalid folder name")
}
if (name.startsWith("~")) {
throw new Error("Invalid folder name")
}
if (name.includes("/") || name.includes("\\")) {
throw new Error("Folder name must not include path separators")
}
if (name.includes("\u0000")) {
throw new Error("Invalid folder name")
}
return name
}
private assertDirectoryExists(directory: string) {
if (!fs.existsSync(directory)) {
throw new Error(`Directory does not exist: ${directory}`)
}
const stats = fs.statSync(directory)
if (!stats.isDirectory()) {
throw new Error(`Path is not a directory: ${directory}`)
}
}
private readDirectoryEntries(directory: string, options: DirectoryReadOptions): FileSystemEntry[] {
const dirents = fs.readdirSync(directory, { withFileTypes: true })
const results: FileSystemEntry[] = []
for (const entry of dirents) {
if (!options.includeFiles && !entry.isDirectory()) {
continue
}
const absoluteEntryPath = path.join(directory, entry.name)
let stats: fs.Stats
try {
// Use fs.statSync (not Dirent.isDirectory) so symlinks to directories
// are treated as directories in directory-only listings.
stats = fs.statSync(absoluteEntryPath)
} catch {
// Skip entries we cannot stat (insufficient permissions, etc.)
continue
}
const isDirectory = entry.isDirectory()
const isDirectory = stats.isDirectory()
if (!options.includeFiles && !isDirectory) {
continue
}

View File

@@ -17,7 +17,10 @@ import { InstanceStore } from "./storage/instance-store"
import { InstanceEventBridge } from "./workspaces/instance-events"
import { createLogger } from "./logger"
import { launchInBrowser } from "./launcher"
import { startReleaseMonitor } from "./releases/release-monitor"
import { resolveUi } from "./ui/remote-ui"
import { AuthManager, BOOTSTRAP_TOKEN_STDOUT_PREFIX, DEFAULT_AUTH_USERNAME } from "./auth/manager"
import { resolveHttpsOptions } from "./server/tls"
import { resolveNetworkAddresses } from "./server/network-addresses"
const require = createRequire(import.meta.url)
@@ -27,8 +30,15 @@ const __dirname = path.dirname(__filename)
const DEFAULT_UI_STATIC_DIR = path.resolve(__dirname, "../public")
interface CliOptions {
port: number
host: string
https: boolean
http: boolean
httpsPort: number
httpPort: number
tlsKeyPath?: string
tlsCertPath?: string
tlsCaPath?: string
tlsSANs?: string
rootDir: string
configPath: string
unrestrictedRoot: boolean
@@ -36,12 +46,20 @@ interface CliOptions {
logDestination?: string
uiStaticDir: string
uiDevServer?: string
uiAutoUpdate: boolean
uiNoUpdate: boolean
uiManifestUrl?: string
launch: boolean
authUsername: string
authPassword?: string
generateToken: boolean
dangerouslySkipAuth: boolean
}
const DEFAULT_PORT = 9898
const DEFAULT_HOST = "127.0.0.1"
const DEFAULT_CONFIG_PATH = "~/.config/codenomad/config.json"
const DEFAULT_HTTPS_PORT = 9898
const DEFAULT_HTTP_PORT = 9899
function parseCliOptions(argv: string[]): CliOptions {
const program = new Command()
@@ -49,7 +67,14 @@ function parseCliOptions(argv: string[]): CliOptions {
.description("CodeNomad CLI server")
.version(packageJson.version, "-v, --version", "Show the CLI version")
.addOption(new Option("--host <host>", "Host interface to bind").env("CLI_HOST").default(DEFAULT_HOST))
.addOption(new Option("--port <number>", "Port for the HTTP server").env("CLI_PORT").default(DEFAULT_PORT).argParser(parsePort))
.addOption(new Option("--https <enabled>", "Enable HTTPS listener (true|false)").env("CLI_HTTPS").default("true"))
.addOption(new Option("--http <enabled>", "Enable HTTP listener (true|false)").env("CLI_HTTP").default("false"))
.addOption(new Option("--https-port <number>", "HTTPS port (0 for auto)").env("CLI_HTTPS_PORT").default(DEFAULT_HTTPS_PORT).argParser(parsePort))
.addOption(new Option("--http-port <number>", "HTTP port (0 for auto)").env("CLI_HTTP_PORT").default(DEFAULT_HTTP_PORT).argParser(parsePort))
.addOption(new Option("--tls-key <path>", "TLS private key (PEM)").env("CLI_TLS_KEY"))
.addOption(new Option("--tls-cert <path>", "TLS certificate (PEM)").env("CLI_TLS_CERT"))
.addOption(new Option("--tls-ca <path>", "TLS CA chain (PEM)").env("CLI_TLS_CA"))
.addOption(new Option("--tlsSANs <list>", "Additional TLS SANs (comma-separated)").env("CLI_TLS_SANS"))
.addOption(
new Option("--workspace-root <path>", "Workspace root directory").env("CLI_WORKSPACE_ROOT").default(process.cwd()),
)
@@ -62,12 +87,41 @@ function parseCliOptions(argv: string[]): CliOptions {
new Option("--ui-dir <path>", "Directory containing the built UI bundle").env("CLI_UI_DIR").default(DEFAULT_UI_STATIC_DIR),
)
.addOption(new Option("--ui-dev-server <url>", "Proxy UI requests to a running dev server").env("CLI_UI_DEV_SERVER"))
.addOption(new Option("--ui-no-update", "Disable remote UI updates").env("CLI_UI_NO_UPDATE").default(false))
.addOption(new Option("--ui-auto-update <enabled>", "Enable remote UI updates (true|false)").env("CLI_UI_AUTO_UPDATE").default("true"))
.addOption(new Option("--ui-manifest-url <url>", "Remote UI manifest URL").env("CLI_UI_MANIFEST_URL"))
.addOption(new Option("--launch", "Launch the UI in a browser after start").env("CLI_LAUNCH").default(false))
.addOption(
new Option("--username <username>", "Username for server authentication")
.env("CODENOMAD_SERVER_USERNAME")
.default(DEFAULT_AUTH_USERNAME),
)
.addOption(new Option("--password <password>", "Password for server authentication").env("CODENOMAD_SERVER_PASSWORD"))
.addOption(
new Option("--generate-token", "Emit a one-time bootstrap token for desktop")
.env("CODENOMAD_GENERATE_TOKEN")
.default(false),
)
.addOption(
new Option(
"--dangerously-skip-auth",
"Disable CodeNomad's internal auth. Use only behind a trusted perimeter (SSO/VPN/etc).",
)
.env("CODENOMAD_SKIP_AUTH")
.default(false),
)
program.parse(argv, { from: "user" })
const parsed = program.opts<{
host: string
port: number
https?: string
http?: string
httpsPort: number
httpPort: number
tlsKey?: string
tlsCert?: string
tlsCa?: string
tlsSANs?: string
workspaceRoot?: string
root?: string
unrestrictedRoot?: boolean
@@ -76,16 +130,45 @@ function parseCliOptions(argv: string[]): CliOptions {
logDestination?: string
uiDir: string
uiDevServer?: string
uiNoUpdate?: boolean
uiAutoUpdate?: string
uiManifestUrl?: string
launch?: boolean
username: string
password?: string
generateToken?: boolean
dangerouslySkipAuth?: boolean
}>()
const parseBooleanEnv = (value: string | undefined): boolean => {
const normalized = (value ?? "").trim().toLowerCase()
return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "y" || normalized === "on"
}
const resolvedRoot = parsed.workspaceRoot ?? parsed.root ?? process.cwd()
const normalizedHost = resolveHost(parsed.host)
const autoUpdateString = (parsed.uiAutoUpdate ?? "true").trim().toLowerCase()
const uiAutoUpdate = autoUpdateString === "1" || autoUpdateString === "true" || autoUpdateString === "yes"
const httpsEnabled = parseBooleanEnv(parsed.https)
const httpEnabled = parseBooleanEnv(parsed.http)
if (!httpsEnabled && !httpEnabled) {
throw new InvalidArgumentError("At least one listener must be enabled (--https or --http)")
}
return {
port: parsed.port,
host: normalizedHost,
https: httpsEnabled,
http: httpEnabled,
httpsPort: parsed.httpsPort,
httpPort: parsed.httpPort,
tlsKeyPath: parsed.tlsKey,
tlsCertPath: parsed.tlsCert,
tlsCaPath: parsed.tlsCa,
tlsSANs: parsed.tlsSANs,
rootDir: resolvedRoot,
configPath: parsed.config,
unrestrictedRoot: Boolean(parsed.unrestrictedRoot),
@@ -93,7 +176,14 @@ function parseCliOptions(argv: string[]): CliOptions {
logDestination: parsed.logDestination,
uiStaticDir: parsed.uiDir,
uiDevServer: parsed.uiDevServer,
uiAutoUpdate,
uiNoUpdate: Boolean(parsed.uiNoUpdate),
uiManifestUrl: parsed.uiManifestUrl,
launch: Boolean(parsed.launch),
authUsername: parsed.username,
authPassword: parsed.password,
generateToken: Boolean(parsed.generateToken),
dangerouslySkipAuth: Boolean(parsed.dangerouslySkipAuth),
}
}
@@ -106,10 +196,29 @@ function parsePort(input: string): number {
}
function resolveHost(input: string | undefined): string {
if (input && input.trim() === "0.0.0.0") {
const trimmed = input?.trim()
if (!trimmed) return DEFAULT_HOST
if (trimmed === "0.0.0.0") {
return "0.0.0.0"
}
return DEFAULT_HOST
if (trimmed === "localhost") {
return DEFAULT_HOST
}
return trimmed
}
function resolvePath(filePath: string) {
if (filePath.startsWith("~/")) {
return path.join(process.env.HOME ?? "", filePath.slice(2))
}
return path.resolve(filePath)
}
function programHasArg(argv: string[], flag: string): boolean {
return argv.includes(flag)
}
async function main() {
@@ -119,9 +228,73 @@ async function main() {
const configLogger = logger.child({ component: "config" })
const eventLogger = logger.child({ component: "events" })
logger.info({ options }, "Starting CodeNomad CLI server")
const logOptions = {
...options,
authPassword: options.authPassword ? "[REDACTED]" : undefined,
}
logger.info({ options: logOptions }, "Starting CodeNomad CLI server")
if (options.dangerouslySkipAuth) {
logger.warn(
"DANGEROUS: internal authentication is disabled (--dangerously-skip-auth / CODENOMAD_SKIP_AUTH).",
)
}
const eventBus = new EventBus(eventLogger)
const isLoopbackHost = (host: string) => host === "127.0.0.1" || host === "::1" || host.startsWith("127.")
const configDir = path.dirname(resolvePath(options.configPath))
if ((options.tlsKeyPath && !options.tlsCertPath) || (!options.tlsKeyPath && options.tlsCertPath)) {
throw new InvalidArgumentError("--tls-key and --tls-cert must be provided together")
}
const serverMeta: ServerMeta = {
localUrl: "http://localhost:0",
remoteUrl: undefined,
eventsUrl: `/api/events`,
host: options.host,
listeningMode: isLoopbackHost(options.host) ? "local" : "all",
localPort: 0,
remotePort: undefined,
hostLabel: options.host,
workspaceRoot: options.rootDir,
addresses: [],
}
const authManager = new AuthManager(
{
configPath: options.configPath,
username: options.authUsername,
password: options.authPassword,
generateToken: options.generateToken,
dangerouslySkipAuth: options.dangerouslySkipAuth,
},
logger.child({ component: "auth" }),
)
if (options.generateToken && !options.dangerouslySkipAuth) {
const token = authManager.issueBootstrapToken()
if (token) {
console.log(`${BOOTSTRAP_TOKEN_STDOUT_PREFIX}${token}`)
}
}
const tlsResolution = resolveHttpsOptions({
enabled: options.https,
configDir,
host: options.host,
tlsKeyPath: options.tlsKeyPath,
tlsCertPath: options.tlsCertPath,
tlsCaPath: options.tlsCaPath,
tlsSANs: options.tlsSANs,
logger: logger.child({ component: "tls" }),
})
const nodeExtraCaCertsPath = !options.http ? tlsResolution?.caCertPath : undefined
const configStore = new ConfigStore(options.configPath, eventBus, configLogger)
const binaryRegistry = new BinaryRegistry(configStore, eventBus, configLogger)
const workspaceManager = new WorkspaceManager({
@@ -130,6 +303,8 @@ async function main() {
binaryRegistry,
eventBus,
logger: workspaceLogger,
getServerBaseUrl: () => serverMeta.localUrl,
nodeExtraCaCertsPath,
})
const fileSystemBrowser = new FileSystemBrowser({ rootDir: options.rootDir, unrestricted: options.unrestrictedRoot })
const instanceStore = new InstanceStore()
@@ -139,51 +314,151 @@ async function main() {
logger: logger.child({ component: "instance-events" }),
})
const serverMeta: ServerMeta = {
httpBaseUrl: `http://${options.host}:${options.port}`,
eventsUrl: `/api/events`,
host: options.host,
listeningMode: options.host === "0.0.0.0" ? "all" : "local",
port: options.port,
hostLabel: options.host,
workspaceRoot: options.rootDir,
addresses: [],
const uiDirEnvOverride = Boolean(process.env.CLI_UI_DIR)
const uiDirCliOverride = programHasArg(process.argv.slice(2), "--ui-dir")
const uiOverrideIsExplicit = uiDirEnvOverride || uiDirCliOverride
const uiDirOverride = uiOverrideIsExplicit ? options.uiStaticDir : undefined
const autoUpdateEnabled = options.uiAutoUpdate && !options.uiNoUpdate
const uiResolution = await resolveUi({
serverVersion: packageJson.version,
bundledUiDir: DEFAULT_UI_STATIC_DIR,
autoUpdate: autoUpdateEnabled,
overrideUiDir: uiDirOverride,
uiDevServerUrl: options.uiDevServer,
manifestUrl: options.uiManifestUrl,
logger: logger.child({ component: "ui" }),
})
serverMeta.serverVersion = packageJson.version
serverMeta.ui = {
version: uiResolution.uiVersion,
source: uiResolution.source,
}
serverMeta.support = {
supported: uiResolution.supported,
message: uiResolution.message,
latestServerVersion: uiResolution.latestServerVersion,
latestServerUrl: uiResolution.latestServerUrl,
minServerVersion: uiResolution.minServerVersion,
}
const releaseMonitor = startReleaseMonitor({
currentVersion: packageJson.version,
logger: logger.child({ component: "release-monitor" }),
onUpdate: (release) => {
if (release) {
serverMeta.latestRelease = release
eventBus.publish({ type: "app.releaseAvailable", release })
} else {
delete serverMeta.latestRelease
if (uiResolution.uiDevServerUrl && options.https) {
throw new InvalidArgumentError("UI dev proxy is only supported with --https=false --http=true")
}
const remoteAccessEnabled = options.host === "0.0.0.0" || !isLoopbackHost(options.host)
const httpsPortExplicit = programHasArg(process.argv.slice(2), "--https-port") || Boolean(process.env.CLI_HTTPS_PORT)
const httpPortExplicit = programHasArg(process.argv.slice(2), "--http-port") || Boolean(process.env.CLI_HTTP_PORT)
const httpsBindPort = httpsPortExplicit ? options.httpsPort : 0
const httpBindPort = httpPortExplicit ? options.httpPort : 0
// Listener binding rules:
// - Remote access enabled: HTTP listens on loopback, HTTPS on all IPs (host=0.0.0.0 / LAN IP).
// - Remote access disabled: both listen on loopback.
// - HTTP-only mode: respect --host (used for dev/testing).
const httpsBindHost = remoteAccessEnabled ? options.host : "127.0.0.1"
const httpBindHost = options.http ? (options.https ? "127.0.0.1" : options.host) : "127.0.0.1"
const servers: Array<ReturnType<typeof createHttpServer>> = []
const httpServer = options.http
? createHttpServer({
bindHost: httpBindHost,
bindPort: httpBindPort,
defaultPort: options.httpPort,
protocol: "http",
workspaceManager,
configStore,
binaryRegistry,
fileSystemBrowser,
eventBus,
serverMeta,
instanceStore,
authManager,
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
uiDevServerUrl: uiResolution.uiDevServerUrl,
logger,
})
: null
const httpsServer = options.https
? createHttpServer({
bindHost: httpsBindHost,
bindPort: httpsBindPort,
defaultPort: options.httpsPort,
protocol: "https",
httpsOptions: tlsResolution?.httpsOptions,
workspaceManager,
configStore,
binaryRegistry,
fileSystemBrowser,
eventBus,
serverMeta,
instanceStore,
authManager,
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
uiDevServerUrl: undefined,
logger,
})
: null
if (httpServer) servers.push(httpServer)
if (httpsServer) servers.push(httpsServer)
const [httpStart, httpsStart] = await Promise.all([
httpServer ? httpServer.start() : Promise.resolve(null),
httpsServer ? httpsServer.start() : Promise.resolve(null),
])
const localStart = httpStart ?? httpsStart
if (!localStart) {
throw new Error("No listeners started")
}
const remoteStart = httpsStart ?? httpStart
const localProtocol: "http" | "https" = httpStart ? "http" : "https"
const remoteProtocol: "http" | "https" = httpsStart ? "https" : "http"
const localUrl = `${localProtocol}://localhost:${localStart.port}`
let remoteUrl: string | undefined
if (remoteStart) {
const wantsAll = options.host === "0.0.0.0" || !isLoopbackHost(options.host)
let remoteHost = options.host
if (wantsAll) {
if (options.host === "0.0.0.0") {
const candidates = resolveNetworkAddresses({ host: options.host, protocol: remoteProtocol, port: remoteStart.port })
remoteHost = candidates.find((addr) => addr.scope === "external")?.ip ?? "localhost"
}
},
})
} else {
remoteHost = "localhost"
}
remoteUrl = `${remoteProtocol}://${remoteHost}:${remoteStart.port}`
}
const server = createHttpServer({
host: options.host,
port: options.port,
workspaceManager,
configStore,
binaryRegistry,
fileSystemBrowser,
eventBus,
serverMeta,
instanceStore,
uiStaticDir: options.uiStaticDir,
uiDevServerUrl: options.uiDevServer,
logger,
})
serverMeta.localUrl = localUrl
serverMeta.localPort = localStart.port
serverMeta.remoteUrl = remoteUrl
serverMeta.remotePort = remoteStart?.port
serverMeta.host = options.host
serverMeta.listeningMode = options.host === "0.0.0.0" || !isLoopbackHost(options.host) ? "all" : "local"
const startInfo = await server.start()
logger.info({ port: startInfo.port, host: options.host }, "HTTP server listening")
console.log(`CodeNomad Server is ready at ${startInfo.url}`)
if (serverMeta.remotePort && remoteUrl) {
serverMeta.addresses = resolveNetworkAddresses({ host: options.host, protocol: remoteProtocol, port: serverMeta.remotePort })
} else {
serverMeta.addresses = []
}
console.log(`Local Connection URL : ${serverMeta.localUrl}`)
if (serverMeta.remoteUrl) {
console.log(`Remote Connection URL : ${serverMeta.remoteUrl}`)
}
if (options.launch) {
await launchInBrowser(startInfo.url, logger.child({ component: "launcher" }))
await launchInBrowser(serverMeta.localUrl, logger.child({ component: "launcher" }))
}
let shuttingDown = false
@@ -194,23 +469,35 @@ async function main() {
return
}
shuttingDown = true
logger.info("Received shutdown signal, closing server")
try {
await server.stop()
logger.info("HTTP server stopped")
} catch (error) {
logger.error({ err: error }, "Failed to stop HTTP server")
}
logger.info("Received shutdown signal, stopping workspaces and server")
try {
instanceEventBridge.shutdown()
await workspaceManager.shutdown()
logger.info("Workspace manager shutdown complete")
} catch (error) {
logger.error({ err: error }, "Workspace manager shutdown failed")
}
const shutdownWorkspaces = (async () => {
try {
instanceEventBridge.shutdown()
} catch (error) {
logger.warn({ err: error }, "Instance event bridge shutdown failed")
}
releaseMonitor.stop()
try {
await workspaceManager.shutdown()
logger.info("Workspace manager shutdown complete")
} catch (error) {
logger.error({ err: error }, "Workspace manager shutdown failed")
}
})()
const shutdownHttp = (async () => {
try {
await Promise.allSettled(servers.map((srv) => srv.stop()))
logger.info("HTTP server(s) stopped")
} catch (error) {
logger.error({ err: error }, "Failed to stop HTTP server")
}
})()
await Promise.allSettled([shutdownWorkspaces, shutdownHttp])
// no-op: remote UI manifest replaces GitHub release monitor
logger.info("Exiting process")
process.exit(0)

View File

@@ -0,0 +1,31 @@
import { existsSync } from "fs"
import path from "path"
import { fileURLToPath } from "url"
import { createLogger } from "./logger"
const log = createLogger({ component: "opencode-config" })
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const devTemplateDir = path.resolve(__dirname, "../../opencode-config")
const resourcesPath = (process as NodeJS.Process & { resourcesPath?: string }).resourcesPath
const prodTemplateDirs = [
resourcesPath ? path.resolve(resourcesPath, "opencode-config") : undefined,
path.resolve(__dirname, "opencode-config"),
].filter((dir): dir is string => Boolean(dir))
const isDevBuild = Boolean(process.env.CODENOMAD_DEV ?? process.env.CLI_UI_DEV_SERVER) || existsSync(devTemplateDir)
const templateDir = isDevBuild
? devTemplateDir
: prodTemplateDirs.find((dir) => existsSync(dir)) ?? prodTemplateDirs[0]
export function getOpencodeConfigDir(): string {
if (!existsSync(templateDir)) {
throw new Error(`CodeNomad Opencode config template missing at ${templateDir}`)
}
if (isDevBuild) {
log.debug({ templateDir }, "Using Opencode config template directly (dev mode)")
}
return templateDir
}

View File

@@ -0,0 +1,55 @@
import type { FastifyReply } from "fastify"
import type { Logger } from "../logger"
export interface PluginOutboundEvent {
type: string
properties?: Record<string, unknown>
}
interface ClientConnection {
reply: FastifyReply
workspaceId: string
}
export class PluginChannelManager {
private readonly clients = new Set<ClientConnection>()
constructor(private readonly logger: Logger) {}
register(workspaceId: string, reply: FastifyReply) {
const connection: ClientConnection = { workspaceId, reply }
this.clients.add(connection)
this.logger.debug({ workspaceId }, "Plugin SSE client connected")
let closed = false
const close = () => {
if (closed) return
closed = true
this.clients.delete(connection)
this.logger.debug({ workspaceId }, "Plugin SSE client disconnected")
}
return { close }
}
send(workspaceId: string, event: PluginOutboundEvent) {
for (const client of this.clients) {
if (client.workspaceId !== workspaceId) continue
this.write(client.reply, event)
}
}
broadcast(event: PluginOutboundEvent) {
for (const client of this.clients) {
this.write(client.reply, event)
}
}
private write(reply: FastifyReply, event: PluginOutboundEvent) {
try {
reply.raw.write(`data: ${JSON.stringify(event)}\n\n`)
} catch (error) {
this.logger.warn({ err: error }, "Failed to write plugin SSE event")
}
}
}

View File

@@ -0,0 +1,36 @@
import type { EventBus } from "../events/bus"
import type { WorkspaceManager } from "../workspaces/manager"
import type { Logger } from "../logger"
import type { PluginOutboundEvent } from "./channel"
export interface PluginInboundEvent {
type: string
properties?: Record<string, unknown>
}
interface HandlerDeps {
workspaceManager: WorkspaceManager
eventBus: EventBus
logger: Logger
}
export function handlePluginEvent(workspaceId: string, event: PluginInboundEvent, deps: HandlerDeps) {
switch (event.type) {
case "codenomad.pong":
deps.logger.debug({ workspaceId, properties: event.properties }, "Plugin pong received")
return
default:
deps.logger.debug({ workspaceId, eventType: event.type }, "Unhandled plugin event")
}
}
export function buildPingEvent(): PluginOutboundEvent {
return {
type: "codenomad.ping",
properties: {
ts: Date.now(),
},
}
}

View File

@@ -7,6 +7,7 @@ import path from "path"
import { fetch } from "undici"
import type { Logger } from "../logger"
import { WorkspaceManager } from "../workspaces/manager"
import { isValidWorktreeSlug, listWorktrees, resolveRepoRoot } from "../workspaces/git-worktrees"
import { ConfigStore } from "../config/store"
import { BinaryRegistry } from "../config/binaries"
@@ -18,12 +19,23 @@ import { registerFilesystemRoutes } from "./routes/filesystem"
import { registerMetaRoutes } from "./routes/meta"
import { registerEventRoutes } from "./routes/events"
import { registerStorageRoutes } from "./routes/storage"
import { registerPluginRoutes } from "./routes/plugin"
import { registerBackgroundProcessRoutes } from "./routes/background-processes"
import { registerWorktreeRoutes } from "./routes/worktrees"
import { ServerMeta } from "../api-types"
import { InstanceStore } from "../storage/instance-store"
import { BackgroundProcessManager } from "../background-processes/manager"
import type { AuthManager } from "../auth/manager"
import { registerAuthRoutes } from "./routes/auth"
import { sendUnauthorized, wantsHtml } from "../auth/http-auth"
interface HttpServerDeps {
host: string
port: number
bindHost: string
bindPort: number
/** When bindPort is 0, try this first. */
defaultPort: number
protocol: "http" | "https"
httpsOptions?: { key: string | Buffer; cert: string | Buffer; ca?: string | Buffer }
workspaceManager: WorkspaceManager
configStore: ConfigStore
binaryRegistry: BinaryRegistry
@@ -31,6 +43,7 @@ interface HttpServerDeps {
eventBus: EventBus
serverMeta: ServerMeta
instanceStore: InstanceStore
authManager: AuthManager
uiStaticDir: string
uiDevServerUrl?: string
logger: Logger
@@ -42,10 +55,15 @@ interface HttpServerStartResult {
displayHost: string
}
const DEFAULT_HTTP_PORT = 9898
export function createHttpServer(deps: HttpServerDeps) {
const app = Fastify({ logger: false })
// Fastify's type-level RawServer inference gets noisy when toggling HTTP vs HTTPS.
// We keep the runtime behavior correct and cast the instance to a generic FastifyInstance.
const app = Fastify(
({
logger: false,
...(deps.protocol === "https" && deps.httpsOptions ? { https: deps.httpsOptions } : {}),
} as unknown) as any,
) as unknown as FastifyInstance
const proxyLogger = deps.logger.child({ component: "proxy" })
const apiLogger = deps.logger.child({ component: "http" })
const sseLogger = deps.logger.child({ component: "sse" })
@@ -85,8 +103,57 @@ export function createHttpServer(deps: HttpServerDeps) {
done()
})
const allowedDevOrigins = new Set(["http://localhost:3000", "http://127.0.0.1:3000"])
const isLoopbackHost = (host: string) => host === "127.0.0.1" || host === "::1" || host.startsWith("127.")
const getSelfOrigins = (): Set<string> => {
const origins = new Set<string>()
const candidates: Array<string | undefined> = [deps.serverMeta.localUrl, deps.serverMeta.remoteUrl]
for (const candidate of candidates) {
if (!candidate) continue
try {
origins.add(new URL(candidate).origin)
} catch {
// ignore
}
}
for (const addr of deps.serverMeta.addresses ?? []) {
try {
origins.add(new URL(addr.remoteUrl).origin)
} catch {
// ignore
}
}
return origins
}
app.register(cors, {
origin: true,
origin: (origin, cb) => {
if (!origin) {
cb(null, true)
return
}
const selfOrigins = getSelfOrigins()
if (selfOrigins.has(origin)) {
cb(null, true)
return
}
if (allowedDevOrigins.has(origin)) {
cb(null, true)
return
}
// When we bind to a non-loopback host (e.g., 0.0.0.0 or LAN IP), allow cross-origin UI access.
if (deps.bindHost === "0.0.0.0" || !isLoopbackHost(deps.bindHost)) {
cb(null, true)
return
}
cb(null, false)
},
credentials: true,
})
@@ -100,35 +167,114 @@ export function createHttpServer(deps: HttpServerDeps) {
},
})
const backgroundProcessManager = new BackgroundProcessManager({
workspaceManager: deps.workspaceManager,
eventBus: deps.eventBus,
logger: deps.logger.child({ component: "background-processes" }),
})
registerAuthRoutes(app, { authManager: deps.authManager })
app.addHook("preHandler", (request, reply, done) => {
const rawUrl = request.raw.url ?? request.url
const pathname = (rawUrl.split("?")[0] ?? "").trim()
const publicApiPaths = new Set(["/api/auth/login", "/api/auth/token", "/api/auth/status", "/api/auth/logout"])
const publicPagePaths = new Set(["/login"])
if (deps.authManager.isTokenBootstrapEnabled()) {
publicPagePaths.add("/auth/token")
}
if (publicApiPaths.has(pathname) || publicPagePaths.has(pathname)) {
done()
return
}
const session = deps.authManager.getSessionFromRequest(request)
const requiresAuthForApi = pathname.startsWith("/api/") || pathname.startsWith("/workspaces/")
if (requiresAuthForApi && !session) {
// Allow OpenCode plugin -> CodeNomad calls with per-instance basic auth.
const pluginMatch = pathname.match(/^\/workspaces\/([^/]+)\/plugin(?:\/|$)/)
if (pluginMatch) {
const workspaceId = pluginMatch[1]
const expected = deps.workspaceManager.getInstanceAuthorizationHeader(workspaceId)
const provided = Array.isArray(request.headers.authorization)
? request.headers.authorization[0]
: request.headers.authorization
if (expected && provided && provided === expected) {
done()
return
}
}
sendUnauthorized(request, reply)
return
}
if (!session && wantsHtml(request)) {
reply.redirect("/login")
return
}
done()
})
app.get("/", async (request, reply) => {
const session = deps.authManager.getSessionFromRequest(request)
if (!session) {
reply.redirect("/login")
return
}
if (deps.uiDevServerUrl) {
await proxyToDevServer(request, reply, deps.uiDevServerUrl)
return
}
const uiDir = deps.uiStaticDir
const indexPath = path.join(uiDir, "index.html")
if (uiDir && fs.existsSync(indexPath)) {
reply.type("text/html").send(fs.readFileSync(indexPath, "utf-8"))
return
}
reply.code(404).send({ message: "UI bundle missing" })
})
registerWorkspaceRoutes(app, { workspaceManager: deps.workspaceManager })
registerConfigRoutes(app, { configStore: deps.configStore, binaryRegistry: deps.binaryRegistry })
registerFilesystemRoutes(app, { fileSystemBrowser: deps.fileSystemBrowser })
registerMetaRoutes(app, { serverMeta: deps.serverMeta })
registerEventRoutes(app, { eventBus: deps.eventBus, registerClient: registerSseClient, logger: sseLogger })
registerWorktreeRoutes(app, { workspaceManager: deps.workspaceManager })
registerStorageRoutes(app, {
instanceStore: deps.instanceStore,
eventBus: deps.eventBus,
workspaceManager: deps.workspaceManager,
})
registerPluginRoutes(app, { workspaceManager: deps.workspaceManager, eventBus: deps.eventBus, logger: proxyLogger })
registerBackgroundProcessRoutes(app, { backgroundProcessManager })
registerInstanceProxyRoutes(app, { workspaceManager: deps.workspaceManager, logger: proxyLogger })
if (deps.uiDevServerUrl) {
setupDevProxy(app, deps.uiDevServerUrl)
setupDevProxy(app, deps.uiDevServerUrl, deps.authManager)
} else {
setupStaticUi(app, deps.uiStaticDir)
setupStaticUi(app, deps.uiStaticDir, deps.authManager)
}
return {
instance: app,
start: async (): Promise<HttpServerStartResult> => {
const attemptListen = async (requestedPort: number) => {
const addressInfo = await app.listen({ port: requestedPort, host: deps.host })
const addressInfo = await app.listen({ port: requestedPort, host: deps.bindHost })
return { addressInfo, requestedPort }
}
const autoPortRequested = deps.port === 0
const primaryPort = autoPortRequested ? DEFAULT_HTTP_PORT : deps.port
const autoPortRequested = deps.bindPort === 0
const primaryPort = autoPortRequested ? deps.defaultPort : deps.bindPort
const shouldRetryWithEphemeral = (error: unknown) => {
if (!autoPortRequested) return false
@@ -164,15 +310,10 @@ export function createHttpServer(deps: HttpServerDeps) {
}
}
const displayHost = deps.host === "0.0.0.0" ? "127.0.0.1" : deps.host === "127.0.0.1" ? "localhost" : deps.host
const serverUrl = `http://${displayHost}:${actualPort}`
const displayHost = deps.bindHost === "127.0.0.1" ? "localhost" : deps.bindHost
const serverUrl = `${deps.protocol}://${displayHost}:${actualPort}`
deps.serverMeta.httpBaseUrl = serverUrl
deps.serverMeta.host = deps.host
deps.serverMeta.port = actualPort
deps.serverMeta.listeningMode = deps.host === "0.0.0.0" ? "all" : "local"
deps.logger.info({ port: actualPort, host: deps.host }, "HTTP server listening")
console.log(`CodeNomad Server is ready at ${serverUrl}`)
deps.logger.info({ port: actualPort, host: deps.bindHost, protocol: deps.protocol }, "HTTP server listening")
return { port: actualPort, url: serverUrl, displayHost }
},
@@ -193,31 +334,36 @@ function registerInstanceProxyRoutes(app: FastifyInstance, deps: InstanceProxyDe
instance.removeAllContentTypeParsers()
instance.addContentTypeParser("*", (req, body, done) => done(null, body))
const proxyBaseHandler = async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => {
await proxyWorkspaceRequest({
request,
reply,
workspaceManager: deps.workspaceManager,
pathSuffix: "",
logger: deps.logger,
})
}
const proxyWildcardHandler = async (
request: FastifyRequest<{ Params: { id: string; "*": string } }>,
const proxyBaseHandler = async (
request: FastifyRequest<{ Params: { id: string; slug: string } }>,
reply: FastifyReply,
) => {
await proxyWorkspaceRequest({
request,
reply,
workspaceManager: deps.workspaceManager,
worktreeSlug: request.params.slug,
pathSuffix: "",
logger: deps.logger,
})
}
const proxyWildcardHandler = async (
request: FastifyRequest<{ Params: { id: string; slug: string; "*": string } }>,
reply: FastifyReply,
) => {
await proxyWorkspaceRequest({
request,
reply,
workspaceManager: deps.workspaceManager,
worktreeSlug: request.params.slug,
pathSuffix: request.params["*"] ?? "",
logger: deps.logger,
})
}
instance.all("/workspaces/:id/instance", proxyBaseHandler)
instance.all("/workspaces/:id/instance/*", proxyWildcardHandler)
instance.all("/workspaces/:id/worktrees/:slug/instance", proxyBaseHandler)
instance.all("/workspaces/:id/worktrees/:slug/instance/*", proxyWildcardHandler)
})
}
@@ -228,12 +374,75 @@ async function proxyWorkspaceRequest(args: {
reply: FastifyReply
workspaceManager: WorkspaceManager
logger: Logger
worktreeSlug: string
pathSuffix?: string
}) {
const { request, reply, workspaceManager, logger } = args
const { request, reply, workspaceManager, logger, worktreeSlug } = args
const workspaceId = (request.params as { id: string }).id
const workspace = workspaceManager.get(workspaceId)
const bodyToJson = (body: unknown): unknown => {
if (body == null) return null
const anyBody = body as any
if (anyBody && typeof anyBody.pipe === "function") {
// Don't consume streams (would break proxying).
// Best-effort: if the stream already has buffered chunks, parse those.
try {
const buffered = anyBody?._readableState?.buffer
if (Array.isArray(buffered) && buffered.length > 0) {
const chunks: Buffer[] = []
for (const entry of buffered) {
if (!entry) continue
if (Buffer.isBuffer(entry)) {
chunks.push(entry)
continue
}
const data = (entry as any).data
if (Buffer.isBuffer(data)) {
chunks.push(data)
}
}
if (chunks.length > 0) {
const text = Buffer.concat(chunks).toString("utf-8")
try {
return JSON.parse(text)
} catch {
return { __raw: text }
}
}
}
} catch {
// fall through
}
return { __stream: true }
}
const maybeParse = (input: string): unknown => {
try {
return JSON.parse(input)
} catch {
return { __raw: input }
}
}
if (Buffer.isBuffer(body)) {
return maybeParse(body.toString("utf-8"))
}
if (typeof body === "string") {
return maybeParse(body)
}
if (typeof body === "object") {
return body
}
return body
}
if (!workspace) {
reply.code(404).send({ error: "Workspace not found" })
return
@@ -245,10 +454,28 @@ async function proxyWorkspaceRequest(args: {
return
}
if (!isValidWorktreeSlug(worktreeSlug)) {
reply.code(400).send({ error: "Invalid worktree slug" })
return
}
const directory = await resolveWorktreeDirectory({
workspaceId,
workspacePath: workspace.path,
worktreeSlug,
logger,
})
if (!directory) {
reply.code(404).send({ error: "Worktree not found" })
return
}
const normalizedSuffix = normalizeInstanceSuffix(args.pathSuffix)
const queryIndex = (request.raw.url ?? "").indexOf("?")
const search = queryIndex >= 0 ? (request.raw.url ?? "").slice(queryIndex) : ""
const targetUrl = `http://${INSTANCE_PROXY_HOST}:${port}${normalizedSuffix}${search}`
const instanceAuthHeader = workspaceManager.getInstanceAuthorizationHeader(workspaceId)
logger.debug({ workspaceId, method: request.method, targetUrl }, "Proxying request to instance")
if (logger.isLevelEnabled("trace")) {
@@ -256,6 +483,49 @@ async function proxyWorkspaceRequest(args: {
}
return reply.from(targetUrl, {
rewriteRequestHeaders: (_originalRequest, headers) => {
if (instanceAuthHeader) {
headers.authorization = instanceAuthHeader
}
// OpenCode expects the *full* path; we send it via header to avoid query tampering.
const isNonASCII = /[^\x00-\x7F]/.test(directory)
const encodedDirectory = isNonASCII ? encodeURIComponent(directory) : directory
// Overwrite any client-provided value (case-insensitive headers are normalized by Node).
;(headers as Record<string, unknown>)["x-opencode-directory"] = encodedDirectory
if (logger.isLevelEnabled("trace")) {
const outgoing: Record<string, unknown> = {}
for (const [key, value] of Object.entries(headers as Record<string, unknown>)) {
outgoing[key] = value
}
// Redact sensitive headers.
for (const key of Object.keys(outgoing)) {
const lower = key.toLowerCase()
if (lower === "authorization" || lower === "cookie" || lower === "set-cookie") {
outgoing[key] = "<redacted>"
}
}
logger.trace(
{
workspaceId,
method: request.method,
targetUrl,
worktreeSlug,
directory,
contentType: request.headers["content-type"],
body: bodyToJson(request.body),
headers: outgoing,
},
"Proxy -> OpenCode request",
)
}
return headers
},
onError: (proxyReply, { error }) => {
logger.error({ err: error, workspaceId, targetUrl }, "Failed to proxy workspace request")
if (!proxyReply.sent) {
@@ -273,7 +543,53 @@ function normalizeInstanceSuffix(pathSuffix: string | undefined) {
return trimmed.length === 0 ? "/" : `/${trimmed}`
}
function setupStaticUi(app: FastifyInstance, uiDir: string) {
type WorktreeCacheEntry = {
expiresAt: number
repoRoot: string
worktrees: Array<{ slug: string; directory: string }>
}
const WORKTREE_CACHE_TTL_MS = 2000
const worktreeCache = new Map<string, WorktreeCacheEntry>()
async function getCachedWorktrees(params: { workspaceId: string; workspacePath: string; logger: Logger }) {
const cached = worktreeCache.get(params.workspaceId)
const now = Date.now()
if (cached && cached.expiresAt > now) {
return cached
}
const { repoRoot } = await resolveRepoRoot(params.workspacePath, params.logger)
const worktrees = await listWorktrees({ repoRoot, workspaceFolder: params.workspacePath, logger: params.logger })
const entry: WorktreeCacheEntry = {
expiresAt: now + WORKTREE_CACHE_TTL_MS,
repoRoot,
worktrees: worktrees.map((wt) => ({ slug: wt.slug, directory: wt.directory })),
}
worktreeCache.set(params.workspaceId, entry)
return entry
}
async function resolveWorktreeDirectory(params: {
workspaceId: string
workspacePath: string
worktreeSlug: string
logger: Logger
}): Promise<string | null> {
const { worktreeSlug } = params
const cached = await getCachedWorktrees({ workspaceId: params.workspaceId, workspacePath: params.workspacePath, logger: params.logger })
const match = cached.worktrees.find((wt) => wt.slug === worktreeSlug)
if (match) {
return match.directory
}
// If the slug is new (e.g., created moments ago), refresh once.
worktreeCache.delete(params.workspaceId)
const refreshed = await getCachedWorktrees({ workspaceId: params.workspaceId, workspacePath: params.workspacePath, logger: params.logger })
return refreshed.worktrees.find((wt) => wt.slug === worktreeSlug)?.directory ?? null
}
function setupStaticUi(app: FastifyInstance, uiDir: string, authManager: AuthManager) {
if (!uiDir) {
app.log.warn("UI static directory not provided; API endpoints only")
return
@@ -299,6 +615,12 @@ function setupStaticUi(app: FastifyInstance, uiDir: string) {
return
}
const session = authManager.getSessionFromRequest(request)
if (!session && wantsHtml(request)) {
reply.redirect("/login")
return
}
if (fs.existsSync(indexPath)) {
reply.type("text/html").send(fs.readFileSync(indexPath, "utf-8"))
} else {
@@ -307,7 +629,7 @@ function setupStaticUi(app: FastifyInstance, uiDir: string) {
})
}
function setupDevProxy(app: FastifyInstance, upstreamBase: string) {
function setupDevProxy(app: FastifyInstance, upstreamBase: string, authManager: AuthManager) {
app.log.info({ upstreamBase }, "Proxying UI requests to development server")
app.setNotFoundHandler((request: FastifyRequest, reply: FastifyReply) => {
const url = request.raw.url ?? ""
@@ -315,6 +637,13 @@ function setupDevProxy(app: FastifyInstance, upstreamBase: string) {
reply.code(404).send({ message: "Not Found" })
return
}
const session = authManager.getSessionFromRequest(request)
if (!session && wantsHtml(request)) {
reply.redirect("/login")
return
}
void proxyToDevServer(request, reply, upstreamBase)
})
}

View File

@@ -0,0 +1,75 @@
import os from "os"
import type { NetworkAddress } from "../api-types"
export function resolveNetworkAddresses(args: {
host: string
protocol: "http" | "https"
port: number
}): NetworkAddress[] {
const { host, protocol, port } = args
const interfaces = os.networkInterfaces()
const seen = new Set<string>()
const results: NetworkAddress[] = []
const addAddress = (ip: string, scope: NetworkAddress["scope"]) => {
if (!ip || ip === "0.0.0.0") return
const key = `ipv4-${ip}`
if (seen.has(key)) return
seen.add(key)
results.push({ ip, family: "ipv4", scope, remoteUrl: `${protocol}://${ip}:${port}` })
}
const normalizeFamily = (value: string | number) => {
if (typeof value === "string") {
const lowered = value.toLowerCase()
if (lowered === "ipv4") {
return "ipv4" as const
}
}
if (value === 4) return "ipv4" as const
return null
}
if (host === "0.0.0.0") {
// Enumerate system interfaces (IPv4 only)
for (const entries of Object.values(interfaces)) {
if (!entries) continue
for (const entry of entries) {
const family = normalizeFamily(entry.family)
if (!family) continue
if (!entry.address || entry.address === "0.0.0.0") continue
const scope: NetworkAddress["scope"] = entry.internal ? "loopback" : "external"
addAddress(entry.address, scope)
}
}
}
// Always include loopback address
addAddress("127.0.0.1", "loopback")
// Include explicitly configured host if it was IPv4
if (isIPv4Address(host) && host !== "0.0.0.0") {
const isLoopback = host.startsWith("127.")
addAddress(host, isLoopback ? "loopback" : "external")
}
const scopeWeight: Record<NetworkAddress["scope"], number> = { external: 0, internal: 1, loopback: 2 }
return results.sort((a, b) => {
const scopeDelta = scopeWeight[a.scope] - scopeWeight[b.scope]
if (scopeDelta !== 0) return scopeDelta
return a.ip.localeCompare(b.ip)
})
}
function isIPv4Address(value: string | undefined): value is string {
if (!value) return false
const parts = value.split(".")
if (parts.length !== 4) return false
return parts.every((part) => {
if (part.length === 0 || part.length > 3) return false
if (!/^[0-9]+$/.test(part)) return false
const num = Number(part)
return Number.isInteger(num) && num >= 0 && num <= 255
})
}

View File

@@ -0,0 +1,134 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>CodeNomad Login</title>
<style>
body {
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial;
background: #0b0b0f;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
margin: 0;
}
.card {
width: 420px;
max-width: calc(100vw - 32px);
background: #14141c;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 14px;
padding: 24px;
}
h1 {
font-size: 18px;
margin: 0 0 12px;
}
p {
margin: 0 0 18px;
color: rgba(255, 255, 255, 0.7);
font-size: 13px;
line-height: 1.4;
}
label {
display: block;
font-size: 12px;
margin: 10px 0 6px;
color: rgba(255, 255, 255, 0.75);
}
input {
width: 100%;
box-sizing: border-box;
padding: 10px 12px;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: #0f0f16;
color: #fff;
}
button {
width: 100%;
margin-top: 14px;
padding: 10px 12px;
border-radius: 10px;
border: 0;
background: #4c6fff;
color: #fff;
font-weight: 600;
cursor: pointer;
}
.error {
margin-top: 12px;
color: #ff6b6b;
font-size: 13px;
}
</style>
</head>
<body>
<div class="card">
<h1>Sign in</h1>
<p>This CodeNomad server is protected. Enter your credentials to continue.</p>
<label for="username">Username</label>
<input id="username" autocomplete="username" placeholder="{{DEFAULT_USERNAME}}" value="" />
<label for="password">Password</label>
<input id="password" type="password" autocomplete="current-password" value="" />
<button id="submit" type="button">Continue</button>
<div id="error" class="error" style="display: none"></div>
</div>
<script>
const $ = (id) => document.getElementById(id)
const errorEl = $("error")
const showError = (msg) => {
errorEl.textContent = msg
errorEl.style.display = "block"
}
const hideError = () => {
errorEl.textContent = ""
errorEl.style.display = "none"
}
async function submit() {
hideError()
const username = $("username").value.trim()
const password = $("password").value
if (!username || !password) {
showError("Username and password are required.")
return
}
try {
const res = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
credentials: "include",
})
if (!res.ok) {
let message = ""
try {
const json = await res.json()
message = json && json.error ? String(json.error) : ""
} catch {
message = ""
}
showError(message || `Login failed (${res.status})`)
return
}
window.location.href = "/"
} catch (e) {
showError(e && e.message ? e.message : String(e))
}
}
$("submit").addEventListener("click", submit)
$("password").addEventListener("keydown", (e) => {
if (e.key === "Enter") submit()
})
</script>
</body>
</html>

View File

@@ -0,0 +1,93 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>CodeNomad</title>
<style>
body {
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial;
background: #0b0b0f;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
margin: 0;
}
.card {
width: 420px;
max-width: calc(100vw - 32px);
background: #14141c;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 14px;
padding: 24px;
}
h1 {
font-size: 18px;
margin: 0 0 12px;
}
p {
margin: 0;
color: rgba(255, 255, 255, 0.7);
font-size: 13px;
line-height: 1.4;
}
.error {
margin-top: 12px;
color: #ff6b6b;
font-size: 13px;
}
</style>
</head>
<body>
<div class="card">
<h1>Connecting…</h1>
<p>Finalizing local authentication.</p>
<div id="error" class="error" style="display: none"></div>
</div>
<script>
const token = (location.hash || "").replace(/^#/, "").trim()
const errorEl = document.getElementById("error")
const showError = (msg) => {
errorEl.textContent = msg
errorEl.style.display = "block"
}
async function run() {
if (!token) {
showError("Missing bootstrap token.")
return
}
try {
const res = await fetch("/api/auth/token", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token }),
credentials: "include",
})
if (!res.ok) {
let message = ""
try {
const json = await res.json()
message = json && json.error ? String(json.error) : ""
} catch {
message = ""
}
showError(message || `Token exchange failed (${res.status})`)
return
}
window.location.replace("/")
} catch (e) {
showError(e && e.message ? e.message : String(e))
}
}
run()
</script>
</body>
</html>

View File

@@ -0,0 +1,164 @@
import type { FastifyInstance } from "fastify"
import fs from "fs"
import { z } from "zod"
import type { AuthManager } from "../../auth/manager"
import { isLoopbackAddress } from "../../auth/http-auth"
interface RouteDeps {
authManager: AuthManager
}
const LoginSchema = z.object({
username: z.string().min(1),
password: z.string().min(1),
})
const TokenSchema = z.object({
token: z.string().min(1),
})
const PasswordSchema = z.object({
password: z.string().min(8),
})
const LOGIN_TEMPLATE_URL = new URL("./auth-pages/login.html", import.meta.url)
const TOKEN_TEMPLATE_URL = new URL("./auth-pages/token.html", import.meta.url)
let cachedLoginTemplate: string | null = null
let cachedTokenTemplate: string | null = null
function readTemplate(url: URL, cache: string | null): string {
if (cache) return cache
const content = fs.readFileSync(url, "utf-8")
return content
}
function getLoginHtml(defaultUsername: string): string {
if (!cachedLoginTemplate) {
cachedLoginTemplate = readTemplate(LOGIN_TEMPLATE_URL, null)
}
const escapedUsername = escapeHtml(defaultUsername)
return cachedLoginTemplate.replace(/\{\{DEFAULT_USERNAME\}\}/g, escapedUsername)
}
function getTokenHtml(): string {
if (!cachedTokenTemplate) {
cachedTokenTemplate = readTemplate(TOKEN_TEMPLATE_URL, null)
}
return cachedTokenTemplate
}
export function registerAuthRoutes(app: FastifyInstance, deps: RouteDeps) {
app.get("/login", async (_request, reply) => {
const status = deps.authManager.getStatus()
reply.type("text/html").send(getLoginHtml(status.username))
})
app.get("/auth/token", async (request, reply) => {
if (!deps.authManager.isTokenBootstrapEnabled()) {
reply.code(404).send({ error: "Not found" })
return
}
if (!isLoopbackAddress(request.socket.remoteAddress)) {
reply.code(404).send({ error: "Not found" })
return
}
reply.type("text/html").send(getTokenHtml())
})
app.get("/api/auth/status", async (request, reply) => {
const session = deps.authManager.getSessionFromRequest(request)
if (!session) {
reply.send({ authenticated: false })
return
}
reply.send({ authenticated: true, ...deps.authManager.getStatus() })
})
app.post("/api/auth/login", async (request, reply) => {
const body = LoginSchema.parse(request.body ?? {})
const ok = deps.authManager.validateLogin(body.username, body.password)
if (!ok) {
reply.code(401).send({ error: "Invalid credentials" })
return
}
const session = deps.authManager.createSession(body.username)
deps.authManager.setSessionCookieWithOptions(reply, session.id, { secure: isSecureRequest(request) })
reply.send({ ok: true })
})
app.post("/api/auth/token", async (request, reply) => {
if (!deps.authManager.isTokenBootstrapEnabled()) {
reply.code(404).send({ error: "Not found" })
return
}
if (!isLoopbackAddress(request.socket.remoteAddress)) {
reply.code(404).send({ error: "Not found" })
return
}
const body = TokenSchema.parse(request.body ?? {})
const ok = deps.authManager.consumeBootstrapToken(body.token)
if (!ok) {
reply.code(401).send({ error: "Invalid token" })
return
}
const username = deps.authManager.getStatus().username
const session = deps.authManager.createSession(username)
deps.authManager.setSessionCookieWithOptions(reply, session.id, { secure: isSecureRequest(request) })
reply.send({ ok: true })
})
app.post("/api/auth/logout", async (request, reply) => {
deps.authManager.clearSessionCookieWithOptions(reply, { secure: isSecureRequest(request) })
reply.send({ ok: true })
})
app.post("/api/auth/password", async (request, reply) => {
const session = deps.authManager.getSessionFromRequest(request)
if (!session) {
reply.code(401).send({ error: "Unauthorized" })
return
}
const body = PasswordSchema.parse(request.body ?? {})
try {
const status = deps.authManager.setPassword(body.password)
reply.send({ ok: true, ...status })
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
reply.code(409).type("text/plain").send(message)
}
})
}
function isSecureRequest(request: any) {
if (request.protocol === "https") {
return true
}
return Boolean(request.raw?.socket && request.raw.socket.encrypted)
}
function escapeHtml(value: string) {
return value.replace(/[&<>"]/g, (char) => {
switch (char) {
case "&":
return "&amp;"
case "<":
return "&lt;"
case ">":
return "&gt;"
case '"':
return "&quot;"
default:
return char
}
})
}

View File

@@ -0,0 +1,85 @@
import { FastifyInstance } from "fastify"
import { z } from "zod"
import type { BackgroundProcessManager } from "../../background-processes/manager"
interface RouteDeps {
backgroundProcessManager: BackgroundProcessManager
}
const StartSchema = z.object({
title: z.string().trim().min(1),
command: z.string().trim().min(1),
})
const OutputQuerySchema = z.object({
method: z.enum(["full", "tail", "head", "grep"]).optional(),
mode: z.enum(["full", "tail", "head", "grep"]).optional(),
pattern: z.string().optional(),
lines: z.coerce.number().int().positive().max(2000).optional(),
maxBytes: z.coerce.number().int().positive().optional(),
})
export function registerBackgroundProcessRoutes(app: FastifyInstance, deps: RouteDeps) {
app.get<{ Params: { id: string } }>("/workspaces/:id/plugin/background-processes", async (request) => {
const processes = await deps.backgroundProcessManager.list(request.params.id)
return { processes }
})
app.post<{ Params: { id: string } }>("/workspaces/:id/plugin/background-processes", async (request, reply) => {
const payload = StartSchema.parse(request.body ?? {})
const process = await deps.backgroundProcessManager.start(request.params.id, payload.title, payload.command)
reply.code(201)
return process
})
app.post<{ Params: { id: string; processId: string } }>(
"/workspaces/:id/plugin/background-processes/:processId/stop",
async (request, reply) => {
const process = await deps.backgroundProcessManager.stop(request.params.id, request.params.processId)
if (!process) {
reply.code(404)
return { error: "Process not found" }
}
return process
},
)
app.post<{ Params: { id: string; processId: string } }>(
"/workspaces/:id/plugin/background-processes/:processId/terminate",
async (request, reply) => {
await deps.backgroundProcessManager.terminate(request.params.id, request.params.processId)
reply.code(204)
return undefined
},
)
app.get<{ Params: { id: string; processId: string } }>(
"/workspaces/:id/plugin/background-processes/:processId/output",
async (request, reply) => {
const query = OutputQuerySchema.parse(request.query ?? {})
const method = query.method ?? query.mode
if (method === "grep" && !query.pattern) {
reply.code(400)
return { error: "Pattern is required for grep output" }
}
try {
return await deps.backgroundProcessManager.readOutput(request.params.id, request.params.processId, {
method,
pattern: query.pattern,
lines: query.lines,
maxBytes: query.maxBytes,
})
} catch (error) {
reply.code(400)
return { error: error instanceof Error ? error.message : "Invalid output request" }
}
},
)
app.get<{ Params: { id: string; processId: string } }>(
"/workspaces/:id/plugin/background-processes/:processId/stream",
async (request, reply) => {
await deps.backgroundProcessManager.streamOutput(request.params.id, request.params.processId, reply)
},
)
}

View File

@@ -11,6 +11,11 @@ const FilesystemQuerySchema = z.object({
includeFiles: z.coerce.boolean().optional(),
})
const FilesystemCreateFolderSchema = z.object({
parentPath: z.string().optional(),
name: z.string(),
})
export function registerFilesystemRoutes(app: FastifyInstance, deps: RouteDeps) {
app.get("/api/filesystem", async (request, reply) => {
const query = FilesystemQuerySchema.parse(request.query ?? {})
@@ -24,4 +29,26 @@ export function registerFilesystemRoutes(app: FastifyInstance, deps: RouteDeps)
return { error: (error as Error).message }
}
})
app.post("/api/filesystem/folders", async (request, reply) => {
const body = FilesystemCreateFolderSchema.parse(request.body ?? {})
try {
const created = deps.fileSystemBrowser.createFolder(body.parentPath, body.name)
reply.code(201)
return created
} catch (error) {
const err = error as NodeJS.ErrnoException
if (err?.code === "EEXIST") {
reply.code(409).type("text/plain").send("Folder already exists")
return
}
if (err?.code === "EACCES" || err?.code === "EPERM") {
reply.code(403).type("text/plain").send("Permission denied")
return
}
reply.code(400).type("text/plain").send((error as Error).message)
}
})
}

View File

@@ -1,6 +1,6 @@
import { FastifyInstance } from "fastify"
import os from "os"
import { NetworkAddress, ServerMeta } from "../../api-types"
import { ServerMeta } from "../../api-types"
import { resolveNetworkAddresses } from "../network-addresses"
interface RouteDeps {
serverMeta: ServerMeta
@@ -11,23 +11,25 @@ export function registerMetaRoutes(app: FastifyInstance, deps: RouteDeps) {
}
function buildMetaResponse(meta: ServerMeta): ServerMeta {
const port = resolvePort(meta)
const addresses = port > 0 ? resolveAddresses(port, meta.host) : []
const localPort = resolveLocalPort(meta)
const remote = resolveRemote(meta)
const addresses = remote && remote.port > 0 ? resolveNetworkAddresses({ host: meta.host, protocol: remote.protocol, port: remote.port }) : []
return {
...meta,
port,
listeningMode: meta.host === "0.0.0.0" ? "all" : "local",
localPort,
remotePort: remote?.port,
listeningMode: meta.host === "0.0.0.0" || !isLoopbackHost(meta.host) ? "all" : "local",
addresses,
}
}
function resolvePort(meta: ServerMeta): number {
if (Number.isInteger(meta.port) && meta.port > 0) {
return meta.port
function resolveLocalPort(meta: ServerMeta): number {
if (Number.isInteger(meta.localPort) && meta.localPort > 0) {
return meta.localPort
}
try {
const parsed = new URL(meta.httpBaseUrl)
const parsed = new URL(meta.localUrl)
const port = Number(parsed.port)
return Number.isInteger(port) && port > 0 ? port : 0
} catch {
@@ -35,70 +37,22 @@ function resolvePort(meta: ServerMeta): number {
}
}
function resolveAddresses(port: number, host: string): NetworkAddress[] {
const interfaces = os.networkInterfaces()
const seen = new Set<string>()
const results: NetworkAddress[] = []
const addAddress = (ip: string, scope: NetworkAddress["scope"]) => {
if (!ip || ip === "0.0.0.0") return
const key = `ipv4-${ip}`
if (seen.has(key)) return
seen.add(key)
results.push({ ip, family: "ipv4", scope, url: `http://${ip}:${port}` })
}
const normalizeFamily = (value: string | number) => {
if (typeof value === "string") {
const lowered = value.toLowerCase()
if (lowered === "ipv4") {
return "ipv4" as const
}
}
if (value === 4) return "ipv4" as const
function resolveRemote(meta: ServerMeta): { protocol: "http" | "https"; port: number } | null {
if (!meta.remoteUrl) {
return null
}
if (host === "0.0.0.0") {
// Enumerate system interfaces (IPv4 only)
for (const entries of Object.values(interfaces)) {
if (!entries) continue
for (const entry of entries) {
const family = normalizeFamily(entry.family)
if (!family) continue
if (!entry.address || entry.address === "0.0.0.0") continue
const scope: NetworkAddress["scope"] = entry.internal ? "loopback" : "external"
addAddress(entry.address, scope)
}
}
try {
const parsed = new URL(meta.remoteUrl)
const protocol = parsed.protocol === "https:" ? "https" : "http"
const port = Number(parsed.port)
return { protocol, port: Number.isInteger(port) && port > 0 ? port : 0 }
} catch {
return null
}
// Always include loopback address
addAddress("127.0.0.1", "loopback")
// Include explicitly configured host if it was IPv4
if (isIPv4Address(host) && host !== "0.0.0.0") {
const isLoopback = host.startsWith("127.")
addAddress(host, isLoopback ? "loopback" : "external")
}
const scopeWeight: Record<NetworkAddress["scope"], number> = { external: 0, internal: 1, loopback: 2 }
return results.sort((a, b) => {
const scopeDelta = scopeWeight[a.scope] - scopeWeight[b.scope]
if (scopeDelta !== 0) return scopeDelta
return a.ip.localeCompare(b.ip)
})
}
function isIPv4Address(value: string | undefined): value is string {
if (!value) return false
const parts = value.split(".")
if (parts.length !== 4) return false
return parts.every((part) => {
if (part.length === 0 || part.length > 3) return false
if (!/^[0-9]+$/.test(part)) return false
const num = Number(part)
return Number.isInteger(num) && num >= 0 && num <= 255
})
function isLoopbackHost(host: string): boolean {
return host === "127.0.0.1" || host === "::1" || host.startsWith("127.")
}
// NetworkAddress shape is resolved in ../network-addresses

View File

@@ -0,0 +1,75 @@
import { FastifyInstance } from "fastify"
import { z } from "zod"
import type { WorkspaceManager } from "../../workspaces/manager"
import type { EventBus } from "../../events/bus"
import type { Logger } from "../../logger"
import { PluginChannelManager } from "../../plugins/channel"
import { buildPingEvent, handlePluginEvent } from "../../plugins/handlers"
interface RouteDeps {
workspaceManager: WorkspaceManager
eventBus: EventBus
logger: Logger
}
const PluginEventSchema = z.object({
type: z.string().min(1),
properties: z.record(z.unknown()).optional(),
})
export function registerPluginRoutes(app: FastifyInstance, deps: RouteDeps) {
const channel = new PluginChannelManager(deps.logger.child({ component: "plugin-channel" }))
app.get<{ Params: { id: string } }>("/workspaces/:id/plugin/events", (request, reply) => {
const workspace = deps.workspaceManager.get(request.params.id)
if (!workspace) {
reply.code(404).send({ error: "Workspace not found" })
return
}
reply.raw.setHeader("Content-Type", "text/event-stream")
reply.raw.setHeader("Cache-Control", "no-cache")
reply.raw.setHeader("Connection", "keep-alive")
reply.raw.flushHeaders?.()
reply.hijack()
const registration = channel.register(request.params.id, reply)
const heartbeat = setInterval(() => {
channel.send(request.params.id, buildPingEvent())
}, 15000)
const close = () => {
clearInterval(heartbeat)
registration.close()
reply.raw.end?.()
}
request.raw.on("close", close)
request.raw.on("error", close)
})
const handleWildcard = async (request: any, reply: any) => {
const workspaceId = request.params.id as string
const workspace = deps.workspaceManager.get(workspaceId)
if (!workspace) {
reply.code(404).send({ error: "Workspace not found" })
return
}
const suffix = (request.params["*"] as string | undefined) ?? ""
const normalized = suffix.replace(/^\/+/, "")
if (normalized === "event" && request.method === "POST") {
const parsed = PluginEventSchema.parse(request.body ?? {})
handlePluginEvent(workspaceId, parsed, { workspaceManager: deps.workspaceManager, eventBus: deps.eventBus, logger: deps.logger })
reply.code(204).send()
return
}
reply.code(404).send({ error: "Unknown plugin endpoint" })
}
app.all("/workspaces/:id/plugin/*", handleWildcard)
app.all("/workspaces/:id/plugin", handleWildcard)
}

View File

@@ -35,10 +35,16 @@ export function registerWorkspaceRoutes(app: FastifyInstance, deps: RouteDeps) {
})
app.post("/api/workspaces", async (request, reply) => {
const body = WorkspaceCreateSchema.parse(request.body ?? {})
const workspace = await deps.workspaceManager.create(body.path, body.name)
reply.code(201)
return workspace
try {
const body = WorkspaceCreateSchema.parse(request.body ?? {})
const workspace = await deps.workspaceManager.create(body.path, body.name)
reply.code(201)
return workspace
} catch (error) {
request.log.error({ err: error }, "Failed to create workspace")
const message = error instanceof Error ? error.message : "Failed to create workspace"
reply.code(400).type("text/plain").send(message)
}
})
app.get<{ Params: { id: string } }>("/api/workspaces/:id", async (request, reply) => {

View File

@@ -0,0 +1,195 @@
import type { FastifyInstance, FastifyReply } from "fastify"
import { z } from "zod"
import { WorkspaceManager } from "../../workspaces/manager"
import {
resolveRepoRoot,
listWorktrees,
isValidWorktreeSlug,
createManagedWorktree,
removeWorktree,
} from "../../workspaces/git-worktrees"
import type { WorktreeListResponse, WorktreeMap } from "../../api-types"
import { ensureCodenomadGitExclude, readWorktreeMap, writeWorktreeMap } from "../../workspaces/worktree-map"
interface RouteDeps {
workspaceManager: WorkspaceManager
}
const WorktreeMapSchema = z.object({
version: z.literal(1),
defaultWorktreeSlug: z.string().min(1).default("root"),
parentSessionWorktreeSlug: z.record(z.string(), z.string()).default({}),
})
const WorktreeCreateSchema = z.object({
slug: z.string().trim().min(1),
branch: z.string().trim().min(1).optional(),
})
export function registerWorktreeRoutes(app: FastifyInstance, deps: RouteDeps) {
app.get<{ Params: { id: string } }>("/api/workspaces/:id/worktrees", async (request, reply) => {
const workspace = deps.workspaceManager.get(request.params.id)
if (!workspace) {
reply.code(404)
return { error: "Workspace not found" }
}
const { repoRoot, isGitRepo } = await resolveRepoRoot(workspace.path, request.log)
const worktrees = await listWorktrees({ repoRoot, workspaceFolder: workspace.path, logger: request.log })
const response: WorktreeListResponse = { worktrees, isGitRepo }
return response
})
app.post<{ Params: { id: string } }>("/api/workspaces/:id/worktrees", async (request, reply) => {
const workspace = deps.workspaceManager.get(request.params.id)
if (!workspace) {
reply.code(404)
return { error: "Workspace not found" }
}
try {
const body = WorktreeCreateSchema.parse(request.body ?? {})
const slug = body.slug
if (!isValidWorktreeSlug(slug) || slug === "root") {
reply.code(400)
return { error: "Invalid worktree slug" }
}
if (body.branch) {
if (!isValidWorktreeSlug(body.branch) || body.branch === "root") {
reply.code(400)
return { error: "Invalid worktree branch" }
}
if (body.branch !== slug) {
reply.code(400)
return { error: "Branch must match slug" }
}
}
const { repoRoot, isGitRepo } = await resolveRepoRoot(workspace.path, request.log)
if (!isGitRepo) {
reply.code(400)
return { error: "Workspace is not a Git repository" }
}
await ensureCodenomadGitExclude(workspace.path, request.log).catch(() => undefined)
const created = await createManagedWorktree({
repoRoot,
workspaceFolder: workspace.path,
slug,
logger: request.log,
})
reply.code(201)
return created
} catch (error) {
return handleError(error, reply)
}
})
app.delete<{ Params: { id: string; slug: string }; Querystring: { force?: string } }>(
"/api/workspaces/:id/worktrees/:slug",
async (request, reply) => {
const workspace = deps.workspaceManager.get(request.params.id)
if (!workspace) {
reply.code(404)
return { error: "Workspace not found" }
}
const slug = (request.params.slug ?? "").trim()
if (!isValidWorktreeSlug(slug) || slug === "root") {
reply.code(400)
return { error: "Invalid worktree slug" }
}
const { repoRoot, isGitRepo } = await resolveRepoRoot(workspace.path, request.log)
if (!isGitRepo) {
reply.code(400)
return { error: "Workspace is not a Git repository" }
}
const force = (request.query?.force ?? "").toString().toLowerCase() === "true"
try {
const worktrees = await listWorktrees({ repoRoot, workspaceFolder: workspace.path, logger: request.log })
const match = worktrees.find((wt) => wt.slug === slug)
if (!match || match.kind === "root") {
reply.code(404)
return { error: "Worktree not found" }
}
await removeWorktree({ workspaceFolder: workspace.path, directory: match.directory, force, logger: request.log })
// Best-effort: prune any mappings that point at the deleted worktree.
const current = await readWorktreeMap(workspace.path, request.log)
let changed = false
const nextMapping: Record<string, string> = { ...(current.parentSessionWorktreeSlug ?? {}) }
for (const [sessionId, mapped] of Object.entries(nextMapping)) {
if (mapped === slug) {
delete nextMapping[sessionId]
changed = true
}
}
const nextDefault = current.defaultWorktreeSlug === slug ? "root" : current.defaultWorktreeSlug
if (nextDefault !== current.defaultWorktreeSlug) {
changed = true
}
if (changed) {
await writeWorktreeMap(
workspace.path,
{
version: 1,
defaultWorktreeSlug: nextDefault,
parentSessionWorktreeSlug: nextMapping,
},
request.log,
)
}
reply.code(204)
} catch (error) {
return handleError(error, reply)
}
},
)
app.get<{ Params: { id: string } }>("/api/workspaces/:id/worktrees/map", async (request, reply) => {
const workspace = deps.workspaceManager.get(request.params.id)
if (!workspace) {
reply.code(404)
return { error: "Workspace not found" }
}
return await readWorktreeMap(workspace.path, request.log)
})
app.put<{ Params: { id: string } }>("/api/workspaces/:id/worktrees/map", async (request, reply) => {
const workspace = deps.workspaceManager.get(request.params.id)
if (!workspace) {
reply.code(404)
return { error: "Workspace not found" }
}
try {
const parsed = WorktreeMapSchema.parse(request.body ?? {}) as WorktreeMap
if (!isValidWorktreeSlug(parsed.defaultWorktreeSlug)) {
reply.code(400)
return { error: "Invalid defaultWorktreeSlug" }
}
for (const slug of Object.values(parsed.parentSessionWorktreeSlug ?? {})) {
if (!isValidWorktreeSlug(slug)) {
reply.code(400)
return { error: "Invalid worktree slug in mapping" }
}
}
await writeWorktreeMap(workspace.path, parsed, request.log)
reply.code(204)
} catch (error) {
return handleError(error, reply)
}
})
}
function handleError(error: unknown, reply: FastifyReply) {
reply.code(400)
return { error: error instanceof Error ? error.message : "Unable to fulfill request" }
}

View File

@@ -0,0 +1,283 @@
import crypto from "crypto"
import fs from "fs"
import path from "path"
import { createRequire } from "module"
import type { Logger } from "../logger"
const require = createRequire(import.meta.url)
type Forge = typeof import("node-forge")
function loadForge(): Forge {
// node-forge is CJS in many installs; require keeps this compatible with our ESM output.
return require("node-forge") as Forge
}
export interface ResolvedHttpsOptions {
httpsOptions: { key: string | Buffer; cert: string | Buffer; ca?: string | Buffer }
/** Path to CA certificate suitable for NODE_EXTRA_CA_CERTS. */
caCertPath?: string
mode: "provided" | "generated"
}
export interface ResolveHttpsOptionsArgs {
enabled: boolean
configDir: string
host: string
tlsKeyPath?: string
tlsCertPath?: string
tlsCaPath?: string
tlsSANs?: string
logger: Logger
}
const LEAF_VALIDITY_DAYS = 30
const ROTATE_IF_EXPIRES_WITHIN_DAYS = 3
const CA_VALIDITY_DAYS = 365
export function resolveHttpsOptions(args: ResolveHttpsOptionsArgs): ResolvedHttpsOptions | null {
if (!args.enabled) {
return null
}
const hasProvided = Boolean(args.tlsKeyPath && args.tlsCertPath)
if (hasProvided) {
const key = fs.readFileSync(args.tlsKeyPath!, "utf-8")
const cert = fs.readFileSync(args.tlsCertPath!, "utf-8")
const ca = args.tlsCaPath ? fs.readFileSync(args.tlsCaPath, "utf-8") : undefined
return {
httpsOptions: { key, cert, ca },
caCertPath: args.tlsCaPath,
mode: "provided",
}
}
return ensureGeneratedTls(args)
}
function ensureGeneratedTls(args: ResolveHttpsOptionsArgs): ResolvedHttpsOptions {
const tlsDir = path.join(args.configDir, "tls")
const caKeyPath = path.join(tlsDir, "ca-key.pem")
const caCertPath = path.join(tlsDir, "ca-cert.pem")
const keyPath = path.join(tlsDir, "server-key.pem")
const certPath = path.join(tlsDir, "server-cert.pem")
fs.mkdirSync(tlsDir, { recursive: true })
const shouldRotateLeaf = () => {
try {
if (!fs.existsSync(certPath)) return true
const pem = fs.readFileSync(certPath, "utf-8")
const x509 = new crypto.X509Certificate(pem)
const validToMs = Date.parse(x509.validTo)
if (!Number.isFinite(validToMs)) return true
const rotateAt = validToMs - ROTATE_IF_EXPIRES_WITHIN_DAYS * 24 * 60 * 60 * 1000
return Date.now() >= rotateAt
} catch {
return true
}
}
const shouldRotateCa = () => {
try {
if (!fs.existsSync(caCertPath)) return true
const pem = fs.readFileSync(caCertPath, "utf-8")
const x509 = new crypto.X509Certificate(pem)
const validToMs = Date.parse(x509.validTo)
if (!Number.isFinite(validToMs)) return true
// CA rotates only when expired.
return Date.now() >= validToMs
} catch {
return true
}
}
if (shouldRotateCa() || !fs.existsSync(caKeyPath)) {
const { caKeyPem, caCertPem } = generateCaCertificate()
writePemFile(caKeyPath, caKeyPem, 0o600)
writePemFile(caCertPath, caCertPem, 0o644)
args.logger.info({ caCertPath }, "Generated self-signed CodeNomad CA certificate")
}
if (shouldRotateLeaf() || !fs.existsSync(keyPath)) {
const caKeyPem = fs.readFileSync(caKeyPath, "utf-8")
const caCertPem = fs.readFileSync(caCertPath, "utf-8")
const { keyPem, certPem } = generateServerCertificate({
host: args.host,
tlsSANs: args.tlsSANs,
caKeyPem,
caCertPem,
})
writePemFile(keyPath, keyPem, 0o600)
writePemFile(certPath, certPem, 0o644)
args.logger.info({ certPath }, "Generated CodeNomad HTTPS certificate")
}
const key = fs.readFileSync(keyPath, "utf-8")
const cert = fs.readFileSync(certPath, "utf-8")
const ca = fs.readFileSync(caCertPath, "utf-8")
// Present the CA as part of the chain.
const chainedCert = `${cert.trim()}\n${ca.trim()}\n`
return {
httpsOptions: {
key,
cert: chainedCert,
},
caCertPath,
mode: "generated",
}
}
function writePemFile(filePath: string, content: string, mode: number) {
fs.writeFileSync(filePath, content, { encoding: "utf-8", mode })
try {
fs.chmodSync(filePath, mode)
} catch {
// best effort on platforms that ignore chmod
}
}
function generateCaCertificate(): { caKeyPem: string; caCertPem: string } {
const forge = loadForge()
const keys = forge.pki.rsa.generateKeyPair(2048)
const cert = forge.pki.createCertificate()
cert.publicKey = keys.publicKey
cert.serialNumber = crypto.randomBytes(16).toString("hex")
const now = new Date()
const notBefore = new Date(now.getTime() - 60_000)
const notAfter = new Date(now.getTime() + CA_VALIDITY_DAYS * 24 * 60 * 60 * 1000)
cert.validity.notBefore = notBefore
cert.validity.notAfter = notAfter
const attrs = [{ name: "commonName", value: "CodeNomad Local CA" }]
cert.setSubject(attrs)
cert.setIssuer(attrs)
cert.setExtensions([
{ name: "basicConstraints", cA: true },
{ name: "keyUsage", keyCertSign: true, cRLSign: true, digitalSignature: true },
{ name: "subjectKeyIdentifier" },
])
cert.sign(keys.privateKey, forge.md.sha256.create())
return {
caKeyPem: forge.pki.privateKeyToPem(keys.privateKey),
caCertPem: forge.pki.certificateToPem(cert),
}
}
function generateServerCertificate(args: {
host: string
tlsSANs?: string
caKeyPem: string
caCertPem: string
}): { keyPem: string; certPem: string } {
const forge = loadForge()
const caKey = forge.pki.privateKeyFromPem(args.caKeyPem)
const caCert = forge.pki.certificateFromPem(args.caCertPem)
const keys = forge.pki.rsa.generateKeyPair(2048)
const cert = forge.pki.createCertificate()
cert.publicKey = keys.publicKey
cert.serialNumber = crypto.randomBytes(16).toString("hex")
const now = new Date()
const notBefore = new Date(now.getTime() - 60_000)
const notAfter = new Date(now.getTime() + LEAF_VALIDITY_DAYS * 24 * 60 * 60 * 1000)
cert.validity.notBefore = notBefore
cert.validity.notAfter = notAfter
const commonName = pickCommonName(args.host)
cert.setSubject([{ name: "commonName", value: commonName }])
cert.setIssuer(caCert.subject.attributes)
const san = buildSubjectAltNames(args.host, args.tlsSANs)
cert.setExtensions([
{ name: "basicConstraints", cA: false },
{ name: "keyUsage", digitalSignature: true, keyEncipherment: true },
{ name: "extKeyUsage", serverAuth: true },
{ name: "subjectAltName", altNames: san },
{ name: "subjectKeyIdentifier" },
])
cert.sign(caKey, forge.md.sha256.create())
return {
keyPem: forge.pki.privateKeyToPem(keys.privateKey),
certPem: forge.pki.certificateToPem(cert),
}
}
function pickCommonName(host: string): string {
if (!host || host === "0.0.0.0") {
return "localhost"
}
if (host === "127.0.0.1") {
return "localhost"
}
return host
}
function buildSubjectAltNames(host: string, tlsSANs?: string): Array<{ type: number; value?: string; ip?: string }> {
const dns = new Set<string>()
const ips = new Set<string>()
dns.add("localhost")
ips.add("127.0.0.1")
if (host && host !== "0.0.0.0") {
if (isIPv4(host)) {
ips.add(host)
} else {
dns.add(host)
}
}
for (const token of splitList(tlsSANs)) {
if (isIPv4(token)) {
ips.add(token)
} else if (token) {
dns.add(token)
}
}
const altNames: Array<{ type: number; value?: string; ip?: string }> = []
// 2 = DNS, 7 = IP
for (const name of Array.from(dns)) {
altNames.push({ type: 2, value: name })
}
for (const ip of Array.from(ips)) {
altNames.push({ type: 7, ip })
}
return altNames
}
function splitList(input: string | undefined): string[] {
if (!input) return []
return input
.split(",")
.map((part) => part.trim())
.filter(Boolean)
}
function isIPv4(value: string): boolean {
const parts = value.split(".")
if (parts.length !== 4) return false
return parts.every((part) => {
if (!/^[0-9]+$/.test(part)) return false
const num = Number(part)
return Number.isInteger(num) && num >= 0 && num <= 255
})
}

View File

@@ -0,0 +1,58 @@
import assert from "node:assert/strict"
import { mkdtempSync, rmSync, writeFileSync } from "node:fs"
import { mkdir } from "node:fs/promises"
import os from "node:os"
import path from "node:path"
import { afterEach, beforeEach, describe, it } from "node:test"
import type { Logger } from "../../logger"
import { resolveUi } from "../remote-ui"
const noopLogger: Logger = {
debug: () => {},
info: () => {},
warn: () => {},
error: () => {},
trace: () => {},
child: () => noopLogger,
isLevelEnabled: () => false,
} as any
let tempRoot: string
beforeEach(() => {
tempRoot = mkdtempSync(path.join(os.tmpdir(), "codenomad-ui-test-"))
})
afterEach(() => {
rmSync(tempRoot, { recursive: true, force: true })
})
describe("resolveUi local version preference", () => {
it("prefers bundled when bundled version is higher", async () => {
const bundledDir = path.join(tempRoot, "bundled")
const configDir = path.join(tempRoot, "config")
const currentDir = path.join(configDir, "ui", "current")
await mkdir(bundledDir, { recursive: true })
await mkdir(currentDir, { recursive: true })
writeFileSync(path.join(bundledDir, "index.html"), "<html>bundled</html>")
writeFileSync(path.join(bundledDir, "ui-version.json"), JSON.stringify({ uiVersion: "0.8.1" }))
writeFileSync(path.join(currentDir, "index.html"), "<html>current</html>")
writeFileSync(path.join(currentDir, "ui-version.json"), JSON.stringify({ uiVersion: "0.8.0" }))
const result = await resolveUi({
serverVersion: "0.8.1",
bundledUiDir: bundledDir,
autoUpdate: false,
configDir,
logger: noopLogger,
})
assert.equal(result.source, "bundled")
assert.equal(result.uiStaticDir, bundledDir)
assert.equal(result.uiVersion, "0.8.1")
})
})

View File

@@ -0,0 +1,571 @@
import { createHash } from "crypto"
import fs from "fs"
import { promises as fsp } from "fs"
import os from "os"
import path from "path"
import { Readable } from "stream"
import { fetch } from "undici"
import yauzl from "yauzl"
import type { Logger } from "../logger"
export interface RemoteUiManifest {
minServerVersion: string
latestUIVersion: string
uiPackageURL: string
sha256: string
latestServerVersion?: string
latestServerUrl?: string
}
export type UiSource = "bundled" | "downloaded" | "previous" | "override" | "dev-proxy" | "missing"
export interface UiResolution {
uiStaticDir?: string
uiDevServerUrl?: string
source: UiSource
uiVersion?: string
supported: boolean
message?: string
latestServerVersion?: string
latestServerUrl?: string
minServerVersion?: string
}
export interface RemoteUiOptions {
serverVersion: string
bundledUiDir: string
autoUpdate: boolean
overrideUiDir?: string
uiDevServerUrl?: string
manifestUrl?: string
configDir?: string
logger: Logger
}
const DEFAULT_MANIFEST_URL = "https://ui.codenomad.neuralnomads.ai/version.json"
const MANIFEST_TIMEOUT_MS = 5_000
const ZIP_TIMEOUT_MS = 30_000
export async function resolveUi(options: RemoteUiOptions): Promise<UiResolution> {
const manifestUrl = options.manifestUrl ?? DEFAULT_MANIFEST_URL
if (options.uiDevServerUrl) {
return {
uiDevServerUrl: options.uiDevServerUrl,
source: "dev-proxy",
supported: true,
}
}
if (options.overrideUiDir) {
const resolved = await resolveStaticUiDir(options.overrideUiDir)
return {
uiStaticDir: resolved ?? options.overrideUiDir,
source: "override",
uiVersion: await readUiVersion(resolved ?? options.overrideUiDir),
supported: true,
}
}
const uiRoot = resolveUiCacheRoot(options.configDir)
const currentDir = path.join(uiRoot, "current")
const previousDir = path.join(uiRoot, "previous")
if (!options.autoUpdate) {
return await resolveFromCacheOrBundled({
logger: options.logger,
bundledUiDir: options.bundledUiDir,
currentDir,
previousDir,
supported: true,
})
}
let manifest: RemoteUiManifest | null = null
try {
manifest = await fetchManifest(manifestUrl, options.logger)
} catch (error) {
options.logger.debug({ err: error }, "Remote UI manifest unavailable; using cached/bundled UI")
}
if (!manifest) {
return await resolveFromCacheOrBundled({
logger: options.logger,
bundledUiDir: options.bundledUiDir,
currentDir,
previousDir,
supported: true,
})
}
const supported = compareSemverCore(options.serverVersion, manifest.minServerVersion) >= 0
if (!supported) {
const message = "Upgrade App to use latest features"
return await resolveFromCacheOrBundled({
logger: options.logger,
bundledUiDir: options.bundledUiDir,
currentDir,
previousDir,
supported: false,
message,
latestServerVersion: manifest.latestServerVersion,
latestServerUrl: manifest.latestServerUrl,
minServerVersion: manifest.minServerVersion,
})
}
const bestLocal = await pickBestLocalUi({
logger: options.logger,
bundledUiDir: options.bundledUiDir,
currentDir,
previousDir,
})
const remoteIsNewer =
!bestLocal ||
compareSemverMaybe(manifest.latestUIVersion, bestLocal.uiVersion) > 0
if (!remoteIsNewer) {
return await resolveFromCacheOrBundled({
logger: options.logger,
bundledUiDir: options.bundledUiDir,
currentDir,
previousDir,
supported: true,
latestServerVersion: manifest.latestServerVersion,
latestServerUrl: manifest.latestServerUrl,
minServerVersion: manifest.minServerVersion,
})
}
try {
await installRemoteUi({
manifest,
uiRoot,
currentDir,
previousDir,
logger: options.logger,
})
} catch (error) {
options.logger.warn({ err: error }, "Failed to install remote UI; falling back")
return await resolveFromCacheOrBundled({
logger: options.logger,
bundledUiDir: options.bundledUiDir,
currentDir,
previousDir,
supported: true,
latestServerVersion: manifest.latestServerVersion,
latestServerUrl: manifest.latestServerUrl,
minServerVersion: manifest.minServerVersion,
})
}
const installed = await resolveStaticUiDir(currentDir)
if (installed) {
return {
uiStaticDir: installed,
source: "downloaded",
uiVersion: await readUiVersion(installed),
supported: true,
latestServerVersion: manifest.latestServerVersion,
latestServerUrl: manifest.latestServerUrl,
minServerVersion: manifest.minServerVersion,
}
}
return await resolveFromCacheOrBundled({
logger: options.logger,
bundledUiDir: options.bundledUiDir,
currentDir,
previousDir,
supported: true,
latestServerVersion: manifest.latestServerVersion,
latestServerUrl: manifest.latestServerUrl,
minServerVersion: manifest.minServerVersion,
})
}
function resolveUiCacheRoot(configDir?: string): string {
if (configDir) {
return path.join(configDir, "ui")
}
return path.join(os.homedir(), ".config", "codenomad", "ui")
}
async function resolveFromCacheOrBundled(args: {
logger: Logger
bundledUiDir: string
currentDir: string
previousDir: string
supported: boolean
message?: string
latestServerVersion?: string
latestServerUrl?: string
minServerVersion?: string
}): Promise<UiResolution> {
const bestLocal = await pickBestLocalUi({
logger: args.logger,
bundledUiDir: args.bundledUiDir,
currentDir: args.currentDir,
previousDir: args.previousDir,
})
if (bestLocal) {
return {
uiStaticDir: bestLocal.uiStaticDir,
source: bestLocal.source,
uiVersion: bestLocal.uiVersion,
supported: args.supported,
message: args.message,
latestServerVersion: args.latestServerVersion,
latestServerUrl: args.latestServerUrl,
minServerVersion: args.minServerVersion,
}
}
args.logger.warn({ bundledUiDir: args.bundledUiDir }, "No UI assets found")
return {
uiStaticDir: args.bundledUiDir,
source: "missing",
supported: args.supported,
message: args.message,
latestServerVersion: args.latestServerVersion,
latestServerUrl: args.latestServerUrl,
minServerVersion: args.minServerVersion,
}
}
async function pickBestLocalUi(args: {
logger: Logger
bundledUiDir: string
currentDir: string
previousDir: string
}): Promise<{ uiStaticDir: string; source: UiSource; uiVersion?: string } | null> {
const candidates: Array<{ uiStaticDir: string; source: UiSource; uiVersion?: string; priority: number }> = []
const currentResolved = await resolveStaticUiDir(args.currentDir)
if (currentResolved) {
candidates.push({
uiStaticDir: currentResolved,
source: "downloaded",
uiVersion: await readUiVersion(currentResolved),
priority: 2,
})
}
const bundledResolved = await resolveStaticUiDir(args.bundledUiDir)
if (bundledResolved) {
candidates.push({
uiStaticDir: bundledResolved,
source: "bundled",
uiVersion: await readUiVersion(bundledResolved),
priority: 1,
})
}
const previousResolved = await resolveStaticUiDir(args.previousDir)
if (previousResolved) {
candidates.push({
uiStaticDir: previousResolved,
source: "previous",
uiVersion: await readUiVersion(previousResolved),
priority: 0,
})
}
if (candidates.length === 0) {
return null
}
candidates.sort((a, b) => {
const versionCmp = compareSemverMaybe(a.uiVersion, b.uiVersion)
if (versionCmp !== 0) return -versionCmp
return b.priority - a.priority
})
const best = candidates[0]
if (!best) return null
return { uiStaticDir: best.uiStaticDir, source: best.source, uiVersion: best.uiVersion }
}
function compareSemverMaybe(a: string | undefined, b: string | undefined): number {
if (!a && !b) return 0
if (!a) return -1
if (!b) return 1
return compareSemverCore(a, b)
}
async function resolveStaticUiDir(uiDir: string): Promise<string | null> {
try {
const indexPath = path.join(uiDir, "index.html")
await fsp.access(indexPath, fs.constants.R_OK)
return uiDir
} catch {
return null
}
}
interface UiVersionFile {
uiVersion?: string
version?: string
}
async function readUiVersion(uiDir: string): Promise<string | undefined> {
try {
const content = await fsp.readFile(path.join(uiDir, "ui-version.json"), "utf-8")
const parsed = JSON.parse(content) as UiVersionFile
return parsed.uiVersion ?? parsed.version
} catch {
return undefined
}
}
async function fetchManifest(url: string, logger: Logger): Promise<RemoteUiManifest> {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), MANIFEST_TIMEOUT_MS)
try {
const response = await fetch(url, {
signal: controller.signal,
headers: {
Accept: "application/json",
"User-Agent": "CodeNomad-CLI",
},
})
if (!response.ok) {
throw new Error(`Manifest responded with ${response.status}`)
}
const json = (await response.json()) as RemoteUiManifest
validateManifest(json)
return json
} catch (error) {
logger.debug({ err: error, url }, "Failed to fetch remote UI manifest")
throw error
} finally {
clearTimeout(timeout)
}
}
function validateManifest(manifest: RemoteUiManifest) {
const required: Array<keyof RemoteUiManifest> = ["minServerVersion", "latestUIVersion", "uiPackageURL", "sha256"]
for (const key of required) {
const value = manifest[key]
if (typeof value !== "string" || value.trim().length === 0) {
throw new Error(`Manifest missing ${key}`)
}
}
if (!/^https:\/\//i.test(manifest.uiPackageURL)) {
throw new Error("uiPackageURL must be https")
}
if (!/^[a-f0-9]{64}$/i.test(manifest.sha256.trim())) {
throw new Error("sha256 must be 64 hex chars")
}
}
async function installRemoteUi(args: {
manifest: RemoteUiManifest
uiRoot: string
currentDir: string
previousDir: string
logger: Logger
}) {
await fsp.mkdir(args.uiRoot, { recursive: true })
const tmpDir = path.join(args.uiRoot, `tmp-${Date.now()}`)
const zipPath = path.join(args.uiRoot, `ui-${args.manifest.latestUIVersion}.zip`)
try {
await downloadFile(args.manifest.uiPackageURL, zipPath, args.logger)
const digest = await sha256File(zipPath)
if (digest.toLowerCase() !== args.manifest.sha256.toLowerCase()) {
throw new Error(`sha256 mismatch for UI zip (expected ${args.manifest.sha256}, got ${digest})`)
}
await extractZip(zipPath, tmpDir)
const indexPath = path.join(tmpDir, "index.html")
if (!fs.existsSync(indexPath)) {
throw new Error("Extracted UI missing index.html")
}
await rotateDirs({ currentDir: args.currentDir, previousDir: args.previousDir, logger: args.logger })
fs.rmSync(args.currentDir, { recursive: true, force: true })
fs.renameSync(tmpDir, args.currentDir)
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true })
fs.rmSync(zipPath, { force: true })
}
}
async function rotateDirs(args: { currentDir: string; previousDir: string; logger: Logger }) {
try {
if (fs.existsSync(args.previousDir)) {
fs.rmSync(args.previousDir, { recursive: true, force: true })
}
if (fs.existsSync(args.currentDir)) {
fs.renameSync(args.currentDir, args.previousDir)
}
} catch (error) {
args.logger.warn({ err: error }, "Failed to rotate UI cache directories")
}
}
async function downloadFile(url: string, targetPath: string, logger: Logger) {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), ZIP_TIMEOUT_MS)
try {
const response = await fetch(url, {
signal: controller.signal,
headers: {
Accept: "application/octet-stream",
"User-Agent": "CodeNomad-CLI",
},
})
if (!response.ok || !response.body) {
throw new Error(`UI zip download failed with ${response.status}`)
}
await fsp.mkdir(path.dirname(targetPath), { recursive: true })
const fileStream = fs.createWriteStream(targetPath)
const body = response.body
if (!body) {
throw new Error("UI zip response missing body")
}
const nodeStream = Readable.fromWeb(body as any)
await new Promise<void>((resolve, reject) => {
nodeStream.pipe(fileStream)
nodeStream.on("error", reject)
fileStream.on("error", reject)
fileStream.on("finish", () => resolve())
})
logger.debug({ url, targetPath }, "Downloaded remote UI bundle")
} finally {
clearTimeout(timeout)
}
}
async function sha256File(filePath: string): Promise<string> {
const hash = createHash("sha256")
const stream = fs.createReadStream(filePath)
await new Promise<void>((resolve, reject) => {
stream.on("data", (chunk) => hash.update(chunk))
stream.on("error", reject)
stream.on("end", () => resolve())
})
return hash.digest("hex")
}
async function extractZip(zipPath: string, targetDir: string): Promise<void> {
await fsp.mkdir(targetDir, { recursive: true })
await new Promise<void>((resolve, reject) => {
yauzl.open(zipPath, { lazyEntries: true }, (openErr, zipfile) => {
if (openErr || !zipfile) {
reject(openErr ?? new Error("Unable to open zip"))
return
}
const root = path.resolve(targetDir)
const closeWithError = (error: unknown) => {
try {
zipfile.close()
} catch {
// ignore
}
reject(error)
}
zipfile.readEntry()
zipfile.on("entry", (entry) => {
// Normalize and guard against zip-slip.
const entryPath = entry.fileName.replace(/\\/g, "/")
const segments = entryPath.split("/").filter(Boolean)
if (segments.some((segment: string) => segment === "..") || path.isAbsolute(entryPath)) {
closeWithError(new Error(`Invalid zip entry path: ${entry.fileName}`))
return
}
const destination = path.resolve(targetDir, entryPath)
if (!destination.startsWith(root + path.sep) && destination !== root) {
closeWithError(new Error(`Zip entry escapes target dir: ${entry.fileName}`))
return
}
const isDirectory = entry.fileName.endsWith("/")
if (isDirectory) {
fsp
.mkdir(destination, { recursive: true })
.then(() => zipfile.readEntry())
.catch((error) => closeWithError(error))
return
}
fsp
.mkdir(path.dirname(destination), { recursive: true })
.then(() => {
zipfile.openReadStream(entry, (streamErr, readStream) => {
if (streamErr || !readStream) {
closeWithError(streamErr ?? new Error("Unable to read zip entry"))
return
}
const writeStream = fs.createWriteStream(destination)
const cleanup = (error?: unknown) => {
readStream.destroy()
writeStream.destroy()
if (error) {
closeWithError(error)
}
}
readStream.on("error", cleanup)
writeStream.on("error", cleanup)
writeStream.on("finish", () => zipfile.readEntry())
readStream.pipe(writeStream)
})
})
.catch((error) => closeWithError(error))
})
zipfile.on("end", () => {
zipfile.close()
resolve()
})
zipfile.on("error", (error) => closeWithError(error))
})
})
}
function compareSemverCore(a: string, b: string): number {
const pa = parseSemverCore(a)
const pb = parseSemverCore(b)
if (pa.major !== pb.major) return pa.major > pb.major ? 1 : -1
if (pa.minor !== pb.minor) return pa.minor > pb.minor ? 1 : -1
if (pa.patch !== pb.patch) return pa.patch > pb.patch ? 1 : -1
return 0
}
function parseSemverCore(value: string): { major: number; minor: number; patch: number } {
const core = value.trim().replace(/^v/i, "").split("-", 1)[0] ?? "0.0.0"
const parts = core.split(".")
const parsePart = (input: string | undefined) => {
const n = Number.parseInt((input ?? "0").replace(/[^0-9]/g, ""), 10)
return Number.isFinite(n) ? n : 0
}
return {
major: parsePart(parts[0]),
minor: parsePart(parts[1]),
patch: parsePart(parts[2]),
}
}

View File

@@ -0,0 +1,241 @@
import path from "path"
import { spawn } from "child_process"
import type { WorktreeDescriptor } from "../api-types"
import { promises as fsp } from "fs"
export interface LogLike {
debug?: (obj: any, msg?: string) => void
warn?: (obj: any, msg?: string) => void
}
type GitResult = { ok: true; stdout: string } | { ok: false; error: Error; stdout?: string; stderr?: string }
function runGit(args: string[], cwd: string): Promise<GitResult> {
return new Promise((resolve) => {
const child = spawn("git", args, { cwd, stdio: ["ignore", "pipe", "pipe"] })
let stdout = ""
let stderr = ""
child.stdout?.on("data", (chunk) => {
stdout += chunk.toString()
})
child.stderr?.on("data", (chunk) => {
stderr += chunk.toString()
})
child.once("error", (error) => {
resolve({ ok: false, error, stdout, stderr })
})
child.once("close", (code) => {
if (code === 0) {
resolve({ ok: true, stdout })
} else {
const error = new Error(stderr.trim() || `git ${args.join(" ")} failed with code ${code}`)
resolve({ ok: false, error, stdout, stderr })
}
})
})
}
export async function resolveRepoRoot(folder: string, logger?: LogLike): Promise<{ repoRoot: string; isGitRepo: boolean }> {
const result = await runGit(["rev-parse", "--show-toplevel"], folder)
if (!result.ok) {
logger?.debug?.({ folder, err: result.error }, "Folder is not a Git repository; using workspace folder as root")
return { repoRoot: folder, isGitRepo: false }
}
const repoRoot = result.stdout.trim()
if (!repoRoot) {
return { repoRoot: folder, isGitRepo: false }
}
return { repoRoot, isGitRepo: true }
}
function parseWorktreePorcelain(output: string): Array<{ worktree: string; branch?: string; head?: string; detached?: boolean }> {
const records: Array<{ worktree: string; branch?: string; head?: string; detached?: boolean }> = []
const lines = output.split(/\r?\n/)
let current: { worktree?: string; branch?: string; head?: string; detached?: boolean } = {}
const flush = () => {
if (current.worktree) {
records.push({ worktree: current.worktree, branch: current.branch })
}
current = {}
}
for (const line of lines) {
const trimmed = line.trim()
if (!trimmed) {
flush()
continue
}
const [key, ...rest] = trimmed.split(" ")
const value = rest.join(" ").trim()
if (key === "worktree") {
current.worktree = value
} else if (key === "branch") {
// branch is like refs/heads/foo
current.branch = value.replace(/^refs\/heads\//, "")
} else if (key === "HEAD") {
current.head = value
} else if (key === "detached") {
current.detached = true
}
}
flush()
return records
}
export async function listWorktrees(params: {
repoRoot: string
workspaceFolder: string
logger?: LogLike
}): Promise<WorktreeDescriptor[]> {
const { repoRoot, workspaceFolder, logger } = params
const rootDescriptor: WorktreeDescriptor = { slug: "root", directory: repoRoot, kind: "root" }
const result = await runGit(["worktree", "list", "--porcelain"], workspaceFolder)
if (!result.ok) {
logger?.debug?.({ repoRoot, err: result.error }, "Failed to list git worktrees; returning root only")
return [rootDescriptor]
}
const records = parseWorktreePorcelain(result.stdout)
const worktrees: WorktreeDescriptor[] = [rootDescriptor]
const seen = new Set<string>(["root"])
const normalizeSlug = (record: { branch?: string; head?: string; detached?: boolean; worktree: string }): string => {
const branch = (record.branch ?? "").trim()
if (branch) {
return branch
}
const head = (record.head ?? "").trim()
if (head && /^[0-9a-f]{7,40}$/i.test(head)) {
return `detached-${head.slice(0, 7)}`
}
// Fallback: stable-ish identifier derived from directory basename.
const base = path.basename(record.worktree || "")
return base ? `worktree-${base}` : "worktree"
}
for (const record of records) {
const abs = record.worktree
if (!abs || typeof abs !== "string") continue
// Skip the root record (we always expose it as slug="root").
if (path.resolve(abs) === path.resolve(repoRoot)) {
continue
}
const slug = normalizeSlug(record)
if (!slug || slug === "root") {
continue
}
if (seen.has(slug)) {
continue
}
seen.add(slug)
worktrees.push({ slug, directory: abs, kind: "worktree", branch: record.branch })
}
return worktrees
}
export function isValidWorktreeSlug(slug: string): boolean {
if (!slug) return false
const trimmed = slug.trim()
if (!trimmed) return false
if (trimmed.length > 200) return false
// Disallow control characters; allow branch-like slugs including '/'.
if (/[\x00-\x1F\x7F]/.test(trimmed)) return false
return true
}
export async function createManagedWorktree(params: {
repoRoot: string
workspaceFolder: string
slug: string
logger?: LogLike
}): Promise<{ slug: string; directory: string; branch?: string }> {
const { repoRoot, workspaceFolder, logger } = params
const branch = params.slug.trim()
if (!branch || branch === "root" || !isValidWorktreeSlug(branch)) {
throw new Error("Invalid worktree slug")
}
const sanitizeDirName = (input: string): string => {
const normalized = input
.trim()
.replace(/[\\/]+/g, "-")
.replace(/\s+/g, "-")
.replace(/[^a-zA-Z0-9_.-]+/g, "-")
.replace(/-{2,}/g, "-")
.replace(/^-+|-+$/g, "")
return normalized || "worktree"
}
const worktreesDir = path.join(repoRoot, ".codenomad", "worktrees")
const targetDir = path.join(worktreesDir, sanitizeDirName(branch))
await fsp.mkdir(worktreesDir, { recursive: true })
try {
const stat = await fsp.stat(targetDir)
if (stat.isDirectory()) {
throw new Error("Worktree directory already exists")
}
} catch (error) {
const code = (error as NodeJS.ErrnoException).code
if (code !== "ENOENT") {
throw error
}
}
logger?.debug?.({ slug: branch, branch, targetDir }, "Creating managed git worktree")
// Prefer creating a new branch from HEAD.
const first = await runGit(["worktree", "add", "-b", branch, targetDir, "HEAD"], workspaceFolder)
if (first.ok) {
return { slug: branch, directory: targetDir, branch }
}
const message = first.stderr?.toLowerCase() ?? first.error.message.toLowerCase()
if (message.includes("already exists")) {
// If the branch already exists, add worktree for that branch.
const second = await runGit(["worktree", "add", targetDir, branch], workspaceFolder)
if (second.ok) {
return { slug: branch, directory: targetDir, branch }
}
throw second.error
}
throw first.error
}
export async function removeWorktree(params: {
workspaceFolder: string
directory: string
force?: boolean
logger?: LogLike
}): Promise<void> {
const { workspaceFolder, logger } = params
const directory = (params.directory ?? "").trim()
if (!directory) {
throw new Error("Invalid worktree directory")
}
logger?.debug?.({ directory, force: Boolean(params.force) }, "Removing git worktree")
const args = ["worktree", "remove"]
if (params.force) {
args.push("--force")
}
args.push(directory)
const result = await runGit(args, workspaceFolder)
if (!result.ok) {
throw result.error
}
// Best-effort cleanup of stale metadata.
await runGit(["worktree", "prune"], workspaceFolder).catch(() => undefined)
}

View File

@@ -95,9 +95,16 @@ export class InstanceEventBridge {
}
private async consumeStream(workspaceId: string, port: number, signal: AbortSignal) {
const url = `http://${INSTANCE_HOST}:${port}/event`
const url = `http://${INSTANCE_HOST}:${port}/global/event`
const headers: Record<string, string> = { Accept: "text/event-stream" }
const authHeader = this.options.workspaceManager.getInstanceAuthorizationHeader(workspaceId)
if (authHeader) {
headers["Authorization"] = authHeader
}
const response = await fetch(url, {
headers: { Accept: "text/event-stream" },
headers,
signal,
dispatcher: STREAM_AGENT,
})
@@ -158,8 +165,32 @@ export class InstanceEventBridge {
}
try {
const event = JSON.parse(payload) as InstanceStreamEvent
this.options.logger.debug({ workspaceId, eventType: event.type }, "Instance SSE event received")
const parsed = JSON.parse(payload) as any
if (!parsed || typeof parsed !== "object") {
this.options.logger.warn({ workspaceId, chunk: payload }, "Dropped malformed instance event")
return
}
// OpenCode SSE payload shapes vary across versions.
// Common variants:
// - { type, properties, ... }
// - { payload: { type, properties, ... }, directory: "/abs/path" }
// - { payload: { type, properties, ... } }
const base = parsed.payload && typeof parsed.payload === "object" ? parsed.payload : parsed
const event: InstanceStreamEvent | null = base && typeof base === "object" ? ({ ...base } as any) : null
// Attach directory when available (don't overwrite if already present).
if (event && !(event as any).directory && typeof (parsed as any).directory === "string") {
;(event as any).directory = (parsed as any).directory
}
if (!event || typeof (event as any).type !== "string") {
this.options.logger.warn({ workspaceId, chunk: payload }, "Dropped malformed instance event")
return
}
this.options.logger.debug({ workspaceId, eventType: (event as any).type }, "Instance SSE event received")
if (this.options.logger.isLevelEnabled("trace")) {
this.options.logger.trace({ workspaceId, event }, "Instance SSE event payload")
}

View File

@@ -1,5 +1,6 @@
import path from "path"
import { spawnSync } from "child_process"
import { connect } from "net"
import { EventBus } from "../events/bus"
import { ConfigStore } from "../config/store"
import { BinaryRegistry } from "../config/binaries"
@@ -7,8 +8,18 @@ import { FileSystemBrowser } from "../filesystem/browser"
import { searchWorkspaceFiles, WorkspaceFileSearchOptions } from "../filesystem/search"
import { clearWorkspaceSearchCache } from "../filesystem/search-cache"
import { WorkspaceDescriptor, WorkspaceFileResponse, FileSystemEntry } from "../api-types"
import { WorkspaceRuntime } from "./runtime"
import { WorkspaceRuntime, ProcessExitInfo } from "./runtime"
import { Logger } from "../logger"
import { getOpencodeConfigDir } from "../opencode-config.js"
import {
buildOpencodeBasicAuthHeader,
DEFAULT_OPENCODE_USERNAME,
generateOpencodeServerPassword,
OPENCODE_SERVER_PASSWORD_ENV,
OPENCODE_SERVER_USERNAME_ENV,
} from "./opencode-auth"
const STARTUP_STABILITY_DELAY_MS = 1500
interface WorkspaceManagerOptions {
rootDir: string
@@ -16,6 +27,9 @@ interface WorkspaceManagerOptions {
binaryRegistry: BinaryRegistry
eventBus: EventBus
logger: Logger
getServerBaseUrl: () => string
/** Optional CA bundle path to trust CodeNomad HTTPS certs. */
nodeExtraCaCertsPath?: string
}
interface WorkspaceRecord extends WorkspaceDescriptor {}
@@ -23,9 +37,12 @@ interface WorkspaceRecord extends WorkspaceDescriptor {}
export class WorkspaceManager {
private readonly workspaces = new Map<string, WorkspaceRecord>()
private readonly runtime: WorkspaceRuntime
private readonly opencodeConfigDir: string
private readonly opencodeAuth = new Map<string, { username: string; password: string; authorization: string }>()
constructor(private readonly options: WorkspaceManagerOptions) {
this.runtime = new WorkspaceRuntime(this.options.eventBus, this.options.logger)
this.opencodeConfigDir = getOpencodeConfigDir()
}
list(): WorkspaceDescriptor[] {
@@ -40,6 +57,10 @@ export class WorkspaceManager {
return this.workspaces.get(id)?.port
}
getInstanceAuthorizationHeader(id: string): string | undefined {
return this.opencodeAuth.get(id)?.authorization
}
listFiles(workspaceId: string, relativePath = "."): FileSystemEntry[] {
const workspace = this.requireWorkspace(workspaceId)
const browser = new FileSystemBrowser({ rootDir: workspace.path })
@@ -72,7 +93,7 @@ export class WorkspaceManager {
this.options.logger.info({ workspaceId: id, folder: workspacePath, binary: resolvedBinaryPath }, "Creating workspace")
const proxyPath = `/workspaces/${id}/instance`
const proxyPath = `/workspaces/${id}/worktrees/root/instance`
const descriptor: WorkspaceRecord = {
@@ -97,10 +118,29 @@ export class WorkspaceManager {
this.options.eventBus.publish({ type: "workspace.created", workspace: descriptor })
const environment = this.options.configStore.get().preferences.environmentVariables ?? {}
const preferences = this.options.configStore.get().preferences ?? {}
const userEnvironment = preferences.environmentVariables ?? {}
const opencodeUsername = DEFAULT_OPENCODE_USERNAME
const opencodePassword = generateOpencodeServerPassword()
const authorization = buildOpencodeBasicAuthHeader({ username: opencodeUsername, password: opencodePassword })
if (!authorization) {
throw new Error("Failed to build OpenCode auth header")
}
this.opencodeAuth.set(id, { username: opencodeUsername, password: opencodePassword, authorization })
const environment = {
...userEnvironment,
OPENCODE_CONFIG_DIR: this.opencodeConfigDir,
CODENOMAD_INSTANCE_ID: id,
CODENOMAD_BASE_URL: this.options.getServerBaseUrl(),
...(this.options.nodeExtraCaCertsPath ? { NODE_EXTRA_CA_CERTS: this.options.nodeExtraCaCertsPath } : {}),
[OPENCODE_SERVER_USERNAME_ENV]: opencodeUsername,
[OPENCODE_SERVER_PASSWORD_ENV]: opencodePassword,
}
try {
const { pid, port } = await this.runtime.launch({
const { pid, port, exitPromise, getLastOutput } = await this.runtime.launch({
workspaceId: id,
folder: workspacePath,
binaryPath: resolvedBinaryPath,
@@ -108,6 +148,8 @@ export class WorkspaceManager {
onExit: (info) => this.handleProcessExit(info.workspaceId, info),
})
await this.waitForWorkspaceReadiness({ workspaceId: id, port, exitPromise, getLastOutput })
descriptor.pid = pid
descriptor.port = port
descriptor.status = "ready"
@@ -138,6 +180,7 @@ export class WorkspaceManager {
}
this.workspaces.delete(id)
this.opencodeAuth.delete(id)
clearWorkspaceSearchCache(workspace.path)
if (!wasRunning) {
this.options.eventBus.publish({ type: "workspace.stopped", workspaceId: id })
@@ -147,17 +190,29 @@ export class WorkspaceManager {
async shutdown() {
this.options.logger.info("Shutting down all workspaces")
const stopTasks: Array<Promise<void>> = []
for (const [id, workspace] of this.workspaces) {
if (workspace.pid) {
this.options.logger.info({ workspaceId: id }, "Stopping workspace during shutdown")
await this.runtime.stop(id).catch((error) => {
this.options.logger.error({ workspaceId: id, err: error }, "Failed to stop workspace during shutdown")
})
} else {
if (!workspace.pid) {
this.options.logger.debug({ workspaceId: id }, "Workspace already stopped")
continue
}
this.options.logger.info({ workspaceId: id }, "Stopping workspace during shutdown")
stopTasks.push(
this.runtime.stop(id).catch((error) => {
this.options.logger.error({ workspaceId: id, err: error }, "Failed to stop workspace during shutdown")
}),
)
}
if (stopTasks.length > 0) {
await Promise.allSettled(stopTasks)
}
this.workspaces.clear()
this.opencodeAuth.clear()
this.options.logger.info("All workspaces cleared")
}
@@ -184,13 +239,15 @@ export class WorkspaceManager {
try {
const result = spawnSync(locator, [identifier], { encoding: "utf8" })
if (result.status === 0 && result.stdout) {
const resolved = result.stdout
const candidates = result.stdout
.split(/\r?\n/)
.map((line) => line.trim())
.find((line) => line.length > 0)
.filter((line) => line.length > 0)
.filter((line) => !/^INFO:/i.test(line))
if (resolved) {
this.options.logger.debug({ identifier, resolved }, "Resolved binary path from system PATH")
if (candidates.length > 0) {
const resolved = this.pickBinaryCandidate(candidates)
this.options.logger.debug({ identifier, resolved, candidates }, "Resolved binary path from system PATH")
return resolved
}
} else if (result.error) {
@@ -203,6 +260,23 @@ export class WorkspaceManager {
return identifier
}
private pickBinaryCandidate(candidates: string[]): string {
if (process.platform !== "win32") {
return candidates[0] ?? ""
}
const extensionPreference = [".exe", ".cmd", ".bat", ".ps1"]
for (const ext of extensionPreference) {
const match = candidates.find((candidate) => candidate.toLowerCase().endsWith(ext))
if (match) {
return match
}
}
return candidates[0] ?? ""
}
private detectBinaryVersion(resolvedPath: string): string | undefined {
if (!resolvedPath) {
return undefined
@@ -233,10 +307,173 @@ export class WorkspaceManager {
return undefined
}
private async waitForWorkspaceReadiness(params: {
workspaceId: string
port: number
exitPromise: Promise<ProcessExitInfo>
getLastOutput: () => string
}) {
await Promise.race([
this.waitForPortAvailability(params.port),
params.exitPromise.then((info) => {
throw this.buildStartupError(
params.workspaceId,
"exited before becoming ready",
info,
params.getLastOutput(),
)
}),
])
await this.waitForInstanceHealth(params)
await Promise.race([
this.delay(STARTUP_STABILITY_DELAY_MS),
params.exitPromise.then((info) => {
throw this.buildStartupError(
params.workspaceId,
"exited shortly after start",
info,
params.getLastOutput(),
)
}),
])
}
private async waitForInstanceHealth(params: {
workspaceId: string
port: number
exitPromise: Promise<ProcessExitInfo>
getLastOutput: () => string
}) {
const probeResult = await Promise.race([
this.probeInstance(params.workspaceId, params.port),
params.exitPromise.then((info) => {
throw this.buildStartupError(
params.workspaceId,
"exited during health checks",
info,
params.getLastOutput(),
)
}),
])
if (probeResult.ok) {
return
}
const latestOutput = params.getLastOutput().trim()
if (latestOutput) {
throw new Error(latestOutput)
}
const reason = probeResult.reason ?? "Health check failed"
throw new Error(`Workspace ${params.workspaceId} failed health check: ${reason}.`)
}
private async probeInstance(workspaceId: string, port: number): Promise<{ ok: boolean; reason?: string }> {
const url = `http://127.0.0.1:${port}/project/current`
try {
const headers: Record<string, string> = {}
const authHeader = this.opencodeAuth.get(workspaceId)?.authorization
if (authHeader) {
headers["Authorization"] = authHeader
}
const response = await fetch(url, { headers })
if (!response.ok) {
const reason = `health probe returned HTTP ${response.status}`
this.options.logger.debug({ workspaceId, status: response.status }, "Health probe returned server error")
return { ok: false, reason }
}
return { ok: true }
} catch (error) {
const reason = error instanceof Error ? error.message : String(error)
this.options.logger.debug({ workspaceId, err: error }, "Health probe failed")
return { ok: false, reason }
}
}
private buildStartupError(
workspaceId: string,
phase: string,
exitInfo: ProcessExitInfo,
lastOutput: string,
): Error {
const exitDetails = this.describeExit(exitInfo)
const trimmedOutput = lastOutput.trim()
const outputDetails = trimmedOutput ? ` Last output: ${trimmedOutput}` : ""
return new Error(`Workspace ${workspaceId} ${phase} (${exitDetails}).${outputDetails}`)
}
private waitForPortAvailability(port: number, timeoutMs = 5000): Promise<void> {
return new Promise((resolve, reject) => {
const deadline = Date.now() + timeoutMs
let settled = false
let retryTimer: NodeJS.Timeout | null = null
const cleanup = () => {
settled = true
if (retryTimer) {
clearTimeout(retryTimer)
retryTimer = null
}
}
const tryConnect = () => {
if (settled) {
return
}
const socket = connect({ port, host: "127.0.0.1" }, () => {
cleanup()
socket.end()
resolve()
})
socket.once("error", () => {
socket.destroy()
if (settled) {
return
}
if (Date.now() >= deadline) {
cleanup()
reject(new Error(`Workspace port ${port} did not become ready within ${timeoutMs}ms`))
} else {
retryTimer = setTimeout(() => {
retryTimer = null
tryConnect()
}, 100)
}
})
}
tryConnect()
})
}
private delay(durationMs: number): Promise<void> {
if (durationMs <= 0) {
return Promise.resolve()
}
return new Promise((resolve) => setTimeout(resolve, durationMs))
}
private describeExit(info: ProcessExitInfo): string {
if (info.signal) {
return `signal ${info.signal}`
}
if (info.code !== null) {
return `code ${info.code}`
}
return "unknown reason"
}
private handleProcessExit(workspaceId: string, info: { code: number | null; requested: boolean }) {
const workspace = this.workspaces.get(workspaceId)
if (!workspace) return
this.opencodeAuth.delete(workspaceId)
this.options.logger.info({ workspaceId, ...info }, "Workspace process exited")
workspace.pid = undefined

View File

@@ -0,0 +1,22 @@
import crypto from "node:crypto"
export const OPENCODE_SERVER_USERNAME_ENV = "OPENCODE_SERVER_USERNAME" as const
export const OPENCODE_SERVER_PASSWORD_ENV = "OPENCODE_SERVER_PASSWORD" as const
export const DEFAULT_OPENCODE_USERNAME = "codenomad" as const
export function generateOpencodeServerPassword(): string {
return crypto.randomBytes(32).toString("base64url")
}
export function buildOpencodeBasicAuthHeader(params: { username?: string; password?: string }): string | undefined {
const username = params.username
const password = params.password
if (!username || !password) {
return undefined
}
const token = Buffer.from(`${username}:${password}`, "utf8").toString("base64")
return `Basic ${token}`
}

View File

@@ -1,10 +1,59 @@
import { ChildProcess, spawn } from "child_process"
import { ChildProcess, spawn, spawnSync } from "child_process"
import { existsSync, statSync } from "fs"
import path from "path"
import { EventBus } from "../events/bus"
import { LogLevel, WorkspaceLogEntry } from "../api-types"
import { Logger } from "../logger"
export const WINDOWS_CMD_EXTENSIONS = new Set([".cmd", ".bat"])
export const WINDOWS_POWERSHELL_EXTENSIONS = new Set([".ps1"])
export function buildSpawnSpec(binaryPath: string, args: string[]) {
if (process.platform !== "win32") {
return { command: binaryPath, args, options: {} as const }
}
const extension = path.extname(binaryPath).toLowerCase()
if (WINDOWS_CMD_EXTENSIONS.has(extension)) {
const comspec = process.env.ComSpec || "cmd.exe"
// cmd.exe requires the full command as a single string.
// Using the ""<script> <args>"" pattern ensures paths with spaces are handled.
const commandLine = `""${binaryPath}" ${args.join(" ")}"`
return {
command: comspec,
args: ["/d", "/s", "/c", commandLine],
options: { windowsVerbatimArguments: true } as const,
}
}
if (WINDOWS_POWERSHELL_EXTENSIONS.has(extension)) {
// powershell.exe ships with Windows. (pwsh may not.)
return {
command: "powershell.exe",
args: ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", binaryPath, ...args],
options: {} as const,
}
}
return { command: binaryPath, args, options: {} as const }
}
const SENSITIVE_ENV_KEY = /(PASSWORD|TOKEN|SECRET)/i
function redactEnvironment(env: Record<string, string | undefined>): Record<string, string | undefined> {
const redacted: Record<string, string | undefined> = {}
for (const [key, value] of Object.entries(env)) {
if (value === undefined) {
redacted[key] = value
continue
}
redacted[key] = SENSITIVE_ENV_KEY.test(key) ? "[REDACTED]" : value
}
return redacted
}
interface LaunchOptions {
workspaceId: string
folder: string
@@ -13,7 +62,7 @@ interface LaunchOptions {
onExit?: (info: ProcessExitInfo) => void
}
interface ProcessExitInfo {
export interface ProcessExitInfo {
workspaceId: string
code: number | null
signal: NodeJS.Signals | null
@@ -30,21 +79,56 @@ export class WorkspaceRuntime {
constructor(private readonly eventBus: EventBus, private readonly logger: Logger) {}
async launch(options: LaunchOptions): Promise<{ pid: number; port: number }> {
async launch(options: LaunchOptions): Promise<{ pid: number; port: number; exitPromise: Promise<ProcessExitInfo>; getLastOutput: () => string }> {
this.validateFolder(options.folder)
const args = ["serve", "--port", "0", "--print-logs", "--log-level", "DEBUG"]
const env = { ...process.env, ...(options.environment ?? {}) }
let exitResolve: ((info: ProcessExitInfo) => void) | null = null
const exitPromise = new Promise<ProcessExitInfo>((resolveExit) => {
exitResolve = resolveExit
})
// Store recent output for debugging - keep last 50 lines from each stream
const MAX_OUTPUT_LINES = 50
const recentStdout: string[] = []
const recentStderr: string[] = []
const getLastOutput = () => {
const combined: string[] = []
if (recentStderr.length > 0) {
combined.push("Error Stream")
combined.push(...recentStderr.slice(-10))
}
if (recentStdout.length > 0) {
combined.push("Output Stream")
combined.push(...recentStdout.slice(-10))
}
return combined.join("\n")
}
return new Promise((resolve, reject) => {
const spec = buildSpawnSpec(options.binaryPath, args)
const commandLine = [spec.command, ...spec.args].join(" ")
this.logger.info(
{ workspaceId: options.workspaceId, folder: options.folder, binary: options.binaryPath },
{
workspaceId: options.workspaceId,
folder: options.folder,
binary: options.binaryPath,
spawnCommand: spec.command,
spawnArgs: spec.args,
commandLine,
env: redactEnvironment(env),
},
"Launching OpenCode process",
)
const child = spawn(options.binaryPath, args, {
const detached = process.platform !== "win32"
const child = spawn(spec.command, spec.args, {
cwd: options.folder,
env,
stdio: ["ignore", "pipe", "pipe"],
detached,
...spec.options,
})
const managed: ManagedProcess = { child, requestedStop: false }
@@ -83,11 +167,22 @@ export class WorkspaceRuntime {
cleanupStreams()
child.removeListener("error", handleError)
child.removeListener("exit", handleExit)
const exitInfo: ProcessExitInfo = {
workspaceId: options.workspaceId,
code,
signal,
requested: managed.requestedStop,
}
if (exitResolve) {
exitResolve(exitInfo)
exitResolve = null
}
if (!portFound) {
const reason = stderrBuffer || `Process exited with code ${code}`
const recentOutput = getLastOutput().trim()
const reason = recentOutput || stderrBuffer || `Process exited with code ${code}`
reject(new Error(reason))
} else {
options.onExit?.({ workspaceId: options.workspaceId, code, signal, requested: managed.requestedStop })
options.onExit?.(exitInfo)
}
}
@@ -96,6 +191,10 @@ export class WorkspaceRuntime {
child.removeListener("exit", handleExit)
this.processes.delete(options.workspaceId)
this.logger.error({ workspaceId: options.workspaceId, err: error }, "Workspace runtime error")
if (exitResolve) {
exitResolve({ workspaceId: options.workspaceId, code: null, signal: null, requested: managed.requestedStop })
exitResolve = null
}
reject(error)
}
@@ -109,18 +208,25 @@ export class WorkspaceRuntime {
stdoutBuffer = lines.pop() ?? ""
for (const line of lines) {
if (!line.trim()) continue
const trimmed = line.trim()
if (!trimmed) continue
recentStdout.push(trimmed)
if (recentStdout.length > MAX_OUTPUT_LINES) {
recentStdout.shift()
}
this.emitLog(options.workspaceId, "info", line)
if (!portFound) {
const portMatch = line.match(/opencode server listening on http:\/\/.+:(\d+)/i)
if (portMatch) {
portFound = true
cleanupStreams()
stopWarningTimer()
child.removeListener("error", handleError)
const port = parseInt(portMatch[1], 10)
this.logger.info({ workspaceId: options.workspaceId, port }, "Workspace runtime allocated port")
resolve({ pid: child.pid!, port })
resolve({ pid: child.pid!, port, exitPromise, getLastOutput })
}
}
}
@@ -133,7 +239,14 @@ export class WorkspaceRuntime {
stderrBuffer = lines.pop() ?? ""
for (const line of lines) {
if (!line.trim()) continue
const trimmed = line.trim()
if (!trimmed) continue
recentStderr.push(trimmed)
if (recentStderr.length > MAX_OUTPUT_LINES) {
recentStderr.shift()
}
this.emitLog(options.workspaceId, "error", line)
}
})
@@ -148,10 +261,96 @@ export class WorkspaceRuntime {
const child = managed.child
this.logger.info({ workspaceId }, "Stopping OpenCode process")
const pid = child.pid
if (!pid) {
this.logger.warn({ workspaceId }, "Workspace process missing PID; cannot stop")
return
}
const isAlreadyExited = () => child.exitCode !== null || child.signalCode !== null
const tryKillPosixGroup = (signal: NodeJS.Signals) => {
try {
// Negative PID targets the process group (POSIX).
process.kill(-pid, signal)
return true
} catch (error) {
const err = error as NodeJS.ErrnoException
if (err?.code === "ESRCH") {
return true
}
this.logger.debug({ workspaceId, pid, err }, "Failed to signal POSIX process group")
return false
}
}
const tryKillSinglePid = (signal: NodeJS.Signals) => {
try {
process.kill(pid, signal)
return true
} catch (error) {
const err = error as NodeJS.ErrnoException
if (err?.code === "ESRCH") {
return true
}
this.logger.debug({ workspaceId, pid, err }, "Failed to signal workspace PID")
return false
}
}
const tryTaskkill = (force: boolean) => {
const args = ["/PID", String(pid), "/T"]
if (force) {
args.push("/F")
}
try {
const result = spawnSync("taskkill", args, { encoding: "utf8" })
const exitCode = result.status
if (exitCode === 0) {
return true
}
// If the PID is already gone, treat it as success.
const stderr = (result.stderr ?? "").toString().toLowerCase()
const stdout = (result.stdout ?? "").toString().toLowerCase()
const combined = `${stdout}\n${stderr}`
if (combined.includes("not found") || combined.includes("no running instance") || combined.includes("process") && combined.includes("not")) {
return true
}
this.logger.debug({ workspaceId, pid, exitCode, stderr: result.stderr, stdout: result.stdout }, "taskkill failed")
return false
} catch (error) {
this.logger.debug({ workspaceId, pid, err: error }, "taskkill failed to execute")
return false
}
}
const sendStopSignal = (signal: NodeJS.Signals) => {
if (process.platform === "win32") {
// Best-effort: terminate the whole process tree rooted at pid.
// Use /F only for escalation.
tryTaskkill(signal === "SIGKILL")
return
}
// Prefer process-group signaling so wrapper launchers (bun/node) don't orphan the real server.
const groupOk = tryKillPosixGroup(signal)
if (!groupOk) {
// Fallback to direct PID kill.
tryKillSinglePid(signal)
}
}
await new Promise<void>((resolve, reject) => {
let escalationTimer: NodeJS.Timeout | null = null
const cleanup = () => {
child.removeListener("exit", onExit)
child.removeListener("error", onError)
if (escalationTimer) {
clearTimeout(escalationTimer)
escalationTimer = null
}
}
const onExit = () => {
@@ -163,32 +362,30 @@ export class WorkspaceRuntime {
reject(error)
}
const resolveIfAlreadyExited = () => {
if (child.exitCode !== null || child.signalCode !== null) {
this.logger.debug({ workspaceId, exitCode: child.exitCode, signal: child.signalCode }, "Process already exited")
cleanup()
resolve()
return true
}
return false
if (isAlreadyExited()) {
this.logger.debug({ workspaceId, exitCode: child.exitCode, signal: child.signalCode }, "Process already exited")
cleanup()
resolve()
return
}
child.once("exit", onExit)
child.once("error", onError)
if (resolveIfAlreadyExited()) {
return
}
this.logger.debug(
{ workspaceId, pid, detached: process.platform !== "win32" },
"Sending SIGTERM to workspace process (tree/group)",
)
sendStopSignal("SIGTERM")
this.logger.debug({ workspaceId }, "Sending SIGTERM to workspace process")
child.kill("SIGTERM")
setTimeout(() => {
if (!child.killed) {
this.logger.warn({ workspaceId }, "Process did not stop after SIGTERM, force killing")
child.kill("SIGKILL")
} else {
this.logger.debug({ workspaceId }, "Workspace process stopped gracefully before SIGKILL timeout")
escalationTimer = setTimeout(() => {
escalationTimer = null
if (isAlreadyExited()) {
this.logger.debug({ workspaceId, pid }, "Workspace exited before SIGKILL escalation")
return
}
this.logger.warn({ workspaceId, pid }, "Process did not stop after SIGTERM, escalating")
sendStopSignal("SIGKILL")
}, 2000)
})
}

View File

@@ -0,0 +1,129 @@
import fs from "fs"
import { promises as fsp } from "fs"
import path from "path"
import type { WorktreeMap } from "../api-types"
import { resolveRepoRoot } from "./git-worktrees"
import type { LogLike } from "./git-worktrees"
const DEFAULT_MAP: WorktreeMap = {
version: 1,
defaultWorktreeSlug: "root",
parentSessionWorktreeSlug: {},
}
function getMapPath(repoRoot: string): string {
return path.join(repoRoot, ".codenomad", "worktreeMap.json")
}
function getGitExcludePath(repoRoot: string): string {
return path.join(repoRoot, ".git", "info", "exclude")
}
async function ensureGitExclude(repoRoot: string, logger?: LogLike): Promise<void> {
const excludePath = getGitExcludePath(repoRoot)
try {
await fsp.mkdir(path.dirname(excludePath), { recursive: true })
} catch {
return
}
const entries = [
".codenomad/worktrees/",
".codenomad/worktreeMap.json",
]
let existing = ""
try {
existing = await fsp.readFile(excludePath, "utf-8")
} catch (error) {
const code = (error as NodeJS.ErrnoException).code
if (code !== "ENOENT") {
logger?.debug?.({ err: error, excludePath }, "Failed to read .git/info/exclude")
return
}
existing = ""
}
const lines = new Set(existing.split(/\r?\n/).map((l) => l.trim()).filter(Boolean))
const missing = entries.filter((e) => !lines.has(e))
if (missing.length === 0) {
return
}
const header = existing.includes("# codenomad") ? "" : (existing.trim() ? "\n" : "") + "# codenomad\n"
const suffix = missing.map((e) => `${e}\n`).join("")
await fsp.writeFile(excludePath, `${existing}${header}${suffix}`, "utf-8")
}
export async function ensureCodenomadGitExclude(workspaceFolder: string, logger?: LogLike): Promise<void> {
const { repoRoot, isGitRepo } = await resolveRepoRoot(workspaceFolder, logger)
if (!isGitRepo) {
return
}
await ensureGitExclude(repoRoot, logger)
}
export async function readWorktreeMap(workspaceFolder: string, logger?: LogLike): Promise<WorktreeMap> {
const { repoRoot, isGitRepo } = await resolveRepoRoot(workspaceFolder, logger)
const filePath = getMapPath(repoRoot)
try {
const raw = await fsp.readFile(filePath, "utf-8")
const parsed = JSON.parse(raw)
if (!parsed || typeof parsed !== "object") {
return DEFAULT_MAP
}
const version = (parsed as any).version
if (version !== 1) {
return DEFAULT_MAP
}
const defaultWorktreeSlug = typeof (parsed as any).defaultWorktreeSlug === "string" ? (parsed as any).defaultWorktreeSlug : "root"
const parentSessionWorktreeSlug = (parsed as any).parentSessionWorktreeSlug
const mapping = parentSessionWorktreeSlug && typeof parentSessionWorktreeSlug === "object" ? parentSessionWorktreeSlug : {}
return {
version: 1,
defaultWorktreeSlug,
parentSessionWorktreeSlug: { ...mapping },
}
} catch (error) {
const code = (error as NodeJS.ErrnoException).code
if (code === "ENOENT") {
if (isGitRepo) {
// Best-effort ignore setup on first use.
await ensureGitExclude(repoRoot, logger).catch(() => undefined)
}
return DEFAULT_MAP
}
logger?.warn?.({ err: error, filePath }, "Failed to read worktree map")
return DEFAULT_MAP
}
}
export async function writeWorktreeMap(workspaceFolder: string, next: WorktreeMap, logger?: LogLike): Promise<void> {
const { repoRoot, isGitRepo } = await resolveRepoRoot(workspaceFolder, logger)
const filePath = getMapPath(repoRoot)
await fsp.mkdir(path.dirname(filePath), { recursive: true })
// Ensure ignore rules are present (local-only).
if (isGitRepo) {
await ensureGitExclude(repoRoot, logger).catch(() => undefined)
}
const payload: WorktreeMap = {
version: 1,
defaultWorktreeSlug: next.defaultWorktreeSlug || "root",
parentSessionWorktreeSlug: next.parentSessionWorktreeSlug ?? {},
}
// Write atomically.
const tmpPath = `${filePath}.${process.pid}.tmp`
await fsp.writeFile(tmpPath, JSON.stringify(payload, null, 2), "utf-8")
await fsp.rename(tmpPath, filePath)
}
export function worktreeMapExists(repoRoot: string): boolean {
try {
return fs.existsSync(getMapPath(repoRoot))
} catch {
return false
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,15 +1,16 @@
{
"name": "@codenomad/tauri-app",
"version": "0.4.0",
"version": "0.10.1",
"private": true,
"license": "MIT",
"scripts": {
"dev": "npx --yes @tauri-apps/cli@^2.9.4 dev",
"dev": "tauri dev",
"dev:ui": "npm run dev --workspace @codenomad/ui",
"dev:prep": "node ./scripts/dev-prep.js",
"dev:bootstrap": "npm run dev:prep && npm run dev:ui",
"prebuild": "node ./scripts/prebuild.js",
"bundle:server": "npm run prebuild",
"build": "npx --yes @tauri-apps/cli@^2.9.4 build"
"build": "tauri build"
},
"devDependencies": {
"@tauri-apps/cli": "^2.9.4"

View File

@@ -166,6 +166,44 @@ function copyServerArtifacts() {
}
}
function stripNodeModuleBins() {
const root = path.join(serverDest, "node_modules")
if (!fs.existsSync(root)) {
return
}
const stack = [root]
let removed = 0
while (stack.length > 0) {
const current = stack.pop()
if (!current) break
let entries
try {
entries = fs.readdirSync(current, { withFileTypes: true })
} catch {
continue
}
for (const entry of entries) {
const full = path.join(current, entry.name)
if (entry.name === ".bin") {
fs.rmSync(full, { recursive: true, force: true })
removed += 1
continue
}
if (entry.isDirectory()) {
stack.push(full)
}
}
}
if (removed > 0) {
console.log(`[prebuild] removed ${removed} node_modules/.bin directories`)
}
}
function copyUiLoadingAssets() {
const loadingSource = path.join(uiDist, "loading.html")
const assetsSource = path.join(uiDist, "assets")
@@ -192,4 +230,5 @@ ensureServerDependencies()
ensureServerBuild()
ensureUiBuild()
copyServerArtifacts()
stripNodeModuleBins()
copyUiLoadingAssets()

View File

@@ -2,6 +2,7 @@
name = "codenomad-tauri"
version = "0.1.0"
edition = "2021"
license = "MIT"
[build-dependencies]
tauri-build = { version = "2.5.2", features = [] }
@@ -21,3 +22,4 @@ tauri-plugin-dialog = "2"
dirs = "5"
tauri-plugin-opener = "2"
url = "2"
tauri-plugin-keepawake = "0.1.1"

View File

@@ -3,7 +3,7 @@
"identifier": "main-window-native-dialogs",
"description": "Grant the main window access to required core features and native dialog commands.",
"remote": {
"urls": ["http://127.0.0.1:*", "http://localhost:*"]
"urls": ["http://127.0.0.1:*", "http://localhost:*", "http://tauri.localhost/*", "https://tauri.localhost/*"]
},
"windows": ["main"],
"permissions": [

File diff suppressed because one or more lines are too long

View File

@@ -1 +1 @@
{"main-window-native-dialogs":{"identifier":"main-window-native-dialogs","description":"Grant the main window access to required core features and native dialog commands.","remote":{"urls":["http://127.0.0.1:*","http://localhost:*"]},"local":true,"windows":["main"],"permissions":["core:default","core:menu:default","dialog:allow-open","opener:allow-default-urls","core:webview:allow-set-webview-zoom"]}}
{"main-window-native-dialogs":{"identifier":"main-window-native-dialogs","description":"Grant the main window access to required core features and native dialog commands.","remote":{"urls":["http://127.0.0.1:*","http://localhost:*","http://tauri.localhost/*","https://tauri.localhost/*"]},"local":true,"windows":["main"],"permissions":["core:default","core:menu:default","dialog:allow-open","opener:allow-default-urls","core:webview:allow-set-webview-zoom"]}}

View File

@@ -2378,6 +2378,36 @@
"const": "dialog:deny-save",
"markdownDescription": "Denies the save command without any pre-configured scope."
},
{
"description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-start`\n- `allow-stop`",
"type": "string",
"const": "keepawake:default",
"markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-start`\n- `allow-stop`"
},
{
"description": "Enables the start command without any pre-configured scope.",
"type": "string",
"const": "keepawake:allow-start",
"markdownDescription": "Enables the start command without any pre-configured scope."
},
{
"description": "Enables the stop command without any pre-configured scope.",
"type": "string",
"const": "keepawake:allow-stop",
"markdownDescription": "Enables the stop command without any pre-configured scope."
},
{
"description": "Denies the start command without any pre-configured scope.",
"type": "string",
"const": "keepawake:deny-start",
"markdownDescription": "Denies the start command without any pre-configured scope."
},
{
"description": "Denies the stop command without any pre-configured scope.",
"type": "string",
"const": "keepawake:deny-stop",
"markdownDescription": "Denies the stop command without any pre-configured scope."
},
{
"description": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`",
"type": "string",

View File

@@ -2378,6 +2378,36 @@
"const": "dialog:deny-save",
"markdownDescription": "Denies the save command without any pre-configured scope."
},
{
"description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-start`\n- `allow-stop`",
"type": "string",
"const": "keepawake:default",
"markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-start`\n- `allow-stop`"
},
{
"description": "Enables the start command without any pre-configured scope.",
"type": "string",
"const": "keepawake:allow-start",
"markdownDescription": "Enables the start command without any pre-configured scope."
},
{
"description": "Enables the stop command without any pre-configured scope.",
"type": "string",
"const": "keepawake:allow-stop",
"markdownDescription": "Enables the stop command without any pre-configured scope."
},
{
"description": "Denies the start command without any pre-configured scope.",
"type": "string",
"const": "keepawake:deny-start",
"markdownDescription": "Denies the start command without any pre-configured scope."
},
{
"description": "Denies the stop command without any pre-configured scope.",
"type": "string",
"const": "keepawake:deny-stop",
"markdownDescription": "Denies the stop command without any pre-configured scope."
},
{
"description": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`",
"type": "string",

View File

@@ -7,14 +7,15 @@ use std::collections::VecDeque;
use std::env;
use std::ffi::OsStr;
use std::fs;
use std::io::{BufRead, BufReader};
use std::io::{BufRead, BufReader, Read, Write};
use std::net::TcpStream;
use std::path::PathBuf;
use std::process::{Child, Command, Stdio};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::thread;
use std::time::{Duration, Instant};
use tauri::{AppHandle, Emitter, Manager, Url};
use tauri::{webview::cookie::Cookie, AppHandle, Emitter, Manager, Url};
fn log_line(message: &str) {
println!("[tauri-cli] {message}");
@@ -31,9 +32,17 @@ fn workspace_root() -> Option<PathBuf> {
})
}
const SESSION_COOKIE_NAME: &str = "codenomad_session";
const CLI_STOP_GRACE_SECS: u64 = 30;
fn navigate_main(app: &AppHandle, url: &str) {
if let Some(win) = app.webview_windows().get("main") {
log_line(&format!("navigating main to {url}"));
let mut display = url.to_string();
if let Some(hash_index) = display.find('#') {
display.replace_range(hash_index + 1.., "[REDACTED]");
}
log_line(&format!("navigating main to {display}"));
if let Ok(parsed) = Url::parse(url) {
let _ = win.navigate(parsed);
} else {
@@ -44,6 +53,85 @@ fn navigate_main(app: &AppHandle, url: &str) {
}
}
fn extract_cookie_value(set_cookie: &str, name: &str) -> Option<String> {
let prefix = format!("{name}=");
let cookie_kv = set_cookie.split(';').next()?.trim();
if !cookie_kv.starts_with(&prefix) {
return None;
}
let value = cookie_kv.trim_start_matches(&prefix).trim();
if value.is_empty() {
return None;
}
Some(value.to_string())
}
fn exchange_bootstrap_token(base_url: &str, token: &str) -> anyhow::Result<Option<String>> {
let parsed = Url::parse(base_url)?;
let host = parsed.host_str().unwrap_or("127.0.0.1");
let port = parsed.port_or_known_default().unwrap_or(80);
// This is only used for local bootstrap; we assume plain HTTP.
let mut stream = TcpStream::connect((host, port))?;
let body = format!("{{\"token\":\"{}\"}}", token);
let request = format!(
"POST /api/auth/token HTTP/1.1\r\nHost: {host}:{port}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.as_bytes().len(),
body
);
stream.write_all(request.as_bytes())?;
stream.flush()?;
let mut response = String::new();
stream.read_to_string(&mut response)?;
let (raw_headers, _rest) = response
.split_once("\r\n\r\n")
.or_else(|| response.split_once("\n\n"))
.unwrap_or((response.as_str(), ""));
let mut lines = raw_headers.lines();
let status_line = lines.next().unwrap_or("");
if !status_line.contains(" 200 ") {
return Ok(None);
}
for line in lines {
// handle case-insensitive header name
if let Some(value) = line.strip_prefix("Set-Cookie:") {
if let Some(session_id) = extract_cookie_value(value.trim(), SESSION_COOKIE_NAME) {
return Ok(Some(session_id));
}
} else if let Some(value) = line.strip_prefix("set-cookie:") {
if let Some(session_id) = extract_cookie_value(value.trim(), SESSION_COOKIE_NAME) {
return Ok(Some(session_id));
}
}
}
Ok(None)
}
fn set_session_cookie(app: &AppHandle, base_url: &str, session_id: &str) -> anyhow::Result<()> {
let parsed = Url::parse(base_url)?;
let domain = parsed.host_str().unwrap_or("127.0.0.1").to_string();
let cookie = Cookie::build((SESSION_COOKIE_NAME, session_id))
.domain(domain)
.path("/")
.http_only(true)
.same_site(tauri::webview::cookie::SameSite::Lax)
.build();
if let Some(win) = app.webview_windows().get("main") {
win.set_cookie(cookie)?;
}
Ok(())
}
const DEFAULT_CONFIG_PATH: &str = "~/.config/codenomad/config.json";
#[derive(Debug, Deserialize)]
@@ -139,6 +227,7 @@ pub struct CliProcessManager {
status: Arc<Mutex<CliStatus>>,
child: Arc<Mutex<Option<Child>>>,
ready: Arc<AtomicBool>,
bootstrap_token: Arc<Mutex<Option<String>>>,
}
impl CliProcessManager {
@@ -147,6 +236,7 @@ impl CliProcessManager {
status: Arc::new(Mutex::new(CliStatus::default())),
child: Arc::new(Mutex::new(None)),
ready: Arc::new(AtomicBool::new(false)),
bootstrap_token: Arc::new(Mutex::new(None)),
}
}
@@ -154,6 +244,7 @@ impl CliProcessManager {
log_line(&format!("start requested (dev={dev})"));
self.stop()?;
self.ready.store(false, Ordering::SeqCst);
*self.bootstrap_token.lock() = None;
{
let mut status = self.status.lock();
status.state = CliState::Starting;
@@ -167,8 +258,9 @@ impl CliProcessManager {
let status_arc = self.status.clone();
let child_arc = self.child.clone();
let ready_flag = self.ready.clone();
let token_arc = self.bootstrap_token.clone();
thread::spawn(move || {
if let Err(err) = Self::spawn_cli(app.clone(), status_arc.clone(), child_arc, ready_flag, dev) {
if let Err(err) = Self::spawn_cli(app.clone(), status_arc.clone(), child_arc, ready_flag, token_arc, dev) {
log_line(&format!("cli spawn failed: {err}"));
let mut locked = status_arc.lock();
locked.state = CliState::Error;
@@ -186,6 +278,7 @@ impl CliProcessManager {
pub fn stop(&self) -> anyhow::Result<()> {
let mut child_opt = self.child.lock();
if let Some(mut child) = child_opt.take() {
log_line(&format!("stopping CLI pid={}", child.id()));
#[cfg(unix)]
unsafe {
libc::kill(child.id() as i32, libc::SIGTERM);
@@ -200,7 +293,12 @@ impl CliProcessManager {
match child.try_wait() {
Ok(Some(_)) => break,
Ok(None) => {
if start.elapsed() > Duration::from_secs(4) {
if start.elapsed() > Duration::from_secs(CLI_STOP_GRACE_SECS) {
log_line(&format!(
"stop timed out after {}s; sending SIGKILL pid={}",
CLI_STOP_GRACE_SECS,
child.id()
));
#[cfg(unix)]
unsafe {
libc::kill(child.id() as i32, libc::SIGKILL);
@@ -237,6 +335,7 @@ impl CliProcessManager {
status: Arc<Mutex<CliStatus>>,
child_holder: Arc<Mutex<Option<Child>>>,
ready: Arc<AtomicBool>,
bootstrap_token: Arc<Mutex<Option<String>>>,
dev: bool,
) -> anyhow::Result<()> {
log_line("resolving CLI entry");
@@ -318,8 +417,10 @@ impl CliProcessManager {
let status_clone = status.clone();
let app_clone = app.clone();
let ready_clone = ready.clone();
let token_clone = bootstrap_token.clone();
thread::spawn(move || {
let stdout = child_clone
.lock()
.as_mut()
@@ -332,10 +433,10 @@ impl CliProcessManager {
.map(BufReader::new);
if let Some(reader) = stdout {
Self::process_stream(reader, "stdout", &app_clone, &status_clone, &ready_clone);
Self::process_stream(reader, "stdout", &app_clone, &status_clone, &ready_clone, &token_clone);
}
if let Some(reader) = stderr {
Self::process_stream(reader, "stderr", &app_clone, &status_clone, &ready_clone);
Self::process_stream(reader, "stderr", &app_clone, &status_clone, &ready_clone, &token_clone);
}
});
@@ -363,13 +464,33 @@ impl CliProcessManager {
let status_clone = status.clone();
let app_clone = app.clone();
thread::spawn(move || {
let code = {
let mut guard = child_holder.lock();
if let Some(child) = guard.as_mut() {
child.wait().ok()
} else {
None
// Do not hold the child mutex while waiting for process exit.
// Holding the lock across `wait()` deadlocks `stop()`, which needs the
// same lock to send SIGTERM/SIGKILL when the user quits the app.
let code = loop {
let maybe_exited = {
let mut guard = child_holder.lock();
if guard.is_none() {
return;
}
match guard
.as_mut()
.and_then(|child| child.try_wait().ok().flatten())
{
Some(status) => {
// Drop the handle after the process exits so other callers
// don't attempt to stop/kill a finished process.
*guard = None;
Some(status)
}
None => None,
}
};
if let Some(status) = maybe_exited {
break Some(status);
}
thread::sleep(Duration::from_millis(100));
};
let mut locked = status_clone.lock();
@@ -407,10 +528,12 @@ impl CliProcessManager {
app: &AppHandle,
status: &Arc<Mutex<CliStatus>>,
ready: &Arc<AtomicBool>,
bootstrap_token: &Arc<Mutex<Option<String>>>,
) {
let mut buffer = String::new();
let port_regex = Regex::new(r"CodeNomad Server is ready at http://[^:]+:(\d+)").ok();
let local_url_regex = Regex::new(r"^Local\s+Connection\s+URL\s*:\s*(https?://\S+)").ok();
let http_regex = Regex::new(r":(\d{2,5})(?!.*:\d)").ok();
let token_prefix = "CODENOMAD_BOOTSTRAP_TOKEN:";
loop {
buffer.clear();
@@ -419,18 +542,29 @@ impl CliProcessManager {
Ok(_) => {
let line = buffer.trim_end();
if !line.is_empty() {
if line.starts_with(token_prefix) {
let token = line.trim_start_matches(token_prefix).trim();
if !token.is_empty() {
let mut guard = bootstrap_token.lock();
if guard.is_none() {
*guard = Some(token.to_string());
}
}
continue;
}
log_line(&format!("[cli][{}] {}", stream, line));
if ready.load(Ordering::SeqCst) {
continue;
}
if let Some(port) = port_regex
if let Some(url) = local_url_regex
.as_ref()
.and_then(|re| re.captures(line).and_then(|c| c.get(1)))
.and_then(|m| m.as_str().parse::<u16>().ok())
.map(|m| m.as_str().to_string())
{
Self::mark_ready(app, status, ready, port);
Self::mark_ready(app, status, ready, bootstrap_token, url);
continue;
}
@@ -440,13 +574,13 @@ impl CliProcessManager {
.and_then(|re| re.captures(line).and_then(|c| c.get(1)))
.and_then(|m| m.as_str().parse::<u16>().ok())
{
Self::mark_ready(app, status, ready, port);
Self::mark_ready(app, status, ready, bootstrap_token, format!("http://localhost:{port}"));
continue;
}
if let Ok(value) = serde_json::from_str::<serde_json::Value>(line) {
if let Some(port) = value.get("port").and_then(|p| p.as_u64()) {
Self::mark_ready(app, status, ready, port as u16);
Self::mark_ready(app, status, ready, bootstrap_token, format!("http://localhost:{}", port));
continue;
}
}
@@ -458,16 +592,56 @@ impl CliProcessManager {
}
}
fn mark_ready(app: &AppHandle, status: &Arc<Mutex<CliStatus>>, ready: &Arc<AtomicBool>, port: u16) {
fn mark_ready(
app: &AppHandle,
status: &Arc<Mutex<CliStatus>>,
ready: &Arc<AtomicBool>,
bootstrap_token: &Arc<Mutex<Option<String>>>,
base_url: String,
) {
ready.store(true, Ordering::SeqCst);
let port = Url::parse(&base_url)
.ok()
.and_then(|u| u.port_or_known_default())
.map(|p| p as u16);
let mut locked = status.lock();
let url = format!("http://127.0.0.1:{port}");
locked.port = Some(port);
locked.url = Some(url.clone());
locked.port = port;
locked.url = Some(base_url.clone());
locked.state = CliState::Ready;
locked.error = None;
log_line(&format!("cli ready on {url}"));
navigate_main(app, &url);
log_line(&format!("cli ready on {base_url}"));
let token = bootstrap_token.lock().take();
if let Some(token) = token {
// Token exchange is only implemented for loopback HTTP. If localUrl is HTTPS,
// skip the exchange and let the user authenticate normally.
let scheme = Url::parse(&base_url).ok().map(|u| u.scheme().to_string());
if scheme.as_deref() != Some("http") {
navigate_main(app, &base_url);
} else {
match exchange_bootstrap_token(&base_url, &token) {
Ok(Some(session_id)) => {
if let Err(err) = set_session_cookie(app, &base_url, &session_id) {
log_line(&format!("failed to set session cookie: {err}"));
navigate_main(app, &format!("{base_url}/login"));
} else {
navigate_main(app, &base_url);
}
}
Ok(None) => {
log_line("bootstrap token exchange failed (invalid token)");
navigate_main(app, &format!("{base_url}/login"));
}
Err(err) => {
log_line(&format!("bootstrap token exchange failed: {err}"));
navigate_main(app, &format!("{base_url}/login"));
}
}
}
} else {
navigate_main(app, &base_url);
}
let _ = app.emit("cli:ready", locked.clone());
Self::emit_status(app, &locked);
}
@@ -545,18 +719,24 @@ impl CliEntry {
}
fn build_args(&self, dev: bool, host: &str) -> Vec<String> {
let mut args = vec![
"serve".to_string(),
"--host".to_string(),
host.to_string(),
"--port".to_string(),
"0".to_string(),
];
let mut args = vec!["serve".to_string(), "--host".to_string(), host.to_string(), "--generate-token".to_string()];
if dev {
// Dev: plain HTTP + Vite dev server proxy.
args.push("--https".to_string());
args.push("false".to_string());
args.push("--http".to_string());
args.push("true".to_string());
args.push("--ui-dev-server".to_string());
args.push("http://localhost:3000".to_string());
args.push("--log-level".to_string());
args.push("debug".to_string());
} else {
// Prod desktop: always keep loopback HTTP enabled.
args.push("--https".to_string());
args.push("true".to_string());
args.push("--http".to_string());
args.push("true".to_string());
}
args
}

View File

@@ -4,6 +4,7 @@ mod cli_manager;
use cli_manager::{CliProcessManager, CliStatus};
use serde_json::json;
use std::sync::atomic::{AtomicBool, Ordering};
use tauri::menu::{MenuBuilder, MenuItem, SubmenuBuilder};
use tauri::plugin::{Builder as PluginBuilder, TauriPlugin};
use tauri::webview::Webview;
@@ -11,6 +12,8 @@ use tauri::{AppHandle, Emitter, Manager, Runtime, Wry};
use tauri_plugin_opener::OpenerExt;
use url::Url;
static QUIT_REQUESTED: AtomicBool = AtomicBool::new(false);
#[derive(Clone)]
pub struct AppState {
pub manager: CliProcessManager,
@@ -32,6 +35,7 @@ fn cli_restart(app: AppHandle, state: tauri::State<AppState>) -> Result<CliStatu
Ok(state.manager.status())
}
fn is_dev_mode() -> bool {
cfg!(debug_assertions) || std::env::var("TAURI_DEV").is_ok()
}
@@ -39,7 +43,10 @@ fn is_dev_mode() -> bool {
fn should_allow_internal(url: &Url) -> bool {
match url.scheme() {
"tauri" | "asset" | "file" => true,
"http" | "https" => matches!(url.host_str(), Some("127.0.0.1" | "localhost")),
// On Windows/WebView2, Tauri serves the app assets from `tauri.localhost`.
// This must be treated as an internal origin or the navigation guard will
// redirect it to the system browser and the app will appear blank.
"http" | "https" => matches!(url.host_str(), Some("127.0.0.1" | "localhost" | "tauri.localhost")),
_ => false,
}
}
@@ -67,6 +74,7 @@ fn main() {
tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_keepawake::init())
.plugin(navigation_guard)
.manage(AppState {
manager: CliProcessManager::new(),
@@ -163,7 +171,13 @@ fn main() {
.build(tauri::generate_context!())
.expect("error while building tauri application")
.run(|app_handle, event| match event {
tauri::RunEvent::ExitRequested { .. } => {
tauri::RunEvent::ExitRequested { api, .. } => {
// `app_handle.exit(0)` triggers another `ExitRequested`. Without a guard, we can
// prevent exit forever and the app never quits (Cmd+Q / Quit menu appears stuck).
if QUIT_REQUESTED.swap(true, Ordering::SeqCst) {
return;
}
api.prevent_exit();
let app = app_handle.clone();
std::thread::spawn(move || {
if let Some(state) = app.try_state::<AppState>() {
@@ -173,18 +187,21 @@ fn main() {
});
}
tauri::RunEvent::WindowEvent {
event: tauri::WindowEvent::Destroyed,
event: tauri::WindowEvent::CloseRequested { api, .. },
..
} => {
if app_handle.webview_windows().len() <= 1 {
let app = app_handle.clone();
std::thread::spawn(move || {
if let Some(state) = app.try_state::<AppState>() {
let _ = state.manager.stop();
}
app.exit(0);
});
// Ensure we have time to stop the CLI process before the app exits.
if QUIT_REQUESTED.swap(true, Ordering::SeqCst) {
return;
}
api.prevent_close();
let app = app_handle.clone();
std::thread::spawn(move || {
if let Some(state) = app.try_state::<AppState>() {
let _ = state.manager.stop();
}
app.exit(0);
});
}
_ => {}
});

View File

@@ -1,3 +1,4 @@
node_modules/
dist/
.vite/
src/renderer/public/logo.png

View File

@@ -1,7 +1,8 @@
{
"name": "@codenomad/ui",
"version": "0.4.0",
"version": "0.10.1",
"private": true,
"license": "MIT",
"type": "module",
"scripts": {
"dev": "vite dev",
@@ -12,11 +13,13 @@
"dependencies": {
"@git-diff-view/solid": "^0.0.8",
"@kobalte/core": "0.13.11",
"@opencode-ai/sdk": "^1.0.138",
"@opencode-ai/sdk": "1.1.11",
"@solidjs/router": "^0.13.0",
"@suid/icons-material": "^0.9.0",
"@suid/material": "^0.19.0",
"@suid/system": "^0.14.0",
"@tauri-apps/plugin-opener": "^2.5.3",
"ansi-sequence-parser": "^1.1.3",
"debug": "^4.4.3",
"github-markdown-css": "^5.8.1",
"lucide-solid": "^0.300.0",
@@ -24,14 +27,17 @@
"qrcode": "^1.5.3",
"shiki": "^3.13.0",
"solid-js": "^1.8.0",
"solid-toast": "^0.5.0"
"solid-toast": "^0.5.0",
"tauri-plugin-keepawake-api": "^0.1.0"
},
"devDependencies": {
"@vite-pwa/assets-generator": "^1.0.2",
"autoprefixer": "10.4.21",
"postcss": "8.5.6",
"tailwindcss": "3",
"typescript": "^5.3.0",
"vite": "^5.0.0",
"vite-plugin-pwa": "^1.2.0",
"vite-plugin-solid": "^2.10.0"
}
}

View File

@@ -10,6 +10,7 @@ import InstanceShell from "./components/instance/instance-shell2"
import { RemoteAccessOverlay } from "./components/remote-access-overlay"
import { InstanceMetadataProvider } from "./lib/contexts/instance-metadata-context"
import { initMarkdown } from "./lib/markdown"
import { initGithubStars } from "./stores/github-stars"
import { useTheme } from "./lib/theme"
import { useCommands } from "./lib/hooks/use-commands"
@@ -17,15 +18,15 @@ import { useAppLifecycle } from "./lib/hooks/use-app-lifecycle"
import { getLogger } from "./lib/logger"
import { initReleaseNotifications } from "./stores/releases"
import { runtimeEnv } from "./lib/runtime-env"
import { useI18n } from "./lib/i18n"
import { setWakeLockDesired } from "./lib/native/wake-lock"
import {
hasInstances,
isSelectingFolder,
setIsSelectingFolder,
setHasInstances,
showFolderSelection,
setShowFolderSelection,
} from "./stores/ui"
import { instances as instanceStore } from "./stores/instances"
import { useConfig } from "./stores/preferences"
import {
createInstance,
@@ -48,10 +49,13 @@ import {
updateSessionModel,
} from "./stores/sessions"
import { getInstanceSessionIndicatorStatus } from "./stores/session-status"
const log = getLogger("actions")
const App: Component = () => {
const { isDark } = useTheme()
const { t } = useI18n()
const {
preferences,
recordWorkspaceLaunch,
@@ -59,13 +63,19 @@ const App: Component = () => {
toggleShowTimelineTools,
toggleAutoCleanupBlankSessions,
toggleUsageMetrics,
togglePromptSubmitOnEnter,
setDiffViewMode,
setToolOutputExpansion,
setDiagnosticsExpansion,
setThinkingBlocksExpansion,
} = useConfig()
const [escapeInDebounce, setEscapeInDebounce] = createSignal(false)
const [launchErrorBinary, setLaunchErrorBinary] = createSignal<string | null>(null)
interface LaunchErrorState {
message: string
binaryPath: string
missingBinary: boolean
}
const [launchError, setLaunchError] = createSignal<LaunchErrorState | null>(null)
const [isAdvancedSettingsOpen, setIsAdvancedSettingsOpen] = createSignal(false)
const [remoteAccessOpen, setRemoteAccessOpen] = createSignal(false)
const [instanceTabBarHeight, setInstanceTabBarHeight] = createSignal(0)
@@ -84,6 +94,26 @@ const App: Component = () => {
initReleaseNotifications()
})
const shouldHoldWakeLock = createMemo(() => {
const map = instances()
for (const id of map.keys()) {
const status = getInstanceSessionIndicatorStatus(id)
if (status !== "idle") {
return true
}
}
return false
})
createEffect(() => {
const hold = shouldHoldWakeLock()
void setWakeLockDesired(hold)
})
onCleanup(() => {
void setWakeLockDesired(false)
})
createEffect(() => {
instances()
hasInstances()
@@ -91,6 +121,7 @@ const App: Component = () => {
})
onMount(() => {
void initGithubStars()
updateInstanceTabBarHeight()
const handleResize = () => updateInstanceTabBarHeight()
window.addEventListener("resize", handleResize)
@@ -105,14 +136,30 @@ const App: Component = () => {
})
const launchErrorPath = () => {
const value = launchErrorBinary()
const value = launchError()?.binaryPath
if (!value) return "opencode"
return value.trim() || "opencode"
}
const isMissingBinaryError = (error: unknown): boolean => {
if (!error) return false
const message = typeof error === "string" ? error : error instanceof Error ? error.message : String(error)
const launchErrorMessage = () => launchError()?.message ?? ""
const formatLaunchErrorMessage = (error: unknown): string => {
if (!error) {
return t("app.launchError.fallbackMessage")
}
const raw = typeof error === "string" ? error : error instanceof Error ? error.message : String(error)
try {
const parsed = JSON.parse(raw)
if (parsed && typeof parsed.error === "string") {
return parsed.error
}
} catch {
// ignore JSON parse errors
}
return raw
}
const isMissingBinaryMessage = (message: string): boolean => {
const normalized = message.toLowerCase()
return (
normalized.includes("opencode binary not found") ||
@@ -123,7 +170,7 @@ const App: Component = () => {
)
}
const clearLaunchError = () => setLaunchErrorBinary(null)
const clearLaunchError = () => setLaunchError(null)
async function handleSelectFolder(folderPath: string, binaryPath?: string) {
if (!folderPath) {
@@ -135,7 +182,6 @@ const App: Component = () => {
recordWorkspaceLaunch(folderPath, selectedBinary)
clearLaunchError()
const instanceId = await createInstance(folderPath, selectedBinary)
setHasInstances(true)
setShowFolderSelection(false)
setIsAdvancedSettingsOpen(false)
@@ -144,10 +190,13 @@ const App: Component = () => {
port: instances().get(instanceId)?.port,
})
} catch (error) {
clearLaunchError()
if (isMissingBinaryError(error)) {
setLaunchErrorBinary(selectedBinary)
}
const message = formatLaunchErrorMessage(error)
const missingBinary = isMissingBinaryMessage(message)
setLaunchError({
message,
binaryPath: selectedBinary,
missingBinary,
})
log.error("Failed to create instance", error)
} finally {
setIsSelectingFolder(false)
@@ -179,21 +228,18 @@ const App: Component = () => {
async function handleCloseInstance(instanceId: string) {
const confirmed = await showConfirmDialog(
"Stop OpenCode instance? This will stop the server.",
t("app.stopInstance.confirmMessage"),
{
title: "Stop instance",
title: t("app.stopInstance.title"),
variant: "warning",
confirmLabel: "Stop",
cancelLabel: "Keep running",
confirmLabel: t("app.stopInstance.confirmLabel"),
cancelLabel: t("app.stopInstance.cancelLabel"),
},
)
if (!confirmed) return
await stopInstance(instanceId)
if (instances().size === 0) {
setHasInstances(false)
}
}
async function handleNewSession(instanceId: string) {
@@ -249,6 +295,7 @@ const App: Component = () => {
toggleShowThinkingBlocks,
toggleShowTimelineTools,
toggleUsageMetrics,
togglePromptSubmitOnEnter,
setDiffViewMode,
setToolOutputExpansion,
setDiagnosticsExpansion,
@@ -304,30 +351,42 @@ const App: Component = () => {
onClose={handleDisconnectedInstanceClose}
/>
<Dialog open={Boolean(launchErrorBinary())} modal>
<Dialog open={Boolean(launchError())} modal>
<Dialog.Portal>
<Dialog.Overlay class="modal-overlay" />
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<Dialog.Content class="modal-surface w-full max-w-md p-6 flex flex-col gap-6">
<div>
<Dialog.Title class="text-xl font-semibold text-primary">Unable to launch OpenCode</Dialog.Title>
<Dialog.Title class="text-xl font-semibold text-primary">{t("app.launchError.title")}</Dialog.Title>
<Dialog.Description class="text-sm text-secondary mt-2 break-words">
Install the OpenCode CLI and make sure it is available in your PATH, or pick a custom binary from
Advanced Settings.
{t("app.launchError.description")}
</Dialog.Description>
</div>
<div class="rounded-lg border border-base bg-surface-secondary p-4">
<p class="text-xs font-medium text-muted uppercase tracking-wide mb-1">Binary path</p>
<p class="text-xs font-medium text-muted uppercase tracking-wide mb-1">{t("app.launchError.binaryPathLabel")}</p>
<p class="text-sm font-mono text-primary break-all">{launchErrorPath()}</p>
</div>
<Show when={launchErrorMessage()}>
<div class="rounded-lg border border-base bg-surface-secondary p-4">
<p class="text-xs font-medium text-muted uppercase tracking-wide mb-1">{t("app.launchError.errorOutputLabel")}</p>
<pre class="text-sm font-mono text-primary whitespace-pre-wrap break-words max-h-48 overflow-y-auto">{launchErrorMessage()}</pre>
</div>
</Show>
<div class="flex justify-end gap-2">
<button type="button" class="selector-button selector-button-secondary" onClick={handleLaunchErrorAdvanced}>
Open Advanced Settings
</button>
<Show when={launchError()?.missingBinary}>
<button
type="button"
class="selector-button selector-button-secondary"
onClick={handleLaunchErrorAdvanced}
>
{t("app.launchError.openAdvancedSettings")}
</button>
</Show>
<button type="button" class="selector-button selector-button-primary" onClick={handleLaunchErrorClose}>
Close
{t("app.launchError.close")}
</button>
</div>
</Dialog.Content>
@@ -397,7 +456,7 @@ const App: Component = () => {
clearLaunchError()
}}
class="absolute top-4 right-4 z-10 p-2 bg-white dark:bg-gray-800 rounded-lg shadow-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
title="Close (Esc)"
title={t("app.launchError.closeTitle")}
>
<svg class="w-5 h-5 text-gray-600 dark:text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />

View File

@@ -2,6 +2,7 @@ import { Component } from "solid-js"
import { Dialog } from "@kobalte/core/dialog"
import OpenCodeBinarySelector from "./opencode-binary-selector"
import EnvironmentVariablesEditor from "./environment-variables-editor"
import { useI18n } from "../lib/i18n"
interface AdvancedSettingsModalProps {
open: boolean
@@ -12,6 +13,8 @@ interface AdvancedSettingsModalProps {
}
const AdvancedSettingsModal: Component<AdvancedSettingsModalProps> = (props) => {
const { t } = useI18n()
return (
<Dialog open={props.open} onOpenChange={(open) => !open && props.onClose()}>
<Dialog.Portal>
@@ -19,7 +22,7 @@ const AdvancedSettingsModal: Component<AdvancedSettingsModalProps> = (props) =>
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<Dialog.Content class="modal-surface w-full max-w-5xl max-h-[90vh] flex flex-col overflow-hidden">
<header class="px-6 py-4 border-b" style={{ "border-color": "var(--border-base)" }}>
<Dialog.Title class="text-xl font-semibold text-primary">Advanced Settings</Dialog.Title>
<Dialog.Title class="text-xl font-semibold text-primary">{t("advancedSettings.title")}</Dialog.Title>
</header>
<div class="flex-1 overflow-y-auto p-6 space-y-6">
@@ -32,8 +35,8 @@ const AdvancedSettingsModal: Component<AdvancedSettingsModalProps> = (props) =>
<div class="panel">
<div class="panel-header">
<h3 class="panel-title">Environment Variables</h3>
<p class="panel-subtitle">Applied whenever a new OpenCode instance starts</p>
<h3 class="panel-title">{t("advancedSettings.environmentVariables.title")}</h3>
<p class="panel-subtitle">{t("advancedSettings.environmentVariables.subtitle")}</p>
</div>
<div class="panel-body">
<EnvironmentVariablesEditor disabled={Boolean(props.isLoading)} />
@@ -47,7 +50,7 @@ const AdvancedSettingsModal: Component<AdvancedSettingsModalProps> = (props) =>
class="selector-button selector-button-secondary"
onClick={props.onClose}
>
Close
{t("advancedSettings.actions.close")}
</button>
</div>
</Dialog.Content>

View File

@@ -3,6 +3,7 @@ import { For, Show, createEffect, createMemo } from "solid-js"
import { agents, fetchAgents, sessions } from "../stores/sessions"
import { ChevronDown } from "lucide-solid"
import type { Agent } from "../types/session"
import { useI18n } from "../lib/i18n"
import { getLogger } from "../lib/logger"
const log = getLogger("session")
@@ -15,6 +16,7 @@ interface AgentSelectorProps {
}
export default function AgentSelector(props: AgentSelectorProps) {
const { t } = useI18n()
const instanceAgents = () => agents().get(props.instanceId) || []
const session = createMemo(() => {
@@ -71,7 +73,7 @@ export default function AgentSelector(props: AgentSelectorProps) {
options={availableAgents()}
optionValue="name"
optionTextValue="name"
placeholder="Select agent..."
placeholder={t("agentSelector.placeholder")}
itemComponent={(itemProps) => (
<Select.Item
item={itemProps.item}
@@ -81,7 +83,7 @@ export default function AgentSelector(props: AgentSelectorProps) {
<Select.ItemLabel class="selector-option-label flex items-center gap-2">
<span>{itemProps.item.rawValue.name}</span>
<Show when={itemProps.item.rawValue.mode === "subagent"}>
<span class="neutral-badge">subagent</span>
<span class="neutral-badge">{t("agentSelector.badge.subagent")}</span>
</Show>
</Select.ItemLabel>
<Show when={itemProps.item.rawValue.description}>
@@ -99,15 +101,17 @@ export default function AgentSelector(props: AgentSelectorProps) {
data-agent-selector
class="selector-trigger"
>
<Select.Value<Agent>>
{(state) => (
<div class="selector-trigger-label">
<span class="selector-trigger-primary">
Agent: {state.selectedOption()?.name ?? "None"}
</span>
</div>
)}
</Select.Value>
<div class="flex-1 min-w-0">
<Select.Value<Agent>>
{(state) => (
<div class="selector-trigger-label selector-trigger-label--stacked">
<span class="selector-trigger-primary selector-trigger-primary--align-left">
{t("agentSelector.trigger.primary", { agent: state.selectedOption()?.name ?? t("agentSelector.none") })}
</span>
</div>
)}
</Select.Value>
</div>
<Select.Icon class="selector-trigger-icon">
<ChevronDown class="w-3 h-3" />
</Select.Icon>

View File

@@ -1,34 +1,33 @@
import { Dialog } from "@kobalte/core/dialog"
import { Component, Show, createEffect } from "solid-js"
import { Component, Show, createEffect, createSignal } from "solid-js"
import { alertDialogState, dismissAlertDialog } from "../stores/alerts"
import type { AlertVariant, AlertDialogState } from "../stores/alerts"
import { useI18n } from "../lib/i18n"
const variantAccent: Record<AlertVariant, { badgeBg: string; badgeBorder: string; badgeText: string; symbol: string; fallbackTitle: string }> = {
const variantAccent: Record<AlertVariant, { badgeBg: string; badgeBorder: string; badgeText: string; symbol: string }> = {
info: {
badgeBg: "var(--badge-neutral-bg)",
badgeBorder: "var(--border-base)",
badgeText: "var(--accent-primary)",
symbol: "i",
fallbackTitle: "Heads up",
},
warning: {
badgeBg: "rgba(255, 152, 0, 0.14)",
badgeBorder: "var(--status-warning)",
badgeText: "var(--status-warning)",
symbol: "!",
fallbackTitle: "Please review",
},
error: {
badgeBg: "var(--danger-soft-bg)",
badgeBorder: "var(--status-error)",
badgeText: "var(--status-error)",
symbol: "!",
fallbackTitle: "Something went wrong",
},
}
function dismiss(confirmed: boolean, payload?: AlertDialogState | null) {
function dismiss(confirmed: boolean, payload?: AlertDialogState | null, promptValue?: string) {
const current = payload ?? alertDialogState()
if (current?.type === "confirm") {
if (confirmed) {
current.onConfirm?.()
@@ -36,21 +35,45 @@ function dismiss(confirmed: boolean, payload?: AlertDialogState | null) {
current.onCancel?.()
}
current.resolve?.(confirmed)
} else if (confirmed) {
dismissAlertDialog()
return
}
if (current?.type === "prompt") {
if (confirmed) {
current.onConfirm?.()
current.resolvePrompt?.(promptValue ?? "")
} else {
current.onCancel?.()
current.resolvePrompt?.(null)
}
dismissAlertDialog()
return
}
if (confirmed) {
current?.onConfirm?.()
}
dismissAlertDialog()
}
const AlertDialog: Component = () => {
const { t } = useI18n()
let primaryButtonRef: HTMLButtonElement | undefined
let promptInputRef: HTMLInputElement | undefined
createEffect(() => {
if (alertDialogState()) {
queueMicrotask(() => {
primaryButtonRef?.focus()
})
}
const state = alertDialogState()
if (!state) return
queueMicrotask(() => {
if (state.type === "prompt") {
promptInputRef?.focus()
promptInputRef?.select()
return
}
primaryButtonRef?.focus()
})
})
return (
@@ -58,10 +81,27 @@ const AlertDialog: Component = () => {
{(payload) => {
const variant = payload.variant ?? "info"
const accent = variantAccent[variant]
const title = payload.title || accent.fallbackTitle
const fallbackTitle =
variant === "warning"
? t("alertDialog.fallbackTitle.warning")
: variant === "error"
? t("alertDialog.fallbackTitle.error")
: t("alertDialog.fallbackTitle.info")
const title = payload.title || fallbackTitle
const isConfirm = payload.type === "confirm"
const confirmLabel = payload.confirmLabel || (isConfirm ? "Confirm" : "OK")
const cancelLabel = payload.cancelLabel || "Cancel"
const isPrompt = payload.type === "prompt"
const confirmLabel =
payload.confirmLabel ||
(isConfirm
? t("alertDialog.actions.confirm")
: isPrompt
? t("alertDialog.actions.run")
: t("alertDialog.actions.ok"))
const cancelLabel = payload.cancelLabel || t("alertDialog.actions.cancel")
const [inputValue, setInputValue] = createSignal(payload.inputDefaultValue ?? "")
return (
<Dialog
@@ -98,27 +138,53 @@ const AlertDialog: Component = () => {
</div>
</div>
<div class="mt-6 flex justify-end gap-3">
{isConfirm && (
<button
type="button"
class="button-secondary"
onClick={() => dismiss(false, payload)}
>
{cancelLabel}
</button>
)}
<button
type="button"
class="button-primary"
ref={(el) => {
primaryButtonRef = el
}}
onClick={() => dismiss(true, payload)}
>
{confirmLabel}
</button>
</div>
<Show when={isPrompt}>
<div class="mt-4">
<label class="text-sm font-medium text-secondary">
{payload.inputLabel || t("alertDialog.prompt.inputLabel")}
</label>
<input
ref={(el) => {
promptInputRef = el
}}
class="form-input mt-2"
value={inputValue()}
placeholder={payload.inputPlaceholder || ""}
autocapitalize="off"
autocorrect="off"
spellcheck={false}
onInput={(e) => setInputValue(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault()
dismiss(true, payload, inputValue())
}
}}
/>
</div>
</Show>
<div class="mt-6 flex justify-end gap-3">
{(isConfirm || isPrompt) && (
<button
type="button"
class="button-secondary"
onClick={() => dismiss(false, payload)}
>
{cancelLabel}
</button>
)}
<button
type="button"
class="button-primary"
ref={(el) => {
primaryButtonRef = el
}}
onClick={() => dismiss(true, payload, inputValue())}
>
{confirmLabel}
</button>
</div>
</Dialog.Content>
</div>
</Dialog.Portal>

View File

@@ -1,5 +1,6 @@
import { Component } from "solid-js"
import type { Attachment } from "../types/attachment"
import { useI18n } from "../lib/i18n"
interface AttachmentChipProps {
attachment: Attachment
@@ -7,6 +8,7 @@ interface AttachmentChipProps {
}
const AttachmentChip: Component<AttachmentChipProps> = (props) => {
const { t } = useI18n()
return (
<div
class="attachment-chip"
@@ -16,7 +18,7 @@ const AttachmentChip: Component<AttachmentChipProps> = (props) => {
<button
onClick={props.onRemove}
class="attachment-remove"
aria-label="Remove attachment"
aria-label={t("attachmentChip.removeAriaLabel")}
>
×
</button>

View File

@@ -0,0 +1,169 @@
import { Dialog } from "@kobalte/core/dialog"
import { Show, createEffect, createSignal, onCleanup } from "solid-js"
import type { BackgroundProcess } from "../../../server/src/api-types"
import { buildBackgroundProcessStreamUrl, serverApi } from "../lib/api-client"
import { createAnsiStreamRenderer, hasAnsi } from "../lib/ansi"
import { useI18n } from "../lib/i18n"
interface BackgroundProcessOutputDialogProps {
open: boolean
instanceId: string
process: BackgroundProcess | null
onClose: () => void
}
export function BackgroundProcessOutputDialog(props: BackgroundProcessOutputDialogProps) {
const { t } = useI18n()
const [output, setOutput] = createSignal("")
const [outputHtml, setOutputHtml] = createSignal("")
const [ansiEnabled, setAnsiEnabled] = createSignal(false)
const [truncated, setTruncated] = createSignal(false)
const [loading, setLoading] = createSignal(false)
let ansiRenderer = createAnsiStreamRenderer()
createEffect(() => {
const process = props.process
if (!props.open || !process) {
return
}
let eventSource: EventSource | null = null
let active = true
let rawOutput = ""
const setRawOutput = (next: string) => {
rawOutput = next
setOutput(next)
}
const appendRawOutput = (chunk: string) => {
rawOutput += chunk
setOutput(rawOutput)
}
setAnsiEnabled(false)
setOutputHtml("")
setRawOutput("")
ansiRenderer.reset()
setLoading(true)
serverApi
.fetchBackgroundProcessOutput(props.instanceId, process.id, { method: "full", maxBytes: undefined })
.then((response) => {
if (!active) return
setRawOutput(response.content)
setTruncated(response.truncated)
const detectedAnsi = hasAnsi(response.content)
if (detectedAnsi) {
setAnsiEnabled(true)
ansiRenderer.reset()
setOutputHtml(ansiRenderer.render(response.content))
} else {
setAnsiEnabled(false)
setOutputHtml("")
ansiRenderer.reset()
}
})
.catch(() => {
if (!active) return
setRawOutput(t("backgroundProcessOutputDialog.loadErrorFallback"))
setAnsiEnabled(false)
setOutputHtml("")
})
.finally(() => {
if (!active) return
setLoading(false)
})
eventSource = new EventSource(buildBackgroundProcessStreamUrl(props.instanceId, process.id), { withCredentials: true } as any)
eventSource.onmessage = (event) => {
try {
const payload = JSON.parse(event.data) as { type?: string; content?: string }
if (payload?.type !== "chunk" || typeof payload.content !== "string") {
return
}
const chunk = payload.content
const wasAnsiEnabled = ansiEnabled()
if (!wasAnsiEnabled) {
appendRawOutput(chunk)
if (hasAnsi(chunk)) {
setAnsiEnabled(true)
ansiRenderer.reset()
setOutputHtml(ansiRenderer.render(rawOutput))
}
return
}
appendRawOutput(chunk)
const htmlChunk = ansiRenderer.render(chunk)
setOutputHtml((prev) => `${prev}${htmlChunk}`)
} catch {
// ignore parse errors
}
}
onCleanup(() => {
active = false
eventSource?.close()
})
})
return (
<Dialog open={props.open} onOpenChange={(open) => !open && props.onClose()} modal>
<Dialog.Portal>
<Dialog.Overlay class="modal-overlay" />
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<Dialog.Content class="modal-surface w-full max-w-5xl max-h-[90vh] flex flex-col overflow-hidden">
<div class="flex items-start justify-between px-6 py-4 border-b border-base gap-4">
<div class="flex-1 min-w-0">
<Dialog.Title class="text-lg font-semibold text-primary">{t("backgroundProcessOutputDialog.title")}</Dialog.Title>
<Show when={props.process}>
<span class="text-xs text-secondary block">
{props.process?.title} · {props.process?.id}
</span>
<span class="text-xs text-secondary mt-1 block truncate" title={props.process?.command}>
{props.process?.command}
</span>
</Show>
</div>
<button type="button" class="button-tertiary flex-shrink-0" onClick={props.onClose}>
{t("backgroundProcessOutputDialog.actions.close")}
</button>
</div>
<div class="flex-1 overflow-auto p-6">
<Show when={loading()}>
<p class="text-xs text-secondary">{t("backgroundProcessOutputDialog.loading")}</p>
</Show>
<Show when={!loading()}>
<Show when={truncated()}>
<p class="text-xs text-secondary mb-2">{t("backgroundProcessOutputDialog.truncatedNotice")}</p>
</Show>
<Show
when={ansiEnabled()}
fallback={
<pre class="text-xs whitespace-pre-wrap break-all text-primary bg-surface-secondary border border-base rounded-md p-4 font-mono">
{output()}
</pre>
}
>
<pre
class="text-xs whitespace-pre-wrap break-all text-primary bg-surface-secondary border border-base rounded-md p-4 font-mono"
innerHTML={outputHtml()}
/>
</Show>
</Show>
</div>
</Dialog.Content>
</div>
</Dialog.Portal>
</Dialog>
)
}

View File

@@ -0,0 +1,38 @@
import type { Component } from "solid-js"
type BrandIconProps = {
class?: string
title?: string
}
export const GitHubMarkIcon: Component<BrandIconProps> = (props) => (
<svg
viewBox="0 0 98 96"
xmlns="http://www.w3.org/2000/svg"
aria-hidden={props.title ? undefined : "true"}
role={props.title ? "img" : "presentation"}
class={props.class}
>
{props.title ? <title>{props.title}</title> : null}
<path
fill="currentColor"
d="M41.4395 69.3848C28.8066 67.8535 19.9062 58.7617 19.9062 46.9902C19.9062 42.2051 21.6289 37.0371 24.5 33.5918C23.2559 30.4336 23.4473 23.7344 24.8828 20.959C28.7109 20.4805 33.8789 22.4902 36.9414 25.2656C40.5781 24.1172 44.4062 23.543 49.0957 23.543C53.7852 23.543 57.6133 24.1172 61.0586 25.1699C64.0254 22.4902 69.2891 20.4805 73.1172 20.959C74.457 23.543 74.6484 30.2422 73.4043 33.4961C76.4668 37.1328 78.0937 42.0137 78.0937 46.9902C78.0937 58.7617 69.1934 67.6621 56.3691 69.2891C59.623 71.3945 61.8242 75.9883 61.8242 81.252L61.8242 91.2051C61.8242 94.0762 64.2168 95.7031 67.0879 94.5547C84.4102 87.9512 98 70.6289 98 49.1914C98 22.1074 75.9883 0 48.9043 0C21.8203 0 0 22.1074 0 49.1914C0 70.4375 13.4941 88.0469 31.6777 94.6504C34.2617 95.6074 36.75 93.8848 36.75 91.3008L36.75 83.6445C35.4102 84.2188 33.6875 84.6016 32.1562 84.6016C25.8398 84.6016 22.1074 81.1563 19.4277 74.7441C18.375 72.1602 17.2266 70.6289 15.0254 70.3418C13.877 70.2461 13.4941 69.7676 13.4941 69.1934C13.4941 68.0449 15.4082 67.1836 17.3223 67.1836C20.0977 67.1836 22.4902 68.9063 24.9785 72.4473C26.8926 75.2227 28.9023 76.4668 31.2949 76.4668C33.6875 76.4668 35.2187 75.6055 37.4199 73.4043C39.0469 71.7773 40.291 70.3418 41.4395 69.3848Z"
/>
</svg>
)
export const DiscordSymbolIcon: Component<BrandIconProps> = (props) => (
<svg
viewBox="0 0 64 48"
xmlns="http://www.w3.org/2000/svg"
aria-hidden={props.title ? undefined : "true"}
role={props.title ? "img" : "presentation"}
class={props.class}
>
{props.title ? <title>{props.title}</title> : null}
<path
fill="currentColor"
d="M40.575 0C39.9562 1.09866 39.4006 2.2352 38.8954 3.397C34.0967 2.67719 29.2096 2.67719 24.3982 3.397C23.9057 2.2352 23.3374 1.09866 22.7186 0C18.2104 0.770324 13.8157 2.12155 9.64839 4.02841C1.38951 16.2652 -0.845688 28.1863 0.265599 39.9432C5.10222 43.517 10.5197 46.2447 16.2909 47.9874C17.5916 46.2447 18.7407 44.3883 19.7257 42.4562C17.8568 41.7616 16.0509 40.8903 14.3208 39.88C14.7755 39.5517 15.2175 39.2107 15.6468 38.8824C25.7873 43.6559 37.5316 43.6559 47.6847 38.8824C48.1141 39.236 48.5561 39.577 49.0107 39.88C47.2806 40.9029 45.4748 41.7616 43.5931 42.4688C44.5781 44.4009 45.7273 46.2573 47.028 48C52.7991 46.2573 58.2167 43.5422 63.0533 39.9684C64.3666 26.3299 60.8055 14.5099 53.6452 4.04104C49.4905 2.13418 45.0959 0.782952 40.5876 0.0252565L40.575 0ZM21.1401 32.7072C18.0209 32.7072 15.4321 29.8785 15.4321 26.3804C15.4321 22.8824 17.9199 20.041 21.1275 20.041C24.3351 20.041 26.886 22.895 26.8354 26.3804C26.7849 29.8658 24.3224 32.7072 21.1401 32.7072ZM42.1788 32.7072C39.047 32.7072 36.4834 29.8785 36.4834 26.3804C36.4834 22.8824 38.9712 20.041 42.1788 20.041C45.3864 20.041 47.9246 22.895 47.8741 26.3804C47.8236 29.8658 45.3611 32.7072 42.1788 32.7072Z"
/>
</svg>
)

View File

@@ -2,6 +2,8 @@ import { createSignal, onMount, Show, createEffect } from "solid-js"
import type { Highlighter } from "shiki/bundle/full"
import { useTheme } from "../lib/theme"
import { getSharedHighlighter, escapeHtml } from "../lib/markdown"
import { copyToClipboard } from "../lib/clipboard"
import { useI18n } from "../lib/i18n"
const inlineLoadedLanguages = new Set<string>()
@@ -14,6 +16,7 @@ interface CodeBlockInlineProps {
}
export function CodeBlockInline(props: CodeBlockInlineProps) {
const { t } = useI18n()
const { isDark } = useTheme()
const [html, setHtml] = createSignal("")
const [copied, setCopied] = createSignal(false)
@@ -52,7 +55,7 @@ export function CodeBlockInline(props: CodeBlockInlineProps) {
const highlighted = highlighter.codeToHtml(props.code, {
lang: props.language as CodeToHtmlOptions["lang"],
theme: isDark() ? "github-dark" : "github-light",
theme: isDark() ? "github-dark" : "github-light-high-contrast",
})
setHtml(highlighted)
} catch {
@@ -61,9 +64,11 @@ export function CodeBlockInline(props: CodeBlockInlineProps) {
}
const copyCode = async () => {
await navigator.clipboard.writeText(props.code)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
const success = await copyToClipboard(props.code)
if (success) {
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
}
return (
@@ -94,8 +99,8 @@ export function CodeBlockInline(props: CodeBlockInlineProps) {
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg>
<span class="copy-text">
<Show when={copied()} fallback="Copy">
Copied!
<Show when={copied()} fallback={t("codeBlockInline.actions.copy")}>
{t("codeBlockInline.actions.copied")}
</Show>
</span>
</button>

View File

@@ -1,7 +1,8 @@
import { Component, createSignal, For, Show, createEffect, createMemo } from "solid-js"
import { Dialog } from "@kobalte/core/dialog"
import type { Command } from "../lib/commands"
import { resolveResolvable, type Command } from "../lib/commands"
import Kbd from "./kbd"
import { useI18n } from "../lib/i18n"
interface CommandPaletteProps {
open: boolean
@@ -24,6 +25,7 @@ function buildShortcutString(shortcut: Command["shortcut"]): string {
}
const CommandPalette: Component<CommandPaletteProps> = (props) => {
const { t } = useI18n()
const [query, setQuery] = createSignal("")
const [selectedCommandId, setSelectedCommandId] = createSignal<string | null>(null)
const [isPointerSelecting, setIsPointerSelecting] = createSignal(false)
@@ -32,6 +34,27 @@ const CommandPalette: Component<CommandPaletteProps> = (props) => {
const categoryOrder = ["Custom Commands", "Instance", "Session", "Agent & Model", "Input & Focus", "System", "Other"] as const
const categoryLabel = (category: string) => {
switch (category) {
case "Custom Commands":
return t("commandPalette.category.customCommands")
case "Instance":
return t("commandPalette.category.instance")
case "Session":
return t("commandPalette.category.session")
case "Agent & Model":
return t("commandPalette.category.agentModel")
case "Input & Focus":
return t("commandPalette.category.inputFocus")
case "System":
return t("commandPalette.category.system")
case "Other":
return t("commandPalette.category.other")
default:
return category
}
}
type CommandGroup = { category: string; commands: Command[]; startIndex: number }
type ProcessedCommands = { groups: CommandGroup[]; ordered: Command[] }
@@ -41,18 +64,21 @@ const CommandPalette: Component<CommandPaletteProps> = (props) => {
const filtered = q
? source.filter((cmd) => {
const label = typeof cmd.label === "function" ? cmd.label() : cmd.label
const label = resolveResolvable(cmd.label)
const description = resolveResolvable(cmd.description)
const keywords = cmd.keywords ? resolveResolvable(cmd.keywords) : undefined
const category = cmd.category ? resolveResolvable(cmd.category) : undefined
const labelMatch = label.toLowerCase().includes(q)
const descMatch = cmd.description.toLowerCase().includes(q)
const keywordMatch = cmd.keywords?.some((k) => k.toLowerCase().includes(q))
const categoryMatch = cmd.category?.toLowerCase().includes(q)
const descMatch = description.toLowerCase().includes(q)
const keywordMatch = keywords?.some((k) => k.toLowerCase().includes(q))
const categoryMatch = category?.toLowerCase().includes(q)
return labelMatch || descMatch || keywordMatch || categoryMatch
})
: source
const groupsMap = new Map<string, Command[]>()
for (const cmd of filtered) {
const category = cmd.category || "Other"
const category = (cmd.category ? resolveResolvable(cmd.category) : undefined) || "Other"
const list = groupsMap.get(category)
if (list) {
list.push(cmd)
@@ -189,12 +215,12 @@ const CommandPalette: Component<CommandPaletteProps> = (props) => {
<Dialog.Portal>
<Dialog.Overlay class="modal-overlay" />
<div class="fixed inset-0 z-50 flex items-start justify-center pt-[20vh]">
<Dialog.Content
class="modal-surface w-full max-w-2xl max-h-[60vh]"
onKeyDown={handleKeyDown}
>
<Dialog.Title class="sr-only">Command Palette</Dialog.Title>
<Dialog.Description class="sr-only">Search and execute commands</Dialog.Description>
<Dialog.Content
class="modal-surface w-full max-w-2xl max-h-[60vh]"
onKeyDown={handleKeyDown}
>
<Dialog.Title class="sr-only">{t("commandPalette.title")}</Dialog.Title>
<Dialog.Description class="sr-only">{t("commandPalette.description")}</Dialog.Description>
<div class="modal-search-container">
<div class="flex items-center gap-3">
@@ -214,7 +240,7 @@ const CommandPalette: Component<CommandPaletteProps> = (props) => {
setQuery(e.currentTarget.value)
setSelectedCommandId(null)
}}
placeholder="Type a command or search..."
placeholder={t("commandPalette.searchPlaceholder")}
class="modal-search-input"
/>
</div>
@@ -228,13 +254,13 @@ const CommandPalette: Component<CommandPaletteProps> = (props) => {
>
<Show
when={orderedCommands().length > 0}
fallback={<div class="modal-empty-state">No commands found for "{query()}"</div>}
fallback={<div class="modal-empty-state">{t("commandPalette.empty", { query: query() })}</div>}
>
<For each={groupedCommandList()}>
{(group) => (
<div class="py-2">
<div class="modal-section-header">
{group.category}
{categoryLabel(group.category)}
</div>
<For each={group.commands}>
{(command, localIndex) => {
@@ -257,10 +283,10 @@ const CommandPalette: Component<CommandPaletteProps> = (props) => {
>
<div class="flex-1 min-w-0">
<div class="modal-item-label">
{typeof command.label === "function" ? command.label() : command.label}
{resolveResolvable(command.label)}
</div>
<div class="modal-item-description">
{command.description}
{resolveResolvable(command.description)}
</div>
</div>
<Show when={command.shortcut}>

View File

@@ -1,8 +1,10 @@
import { Component, Show, For, createSignal, createMemo, createEffect, onCleanup } from "solid-js"
import { ArrowUpLeft, Folder as FolderIcon, Loader2, X } from "lucide-solid"
import { ArrowUpLeft, Folder as FolderIcon, FolderPlus, Loader2, X } from "lucide-solid"
import type { FileSystemEntry, FileSystemListingMetadata } from "../../../server/src/api-types"
import { WINDOWS_DRIVES_ROOT } from "../../../server/src/api-types"
import { serverApi } from "../lib/api-client"
import { showAlertDialog, showPromptDialog } from "../stores/alerts"
import { useI18n } from "../lib/i18n"
function normalizePathKey(input?: string | null) {
if (!input || input === "." || input === "./") {
@@ -61,9 +63,11 @@ type FolderRow =
| { type: "folder"; entry: FileSystemEntry }
const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) => {
const { t } = useI18n()
const [rootPath, setRootPath] = createSignal("")
const [loading, setLoading] = createSignal(false)
const [error, setError] = createSignal<string | null>(null)
const [creatingFolder, setCreatingFolder] = createSignal(false)
const [directoryChildren, setDirectoryChildren] = createSignal<Map<string, FileSystemEntry[]>>(new Map())
const [loadingPaths, setLoadingPaths] = createSignal<Set<string>>(new Set())
const [currentPathKey, setCurrentPathKey] = createSignal<string | null>(null)
@@ -108,7 +112,7 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
const metadata = await loadDirectory()
applyMetadata(metadata)
} catch (err) {
const message = err instanceof Error ? err.message : "Unable to load filesystem"
const message = err instanceof Error ? err.message : t("directoryBrowser.load.errorFallback")
setError(message)
} finally {
setLoading(false)
@@ -198,7 +202,7 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
const metadata = await loadDirectory(path)
applyMetadata(metadata)
} catch (err) {
const message = err instanceof Error ? err.message : "Unable to load filesystem"
const message = err instanceof Error ? err.message : t("directoryBrowser.load.errorFallback")
setError(message)
}
}
@@ -256,6 +260,52 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
props.onSelect(absolutePath)
}
async function handleCreateFolder() {
if (creatingFolder()) return
const metadata = currentMetadata()
if (!metadata || metadata.pathKind === "drives") {
return
}
const name =
(await showPromptDialog(t("directoryBrowser.createFolder.promptMessage"), {
title: t("directoryBrowser.createFolder.title"),
inputLabel: t("directoryBrowser.createFolder.inputLabel"),
inputPlaceholder: t("directoryBrowser.createFolder.inputPlaceholder"),
confirmLabel: t("directoryBrowser.createFolder.confirmLabel"),
cancelLabel: t("directoryBrowser.createFolder.cancelLabel"),
}))?.trim() ?? ""
if (!name) return
if (name === "." || name === ".." || name.startsWith("~") || name.includes("/") || name.includes("\\")) {
showAlertDialog(t("directoryBrowser.createFolder.invalidNameMessage"), {
variant: "warning",
detail: t("directoryBrowser.createFolder.invalidNameDetail"),
})
return
}
setCreatingFolder(true)
try {
const parentKey = normalizePathKey(metadata.currentPath)
metadataCache.delete(parentKey)
inFlightRequests.delete(parentKey)
setDirectoryChildren((prev) => {
const next = new Map(prev)
next.delete(parentKey)
return next
})
const created = await serverApi.createFileSystemFolder(metadata.currentPath, name)
await navigateTo(created.path)
} catch (err) {
const message = err instanceof Error ? err.message : t("directoryBrowser.createFolder.errorFallback")
showAlertDialog(message, { variant: "error", title: t("directoryBrowser.createFolder.errorFallback") })
} finally {
setCreatingFolder(false)
}
}
function isPathLoading(path: string) {
return loadingPaths().has(normalizePathKey(path))
}
@@ -275,10 +325,10 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
<div class="directory-browser-heading">
<h3 class="directory-browser-title">{props.title}</h3>
<p class="directory-browser-description">
{props.description || "Browse folders under the configured workspace root."}
{props.description || t("directoryBrowser.defaultDescription")}
</p>
</div>
<button type="button" class="directory-browser-close" aria-label="Close" onClick={props.onClose}>
<button type="button" class="directory-browser-close" aria-label={t("directoryBrowser.close")} onClick={props.onClose}>
<X class="w-5 h-5" />
</button>
</div>
@@ -287,22 +337,35 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
<Show when={rootPath()}>
<div class="directory-browser-current">
<div class="directory-browser-current-meta">
<span class="directory-browser-current-label">Current folder</span>
<span class="directory-browser-current-label">{t("directoryBrowser.currentFolder")}</span>
<span class="directory-browser-current-path">{currentAbsolutePath()}</span>
</div>
<button
type="button"
class="selector-button selector-button-secondary directory-browser-select directory-browser-current-select"
disabled={!canSelectCurrent()}
onClick={() => {
const absolute = currentAbsolutePath()
if (absolute) {
props.onSelect(absolute)
}
}}
>
Select Current
</button>
<div class="directory-browser-current-actions">
<button
type="button"
class="selector-button selector-button-secondary directory-browser-select directory-browser-current-select"
disabled={!canSelectCurrent() || creatingFolder()}
onClick={() => {
const absolute = currentAbsolutePath()
if (absolute) {
props.onSelect(absolute)
}
}}
>
{t("directoryBrowser.selectCurrent")}
</button>
<button
type="button"
class="selector-button selector-button-secondary directory-browser-select"
disabled={!canSelectCurrent() || creatingFolder()}
onClick={() => void handleCreateFolder()}
>
<span class="inline-flex items-center gap-2">
<FolderPlus class="w-4 h-4" />
{creatingFolder() ? t("directoryBrowser.creating") : t("directoryBrowser.newFolder")}
</span>
</button>
</div>
</div>
</Show>
<Show
@@ -312,7 +375,7 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
<Show when={loading()} fallback={<span class="text-red-500">{error()}</span>}>
<div class="directory-browser-loading">
<Loader2 class="w-5 h-5 animate-spin" />
<span>Loading folders</span>
<span>{t("directoryBrowser.loadingFolders")}</span>
</div>
</Show>
</div>
@@ -320,13 +383,13 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
>
<Show
when={folderRows().length > 0}
fallback={<div class="panel-empty-state flex-1">No folders available.</div>}
fallback={<div class="panel-empty-state flex-1">{t("directoryBrowser.noFolders")}</div>}
>
<div class="panel-list panel-list--fill flex-1 min-h-0 overflow-auto directory-browser-list" role="listbox">
<For each={folderRows()}>
{(item) => {
const isFolder = item.type === "folder"
const label = isFolder ? item.entry.name || item.entry.path : "Up one level"
const label = isFolder ? item.entry.name || item.entry.path : t("directoryBrowser.upOneLevel")
const navigate = () => (isFolder ? handleNavigateTo(item.entry.path) : handleNavigateUp())
return (
<div class="panel-list-item" role="option">
@@ -353,7 +416,7 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
handleEntrySelect(item.entry)
}}
>
Select
{t("directoryBrowser.select")}
</button>
) : null}
</div>

View File

@@ -1,5 +1,6 @@
import { Component } from "solid-js"
import { Loader2 } from "lucide-solid"
import { useI18n } from "../lib/i18n"
const codeNomadIcon = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
@@ -9,15 +10,19 @@ interface EmptyStateProps {
}
const EmptyState: Component<EmptyStateProps> = (props) => {
const { t } = useI18n()
const modifier = typeof navigator !== "undefined" && navigator.platform.includes("Mac") ? "Cmd" : "Ctrl"
const shortcut = `${modifier}+N`
return (
<div class="flex h-full w-full items-center justify-center bg-surface-secondary">
<div class="max-w-[500px] px-8 py-12 text-center">
<div class="mb-8 flex justify-center">
<img src={codeNomadIcon} alt="CodeNomad logo" class="h-24 w-auto" loading="lazy" />
<img src={codeNomadIcon} alt={t("emptyState.logoAlt")} class="h-24 w-auto" loading="lazy" />
</div>
<h1 class="mb-3 text-3xl font-semibold text-primary">CodeNomad</h1>
<p class="mb-8 text-base text-secondary">Select a folder to start coding with AI</p>
<h1 class="mb-3 text-3xl font-semibold text-primary">{t("emptyState.brandTitle")}</h1>
<p class="mb-8 text-base text-secondary">{t("emptyState.tagline")}</p>
<button
@@ -28,20 +33,20 @@ const EmptyState: Component<EmptyStateProps> = (props) => {
{props.isLoading ? (
<>
<Loader2 class="h-4 w-4 animate-spin" />
Selecting...
{t("emptyState.actions.selecting")}
</>
) : (
"Select Folder"
t("emptyState.actions.selectFolder")
)}
</button>
<p class="text-sm text-muted">
Keyboard shortcut: {navigator.platform.includes("Mac") ? "Cmd" : "Ctrl"}+N
{t("emptyState.keyboardShortcut", { shortcut })}
</p>
<div class="mt-6 space-y-1 text-sm text-muted">
<p>Examples: ~/projects/my-app</p>
<p>You can have multiple instances of the same folder</p>
<p>{t("emptyState.examples", { example: "~/projects/my-app" })}</p>
<p>{t("emptyState.multipleInstances")}</p>
</div>
</div>
</div>

View File

@@ -1,12 +1,14 @@
import { Component, createSignal, For, Show } from "solid-js"
import { Plus, Trash2, Key, Globe } from "lucide-solid"
import { useConfig } from "../stores/preferences"
import { useI18n } from "../lib/i18n"
interface EnvironmentVariablesEditorProps {
disabled?: boolean
}
const EnvironmentVariablesEditor: Component<EnvironmentVariablesEditorProps> = (props) => {
const { t } = useI18n()
const {
preferences,
addEnvironmentVariable,
@@ -54,9 +56,11 @@ const EnvironmentVariablesEditor: Component<EnvironmentVariablesEditorProps> = (
<div class="space-y-3">
<div class="flex items-center gap-2 mb-3">
<Globe class="w-4 h-4 icon-muted" />
<span class="text-sm font-medium text-secondary">Environment Variables</span>
<span class="text-sm font-medium text-secondary">{t("envEditor.title")}</span>
<span class="text-xs text-muted">
({entries().length} variable{entries().length !== 1 ? "s" : ""})
{entries().length === 1
? t("envEditor.count.one", { count: entries().length })
: t("envEditor.count.other", { count: entries().length })}
</span>
</div>
@@ -73,8 +77,8 @@ const EnvironmentVariablesEditor: Component<EnvironmentVariablesEditorProps> = (
value={key}
disabled={props.disabled}
class="flex-1 px-2.5 py-1.5 text-sm bg-surface-secondary border border-base rounded text-muted cursor-not-allowed"
placeholder="Variable name"
title="Variable name (read-only)"
placeholder={t("envEditor.fields.name.placeholder")}
title={t("envEditor.fields.name.readOnlyTitle")}
/>
<input
type="text"
@@ -82,14 +86,14 @@ const EnvironmentVariablesEditor: Component<EnvironmentVariablesEditorProps> = (
disabled={props.disabled}
onInput={(e) => handleUpdateVariable(key, e.currentTarget.value)}
class="flex-1 px-2.5 py-1.5 text-sm bg-surface-base border border-base rounded text-primary focus-ring-accent disabled:opacity-50 disabled:cursor-not-allowed"
placeholder="Variable value"
placeholder={t("envEditor.fields.value.placeholder")}
/>
</div>
<button
onClick={() => handleRemoveVariable(key)}
disabled={props.disabled}
class="p-1.5 icon-muted icon-danger-hover disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
title="Remove variable"
title={t("envEditor.actions.remove.title")}
>
<Trash2 class="w-3.5 h-3.5" />
</button>
@@ -110,7 +114,7 @@ const EnvironmentVariablesEditor: Component<EnvironmentVariablesEditorProps> = (
onKeyPress={handleKeyPress}
disabled={props.disabled}
class="flex-1 px-2.5 py-1.5 text-sm bg-surface-base border border-base rounded text-primary focus-ring-accent disabled:opacity-50 disabled:cursor-not-allowed"
placeholder="Variable name"
placeholder={t("envEditor.fields.name.placeholder")}
/>
<input
type="text"
@@ -119,14 +123,14 @@ const EnvironmentVariablesEditor: Component<EnvironmentVariablesEditorProps> = (
onKeyPress={handleKeyPress}
disabled={props.disabled}
class="flex-1 px-2.5 py-1.5 text-sm bg-surface-base border border-base rounded text-primary focus-ring-accent disabled:opacity-50 disabled:cursor-not-allowed"
placeholder="Variable value"
placeholder={t("envEditor.fields.value.placeholder")}
/>
</div>
<button
onClick={handleAddVariable}
disabled={props.disabled || !newKey().trim()}
class="p-1.5 icon-muted icon-accent-hover disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
title="Add variable"
title={t("envEditor.actions.add.title")}
>
<Plus class="w-3.5 h-3.5" />
</button>
@@ -134,12 +138,12 @@ const EnvironmentVariablesEditor: Component<EnvironmentVariablesEditorProps> = (
<Show when={entries().length === 0}>
<div class="text-xs text-muted text-center py-2">
No environment variables configured. Add variables above to customize the OpenCode environment.
{t("envEditor.empty")}
</div>
</Show>
<div class="text-xs text-muted mt-2">
These variables will be available in the OpenCode environment when starting instances.
{t("envEditor.help")}
</div>
</div>
)

View File

@@ -0,0 +1,33 @@
import { Show } from "solid-js"
import { Maximize2, Minimize2 } from "lucide-solid"
import { useI18n } from "../lib/i18n"
interface ExpandButtonProps {
expandState: () => "normal" | "expanded"
onToggleExpand: (nextState: "normal" | "expanded") => void
}
export default function ExpandButton(props: ExpandButtonProps) {
const { t } = useI18n()
function handleClick() {
const current = props.expandState()
props.onToggleExpand(current === "normal" ? "expanded" : "normal")
}
return (
<button
type="button"
class="prompt-expand-button"
onClick={handleClick}
aria-label={t("expandButton.toggleAriaLabel")}
>
<Show
when={props.expandState() === "normal"}
fallback={<Minimize2 class="h-4 w-4" aria-hidden="true" />}
>
<Maximize2 class="h-4 w-4" aria-hidden="true" />
</Show>
</button>
)
}

View File

@@ -3,6 +3,7 @@ import { Folder as FolderIcon, File as FileIcon, Loader2, Search, X, ArrowUpLeft
import type { FileSystemEntry, FileSystemListingMetadata } from "../../../server/src/api-types"
import { serverApi } from "../lib/api-client"
import { getLogger } from "../lib/logger"
import { useI18n } from "../lib/i18n"
const log = getLogger("actions")
@@ -49,6 +50,7 @@ interface FileSystemBrowserDialogProps {
type FolderRow = { type: "up"; path: string } | { type: "entry"; entry: FileSystemEntry }
const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props) => {
const { t } = useI18n()
const [rootPath, setRootPath] = createSignal("")
const [entries, setEntries] = createSignal<FileSystemEntry[]>([])
const [currentMetadata, setCurrentMetadata] = createSignal<FileSystemListingMetadata | null>(null)
@@ -135,7 +137,7 @@ const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props)
setRootPath(metadata.rootPath)
setEntries(directoryCache.get(normalizeEntryPath(metadata.currentPath)) ?? [])
} catch (err) {
const message = err instanceof Error ? err.message : "Unable to load filesystem"
const message = err instanceof Error ? err.message : t("filesystemBrowser.errors.loadFilesystemFallback")
setError(message)
}
}
@@ -143,10 +145,10 @@ const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props)
function describeLoadingPath() {
const path = loadingPath()
if (!path) {
return "filesystem"
return t("filesystemBrowser.loading.filesystem")
}
if (path === ".") {
return rootPath() || "workspace root"
return rootPath() || t("filesystemBrowser.loading.workspaceRoot")
}
return resolveAbsolutePath(rootPath(), path)
}
@@ -176,7 +178,7 @@ const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props)
function handleNavigateTo(path: string) {
void fetchDirectory(path, true).catch((err) => {
log.error("Failed to open directory", err)
setError(err instanceof Error ? err.message : "Unable to open directory")
setError(err instanceof Error ? err.message : t("filesystemBrowser.errors.openDirectoryFallback"))
})
}
@@ -277,19 +279,21 @@ const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props)
<div class="panel-header flex items-start justify-between gap-4">
<div>
<h3 class="panel-title">{props.title}</h3>
<p class="panel-subtitle">{props.description || "Search for a path under the configured workspace root."}</p>
<p class="panel-subtitle">{props.description || t("filesystemBrowser.descriptionFallback")}</p>
<Show when={rootPath()}>
<p class="text-xs text-muted mt-1 font-mono break-all">Root: {rootPath()}</p>
<p class="text-xs text-muted mt-1 font-mono break-all">
{t("filesystemBrowser.rootLabel", { root: rootPath() })}
</p>
</Show>
</div>
<button type="button" class="selector-button selector-button-secondary" onClick={props.onClose}>
<X class="w-4 h-4" />
Close
{t("filesystemBrowser.actions.close")}
</button>
</div>
<div class="panel-body">
<label class="w-full text-sm text-secondary mb-2 block">Filter</label>
<label class="w-full text-sm text-secondary mb-2 block">{t("filesystemBrowser.filterLabel")}</label>
<div class="selector-input-group">
<div class="flex items-center gap-2 px-3 text-muted">
<Search class="w-4 h-4" />
@@ -301,7 +305,11 @@ const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props)
type="text"
value={searchQuery()}
onInput={(event) => setSearchQuery(event.currentTarget.value)}
placeholder={props.mode === "directories" ? "Search for folders" : "Search for files"}
placeholder={
props.mode === "directories"
? t("filesystemBrowser.search.placeholder.directories")
: t("filesystemBrowser.search.placeholder.files")
}
class="selector-input"
/>
</div>
@@ -311,7 +319,7 @@ const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props)
<div class="px-4 pb-2">
<div class="flex items-center justify-between gap-3 rounded-md border border-border-subtle px-4 py-3">
<div>
<p class="text-xs text-secondary uppercase tracking-wide">Current folder</p>
<p class="text-xs text-secondary uppercase tracking-wide">{t("filesystemBrowser.currentFolder.label")}</p>
<p class="text-sm font-mono text-primary break-all">{currentAbsolutePath()}</p>
</div>
<button
@@ -319,7 +327,7 @@ const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props)
class="selector-button selector-button-secondary whitespace-nowrap"
onClick={() => props.onSelect(currentAbsolutePath())}
>
Select Current
{t("filesystemBrowser.currentFolder.selectCurrent")}
</button>
</div>
</div>
@@ -336,7 +344,7 @@ const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props)
>
<div class="flex items-center gap-2">
<Loader2 class="w-4 h-4 animate-spin" />
<span>Loading {describeLoadingPath()}</span>
<span>{t("filesystemBrowser.loading.loadingWithPath", { path: describeLoadingPath() })}</span>
</div>
</Show>
</div>
@@ -345,16 +353,16 @@ const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props)
<Show when={loadingPath()}>
<div class="flex items-center gap-2 px-4 py-2 text-xs text-secondary">
<Loader2 class="w-3.5 h-3.5 animate-spin" />
<span>Loading {describeLoadingPath()}</span>
<span>{t("filesystemBrowser.loading.loadingWithPath", { path: describeLoadingPath() })}</span>
</div>
</Show>
<Show
when={folderRows().length > 0}
fallback={
<div class="flex flex-col items-center justify-center gap-2 py-10 text-sm text-secondary">
<p>No entries found.</p>
<p>{t("filesystemBrowser.empty.noEntries")}</p>
<button type="button" class="selector-button selector-button-secondary" onClick={refreshEntries}>
Retry
{t("filesystemBrowser.actions.retry")}
</button>
</div>
}
@@ -370,7 +378,7 @@ const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props)
<ArrowUpLeft class="w-4 h-4" />
</div>
<div class="directory-browser-row-text">
<span class="directory-browser-row-name">Up one level</span>
<span class="directory-browser-row-name">{t("filesystemBrowser.navigation.upOneLevel")}</span>
</div>
</button>
</div>
@@ -412,7 +420,7 @@ const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props)
selectEntry()
}}
>
Select
{t("filesystemBrowser.actions.select")}
</button>
</div>
</div>
@@ -428,15 +436,15 @@ const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props)
<div class="flex items-center gap-1.5">
<kbd class="kbd"></kbd>
<kbd class="kbd"></kbd>
<span>Navigate</span>
<span>{t("filesystemBrowser.hints.navigate")}</span>
</div>
<div class="flex items-center gap-1.5">
<kbd class="kbd">Enter</kbd>
<span>Select</span>
<span>{t("filesystemBrowser.hints.select")}</span>
</div>
<div class="flex items-center gap-1.5">
<kbd class="kbd">Esc</kbd>
<span>Close</span>
<span>{t("filesystemBrowser.hints.close")}</span>
</div>
</div>
</div>
@@ -448,4 +456,3 @@ const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props)
}
export default FileSystemBrowserDialog

View File

@@ -1,10 +1,17 @@
import { Select } from "@kobalte/core/select"
import { Component, createSignal, Show, For, onMount, onCleanup, createEffect } from "solid-js"
import { Folder, Clock, Trash2, FolderPlus, Settings, ChevronRight, MonitorUp } from "lucide-solid"
import { Folder, Clock, Trash2, FolderPlus, Settings, ChevronRight, MonitorUp, Star, Languages, ChevronDown } from "lucide-solid"
import { useConfig } from "../stores/preferences"
import AdvancedSettingsModal from "./advanced-settings-modal"
import DirectoryBrowserDialog from "./directory-browser-dialog"
import Kbd from "./kbd"
import { ThemeModeToggle } from "./theme-mode-toggle"
import { openNativeFolderDialog, supportsNativeDialogs } from "../lib/native/native-functions"
import VersionPill from "./version-pill"
import { DiscordSymbolIcon, GitHubMarkIcon } from "./brand-icons"
import { githubStars } from "../stores/github-stars"
import { formatCompactCount } from "../lib/formatters"
import { useI18n, type Locale } from "../lib/i18n"
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
@@ -19,13 +26,27 @@ interface FolderSelectionViewProps {
}
const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
const { recentFolders, removeRecentFolder, preferences } = useConfig()
const { recentFolders, removeRecentFolder, preferences, updatePreferences } = useConfig()
const { t, locale } = useI18n()
const [selectedIndex, setSelectedIndex] = createSignal(0)
const [focusMode, setFocusMode] = createSignal<"recent" | "new" | null>("recent")
const [selectedBinary, setSelectedBinary] = createSignal(preferences().lastUsedBinary || "opencode")
const [isFolderBrowserOpen, setIsFolderBrowserOpen] = createSignal(false)
const nativeDialogsAvailable = supportsNativeDialogs()
let recentListRef: HTMLDivElement | undefined
type LanguageOption = { value: Locale; label: string }
const languageOptions: LanguageOption[] = [
{ value: "en", label: "English" },
{ value: "es", label: "Español" },
{ value: "fr", label: "Français" },
{ value: "ru", label: "Русский" },
{ value: "ja", label: "日本語" },
{ value: "zh-Hans", label: "简体中文" },
]
const selectedLanguageOption = () => languageOptions.find((opt) => opt.value === locale()) ?? languageOptions[0]
const folders = () => recentFolders()
const isLoading = () => Boolean(props.isLoading)
@@ -56,6 +77,19 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
function handleKeyDown(e: KeyboardEvent) {
let activeElement: HTMLElement | null = null
if (typeof document !== "undefined") {
activeElement = document.activeElement as HTMLElement | null
}
const insideModal = activeElement?.closest(".modal-surface") || activeElement?.closest("[role='dialog']")
const isEditingField =
activeElement &&
(["INPUT", "TEXTAREA", "SELECT"].includes(activeElement.tagName) || activeElement.isContentEditable || Boolean(insideModal))
if (isEditingField) {
return
}
const normalizedKey = e.key.toLowerCase()
const isBrowseShortcut = (e.metaKey || e.ctrlKey) && !e.shiftKey && normalizedKey === "n"
const blockedKeys = [
@@ -164,16 +198,21 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
const hours = Math.floor(minutes / 60)
const days = Math.floor(hours / 24)
if (days > 0) return `${days}d ago`
if (hours > 0) return `${hours}h ago`
if (minutes > 0) return `${minutes}m ago`
return "just now"
if (days > 0) return t("time.relative.daysAgoShort", { count: days })
if (hours > 0) return t("time.relative.hoursAgoShort", { count: hours })
if (minutes > 0) return t("time.relative.minutesAgoShort", { count: minutes })
return t("time.relative.justNow")
}
function handleFolderSelect(path: string) {
if (isLoading()) return
props.onSelectFolder(path, selectedBinary())
}
const openExternalLink = (url: string) => {
if (typeof window === "undefined") return
window.open(url, "_blank", "noopener,noreferrer")
}
async function handleBrowse() {
if (isLoading()) return
@@ -181,7 +220,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
if (nativeDialogsAvailable) {
const fallbackPath = folders()[0]?.path
const selected = await openNativeFolderDialog({
title: "Select Workspace",
title: t("folderSelection.dialog.title"),
defaultPath: fallbackPath,
})
if (selected) {
@@ -228,167 +267,282 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
style="background-color: var(--surface-secondary)"
>
<div
class="w-full max-w-3xl h-full px-4 sm:px-8 pb-2 flex flex-col overflow-hidden"
class="w-full max-w-5xl h-full px-4 sm:px-8 pb-2 flex flex-col overflow-hidden"
aria-busy={isLoading() ? "true" : "false"}
>
<Show when={props.onOpenRemoteAccess}>
<div class="absolute top-4 right-6">
<div class="absolute top-4 left-6">
<Select<LanguageOption>
value={selectedLanguageOption()}
onChange={(value) => {
if (!value) return
if (value.value === locale()) return
updatePreferences({ locale: value.value })
}}
options={languageOptions}
optionValue="value"
optionTextValue="label"
itemComponent={(itemProps) => (
<Select.Item item={itemProps.item} class="selector-option">
<Select.ItemLabel class="selector-option-label">{itemProps.item.rawValue.label}</Select.ItemLabel>
</Select.Item>
)}
>
<Select.Trigger
class="selector-trigger"
aria-label={t("folderSelection.language.ariaLabel")}
title={t("folderSelection.language.ariaLabel")}
>
<Languages class="w-4 h-4 icon-muted" aria-hidden="true" />
<div class="flex-1 min-w-0">
<Select.Value<LanguageOption>>
{(state) => (
<span class="selector-trigger-primary selector-trigger-primary--align-left">
{state.selectedOption()?.label}
</span>
)}
</Select.Value>
</div>
<Select.Icon class="selector-trigger-icon">
<ChevronDown class="w-3 h-3" />
</Select.Icon>
</Select.Trigger>
<Select.Portal>
<Select.Content class="selector-popover min-w-[180px]">
<Select.Listbox class="selector-listbox" />
</Select.Content>
</Select.Portal>
</Select>
</div>
<div class="absolute top-4 right-6 flex items-center gap-2">
<ThemeModeToggle class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center" />
<Show when={props.onOpenRemoteAccess}>
<button
type="button"
class="selector-button selector-button-secondary inline-flex items-center justify-center"
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
onClick={() => props.onOpenRemoteAccess?.()}
>
<MonitorUp class="w-4 h-4" />
</button>
</div>
</Show>
<div class="mb-6 text-center shrink-0">
<div class="mb-3 flex justify-center">
<img src={codeNomadLogo} alt="CodeNomad logo" class="h-32 w-auto sm:h-48" loading="lazy" />
</div>
<h1 class="mb-2 text-3xl font-semibold text-primary">CodeNomad</h1>
<p class="text-base text-secondary">Select a folder to start coding with AI</p>
</div>
<div class="space-y-4 flex-1 min-h-0 overflow-hidden flex flex-col">
<Show
when={folders().length > 0}
fallback={
<div class="panel panel-empty-state flex-1">
<div class="panel-empty-state-icon">
<Clock class="w-12 h-12 mx-auto" />
</div>
<p class="panel-empty-state-title">No Recent Folders</p>
<p class="panel-empty-state-description">Browse for a folder to get started</p>
</div>
}
>
<div class="panel flex flex-col flex-1 min-h-0">
<div class="panel-header">
<h2 class="panel-title">Recent Folders</h2>
<p class="panel-subtitle">
{folders().length} {folders().length === 1 ? "folder" : "folders"} available
</p>
</div>
<div class="panel-list panel-list--fill flex-1 min-h-0 overflow-auto" ref={(el) => (recentListRef = el)}>
<For each={folders()}>
{(folder, index) => (
<div
class="panel-list-item"
classList={{
"panel-list-item-highlight": focusMode() === "recent" && selectedIndex() === index(),
"panel-list-item-disabled": isLoading(),
}}
>
<div class="flex items-center gap-2 w-full px-1">
<button
data-folder-index={index()}
class="panel-list-item-content flex-1"
disabled={isLoading()}
onClick={() => handleFolderSelect(folder.path)}
onMouseEnter={() => {
if (isLoading()) return
setFocusMode("recent")
setSelectedIndex(index())
}}
>
<div class="flex items-center justify-between gap-3 w-full">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<Folder class="w-4 h-4 flex-shrink-0 icon-muted" />
<span class="text-sm font-medium truncate text-primary">
{folder.path.split("/").pop()}
</span>
</div>
<div class="text-xs font-mono truncate pl-6 text-muted">
{getDisplayPath(folder.path)}
</div>
<div class="text-xs mt-1 pl-6 text-muted">
{formatRelativeTime(folder.lastAccessed)}
</div>
</div>
<Show when={focusMode() === "recent" && selectedIndex() === index()}>
<kbd class="kbd"></kbd>
</Show>
</div>
</button>
<button
onClick={(e) => handleRemove(folder.path, e)}
disabled={isLoading()}
class="p-2 transition-all hover:bg-red-100 dark:hover:bg-red-900/30 opacity-70 hover:opacity-100 rounded"
title="Remove from recent"
>
<Trash2 class="w-3.5 h-3.5 transition-colors icon-muted hover:text-red-600 dark:hover:text-red-400" />
</button>
</div>
</div>
)}
</For>
</div>
</div>
</Show>
<div class="panel shrink-0">
<div class="panel-header hidden sm:block">
<h2 class="panel-title">Browse for Folder</h2>
<p class="panel-subtitle">Select any folder on your computer</p>
</div>
<div class="panel-body">
<button
onClick={() => void handleBrowse()}
disabled={props.isLoading}
class="button-primary w-full flex items-center justify-center text-sm disabled:cursor-not-allowed"
onMouseEnter={() => setFocusMode("new")}
>
<div class="flex items-center gap-2">
<FolderPlus class="w-4 h-4" />
<span>{props.isLoading ? "Opening..." : "Browse Folders"}</span>
</div>
<Kbd shortcut="cmd+n" class="ml-2" />
</button>
</div>
{/* Advanced settings section */}
<div class="panel-section w-full">
<button
onClick={() => props.onAdvancedSettingsOpen?.()}
class="panel-section-header w-full justify-between"
>
<div class="flex items-center gap-2">
<Settings class="w-4 h-4 icon-muted" />
<span class="text-sm font-medium text-secondary">Advanced Settings</span>
</div>
<ChevronRight class="w-4 h-4 icon-muted" />
</button>
</div>
</div>
<div class="mb-6 text-center shrink-0">
<div class="mb-3 flex justify-center">
<img src={codeNomadLogo} alt={t("folderSelection.logoAlt")} class="h-32 w-auto sm:h-48" loading="lazy" />
</div>
<h1 class="mb-2 text-3xl font-semibold text-primary">CodeNomad</h1>
<div class="mt-3 flex justify-center gap-2">
<a
href="https://github.com/NeuralNomadsAI/CodeNomad"
target="_blank"
rel="noreferrer"
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
aria-label={t("folderSelection.links.github")}
title={t("folderSelection.links.github")}
onClick={(event) => {
event.preventDefault()
openExternalLink("https://github.com/NeuralNomadsAI/CodeNomad")
}}
>
<GitHubMarkIcon class="w-4 h-4" />
</a>
<a
href="https://github.com/NeuralNomadsAI/CodeNomad"
target="_blank"
rel="noreferrer"
class="selector-button selector-button-secondary w-auto px-3 py-1.5 inline-flex items-center justify-center gap-1.5"
aria-label={t("folderSelection.links.githubStars")}
title={t("folderSelection.links.githubStars")}
onClick={(event) => {
event.preventDefault()
openExternalLink("https://github.com/NeuralNomadsAI/CodeNomad")
}}
>
<Star class="w-4 h-4" />
<Show when={githubStars() !== null}>
<span class="text-xs font-medium">{formatCompactCount(githubStars()!)}</span>
</Show>
</a>
<a
href="https://discord.com/channels/1391832426048651334/1458412028325793887/1464701235683917945"
target="_blank"
rel="noreferrer"
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
aria-label={t("folderSelection.links.discord")}
title={t("folderSelection.links.discord")}
onClick={(event) => {
event.preventDefault()
openExternalLink(
"https://discord.com/channels/1391832426048651334/1458412028325793887/1464701235683917945",
)
}}
>
<DiscordSymbolIcon class="w-4 h-4" />
</a>
</div>
<p class="mt-3 text-base text-secondary">{t("folderSelection.tagline")}</p>
</div>
<div class="mt-1 panel panel-footer shrink-0 hidden sm:block">
<div class="panel-footer-hints">
<Show when={folders().length > 0}>
<div class="flex items-center gap-1.5">
<kbd class="kbd"></kbd>
<kbd class="kbd"></kbd>
<span>Navigate</span>
</div>
<div class="flex items-center gap-1.5">
<kbd class="kbd">Enter</kbd>
<span>Select</span>
</div>
<div class="flex items-center gap-1.5">
<kbd class="kbd">Del</kbd>
<span>Remove</span>
<div class="flex-1 min-h-0 overflow-hidden flex flex-col gap-4">
<div class="flex-1 min-h-0 overflow-hidden flex flex-col lg:flex-row gap-4">
{/* Right column: recent folders */}
<div class="order-1 lg:order-2 flex flex-col gap-4 flex-1 min-h-0 overflow-hidden">
<Show
when={folders().length > 0}
fallback={
<div class="panel panel-empty-state flex-1">
<div class="panel-empty-state-icon">
<Clock class="w-12 h-12 mx-auto" />
</div>
<p class="panel-empty-state-title">{t("folderSelection.empty.title")}</p>
<p class="panel-empty-state-description">{t("folderSelection.empty.description")}</p>
</div>
}
>
<div class="panel flex flex-col flex-1 min-h-0">
<div class="panel-header">
<h2 class="panel-title">{t("folderSelection.recent.title")}</h2>
<p class="panel-subtitle">
{t(
folders().length === 1
? "folderSelection.recent.subtitle.one"
: "folderSelection.recent.subtitle.other",
{ count: folders().length },
)}
</p>
</div>
<div
class="panel-list panel-list--fill flex-1 min-h-0 overflow-auto"
ref={(el) => (recentListRef = el)}
>
<For each={folders()}>
{(folder, index) => (
<div
class="panel-list-item"
classList={{
"panel-list-item-highlight": focusMode() === "recent" && selectedIndex() === index(),
"panel-list-item-disabled": isLoading(),
}}
>
<div class="flex items-center gap-2 w-full px-1">
<button
data-folder-index={index()}
class="panel-list-item-content flex-1"
disabled={isLoading()}
onClick={() => handleFolderSelect(folder.path)}
onMouseEnter={() => {
if (isLoading()) return
setFocusMode("recent")
setSelectedIndex(index())
}}
>
<div class="flex items-center justify-between gap-3 w-full">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<Folder class="w-4 h-4 flex-shrink-0 icon-muted" />
<span class="text-sm font-medium truncate text-primary">
{folder.path.split("/").pop()}
</span>
</div>
<div class="text-xs font-mono truncate pl-6 text-muted">
{getDisplayPath(folder.path)}
</div>
<div class="text-xs mt-1 pl-6 text-muted">
{formatRelativeTime(folder.lastAccessed)}
</div>
</div>
<Show when={focusMode() === "recent" && selectedIndex() === index()}>
<kbd class="kbd"></kbd>
</Show>
</div>
</button>
<button
onClick={(e) => handleRemove(folder.path, e)}
disabled={isLoading()}
class="p-2 transition-all hover:bg-red-100 dark:hover:bg-red-900/30 opacity-70 hover:opacity-100 rounded"
title={t("folderSelection.recent.remove")}
>
<Trash2 class="w-3.5 h-3.5 transition-colors icon-muted hover:text-red-600 dark:hover:text-red-400" />
</button>
</div>
</div>
)}
</For>
</div>
</div>
</Show>
<div class="flex items-center gap-1.5">
<Kbd shortcut="cmd+n" />
<span>Browse</span>
</div>
{/* Left column: version + browse + advanced settings */}
<div class="order-2 lg:order-1 flex flex-col gap-4 flex-1 min-h-0">
<div class="panel shrink-0">
<div class="panel-header hidden sm:block">
<h2 class="panel-title">{t("folderSelection.browse.title")}</h2>
<p class="panel-subtitle">{t("folderSelection.browse.subtitle")}</p>
</div>
<div class="panel-body">
<button
onClick={() => void handleBrowse()}
disabled={props.isLoading}
class="button-primary w-full flex items-center justify-center text-sm disabled:cursor-not-allowed"
onMouseEnter={() => setFocusMode("new")}
>
<div class="flex items-center gap-2">
<FolderPlus class="w-4 h-4" />
<span>
{props.isLoading
? t("folderSelection.browse.buttonOpening")
: t("folderSelection.browse.button")}
</span>
</div>
<Kbd shortcut="cmd+n" class="ml-2" />
</button>
</div>
{/* Advanced settings section */}
<div class="panel-section w-full">
<button onClick={() => props.onAdvancedSettingsOpen?.()} class="panel-section-header w-full justify-between">
<div class="flex items-center gap-2">
<Settings class="w-4 h-4 icon-muted" />
<span class="text-sm font-medium text-secondary">{t("folderSelection.advancedSettings")}</span>
</div>
<ChevronRight class="w-4 h-4 icon-muted" />
</button>
</div>
</div>
<div class="panel shrink-0">
<div class="panel-body flex items-center justify-center">
<VersionPill />
</div>
</div>
</div>
</div>
<div class="panel panel-footer shrink-0 hidden sm:block">
<div class="panel-footer-hints">
<Show when={folders().length > 0}>
<div class="flex items-center gap-1.5">
<kbd class="kbd"></kbd>
<kbd class="kbd"></kbd>
<span>{t("folderSelection.hints.navigate")}</span>
</div>
<div class="flex items-center gap-1.5">
<kbd class="kbd">Enter</kbd>
<span>{t("folderSelection.hints.select")}</span>
</div>
<div class="flex items-center gap-1.5">
<kbd class="kbd">Del</kbd>
<span>{t("folderSelection.hints.remove")}</span>
</div>
</Show>
<div class="flex items-center gap-1.5">
<Kbd shortcut="cmd+n" />
<span>{t("folderSelection.hints.browse")}</span>
</div>
</div>
</div>
</div>
@@ -397,8 +551,8 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
<div class="folder-loading-overlay">
<div class="folder-loading-indicator">
<div class="spinner" />
<p class="folder-loading-text">Starting instance</p>
<p class="folder-loading-subtext">Hang tight while we prepare your workspace.</p>
<p class="folder-loading-text">{t("folderSelection.loading.title")}</p>
<p class="folder-loading-subtext">{t("folderSelection.loading.subtitle")}</p>
</div>
</div>
</Show>
@@ -414,8 +568,8 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
<DirectoryBrowserDialog
open={isFolderBrowserOpen()}
title="Select Workspace"
description="Select workspace to start coding."
title={t("folderSelection.dialog.title")}
description={t("folderSelection.dialog.description")}
onClose={() => setIsFolderBrowserOpen(false)}
onSelect={handleBrowserSelect}
/>

Some files were not shown because too many files have changed in this diff Show More