@neurocode-ai/desktop 1.18.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (253) hide show
  1. package/AGENTS.md +4 -0
  2. package/README.md +19 -0
  3. package/electron-builder.config.test.ts +48 -0
  4. package/electron-builder.config.ts +146 -0
  5. package/electron.vite.config.ts +106 -0
  6. package/icons/README.md +14 -0
  7. package/icons/beta/128x128.png +0 -0
  8. package/icons/beta/128x128@2x.png +0 -0
  9. package/icons/beta/32x32.png +0 -0
  10. package/icons/beta/64x64.png +0 -0
  11. package/icons/beta/Square107x107Logo.png +0 -0
  12. package/icons/beta/Square142x142Logo.png +0 -0
  13. package/icons/beta/Square150x150Logo.png +0 -0
  14. package/icons/beta/Square284x284Logo.png +0 -0
  15. package/icons/beta/Square30x30Logo.png +0 -0
  16. package/icons/beta/Square310x310Logo.png +0 -0
  17. package/icons/beta/Square44x44Logo.png +0 -0
  18. package/icons/beta/Square71x71Logo.png +0 -0
  19. package/icons/beta/Square89x89Logo.png +0 -0
  20. package/icons/beta/StoreLogo.png +0 -0
  21. package/icons/beta/android/mipmap-anydpi-v26/ic_launcher.xml +5 -0
  22. package/icons/beta/android/mipmap-hdpi/ic_launcher.png +0 -0
  23. package/icons/beta/android/mipmap-hdpi/ic_launcher_foreground.png +0 -0
  24. package/icons/beta/android/mipmap-hdpi/ic_launcher_round.png +0 -0
  25. package/icons/beta/android/mipmap-mdpi/ic_launcher.png +0 -0
  26. package/icons/beta/android/mipmap-mdpi/ic_launcher_foreground.png +0 -0
  27. package/icons/beta/android/mipmap-mdpi/ic_launcher_round.png +0 -0
  28. package/icons/beta/android/mipmap-xhdpi/ic_launcher.png +0 -0
  29. package/icons/beta/android/mipmap-xhdpi/ic_launcher_foreground.png +0 -0
  30. package/icons/beta/android/mipmap-xhdpi/ic_launcher_round.png +0 -0
  31. package/icons/beta/android/mipmap-xxhdpi/ic_launcher.png +0 -0
  32. package/icons/beta/android/mipmap-xxhdpi/ic_launcher_foreground.png +0 -0
  33. package/icons/beta/android/mipmap-xxhdpi/ic_launcher_round.png +0 -0
  34. package/icons/beta/android/mipmap-xxxhdpi/ic_launcher.png +0 -0
  35. package/icons/beta/android/mipmap-xxxhdpi/ic_launcher_foreground.png +0 -0
  36. package/icons/beta/android/mipmap-xxxhdpi/ic_launcher_round.png +0 -0
  37. package/icons/beta/android/values/ic_launcher_background.xml +4 -0
  38. package/icons/beta/dock.png +0 -0
  39. package/icons/beta/icon.icns +0 -0
  40. package/icons/beta/icon.ico +0 -0
  41. package/icons/beta/icon.png +0 -0
  42. package/icons/beta/ios/AppIcon-20x20@1x.png +0 -0
  43. package/icons/beta/ios/AppIcon-20x20@2x-1.png +0 -0
  44. package/icons/beta/ios/AppIcon-20x20@2x.png +0 -0
  45. package/icons/beta/ios/AppIcon-20x20@3x.png +0 -0
  46. package/icons/beta/ios/AppIcon-29x29@1x.png +0 -0
  47. package/icons/beta/ios/AppIcon-29x29@2x-1.png +0 -0
  48. package/icons/beta/ios/AppIcon-29x29@2x.png +0 -0
  49. package/icons/beta/ios/AppIcon-29x29@3x.png +0 -0
  50. package/icons/beta/ios/AppIcon-40x40@1x.png +0 -0
  51. package/icons/beta/ios/AppIcon-40x40@2x-1.png +0 -0
  52. package/icons/beta/ios/AppIcon-40x40@2x.png +0 -0
  53. package/icons/beta/ios/AppIcon-40x40@3x.png +0 -0
  54. package/icons/beta/ios/AppIcon-512@2x.png +0 -0
  55. package/icons/beta/ios/AppIcon-60x60@2x.png +0 -0
  56. package/icons/beta/ios/AppIcon-60x60@3x.png +0 -0
  57. package/icons/beta/ios/AppIcon-76x76@1x.png +0 -0
  58. package/icons/beta/ios/AppIcon-76x76@2x.png +0 -0
  59. package/icons/beta/ios/AppIcon-83.5x83.5@2x.png +0 -0
  60. package/icons/dev/128x128.png +0 -0
  61. package/icons/dev/128x128@2x.png +0 -0
  62. package/icons/dev/32x32.png +0 -0
  63. package/icons/dev/64x64.png +0 -0
  64. package/icons/dev/Square107x107Logo.png +0 -0
  65. package/icons/dev/Square142x142Logo.png +0 -0
  66. package/icons/dev/Square150x150Logo.png +0 -0
  67. package/icons/dev/Square284x284Logo.png +0 -0
  68. package/icons/dev/Square30x30Logo.png +0 -0
  69. package/icons/dev/Square310x310Logo.png +0 -0
  70. package/icons/dev/Square44x44Logo.png +0 -0
  71. package/icons/dev/Square71x71Logo.png +0 -0
  72. package/icons/dev/Square89x89Logo.png +0 -0
  73. package/icons/dev/StoreLogo.png +0 -0
  74. package/icons/dev/android/mipmap-anydpi-v26/ic_launcher.xml +5 -0
  75. package/icons/dev/android/mipmap-hdpi/ic_launcher.png +0 -0
  76. package/icons/dev/android/mipmap-hdpi/ic_launcher_foreground.png +0 -0
  77. package/icons/dev/android/mipmap-hdpi/ic_launcher_round.png +0 -0
  78. package/icons/dev/android/mipmap-mdpi/ic_launcher.png +0 -0
  79. package/icons/dev/android/mipmap-mdpi/ic_launcher_foreground.png +0 -0
  80. package/icons/dev/android/mipmap-mdpi/ic_launcher_round.png +0 -0
  81. package/icons/dev/android/mipmap-xhdpi/ic_launcher.png +0 -0
  82. package/icons/dev/android/mipmap-xhdpi/ic_launcher_foreground.png +0 -0
  83. package/icons/dev/android/mipmap-xhdpi/ic_launcher_round.png +0 -0
  84. package/icons/dev/android/mipmap-xxhdpi/ic_launcher.png +0 -0
  85. package/icons/dev/android/mipmap-xxhdpi/ic_launcher_foreground.png +0 -0
  86. package/icons/dev/android/mipmap-xxhdpi/ic_launcher_round.png +0 -0
  87. package/icons/dev/android/mipmap-xxxhdpi/ic_launcher.png +0 -0
  88. package/icons/dev/android/mipmap-xxxhdpi/ic_launcher_foreground.png +0 -0
  89. package/icons/dev/android/mipmap-xxxhdpi/ic_launcher_round.png +0 -0
  90. package/icons/dev/android/values/ic_launcher_background.xml +4 -0
  91. package/icons/dev/dock.png +0 -0
  92. package/icons/dev/icon.icns +0 -0
  93. package/icons/dev/icon.ico +0 -0
  94. package/icons/dev/icon.png +0 -0
  95. package/icons/dev/ios/AppIcon-20x20@1x.png +0 -0
  96. package/icons/dev/ios/AppIcon-20x20@2x-1.png +0 -0
  97. package/icons/dev/ios/AppIcon-20x20@2x.png +0 -0
  98. package/icons/dev/ios/AppIcon-20x20@3x.png +0 -0
  99. package/icons/dev/ios/AppIcon-29x29@1x.png +0 -0
  100. package/icons/dev/ios/AppIcon-29x29@2x-1.png +0 -0
  101. package/icons/dev/ios/AppIcon-29x29@2x.png +0 -0
  102. package/icons/dev/ios/AppIcon-29x29@3x.png +0 -0
  103. package/icons/dev/ios/AppIcon-40x40@1x.png +0 -0
  104. package/icons/dev/ios/AppIcon-40x40@2x-1.png +0 -0
  105. package/icons/dev/ios/AppIcon-40x40@2x.png +0 -0
  106. package/icons/dev/ios/AppIcon-40x40@3x.png +0 -0
  107. package/icons/dev/ios/AppIcon-512@2x.png +0 -0
  108. package/icons/dev/ios/AppIcon-60x60@2x.png +0 -0
  109. package/icons/dev/ios/AppIcon-60x60@3x.png +0 -0
  110. package/icons/dev/ios/AppIcon-76x76@1x.png +0 -0
  111. package/icons/dev/ios/AppIcon-76x76@2x.png +0 -0
  112. package/icons/dev/ios/AppIcon-83.5x83.5@2x.png +0 -0
  113. package/icons/prod/128x128.png +0 -0
  114. package/icons/prod/128x128@2x.png +0 -0
  115. package/icons/prod/32x32.png +0 -0
  116. package/icons/prod/64x64.png +0 -0
  117. package/icons/prod/Square107x107Logo.png +0 -0
  118. package/icons/prod/Square142x142Logo.png +0 -0
  119. package/icons/prod/Square150x150Logo.png +0 -0
  120. package/icons/prod/Square284x284Logo.png +0 -0
  121. package/icons/prod/Square30x30Logo.png +0 -0
  122. package/icons/prod/Square310x310Logo.png +0 -0
  123. package/icons/prod/Square44x44Logo.png +0 -0
  124. package/icons/prod/Square71x71Logo.png +0 -0
  125. package/icons/prod/Square89x89Logo.png +0 -0
  126. package/icons/prod/StoreLogo.png +0 -0
  127. package/icons/prod/android/mipmap-anydpi-v26/ic_launcher.xml +5 -0
  128. package/icons/prod/android/mipmap-hdpi/ic_launcher.png +0 -0
  129. package/icons/prod/android/mipmap-hdpi/ic_launcher_foreground.png +0 -0
  130. package/icons/prod/android/mipmap-hdpi/ic_launcher_round.png +0 -0
  131. package/icons/prod/android/mipmap-mdpi/ic_launcher.png +0 -0
  132. package/icons/prod/android/mipmap-mdpi/ic_launcher_foreground.png +0 -0
  133. package/icons/prod/android/mipmap-mdpi/ic_launcher_round.png +0 -0
  134. package/icons/prod/android/mipmap-xhdpi/ic_launcher.png +0 -0
  135. package/icons/prod/android/mipmap-xhdpi/ic_launcher_foreground.png +0 -0
  136. package/icons/prod/android/mipmap-xhdpi/ic_launcher_round.png +0 -0
  137. package/icons/prod/android/mipmap-xxhdpi/ic_launcher.png +0 -0
  138. package/icons/prod/android/mipmap-xxhdpi/ic_launcher_foreground.png +0 -0
  139. package/icons/prod/android/mipmap-xxhdpi/ic_launcher_round.png +0 -0
  140. package/icons/prod/android/mipmap-xxxhdpi/ic_launcher.png +0 -0
  141. package/icons/prod/android/mipmap-xxxhdpi/ic_launcher_foreground.png +0 -0
  142. package/icons/prod/android/mipmap-xxxhdpi/ic_launcher_round.png +0 -0
  143. package/icons/prod/android/values/ic_launcher_background.xml +4 -0
  144. package/icons/prod/dock.png +0 -0
  145. package/icons/prod/icon.icns +0 -0
  146. package/icons/prod/icon.ico +0 -0
  147. package/icons/prod/icon.png +0 -0
  148. package/icons/prod/ios/AppIcon-20x20@1x.png +0 -0
  149. package/icons/prod/ios/AppIcon-20x20@2x-1.png +0 -0
  150. package/icons/prod/ios/AppIcon-20x20@2x.png +0 -0
  151. package/icons/prod/ios/AppIcon-20x20@3x.png +0 -0
  152. package/icons/prod/ios/AppIcon-29x29@1x.png +0 -0
  153. package/icons/prod/ios/AppIcon-29x29@2x-1.png +0 -0
  154. package/icons/prod/ios/AppIcon-29x29@2x.png +0 -0
  155. package/icons/prod/ios/AppIcon-29x29@3x.png +0 -0
  156. package/icons/prod/ios/AppIcon-40x40@1x.png +0 -0
  157. package/icons/prod/ios/AppIcon-40x40@2x-1.png +0 -0
  158. package/icons/prod/ios/AppIcon-40x40@2x.png +0 -0
  159. package/icons/prod/ios/AppIcon-40x40@3x.png +0 -0
  160. package/icons/prod/ios/AppIcon-512@2x.png +0 -0
  161. package/icons/prod/ios/AppIcon-60x60@2x.png +0 -0
  162. package/icons/prod/ios/AppIcon-60x60@3x.png +0 -0
  163. package/icons/prod/ios/AppIcon-76x76@1x.png +0 -0
  164. package/icons/prod/ios/AppIcon-76x76@2x.png +0 -0
  165. package/icons/prod/ios/AppIcon-83.5x83.5@2x.png +0 -0
  166. package/package.json +75 -0
  167. package/resources/entitlements.plist +18 -0
  168. package/resources/linux/opencode-desktop.desktop +10 -0
  169. package/scripts/copy-bundles.ts +12 -0
  170. package/scripts/copy-icons.ts +12 -0
  171. package/scripts/copy-metainfo.ts +47 -0
  172. package/scripts/finalize-latest-json.ts +219 -0
  173. package/scripts/finalize-latest-yml.ts +124 -0
  174. package/scripts/prebuild.ts +10 -0
  175. package/scripts/predev.ts +5 -0
  176. package/scripts/prepare.ts +9 -0
  177. package/scripts/utils.ts +77 -0
  178. package/src/main/apps.ts +136 -0
  179. package/src/main/attachment-picker.test.ts +87 -0
  180. package/src/main/attachment-picker.ts +56 -0
  181. package/src/main/constants.ts +7 -0
  182. package/src/main/debug.ts +95 -0
  183. package/src/main/desktop-menu-actions.ts +84 -0
  184. package/src/main/env.d.ts +19 -0
  185. package/src/main/index.test.ts +37 -0
  186. package/src/main/index.ts +399 -0
  187. package/src/main/initialization.ts +6 -0
  188. package/src/main/install-state.test.ts +19 -0
  189. package/src/main/install-state.ts +8 -0
  190. package/src/main/ipc.ts +274 -0
  191. package/src/main/logging.ts +205 -0
  192. package/src/main/markdown.ts +16 -0
  193. package/src/main/menu.ts +67 -0
  194. package/src/main/migrate.ts +91 -0
  195. package/src/main/onboarding.ts +45 -0
  196. package/src/main/server.ts +238 -0
  197. package/src/main/shell-env.test.ts +50 -0
  198. package/src/main/shell-env.ts +101 -0
  199. package/src/main/sidecar.ts +157 -0
  200. package/src/main/store-cleanup.test.ts +93 -0
  201. package/src/main/store-cleanup.ts +94 -0
  202. package/src/main/store-keys.ts +7 -0
  203. package/src/main/store.ts +35 -0
  204. package/src/main/unresponsive.ts +70 -0
  205. package/src/main/updater-controller.test.ts +111 -0
  206. package/src/main/updater-controller.ts +97 -0
  207. package/src/main/updater-subscriptions.test.ts +16 -0
  208. package/src/main/updater-subscriptions.ts +20 -0
  209. package/src/main/updater.ts +85 -0
  210. package/src/main/window-registry.test.ts +91 -0
  211. package/src/main/window-registry.ts +47 -0
  212. package/src/main/window-state.ts +29 -0
  213. package/src/main/windows.ts +496 -0
  214. package/src/main/wsl/ipc.ts +103 -0
  215. package/src/main/wsl/policy.ts +33 -0
  216. package/src/main/wsl/runtime.ts +400 -0
  217. package/src/main/wsl/servers.test.ts +231 -0
  218. package/src/main/wsl/servers.ts +521 -0
  219. package/src/main/wsl/sidecar.ts +129 -0
  220. package/src/main/wsl/startup.ts +31 -0
  221. package/src/preload/index.ts +128 -0
  222. package/src/preload/types.ts +109 -0
  223. package/src/renderer/cli.ts +12 -0
  224. package/src/renderer/env.d.ts +10 -0
  225. package/src/renderer/html.test.ts +62 -0
  226. package/src/renderer/i18n/ar.ts +26 -0
  227. package/src/renderer/i18n/br.ts +27 -0
  228. package/src/renderer/i18n/bs.ts +28 -0
  229. package/src/renderer/i18n/da.ts +28 -0
  230. package/src/renderer/i18n/de.ts +28 -0
  231. package/src/renderer/i18n/en.ts +27 -0
  232. package/src/renderer/i18n/es.ts +27 -0
  233. package/src/renderer/i18n/fr.ts +28 -0
  234. package/src/renderer/i18n/index.ts +194 -0
  235. package/src/renderer/i18n/ja.ts +28 -0
  236. package/src/renderer/i18n/ko.ts +27 -0
  237. package/src/renderer/i18n/no.ts +28 -0
  238. package/src/renderer/i18n/pl.ts +28 -0
  239. package/src/renderer/i18n/ru.ts +27 -0
  240. package/src/renderer/i18n/uk.ts +28 -0
  241. package/src/renderer/i18n/zh.ts +26 -0
  242. package/src/renderer/i18n/zht.ts +26 -0
  243. package/src/renderer/index.html +21 -0
  244. package/src/renderer/index.tsx +464 -0
  245. package/src/renderer/initialization.test.ts +73 -0
  246. package/src/renderer/initialization.ts +22 -0
  247. package/src/renderer/onboarding.tsx +53 -0
  248. package/src/renderer/styles.css +0 -0
  249. package/src/renderer/webview-zoom.ts +138 -0
  250. package/src/renderer/wsl/connections.test.ts +43 -0
  251. package/src/renderer/wsl/connections.ts +28 -0
  252. package/sst-env.d.ts +10 -0
  253. package/tsconfig.json +23 -0
