@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.
- package/AGENTS.md +4 -0
- package/README.md +19 -0
- package/electron-builder.config.test.ts +48 -0
- package/electron-builder.config.ts +146 -0
- package/electron.vite.config.ts +106 -0
- package/icons/README.md +14 -0
- package/icons/beta/128x128.png +0 -0
- package/icons/beta/128x128@2x.png +0 -0
- package/icons/beta/32x32.png +0 -0
- package/icons/beta/64x64.png +0 -0
- package/icons/beta/Square107x107Logo.png +0 -0
- package/icons/beta/Square142x142Logo.png +0 -0
- package/icons/beta/Square150x150Logo.png +0 -0
- package/icons/beta/Square284x284Logo.png +0 -0
- package/icons/beta/Square30x30Logo.png +0 -0
- package/icons/beta/Square310x310Logo.png +0 -0
- package/icons/beta/Square44x44Logo.png +0 -0
- package/icons/beta/Square71x71Logo.png +0 -0
- package/icons/beta/Square89x89Logo.png +0 -0
- package/icons/beta/StoreLogo.png +0 -0
- package/icons/beta/android/mipmap-anydpi-v26/ic_launcher.xml +5 -0
- package/icons/beta/android/mipmap-hdpi/ic_launcher.png +0 -0
- package/icons/beta/android/mipmap-hdpi/ic_launcher_foreground.png +0 -0
- package/icons/beta/android/mipmap-hdpi/ic_launcher_round.png +0 -0
- package/icons/beta/android/mipmap-mdpi/ic_launcher.png +0 -0
- package/icons/beta/android/mipmap-mdpi/ic_launcher_foreground.png +0 -0
- package/icons/beta/android/mipmap-mdpi/ic_launcher_round.png +0 -0
- package/icons/beta/android/mipmap-xhdpi/ic_launcher.png +0 -0
- package/icons/beta/android/mipmap-xhdpi/ic_launcher_foreground.png +0 -0
- package/icons/beta/android/mipmap-xhdpi/ic_launcher_round.png +0 -0
- package/icons/beta/android/mipmap-xxhdpi/ic_launcher.png +0 -0
- package/icons/beta/android/mipmap-xxhdpi/ic_launcher_foreground.png +0 -0
- package/icons/beta/android/mipmap-xxhdpi/ic_launcher_round.png +0 -0
- package/icons/beta/android/mipmap-xxxhdpi/ic_launcher.png +0 -0
- package/icons/beta/android/mipmap-xxxhdpi/ic_launcher_foreground.png +0 -0
- package/icons/beta/android/mipmap-xxxhdpi/ic_launcher_round.png +0 -0
- package/icons/beta/android/values/ic_launcher_background.xml +4 -0
- package/icons/beta/dock.png +0 -0
- package/icons/beta/icon.icns +0 -0
- package/icons/beta/icon.ico +0 -0
- package/icons/beta/icon.png +0 -0
- package/icons/beta/ios/AppIcon-20x20@1x.png +0 -0
- package/icons/beta/ios/AppIcon-20x20@2x-1.png +0 -0
- package/icons/beta/ios/AppIcon-20x20@2x.png +0 -0
- package/icons/beta/ios/AppIcon-20x20@3x.png +0 -0
- package/icons/beta/ios/AppIcon-29x29@1x.png +0 -0
- package/icons/beta/ios/AppIcon-29x29@2x-1.png +0 -0
- package/icons/beta/ios/AppIcon-29x29@2x.png +0 -0
- package/icons/beta/ios/AppIcon-29x29@3x.png +0 -0
- package/icons/beta/ios/AppIcon-40x40@1x.png +0 -0
- package/icons/beta/ios/AppIcon-40x40@2x-1.png +0 -0
- package/icons/beta/ios/AppIcon-40x40@2x.png +0 -0
- package/icons/beta/ios/AppIcon-40x40@3x.png +0 -0
- package/icons/beta/ios/AppIcon-512@2x.png +0 -0
- package/icons/beta/ios/AppIcon-60x60@2x.png +0 -0
- package/icons/beta/ios/AppIcon-60x60@3x.png +0 -0
- package/icons/beta/ios/AppIcon-76x76@1x.png +0 -0
- package/icons/beta/ios/AppIcon-76x76@2x.png +0 -0
- package/icons/beta/ios/AppIcon-83.5x83.5@2x.png +0 -0
- package/icons/dev/128x128.png +0 -0
- package/icons/dev/128x128@2x.png +0 -0
- package/icons/dev/32x32.png +0 -0
- package/icons/dev/64x64.png +0 -0
- package/icons/dev/Square107x107Logo.png +0 -0
- package/icons/dev/Square142x142Logo.png +0 -0
- package/icons/dev/Square150x150Logo.png +0 -0
- package/icons/dev/Square284x284Logo.png +0 -0
- package/icons/dev/Square30x30Logo.png +0 -0
- package/icons/dev/Square310x310Logo.png +0 -0
- package/icons/dev/Square44x44Logo.png +0 -0
- package/icons/dev/Square71x71Logo.png +0 -0
- package/icons/dev/Square89x89Logo.png +0 -0
- package/icons/dev/StoreLogo.png +0 -0
- package/icons/dev/android/mipmap-anydpi-v26/ic_launcher.xml +5 -0
- package/icons/dev/android/mipmap-hdpi/ic_launcher.png +0 -0
- package/icons/dev/android/mipmap-hdpi/ic_launcher_foreground.png +0 -0
- package/icons/dev/android/mipmap-hdpi/ic_launcher_round.png +0 -0
- package/icons/dev/android/mipmap-mdpi/ic_launcher.png +0 -0
- package/icons/dev/android/mipmap-mdpi/ic_launcher_foreground.png +0 -0
- package/icons/dev/android/mipmap-mdpi/ic_launcher_round.png +0 -0
- package/icons/dev/android/mipmap-xhdpi/ic_launcher.png +0 -0
- package/icons/dev/android/mipmap-xhdpi/ic_launcher_foreground.png +0 -0
- package/icons/dev/android/mipmap-xhdpi/ic_launcher_round.png +0 -0
- package/icons/dev/android/mipmap-xxhdpi/ic_launcher.png +0 -0
- package/icons/dev/android/mipmap-xxhdpi/ic_launcher_foreground.png +0 -0
- package/icons/dev/android/mipmap-xxhdpi/ic_launcher_round.png +0 -0
- package/icons/dev/android/mipmap-xxxhdpi/ic_launcher.png +0 -0
- package/icons/dev/android/mipmap-xxxhdpi/ic_launcher_foreground.png +0 -0
- package/icons/dev/android/mipmap-xxxhdpi/ic_launcher_round.png +0 -0
- package/icons/dev/android/values/ic_launcher_background.xml +4 -0
- package/icons/dev/dock.png +0 -0
- package/icons/dev/icon.icns +0 -0
- package/icons/dev/icon.ico +0 -0
- package/icons/dev/icon.png +0 -0
- package/icons/dev/ios/AppIcon-20x20@1x.png +0 -0
- package/icons/dev/ios/AppIcon-20x20@2x-1.png +0 -0
- package/icons/dev/ios/AppIcon-20x20@2x.png +0 -0
- package/icons/dev/ios/AppIcon-20x20@3x.png +0 -0
- package/icons/dev/ios/AppIcon-29x29@1x.png +0 -0
- package/icons/dev/ios/AppIcon-29x29@2x-1.png +0 -0
- package/icons/dev/ios/AppIcon-29x29@2x.png +0 -0
- package/icons/dev/ios/AppIcon-29x29@3x.png +0 -0
- package/icons/dev/ios/AppIcon-40x40@1x.png +0 -0
- package/icons/dev/ios/AppIcon-40x40@2x-1.png +0 -0
- package/icons/dev/ios/AppIcon-40x40@2x.png +0 -0
- package/icons/dev/ios/AppIcon-40x40@3x.png +0 -0
- package/icons/dev/ios/AppIcon-512@2x.png +0 -0
- package/icons/dev/ios/AppIcon-60x60@2x.png +0 -0
- package/icons/dev/ios/AppIcon-60x60@3x.png +0 -0
- package/icons/dev/ios/AppIcon-76x76@1x.png +0 -0
- package/icons/dev/ios/AppIcon-76x76@2x.png +0 -0
- package/icons/dev/ios/AppIcon-83.5x83.5@2x.png +0 -0
- package/icons/prod/128x128.png +0 -0
- package/icons/prod/128x128@2x.png +0 -0
- package/icons/prod/32x32.png +0 -0
- package/icons/prod/64x64.png +0 -0
- package/icons/prod/Square107x107Logo.png +0 -0
- package/icons/prod/Square142x142Logo.png +0 -0
- package/icons/prod/Square150x150Logo.png +0 -0
- package/icons/prod/Square284x284Logo.png +0 -0
- package/icons/prod/Square30x30Logo.png +0 -0
- package/icons/prod/Square310x310Logo.png +0 -0
- package/icons/prod/Square44x44Logo.png +0 -0
- package/icons/prod/Square71x71Logo.png +0 -0
- package/icons/prod/Square89x89Logo.png +0 -0
- package/icons/prod/StoreLogo.png +0 -0
- package/icons/prod/android/mipmap-anydpi-v26/ic_launcher.xml +5 -0
- package/icons/prod/android/mipmap-hdpi/ic_launcher.png +0 -0
- package/icons/prod/android/mipmap-hdpi/ic_launcher_foreground.png +0 -0
- package/icons/prod/android/mipmap-hdpi/ic_launcher_round.png +0 -0
- package/icons/prod/android/mipmap-mdpi/ic_launcher.png +0 -0
- package/icons/prod/android/mipmap-mdpi/ic_launcher_foreground.png +0 -0
- package/icons/prod/android/mipmap-mdpi/ic_launcher_round.png +0 -0
- package/icons/prod/android/mipmap-xhdpi/ic_launcher.png +0 -0
- package/icons/prod/android/mipmap-xhdpi/ic_launcher_foreground.png +0 -0
- package/icons/prod/android/mipmap-xhdpi/ic_launcher_round.png +0 -0
- package/icons/prod/android/mipmap-xxhdpi/ic_launcher.png +0 -0
- package/icons/prod/android/mipmap-xxhdpi/ic_launcher_foreground.png +0 -0
- package/icons/prod/android/mipmap-xxhdpi/ic_launcher_round.png +0 -0
- package/icons/prod/android/mipmap-xxxhdpi/ic_launcher.png +0 -0
- package/icons/prod/android/mipmap-xxxhdpi/ic_launcher_foreground.png +0 -0
- package/icons/prod/android/mipmap-xxxhdpi/ic_launcher_round.png +0 -0
- package/icons/prod/android/values/ic_launcher_background.xml +4 -0
- package/icons/prod/dock.png +0 -0
- package/icons/prod/icon.icns +0 -0
- package/icons/prod/icon.ico +0 -0
- package/icons/prod/icon.png +0 -0
- package/icons/prod/ios/AppIcon-20x20@1x.png +0 -0
- package/icons/prod/ios/AppIcon-20x20@2x-1.png +0 -0
- package/icons/prod/ios/AppIcon-20x20@2x.png +0 -0
- package/icons/prod/ios/AppIcon-20x20@3x.png +0 -0
- package/icons/prod/ios/AppIcon-29x29@1x.png +0 -0
- package/icons/prod/ios/AppIcon-29x29@2x-1.png +0 -0
- package/icons/prod/ios/AppIcon-29x29@2x.png +0 -0
- package/icons/prod/ios/AppIcon-29x29@3x.png +0 -0
- package/icons/prod/ios/AppIcon-40x40@1x.png +0 -0
- package/icons/prod/ios/AppIcon-40x40@2x-1.png +0 -0
- package/icons/prod/ios/AppIcon-40x40@2x.png +0 -0
- package/icons/prod/ios/AppIcon-40x40@3x.png +0 -0
- package/icons/prod/ios/AppIcon-512@2x.png +0 -0
- package/icons/prod/ios/AppIcon-60x60@2x.png +0 -0
- package/icons/prod/ios/AppIcon-60x60@3x.png +0 -0
- package/icons/prod/ios/AppIcon-76x76@1x.png +0 -0
- package/icons/prod/ios/AppIcon-76x76@2x.png +0 -0
- package/icons/prod/ios/AppIcon-83.5x83.5@2x.png +0 -0
- package/package.json +75 -0
- package/resources/entitlements.plist +18 -0
- package/resources/linux/opencode-desktop.desktop +10 -0
- package/scripts/copy-bundles.ts +12 -0
- package/scripts/copy-icons.ts +12 -0
- package/scripts/copy-metainfo.ts +47 -0
- package/scripts/finalize-latest-json.ts +219 -0
- package/scripts/finalize-latest-yml.ts +124 -0
- package/scripts/prebuild.ts +10 -0
- package/scripts/predev.ts +5 -0
- package/scripts/prepare.ts +9 -0
- package/scripts/utils.ts +77 -0
- package/src/main/apps.ts +136 -0
- package/src/main/attachment-picker.test.ts +87 -0
- package/src/main/attachment-picker.ts +56 -0
- package/src/main/constants.ts +7 -0
- package/src/main/debug.ts +95 -0
- package/src/main/desktop-menu-actions.ts +84 -0
- package/src/main/env.d.ts +19 -0
- package/src/main/index.test.ts +37 -0
- package/src/main/index.ts +399 -0
- package/src/main/initialization.ts +6 -0
- package/src/main/install-state.test.ts +19 -0
- package/src/main/install-state.ts +8 -0
- package/src/main/ipc.ts +274 -0
- package/src/main/logging.ts +205 -0
- package/src/main/markdown.ts +16 -0
- package/src/main/menu.ts +67 -0
- package/src/main/migrate.ts +91 -0
- package/src/main/onboarding.ts +45 -0
- package/src/main/server.ts +238 -0
- package/src/main/shell-env.test.ts +50 -0
- package/src/main/shell-env.ts +101 -0
- package/src/main/sidecar.ts +157 -0
- package/src/main/store-cleanup.test.ts +93 -0
- package/src/main/store-cleanup.ts +94 -0
- package/src/main/store-keys.ts +7 -0
- package/src/main/store.ts +35 -0
- package/src/main/unresponsive.ts +70 -0
- package/src/main/updater-controller.test.ts +111 -0
- package/src/main/updater-controller.ts +97 -0
- package/src/main/updater-subscriptions.test.ts +16 -0
- package/src/main/updater-subscriptions.ts +20 -0
- package/src/main/updater.ts +85 -0
- package/src/main/window-registry.test.ts +91 -0
- package/src/main/window-registry.ts +47 -0
- package/src/main/window-state.ts +29 -0
- package/src/main/windows.ts +496 -0
- package/src/main/wsl/ipc.ts +103 -0
- package/src/main/wsl/policy.ts +33 -0
- package/src/main/wsl/runtime.ts +400 -0
- package/src/main/wsl/servers.test.ts +231 -0
- package/src/main/wsl/servers.ts +521 -0
- package/src/main/wsl/sidecar.ts +129 -0
- package/src/main/wsl/startup.ts +31 -0
- package/src/preload/index.ts +128 -0
- package/src/preload/types.ts +109 -0
- package/src/renderer/cli.ts +12 -0
- package/src/renderer/env.d.ts +10 -0
- package/src/renderer/html.test.ts +62 -0
- package/src/renderer/i18n/ar.ts +26 -0
- package/src/renderer/i18n/br.ts +27 -0
- package/src/renderer/i18n/bs.ts +28 -0
- package/src/renderer/i18n/da.ts +28 -0
- package/src/renderer/i18n/de.ts +28 -0
- package/src/renderer/i18n/en.ts +27 -0
- package/src/renderer/i18n/es.ts +27 -0
- package/src/renderer/i18n/fr.ts +28 -0
- package/src/renderer/i18n/index.ts +194 -0
- package/src/renderer/i18n/ja.ts +28 -0
- package/src/renderer/i18n/ko.ts +27 -0
- package/src/renderer/i18n/no.ts +28 -0
- package/src/renderer/i18n/pl.ts +28 -0
- package/src/renderer/i18n/ru.ts +27 -0
- package/src/renderer/i18n/uk.ts +28 -0
- package/src/renderer/i18n/zh.ts +26 -0
- package/src/renderer/i18n/zht.ts +26 -0
- package/src/renderer/index.html +21 -0
- package/src/renderer/index.tsx +464 -0
- package/src/renderer/initialization.test.ts +73 -0
- package/src/renderer/initialization.ts +22 -0
- package/src/renderer/onboarding.tsx +53 -0
- package/src/renderer/styles.css +0 -0
- package/src/renderer/webview-zoom.ts +138 -0
- package/src/renderer/wsl/connections.test.ts +43 -0
- package/src/renderer/wsl/connections.ts +28 -0
- package/sst-env.d.ts +10 -0
- 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
|
+
})
|