@@ -0,0 +1,45 @@
1
+ import { existsSync, readdirSync } from "node:fs"
2
+ import { mkdir } from "node:fs/promises"
3
+ import { join } from "node:path"
4
+ import { app } from "electron"
5
+ import { getStore } from "./store"
6
+ import { FIRST_LAUNCH_ONBOARDING_COMPLETE_KEY, OLD_LAYOUT_ELIGIBLE_KEY } from "./store-keys"
7
+ import { write as writeLog } from "./logging"
8
+ import { hasExistingAppState } from "./install-state"
9
+
10
+ const DEFAULT_PROJECT_DIR = "Default Project"
11
+
12
+ export function initializeOldLayoutEligibility(userDataPath: string) {
13
+ const entries = existsSync(userDataPath) ? readdirSync(userDataPath, { withFileTypes: true }) : []
14
+ const store = getStore()
15
+ const current = store.get(OLD_LAYOUT_ELIGIBLE_KEY)
16
+ if (typeof current === "boolean") return current
17
+
18
+ const eligible = hasExistingAppState(entries)
19
+ store.set(OLD_LAYOUT_ELIGIBLE_KEY, eligible)
20
+ return eligible
21
+ }
22
+
23
+ export function isOldLayoutEligible() {
24
+ return getStore().get(OLD_LAYOUT_ELIGIBLE_KEY) === true
25
+ }
26
+
27
+ export function isFirstLaunchOnboardingPending() {
28
+ const pending = getStore().get(FIRST_LAUNCH_ONBOARDING_COMPLETE_KEY) !== true
29
+ writeLog("onboarding", "first launch onboarding pending checked", { pending })
30
+ return pending
31
+ }
32
+
33
+ export async function finishFirstLaunchOnboarding(createDefaultProject: boolean) {
34
+ if (!isFirstLaunchOnboardingPending()) {
35
+ writeLog("onboarding", "first launch onboarding already completed")
36
+ return null
37
+ }
38
+
39
+ const defaultProject = createDefaultProject ? join(app.getPath("documents"), DEFAULT_PROJECT_DIR) : null
40
+ if (defaultProject) await mkdir(defaultProject, { recursive: true })
41
+
42
+ getStore().set(FIRST_LAUNCH_ONBOARDING_COMPLETE_KEY, true)
43
+ writeLog("onboarding", "first launch onboarding completed", { createDefaultProject, defaultProject })
44
+ return defaultProject
45
+ }
@@ -0,0 +1,238 @@
1
+ import { dirname, join } from "node:path"
2
+ import { fileURLToPath } from "node:url"
3
+ import { app, utilityProcess } from "electron"
4
+ import type { Details } from "electron"
5
+ import { getLogger } from "./logging"
6
+ import { getUserShell, loadShellEnv } from "./shell-env"
7
+ import { getStore } from "./store"
8
+ import { DEFAULT_SERVER_URL_KEY } from "./store-keys"
9
+
10
+ export type HealthCheck = { wait: Promise<void> }
11
+
12
+ type SidecarMessage =
13
+ | { type: "ready" }
14
+ | { type: "stopped" }
15
+ | { type: "error"; error: { message: string; stack?: string } }
16
+
17
+ export type SidecarListener = { stop: () => Promise<void> }
18
+
19
+ const SIDECAR_SERVICE_NAME = "neurocode server"
20
+ const SIDECAR_START_STALL_TIMEOUT = 60_000
21
+ const SIDECAR_STOP_TIMEOUT = 6_000
22
+
23
+ type SpawnLocalServerOptions = {
24
+ userDataPath: string
25
+ onStdout?: (message: string) => void
26
+ onStderr?: (message: string) => void
27
+ onExit?: (code: number) => void
28
+ }
29
+
30
+ export function getDefaultServerUrl(): string | null {
31
+ const value = getStore().get(DEFAULT_SERVER_URL_KEY)
32
+ return typeof value === "string" ? value : null
33
+ }
34
+
35
+ export function setDefaultServerUrl(url: string | null) {
36
+ if (url) {
37
+ getStore().set(DEFAULT_SERVER_URL_KEY, url)
38
+ return
39
+ }
40
+
41
+ getStore().delete(DEFAULT_SERVER_URL_KEY)
42
+ }
43
+
44
+ export function preferAppEnv(userDataPath: string) {
45
+ const shell = process.platform === "win32" ? null : getUserShell()
46
+ Object.assign(process.env, {
47
+ ...(shell ? loadShellEnv(shell, getLogger()) : null),
48
+ NEUROCODE_EXPERIMENTAL_ICON_DISCOVERY: "true",
49
+ NEUROCODE_EXPERIMENTAL_FILEWATCHER: "true",
50
+ NEUROCODE_CLIENT: "desktop",
51
+ XDG_STATE_HOME: process.env.XDG_STATE_HOME ?? userDataPath,
52
+ })
53
+ }
54
+
55
+ export async function spawnLocalServer(
56
+ hostname: string,
57
+ port: number,
58
+ password: string,
59
+ options: SpawnLocalServerOptions,
60
+ ) {
61
+ const sidecar = join(dirname(fileURLToPath(import.meta.url)), "sidecar.js")
62
+ const child = utilityProcess.fork(sidecar, [], {
63
+ cwd: process.cwd(),
64
+ env: createSidecarEnv(),
65
+ serviceName: SIDECAR_SERVICE_NAME,
66
+ stdio: "pipe",
67
+ })
68
+ let exited = false
69
+ const exit = defer<number>()
70
+
71
+ const onProcessGone = (_event: unknown, details: Details) => {
72
+ if (details.type !== "Utility" || details.name !== SIDECAR_SERVICE_NAME) return
73
+ options.onStderr?.(`utility process gone reason=${details.reason} exitCode=${details.exitCode}`)
74
+ }
75
+
76
+ app.on("child-process-gone", onProcessGone)
77
+ child.once("exit", (code) => {
78
+ exited = true
79
+ app.off("child-process-gone", onProcessGone)
80
+ options.onExit?.(code)
81
+ exit.resolve(code)
82
+ })
83
+ child.on("error", (error) => options.onStderr?.(`utility process error: ${serializeError(error).message}`))
84
+
85
+ child.stdout?.on("data", (chunk: Buffer) => options.onStdout?.(chunk.toString("utf8").trimEnd()))
86
+ child.stderr?.on("data", (chunk: Buffer) => options.onStderr?.(chunk.toString("utf8").trimEnd()))
87
+
88
+ await new Promise<void>((resolve, reject) => {
89
+ let done = false
90
+ let timeout: NodeJS.Timeout
91
+
92
+ const fail = (error: Error) => {
93
+ if (done) return
94
+ done = true
95
+ cleanup()
96
+ reject(error)
97
+ }
98
+
99
+ const refreshTimeout = () => {
100
+ clearTimeout(timeout)
101
+ timeout = setTimeout(() => {
102
+ fail(new Error(`Sidecar did not become ready within ${SIDECAR_START_STALL_TIMEOUT}ms: ${sidecar}`))
103
+ }, SIDECAR_START_STALL_TIMEOUT)
104
+ }
105
+
106
+ const onMessage = (message: SidecarMessage) => {
107
+ if (message.type === "ready") {
108
+ if (done) return
109
+ done = true
110
+ cleanup()
111
+ resolve()
112
+ return
113
+ }
114
+ if (message.type === "error") {
115
+ fail(Object.assign(new Error(message.error.message), { stack: message.error.stack }))
116
+ }
117
+ }
118
+ const onExit = (code: number) => {
119
+ fail(new Error(`Sidecar exited before ready with code ${code}`))
120
+ }
121
+ const cleanup = () => {
122
+ clearTimeout(timeout)
123
+ child.off("message", onMessage)
124
+ child.off("exit", onExit)
125
+ }
126
+
127
+ child.on("message", onMessage)
128
+ child.on("exit", onExit)
129
+ refreshTimeout()
130
+ child.postMessage({
131
+ type: "start",
132
+ hostname,
133
+ port,
134
+ password,
135
+ userDataPath: options.userDataPath,
136
+ })
137
+ }).catch((error) => {
138
+ if (!exited) child.kill()
139
+ throw error
140
+ })
141
+
142
+ const wait = (async () => {
143
+ const url = `http://${hostname}:${port}`
144
+ let healthy = false
145
+ const gone = exit.promise.then((code) => {
146
+ if (healthy) return
147
+ throw new Error(`Sidecar exited before health check passed with code ${code}`)
148
+ })
149
+
150
+ const ready = async () => {
151
+ while (true) {
152
+ await new Promise((resolve) => setTimeout(resolve, 100))
153
+ if (await checkHealth(url, password)) {
154
+ healthy = true
155
+ return
156
+ }
157
+ }
158
+ }
159
+
160
+ await Promise.race([ready(), gone])
161
+ })()
162
+
163
+ let stopping: Promise<void> | undefined
164
+
165
+ return {
166
+ listener: {
167
+ stop: () => {
168
+ if (stopping) return stopping
169
+ if (exited) return Promise.resolve()
170
+ child.postMessage({ type: "stop" })
171
+ stopping = Promise.race([
172
+ exit.promise.then(() => undefined),
173
+ delay(SIDECAR_STOP_TIMEOUT).then(() => {
174
+ if (!exited) child.kill()
175
+ }),
176
+ ])
177
+ return stopping
178
+ },
179
+ },
180
+ health: { wait },
181
+ }
182
+ }
183
+
184
+ export async function checkHealth(url: string, password?: string | null): Promise<boolean> {
185
+ let healthUrls: URL[]
186
+ try {
187
+ healthUrls = [new URL("/api/health", url), new URL("/global/health", url)]
188
+ } catch {
189
+ return false
190
+ }
191
+
192
+ const headers = new Headers()
193
+ if (password) {
194
+ const auth = Buffer.from(`opencode:${password}`).toString("base64")
195
+ headers.set("authorization", `Basic ${auth}`)
196
+ }
197
+
198
+ for (const healthUrl of healthUrls) {
199
+ try {
200
+ const res = await fetch(healthUrl, {
201
+ method: "GET",
202
+ headers,
203
+ signal: AbortSignal.timeout(3000),
204
+ })
205
+ if (res.ok) return true
206
+ } catch {}
207
+ }
208
+ return false
209
+ }
210
+
211
+ function createSidecarEnv(): Record<string, string> {
212
+ const env = Object.fromEntries(
213
+ Object.entries(process.env).flatMap(([key, value]) => (value === undefined ? [] : [[key, String(value)]])),
214
+ )
215
+ delete env.DEBUG
216
+ if (process.platform === "linux") delete env.LD_PRELOAD
217
+ if (!app.isPackaged) env.NEUROCODE_DISABLE_CHANNEL_DB = "1"
218
+ return env
219
+ }
220
+
221
+ function delay(ms: number) {
222
+ return new Promise<void>((resolve) => setTimeout(resolve, ms))
223
+ }
224
+
225
+ function serializeError(error: unknown) {
226
+ if (error instanceof Error) return { message: error.message, stack: error.stack }
227
+ return { message: String(error) }
228
+ }
229
+
230
+ function defer<T>() {
231
+ let resolve!: (value: T) => void
232
+ let reject!: (error: Error) => void
233
+ const promise = new Promise<T>((res, rej) => {
234
+ resolve = res
235
+ reject = rej
236
+ })
237
+ return { promise, resolve, reject }
238
+ }
@@ -0,0 +1,50 @@
1
+ import { describe, expect, test } from "bun:test"
2
+
3
+ import { isNushell, mergeShellEnv, parseShellEnv, resolveUserShell } from "./shell-env"
4
+
5
+ describe("shell env", () => {
6
+ test("parseShellEnv supports null-delimited pairs", () => {
7
+ const env = parseShellEnv(Buffer.from("PATH=/usr/bin:/bin\0FOO=bar=baz\0\0"))
8
+
9
+ expect(env.PATH).toBe("/usr/bin:/bin")
10
+ expect(env.FOO).toBe("bar=baz")
11
+ })
12
+
13
+ test("parseShellEnv ignores invalid entries", () => {
14
+ const env = parseShellEnv(Buffer.from("INVALID\0=empty\0OK=1\0"))
15
+
16
+ expect(Object.keys(env).length).toBe(1)
17
+ expect(env.OK).toBe("1")
18
+ })
19
+
20
+ test("mergeShellEnv keeps explicit overrides", () => {
21
+ const env = mergeShellEnv(
22
+ {
23
+ PATH: "/shell/path",
24
+ HOME: "/tmp/home",
25
+ },
26
+ {
27
+ PATH: "/desktop/path",
28
+ NEUROCODE_CLIENT: "desktop",
29
+ },
30
+ )
31
+
32
+ expect(env.PATH).toBe("/desktop/path")
33
+ expect(env.HOME).toBe("/tmp/home")
34
+ expect(env.NEUROCODE_CLIENT).toBe("desktop")
35
+ })
36
+
37
+ test("resolveUserShell falls back to the login shell before /bin/sh", () => {
38
+ expect(resolveUserShell("/custom/env-shell", "/bin/zsh")).toBe("/custom/env-shell")
39
+ expect(resolveUserShell(undefined, "/bin/zsh")).toBe("/bin/zsh")
40
+ expect(resolveUserShell(undefined, "unknown")).toBe("/bin/sh")
41
+ expect(resolveUserShell(undefined, undefined)).toBe("/bin/sh")
42
+ })
43
+
44
+ test("isNushell handles path and binary name", () => {
45
+ expect(isNushell("nu")).toBe(true)
46
+ expect(isNushell("/opt/homebrew/bin/nu")).toBe(true)
47
+ expect(isNushell("C:\\Program Files\\nu.exe")).toBe(true)
48
+ expect(isNushell("/bin/zsh")).toBe(false)
49
+ })
50
+ })
@@ -0,0 +1,101 @@
1
+ import { spawnSync } from "node:child_process"
2
+ import { userInfo } from "node:os"
3
+ import { basename } from "node:path"
4
+
5
+ const TIMEOUT = 5_000
6
+
7
+ type Probe = { type: "Loaded"; value: Record<string, string> } | { type: "Timeout" } | { type: "Unavailable" }
8
+ type ShellEnvLogger = {
9
+ log: (message: string) => void
10
+ }
11
+
12
+ export function resolveUserShell(envShell: string | undefined, loginShell: string | null | undefined) {
13
+ const resolvedLoginShell = loginShell && loginShell !== "unknown" ? loginShell : undefined
14
+ return envShell || resolvedLoginShell || "/bin/sh"
15
+ }
16
+
17
+ export function getUserShell() {
18
+ try {
19
+ return resolveUserShell(process.env.SHELL, userInfo().shell)
20
+ } catch {
21
+ return resolveUserShell(process.env.SHELL, undefined)
22
+ }
23
+ }
24
+
25
+ export function parseShellEnv(out: Buffer) {
26
+ const env: Record<string, string> = {}
27
+ for (const line of out.toString("utf8").split("\0")) {
28
+ if (!line) continue
29
+ const ix = line.indexOf("=")
30
+ if (ix <= 0) continue
31
+ env[line.slice(0, ix)] = line.slice(ix + 1)
32
+ }
33
+ return env
34
+ }
35
+
36
+ function probe(shell: string, mode: "-il" | "-l"): Probe {
37
+ const out = spawnSync(shell, [mode, "-c", "env -0"], {
38
+ stdio: ["ignore", "pipe", "ignore"],
39
+ timeout: TIMEOUT,
40
+ windowsHide: true,
41
+ })
42
+
43
+ const err = out.error as NodeJS.ErrnoException | undefined
44
+ if (err) {
45
+ if (err.code === "ETIMEDOUT") return { type: "Timeout" }
46
+ console.log(`[server] Shell env probe failed for ${shell} ${mode}: ${err.message}`)
47
+ return { type: "Unavailable" }
48
+ }
49
+
50
+ if (out.status !== 0) {
51
+ console.log(`[server] Shell env probe exited with non-zero status for ${shell} ${mode}`)
52
+ return { type: "Unavailable" }
53
+ }
54
+
55
+ const env = parseShellEnv(out.stdout)
56
+ if (Object.keys(env).length === 0) {
57
+ console.log(`[server] Shell env probe returned empty env for ${shell} ${mode}`)
58
+ return { type: "Unavailable" }
59
+ }
60
+
61
+ return { type: "Loaded", value: env }
62
+ }
63
+
64
+ export function isNushell(shell: string) {
65
+ const name = basename(shell).toLowerCase()
66
+ const raw = shell.toLowerCase()
67
+ return name === "nu" || name === "nu.exe" || raw.endsWith("\\nu.exe")
68
+ }
69
+
70
+ export function loadShellEnv(shell: string, logger: ShellEnvLogger) {
71
+ if (isNushell(shell)) {
72
+ logger.log(`[server] Skipping shell env probe for nushell: ${shell}`)
73
+ return null
74
+ }
75
+
76
+ const interactive = probe(shell, "-il")
77
+ if (interactive.type === "Loaded") {
78
+ logger.log(`[server] Loaded shell environment with -il (${Object.keys(interactive.value).length} vars)`)
79
+ return interactive.value
80
+ }
81
+ if (interactive.type === "Timeout") {
82
+ logger.log(`[server] Interactive shell env probe timed out: ${shell}`)
83
+ return null
84
+ }
85
+
86
+ const login = probe(shell, "-l")
87
+ if (login.type === "Loaded") {
88
+ logger.log(`[server] Loaded shell environment with -l (${Object.keys(login.value).length} vars)`)
89
+ return login.value
90
+ }
91
+
92
+ logger.log(`[server] Falling back to app environment: ${shell}`)
93
+ return null
94
+ }
95
+
96
+ export function mergeShellEnv(shell: Record<string, string> | null, env: Record<string, string>) {
97
+ return {
98
+ ...shell,
99
+ ...env,
100
+ }
101
+ }
@@ -0,0 +1,157 @@
1
+ import * as http from "node:http"
2
+ import * as tls from "node:tls"
3
+
4
+ type NodeHttpWithEnvProxy = typeof http & {
5
+ setGlobalProxyFromEnv: () => void
6
+ }
7
+
8
+ type NodeTlsWithSystemCertificates = typeof tls & {
9
+ getCACertificates: (type: "default" | "system") => string[]
10
+ setDefaultCACertificates: (certificates: string[]) => void
11
+ }
12
+
13
+ type StartCommand = {
14
+ type: "start"
15
+ hostname: string
16
+ port: number
17
+ password: string
18
+ userDataPath: string
19
+ }
20
+
21
+ type StopCommand = { type: "stop" }
22
+ type SidecarCommand = StartCommand | StopCommand
23
+
24
+ type SidecarMessage =
25
+ | { type: "ready" }
26
+ | { type: "stopped" }
27
+ | { type: "error"; error: { message: string; stack?: string } }
28
+
29
+ type ParentPort = {
30
+ postMessage(message: SidecarMessage): void
31
+ on(event: "message", listener: (event: { data: unknown }) => void): void
32
+ }
33
+
34
+ type Listener = {
35
+ stop(close?: boolean): void | Promise<void>
36
+ }
37
+
38
+ const parentPort = getParentPort()
39
+ let listener: Listener | undefined
40
+
41
+ parentPort.on("message", (event) => {
42
+ const command = parseCommand(event.data)
43
+ if (!command) return
44
+ if (command.type === "stop") {
45
+ void stop()
46
+ return
47
+ }
48
+ void start(command)
49
+ })
50
+
51
+ async function start(command: StartCommand) {
52
+ try {
53
+ prepareSidecarEnv(command.password, command.userDataPath)
54
+ ensureLoopbackNoProxy()
55
+ useSystemCertificates()
56
+ useEnvProxy()
57
+ const { Server } = await import("virtual:neurocode-server")
58
+
59
+ listener = await Server.listen({
60
+ port: command.port,
61
+ hostname: command.hostname,
62
+ username: "opencode",
63
+ password: command.password,
64
+ cors: ["oc://renderer"],
65
+ })
66
+ parentPort.postMessage({ type: "ready" })
67
+ } catch (error) {
68
+ parentPort.postMessage({ type: "error", error: serializeError(error) })
69
+ setImmediate(() => process.exit(1))
70
+ }
71
+ }
72
+
73
+ async function stop() {
74
+ try {
75
+ await listener?.stop()
76
+ } finally {
77
+ listener = undefined
78
+ parentPort.postMessage({ type: "stopped" })
79
+ setImmediate(() => process.exit(0))
80
+ }
81
+ }
82
+
83
+ function prepareSidecarEnv(password: string, userDataPath: string) {
84
+ Object.assign(process.env, {
85
+ NEUROCODE_SERVER_USERNAME: "opencode",
86
+ NEUROCODE_SERVER_PASSWORD: password,
87
+ XDG_STATE_HOME: process.env.XDG_STATE_HOME ?? userDataPath,
88
+ })
89
+ }
90
+
91
+ function ensureLoopbackNoProxy() {
92
+ const loopback = ["127.0.0.1", "localhost", "::1"]
93
+ const upsert = (key: string) => {
94
+ const items = (process.env[key] ?? "")
95
+ .split(",")
96
+ .map((value: string) => value.trim())
97
+ .filter((value: string) => Boolean(value))
98
+
99
+ for (const host of loopback) {
100
+ if (items.some((value: string) => value.toLowerCase() === host)) continue
101
+ items.push(host)
102
+ }
103
+
104
+ process.env[key] = items.join(",")
105
+ }
106
+
107
+ upsert("NO_PROXY")
108
+ upsert("no_proxy")
109
+ }
110
+
111
+ function useSystemCertificates() {
112
+ try {
113
+ const nodeTls = tls as NodeTlsWithSystemCertificates
114
+ nodeTls.setDefaultCACertificates([
115
+ ...new Set([...nodeTls.getCACertificates("default"), ...nodeTls.getCACertificates("system")]),
116
+ ])
117
+ } catch (error) {
118
+ console.warn("failed to load system certificates", error)
119
+ }
120
+ }
121
+
122
+ function useEnvProxy() {
123
+ try {
124
+ ;(http as NodeHttpWithEnvProxy).setGlobalProxyFromEnv()
125
+ } catch (error) {
126
+ console.warn("failed to load proxy environment", error)
127
+ }
128
+ }
129
+
130
+ function parseCommand(value: unknown): SidecarCommand | undefined {
131
+ if (!value || typeof value !== "object") return
132
+ const command = value as Partial<StartCommand | StopCommand>
133
+ if (command.type === "stop") return { type: "stop" }
134
+ if (command.type !== "start") return
135
+ if (typeof command.hostname !== "string") return
136
+ if (typeof command.port !== "number") return
137
+ if (typeof command.password !== "string") return
138
+ if (typeof command.userDataPath !== "string") return
139
+ return {
140
+ type: "start",
141
+ hostname: command.hostname,
142
+ port: command.port,
143
+ password: command.password,
144
+ userDataPath: command.userDataPath,
145
+ }
146
+ }
147
+
148
+ function serializeError(error: unknown) {
149
+ if (error instanceof Error) return { message: error.message, stack: error.stack }
150
+ return { message: String(error) }
151
+ }
152
+
153
+ function getParentPort() {
154
+ const port = process.parentPort as ParentPort | undefined
155
+ if (!port) throw new Error("Sidecar parent port unavailable")
156
+ return port
157
+ }
@@ -0,0 +1,93 @@
1
+ import { afterEach, describe, expect, test } from "bun:test"
2
+ import { mkdtemp, readdir, rm, utimes, writeFile } from "node:fs/promises"
3
+ import { tmpdir } from "node:os"
4
+ import { join } from "node:path"
5
+ import { cleanupStoreFiles, deleteStoreFileIfEmpty } from "./store-cleanup"
6
+
7
+ const roots: string[] = []
8
+
9
+ async function tempRoot() {
10
+ const root = await mkdtemp(join(tmpdir(), "opencode-store-cleanup-"))
11
+ roots.push(root)
12
+ return root
13
+ }
14
+
15
+ async function writeStore(root: string, name: string, value: string, modified: Date) {
16
+ await writeFile(join(root, name), value)
17
+ await utimes(join(root, name), modified, modified)
18
+ }
19
+
20
+ afterEach(async () => {
21
+ await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
22
+ })
23
+
24
+ describe("store cleanup", () => {
25
+ test("removes empty scoped stores and leaves global stores alone", async () => {
26
+ const root = await tempRoot()
27
+ const now = new Date("2026-07-01T00:00:00.000Z")
28
+ await writeStore(root, "neurocode.draft.empty.dat", "{}", now)
29
+ await writeStore(root, "neurocode.workspace.empty.dat", "{\n}", now)
30
+ await writeStore(root, "neurocode.global.dat", "{}", now)
31
+ await writeStore(root, "neurocode.workspace.empty.dat.json", "{}", now)
32
+
33
+ const result = await cleanupStoreFiles(root, now.getTime())
34
+
35
+ expect(result.deleted.sort()).toEqual(["neurocode.draft.empty.dat", "neurocode.workspace.empty.dat"])
36
+ expect((await readdir(root)).sort()).toEqual(["neurocode.global.dat", "neurocode.workspace.empty.dat.json"])
37
+ })
38
+
39
+ test("removes stale drafts by age without removing non-empty workspace stores", async () => {
40
+ const root = await tempRoot()
41
+ const now = new Date("2026-07-01T00:00:00.000Z")
42
+ await writeStore(root, "neurocode.draft.old.dat", '{"draft:prompt":"hello"}', new Date("2026-05-01T00:00:00.000Z"))
43
+ await writeStore(root, "neurocode.draft.recent.dat", '{"draft:prompt":"hello"}', now)
44
+ await writeStore(
45
+ root,
46
+ "neurocode.workspace.old.dat",
47
+ '{"workspace:layout":"wide"}',
48
+ new Date("2025-01-01T00:00:00.000Z"),
49
+ )
50
+ await writeStore(root, "neurocode.workspace.recent.dat", '{"workspace:layout":"wide"}', now)
51
+
52
+ const result = await cleanupStoreFiles(root, now.getTime())
53
+
54
+ expect(result.deleted).toEqual(["neurocode.draft.old.dat"])
55
+ expect((await readdir(root)).sort()).toEqual([
56
+ "neurocode.draft.recent.dat",
57
+ "neurocode.workspace.old.dat",
58
+ "neurocode.workspace.recent.dat",
59
+ ])
60
+ })
61
+
62
+ test("caps scoped stores by recency", async () => {
63
+ const root = await tempRoot()
64
+ const now = new Date("2026-07-01T00:00:00.000Z")
65
+ await Promise.all(
66
+ Array.from({ length: 102 }, (_, index) =>
67
+ writeStore(
68
+ root,
69
+ `neurocode.draft.${index}.dat`,
70
+ '{"draft:prompt":"hello"}',
71
+ new Date(now.getTime() - index * 1000),
72
+ ),
73
+ ),
74
+ )
75
+
76
+ const result = await cleanupStoreFiles(root, now.getTime())
77
+
78
+ const remaining = await readdir(root)
79
+
80
+ expect(result.deleted.sort()).toEqual(["neurocode.draft.100.dat", "neurocode.draft.101.dat"])
81
+ expect(remaining).toHaveLength(100)
82
+ })
83
+
84
+ test("removes a scoped store immediately when it becomes empty", async () => {
85
+ const root = await tempRoot()
86
+ await writeStore(root, "neurocode.draft.empty.dat", "{}", new Date("2026-07-01T00:00:00.000Z"))
87
+ await writeStore(root, "neurocode.global.dat", "{}", new Date("2026-07-01T00:00:00.000Z"))
88
+
89
+ expect(await deleteStoreFileIfEmpty(root, "neurocode.draft.empty.dat")).toBe(true)
90
+ expect(await deleteStoreFileIfEmpty(root, "neurocode.global.dat")).toBe(false)
91
+ expect(await readdir(root)).toEqual(["neurocode.global.dat"])
92
+ })
93
+ })