From 8de565bc6bdab715d549981d5c7ac03eb43f085a Mon Sep 17 00:00:00 2001 From: Jainath Ponnala <1083824+jainath@users.noreply.github.com> Date: Sat, 13 Jun 2026 19:50:33 -0400 Subject: [PATCH 1/3] feat: v1.1.0 - menubar, notifications, bulk import, search, and a hardening pass Features: - Menubar (tray) presence: start/stop apps and see ports without the window - Desktop notifications on crash (and optionally on ready) - Bulk import: register a whole folder of repos at once - Global log search (Cmd+Shift+F) across every running task - Monorepo detection, per-app auto-start, launch at login, folder start/stop-all - Graceful quit confirmation; update banner shows release notes; local diagnostics log Hardening: - Secret env values encrypted at rest (safeStorage); a project .env can no longer override user-set env or process-control vars (PATH/NODE_OPTIONS/DYLD_*) - Fix silently-dead restart-on-change (chokidar v5 globs), unremovable run-once apps, stuck "starting" state, start/stop races, and readiness hangs - env_vars schema rebuilt so task-scoped overrides save (migration 0006); deleted task vars no longer resurrect; WAL-safe DB export/reset; corrupt-DB recovery dialog - Faster process/port polling; bounded log memory; visibility-gated log streaming Accessibility: - Focus-trapped dialogs, keyboard menus/comboboxes, focusable sidebar rows, accessible status indicators, screen-reader-readable logs, light terminal theme Schema migrations 0006 (env scope indexes) and 0007 (auto_start). Also normalizes punctuation (em/en dashes to hyphens) across comments and strings. --- package.json | 18 +- pnpm-lock.yaml | 667 +++++++++++++++++- src/main/db/backfill.ts | 4 +- src/main/db/index.ts | 56 +- .../db/migrations/0004_env_task_scope.sql | 2 +- src/main/db/migrations/0005_app_folders.sql | 2 +- .../db/migrations/0006_env_scope_unique.sql | 50 ++ src/main/db/migrations/0007_app_autostart.sql | 4 + src/main/index.ts | 171 ++++- src/main/ipc/index.ts | 326 ++++++++- src/main/menu.ts | 8 +- src/main/services/AppOrchestrator.ts | 178 ++++- src/main/services/AppRegistry.ts | 66 +- src/main/services/DeepLinks.ts | 28 +- src/main/services/DetectionService.ts | 146 +++- src/main/services/EnvBuilder.ts | 64 +- src/main/services/EnvStore.ts | 121 +++- src/main/services/LogBuffer.ts | 75 +- src/main/services/Logger.ts | 64 ++ src/main/services/NodeResolver.ts | 2 +- src/main/services/OpenIn.ts | 6 +- src/main/services/PathProbe.ts | 50 +- src/main/services/PortDetector.ts | 193 +++-- src/main/services/RestartWatcher.ts | 59 +- src/main/services/RunHistory.ts | 33 + src/main/services/Settings.ts | 30 +- src/main/services/StatsMonitor.ts | 61 +- src/main/services/TaskRegistry.ts | 50 +- src/main/services/TaskRunner.ts | 292 ++++++-- src/main/services/TrayController.ts | 142 ++++ src/main/services/Updater.ts | 96 ++- .../__tests__/AppOrchestrator.e2e.test.ts | 23 +- .../__tests__/AppOrchestrator.state.test.ts | 2 +- .../services/__tests__/EnvLayering.test.ts | 248 ++++--- .../services/__tests__/RestartWatcher.test.ts | 37 + src/main/services/__tests__/Settings.test.ts | 4 +- src/main/services/readiness/LogReadiness.ts | 2 +- src/main/services/readiness/PortReadiness.ts | 2 +- src/renderer/App.tsx | 138 ++-- src/renderer/components/AddAppDrawer.tsx | 534 +++++++++----- src/renderer/components/AppConfigDrawer.tsx | 58 +- src/renderer/components/AppDetail.tsx | 47 +- src/renderer/components/CommandPalette.tsx | 69 +- src/renderer/components/ContextMenu.tsx | 152 +++- src/renderer/components/CrashPin.tsx | 79 ++- src/renderer/components/Dashboard.tsx | 104 +-- src/renderer/components/EmptyState.tsx | 103 ++- src/renderer/components/EnvEditor.tsx | 169 ++++- src/renderer/components/ErrorBoundary.tsx | 52 ++ src/renderer/components/FolderSelect.tsx | 154 +++- src/renderer/components/GlobalLogSearch.tsx | 136 ++++ .../components/ImportProjectsDrawer.tsx | 202 ++++++ src/renderer/components/LogSearchView.tsx | 29 +- src/renderer/components/LogTerminal.tsx | 63 +- src/renderer/components/NodeVersionPicker.tsx | 8 +- src/renderer/components/OpenInMenu.tsx | 118 +++- src/renderer/components/PortChip.tsx | 4 +- src/renderer/components/PromptModal.tsx | 164 +++-- src/renderer/components/SettingsDrawer.tsx | 71 +- src/renderer/components/Sidebar.tsx | 294 ++++++-- src/renderer/components/StatusDot.tsx | 22 +- src/renderer/components/TagInput.tsx | 62 +- src/renderer/components/TaskEditor.tsx | 33 +- src/renderer/components/TaskTabs.tsx | 152 ++-- src/renderer/components/Toast.tsx | 88 +++ src/renderer/components/UpdateBanner.tsx | 164 ++++- src/renderer/hooks/useDialog.ts | 113 +++ src/renderer/index.html | 2 +- src/renderer/lib/invoke.ts | 36 + src/renderer/lib/processState.ts | 46 ++ src/renderer/main.tsx | 5 +- src/renderer/styles.css | 38 +- src/shared/__tests__/dotenv.test.ts | 31 + src/shared/dotenv.ts | 97 ++- src/shared/ipc.ts | 251 +++++-- src/shared/types.ts | 15 + 76 files changed, 6036 insertions(+), 1249 deletions(-) create mode 100644 src/main/db/migrations/0006_env_scope_unique.sql create mode 100644 src/main/db/migrations/0007_app_autostart.sql create mode 100644 src/main/services/Logger.ts create mode 100644 src/main/services/TrayController.ts create mode 100644 src/main/services/__tests__/RestartWatcher.test.ts create mode 100644 src/renderer/components/ErrorBoundary.tsx create mode 100644 src/renderer/components/GlobalLogSearch.tsx create mode 100644 src/renderer/components/ImportProjectsDrawer.tsx create mode 100644 src/renderer/components/Toast.tsx create mode 100644 src/renderer/hooks/useDialog.ts create mode 100644 src/renderer/lib/invoke.ts create mode 100644 src/renderer/lib/processState.ts diff --git a/package.json b/package.json index 3bb1c28..15d9693 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,8 @@ { "name": "devharbor", - "version": "1.0.1", + "version": "1.1.0", "private": true, - "description": "DevHarbor — a harbor for your local dev servers. Desktop app for managing local Node.js projects on macOS.", + "description": "DevHarbor - a harbor for your local dev servers. Desktop app for managing local Node.js projects on macOS.", "author": "Jainath Ponnala", "license": "AGPL-3.0-only", "homepage": "https://www.devharbor.app", @@ -21,6 +21,10 @@ "typecheck:node": "tsc --noEmit -p tsconfig.node.json", "typecheck:web": "tsc --noEmit -p tsconfig.web.json", "typecheck": "pnpm typecheck:node && pnpm typecheck:web", + "lint": "eslint .", + "lint:fix": "eslint . --fix", + "format": "prettier --write .", + "format:check": "prettier --check .", "test": "vitest run", "test:watch": "vitest", "rebuild": "electron-rebuild -f -w better-sqlite3,@homebridge/node-pty-prebuilt-multiarch", @@ -30,7 +34,7 @@ "pack:mac:universal": "electron-vite build && electron-builder --mac --universal" }, "comments": { - "dependencies": "ONLY main-process / native modules that electron-vite externalizes belong here — electron-builder ships every production dependency into the asar as raw node_modules. Renderer-only libs (react, lucide-react, xterm, etc.) are bundled into out/renderer by Vite, so they live in devDependencies to avoid being shipped twice (lucide-react alone is 3,500+ files)." + "dependencies": "ONLY main-process / native modules that electron-vite externalizes belong here - electron-builder ships every production dependency into the asar as raw node_modules. Renderer-only libs (react, lucide-react, xterm, etc.) are bundled into out/renderer by Vite, so they live in devDependencies to avoid being shipped twice (lucide-react alone is 3,500+ files)." }, "dependencies": { "@homebridge/node-pty-prebuilt-multiarch": "^0.13.1", @@ -38,16 +42,20 @@ "chokidar": "^5.0.0", "electron-updater": "^6.8.3", "package-manager-detector": "^1.6.0", + "picomatch": "^4.0.4", "pidusage": "^4.0.1", "semver": "^7.8.1", "tree-kill": "^1.2.2", "ulid": "^2.3.0" }, "devDependencies": { + "@electron/fuses": "^1.8.0", "@electron/notarize": "^3.1.1", "@electron/rebuild": "^3.7.0", + "@eslint/js": "^9.39.4", "@types/better-sqlite3": "^7.6.11", "@types/node": "^22.9.0", + "@types/picomatch": "^3.0.2", "@types/pidusage": "^2.0.5", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", @@ -66,14 +74,18 @@ "electron": "^33.2.0", "electron-builder": "^25.1.8", "electron-vite": "^2.3.0", + "eslint": "^9.39.4", + "eslint-plugin-react-hooks": "^5.2.0", "lucide-react": "^0.460.0", "postcss": "^8.4.49", + "prettier": "^3.8.4", "react": "^18.3.1", "react-dom": "^18.3.1", "react-window": "^2.2.7", "tailwind-merge": "^2.5.4", "tailwindcss": "^3.4.15", "typescript": "^5.6.3", + "typescript-eslint": "^8.61.0", "vite": "^5.4.11", "vitest": "^2.1.9", "zustand": "^5.0.1" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e33a9e3..4c65f4f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: package-manager-detector: specifier: ^1.6.0 version: 1.6.0 + picomatch: + specifier: ^4.0.4 + version: 4.0.4 pidusage: specifier: ^4.0.1 version: 4.0.1 @@ -36,18 +39,27 @@ importers: specifier: ^2.3.0 version: 2.4.0 devDependencies: + '@electron/fuses': + specifier: ^1.8.0 + version: 1.8.0 '@electron/notarize': specifier: ^3.1.1 version: 3.1.1 '@electron/rebuild': specifier: ^3.7.0 version: 3.7.2 + '@eslint/js': + specifier: ^9.39.4 + version: 9.39.4 '@types/better-sqlite3': specifier: ^7.6.11 version: 7.6.13 '@types/node': specifier: ^22.9.0 version: 22.19.19 + '@types/picomatch': + specifier: ^3.0.2 + version: 3.0.2 '@types/pidusage': specifier: ^2.0.5 version: 2.0.5 @@ -102,12 +114,21 @@ importers: electron-vite: specifier: ^2.3.0 version: 2.3.0(vite@5.4.21(@types/node@22.19.19)) + eslint: + specifier: ^9.39.4 + version: 9.39.4(jiti@1.21.7) + eslint-plugin-react-hooks: + specifier: ^5.2.0 + version: 5.2.0(eslint@9.39.4(jiti@1.21.7)) lucide-react: specifier: ^0.460.0 version: 0.460.0(react@18.3.1) postcss: specifier: ^8.4.49 version: 8.5.15 + prettier: + specifier: ^3.8.4 + version: 3.8.4 react: specifier: ^18.3.1 version: 18.3.1 @@ -126,6 +147,9 @@ importers: typescript: specifier: ^5.6.3 version: 5.9.3 + typescript-eslint: + specifier: ^8.61.0 + version: 8.61.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3) vite: specifier: ^5.4.11 version: 5.4.21(@types/node@22.19.19) @@ -243,6 +267,10 @@ packages: engines: {node: '>=10.12.0'} hasBin: true + '@electron/fuses@1.8.0': + resolution: {integrity: sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw==} + hasBin: true + '@electron/get@2.0.3': resolution: {integrity: sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==} engines: {node: '>=12'} @@ -418,6 +446,44 @@ packages: cpu: [x64] os: [win32] + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.21.2': + resolution: {integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/config-helpers@0.4.2': + resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/core@0.17.0': + resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/eslintrc@3.3.5': + resolution: {integrity: sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/js@9.39.4': + resolution: {integrity: sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/object-schema@2.1.7': + resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/plugin-kit@0.4.1': + resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@gar/promisify@1.1.3': resolution: {integrity: sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==} @@ -425,6 +491,26 @@ packages: resolution: {integrity: sha512-ccQ60nMcbEGrQh0U9E6x0ajW9qJNeazpcM/9CH6J8leyNtJgb+gu24WTBAfBUVeO486ZhscnaxLEITI2HXwhow==} engines: {node: '>=18.0.0 <25.0.0'} + '@humanfs/core@0.19.2': + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.8': + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} + engines: {node: '>=18.18.0'} + + '@humanfs/types@0.15.0': + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} @@ -854,6 +940,9 @@ packages: '@types/http-cache-semantics@4.2.0': resolution: {integrity: sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==} + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/keyv@3.1.4': resolution: {integrity: sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==} @@ -866,6 +955,9 @@ packages: '@types/node@22.19.19': resolution: {integrity: sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==} + '@types/picomatch@3.0.2': + resolution: {integrity: sha512-n0i8TD3UDB7paoMMxA3Y65vUncFJXjcUf7lQY7YyKGl6031FNjfsLs6pdLFCy2GNFxItPJG8GvvpbZc2skH7WA==} + '@types/pidusage@2.0.5': resolution: {integrity: sha512-MIiyZI4/MK9UGUXWt0jJcCZhVw7YdhBuTOuqP/BjuLDLZ2PmmViMIQgZiWxtaMicQfAz/kMrZ5T7PKxFSkTeUA==} @@ -899,6 +991,65 @@ packages: '@types/yauzl@2.10.3': resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} + '@typescript-eslint/eslint-plugin@8.61.0': + resolution: {integrity: sha512-bFNvl9ZczlVb+wR2Akszf3gHfKVj/8WanXaGJ3UstTA7brNKg0cNdk6X1Psu5V7MZ2oQtzZKOEzIUehaoxbDGw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.61.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/parser@8.61.0': + resolution: {integrity: sha512-5B7PfA2e1NQGCnDHd/0lW7W3gvp3d59Ryw54FYO8Uswxo9f6ikw3AZV+Xj/TvpImmpsiYyUqAfhC6kJID1jF6w==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/project-service@8.61.0': + resolution: {integrity: sha512-DV42F7MLJO6Rax7SK1yg43tcnEfGUrurSpSxKuVX+a3RCTzBlH3fuxprrOJXKCJGAaw82xXocikJ0uQaqwXgGA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/scope-manager@8.61.0': + resolution: {integrity: sha512-IWdXFHFSb6mlC3HPc7QsLDm5zYEbUla6trDEHf32D3/dnuUyXd87plScSNXSbm0/RxMvObpI17sv/EDTGrGZkA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.61.0': + resolution: {integrity: sha512-O5Amvdv9ztMpxpf+vmFULGG78IE6Qwdr3bCGvqwG4nwc9H2qXkOYJJnRbRHyMkQTjv1d03olqwwwzHLMqpFePQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/type-utils@8.61.0': + resolution: {integrity: sha512-TuBiQYIkd97yBfInHCTKVYMbX4kvEmpOEuixIuzCU9p8BGT1SfyyO0d0IfDMbPIHcjn/hWnusUX5e8v5Xg+X8A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/types@8.61.0': + resolution: {integrity: sha512-9QTQpZ5Iin4CdIodfbDQFSeiSJKidgYJYug1P9CC2xWgUTvlmixViqDZNciMjwLBZyJnG4tGmPl97rVAFb1AJg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.61.0': + resolution: {integrity: sha512-42zatd5qSvvcV1JdDBCLxYRznvP4eIHpPoZXdkPFnAmanA4FuZ5dibSnCBggY8hQnqajPpoGjXFdZ7fIJKQnlA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/utils@8.61.0': + resolution: {integrity: sha512-3bzFt7ImFMW/jVYwJamDoe/dMOdFLSC6pom6rRjdh4SZJEYupyMzem8e7vKZLclLfpHjlwSAXOUxtKxGXUiLqA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/visitor-keys@8.61.0': + resolution: {integrity: sha512-QVLZu3ZPQEE+HICQyAMZ2yLQhxf0meY/wx6Hx14YcTNj13JB3qHlX3lJ02L3fLGHgERRH71kvYDwiXIguT3AjQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@vitejs/plugin-react@4.7.0': resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} engines: {node: ^14.18.0 || >=16.0.0} @@ -953,6 +1104,16 @@ packages: abbrev@1.1.1: resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==} + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.17.0: + resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==} + engines: {node: '>=0.4.0'} + hasBin: true + agent-base@6.0.2: resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} engines: {node: '>= 6.0.0'} @@ -1173,6 +1334,10 @@ packages: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + camelcase-css@2.0.1: resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} engines: {node: '>= 6'} @@ -1348,6 +1513,9 @@ packages: resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} engines: {node: '>=4.0.0'} + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + defaults@1.0.4: resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==} @@ -1505,9 +1673,61 @@ packages: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} + eslint-plugin-react-hooks@5.2.0: + resolution: {integrity: sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==} + engines: {node: '>=10'} + peerDependencies: + eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 + + eslint-scope@8.4.0: + resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@4.2.1: + resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@9.39.4: + resolution: {integrity: sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@10.4.0: + resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + expand-template@2.0.3: resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} engines: {node: '>=6'} @@ -1538,6 +1758,9 @@ packages: fast-json-stable-stringify@2.1.0: resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} @@ -1553,6 +1776,10 @@ packages: picomatch: optional: true + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + file-uri-to-path@1.0.0: resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} @@ -1563,6 +1790,17 @@ packages: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flatted@3.4.2: + resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + foreground-child@3.3.1: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} @@ -1666,6 +1904,10 @@ packages: resolution: {integrity: sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==} engines: {node: '>=10.0'} + globals@14.0.0: + resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} + engines: {node: '>=18'} + globalthis@1.0.4: resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} engines: {node: '>= 0.4'} @@ -1745,6 +1987,18 @@ packages: ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + engines: {node: '>= 4'} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + imurmurhash@0.1.4: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} @@ -1853,6 +2107,9 @@ packages: json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + json-stringify-safe@5.0.1: resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==} @@ -1877,6 +2134,10 @@ packages: resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==} engines: {node: '>= 0.6.3'} + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + lilconfig@3.1.3: resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} engines: {node: '>=14'} @@ -1884,6 +2145,10 @@ packages: lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + lodash.defaults@4.2.0: resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} @@ -1903,6 +2168,9 @@ packages: lodash.isplainobject@4.0.6: resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + lodash.union@4.6.0: resolution: {integrity: sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==} @@ -2067,6 +2335,9 @@ packages: napi-build-utils@2.0.0: resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + negotiator@0.6.4: resolution: {integrity: sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==} engines: {node: '>= 0.6'} @@ -2130,6 +2401,10 @@ packages: resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} engines: {node: '>=6'} + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + ora@5.4.1: resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==} engines: {node: '>=10'} @@ -2142,6 +2417,10 @@ packages: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + p-map@4.0.0: resolution: {integrity: sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==} engines: {node: '>=10'} @@ -2152,6 +2431,14 @@ packages: package-manager-detector@1.6.0: resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + path-is-absolute@1.0.1: resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} engines: {node: '>=0.10.0'} @@ -2261,6 +2548,15 @@ packages: deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available. hasBin: true + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + prettier@3.8.4: + resolution: {integrity: sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q==} + engines: {node: '>=14'} + hasBin: true + proc-log@2.0.1: resolution: {integrity: sha512-Kcmo2FhfDTXdcbfDH76N7uBYHINxc/8GW7UAVuVP9I+Va3uHSerrnKV6dLooga/gh7GlgzuCCr/eoldnL1muGw==} engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} @@ -2387,6 +2683,10 @@ packages: resolve-alpn@1.2.1: resolution: {integrity: sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==} + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + resolve@1.22.12: resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==} engines: {node: '>= 0.4'} @@ -2565,6 +2865,10 @@ packages: resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} engines: {node: '>=0.10.0'} + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + sucrase@3.35.1: resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} engines: {node: '>=16 || 14 >=14.17'} @@ -2655,6 +2959,12 @@ packages: truncate-utf8-bytes@1.0.2: resolution: {integrity: sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ==} + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + ts-interface-checker@0.1.13: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} @@ -2664,10 +2974,21 @@ packages: tunnel-agent@0.6.0: resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + type-fest@0.13.1: resolution: {integrity: sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==} engines: {node: '>=10'} + typescript-eslint@8.61.0: + resolution: {integrity: sha512-8y31Rd0eGTrDKqhy6vT0HtzhN+YLjQizwX3aA3hPXP/ynSfnrBXcQY5IzsP9/DM7+klX4IUncZZjkchP0z+rUw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} @@ -2812,6 +3133,10 @@ packages: wide-align@1.1.5: resolution: {integrity: sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==} + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} @@ -3008,6 +3333,12 @@ snapshots: glob: 7.2.3 minimatch: 3.1.5 + '@electron/fuses@1.8.0': + dependencies: + chalk: 4.1.2 + fs-extra: 9.1.0 + minimist: 1.2.8 + '@electron/get@2.0.3': dependencies: debug: 4.4.3 @@ -3185,6 +3516,52 @@ snapshots: '@esbuild/win32-x64@0.21.5': optional: true + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@1.21.7))': + dependencies: + eslint: 9.39.4(jiti@1.21.7) + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.21.2': + dependencies: + '@eslint/object-schema': 2.1.7 + debug: 4.4.3 + minimatch: 3.1.5 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.4.2': + dependencies: + '@eslint/core': 0.17.0 + + '@eslint/core@0.17.0': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/eslintrc@3.3.5': + dependencies: + ajv: 6.15.0 + debug: 4.4.3 + espree: 10.4.0 + globals: 14.0.0 + ignore: 5.3.2 + import-fresh: 3.3.1 + js-yaml: 4.1.1 + minimatch: 3.1.5 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@9.39.4': {} + + '@eslint/object-schema@2.1.7': {} + + '@eslint/plugin-kit@0.4.1': + dependencies: + '@eslint/core': 0.17.0 + levn: 0.4.1 + '@gar/promisify@1.1.3': {} '@homebridge/node-pty-prebuilt-multiarch@0.13.1': @@ -3192,6 +3569,22 @@ snapshots: node-addon-api: 7.1.1 prebuild-install: 7.1.3 + '@humanfs/core@0.19.2': + dependencies: + '@humanfs/types': 0.15.0 + + '@humanfs/node@0.16.8': + dependencies: + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 + '@humanwhocodes/retry': 0.4.3 + + '@humanfs/types@0.15.0': {} + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + '@isaacs/cliui@8.0.2': dependencies: string-width: 5.1.2 @@ -3546,6 +3939,8 @@ snapshots: '@types/http-cache-semantics@4.2.0': {} + '@types/json-schema@7.0.15': {} + '@types/keyv@3.1.4': dependencies: '@types/node': 22.19.19 @@ -3560,6 +3955,8 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/picomatch@3.0.2': {} + '@types/pidusage@2.0.5': {} '@types/plist@3.0.5': @@ -3600,6 +3997,97 @@ snapshots: '@types/node': 22.19.19 optional: true + '@typescript-eslint/eslint-plugin@8.61.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.61.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.61.0 + '@typescript-eslint/type-utils': 8.61.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/utils': 8.61.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.61.0 + eslint: 9.39.4(jiti@1.21.7) + ignore: 7.0.5 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.61.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.61.0 + '@typescript-eslint/types': 8.61.0 + '@typescript-eslint/typescript-estree': 8.61.0(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.61.0 + debug: 4.4.3 + eslint: 9.39.4(jiti@1.21.7) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.61.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.61.0(typescript@5.9.3) + '@typescript-eslint/types': 8.61.0 + debug: 4.4.3 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.61.0': + dependencies: + '@typescript-eslint/types': 8.61.0 + '@typescript-eslint/visitor-keys': 8.61.0 + + '@typescript-eslint/tsconfig-utils@8.61.0(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + + '@typescript-eslint/type-utils@8.61.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 8.61.0 + '@typescript-eslint/typescript-estree': 8.61.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.61.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3) + debug: 4.4.3 + eslint: 9.39.4(jiti@1.21.7) + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.61.0': {} + + '@typescript-eslint/typescript-estree@8.61.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/project-service': 8.61.0(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.61.0(typescript@5.9.3) + '@typescript-eslint/types': 8.61.0 + '@typescript-eslint/visitor-keys': 8.61.0 + debug: 4.4.3 + minimatch: 10.2.5 + semver: 7.8.1 + tinyglobby: 0.2.16 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.61.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@1.21.7)) + '@typescript-eslint/scope-manager': 8.61.0 + '@typescript-eslint/types': 8.61.0 + '@typescript-eslint/typescript-estree': 8.61.0(typescript@5.9.3) + eslint: 9.39.4(jiti@1.21.7) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.61.0': + dependencies: + '@typescript-eslint/types': 8.61.0 + eslint-visitor-keys: 5.0.1 + '@vitejs/plugin-react@4.7.0(vite@5.4.21(@types/node@22.19.19))': dependencies: '@babel/core': 7.29.0 @@ -3664,6 +4152,12 @@ snapshots: abbrev@1.1.1: {} + acorn-jsx@5.3.2(acorn@8.17.0): + dependencies: + acorn: 8.17.0 + + acorn@8.17.0: {} + agent-base@6.0.2: dependencies: debug: 4.4.3 @@ -3974,6 +4468,8 @@ snapshots: es-errors: 1.3.0 function-bind: 1.1.2 + callsites@3.1.0: {} + camelcase-css@2.0.1: {} caniuse-lite@1.0.30001793: {} @@ -4136,6 +4632,8 @@ snapshots: deep-extend@0.6.0: {} + deep-is@0.1.4: {} + defaults@1.0.4: dependencies: clone: 1.0.4 @@ -4361,13 +4859,86 @@ snapshots: escalade@3.2.0: {} - escape-string-regexp@4.0.0: - optional: true + escape-string-regexp@4.0.0: {} + + eslint-plugin-react-hooks@5.2.0(eslint@9.39.4(jiti@1.21.7)): + dependencies: + eslint: 9.39.4(jiti@1.21.7) + + eslint-scope@8.4.0: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@4.2.1: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@9.39.4(jiti@1.21.7): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@1.21.7)) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.21.2 + '@eslint/config-helpers': 0.4.2 + '@eslint/core': 0.17.0 + '@eslint/eslintrc': 3.3.5 + '@eslint/js': 9.39.4 + '@eslint/plugin-kit': 0.4.1 + '@humanfs/node': 0.16.8 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.8 + ajv: 6.15.0 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 8.4.0 + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + lodash.merge: 4.6.2 + minimatch: 3.1.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + optionalDependencies: + jiti: 1.21.7 + transitivePeerDependencies: + - supports-color + + espree@10.4.0: + dependencies: + acorn: 8.17.0 + acorn-jsx: 5.3.2(acorn@8.17.0) + eslint-visitor-keys: 4.2.1 + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} estree-walker@3.0.3: dependencies: '@types/estree': 1.0.8 + esutils@2.0.3: {} + expand-template@2.0.3: {} expect-type@1.3.0: {} @@ -4399,6 +4970,8 @@ snapshots: fast-json-stable-stringify@2.1.0: {} + fast-levenshtein@2.0.6: {} + fastq@1.20.1: dependencies: reusify: 1.1.0 @@ -4411,6 +4984,10 @@ snapshots: optionalDependencies: picomatch: 4.0.4 + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + file-uri-to-path@1.0.0: {} filelist@1.0.6: @@ -4421,6 +4998,18 @@ snapshots: dependencies: to-regex-range: 5.0.1 + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.4.2 + keyv: 4.5.4 + + flatted@3.4.2: {} + foreground-child@3.3.1: dependencies: cross-spawn: 7.0.6 @@ -4559,6 +5148,8 @@ snapshots: serialize-error: 7.0.1 optional: true + globals@14.0.0: {} + globalthis@1.0.4: dependencies: define-properties: 1.2.1 @@ -4658,6 +5249,15 @@ snapshots: ieee754@1.2.1: {} + ignore@5.3.2: {} + + ignore@7.0.5: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + imurmurhash@0.1.4: {} indent-string@4.0.0: {} @@ -4737,6 +5337,8 @@ snapshots: json-schema-traverse@0.4.1: {} + json-stable-stringify-without-jsonify@1.0.1: {} + json-stringify-safe@5.0.1: optional: true @@ -4762,10 +5364,19 @@ snapshots: dependencies: readable-stream: 2.3.8 + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + lilconfig@3.1.3: {} lines-and-columns@1.2.4: {} + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + lodash.defaults@4.2.0: {} lodash.difference@4.5.0: {} @@ -4778,6 +5389,8 @@ snapshots: lodash.isplainobject@4.0.6: {} + lodash.merge@4.6.2: {} + lodash.union@4.6.0: {} lodash@4.18.1: {} @@ -4936,6 +5549,8 @@ snapshots: napi-build-utils@2.0.0: {} + natural-compare@1.4.0: {} + negotiator@0.6.4: {} node-abi@3.92.0: @@ -5000,6 +5615,15 @@ snapshots: dependencies: mimic-fn: 2.1.0 + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + ora@5.4.1: dependencies: bl: 4.1.0 @@ -5018,6 +5642,10 @@ snapshots: dependencies: yocto-queue: 0.1.0 + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + p-map@4.0.0: dependencies: aggregate-error: 3.1.0 @@ -5026,6 +5654,12 @@ snapshots: package-manager-detector@1.6.0: {} + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + path-exists@4.0.0: {} + path-is-absolute@1.0.1: {} path-key@3.1.1: {} @@ -5117,6 +5751,10 @@ snapshots: tar-fs: 2.1.4 tunnel-agent: 0.6.0 + prelude-ls@1.2.1: {} + + prettier@3.8.4: {} + proc-log@2.0.1: {} process-nextick-args@2.0.1: {} @@ -5236,6 +5874,8 @@ snapshots: resolve-alpn@1.2.1: {} + resolve-from@4.0.0: {} + resolve@1.22.12: dependencies: es-errors: 1.3.0 @@ -5435,6 +6075,8 @@ snapshots: strip-json-comments@2.0.1: {} + strip-json-comments@3.1.1: {} + sucrase@3.35.1: dependencies: '@jridgewell/gen-mapping': 0.3.13 @@ -5557,6 +6199,10 @@ snapshots: dependencies: utf8-byte-length: 1.0.5 + ts-api-utils@2.5.0(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + ts-interface-checker@0.1.13: {} tslib@2.8.1: {} @@ -5565,9 +6211,24 @@ snapshots: dependencies: safe-buffer: 5.2.1 + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + type-fest@0.13.1: optional: true + typescript-eslint@8.61.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.61.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/parser': 8.61.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.61.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.61.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3) + eslint: 9.39.4(jiti@1.21.7) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + typescript@5.9.3: {} ulid@2.4.0: {} @@ -5701,6 +6362,8 @@ snapshots: dependencies: string-width: 4.2.3 + word-wrap@1.2.5: {} + wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 diff --git a/src/main/db/backfill.ts b/src/main/db/backfill.ts index 3c09941..21e55e7 100644 --- a/src/main/db/backfill.ts +++ b/src/main/db/backfill.ts @@ -5,7 +5,7 @@ import { ulid } from 'ulid'; * Phase 1.5 backfill: every existing App that doesn't yet have any tasks * gets one synthesised from its `default_script` / `custom_command`. * - * Idempotent — re-running does nothing once the seed task exists. + * Idempotent - re-running does nothing once the seed task exists. */ export function backfillTasksForExistingApps(): void { const d = db(); @@ -40,7 +40,7 @@ export function backfillTasksForExistingApps(): void { } else if (row.default_script) { insert.run(id, row.id, row.default_script, 'script', row.default_script, null, now, now); } else { - // No script and no custom command — skip. The app needs explicit task creation. + // No script and no custom command - skip. The app needs explicit task creation. } } }); diff --git a/src/main/db/index.ts b/src/main/db/index.ts index 172d40f..da7f0d2 100644 --- a/src/main/db/index.ts +++ b/src/main/db/index.ts @@ -14,21 +14,49 @@ const migrationModules = import.meta.glob('./migrations/*.sql', { }) as Record; let _db: DB | null = null; +let shuttingDown = false; + +export function dbFile(): string { + return join(app.getPath('userData'), 'devharbor.db'); +} export function db(): DB { if (_db) return _db; const dir = app.getPath('userData'); mkdirSync(dir, { recursive: true }); - const file = join(dir, 'devharbor.db'); - _db = new Database(file); - _db.pragma('journal_mode = WAL'); - _db.pragma('foreign_keys = ON'); - ensureMigrationsTable(_db); - runMigrations(_db); - backfillTasksForExistingApps(); + // A late write during shutdown (e.g. a final run_history UPDATE) must NOT re-run migrations + // + backfill on a freshly-opened handle. The schema already exists - open minimally. + if (shuttingDown) { + const reopened = new Database(file); + reopened.pragma('journal_mode = WAL'); + reopened.pragma('foreign_keys = ON'); + _db = reopened; + return _db; + } + + const database = new Database(file); + database.pragma('journal_mode = WAL'); + database.pragma('foreign_keys = ON'); + + try { + ensureMigrationsTable(database); + runMigrations(database); + // backfill reads through db(), so the handle must be assigned first - but only AFTER + // migrations succeed, so a thrown migration never leaves a half-migrated handle cached. + _db = database; + backfillTasksForExistingApps(); + } catch (e) { + _db = null; + try { + database.close(); + } catch { + // ignore + } + throw e; + } return _db; } @@ -51,7 +79,7 @@ function runMigrations(d: DB): void { const entries = Object.entries(migrationModules) .map(([path, sql]) => { - // path looks like './migrations/0001_init.sql' — strip prefix + extension for the version key. + // path looks like './migrations/0001_init.sql' - strip prefix + extension for the version key. const file = path.split('/').pop() ?? path; const version = file.replace(/\.sql$/, ''); return { version, sql }; @@ -73,6 +101,16 @@ function runMigrations(d: DB): void { } export function closeDb(): void { - _db?.close(); + shuttingDown = true; + if (_db) { + // Flush the WAL into the main db file so an export/backup taken right after isn't missing + // recent writes, and so db:reset doesn't strand an orphaned -wal (IMPROVEMENT-PLAN 5.11). + try { + _db.pragma('wal_checkpoint(TRUNCATE)'); + } catch { + // ignore + } + _db.close(); + } _db = null; } diff --git a/src/main/db/migrations/0004_env_task_scope.sql b/src/main/db/migrations/0004_env_task_scope.sql index aba3c36..2f34466 100644 --- a/src/main/db/migrations/0004_env_task_scope.sql +++ b/src/main/db/migrations/0004_env_task_scope.sql @@ -2,7 +2,7 @@ -- -- Add `task_id` to env_vars so the three-scope layering (global / app / task) -- can be done with one table and one query per scope. NULL means "not scoped --- to a specific task" — combined with `app_id` it gives: +-- to a specific task" - combined with `app_id` it gives: -- app_id IS NULL AND task_id IS NULL → global -- app_id = ? AND task_id IS NULL → app -- task_id = ? → task (app_id denormalised for cascade) diff --git a/src/main/db/migrations/0005_app_folders.sql b/src/main/db/migrations/0005_app_folders.sql index f280778..32e29b6 100644 --- a/src/main/db/migrations/0005_app_folders.sql +++ b/src/main/db/migrations/0005_app_folders.sql @@ -1,6 +1,6 @@ -- Phase 8 (F21): folders in the sidebar. -- --- One-level visual grouping. NULL means "(Ungrouped)". Tags remain orthogonal — +-- One-level visual grouping. NULL means "(Ungrouped)". Tags remain orthogonal - -- folder = hierarchy, tags = facets. See specs/03-features.md F21. ALTER TABLE apps ADD COLUMN folder TEXT; diff --git a/src/main/db/migrations/0006_env_scope_unique.sql b/src/main/db/migrations/0006_env_scope_unique.sql new file mode 100644 index 0000000..8fd6fcb --- /dev/null +++ b/src/main/db/migrations/0006_env_scope_unique.sql @@ -0,0 +1,50 @@ +-- Fix layered env saves (IMPROVEMENT-PLAN 5.6). +-- +-- 0001 created env_vars with a table-level UNIQUE(app_id, key). 0004 added task_id for +-- three-scope layering (global / app / task), but the old constraint: +-- * makes a TASK-scoped override of an app-scoped key impossible to save - the whole +-- point of layering - rolling back the entire save transaction; and +-- * is INERT for global scope (app_id IS NULL bypasses UNIQUE), so it constrains exactly +-- where it shouldn't and not where it should. +-- +-- SQLite can't drop a table-level constraint, so rebuild the table without it and replace +-- it with three PARTIAL unique indexes - correct per-scope uniqueness. Nothing references +-- env_vars, so the drop/rename is safe inside the migration transaction. + +CREATE TABLE env_vars_new ( + id TEXT PRIMARY KEY, + app_id TEXT REFERENCES apps(id) ON DELETE CASCADE, + task_id TEXT REFERENCES tasks(id) ON DELETE CASCADE, + key TEXT NOT NULL, + value TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1, + is_secret INTEGER NOT NULL DEFAULT 0, + note TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); + +INSERT INTO env_vars_new (id, app_id, task_id, key, value, enabled, is_secret, note, created_at, updated_at) + SELECT id, app_id, task_id, key, value, enabled, is_secret, note, created_at, updated_at FROM env_vars; + +DROP TABLE env_vars; +ALTER TABLE env_vars_new RENAME TO env_vars; + +CREATE INDEX IF NOT EXISTS idx_env_vars_app ON env_vars(app_id); +CREATE INDEX IF NOT EXISTS idx_env_vars_task ON env_vars(task_id) WHERE task_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_env_vars_app_scope ON env_vars(app_id, task_id); + +-- De-dupe any rows that would violate the new unique indexes (keep the most recently +-- updated per scope+key), since the old schema permitted duplicate global keys. +DELETE FROM env_vars WHERE id NOT IN ( + SELECT id FROM ( + SELECT id, ROW_NUMBER() OVER ( + PARTITION BY IFNULL(app_id, '∅'), IFNULL(task_id, '∅'), key + ORDER BY updated_at DESC, id DESC + ) AS rn FROM env_vars + ) WHERE rn = 1 +); + +CREATE UNIQUE INDEX uq_env_global ON env_vars(key) WHERE app_id IS NULL AND task_id IS NULL; +CREATE UNIQUE INDEX uq_env_app ON env_vars(app_id, key) WHERE app_id IS NOT NULL AND task_id IS NULL; +CREATE UNIQUE INDEX uq_env_task ON env_vars(task_id, key) WHERE task_id IS NOT NULL; diff --git a/src/main/db/migrations/0007_app_autostart.sql b/src/main/db/migrations/0007_app_autostart.sql new file mode 100644 index 0000000..3c69edf --- /dev/null +++ b/src/main/db/migrations/0007_app_autostart.sql @@ -0,0 +1,4 @@ +-- Per-app auto-start (IMPROVEMENT-PLAN 14.6). When set, the orchestrator starts the app on +-- DevHarbor launch - pairs with the "Launch DevHarbor at login" setting so a dev's stack is +-- already up when they sit down. +ALTER TABLE apps ADD COLUMN auto_start INTEGER NOT NULL DEFAULT 0; diff --git a/src/main/index.ts b/src/main/index.ts index cc60d7b..47f89de 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,12 +1,60 @@ -import { app, BrowserWindow, shell } from 'electron'; +import { app, BrowserWindow, dialog, screen, shell } from 'electron'; +import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { db, closeDb } from './db/index.js'; -import { registerAllIpcHandlers } from './ipc/index.js'; +import { registerAllIpcHandlers, type IpcRuntime } from './ipc/index.js'; import { installAppMenu } from './menu.js'; +import { installProcessLogging, logger } from './services/Logger.js'; const isDev = process.env.NODE_ENV === 'development' || !!process.env.ELECTRON_RENDERER_URL; let mainWindow: BrowserWindow | null = null; +let ipcRuntime: IpcRuntime | null = null; +let isQuitting = false; + +interface WindowBounds { + x?: number; + y?: number; + width: number; + height: number; +} + +function boundsFile(): string { + return join(app.getPath('userData'), 'window-state.json'); +} + +/** Restore the last window bounds, validated against currently-connected displays. */ +function loadBounds(): WindowBounds { + const fallback: WindowBounds = { width: 1280, height: 820 }; + try { + const raw = readFileSync(boundsFile(), 'utf8'); + const b = JSON.parse(raw) as Partial; + if (typeof b.width !== 'number' || typeof b.height !== 'number') return fallback; + const w = Math.max(960, Math.min(b.width, 8000)); + const h = Math.max(600, Math.min(b.height, 8000)); + // Only keep x/y if the window would land on some connected display (avoid off-screen). + if (typeof b.x === 'number' && typeof b.y === 'number') { + const onScreen = screen.getAllDisplays().some((d) => { + const wa = d.workArea; + return b.x! < wa.x + wa.width && b.x! + w > wa.x && b.y! < wa.y + wa.height && b.y! + h > wa.y; + }); + if (onScreen) return { x: b.x, y: b.y, width: w, height: h }; + } + return { width: w, height: h }; + } catch { + return fallback; + } +} + +function saveBounds(win: BrowserWindow): void { + if (win.isDestroyed()) return; + try { + const b = win.getNormalBounds(); + writeFileSync(boundsFile(), JSON.stringify({ x: b.x, y: b.y, width: b.width, height: b.height })); + } catch { + // ignore + } +} // Single-instance lock: a second launch (or a deep-link open) focuses the existing // window instead of spawning a second process with its own DB handle. @@ -16,20 +64,20 @@ if (!gotLock) { } else { app.on('second-instance', () => { // Re-launching while an instance holds the lock must surface a window. Previously this - // only focused an EXISTING window — if all windows were closed (app still alive on + // only focused an EXISTING window - if all windows were closed (app still alive on // macOS), the second launch did nothing, so the app looked like it "won't open". getOrCreateWindow(); }); } function createWindow(): BrowserWindow { + const bounds = loadBounds(); const win = new BrowserWindow({ - width: 1280, - height: 820, + ...bounds, minWidth: 960, minHeight: 600, show: false, - backgroundColor: '#18181b', // matches the zinc-900 theme base — no launch color flash + backgroundColor: '#18181b', // matches the zinc-900 theme base - no launch color flash titleBarStyle: 'hiddenInset', trafficLightPosition: { x: 12, y: 14 }, webPreferences: { @@ -43,12 +91,33 @@ function createWindow(): BrowserWindow { win.once('ready-to-show', () => win.show()); + // Persist window size/position so the app reopens where the user left it. + win.on('close', () => saveBounds(win)); + win.on('closed', () => { if (mainWindow === win) mainWindow = null; }); + // Reset per-renderer main-process state (log subscriptions) on reload/navigation, so a ⌘R + // can't leave log forwarding gated to taskIds the new renderer never subscribed to. + win.webContents.on('did-start-navigation', (_e, _url, _isInPlace, isMainFrame) => { + if (isMainFrame) ipcRuntime?.onRendererReload(); + }); + + // Renderer crash recovery: a GPU/OOM crash (xterm WebGL contexts are a realistic source) + // would otherwise leave a blank window. Reload once so it self-heals - running PTYs live in + // the main process and survive (IMPROVEMENT-PLAN 13.2). + let reloadAttempts = 0; + win.webContents.on('render-process-gone', (_e, details) => { + logger.error('render-process-gone', details.reason, details.exitCode); + if (reloadAttempts < 2 && details.reason !== 'clean-exit' && !win.isDestroyed()) { + reloadAttempts += 1; + win.webContents.reload(); + } + }); + win.webContents.setWindowOpenHandler(({ url }) => { - // Only forward http(s) — refuse file://, mailto:, etc. that a compromised + // Only forward http(s) - refuse file://, mailto:, etc. that a compromised // renderer could try to abuse. try { const parsed = new URL(url); @@ -56,7 +125,7 @@ function createWindow(): BrowserWindow { shell.openExternal(url); } } catch { - // Invalid URL — ignore. + // Invalid URL - ignore. } return { action: 'deny' }; }); @@ -67,7 +136,7 @@ function createWindow(): BrowserWindow { win.webContents.on('will-navigate', (event, url) => { const current = win.webContents.getURL(); if (url === current) return; - // Compare by ORIGIN, not string prefix — a prefix check would let + // Compare by ORIGIN, not string prefix - a prefix check would let // `http://localhost:5173.evil.com` pass as the dev server. let sameDevServer = false; if (process.env.ELECTRON_RENDERER_URL) { @@ -121,6 +190,9 @@ function getOrCreateWindow(): { win: BrowserWindow; created: boolean } { } app.whenReady().then(() => { + // Route uncaught exceptions / rejections to the local log file for support diagnostics. + installProcessLogging(); + // Configure the macOS About panel ( ⌘ → DevHarbor → About DevHarbor ). if (process.platform === 'darwin') { app.setAboutPanelOptions({ @@ -134,8 +206,39 @@ app.whenReady().then(() => { } installAppMenu(isDev, getOrCreateWindow); - db(); - registerAllIpcHandlers(() => mainWindow); + // Opening the DB can fail (corrupt file, half-applied migration). Surface it instead of + // launching to a dock icon with no window and no error (IMPROVEMENT-PLAN 8.1). + try { + db(); + } catch (e) { + logger.error('database open/migration failed', e); + const choice = dialog.showMessageBoxSync({ + type: 'error', + title: 'DevHarbor - database error', + message: 'DevHarbor could not open its database.', + detail: `${(e as Error).message}\n\nThe database is at:\n${join(app.getPath('userData'), 'devharbor.db')}\n\nYou can move the corrupt database aside and start fresh, or quit.`, + buttons: ['Move aside & restart', 'Quit'], + defaultId: 0, + cancelId: 1 + }); + if (choice === 0) { + try { + const p = join(app.getPath('userData'), 'devharbor.db'); + for (const suffix of ['', '-wal', '-shm']) { + if (existsSync(`${p}${suffix}`)) { + writeFileSync(`${p}${suffix}.corrupt-${Date.now()}.bak`, readFileSync(`${p}${suffix}`)); + } + } + for (const suffix of ['', '-wal', '-shm']) rmSync(`${p}${suffix}`, { force: true }); + } catch (moveErr) { + logger.error('failed to move corrupt DB aside', moveErr); + } + app.relaunch(); + } + app.exit(choice === 0 ? 0 : 1); + return; + } + ipcRuntime = registerAllIpcHandlers(() => mainWindow, getOrCreateWindow); createWindow(); app.on('activate', () => { @@ -147,6 +250,48 @@ app.on('window-all-closed', () => { if (process.platform !== 'darwin') app.quit(); }); -app.on('before-quit', () => { - closeDb(); +// Graceful teardown on quit (including auto-update's quitAndInstall). The whole point of the +// app is "stop apps cleanly, no orphans" - but the only quit hook used to be closeDb(), so +// running dev servers were killed abruptly via PTY teardown (or survived as orphans). Now we +// confirm, run the reverse-topo graceful stop, then close the DB last (IMPROVEMENT-PLAN 5.9). +let teardownInFlight = false; + +app.on('before-quit', (event) => { + if (isQuitting) return; // teardown finished - let the final quit proceed + if (teardownInFlight) { + // A second ⌘Q / dock-quit while we're still stopping tasks must NOT bypass the teardown + // (it would abandon the SIGTERM→grace sequence mid-flight and skip the DB close). + event.preventDefault(); + return; + } + const running = ipcRuntime?.runningTaskCount() ?? 0; + if (running === 0) { + closeDb(); + return; + } + event.preventDefault(); + teardownInFlight = true; + void (async () => { + const { response } = await dialog.showMessageBox({ + type: 'warning', + title: 'Quit DevHarbor?', + message: `${running} running ${running === 1 ? 'task is' : 'tasks are'} still active.`, + detail: 'DevHarbor will stop your running dev servers before quitting.', + buttons: ['Stop & Quit', 'Cancel'], + defaultId: 0, + cancelId: 1 + }); + if (response !== 0) { + teardownInFlight = false; + return; // cancelled - stay open + } + try { + await ipcRuntime?.stopAllRunning(); + } catch (e) { + logger.error('teardown on quit failed', e); + } + closeDb(); + isQuitting = true; + app.quit(); + })(); }); diff --git a/src/main/ipc/index.ts b/src/main/ipc/index.ts index 2054d82..cff5acc 100644 --- a/src/main/ipc/index.ts +++ b/src/main/ipc/index.ts @@ -1,6 +1,9 @@ -import { BrowserWindow, dialog, ipcMain } from 'electron'; -import type { InvokeChannelName, InvokeChannels } from '@shared/ipc'; -import type { AppId, TaskId } from '@shared/types'; +import { BrowserWindow, dialog, ipcMain, Notification } from 'electron'; +import type { InvokeChannelName, InvokeChannels, ImportCandidate, GlobalLogMatch } from '@shared/ipc'; +import type { AppId, EnvVar, TaskId } from '@shared/types'; +import { closeDb, db, dbFile } from '../db/index.js'; +import { logger } from '../services/Logger'; +import { TrayController, type TrayApp } from '../services/TrayController'; import { AppRegistry } from '../services/AppRegistry'; import { DetectionService } from '../services/DetectionService'; import { NodeResolver } from '../services/NodeResolver'; @@ -18,8 +21,8 @@ import { DeepLinks } from '../services/DeepLinks'; import { Updater } from '../services/Updater'; import { OpenIn } from '../services/OpenIn'; import { app as electronApp, dialog as electronDialog } from 'electron'; -import { copyFileSync, existsSync, realpathSync, unlinkSync } from 'node:fs'; -import { join } from 'node:path'; +import { existsSync, readdirSync, realpathSync, renameSync, statSync } from 'node:fs'; +import { basename, join } from 'node:path'; import type { StatsTick } from '../services/StatsMonitor'; import type { PortsEvent } from '../services/PortDetector'; @@ -31,7 +34,19 @@ function register(channel: C, handler: Handler): ipcMain.handle(channel, async (_evt, req) => handler(req)); } -export function registerAllIpcHandlers(win: () => BrowserWindow | null): void { +export interface IpcRuntime { + /** Number of tasks currently live - for the quit confirmation. */ + runningTaskCount: () => number; + /** Gracefully stop every running task (SIGTERM → grace → SIGKILL tree). */ + stopAllRunning: () => Promise; + /** Reset per-renderer state (log subscriptions) when the window reloads/navigates. */ + onRendererReload: () => void; +} + +export function registerAllIpcHandlers( + win: () => BrowserWindow | null, + getOrCreateWindow: () => { win: BrowserWindow; created: boolean } +): IpcRuntime { const settings = new Settings(); const pathProbe = new PathProbe(); const detector = new DetectionService(); @@ -75,6 +90,24 @@ export function registerAllIpcHandlers(win: () => BrowserWindow | null): void { // Apply current log_ring_size to the LogBuffer. runner.logs.setLimits({ maxLines: settings.get('log_ring_size') }); + // Boot maintenance: cap run_history growth and encrypt any plaintext secret env values + // left over from before encryption-at-rest landed (both are no-ops when already done). + try { + runHistory.prune(settings.get('run_history_limit')); + } catch (e) { + logger.warn('run_history prune failed', e); + } + try { + envStore.migratePlaintextSecrets(); + } catch (e) { + logger.warn('secret migration failed', e); + } + + // Visibility-gated log streaming: the renderer subscribes to the task(s) it's showing. Until + // it subscribes to anything we forward all task:log events (back-compat); once it subscribes, + // we only forward subscribed tasks, so background tasks' chatter doesn't flood the renderer. + const logSubs = new Set(); + const updater = new Updater(win); if (settings.get('auto_update')) updater.start(); const deepLinks = new DeepLinks( @@ -126,6 +159,7 @@ export function registerAllIpcHandlers(win: () => BrowserWindow | null): void { // Forward task events to the renderer. runner.on('log', (evt: TaskLogEvent) => { + if (logSubs.size > 0 && !logSubs.has(evt.taskId)) return; win()?.webContents.send('task:log', evt); }); runner.on('status', (evt: TaskStatusEvent) => { @@ -144,6 +178,87 @@ export function registerAllIpcHandlers(win: () => BrowserWindow | null): void { win()?.webContents.send('env:fileChanged', evt); }); + // --- Menubar tray (IMPROVEMENT-PLAN 14.1) --------------------------------------------------- + const aggregatePorts = (appId: AppId): number[] => { + const ports = new Set(); + for (const rt of runner.list()) if (rt.appId === appId) for (const p of rt.ports) ports.add(p); + return [...ports].sort((a, b) => a - b); + }; + const trayApps = (): TrayApp[] => + registry.list().map((a) => ({ + id: a.id, + name: a.name, + state: orchestrator.appState(a.id), + ports: aggregatePorts(a.id) + })); + const tray = new TrayController({ + listApps: trayApps, + start: (id) => void orchestrator.startApp(id).catch((e) => logger.warn('tray start failed', e)), + stop: (id) => void orchestrator.stopApp(id).catch((e) => logger.warn('tray stop failed', e)), + stopAll: () => void orchestrator.stopAllRunning(), + open: () => { + getOrCreateWindow(); + }, + quit: () => electronApp.quit() + }); + if (settings.get('tray_enabled')) tray.enable(); + // Port chips in the tray menu update as lsof discovers them. + runner.on('ports', () => tray.refresh()); + + // Reconcile launch-at-login with the OS - the OS wins. If the user removed (or added) + // DevHarbor under System Settings → Login Items, adopt that into our setting rather than + // re-asserting a stale stored value over their explicit choice; we only WRITE login-item + // state from the settings:set handler, i.e. when toggled in-app. + try { + const osValue = electronApp.getLoginItemSettings().openAtLogin; + if (osValue !== settings.get('launch_at_login')) { + settings.set('launch_at_login', osValue); + } + } catch (e) { + logger.warn('login-item reconcile failed', e); + } + + // --- Crash / ready desktop notifications (IMPROVEMENT-PLAN 14.2) ----------------------------- + const focusAppInWindow = (appId: AppId): void => { + const { win: w, created } = getOrCreateWindow(); + const send = (): void => { + w.webContents.send('deepLink:focusApp', { appId }); + }; + if (created) w.webContents.once('did-finish-load', () => setTimeout(send, 200)); + else send(); + }; + const taskReady = new Map(); + runner.on('status', (evt: TaskStatusEvent) => { + tray.refresh(); + if (!Notification.isSupported()) return; + const appName = registry.get(evt.appId)?.name ?? 'App'; + if (evt.state === 'crashed' && settings.get('notify_on_crash')) { + const n = new Notification({ + title: `${appName} crashed`, + body: evt.exitCode != null ? `A task exited with code ${evt.exitCode}.` : 'A task crashed.' + }); + n.on('click', () => focusAppInWindow(evt.appId)); + n.show(); + } + if (settings.get('notify_on_ready')) { + const was = taskReady.get(evt.taskId) ?? false; + if (evt.ready && !was) { + const n = new Notification({ title: `${appName} is ready`, body: 'A task reached its readiness signal.' }); + n.on('click', () => focusAppInWindow(evt.appId)); + n.show(); + } + } + taskReady.set(evt.taskId, evt.ready); + if (evt.state === 'exited' || evt.state === 'crashed') taskReady.delete(evt.taskId); + }); + + // --- Auto-start flagged apps on launch (IMPROVEMENT-PLAN 14.6) ------------------------------- + for (const a of registry.list()) { + if (a.autoStart) { + void orchestrator.startApp(a.id).catch((e) => logger.warn(`auto-start of ${a.name} failed`, e)); + } + } + register('app:ping', (msg) => `pong: ${msg}`); register('apps:list', () => registry.list()); @@ -151,6 +266,7 @@ export function registerAllIpcHandlers(win: () => BrowserWindow | null): void { const app = await registry.add(path); envFileWatcher.watch(app.id, app.path); syncRestartWatcher(app.id); + tray.refresh(); return app; }); register('apps:update', ({ id, patch }) => { @@ -168,17 +284,116 @@ export function registerAllIpcHandlers(win: () => BrowserWindow | null): void { ) { syncRestartWatcher(app.id); } + tray.refresh(); // rename / folder changes show in the tray menu return app; }); register('apps:remove', ({ id }) => { - if (orchestrator.appState(id as AppId) !== 'idle') { + // Only block removal while the app is actually LIVE. The sticky outcome design means an + // app that ever ran reports 'exited'/'crashed' forever, so the old `!== 'idle'` guard made + // every previously-run app permanently unremovable (IMPROVEMENT-PLAN 5.2). + const st = orchestrator.appState(id as AppId); + if (st === 'running' || st === 'starting' || st === 'exiting') { throw new Error('Stop the app before removing it.'); } envFileWatcher.unwatch(id); restartWatcher.unwatch(id); + orchestrator.clearOutcome(id as AppId); registry.remove(id); + tray.refresh(); }); register('apps:detect', ({ path }) => detector.detect(path)); + + // Atomic create: app + first task + env vars in ONE main-process handler with rollback, so a + // partial failure (or a renderer reload mid-flow) can't leave an orphan app row + // (IMPROVEMENT-PLAN 12.7). FK cascade cleans tasks/env if we roll back. + register('apps:create', async (input) => { + const real = realpathSync(input.path); + if (registry.getByPath(real)) { + throw new Error('This folder is already registered.'); + } + const app = await registry.add(input.path); + try { + const patched = registry.update(app.id, { + name: input.name?.trim() || app.name, + nodeVersionPref: input.nodeVersionPref ?? { kind: 'auto' }, + packageManager: input.packageManager ?? null, + defaultScript: input.defaultScript ?? null + }); + const taskSpecs = [...(input.firstTask ? [input.firstTask] : []), ...(input.tasks ?? [])]; + for (const spec of taskSpecs) { + taskRegistry.add(app.id, { + name: spec.name, + commandKind: spec.commandKind, + script: spec.script ?? null, + customCommand: spec.customCommand ?? null, + workingDirOverride: spec.workingDirOverride ?? null, + enabled: true + }); + } + if (input.envVars && input.envVars.length > 0) { + const vars: EnvVar[] = input.envVars + .filter((v) => v.key.trim()) + .map((v) => ({ + id: '', + appId: app.id, + key: v.key.trim(), + value: v.value, + enabled: true, + isSecret: v.isSecret ?? false + })); + envStore.setApp(app.id, vars); + } + envFileWatcher.watch(app.id, app.path); + syncRestartWatcher(app.id); + tray.refresh(); + return patched; + } catch (err) { + try { + registry.remove(app.id); + } catch { + /* best-effort rollback */ + } + throw err; + } + }); + + // Shallow-scan a folder for package.json projects (bulk import). One level deep; skips + // already-registered folders' "alreadyRegistered" flag so the picker can disable them. + register('apps:scanFolder', async ({ dir }) => { + const out: ImportCandidate[] = []; + let entries: string[]; + try { + entries = readdirSync(dir); + } catch { + return out; + } + for (const name of entries) { + if (name.startsWith('.')) continue; + const full = join(dir, name); + try { + if (!statSync(full).isDirectory()) continue; + if (!existsSync(join(full, 'package.json'))) continue; + } catch { + continue; + } + let real = full; + try { + real = realpathSync(full); + } catch { + // use raw + } + const detection = await detector.detect(full); + out.push({ + path: full, + name: basename(real), + alreadyRegistered: !!registry.getByPath(real), + packageManager: detection.packageManager, + suggestedScript: detection.suggestedDefaultScript, + scripts: Object.keys(detection.scripts) + }); + } + return out.sort((a, b) => a.name.localeCompare(b.name)); + }); register('apps:findByPath', ({ path }) => { try { const real = realpathSync(path); @@ -200,6 +415,7 @@ export function registerAllIpcHandlers(win: () => BrowserWindow | null): void { register('proc:list', () => orchestrator.listApps()); register('tasks:list', ({ appId }) => taskRegistry.list(appId)); + register('tasks:listAll', () => taskRegistry.listAll()); register('tasks:add', ({ appId, patch }) => taskRegistry.add(appId, patch)); register('tasks:update', ({ id, patch }) => taskRegistry.update(id, patch)); register('tasks:remove', ({ id }) => { @@ -219,6 +435,46 @@ export function registerAllIpcHandlers(win: () => BrowserWindow | null): void { register('task:clearBuffer', ({ id }) => runner.clearBuffer(id)); register('task:resize', ({ id, cols, rows }) => runner.resize(id, cols, rows)); + register('task:subscribeLogs', ({ id }) => { + logSubs.add(id); + }); + register('task:unsubscribeLogs', ({ id }) => { + logSubs.delete(id); + }); + + // Global log search: fan over every live task's ring buffer in main and return matches. + register('logs:searchAll', ({ query, flags, limit }) => { + const out: GlobalLogMatch[] = []; + if (!query.trim()) return out; + let re: RegExp; + try { + re = new RegExp(query, flags ?? 'i'); + } catch { + // Fall back to a literal substring match if the regex is invalid. + re = new RegExp(query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i'); + } + const cap = limit ?? 500; + for (const rt of runner.list()) { + const app = registry.get(rt.appId); + const tasks = taskRegistry.list(rt.appId); + const taskName = tasks.find((t) => t.id === rt.taskId)?.name ?? ''; + const buf = runner.readBuffer(rt.taskId); + for (const line of buf.split('\n')) { + if (re.test(line)) { + out.push({ + appId: rt.appId, + taskId: rt.taskId, + appName: app?.name ?? '', + taskName, + line: line.length > 2000 ? line.slice(0, 2000) : line + }); + if (out.length >= cap) return out; + } + } + } + return out; + }); + register('runs:list', ({ appId, limit }) => runHistory.list(appId, limit)); register('env:getGlobal', () => envStore.getGlobal()); @@ -257,19 +513,37 @@ export function registerAllIpcHandlers(win: () => BrowserWindow | null): void { runner.logs.setLimits({ maxLines: patch.log_ring_size }); } if (patch.auto_update === true) updater.start(); + if (patch.auto_update === false) updater.stop(); + // Apply OS-level + tray side effects immediately. + if (typeof patch.tray_enabled === 'boolean') { + if (patch.tray_enabled) tray.enable(); + else tray.disable(); + tray.refresh(); + } + if (typeof patch.launch_at_login === 'boolean') { + electronApp.setLoginItemSettings({ openAtLogin: patch.launch_at_login, openAsHidden: true }); + } return next; }); register('update:install', () => { updater.quitAndInstall(); }); + register('update:check', () => { + updater.checkNow(); + }); + + register('logs:path', () => logger.path()); + register('logs:openFolder', () => { + logger.openFolder(); + }); const openIn = new OpenIn(); register('openIn:caps', () => openIn.caps()); register('openIn:open', ({ target, path }) => openIn.open(target, path)); // Danger-zone DB helpers. - const dbPath = join(electronApp.getPath('userData'), 'devharbor.db'); + const dbPath = dbFile(); register('db:path', () => dbPath); register('db:export', async () => { const w = win(); @@ -280,23 +554,25 @@ export function registerAllIpcHandlers(win: () => BrowserWindow | null): void { filters: [{ name: 'SQLite DB', extensions: ['db'] }] }); if (result.canceled || !result.filePath) return null; - copyFileSync(dbPath, result.filePath); + // Use SQLite's online backup, NOT a raw file copy: in WAL mode most recent writes live in + // the -wal sidecar, so copyFileSync of just the .db would silently miss them + // (IMPROVEMENT-PLAN 5.11). backup() produces a fully-consistent single file. + await db().backup(result.filePath); return result.filePath; }); register('db:reset', () => { - // Best-effort wipe — the next launch creates a fresh DB via migrations. - // We DON'T delete in-place; we move the existing file aside in case of regret. - if (existsSync(dbPath)) { - const archive = `${dbPath}.reset-${Date.now()}.bak`; + // Close the handle FIRST (checkpoints the WAL into the main file), then move the .db AND + // its -wal/-shm sidecars aside together so the relaunch can't recover stale WAL into the + // fresh DB. closeDb() also sets the shutdown guard so nothing re-opens mid-reset. + closeDb(); + const stamp = Date.now(); + for (const suffix of ['', '-wal', '-shm']) { + const f = `${dbPath}${suffix}`; + if (!existsSync(f)) continue; try { - copyFileSync(dbPath, archive); - } catch { - // ignore - } - try { - unlinkSync(dbPath); - } catch { - // ignore + renameSync(f, `${f}.reset-${stamp}.bak`); + } catch (e) { + logger.warn(`db:reset could not move ${f} aside`, e); } } // Force a restart so migrations + handlers run against the empty DB. @@ -321,4 +597,12 @@ export function registerAllIpcHandlers(win: () => BrowserWindow | null): void { if (result.canceled || result.filePaths.length === 0) return null; return result.filePaths[0] ?? null; }); + + return { + runningTaskCount: () => orchestrator.runningTaskCount(), + stopAllRunning: () => orchestrator.stopAllRunning(), + // A renderer reload (⌘R is kept in prod) loses the renderer-side unsubscribe calls; if the + // stale subscriptions lingered, log forwarding would stay gated to dead taskIds forever. + onRendererReload: () => logSubs.clear() + }; } diff --git a/src/main/menu.ts b/src/main/menu.ts index cfb22bb..f7e600f 100644 --- a/src/main/menu.ts +++ b/src/main/menu.ts @@ -8,11 +8,11 @@ const WEBSITE = 'https://www.devharbor.app'; * Install the application menu. * * The default Electron menu ships developer affordances (Toggle DevTools, Force Reload) - * and a "Learn More → electronjs.org" Help link — neither belongs in a shipped app. This + * and a "Learn More → electronjs.org" Help link - neither belongs in a shipped app. This * builds a clean, native macOS menu instead. * * Notes: - * - **Reload (⌘R) is kept in every build** — the renderer relies on a hard reload to + * - **Reload (⌘R) is kept in every build** - the renderer relies on a hard reload to * re-bootstrap onto the Dashboard (see App.tsx). Force Reload + Toggle DevTools are * gated to dev only. * - The **Edit** submenu (undo/cut/copy/paste/select-all) is required: once a custom @@ -113,6 +113,10 @@ export function installAppMenu( { role: 'help', submenu: [ + // Forwarded to the renderer so the result (toast / up-to-date / error) surfaces there. + { label: 'Check for Updates…', click: send('menu:checkUpdates') }, + { label: 'Open Logs Folder', click: send('menu:openLogs') }, + { type: 'separator' }, { label: `${APP_NAME} Website`, click: () => void shell.openExternal(WEBSITE) diff --git a/src/main/services/AppOrchestrator.ts b/src/main/services/AppOrchestrator.ts index 85c5bb4..968b7b4 100644 --- a/src/main/services/AppOrchestrator.ts +++ b/src/main/services/AppOrchestrator.ts @@ -28,6 +28,17 @@ export class AppOrchestrator extends EventEmitter { // next start, so a stopped app stays visibly Stopped instead of flickering to Idle. private lastOutcome = new Map(); + // Per-app operation lock. start/stop/restart for one app are serialised so a file-change + // restart can't overlap a user stop (or another restart) and double-spawn / interleave + // (IMPROVEMENT-PLAN 7.4). Different apps still run concurrently. + private readonly ops = new Map>(); + + // Cancellation hooks for in-flight starts. The lock serialises a queued stop BEHIND the + // start - and doStartApp can sit awaiting readiness for up to readiness_timeout_ms per + // level, which is exactly when users reach for Stop. stopApp/restartApp invoke this + // synchronously (before enqueueing) so the blocked start bails out immediately. + private readonly startCancels = new Map void>(); + constructor( private readonly apps: AppRegistry, private readonly tasks: TaskRegistry, @@ -38,6 +49,29 @@ export class AppOrchestrator extends EventEmitter { this.runner.on('log', (e: TaskLogEvent) => this.emit('task:log', e)); } + /** Serialise an app-scoped operation behind any in-flight one for the same app. */ + private withLock(appId: AppId, fn: () => Promise): Promise { + const prev = this.ops.get(appId) ?? Promise.resolve(); + const next = prev.then(fn, fn); + // Keep the chain alive even if fn rejects (callers still see the real rejection). + this.ops.set( + appId, + next.then( + () => undefined, + () => undefined + ) + ); + return next; + } + + /** Drop the sticky stopped/crashed badge for an app (called when it's removed). */ + clearOutcome(appId: AppId): void { + this.lastOutcome.delete(appId); + this.ops.delete(appId); + this.startCancels.get(appId)?.(); + this.startCancels.delete(appId); + } + /** Snapshot of every running task across all apps. */ listTasks(): RunningTask[] { return this.runner.list(); @@ -47,7 +81,7 @@ export class AppOrchestrator extends EventEmitter { * App-level summaries. Includes every app with a running task PLUS any app that ran and * stopped this session (sticky `lastOutcome`) so a renderer reload (Cmd+R) re-hydrates the * Stopped/Crashed badge instead of snapping it back to Idle. Without the latter, the - * `setRunningApps` snapshot — built only from running tasks — would erase the exited state. + * `setRunningApps` snapshot - built only from running tasks - would erase the exited state. */ listApps(): RunningProcess[] { const byApp = new Map(); @@ -86,13 +120,17 @@ export class AppOrchestrator extends EventEmitter { return derived; } - async startApp(appId: AppId): Promise { + startApp(appId: AppId): Promise { + return this.withLock(appId, () => this.doStartApp(appId)); + } + + private async doStartApp(appId: AppId): Promise { const allTasks = this.tasks.list(appId).filter((t) => t.enabled); if (allTasks.length === 0) { - throw new Error('No enabled tasks for this app. Add one in Config.'); + throw new Error('No enabled tasks for this app. Add one via Manage tasks.'); } - // Fresh run — drop any sticky "stopped/crashed" outcome from a previous run. + // Fresh run - drop any sticky "stopped/crashed" outcome from a previous run. this.lastOutcome.delete(appId); // Mark the app as recently used. Drives Dashboard recently-used sort and @@ -123,35 +161,67 @@ export class AppOrchestrator extends EventEmitter { let cancelled = false; - for (const level of levels) { - // Skip tasks already running (e.g. user manually started one). - const toStart: Task[] = []; - const awaitFor: Promise[] = []; - - for (const id of level) { - const t = byId.get(id)!; - if (this.runner.isRunning(id)) { - // Already running — still need to wait for readiness if not already ready. - if (!this.runner.isReady(id)) { - // We don't have a handle on the existing readiness promise here; skip waiting. - // Practical effect: if the user manually started a task with a long readiness, - // the orchestrator won't block on it. Acceptable for v1; document. + // A queued stop/restart cancels this start synchronously, so we never sit on the lock + // through a long readiness wait while the user's Stop click is stuck behind us. + let stopRequested = false; + const stopSignal = new Promise((resolve) => { + this.startCancels.set(appId, () => { + stopRequested = true; + resolve(); + }); + }); + + try { + for (const level of levels) { + if (stopRequested) break; + // Skip tasks already running (e.g. user manually started one). + const toStart: Task[] = []; + const awaitFor: Promise[] = []; + + for (const id of level) { + const t = byId.get(id)!; + if (this.runner.isRunning(id)) { + // Already running - still need to wait for readiness if not already ready. + if (!this.runner.isReady(id)) { + // We don't have a handle on the existing readiness promise here; skip waiting. + // Practical effect: if the user manually started a task with a long readiness, + // the orchestrator won't block on it. Acceptable for v1; document. + } + continue; } - continue; + toStart.push(t); } - toStart.push(t); - } - // Start the level in parallel. - const started = await Promise.all(toStart.map((t) => this.runner.start(t))); - for (const s of started) awaitFor.push(s.awaitReady); - - // Wait for all to hit ready (or fail). - const results = await Promise.all(awaitFor); - if (results.some((ok) => !ok)) { - cancelled = true; - break; + // Start the level in parallel. + const started = await Promise.all(toStart.map((t) => this.runner.start(t))); + for (const s of started) awaitFor.push(s.awaitReady); + + // Wait for all to hit ready (or fail) - or for a stop to cancel the sequence. The + // queued stop then tears down whatever already spawned. + const results = await Promise.race([ + Promise.all(awaitFor), + stopSignal.then(() => null) + ]); + if (results === null) break; // cancelled by a queued stop/restart + if (results.some((ok) => !ok)) { + cancelled = true; + break; + } } + } catch (err) { + // A task threw while spawning (folder moved, Node version missing, env build failed). + // Always emit a terminal state so the renderer never hangs on 'starting' + // (IMPROVEMENT-PLAN 5.4); the rejection still propagates so the caller can surface it. + this.emit('proc:status', { appId, state: 'crashed' as ProcessState }); + throw err; + } finally { + this.startCancels.delete(appId); + } + + if (stopRequested) { + // The queued stop emits its own terminal status; just reflect the current state. + this.emit('proc:status', { appId, state: this.appState(appId) }); + return; } this.emit('proc:status', { @@ -160,7 +230,14 @@ export class AppOrchestrator extends EventEmitter { }); } - async stopApp(appId: AppId): Promise { + stopApp(appId: AppId): Promise { + // Cancel any in-flight start FIRST (synchronously) so this stop isn't queued behind a + // readiness wait that can hold the lock for up to readiness_timeout_ms per level. + this.startCancels.get(appId)?.(); + return this.withLock(appId, () => this.doStopApp(appId)); + } + + private async doStopApp(appId: AppId): Promise { const enabledTasks = this.tasks.list(appId).filter((t) => t.enabled); const ids = enabledTasks.map((t) => t.id); const depsMap = new Map(); @@ -188,10 +265,15 @@ export class AppOrchestrator extends EventEmitter { this.emit('proc:status', { appId, state: 'exited' as ProcessState }); } - async restartApp(appId: AppId): Promise { - await this.stopApp(appId); - await new Promise((r) => setTimeout(r, 100)); - await this.startApp(appId); + restartApp(appId: AppId): Promise { + // Cancel an in-flight start so the restart isn't queued behind its readiness waits. + this.startCancels.get(appId)?.(); + // One lock acquisition for the whole stop→start so nothing interleaves between them. + return this.withLock(appId, async () => { + await this.doStopApp(appId); + await new Promise((r) => setTimeout(r, 100)); + await this.doStartApp(appId); + }); } async startTask(taskId: TaskId): Promise { @@ -205,6 +287,30 @@ export class AppOrchestrator extends EventEmitter { await this.runner.stop(taskId); } + /** How many tasks are currently live (for the quit confirmation). Includes mid-spawn starts. */ + runningTaskCount(): number { + const live = this.runner + .list() + .filter((t) => t.state === 'running' || t.state === 'starting' || t.state === 'exiting'); + const liveIds = new Set(live.map((t) => t.taskId)); + const pending = this.runner.pendingStartIds().filter((id) => !liveIds.has(id)); + return live.length + pending.length; + } + + /** + * Stop every running task, bounded by each task's kill-grace. Called on quit so dev + * servers are torn down gracefully (SIGTERM → grace → SIGKILL tree) instead of being left + * as orphans when the PTY master closes (IMPROVEMENT-PLAN 5.9). Mid-spawn starts are + * included - runner.stop() awaits the pending spawn before killing it. + */ + async stopAllRunning(): Promise { + const ids = new Set([ + ...this.runner.list().map((t) => t.taskId), + ...this.runner.pendingStartIds() + ]); + await Promise.allSettled([...ids].map((id) => this.runner.stop(id))); + } + private onTaskStatus(e: TaskStatusEvent): void { this.emit('task:status', e); // Record a sticky app outcome when a task ends, so the app stays Stopped/Crashed @@ -274,7 +380,7 @@ function deriveAppState(allTasks: Task[], running: RunningTask[]): ProcessState for (const t of allTasks) { const r = runningById.get(t.id); if (!r) { - // Not tracked — either never started or already torn down. + // Not tracked - either never started or already torn down. continue; } if (r.state === 'starting') anyStarting = true; @@ -292,7 +398,7 @@ function deriveAppState(allTasks: Task[], running: RunningTask[]): ProcessState if (anyStarting || anyNotReady) return 'starting'; if (anyRunning) return 'running'; // A task that's tracked-but-exited (the brief post-stop window before teardown) reads - // as 'exited', not 'idle' — so the app doesn't flicker Idle → Exited → Idle on stop. + // as 'exited', not 'idle' - so the app doesn't flicker Idle → Exited → Idle on stop. if (anyExited) return 'exited'; return 'idle'; } diff --git a/src/main/services/AppRegistry.ts b/src/main/services/AppRegistry.ts index 9915c89..b3e5db4 100644 --- a/src/main/services/AppRegistry.ts +++ b/src/main/services/AppRegistry.ts @@ -23,6 +23,7 @@ type AppRow = { custom_command: string | null; working_dir: string; auto_restart_on_change: number; + auto_start: number; watch_globs: string; port_hint: number | null; tags: string; @@ -100,14 +101,38 @@ export class AppRegistry { const current = this.get(id); if (!current) throw new Error(`App not found: ${id}`); - const next: App = { ...current, ...patch, updatedAt: Date.now() }; + // Validate & canonicalise a changed working directory the same way add() + // validates the app path. Without this an IPC patch could point working_dir + // at a non-existent or non-directory location (or a non-canonical symlink), + // which would later break process spawning. Only re-validate when it + // actually changes so unrelated patches stay cheap. + let workingDir = current.workingDir; + if (patch.workingDir != null && patch.workingDir !== current.workingDir) { + workingDir = this.normaliseDir(patch.workingDir); + } + + // Pin the identity / immutable fields to the CURRENT row's values. A patch + // is untrusted (it arrives over IPC) and must never be able to change which + // row we write - `id` is forced back so the WHERE clause below can't be + // retargeted at a different app, and `path`/`createdAt` are forced back so a + // stray field can't silently rewrite immutable provenance. updatedAt is + // always refreshed to now, exactly as before. + const next: App = { + ...current, + ...patch, + id: current.id, + path: current.path, + createdAt: current.createdAt, + workingDir, + updatedAt: Date.now() + }; db() .prepare( `UPDATE apps SET name = ?, color = ?, icon = ?, node_version_pref = ?, package_manager = ?, default_script = ?, custom_command = ?, working_dir = ?, - auto_restart_on_change = ?, watch_globs = ?, port_hint = ?, tags = ?, + auto_restart_on_change = ?, auto_start = ?, watch_globs = ?, port_hint = ?, tags = ?, folder = ?, last_started_at = ?, last_exit_code = ?, updated_at = ? WHERE id = ?` ) @@ -121,6 +146,7 @@ export class AppRegistry { next.customCommand, next.workingDir, next.autoRestartOnChange ? 1 : 0, + next.autoStart ? 1 : 0, JSON.stringify(next.watchGlobs), next.portHint, JSON.stringify(next.tags), @@ -140,7 +166,7 @@ export class AppRegistry { /** * Distinct folder names across all apps, sorted alphabetically (case-insensitive). - * Excludes NULL — that's the "(Ungrouped)" pseudo-folder, handled at render time. + * Excludes NULL - that's the "(Ungrouped)" pseudo-folder, handled at render time. */ listFolders(): string[] { const rows = db() @@ -178,17 +204,42 @@ export class AppRegistry { } private normalisePath(p: string): string { + return this.normaliseDir(p, 'path'); + } + + /** + * Resolve `p` to a canonical, existing directory. Shared by add() (app path) + * and update() (working_dir) so both enforce the same realpath + isDirectory + * invariant; `label` only tailors the error message for the caller. + */ + private normaliseDir(p: string, label = 'workingDir'): string { try { const real = realpathSync(p); const s = statSync(real); if (!s.isDirectory()) throw new Error(`Not a directory: ${p}`); return real; } catch (err) { - throw new Error(`Invalid path: ${p} (${(err as Error).message})`); + throw new Error(`Invalid ${label}: ${p} (${(err as Error).message})`); } } } +/** + * Parse a JSON column defensively. A single corrupt cell (e.g. truncated write, + * manual DB edit, or a schema-migration mishap) must not blow up the entire + * `list()` - one bad row would otherwise throw and hide every other app. On + * failure we warn once per call site and fall back to the column's default so + * the app still surfaces in the UI and can be repaired by re-saving. + */ +function safeJson(raw: string, fallback: T): T { + try { + return JSON.parse(raw) as T; + } catch { + console.warn(`[AppRegistry] Ignoring corrupt JSON column; using fallback. Raw: ${raw}`); + return fallback; + } +} + function rowToApp(r: AppRow): App { return { id: r.id as AppId, @@ -196,15 +247,16 @@ function rowToApp(r: AppRow): App { path: r.path, color: r.color, icon: r.icon ?? undefined, - nodeVersionPref: JSON.parse(r.node_version_pref) as NodeVersionPref, + nodeVersionPref: safeJson(r.node_version_pref, { kind: 'auto' }), packageManager: (r.package_manager as PackageManager | null) ?? null, defaultScript: r.default_script, customCommand: r.custom_command, workingDir: r.working_dir, autoRestartOnChange: !!r.auto_restart_on_change, - watchGlobs: JSON.parse(r.watch_globs) as string[], + autoStart: !!r.auto_start, + watchGlobs: safeJson(r.watch_globs, []), portHint: r.port_hint, - tags: JSON.parse(r.tags) as string[], + tags: safeJson(r.tags, []), folder: r.folder ?? null, lastStartedAt: r.last_started_at, lastExitCode: r.last_exit_code, diff --git a/src/main/services/DeepLinks.ts b/src/main/services/DeepLinks.ts index d1b4298..e918b73 100644 --- a/src/main/services/DeepLinks.ts +++ b/src/main/services/DeepLinks.ts @@ -1,4 +1,5 @@ import { app, BrowserWindow } from 'electron'; +import { realpathSync } from 'node:fs'; import type { AppRegistry } from './AppRegistry'; import type { AppOrchestrator } from './AppOrchestrator'; import type { AppId } from '@shared/types'; @@ -14,7 +15,7 @@ const PROTOCOL = 'devharbor'; * can offer to register it). * - devharbor://open?id= → focus by id * - devharbor://start?id= → focus the app and ask the renderer to CONFIRM - * starting it (never starts silently — a link + * starting it (never starts silently - a link * from any web page must not run shell commands * without the user's consent) * @@ -73,11 +74,18 @@ export class DeepLinks { } } if (path) { - const existing = this.registry.getByPath(path); + // Registered apps store their realpath'd, canonical path (AppRegistry.add → + // normalisePath → realpathSync), so a symlinked or otherwise non-canonical path + // from the URL would never match an existing entry on a raw string compare. Resolve + // it the same way before the lookup. If the path doesn't exist on disk, realpathSync + // throws - fall back to the raw value, which simply won't match and proceeds to the + // unknownPath branch exactly as before. + const canonicalPath = this.canonicalise(path); + const existing = this.registry.getByPath(canonicalPath); if (existing) { win.webContents.send('deepLink:focusApp', { appId: existing.id }); } else { - win.webContents.send('deepLink:unknownPath', { path }); + win.webContents.send('deepLink:unknownPath', { path: canonicalPath }); } } } else if (host === 'start') { @@ -93,4 +101,18 @@ export class DeepLinks { } } } + + /** + * Resolve a filesystem path to its canonical (symlink-free) form so it can be compared + * against the realpath'd paths stored in the registry. Wrapped in try/catch because the + * path may not exist on disk - in that case we keep the raw value, which won't match any + * registered app and falls through to the unknownPath flow, preserving prior behaviour. + */ + private canonicalise(path: string): string { + try { + return realpathSync(path); + } catch { + return path; + } + } } diff --git a/src/main/services/DetectionService.ts b/src/main/services/DetectionService.ts index 7c55c4d..be7da46 100644 --- a/src/main/services/DetectionService.ts +++ b/src/main/services/DetectionService.ts @@ -1,11 +1,15 @@ -import { existsSync, readFileSync, readdirSync } from 'node:fs'; -import { join } from 'node:path'; -import type { DetectionResult } from '@shared/types'; +import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'; +import { join, relative } from 'node:path'; +import type { DetectionResult, WorkspaceCandidate } from '@shared/types'; import { PMDetector } from './PMDetector'; import { NodeResolver } from './NodeResolver'; const SCRIPT_PRIORITY = ['dev', 'start', 'serve', 'develop']; +function pickSuggested(scriptNames: string[]): string | null { + return SCRIPT_PRIORITY.find((s) => scriptNames.includes(s)) ?? scriptNames[0] ?? null; +} + export class DetectionService { constructor( private readonly pms = new PMDetector(), @@ -17,13 +21,11 @@ export class DetectionService { const nodeVersionFromProject = this.node.readProjectNodeVersion(projectPath) ?? this.node.readEnginesNode(projectPath); + const hasPackageJson = existsSync(join(projectPath, 'package.json')); const scripts = this.readScripts(projectPath); const envFiles = this.findEnvFiles(projectPath); - const suggestedDefaultScript = - SCRIPT_PRIORITY.find((s) => Object.prototype.hasOwnProperty.call(scripts, s)) ?? - Object.keys(scripts)[0] ?? - null; + const suggestedDefaultScript = pickSuggested(Object.keys(scripts)); return { packageManager, @@ -31,8 +33,103 @@ export class DetectionService { scripts, hasEnvFile: envFiles.length > 0, envFiles, - suggestedDefaultScript + suggestedDefaultScript, + hasPackageJson, + workspaces: this.detectWorkspaces(projectPath) + }; + } + + /** + * Monorepo support: read pnpm-workspace.yaml / package.json "workspaces" globs, expand one + * level, and return each workspace package that defines runnable scripts. Lets the add flow + * offer "create a task per workspace package" instead of hand-building each task. + */ + private detectWorkspaces(root: string): WorkspaceCandidate[] { + const { include, exclude } = this.workspaceGlobs(root); + if (include.length === 0) return []; + const dirs = new Set(); + for (const g of include) { + for (const d of expandGlob(root, g)) dirs.add(d); + } + // pnpm supports negated entries (e.g. `- '!packages/legacy'`) - subtract their expansions + // so excluded packages aren't offered as workspace-task candidates. + for (const g of exclude) { + for (const d of expandGlob(root, g)) dirs.delete(d); + } + const out: WorkspaceCandidate[] = []; + for (const dir of dirs) { + const pkgPath = join(dir, 'package.json'); + if (!existsSync(pkgPath)) continue; + try { + const data = JSON.parse(readFileSync(pkgPath, 'utf8')); + const scriptNames = + data?.scripts && typeof data.scripts === 'object' + ? Object.keys(data.scripts).filter((k) => typeof data.scripts[k] === 'string') + : []; + if (scriptNames.length === 0) continue; + out.push({ + name: typeof data?.name === 'string' ? data.name : relative(root, dir), + relPath: relative(root, dir), + scripts: scriptNames, + suggestedScript: pickSuggested(scriptNames) + }); + } catch { + // skip unreadable / malformed package.json + } + } + return out.sort((a, b) => a.relPath.localeCompare(b.relPath)).slice(0, 50); + } + + private workspaceGlobs(root: string): { include: string[]; exclude: string[] } { + const include: string[] = []; + const exclude: string[] = []; + const push = (raw: string): void => { + const g = raw.trim(); + if (!g) return; + if (g.startsWith('!')) exclude.push(g.slice(1)); + else include.push(g); }; + // pnpm-workspace.yaml (light line parse - no YAML dep). Handles both the block list + // (`packages:\n - 'apps/*'`) and the inline flow form (`packages: ['apps/*', 'libs/*']`). + const pnpmWs = join(root, 'pnpm-workspace.yaml'); + if (existsSync(pnpmWs)) { + try { + let inPackages = false; + for (const raw of readFileSync(pnpmWs, 'utf8').split(/\r?\n/)) { + const flow = raw.match(/^packages:\s*\[(.*)\]\s*$/); + if (flow) { + for (const part of (flow[1] ?? '').split(',')) { + push(part.replace(/['"]/g, '')); + } + continue; + } + if (/^packages:\s*$/.test(raw)) { + inPackages = true; + continue; + } + if (inPackages) { + const m = raw.match(/^\s*-\s*['"]?([^'"#]+?)['"]?\s*$/); + if (m && m[1]) push(m[1]); + else if (/^\S/.test(raw)) inPackages = false; // dedented → left the list + } + } + } catch { + // ignore + } + } + // package.json "workspaces" (npm/yarn) - array or { packages: [...] }. + const pkgPath = join(root, 'package.json'); + if (existsSync(pkgPath)) { + try { + const data = JSON.parse(readFileSync(pkgPath, 'utf8')); + const ws = data?.workspaces; + const arr = Array.isArray(ws) ? ws : Array.isArray(ws?.packages) ? ws.packages : []; + for (const g of arr) if (typeof g === 'string') push(g); + } catch { + // ignore + } + } + return { include: [...new Set(include)], exclude: [...new Set(exclude)] }; } private readScripts(projectPath: string): Record { @@ -62,3 +159,36 @@ export class DetectionService { } } } + +/** + * Expand a single workspace glob one level deep. Handles the common monorepo shapes - + * `packages/*`, `apps/*`, or an exact relative path - returning absolute directory paths. + * Deliberately not a full glob engine; deeper/`**` patterns just resolve their static prefix. + */ +function expandGlob(root: string, glob: string): string[] { + const clean = glob.replace(/\/+$/, ''); + const starIdx = clean.indexOf('*'); + if (starIdx === -1) { + const abs = join(root, clean); + return isDir(abs) ? [abs] : []; + } + const prefix = clean.slice(0, starIdx).replace(/\/+$/, ''); + const parent = join(root, prefix); + if (!isDir(parent)) return []; + try { + return readdirSync(parent) + .filter((name) => !name.startsWith('.')) + .map((name) => join(parent, name)) + .filter(isDir); + } catch { + return []; + } +} + +function isDir(p: string): boolean { + try { + return statSync(p).isDirectory(); + } catch { + return false; + } +} diff --git a/src/main/services/EnvBuilder.ts b/src/main/services/EnvBuilder.ts index 14ad38f..969aca3 100644 --- a/src/main/services/EnvBuilder.ts +++ b/src/main/services/EnvBuilder.ts @@ -5,16 +5,36 @@ import { parseDotEnv } from '@shared/dotenv'; import type { EnvStore } from './EnvStore'; import type { PathProbe } from './PathProbe'; +/** + * Process-control keys that an on-disk project `.env` is NEVER allowed to set. + * + * IMPROVEMENT-PLAN 6.1: a checked-in or malicious `.env` could otherwise hijack + * the spawned process by overriding the computed PATH (shadowing real binaries + * with attacker-controlled ones on disk), or by injecting loader/runtime hooks + * via NODE_OPTIONS / NODE_PATH / DYLD_* / LD_* (e.g. DYLD_INSERT_LIBRARIES code + * injection on macOS). We strip these from file vars entirely - they can only + * come from the sanitized base, the computed PATH, or user-configured env_vars. + */ +const PROCESS_CONTROL_KEYS = new Set(['PATH', 'NODE_OPTIONS', 'NODE_PATH']); + +/** True if a key from a project `.env` must be dropped (see {@link PROCESS_CONTROL_KEYS}). */ +function isProcessControlKey(key: string): boolean { + return PROCESS_CONTROL_KEYS.has(key) || /^(DYLD_|LD_)/.test(key); +} + /** * Build the env handed to a spawned task. * - * Layering order (later wins): - * 1. Sanitized OS base (HOME, USER, LANG, …) - * 2. User's full PATH from the login-shell probe (Node bin prepended in front) - * 3. Global env_vars rows (enabled only) - * 4. App env_vars rows (enabled only) - * 5. Task env_vars rows (enabled only) — Phase 7 - * 6. .env, .env.local from the task's working dir + * Layering order (later wins). IMPROVEMENT-PLAN 6.1: user-configured vars now + * sit ABOVE project `.env` files so the UI is the source of truth, and project + * files can never override process-control keys (see {@link isProcessControlKey}). + * + * 1. Sanitized OS base (HOME, USER, LANG, SSH_AUTH_SOCK, …) + * 2. Computed PATH (Node bin dir prepended to the login-shell PATH probe) + * 3. Project .env files (process-control keys stripped - see parseEnvFiles) + * 4. Global env_vars rows (enabled only) + * 5. App env_vars rows (enabled only) + * 6. Task env_vars rows (enabled only) - Phase 7 * 7. Hard-coded runtime (FORCE_COLOR, TERM) */ export class EnvBuilder { @@ -37,7 +57,14 @@ export class EnvBuilder { 'LANG', 'LC_ALL', 'TMPDIR', - 'SHELL' + 'SHELL', + // Forward the agent socket so git-over-SSH / `ssh` in a task authenticates + // the same way it does in the user's terminal instead of prompting/failing. + 'SSH_AUTH_SOCK', + // Terminal identity hints so CLIs that probe these (e.g. for hyperlink or + // truecolor support) behave like they would in a real terminal. + 'TERM_PROGRAM', + 'COLORTERM' ]); const userPath = await this.pathProbe.get(); @@ -46,21 +73,22 @@ export class EnvBuilder { PATH: `${nodeBinDir}:${userPath}` }; + // Project .env files first, so user-configured env_vars below always win and + // process-control keys (PATH, NODE_OPTIONS, …) are stripped (see parseEnvFiles). + Object.assign(env, this.parseEnvFiles(cwd)); + // Global env vars (from settings → env_vars table) layerVars(env, this.envStore.getGlobal()); // App env vars layerVars(env, this.envStore.getApp(app.id as AppId)); - // Task env vars — Phase 7. EnvStore.getTask backfills from legacy + // Task env vars - Phase 7. EnvStore.getTask backfills from legacy // tasks.env_overrides JSON on first read if no rows exist yet. if (task) { layerVars(env, this.envStore.getTask(task.id)); } - // .env files in cwd - Object.assign(env, this.parseEnvFiles(cwd)); - // Hard-coded runtime env.FORCE_COLOR = '1'; env.TERM = 'xterm-256color'; @@ -69,13 +97,21 @@ export class EnvBuilder { } private parseEnvFiles(dir: string): Record { - const files = ['.env', '.env.local']; + // Conventional dev-tool precedence (later wins): base, then env-specific, + // then local overrides, then env-specific local overrides. All of these + // still sit BELOW user-configured env_vars in build(). + const files = ['.env', '.env.development', '.env.local', '.env.development.local']; const out: Record = {}; for (const f of files) { const p = join(dir, f); if (!existsSync(p)) continue; try { - Object.assign(out, parseDotEnv(readFileSync(p, 'utf8'))); + const parsed = parseDotEnv(readFileSync(p, 'utf8')); + for (const [key, value] of Object.entries(parsed)) { + // Never let a project file hijack the process - drop control keys. + if (isProcessControlKey(key)) continue; + out[key] = value; + } } catch { // ignore unreadable env file } diff --git a/src/main/services/EnvStore.ts b/src/main/services/EnvStore.ts index 8e83e2b..5cde142 100644 --- a/src/main/services/EnvStore.ts +++ b/src/main/services/EnvStore.ts @@ -1,7 +1,15 @@ +import { safeStorage } from 'electron'; import { ulid } from 'ulid'; import { db } from '../db/index.js'; import type { AppId, EnvVar, TaskId } from '@shared/types'; +/** + * Prefix tagging a value as encrypted-at-rest via Electron safeStorage. + * Versioned so a future scheme change (e.g. 'enc2:') can be distinguished without + * a destructive migration. Plaintext rows carry no prefix. + */ +const ENC_PREFIX = 'enc1:'; + type EnvRow = { id: string; app_id: string | null; @@ -61,7 +69,7 @@ export class EnvStore { `SELECT * FROM env_vars WHERE app_id IS NULL AND task_id IS NULL ORDER BY key ASC` ) .all() - .map(rowToEnvVar); + .map(this.rowToEnvVar); } private listApp(appId: AppId): EnvVar[] { @@ -70,7 +78,7 @@ export class EnvStore { `SELECT * FROM env_vars WHERE app_id = ? AND task_id IS NULL ORDER BY key ASC` ) .all(appId) - .map(rowToEnvVar); + .map(this.rowToEnvVar); } private listTask(taskId: TaskId): EnvVar[] { @@ -79,7 +87,7 @@ export class EnvStore { `SELECT * FROM env_vars WHERE task_id = ? ORDER BY key ASC` ) .all(taskId) - .map(rowToEnvVar); + .map(this.rowToEnvVar); } private replaceGlobal(incoming: EnvVar[]): void { @@ -112,8 +120,19 @@ export class EnvStore { if (!row) throw new Error(`Task ${taskId} not found`); const appId = row.app_id as AppId; const deleteAll = db().prepare(`DELETE FROM env_vars WHERE task_id = ?`); + // Permanently neutralise the legacy tasks.env_overrides JSON in the SAME + // transaction as the delete+insert. Without this, deleting all of a task's + // vars (setTask(id, [])) leaves COUNT(env_vars)=0, and on next launch + // ensureTaskBackfilled would re-insert the frozen JSON, resurrecting the + // deleted values (IMPROVEMENT-PLAN 5.7). '{}' is the persistent done-marker. + const neutralizeLegacy = db().prepare( + `UPDATE tasks SET env_overrides = '{}' WHERE id = ?` + ); this.replaceTx( - () => deleteAll.run(taskId), + () => { + deleteAll.run(taskId); + neutralizeLegacy.run(taskId); + }, incoming, { appId, taskId } ); @@ -142,7 +161,9 @@ export class EnvStore { scope.appId, scope.taskId, key, - v.value ?? '', + // Encrypt secret values at rest; non-secrets stored verbatim. getX still + // returns decrypted plaintext via rowToEnvVar, so callers are unaffected. + this.encMaybe(v.value ?? '', v.isSecret), v.enabled ? 1 : 0, v.isSecret ? 1 : 0, v.note ?? null, @@ -160,6 +181,12 @@ export class EnvStore { * Idempotent: if the task already has any rows in env_vars (task_id = ?) we assume * it's already migrated and skip. We also remember the taskId in-memory so we don't * re-check on every call within one process lifetime. + * + * IMPROVEMENT-PLAN 5.7: on EVERY return path we collapse tasks.env_overrides to + * '{}' (the persistent done-marker from the 0004 migration). Without this, a task + * whose vars were all deleted would have COUNT(env_vars)=0 on next launch and the + * frozen JSON would be re-backfilled, resurrecting the deleted values. Stamping + * '{}' once and for all makes resurrection impossible across relaunches. */ private ensureTaskBackfilled(taskId: TaskId): void { if (this.backfilledTasks.has(taskId)) return; @@ -169,6 +196,7 @@ export class EnvStore { ) .get(taskId); if (existing && existing.n > 0) { + this.neutralizeLegacyOverrides(taskId); this.backfilledTasks.add(taskId); return; } @@ -178,6 +206,7 @@ export class EnvStore { ) .get(taskId); if (!row || !row.env_overrides) { + this.neutralizeLegacyOverrides(taskId); this.backfilledTasks.add(taskId); return; } @@ -185,11 +214,13 @@ export class EnvStore { try { parsed = JSON.parse(row.env_overrides) as Record; } catch { + this.neutralizeLegacyOverrides(taskId); this.backfilledTasks.add(taskId); return; } const entries = Object.entries(parsed).filter(([, v]) => typeof v === 'string'); if (entries.length === 0) { + this.neutralizeLegacyOverrides(taskId); this.backfilledTasks.add(taskId); return; } @@ -201,19 +232,89 @@ export class EnvStore { enabled: true, isSecret: /SECRET|TOKEN|PASSWORD|KEY|PRIVATE/i.test(k) })); + // replaceTask already stamps env_overrides='{}' inside its transaction, so the + // backfill itself is self-neutralising; the explicit stamps above cover the + // paths where replaceTask is never reached. this.replaceTask(taskId, incoming); this.backfilledTasks.add(taskId); } -} -function rowToEnvVar(r: EnvRow): EnvVar { - return { + /** Collapse a task's legacy env_overrides JSON to the '{}' done-marker (idempotent). */ + private neutralizeLegacyOverrides(taskId: TaskId): void { + db() + .prepare(`UPDATE tasks SET env_overrides = '{}' WHERE id = ?`) + .run(taskId); + } + + /** + * Encrypt a value for storage when it is a secret and OS-backed encryption is + * available. Returns 'enc1:'+base64(ciphertext) for secrets, otherwise the value + * verbatim. When safeStorage is unavailable (e.g. headless/Linux without a keyring) + * we silently fall back to plaintext rather than block saves. + */ + private encMaybe(value: string, isSecret: boolean): string { + if (isSecret && safeStorage.isEncryptionAvailable()) { + return ENC_PREFIX + safeStorage.encryptString(value).toString('base64'); + } + return value; + } + + /** + * Inverse of encMaybe: decrypt 'enc1:'-tagged SECRET values back to plaintext; pass + * everything else through unchanged. Only rows flagged is_secret are ever decrypted - + * a non-secret value that legitimately begins with 'enc1:' must not be interpreted as + * ciphertext. On decrypt failure (keychain unavailable, corrupt blob) we return the RAW + * stored string rather than '' - destroying the value would make a transient keychain + * hiccup permanent the moment the user hits Save. + */ + private decMaybe(value: string, isSecret: boolean): string { + if (isSecret && value.startsWith(ENC_PREFIX)) { + try { + return safeStorage.decryptString( + Buffer.from(value.slice(ENC_PREFIX.length), 'base64') + ); + } catch { + return value; + } + } + return value; + } + + /** + * One-shot, idempotent boot migration: re-encrypt any secret rows still stored + * as plaintext (is_secret = 1 AND value NOT LIKE 'enc1:%'). Called once on boot by + * the IPC layer. No-op when encryption is unavailable, so plaintext stays readable. + */ + migratePlaintextSecrets(): void { + if (!safeStorage.isEncryptionAvailable()) return; + const rows = db() + .prepare( + `SELECT id, value FROM env_vars WHERE is_secret = 1 AND value NOT LIKE 'enc1:%'` + ) + .all(); + if (rows.length === 0) return; + const update = db().prepare(`UPDATE env_vars SET value = ? WHERE id = ?`); + const tx = db().transaction(() => { + for (const r of rows) { + update.run(this.encMaybe(r.value, true), r.id); + } + }); + tx(); + } + + /** + * Map a raw env_vars row to the public EnvVar shape, transparently decrypting + * secret values so EnvBuilder/EnvEditor always see plaintext. Arrow-bound so it + * can be passed directly to Array.map without losing `this` (needs this.decMaybe). + */ + private readonly rowToEnvVar = (r: EnvRow): EnvVar => ({ id: r.id, appId: (r.app_id as AppId | null) ?? null, key: r.key, - value: r.value, + value: this.decMaybe(r.value, !!r.is_secret), enabled: !!r.enabled, isSecret: !!r.is_secret, note: r.note ?? undefined - }; + }); } + diff --git a/src/main/services/LogBuffer.ts b/src/main/services/LogBuffer.ts index 4001af8..ad1775b 100644 --- a/src/main/services/LogBuffer.ts +++ b/src/main/services/LogBuffer.ts @@ -3,23 +3,32 @@ import type { TaskId } from '@shared/types'; interface Buffer { chunks: string[]; bytes: number; + lastTouch: number; + exited: boolean; } const DEFAULT_MAX_BYTES = 5 * 1024 * 1024; // 5 MB per task const DEFAULT_MAX_LINES = 10_000; // hard upper bound on chunk count const MAX_SINGLE_CHUNK_BYTES = 10 * 1024; // truncate any single chunk this long +const GLOBAL_MAX_BYTES = 100 * 1024 * 1024; // 100 MB across ALL tasks +const EXITED_TTL_MS = 10 * 60 * 1000; // free an exited task's buffer ~10 min after exit /** * Main-side authoritative ring buffer for each task's log stream. * - * - Bounded by both bytes and chunk count. + * - Bounded per-task by both bytes and chunk count. + * - Bounded GLOBALLY by a total byte budget with LRU eviction of EXITED-task buffers first, + * so a long session that ran dozens of tasks can't pin hundreds of MB in main forever + * (IMPROVEMENT-PLAN 9.4). An exited task's buffer is also freed ~10 min after exit. * - Single chunks larger than `MAX_SINGLE_CHUNK_BYTES` are truncated with a marker. - * - Survives task exits (the renderer can still scroll back). + * - Survives task exits (the renderer can still scroll back) until evicted/timed-out. * - Cleared explicitly via `clear()` or when the app is removed. * - Bounds live-updatable via `setLimits()` so Settings changes take effect immediately. */ export class LogBuffer { private readonly buffers = new Map(); + private readonly expiry = new Map(); + private totalBytes = 0; private maxBytes: number; private maxLines: number; @@ -44,34 +53,94 @@ export class LogBuffer { let buf = this.buffers.get(taskId); if (!buf) { - buf = { chunks: [], bytes: 0 }; + buf = { chunks: [], bytes: 0, lastTouch: Date.now(), exited: false }; this.buffers.set(taskId, buf); } + // Fresh output means the task is alive again (e.g. a restart) - un-mark it as exited. + if (buf.exited) { + buf.exited = false; + const t = this.expiry.get(taskId); + if (t) { + clearTimeout(t); + this.expiry.delete(taskId); + } + } buf.chunks.push(chunk); buf.bytes += chunk.length; + this.totalBytes += chunk.length; + buf.lastTouch = Date.now(); while (buf.chunks.length > this.maxLines || buf.bytes > this.maxBytes) { const dropped = buf.chunks.shift(); if (!dropped) break; buf.bytes -= dropped.length; + this.totalBytes -= dropped.length; } + + if (this.totalBytes > GLOBAL_MAX_BYTES) this.enforceGlobalCap(taskId); } read(taskId: TaskId): string { const buf = this.buffers.get(taskId); if (!buf) return ''; + buf.lastTouch = Date.now(); return buf.chunks.join(''); } tail(taskId: TaskId, maxLines = 200): string { const buf = this.buffers.get(taskId); if (!buf) return ''; + buf.lastTouch = Date.now(); const joined = buf.chunks.join(''); const lines = joined.split(/\r?\n/); return lines.slice(Math.max(0, lines.length - maxLines)).join('\n'); } + /** Mark a task as exited: its buffer becomes preferentially evictable and self-frees later. */ + markExited(taskId: TaskId): void { + const buf = this.buffers.get(taskId); + if (!buf) return; + buf.exited = true; + const existing = this.expiry.get(taskId); + if (existing) clearTimeout(existing); + const timer = setTimeout(() => this.clear(taskId), EXITED_TTL_MS); + // Don't keep the process alive just to expire a log buffer. + timer.unref?.(); + this.expiry.set(taskId, timer); + } + clear(taskId: TaskId): void { + const buf = this.buffers.get(taskId); + if (buf) this.totalBytes -= buf.bytes; this.buffers.delete(taskId); + const t = this.expiry.get(taskId); + if (t) { + clearTimeout(t); + this.expiry.delete(taskId); + } + } + + /** Evict whole buffers until under the global budget - exited tasks first, then LRU. */ + private enforceGlobalCap(protectedId: TaskId): void { + while (this.totalBytes > GLOBAL_MAX_BYTES) { + let victim: TaskId | null = null; + let victimTouch = Infinity; + let victimExited = false; + for (const [id, buf] of this.buffers) { + if (id === protectedId) continue; // never evict the task we're actively writing + // Prefer exited buffers; among same exited-ness, evict the least recently touched. + const better = + victim === null || + (buf.exited && !victimExited) || + (buf.exited === victimExited && buf.lastTouch < victimTouch); + if (better) { + victim = id; + victimExited = buf.exited; + victimTouch = buf.lastTouch; + } + } + if (victim === null) break; // only the protected buffer remains + this.clear(victim); + } } } diff --git a/src/main/services/Logger.ts b/src/main/services/Logger.ts new file mode 100644 index 0000000..d5862f6 --- /dev/null +++ b/src/main/services/Logger.ts @@ -0,0 +1,64 @@ +import { app, shell } from 'electron'; +import { appendFileSync, mkdirSync } from 'node:fs'; +import { join } from 'node:path'; + +/** + * Minimal local file logger. DevHarbor ships with no telemetry, so when a user reports "it + * crashed" or "updates don't work" there was previously nothing to attach (IMPROVEMENT-PLAN + * 13.1). This writes plain-text diagnostics to the OS logs dir (~/Library/Logs/DevHarbor on + * macOS) - purely local, nothing leaves the machine. Deliberately dependency-free. + */ + +let logFilePath: string | null = null; + +function file(): string { + if (logFilePath) return logFilePath; + const dir = app.getPath('logs'); + try { + mkdirSync(dir, { recursive: true }); + } catch { + // ignore + } + logFilePath = join(dir, 'devharbor.log'); + return logFilePath; +} + +function fmt(a: unknown): string { + if (a instanceof Error) return a.stack ?? `${a.name}: ${a.message}`; + if (typeof a === 'string') return a; + try { + return JSON.stringify(a); + } catch { + return String(a); + } +} + +function write(level: string, args: unknown[]): void { + const line = `[${new Date().toISOString()}] [${level}] ${args.map(fmt).join(' ')}\n`; + try { + appendFileSync(file(), line); + } catch { + // never let logging throw + } +} + +export const logger = { + info: (...a: unknown[]): void => write('INFO', a), + warn: (...a: unknown[]): void => write('WARN', a), + error: (...a: unknown[]): void => write('ERROR', a), + path: (): string => file(), + openFolder: (): void => { + void shell.showItemInFolder(file()); + } +}; + +/** + * Route process-level faults into the log file. Without these, an uncaught exception or + * rejected promise in the packaged main process vanishes (no console attached). We log and + * keep running rather than letting the default handler tear the app down. + */ +export function installProcessLogging(): void { + process.on('uncaughtException', (err) => write('UNCAUGHT', [err])); + process.on('unhandledRejection', (reason) => write('UNHANDLED_REJECTION', [reason])); + write('INFO', [`DevHarbor ${app.getVersion()} starting`]); +} diff --git a/src/main/services/NodeResolver.ts b/src/main/services/NodeResolver.ts index 1f2b785..0dc5942 100644 --- a/src/main/services/NodeResolver.ts +++ b/src/main/services/NodeResolver.ts @@ -9,7 +9,7 @@ import type { NodeInstallation, NodeVersionPref } from '@shared/types'; * Discover Node installations across nvm / fnm / volta / asdf / system, * and resolve a project's preferred version to an absolute bin directory. * - * Filesystem-based — does NOT shell out to nvm (which is a shell function). + * Filesystem-based - does NOT shell out to nvm (which is a shell function). */ export class NodeResolver { private cache: NodeInstallation[] | null = null; diff --git a/src/main/services/OpenIn.ts b/src/main/services/OpenIn.ts index 33c272e..dbd6a7b 100644 --- a/src/main/services/OpenIn.ts +++ b/src/main/services/OpenIn.ts @@ -19,7 +19,7 @@ export interface OpenInCapabilities { /** * macOS .app bundle names per editor. We detect by bundle existence and launch via - * `open -a ""` — NOT by probing CLI shims (`code`, `cursor`, `subl`) on PATH. + * `open -a ""` - NOT by probing CLI shims (`code`, `cursor`, `subl`) on PATH. * * Why: a GUI-launched macOS app inherits a minimal PATH (`/usr/bin:/bin:/usr/sbin:/sbin`) * that excludes `/usr/local/bin` and `/opt/homebrew/bin` where those CLI shims live. So @@ -44,7 +44,7 @@ function bundleExists(bundles: string[]): boolean { /** * Ask LaunchServices whether an app of this display name is installed ANYWHERE - * (Setapp, nested folders, /Applications/Utilities, etc.) — `open -a ""` resolves + * (Setapp, nested folders, /Applications/Utilities, etc.) - `open -a ""` resolves * the same way, so this matches what the launch will actually do. Returns false on any * error (osascript exits non-zero when the app is unknown). */ @@ -69,7 +69,7 @@ export class OpenIn { async caps(): Promise { if (this.capsCache) return this.capsCache; // Fast path: bundle present in the standard dirs. Fallback: ask LaunchServices, which - // finds the app wherever it's installed — matching what `open -a` will actually do. + // finds the app wherever it's installed - matching what `open -a` will actually do. const detect = async (e: { appName: string; bundles: string[] }): Promise => bundleExists(e.bundles) || launchServicesKnows(e.appName); const [vscode, cursor, sublime] = await Promise.all([ diff --git a/src/main/services/PathProbe.ts b/src/main/services/PathProbe.ts index 849e939..c6f4bda 100644 --- a/src/main/services/PathProbe.ts +++ b/src/main/services/PathProbe.ts @@ -3,6 +3,35 @@ import { promisify } from 'node:util'; const execFileP = promisify(execFile); +/** + * Unique markers printed around the real PATH so we can recover it from stdout that may + * also contain interactive-rc noise (banners, version-manager warnings, etc.). They must + * be unlikely to appear in any legitimate PATH or rc output. + */ +const PATH_BEGIN = '__DH_PATH_BEGIN__'; +const PATH_END = '__DH_PATH_END__'; + +/** + * Recovers the real PATH from probe stdout. Prefers the substring between the sentinels + * (so rc banners/warnings printed before or after the marker are discarded). If the + * markers are missing - e.g. an old shell that mangled the printf - falls back to the last + * non-empty line, which is the most likely place for a bare `echo $PATH`-style value. + * Returns '' when nothing usable is found so the caller can drop to process.env.PATH. + */ +function extractPath(stdout: string): string { + const start = stdout.indexOf(PATH_BEGIN); + const end = stdout.indexOf(PATH_END, start + PATH_BEGIN.length); + if (start !== -1 && end !== -1) { + return stdout.slice(start + PATH_BEGIN.length, end).trim(); + } + const lines = stdout.split('\n'); + for (let i = lines.length - 1; i >= 0; i--) { + const line = lines[i]?.trim(); + if (line) return line; + } + return ''; +} + /** * Apps launched from Finder/Spotlight inherit a stripped PATH that lacks the user's * shell additions (pnpm, asdf shims, /opt/homebrew, etc.). Probing a login shell on @@ -21,13 +50,20 @@ export class PathProbe { this.probed = true; try { const shell = process.env.SHELL || '/bin/zsh'; - // `-l` login, `-i` interactive — read full user rc files. `print -r --` is zsh-safe - // and bash treats it as printing a string too. - const { stdout } = await execFileP(shell, ['-l', '-i', '-c', 'printf %s "$PATH"'], { - timeout: 3000, - maxBuffer: 1 << 20 - }); - const probed = stdout.trim(); + // `-l` login, `-i` interactive - read full user rc files. But `-i` runs the user's + // interactive rc, and anything those files print to stdout (shell banners, + // fastfetch/neofetch, "nvm is not compatible…" warnings, fnm/asdf messages) lands in + // our captured stdout and would corrupt PATH. Wrap the real value in unique sentinels + // so we can slice out exactly the PATH and discard any surrounding rc noise. + const { stdout } = await execFileP( + shell, + ['-l', '-i', '-c', `printf '${PATH_BEGIN}%s${PATH_END}' "$PATH"`], + { + timeout: 3000, + maxBuffer: 1 << 20 + } + ); + const probed = extractPath(stdout); if (probed) { this.cached = probed; return probed; diff --git a/src/main/services/PortDetector.ts b/src/main/services/PortDetector.ts index 4d8d677..6609322 100644 --- a/src/main/services/PortDetector.ts +++ b/src/main/services/PortDetector.ts @@ -5,6 +5,10 @@ import type { AppId, TaskId } from '@shared/types'; const execFileP = promisify(execFile); const POLL_MS = 2000; +// A log-hinted port that lsof never confirms within this many polls is dropped, so a +// transient "Port 3000 in use, trying 3001" line doesn't pin :3000 forever (IMPROVEMENT-PLAN 7.5). +const HINT_MAX_MISSES = 3; +const MAX_TREE_DEPTH = 6; export interface PortsEvent { taskId: TaskId; @@ -19,6 +23,8 @@ interface Tracked { knownPorts: Set; /** Ports inferred from stdout (e.g. "localhost:3000"); merged with lsof results. */ hinted: Set; + /** Per-hint count of consecutive polls where lsof did NOT confirm the port. */ + hintMisses: Map; } const URL_PORT_RE = /\blocalhost:(\d{2,5})\b|\b(?:https?|ws):\/\/[^\s/]+:(\d{2,5})\b/g; @@ -26,14 +32,15 @@ const LISTEN_PORT_RE = /\blistening\s+on\s+(?:port\s+)?:?(\d{2,5})\b/gi; /** * Detects which TCP ports each task is listening on. Two pronged: - * - Stdout parsing for `localhost:` and "listening on :" patterns. - * - `lsof` poll every 2s over the task's process tree. + * - Stdout parsing for `localhost:` and "listening on :" patterns. + * - `lsof` poll every 2s over the task's process tree. * * Emits 'ports' { taskId, appId, ports } when the set changes. */ export class PortDetector extends EventEmitter { private readonly tracked = new Map(); private timer: NodeJS.Timeout | null = null; + private inFlight = false; track(taskId: TaskId, appId: AppId, pid: number): void { this.tracked.set(taskId, { @@ -41,7 +48,8 @@ export class PortDetector extends EventEmitter { appId, pid, knownPorts: new Set(), - hinted: new Set() + hinted: new Set(), + hintMisses: new Map() }); this.start(); // Immediate tick so quickly-listening processes (Vite, Astro) show their port @@ -85,9 +93,59 @@ export class PortDetector extends EventEmitter { } private async tick(): Promise { - for (const t of this.tracked.values()) { - try { - const observed = await this.listListeningPortsFor(t); + const tasks = [...this.tracked.values()]; + if (tasks.length === 0 || this.inFlight) return; + this.inFlight = true; + try { + // ONE `ps` snapshot for the whole machine instead of one `pgrep -P` per process in + // every task's tree, every tick (IMPROVEMENT-PLAN 9.2). With N tasks this turns + // ~25-35 forks/sec into 2: one ps + one lsof. + const childrenByPpid = await snapshotProcessTree(); + + const descByTask = new Map>(); + const allPids = new Set(); + for (const t of tasks) { + const desc = descendantsFromSnapshot(t.pid, childrenByPpid, MAX_TREE_DEPTH); + descByTask.set(t.taskId, desc); + for (const p of desc) allPids.add(p); + } + + const lsofResult = + allPids.size > 0 + ? await lsofPortsByPid([...allPids]) + : { byPid: new Map>(), failed: false }; + const portsByPid = lsofResult.byPid; + + for (const t of tasks) { + const desc = descByTask.get(t.taskId) ?? new Set(); + const observed = new Set(); + for (const pid of desc) { + const ps = portsByPid.get(pid); + if (ps) for (const p of ps) observed.add(p); + } + + // Prune stale hints: a hinted port lsof hasn't confirmed for HINT_MAX_MISSES polls is + // dropped (kills the "Port 3000 in use, trying 3001" false positive). A tick where + // lsof itself produced nothing (failed with no output) proves nothing about any hint - + // counting it as a miss would erase every hinted port within seconds, so skip pruning. + for (const port of [...t.hinted]) { + if (observed.has(port)) { + t.hintMisses.delete(port); + } else if (!lsofResult.failed) { + const misses = (t.hintMisses.get(port) ?? 0) + 1; + if (misses >= HINT_MAX_MISSES) { + t.hinted.delete(port); + t.hintMisses.delete(port); + } else { + t.hintMisses.set(port, misses); + } + } + } + + // Same reasoning for lsof-confirmed ports: on a failed tick, keep the previous set + // instead of blanking every task's chips because one pid in the union was stale. + if (lsofResult.failed && observed.size === 0) continue; + const merged = new Set([...observed, ...t.hinted]); if (!setsEqual(t.knownPorts, merged)) { t.knownPorts = merged; @@ -97,57 +155,55 @@ export class PortDetector extends EventEmitter { ports: [...merged].sort((a, b) => a - b) } satisfies PortsEvent); } - } catch { - // ignore transient lsof failure } + } catch { + // ignore transient ps/lsof failure + } finally { + this.inFlight = false; } } +} - private async listListeningPortsFor(t: Tracked): Promise> { - // Walk the task's process group via `pgrep -P`. Depth 5 covers typical chains - // (pty → npm → cross-spawn shell → tsx/nodemon → user binary). - const pids = await collectDescendants(t.pid, 5); - if (pids.size === 0) return new Set(); - const args = ['-nP', '-iTCP', '-sTCP:LISTEN', '-a', '-Fpn', '-p', [...pids].join(',')]; - try { - const { stdout } = await execFileP('lsof', args); - const out = new Set(); - for (const line of stdout.split(/\r?\n/)) { - if (!line.startsWith('n')) continue; - // Possible formats: "n*:3000", "n127.0.0.1:3000", "n[::1]:3000" - const m = line.match(/:(\d{2,5})$/); - if (m) { - const p = Number(m[1]); - if (p >= 1 && p <= 65535) out.add(p); - } - } - return out; - } catch { - // lsof exits non-zero when nothing matches the filter — treat as "no ports". - return new Set(); +/** + * One `ps` for the whole process table → map of ppid → child pids. Zombies are excluded: + * a reaped-but-unwaited child would otherwise poison the batched lsof call below (lsof + * exits non-zero when ANY pid in `-p` can't be opened). + */ +async function snapshotProcessTree(): Promise> { + const byPpid = new Map(); + try { + const { stdout } = await execFileP('ps', ['-axo', 'pid=,ppid=,stat=']); + for (const line of stdout.split(/\r?\n/)) { + const m = line.trim().match(/^(\d+)\s+(\d+)\s+(\S+)/); + if (!m) continue; + if (m[3]!.startsWith('Z')) continue; // zombie - skip + const pid = Number(m[1]); + const ppid = Number(m[2]); + const arr = byPpid.get(ppid); + if (arr) arr.push(pid); + else byPpid.set(ppid, [pid]); } + } catch { + // ps failed - return what we have (empty), callers treat as "no descendants". } + return byPpid; } -async function collectDescendants(rootPid: number, maxDepth: number): Promise> { +/** BFS the snapshot to collect a root pid and all its descendants (bounded depth). */ +function descendantsFromSnapshot( + rootPid: number, + childrenByPpid: Map, + maxDepth: number +): Set { const all = new Set([rootPid]); let frontier = [rootPid]; for (let d = 0; d < maxDepth && frontier.length > 0; d++) { const next: number[] = []; for (const pid of frontier) { - try { - const { stdout } = await execFileP('pgrep', ['-P', String(pid)]); - for (const line of stdout.split(/\r?\n/)) { - const trimmed = line.trim(); - if (!trimmed) continue; - const n = Number(trimmed); - if (!Number.isFinite(n) || n <= 0) continue; - if (all.has(n)) continue; - all.add(n); - next.push(n); - } - } catch { - // pgrep exits 1 when no children; treat as none. + for (const child of childrenByPpid.get(pid) ?? []) { + if (all.has(child)) continue; + all.add(child); + next.push(child); } } frontier = next; @@ -155,6 +211,55 @@ async function collectDescendants(rootPid: number, maxDepth: number): Promise>; failed: boolean }> { + const out = new Map>(); + const args = ['-nP', '-iTCP', '-sTCP:LISTEN', '-a', '-Fpn', '-p', pids.join(',')]; + let stdout: string; + try { + ({ stdout } = await execFileP('lsof', args)); + } catch (e) { + const salvaged = (e as { stdout?: string }).stdout; + if (!salvaged) { + // No output at all - lsof genuinely failed (or nothing matched). Mark failed so the + // caller keeps previous state instead of treating this as "no ports anywhere". + return { byPid: out, failed: true }; + } + stdout = salvaged; + } + // -Fpn output interleaves `p` (process) records with `n` (file) records. + let currentPid: number | null = null; + for (const line of stdout.split(/\r?\n/)) { + if (line.startsWith('p')) { + const n = Number(line.slice(1)); + currentPid = Number.isFinite(n) ? n : null; + } else if (line.startsWith('n') && currentPid != null) { + // "n*:3000", "n127.0.0.1:3000", "n[::1]:3000" + const m = line.match(/:(\d{2,5})$/); + if (m) { + const p = Number(m[1]); + if (p >= 1 && p <= 65535) { + const set = out.get(currentPid) ?? new Set(); + set.add(p); + out.set(currentPid, set); + } + } + } + } + return { byPid: out, failed: false }; +} + function setsEqual(a: Set, b: Set): boolean { if (a.size !== b.size) return false; for (const v of a) if (!b.has(v)) return false; diff --git a/src/main/services/RestartWatcher.ts b/src/main/services/RestartWatcher.ts index 423548a..b114b10 100644 --- a/src/main/services/RestartWatcher.ts +++ b/src/main/services/RestartWatcher.ts @@ -1,18 +1,49 @@ import { EventEmitter } from 'node:events'; +import { relative, sep } from 'node:path'; import chokidar, { type FSWatcher } from 'chokidar'; +import picomatch from 'picomatch'; import type { AppId } from '@shared/types'; const DEFAULT_GLOBS = ['src/**/*.{ts,tsx,js,jsx,mjs,cjs}']; const DEBOUNCE_MS = 500; -const IGNORE_GLOBS = [ - '**/node_modules/**', - '**/.git/**', - '**/dist/**', - '**/build/**', - '**/.next/**', - '**/.cache/**', - '**/coverage/**' -]; +// Directory names that must never be recursively watched (FD/CPU blowup). +const IGNORE_SEGMENTS = new Set([ + 'node_modules', + '.git', + 'dist', + 'build', + '.next', + '.cache', + 'coverage' +]); + +/** + * Builds the match/ignore predicates for one app's watch globs. + * + * chokidar v4+ REMOVED glob support, so passing `src/**` to chokidar.watch (as before) made + * the watcher silently dead - it treated the pattern as a literal non-existent path and never + * fired, disabling restart-on-change entirely (IMPROVEMENT-PLAN 5.1). The working approach is + * to watch the app DIRECTORY with a function-based `ignored` (so node_modules/.git/dist are + * pruned, avoiding a recursive-watch blowup), then filter emitted paths through picomatch. + */ +function makePredicates(dir: string, globs: string[]): { + ignored: (p: string) => boolean; + matches: (p: string) => boolean; +} { + const patterns = globs.length > 0 ? globs : DEFAULT_GLOBS; + const isMatch = picomatch(patterns, { dot: false }); + const ignored = (p: string): boolean => { + const rel = relative(dir, p); + if (rel === '') return false; // the watched root itself + if (rel.startsWith('..')) return true; // outside the app dir + return rel.split(sep).some((seg) => IGNORE_SEGMENTS.has(seg)); + }; + const matches = (p: string): boolean => { + const rel = relative(dir, p); + return rel !== '' && !rel.startsWith('..') && isMatch(rel); + }; + return { ignored, matches }; +} /** * Watches each app's source files; when they change, emits a debounced 'restart' event. @@ -24,16 +55,16 @@ export class RestartWatcher extends EventEmitter { watch(appId: AppId, dir: string, globs: string[]): void { this.unwatch(appId); - const patterns = (globs.length > 0 ? globs : DEFAULT_GLOBS).map((g) => - g.startsWith('/') ? g : `${dir}/${g}` - ); - const w = chokidar.watch(patterns, { - ignored: IGNORE_GLOBS, + const { ignored, matches } = makePredicates(dir, globs); + const w = chokidar.watch(dir, { + ignored, ignoreInitial: true, awaitWriteFinish: { stabilityThreshold: 100, pollInterval: 30 }, followSymlinks: false }); const trigger = (path: string): void => { + // Only the configured globs count - we watch the whole tree but restart on src changes. + if (!matches(path)) return; const t = this.timers.get(appId); if (t) clearTimeout(t); this.timers.set( diff --git a/src/main/services/RunHistory.ts b/src/main/services/RunHistory.ts index fa5ab1b..32aa2b9 100644 --- a/src/main/services/RunHistory.ts +++ b/src/main/services/RunHistory.ts @@ -90,6 +90,39 @@ export class RunHistory { .all(appId, limit); return rows.map(toRow); } + + /** + * Trim run_history to the most recent `perAppLimit` rows per app. + * + * WHY: nothing else ever deletes from run_history, and with + * restart-on-change a single app can append hundreds of rows/day. Left + * unchecked the table grows without bound, bloating the DB and slowing + * list() queries. Pruning per app_id (rather than a global cap) keeps a + * usable window of history for every app regardless of how active others + * are. The DELETE runs as one statement so it stays atomic and cheap to + * call once on boot. + * + * A non-positive limit is treated as "no cap" and is a no-op - we never + * want a misconfigured setting to wipe the entire table. + */ + prune(perAppLimit: number): void { + if (perAppLimit <= 0) return; + db() + .prepare( + `DELETE FROM run_history + WHERE id NOT IN ( + SELECT id FROM ( + SELECT id, + ROW_NUMBER() OVER ( + PARTITION BY app_id ORDER BY started_at DESC + ) AS rn + FROM run_history + ) + WHERE rn <= ? + )` + ) + .run(perAppLimit); + } } function toRow(r: DbRow): RunHistoryRow { diff --git a/src/main/services/Settings.ts b/src/main/services/Settings.ts index af002fc..3f21bd5 100644 --- a/src/main/services/Settings.ts +++ b/src/main/services/Settings.ts @@ -6,6 +6,12 @@ export type SettingsMap = { auto_update: boolean; theme: 'light' | 'dark' | 'system'; dashboard_refresh_ms: number; + notify_on_crash: boolean; + notify_on_ready: boolean; + launch_at_login: boolean; + tray_enabled: boolean; + run_history_limit: number; + readiness_timeout_ms: number; }; const DEFAULTS: SettingsMap = { @@ -13,7 +19,13 @@ const DEFAULTS: SettingsMap = { kill_grace_ms: 5000, auto_update: true, theme: 'system', - dashboard_refresh_ms: 1000 + dashboard_refresh_ms: 1000, + notify_on_crash: true, + notify_on_ready: false, + launch_at_login: false, + tray_enabled: true, + run_history_limit: 500, + readiness_timeout_ms: 60_000 }; export class Settings { @@ -27,7 +39,13 @@ export class Settings { kill_grace_ms: parseNumber(map.get('kill_grace_ms'), DEFAULTS.kill_grace_ms), auto_update: parseBool(map.get('auto_update'), DEFAULTS.auto_update), theme: parseTheme(map.get('theme')), - dashboard_refresh_ms: parseNumber(map.get('dashboard_refresh_ms'), DEFAULTS.dashboard_refresh_ms) + dashboard_refresh_ms: parseNumber(map.get('dashboard_refresh_ms'), DEFAULTS.dashboard_refresh_ms), + notify_on_crash: parseBool(map.get('notify_on_crash'), DEFAULTS.notify_on_crash), + notify_on_ready: parseBool(map.get('notify_on_ready'), DEFAULTS.notify_on_ready), + launch_at_login: parseBool(map.get('launch_at_login'), DEFAULTS.launch_at_login), + tray_enabled: parseBool(map.get('tray_enabled'), DEFAULTS.tray_enabled), + run_history_limit: parseNumber(map.get('run_history_limit'), DEFAULTS.run_history_limit), + readiness_timeout_ms: parseNumber(map.get('readiness_timeout_ms'), DEFAULTS.readiness_timeout_ms) }; } @@ -51,7 +69,13 @@ export class Settings { 'kill_grace_ms', 'auto_update', 'theme', - 'dashboard_refresh_ms' + 'dashboard_refresh_ms', + 'notify_on_crash', + 'notify_on_ready', + 'launch_at_login', + 'tray_enabled', + 'run_history_limit', + 'readiness_timeout_ms' ]); const tx = db().transaction(() => { for (const [k, v] of Object.entries(patch)) { diff --git a/src/main/services/StatsMonitor.ts b/src/main/services/StatsMonitor.ts index 6054332..3a41ac1 100644 --- a/src/main/services/StatsMonitor.ts +++ b/src/main/services/StatsMonitor.ts @@ -23,6 +23,7 @@ export class StatsMonitor extends EventEmitter { private readonly tracked = new Map(); private timer: NodeJS.Timeout | null = null; private intervalMs: number; + private inFlight = false; constructor(intervalMs = 1000) { super(); @@ -60,27 +61,45 @@ export class StatsMonitor extends EventEmitter { } private async tick(): Promise { - if (this.tracked.size === 0) return; - const entries = [...this.tracked.values()]; - const results = await Promise.all( - entries.map(async (t) => { - try { - const stats = await pidusage(t.pid); - return { t, cpu: stats.cpu, memMB: Math.round(stats.memory / (1024 * 1024)) }; - } catch { - // Process exited between ticks — drop silently; the runner's onExit will untrack. - return null; - } - }) - ); - for (const r of results) { - if (!r) continue; - this.emit('stats', { - taskId: r.t.taskId, - appId: r.t.appId, - cpu: Math.max(0, r.cpu), - memMB: r.memMB - } satisfies StatsTick); + if (this.tracked.size === 0 || this.inFlight) return; + this.inFlight = true; + try { + const entries = [...this.tracked.values()]; + const pids = entries.map((e) => e.pid); + + // ONE `ps` for all tracked pids instead of one fork per task per tick (pidusage + // comma-joins an array into a single ps call). With 10 running tasks this turns 10 + // fork+exec per second into 1 (IMPROVEMENT-PLAN 9.2). + let byPid: Record = {}; + try { + byPid = (await pidusage(pids)) as Record; + } catch { + // pidusage rejects the WHOLE batch if any pid vanished mid-tick - fall back to + // per-pid so the survivors still report (the runner's onExit untracks the dead one). + await Promise.all( + entries.map(async (e) => { + try { + const s = await pidusage(e.pid); + byPid[e.pid] = { cpu: s.cpu, memory: s.memory }; + } catch { + // process gone - skip + } + }) + ); + } + + for (const e of entries) { + const s = byPid[e.pid]; + if (!s) continue; + this.emit('stats', { + taskId: e.taskId, + appId: e.appId, + cpu: Math.max(0, s.cpu), + memMB: Math.round(s.memory / (1024 * 1024)) + } satisfies StatsTick); + } + } finally { + this.inFlight = false; } } diff --git a/src/main/services/TaskRegistry.ts b/src/main/services/TaskRegistry.ts index 736f2cd..645474a 100644 --- a/src/main/services/TaskRegistry.ts +++ b/src/main/services/TaskRegistry.ts @@ -41,6 +41,26 @@ export class TaskRegistry { return rows.map(rowToTask); } + /** + * Load every task across every app in a single query, grouped by app id. + * The boot path needs tasks for all apps at once; doing this as one ordered + * scan replaces an N+1 of per-app `list()` calls. Rows arrive pre-sorted by + * (app_id, position, created_at) so each group is already in position order. + */ + listAll(): Record { + const rows = db() + .prepare( + 'SELECT * FROM tasks ORDER BY app_id ASC, position ASC, created_at ASC' + ) + .all(); + const grouped: Record = {}; + for (const row of rows) { + const task = rowToTask(row); + (grouped[task.appId] ??= []).push(task); + } + return grouped; + } + get(id: TaskId): Task | null { const row = db() .prepare('SELECT * FROM tasks WHERE id = ?') @@ -112,7 +132,7 @@ export class TaskRegistry { update(id: TaskId, patch: Partial): Task { const current = this.get(id); if (!current) throw new Error(`Task not found: ${id}`); - // Phase 7: tasks.env_overrides is frozen — the source of truth is now + // Phase 7: tasks.env_overrides is frozen - the source of truth is now // env_vars rows with task_id set. Ignore any patch.envOverrides so the // legacy JSON column can never silently drift from the rows. const next: Task = { @@ -171,7 +191,7 @@ export class TaskRegistry { const dependents = siblings.filter((t) => t.dependsOn.includes(id)); if (dependents.length > 0) { throw new Error( - `Can't remove "${task.name}" — these tasks depend on it: ${dependents.map((d) => d.name).join(', ')}` + `Can't remove "${task.name}" - these tasks depend on it: ${dependents.map((d) => d.name).join(', ')}` ); } db().prepare('DELETE FROM tasks WHERE id = ?').run(id); @@ -230,6 +250,23 @@ export class TaskRegistry { } } +/** + * Parse a JSON column defensively. A single hand-edited or corrupt cell must not + * take down the whole tasks:list query (IMPROVEMENT-PLAN 8.3): a bad row should + * degrade to its fallback rather than throw and blank out every task in the app. + * `null` is treated as "absent" and returns the fallback without warning, since + * NULL is a legitimate empty state for these columns. + */ +function safeJson(raw: string | null, fallback: T): T { + if (raw == null) return fallback; + try { + return JSON.parse(raw) as T; + } catch (err) { + console.warn('TaskRegistry: ignoring corrupt JSON column, using fallback', err); + return fallback; + } +} + function rowToTask(r: TaskRow): Task { return { id: r.id as TaskId, @@ -241,14 +278,15 @@ function rowToTask(r: TaskRow): Task { customCommand: r.custom_command, workingDirOverride: r.working_dir_override, packageManagerOverride: (r.package_manager_override as PackageManager | null) ?? null, + // Only parse when the column is non-null so the absent case stays null (no warning). nodeVersionPrefOverride: r.node_version_pref_override - ? (JSON.parse(r.node_version_pref_override) as NodeVersionPref) + ? safeJson(r.node_version_pref_override, null) : null, - dependsOn: (JSON.parse(r.depends_on) as string[]).map((s) => s as TaskId), - readiness: JSON.parse(r.readiness) as ReadinessSignal, + dependsOn: safeJson(r.depends_on, []).map((s) => s as TaskId), + readiness: safeJson(r.readiness, { kind: 'none' }), oneShot: !!r.one_shot, enabled: !!r.enabled, - envOverrides: JSON.parse(r.env_overrides) as Record, + envOverrides: safeJson>(r.env_overrides, {}), createdAt: r.created_at, updatedAt: r.updated_at }; diff --git a/src/main/services/TaskRunner.ts b/src/main/services/TaskRunner.ts index 165d014..2b2518f 100644 --- a/src/main/services/TaskRunner.ts +++ b/src/main/services/TaskRunner.ts @@ -77,6 +77,10 @@ export type TaskStatusEvent = { */ export class TaskRunner extends EventEmitter { private readonly tracked = new Map(); + // Serialises concurrent start() calls for the SAME task to a single spawn. Without this, + // `proc:start` + `task:start` (or restart-watcher + a user click) race between the + // isRunning check and tracked.set and both spawn a PTY (IMPROVEMENT-PLAN 5.3). + private readonly pending = new Map }>>(); constructor( private readonly registry: AppRegistry, @@ -100,7 +104,7 @@ export class TaskRunner extends EventEmitter { return this.logs.read(taskId); } - /** Last N lines from the buffer — used by the crash-pin UI. */ + /** Last N lines from the buffer - used by the crash-pin UI. */ tailBuffer(taskId: TaskId, maxLines = 200): string { return this.logs.tail(taskId, maxLines); } @@ -123,6 +127,12 @@ export class TaskRunner extends EventEmitter { return [...this.tracked.values()].map(toRunningTask); } + /** Task ids whose start() is still mid-spawn (not yet tracked). The quit path must count + and stop these too, or a spawn completing after teardown leaves an orphan process. */ + pendingStartIds(): TaskId[] { + return [...this.pending.keys()]; + } + get(taskId: TaskId): RunningTask | null { const t = this.tracked.get(taskId); return t ? toRunningTask(t) : null; @@ -141,13 +151,48 @@ export class TaskRunner extends EventEmitter { * Start one task. Returns a snapshot of the running state. * `awaitReady` returns a promise that resolves to true if the task hits its * readiness signal, false if it exited first. + * + * Concurrency-safe: if the task is already live (starting/running/exiting) the existing + * run is returned; if another start() for the same task is mid-flight, its promise is + * shared rather than spawning a second PTY. */ - async start(task: Task): Promise<{ snapshot: RunningTask; awaitReady: Promise }> { - if (this.isRunning(task.id)) { - const t = this.tracked.get(task.id)!; - return { snapshot: toRunningTask(t), awaitReady: t.readinessWatcher?.ready ?? Promise.resolve(t.ready) }; + start(task: Task): Promise<{ snapshot: RunningTask; awaitReady: Promise }> { + const cur = this.tracked.get(task.id); + if (cur && (cur.state === 'starting' || cur.state === 'running')) { + return Promise.resolve({ + snapshot: toRunningTask(cur), + awaitReady: cur.readinessWatcher?.ready ?? Promise.resolve(cur.ready) + }); } + const inflight = this.pending.get(task.id); + if (inflight) return inflight; + // 'exiting' = a stop's kill-grace window. Returning the DYING run here would make the + // caller's start a no-op (its awaitReady may even already be true), so the user's fresh + // Start would silently never restart the task. Wait for the teardown to finish, then + // spawn a new run - deduped through `pending` like any other start. + const p = ( + cur && cur.state === 'exiting' + ? this.waitForTeardown(task.id, cur).then(() => this.doStart(task)) + : this.doStart(task) + ).finally(() => this.pending.delete(task.id)); + this.pending.set(task.id, p); + return p; + } + + /** Resolve once the given run is gone from tracking (or replaced). Bounded wait. */ + private async waitForTeardown(taskId: TaskId, run: Tracked): Promise { + const grace = this.settings?.get('kill_grace_ms') ?? DEFAULT_KILL_GRACE_MS; + const deadline = Date.now() + grace + 5000; + while (Date.now() < deadline) { + const cur = this.tracked.get(taskId); + if (cur !== run || cur.state === 'exited' || cur.state === 'crashed') return; + await new Promise((r) => setTimeout(r, 100)); + } + } + private async doStart( + task: Task + ): Promise<{ snapshot: RunningTask; awaitReady: Promise }> { const app = this.registry.get(task.appId); if (!app) throw new Error(`Unknown app for task: ${task.appId}`); @@ -163,6 +208,7 @@ export class TaskRunner extends EventEmitter { customCommand: task.customCommand }); + // env.build is BEFORE spawn - a failure here throws with no live process to orphan. const env = await this.env.build({ app, task, nodeBinDir: node.binDir, cwd }); const pty = ptySpawn(file, args, { @@ -173,16 +219,22 @@ export class TaskRunner extends EventEmitter { env }); - // Open a run_history row. - const runId = this.history.start({ - appId: app.id, - taskId: task.id, - taskName: task.name, - script: task.script, - customCommand: task.customCommand, - nodeVersion: node.version, - packageManager: pm - }); + // Open a run_history row. NON-FATAL: a DB hiccup here must not leave a spawned PTY with + // no tracked entry / handlers (an unstoppable orphan) - the rest tolerates runId === null. + let runId: string | null = null; + try { + runId = this.history.start({ + appId: app.id, + taskId: task.id, + taskName: task.name, + script: task.script, + customCommand: task.customCommand, + nodeVersion: node.version, + packageManager: pm + }); + } catch (e) { + console.warn('[taskrunner] run_history.start failed; continuing without a history row', e); + } const tracked: Tracked = { pty, @@ -216,7 +268,10 @@ export class TaskRunner extends EventEmitter { this.ports.track(task.id, app.id, pty.pid); // Bridge PTY → durable log buffer + coalesced IPC + readiness + port hint listeners. + // Identity-guarded: a stale PTY (from a superseded run of the same task) must not feed + // its output into the successor run. pty.onData((chunk: string) => { + if (this.tracked.get(task.id) !== tracked) return; this.logs.append(task.id, chunk); this.ports.observeChunk(task.id, chunk); for (const l of tracked.logListeners) { @@ -230,64 +285,62 @@ export class TaskRunner extends EventEmitter { }); pty.onExit(({ exitCode, signal }) => { - const t = this.tracked.get(task.id); - if (!t) return; - // Flush any pending coalesced log chunk before status flips. - this.flushLogEmit(t); - t.exitCode = exitCode; - t.exitSignal = signal ? String(signal) : null; - t.state = t.userKilled - ? 'exited' - : exitCode === 0 - ? 'exited' - : 'crashed'; - - // Close the run_history row. - if (t.runId) { - this.history.finish(t.runId, { - exitCode: t.exitCode, - exitSignal: t.exitSignal, - wasKilledByUser: t.userKilled + // Operate on THIS closure's own Tracked instance, never look it up by id - otherwise a + // stale PTY's exit would mutate the successor run (mark it exited, finish its history, + // untrack its live stats). Only touch shared/monitoring state if we're still current. + const isCurrent = this.tracked.get(task.id) === tracked; + this.flushLogEmit(tracked); + tracked.exitCode = exitCode; + tracked.exitSignal = signal ? String(signal) : null; + tracked.state = tracked.userKilled ? 'exited' : exitCode === 0 ? 'exited' : 'crashed'; + + if (tracked.runId) { + this.history.finish(tracked.runId, { + exitCode: tracked.exitCode, + exitSignal: tracked.exitSignal, + wasKilledByUser: tracked.userKilled }); + tracked.runId = null; // guard against a later force-finalize double-finishing } - for (const l of t.statusListeners) { + for (const l of tracked.statusListeners) { try { - l(t.state, t.exitCode); + l(tracked.state, tracked.exitCode); } catch { // ignore } } - this.emitStatus(t); - // Stop monitoring this task immediately. + tracked.readinessWatcher?.dispose(); + + if (!isCurrent) return; // a newer run replaced this task - leave its state alone + this.emitStatus(tracked); this.stats.untrack(task.id); this.ports.untrack(task.id); - // Keep around briefly so the renderer can read final state. + this.logs.markExited(task.id); // buffer becomes evictable + self-frees after a delay + + // Keep around briefly so the renderer can read final state, then delete (identity-checked). setTimeout(() => { - const cur = this.tracked.get(task.id); - if (cur && (cur.state === 'exited' || cur.state === 'crashed')) { - cur.readinessWatcher?.dispose(); + if (this.tracked.get(task.id) === tracked) { this.tracked.delete(task.id); } }, 1500); }); // Flip to 'running' next tick so the UI sees the transition. - // Guarded: if the process exited synchronously (instant crash), onExit already - // set state to exited/crashed — don't overwrite. + // Guarded: if the process exited synchronously (instant crash), onExit already set + // state to exited/crashed - don't overwrite; and only act if still the current run. setTimeout(() => { - const t = this.tracked.get(task.id); - if (!t) return; - if (t.state !== 'starting') return; - t.state = 'running'; - for (const l of t.statusListeners) { + if (this.tracked.get(task.id) !== tracked) return; + if (tracked.state !== 'starting') return; + tracked.state = 'running'; + for (const l of tracked.statusListeners) { try { - l(t.state, null); + l(tracked.state, null); } catch { // ignore } } - this.emitStatus(t); + this.emitStatus(tracked); }, 0); // Set up the readiness watcher. @@ -306,17 +359,69 @@ export class TaskRunner extends EventEmitter { tracked.readinessWatcher = watcher; void watcher.ready.then((ok) => { - const t = this.tracked.get(task.id); - if (!t) return; - t.ready = ok; - this.emitStatus(t); + if (this.tracked.get(task.id) !== tracked) return; + tracked.ready = ok; + this.emitStatus(tracked); }); + // Readiness timeout: without a deadline a port that never opens or a regex that never + // matches leaves startApp blocked forever and the UI stuck on 'starting' + // (IMPROVEMENT-PLAN 7.2). On timeout, if the task is still alive we stop blocking and + // treat it as ready (so the spinner clears) rather than killing a healthy server. + // + // Deliberately NOT applied to 'exit' readiness (one-shots: migrations/builds - "ready" + // means the process FINISHED; forcing ready at 60s would start dependents before the + // prerequisite completed). For 'delay', honour the user's explicit wait even past the + // timeout (plus slack) rather than cutting it short. + const baseTimeout = this.settings?.get('readiness_timeout_ms') ?? 60_000; + const timeoutMs = + task.readiness.kind === 'exit' + ? 0 + : task.readiness.kind === 'delay' + ? Math.max(baseTimeout, task.readiness.ms + 5000) + : baseTimeout; + const awaitReady: Promise = + timeoutMs > 0 + ? new Promise((resolve) => { + let settled = false; + const finish = (v: boolean): void => { + if (settled) return; + settled = true; + clearTimeout(to); + resolve(v); + }; + const to = setTimeout(() => { + if (this.tracked.get(task.id) === tracked && tracked.state === 'running') { + console.warn( + `[taskrunner] readiness timed out for "${task.name}" after ${timeoutMs}ms; treating as ready` + ); + tracked.ready = true; + this.emitStatus(tracked); + finish(true); + } else { + finish(false); + } + }, timeoutMs); + void watcher.ready.then(finish); + }) + : watcher.ready; + this.emitStatus(tracked); - return { snapshot: toRunningTask(tracked), awaitReady: watcher.ready }; + return { snapshot: toRunningTask(tracked), awaitReady }; } async stop(taskId: TaskId): Promise { + // A start() may still be mid-flight (awaiting env build, before tracked.set). Without + // waiting for it, this stop would silently no-op and the process would spawn anyway - + // the user's click lost. Await the pending spawn, then stop the now-tracked run. + const inflight = this.pending.get(taskId); + if (inflight) { + try { + await inflight; + } catch { + // the start itself failed - nothing to stop + } + } const t = this.tracked.get(taskId); if (!t) return; if (t.state !== 'running' && t.state !== 'starting') return; @@ -325,31 +430,82 @@ export class TaskRunner extends EventEmitter { t.state = 'exiting'; this.emitStatus(t); + const grace = this.settings?.get('kill_grace_ms') ?? DEFAULT_KILL_GRACE_MS; + + // Graceful: signal the WHOLE process tree, not just the PTY child. For `npm run dev` + // the real server is a grandchild, and a non-interactive `$SHELL -l -c` wrapper does not + // forward SIGTERM - so signalling only the pty child usually skips graceful shutdown + // (IMPROVEMENT-PLAN 7.1). + // + // All checks below are identity-aware (compare against THIS run's Tracked instance, not a + // by-id lookup): if a new run of the same task spawns mid-wait, "the old instance is gone + // from the map" means this stop is complete - it must never burn grace against, SIGKILL, + // or force-finalize the successor's healthy process. + this.signalTree(t.pid, 'SIGTERM'); + await this.waitForExit(taskId, t, grace); + if (this.isRunExited(taskId, t)) return; + + // Escalate: SIGKILL the tree, then wait a bounded window for the exit event to actually + // arrive before giving up - so 'exiting' is never a permanent dead-end. + this.signalTree(t.pid, 'SIGKILL'); + await this.waitForExit(taskId, t, 2000); + if (this.isRunExited(taskId, t)) return; + + // The OS never reported the exit - force the tracked entry to a terminal state so the UI + // and orchestrator don't hang on a zombie 'exiting'. + this.forceFinalize(taskId, t); + } + + private signalTree(pid: number, signal: 'SIGTERM' | 'SIGKILL'): void { try { - t.pty.kill('SIGTERM'); + treeKill(pid, signal, () => {}); } catch { - // ignore + // process may already be gone } + } - const grace = this.settings?.get('kill_grace_ms') ?? DEFAULT_KILL_GRACE_MS; + /** True when THIS run is finished - exited/crashed, or no longer the tracked entry. */ + private isRunExited(taskId: TaskId, run: Tracked): boolean { + const cur = this.tracked.get(taskId); + if (cur !== run) return true; // replaced or torn down - the old run is gone + return cur.state === 'exited' || cur.state === 'crashed'; + } + private async waitForExit(taskId: TaskId, run: Tracked, timeoutMs: number): Promise { await new Promise((resolve) => { - const escalate = setTimeout(() => { - treeKill(t.pid, 'SIGKILL', () => {}); - resolve(); - }, grace); - - const tickUntilExit = setInterval(() => { - const cur = this.tracked.get(taskId); - if (!cur || cur.state === 'exited' || cur.state === 'crashed') { - clearTimeout(escalate); - clearInterval(tickUntilExit); + const deadline = Date.now() + timeoutMs; + const tick = setInterval(() => { + if (this.isRunExited(taskId, run) || Date.now() >= deadline) { + clearInterval(tick); resolve(); } }, 100); }); } + /** Last-resort: SIGKILL+grace elapsed and no exit event arrived. Synthesise the teardown. */ + private forceFinalize(taskId: TaskId, run: Tracked): void { + if (this.tracked.get(taskId) !== run) return; // a successor run owns the slot now + if (run.state === 'exited' || run.state === 'crashed') return; + // Mirror the normal onExit teardown so nothing leaks from this synthetic path. + this.flushLogEmit(run); + run.state = 'exited'; + if (run.runId) { + this.history.finish(run.runId, { + exitCode: run.exitCode, + exitSignal: run.exitSignal ?? 'SIGKILL', + wasKilledByUser: true + }); + run.runId = null; + } + run.readinessWatcher?.dispose(); + this.emitStatus(run); + this.stats.untrack(taskId); + this.ports.untrack(taskId); + this.logs.markExited(taskId); + this.tracked.delete(taskId); + } + private queueLogEmit(t: Tracked, chunk: string): void { t.batchedChunks.push(chunk); t.batchedBytes += chunk.length; diff --git a/src/main/services/TrayController.ts b/src/main/services/TrayController.ts new file mode 100644 index 0000000..3efbe95 --- /dev/null +++ b/src/main/services/TrayController.ts @@ -0,0 +1,142 @@ +import { Tray, Menu, nativeImage, type MenuItemConstructorOptions } from 'electron'; +import type { AppId, ProcessState } from '@shared/types'; + +export interface TrayApp { + id: AppId; + name: string; + state: ProcessState; + ports: number[]; +} + +export interface TrayCallbacks { + listApps: () => TrayApp[]; + start: (id: AppId) => void; + stop: (id: AppId) => void; + stopAll: () => void; + open: () => void; + quit: () => void; +} + +function isLive(s: ProcessState): boolean { + return s === 'running' || s === 'starting' || s === 'exiting'; +} + +/** + * Menubar (Tray) presence (IMPROVEMENT-PLAN 14.1). A supervisor for long-running background + * processes is exactly the category that lives in the menubar - the app already keeps servers + * running with its window closed, but had zero ambient surface to see/control them. The tray + * icon reflects aggregate state and its menu lists every app with Start/Stop + detected ports. + * Pure main-process: all state flows through the callbacks; no renderer dependency. + */ +export class TrayController { + private tray: Tray | null = null; + + constructor(private readonly cb: TrayCallbacks) {} + + enable(): void { + if (this.tray) return; + try { + this.tray = new Tray(this.makeIcon('idle')); + this.tray.setToolTip('DevHarbor'); + this.refresh(); + } catch { + // Tray creation can fail in a display-less environment - never let it block boot. + this.tray = null; + } + } + + disable(): void { + this.tray?.destroy(); + this.tray = null; + } + + get enabled(): boolean { + return this.tray !== null; + } + + /** Rebuild the menu + icon from current state. Call on any task status change. */ + refresh(): void { + if (!this.tray) return; + const apps = this.cb.listApps(); + const runningCount = apps.filter((a) => isLive(a.state)).length; + const anyCrashed = apps.some((a) => a.state === 'crashed'); + + this.tray.setImage(this.makeIcon(anyCrashed ? 'crashed' : runningCount > 0 ? 'running' : 'idle')); + this.tray.setToolTip( + runningCount > 0 ? `DevHarbor - ${runningCount} running` : 'DevHarbor - idle' + ); + + const appItems: MenuItemConstructorOptions[] = apps + .slice() + .sort((a, b) => a.name.localeCompare(b.name)) + .map((a) => { + const live = isLive(a.state); + const portLabel = a.ports.length ? ` :${a.ports.join(' :')}` : ''; + const dot = a.state === 'crashed' ? '⊘' : live ? '●' : '○'; + return { + label: `${dot} ${a.name}${portLabel}`, + submenu: [ + { label: live ? 'Stop' : 'Start', click: () => (live ? this.cb.stop(a.id) : this.cb.start(a.id)) } + ] + } satisfies MenuItemConstructorOptions; + }); + + const template: MenuItemConstructorOptions[] = [ + { label: 'Open DevHarbor', click: () => this.cb.open() }, + { type: 'separator' }, + ...(appItems.length ? appItems : [{ label: 'No apps registered', enabled: false }]), + { type: 'separator' }, + { label: 'Stop all', enabled: runningCount > 0, click: () => this.cb.stopAll() }, + { type: 'separator' }, + { label: 'Quit DevHarbor', click: () => this.cb.quit() } + ]; + this.tray.setContextMenu(Menu.buildFromTemplate(template)); + } + + /** + * Render a tiny template-image dot so the menubar icon tints to the macOS theme. A filled + * dot for idle, a ringed dot for running, an X-ish mark for crashed - distinguishable + * without colour (the menubar is monochrome anyway). + */ + private makeIcon(kind: 'idle' | 'running' | 'crashed'): Electron.NativeImage { + const size = 36; // rendered at 2x for crisp retina menubars (18pt logical) + const canvas = drawDot(size, kind); + const img = nativeImage.createFromBuffer(canvas, { width: size, height: size, scaleFactor: 2 }); + img.setTemplateImage(true); + return img; + } +} + +/** + * Minimal RGBA bitmap of a centered glyph. Avoids shipping icon assets for the tray: a solid + * disc (idle/running) or a ring with a gap (crashed). Template image → macOS recolours it. + */ +function drawDot(size: number, kind: 'idle' | 'running' | 'crashed'): Buffer { + const buf = Buffer.alloc(size * size * 4, 0); + const cx = (size - 1) / 2; + const cy = (size - 1) / 2; + const rOuter = size * 0.34; + const rInner = size * 0.18; + for (let y = 0; y < size; y++) { + for (let x = 0; x < size; x++) { + const d = Math.hypot(x - cx, y - cy); + let on = false; + if (kind === 'running') { + on = d <= rOuter && d >= rInner; // ring + } else if (kind === 'crashed') { + // X mark + on = (Math.abs(x - y) <= 1 || Math.abs(x + y - (size - 1)) <= 1) && d <= rOuter; + } else { + on = d <= rOuter; // solid disc + } + if (on) { + const i = (y * size + x) * 4; + buf[i] = 0; + buf[i + 1] = 0; + buf[i + 2] = 0; + buf[i + 3] = 255; + } + } + } + return buf; +} diff --git a/src/main/services/Updater.ts b/src/main/services/Updater.ts index f74acb7..f954be2 100644 --- a/src/main/services/Updater.ts +++ b/src/main/services/Updater.ts @@ -1,23 +1,35 @@ import { BrowserWindow } from 'electron'; -import { autoUpdater } from 'electron-updater'; +import { autoUpdater, type UpdateInfo } from 'electron-updater'; + +/** How often to re-poll the release feed after the initial launch check. */ +const RECHECK_INTERVAL_MS = 6 * 60 * 60 * 1000; // 6 hours /** - * Auto-update plumbing. + * Auto-update plumbing - ACTIVE in production. + * + * The packaged app ships an `app-update.yml` pointing at the GitHub Releases feed, so + * `start()` actually polls for updates: once on launch, then every 6 hours. We forward + * the full lifecycle to the renderer over IPC (`update:available` / `update:progress` / + * `update:ready` / `update:notAvailable` / `update:error`) so the UI can surface + * progress, release notes, and failures instead of swallowing them. * - * Phase 4: wired but NOT activated — no release feed is configured yet (that's Phase 5 - * packaging + signing). We listen for events and forward an `update:ready` IPC payload - * to the renderer when the time comes; for now `start()` is a no-op unless the host - * environment has a feed URL set. + * In dev (or any build without a feed) `checkForUpdates()` rejects; we swallow that + * rejection so the absence of a feed is a no-op rather than a thrown error. */ export class Updater { private started = false; + /** Handle for the periodic re-check timer so start() can't stack duplicate intervals. */ + private recheckTimer: ReturnType | null = null; constructor(private readonly getWin: () => BrowserWindow | null) { autoUpdater.autoDownload = true; autoUpdater.autoInstallOnAppQuit = true; autoUpdater.on('update-available', (info) => { - this.getWin()?.webContents.send('update:available', { version: info.version }); + this.getWin()?.webContents.send('update:available', { + version: info.version, + releaseNotes: Updater.normalizeReleaseNotes(info) + }); }); autoUpdater.on('download-progress', (p) => { this.getWin()?.webContents.send('update:progress', { @@ -28,24 +40,86 @@ export class Updater { }); }); autoUpdater.on('update-downloaded', (info) => { - this.getWin()?.webContents.send('update:ready', { version: info.version }); + this.getWin()?.webContents.send('update:ready', { + version: info.version, + releaseNotes: Updater.normalizeReleaseNotes(info) + }); + }); + autoUpdater.on('update-not-available', (info) => { + // Lets a manual "Check for Updates…" confirm the app is current instead of going silent. + this.getWin()?.webContents.send('update:notAvailable', { version: info.version }); }); autoUpdater.on('error', (err) => { + // Keep the local log, but also surface the failure so the UI doesn't fail silently. console.warn('[updater]', err.message); + this.getWin()?.webContents.send('update:error', { message: err.message }); }); } - /** Check for updates if a feed is configured. Safe to call repeatedly. */ + /** + * Begin update polling: an immediate check plus a 6-hour repeating check so a + * long-running session still picks up releases. Guarded so it only arms once; + * use {@link checkNow} for explicit user-triggered checks. + */ start(): void { if (this.started) return; this.started = true; - // electron-updater throws if no feed (or no app-update.yml) is configured — silence it. + // Re-assert the download/install flags - stop() clears them when auto-update is + // toggled off, and the user may have toggled it back on in the same session. + autoUpdater.autoDownload = true; + autoUpdater.autoInstallOnAppQuit = true; + // electron-updater rejects if no feed (or no app-update.yml) is configured - silence it; + // any real failure is reported via the 'error' handler above. autoUpdater.checkForUpdates().catch(() => { - // No feed configured yet (Phase 5). That's fine in dev/v0.1.x. + // No feed configured (dev build). That's fine. }); + this.recheckTimer = setInterval(() => { + autoUpdater.checkForUpdates().catch(() => {}); + }, RECHECK_INTERVAL_MS); + } + + /** + * Manual "Check for Updates…" trigger. Unlike {@link start} it has no started-once + * guard, so the user can re-check on demand; errors surface via the 'error' handler. + */ + checkNow(): void { + autoUpdater.checkForUpdates().catch(() => {}); + } + + /** + * Stop background polling - called when the user turns auto-update OFF in Settings. + * Without this, disabling the setting only took effect after a relaunch: the 6-hour + * interval kept checking and a downloaded update would still install on quit. + */ + stop(): void { + if (this.recheckTimer) { + clearInterval(this.recheckTimer); + this.recheckTimer = null; + } + this.started = false; + autoUpdater.autoDownload = false; + autoUpdater.autoInstallOnAppQuit = false; } quitAndInstall(): void { autoUpdater.quitAndInstall(true, true); } + + /** + * Collapse electron-updater's polymorphic `releaseNotes` (string, array of + * `{ note }`, or null/undefined) into a single string for the renderer. Array notes + * are joined with newlines; nullish notes become `undefined` so the payload omits them. + */ + private static normalizeReleaseNotes(info: UpdateInfo): string | undefined { + const notes = info.releaseNotes; + if (typeof notes === 'string') return notes; + if (Array.isArray(notes)) { + const joined = notes + .map((n) => n.note ?? '') + .filter((note) => note.length > 0) + .join('\n'); + return joined.length > 0 ? joined : undefined; + } + return undefined; + } } diff --git a/src/main/services/__tests__/AppOrchestrator.e2e.test.ts b/src/main/services/__tests__/AppOrchestrator.e2e.test.ts index 1db48ae..2716816 100644 --- a/src/main/services/__tests__/AppOrchestrator.e2e.test.ts +++ b/src/main/services/__tests__/AppOrchestrator.e2e.test.ts @@ -7,7 +7,7 @@ import type { App, ProcessState, Task } from '@shared/types'; /** * REAL end-to-end check: spawns an actual short-lived process via TaskRunner, runs the true * Start → Stop cycle through AppOrchestrator, and records every `proc:status` the renderer - * WOULD receive — INCLUDING after the ~1.5s post-exit teardown. Only the DB/FS-touching + * WOULD receive - INCLUDING after the ~1.5s post-exit teardown. Only the DB/FS-touching * collaborators are stubbed; the pty, event wiring, and state derivation are the real code. * * This is the definitive reproduction for the "app reverts to Idle after stop" report. @@ -28,6 +28,7 @@ function makeApp(): App { customCommand: null, workingDir: process.cwd(), autoRestartOnChange: false, + autoStart: false, watchGlobs: [], portHint: null, tags: [], @@ -72,7 +73,7 @@ function makeRunner(): TaskRunner { // eslint-disable-next-line @typescript-eslint/no-explicit-any const nodes: any = { resolve: () => ({ binDir: '', version: 'system', source: 'system' }) }; // eslint-disable-next-line @typescript-eslint/no-explicit-any - const logs: any = { append: () => {}, read: () => '', tail: () => '', clear: () => {} }; + const logs: any = { append: () => {}, read: () => '', tail: () => '', clear: () => {}, markExited: () => {} }; // eslint-disable-next-line @typescript-eslint/no-explicit-any const history: any = { start: () => 'run1', finish: () => {} }; class FakeMon extends EventEmitter { @@ -96,7 +97,7 @@ function makeRunner(): TaskRunner { } describe('AppOrchestrator REAL start→stop (spawns a process)', () => { - it('stays "exited" after stop — including past the 1.5s teardown', async () => { + it('stays "exited" after stop - including past the 1.5s teardown', async () => { const runner = makeRunner(); const task = makeTask(); // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -127,4 +128,20 @@ describe('AppOrchestrator REAL start→stop (spawns a process)', () => { // The renderer must never have been told 'idle' at any point. expect(log.map((l) => l.state)).not.toContain('idle'); }, 15000); + + it('two concurrent start()s spawn exactly one PTY (IMPROVEMENT-PLAN 5.3)', async () => { + const runner = makeRunner(); + const task = makeTask(); + + // Fire both starts in the same tick - the old code passed the isRunning guard twice (the + // check and tracked.set are separated by `await env.build`) and spawned two PTYs. + const [a, b] = await Promise.all([runner.start(task), runner.start(task)]); + + expect(runner.list().length).toBe(1); // only one tracked run for the task + expect(a.snapshot.pid).toBe(b.snapshot.pid); // both callers observe the same run + + await runner.stop(task.id); + await delay(2000); // past the 1500ms post-exit teardown window + expect(runner.list().length).toBe(0); + }, 15000); }); diff --git a/src/main/services/__tests__/AppOrchestrator.state.test.ts b/src/main/services/__tests__/AppOrchestrator.state.test.ts index a10dc91..480ed7f 100644 --- a/src/main/services/__tests__/AppOrchestrator.state.test.ts +++ b/src/main/services/__tests__/AppOrchestrator.state.test.ts @@ -59,7 +59,7 @@ describe('AppOrchestrator app-state on stop', () => { runner.emit('status', { taskId: 't1', appId: 'a1', state, ready, exitCode: null }); }; - it('settles on "exited" after a stop — never reverts to "idle"', () => { + it('settles on "exited" after a stop - never reverts to "idle"', () => { // Running. driveTask('running', true); expect(orch.appState('a1' as never)).toBe('running'); diff --git a/src/main/services/__tests__/EnvLayering.test.ts b/src/main/services/__tests__/EnvLayering.test.ts index 7b5afa0..655be51 100644 --- a/src/main/services/__tests__/EnvLayering.test.ts +++ b/src/main/services/__tests__/EnvLayering.test.ts @@ -1,155 +1,149 @@ -import { describe, expect, it } from 'vitest'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import type { App, AppId, EnvVar, Task, TaskId } from '@shared/types'; +import { EnvBuilder } from '../EnvBuilder'; /** - * Mirrors the layering performed inside EnvBuilder.build(): - * process < global < app < task < .env (later wins) + * Exercises the REAL EnvBuilder.build() (not a pure-function mirror - the old version of this + * file re-implemented the layering and silently kept asserting the pre-hardening precedence). * - * This is replicated as a pure function here (same pattern as PortDetector.test.ts) — - * any change to EnvBuilder's order must also change this test, surfacing the diff. + * Shipped precedence (later wins) - IMPROVEMENT-PLAN 6.1: + * sanitized base < computed PATH < project .env files < global < app < task < FORCE_COLOR/TERM * - * See specs/03-features.md F6 and specs/02-data-model.md "Scope layering". + * Key inversions vs the old behavior, asserted here: + * - USER-configured env_vars now override project .env files (UI is the source of truth). + * - A project .env can never set process-control keys (PATH, NODE_OPTIONS, DYLD_*, …). + * + * EnvBuilder's EnvStore/PathProbe deps are type-only imports, so plain stubs work - no + * better-sqlite3 needed. The .env files are real files in a temp dir. */ -type EnvRow = { key: string; value: string; enabled: boolean }; -function layer( - base: Record, - rows: EnvRow[] -): Record { - const out: Record = { ...base }; - for (const r of rows) { - if (!r.enabled || !r.key) continue; - out[r.key] = r.value; - } - return out; -} +const row = (key: string, value: string, enabled = true): EnvVar => ({ + id: key, + appId: null, + key, + value, + enabled, + isSecret: false +}); -function buildEffective(args: { - processEnv: Record; - global: EnvRow[]; - app: EnvRow[]; - task: EnvRow[]; - dotEnv: Record; -}): Record { - let env = { ...args.processEnv }; - env = layer(env, args.global); - env = layer(env, args.app); - env = layer(env, args.task); - env = { ...env, ...args.dotEnv }; - return env; +function makeBuilder(scopes: { global?: EnvVar[]; app?: EnvVar[]; task?: EnvVar[] }): EnvBuilder { + const envStore = { + getGlobal: () => scopes.global ?? [], + getApp: (_id: AppId) => scopes.app ?? [], + getTask: (_id: TaskId) => scopes.task ?? [] + }; + const pathProbe = { get: async () => '/probe/bin:/usr/bin' }; + // Type-only constructor params - structural stubs are sufficient. + return new EnvBuilder(envStore as never, pathProbe as never); } -describe('env layering (3-scope, Phase 7)', () => { - it('global value wins when no app or task override', () => { - const env = buildEffective({ - processEnv: {}, - global: [{ key: 'DEBUG', value: 'app:*', enabled: true }], - app: [], - task: [], - dotEnv: {} - }); - expect(env.DEBUG).toBe('app:*'); - }); +const app = { id: 'a1' as AppId } as App; +const task = { id: 't1' as TaskId } as Task; - it('app value overrides global', () => { - const env = buildEffective({ - processEnv: {}, - global: [{ key: 'DEBUG', value: 'app:*', enabled: true }], - app: [{ key: 'DEBUG', value: 'app:auth', enabled: true }], - task: [], - dotEnv: {} - }); - expect(env.DEBUG).toBe('app:auth'); +let cwd: string; +beforeAll(() => { + cwd = mkdtempSync(join(tmpdir(), 'envlayer-')); +}); +afterAll(() => { + rmSync(cwd, { recursive: true, force: true }); +}); + +const build = ( + b: EnvBuilder, + opts?: { withTask?: boolean; dir?: string } +): Promise> => + b.build({ + app, + task: opts?.withTask === false ? null : task, + nodeBinDir: '/node/bin', + cwd: opts?.dir ?? cwd }); - it('task value overrides app and global', () => { - const env = buildEffective({ - processEnv: {}, - global: [{ key: 'DEBUG', value: 'app:*', enabled: true }], - app: [{ key: 'DEBUG', value: 'app:auth', enabled: true }], - task: [{ key: 'DEBUG', value: 'app:auth:trace', enabled: true }], - dotEnv: {} - }); +describe('EnvBuilder layering (real builder, shipped precedence)', () => { + it('global < app < task (later scope wins)', async () => { + const env = await build( + makeBuilder({ + global: [row('DEBUG', 'app:*')], + app: [row('DEBUG', 'app:auth')], + task: [row('DEBUG', 'app:auth:trace')] + }) + ); expect(env.DEBUG).toBe('app:auth:trace'); }); - it('task can override global directly with no app row in between', () => { - const env = buildEffective({ - processEnv: {}, - global: [{ key: 'NODE_ENV', value: 'development', enabled: true }], - app: [], - task: [{ key: 'NODE_ENV', value: 'test', enabled: true }], - dotEnv: {} - }); - expect(env.NODE_ENV).toBe('test'); + it('user-configured vars override the project .env (UI is source of truth)', async () => { + const dir = mkdtempSync(join(tmpdir(), 'envlayer-uservs-')); + writeFileSync(join(dir, '.env'), 'API_URL=https://dotenv\nONLY_FILE=file-val\n'); + const env = await build(makeBuilder({ app: [row('API_URL', 'https://user')] }), { dir }); + rmSync(dir, { recursive: true, force: true }); + expect(env.API_URL).toBe('https://user'); // OLD behavior was 'https://dotenv' + expect(env.ONLY_FILE).toBe('file-val'); // untouched keys still flow through + }); + + it('.env can never set process-control keys (PATH / NODE_OPTIONS / DYLD_*)', async () => { + const dir = mkdtempSync(join(tmpdir(), 'envlayer-ctl-')); + writeFileSync( + join(dir, '.env'), + 'PATH=/tmp/evil\nNODE_OPTIONS=--require /tmp/evil.js\nDYLD_INSERT_LIBRARIES=/tmp/evil.dylib\nSAFE=ok\n' + ); + const env = await build(makeBuilder({}), { dir }); + rmSync(dir, { recursive: true, force: true }); + expect(env.PATH).toBe('/node/bin:/probe/bin:/usr/bin'); // computed PATH intact + expect(env.NODE_OPTIONS).toBeUndefined(); + expect(env.DYLD_INSERT_LIBRARIES).toBeUndefined(); + expect(env.SAFE).toBe('ok'); + }); + + it('.env variants load in conventional order (.env < .env.development < .env.local)', async () => { + const dir = mkdtempSync(join(tmpdir(), 'envlayer-variants-')); + writeFileSync(join(dir, '.env'), 'A=base\nB=base\nC=base\n'); + writeFileSync(join(dir, '.env.development'), 'B=dev\nC=dev\n'); + writeFileSync(join(dir, '.env.local'), 'C=local\n'); + const env = await build(makeBuilder({}), { dir }); + rmSync(dir, { recursive: true, force: true }); + expect(env.A).toBe('base'); + expect(env.B).toBe('dev'); + expect(env.C).toBe('local'); }); - it('disabled rows are skipped at every scope', () => { - const env = buildEffective({ - processEnv: {}, - global: [{ key: 'PORT', value: '3000', enabled: true }], - app: [{ key: 'PORT', value: '4000', enabled: false }], - task: [{ key: 'PORT', value: '5000', enabled: false }], - dotEnv: {} - }); - // app + task rows are disabled → global wins + it('disabled rows are skipped at every scope', async () => { + const env = await build( + makeBuilder({ + global: [row('PORT', '3000')], + app: [row('PORT', '4000', false)], + task: [row('PORT', '5000', false)] + }) + ); expect(env.PORT).toBe('3000'); }); - it('.env from the task cwd is the outermost layer and overrides everything', () => { - const env = buildEffective({ - processEnv: {}, - global: [{ key: 'API_URL', value: 'https://global', enabled: true }], - app: [{ key: 'API_URL', value: 'https://app', enabled: true }], - task: [{ key: 'API_URL', value: 'https://task', enabled: true }], - dotEnv: { API_URL: 'https://dotenv' } - }); - expect(env.API_URL).toBe('https://dotenv'); + it('task scope is only applied when a task is passed', async () => { + const env = await build(makeBuilder({ task: [row('ONLY_TASK', 'x')] }), { withTask: false }); + expect(env.ONLY_TASK).toBeUndefined(); }); - it('inherits process keys that no scope touches', () => { - const env = buildEffective({ - processEnv: { HOME: '/Users/me', PATH: '/usr/bin' }, - global: [], - app: [], - task: [], - dotEnv: {} - }); - expect(env.HOME).toBe('/Users/me'); - expect(env.PATH).toBe('/usr/bin'); + it('computed PATH prepends the node bin dir to the probed login-shell PATH', async () => { + const env = await build(makeBuilder({})); + expect(env.PATH).toBe('/node/bin:/probe/bin:/usr/bin'); }); - it('different tasks of the same app get distinct PORT values', () => { - const base = { - processEnv: {}, - global: [{ key: 'NODE_ENV', value: 'development', enabled: true }], - app: [{ key: 'DATABASE_URL', value: 'postgres://local', enabled: true }], - dotEnv: {} - }; - const apiEnv = buildEffective({ - ...base, - task: [{ key: 'PORT', value: '4000', enabled: true }] - }); - const webEnv = buildEffective({ - ...base, - task: [{ key: 'PORT', value: '5173', enabled: true }] - }); - expect(apiEnv.PORT).toBe('4000'); - expect(webEnv.PORT).toBe('5173'); - // App-shared values still present in both - expect(apiEnv.DATABASE_URL).toBe('postgres://local'); - expect(webEnv.DATABASE_URL).toBe('postgres://local'); - expect(apiEnv.NODE_ENV).toBe('development'); - expect(webEnv.NODE_ENV).toBe('development'); + it('hard-coded runtime keys cap the stack', async () => { + const env = await build(makeBuilder({ task: [row('TERM', 'dumb'), row('FORCE_COLOR', '0')] })); + // FORCE_COLOR/TERM are set after all scopes - even a task row can't change them. + expect(env.FORCE_COLOR).toBe('1'); + expect(env.TERM).toBe('xterm-256color'); }); - it('empty key is ignored (defensive)', () => { - const env = buildEffective({ - processEnv: {}, - global: [{ key: '', value: 'whatever', enabled: true }], - app: [], - task: [], - dotEnv: {} - }); - expect(env['']).toBeUndefined(); + it('different tasks of the same app get distinct task-scope values', async () => { + const base = { global: [row('NODE_ENV', 'development')], app: [row('DB', 'postgres://local')] }; + const api = await build(makeBuilder({ ...base, task: [row('PORT', '4000')] })); + const web = await build(makeBuilder({ ...base, task: [row('PORT', '5173')] })); + expect(api.PORT).toBe('4000'); + expect(web.PORT).toBe('5173'); + expect(api.DB).toBe('postgres://local'); + expect(web.NODE_ENV).toBe('development'); }); }); diff --git a/src/main/services/__tests__/RestartWatcher.test.ts b/src/main/services/__tests__/RestartWatcher.test.ts new file mode 100644 index 0000000..42ac526 --- /dev/null +++ b/src/main/services/__tests__/RestartWatcher.test.ts @@ -0,0 +1,37 @@ +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, it, expect } from 'vitest'; +import type { AppId } from '@shared/types'; +import { RestartWatcher } from '../RestartWatcher'; + +/** + * Regression guard for IMPROVEMENT-PLAN 5.1: chokidar v4+ removed glob support, so the old + * `chokidar.watch(['src/**'])` watcher was silently dead. The watcher must (a) fire on a + * change to a file matching the configured globs and (b) ignore node_modules. + */ +describe('RestartWatcher (chokidar v5 directory-watch + glob filter)', () => { + it('fires on a matching src change and ignores node_modules', async () => { + const dir = mkdtempSync(join(tmpdir(), 'rw-test-')); + mkdirSync(join(dir, 'src')); + mkdirSync(join(dir, 'node_modules')); + + const w = new RestartWatcher(); + const fired: string[] = []; + w.on('restart', ({ path }: { path: string }) => fired.push(path)); + w.watch('app1' as AppId, dir, []); // [] → DEFAULT_GLOBS (src/**/*.{ts,...}) + + // Let the watcher finish its initial scan before touching files. + await new Promise((r) => setTimeout(r, 800)); + writeFileSync(join(dir, 'node_modules', 'junk.ts'), 'x'); // must be ignored + writeFileSync(join(dir, 'src', 'a.ts'), 'x'); // must trigger (after 500ms debounce) + await new Promise((r) => setTimeout(r, 1500)); + + w.unwatch('app1' as AppId); + rmSync(dir, { recursive: true, force: true }); + + expect(fired.length).toBeGreaterThan(0); + expect(fired.every((p) => !p.includes('node_modules'))).toBe(true); + expect(fired.some((p) => p.endsWith('a.ts'))).toBe(true); + }, 10000); +}); diff --git a/src/main/services/__tests__/Settings.test.ts b/src/main/services/__tests__/Settings.test.ts index ab5d72c..04e17f6 100644 --- a/src/main/services/__tests__/Settings.test.ts +++ b/src/main/services/__tests__/Settings.test.ts @@ -5,7 +5,7 @@ import { join } from 'node:path'; /** * Settings.ts depends on the better-sqlite3 native module which is built for Electron's - * ABI, not Node's — so we can't import it directly under vitest. We test the parsing + * ABI, not Node's - so we can't import it directly under vitest. We test the parsing * helpers indirectly by importing them once they're refactored out. * * This file is a placeholder showing the test scaffold; the meaningful Settings tests @@ -21,7 +21,7 @@ afterAll(() => { }); describe('Settings (parsing helpers)', () => { - it('placeholder — see Playwright suite for the integration tests', () => { + it('placeholder - see Playwright suite for the integration tests', () => { expect(true).toBe(true); }); }); diff --git a/src/main/services/readiness/LogReadiness.ts b/src/main/services/readiness/LogReadiness.ts index fa03e75..c58d885 100644 --- a/src/main/services/readiness/LogReadiness.ts +++ b/src/main/services/readiness/LogReadiness.ts @@ -15,7 +15,7 @@ export function makeLogWatcher( try { regex = new RegExp(pattern, flags ?? ''); } catch (e) { - // Bad regex — emit a non-ready and let the orchestrator decide. + // Bad regex - emit a non-ready and let the orchestrator decide. queueMicrotask(() => resolveReady(false)); return { ready, dispose: () => {} }; } diff --git a/src/main/services/readiness/PortReadiness.ts b/src/main/services/readiness/PortReadiness.ts index c792292..ea308fe 100644 --- a/src/main/services/readiness/PortReadiness.ts +++ b/src/main/services/readiness/PortReadiness.ts @@ -66,7 +66,7 @@ async function listListeningPidsOnPort(port: number): Promise> { } return pids; } catch (e) { - // lsof exits 1 when nothing matches — that's "not yet listening", not an error. + // lsof exits 1 when nothing matches - that's "not yet listening", not an error. const err = e as { code?: number; stdout?: string }; if (typeof err.code === 'number' && err.code === 1) return new Set(); throw e; diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index dae3b09..b38d913 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -10,10 +10,15 @@ import { CommandPalette } from './components/CommandPalette'; import { SettingsDrawer } from './components/SettingsDrawer'; import { UpdateBanner } from './components/UpdateBanner'; import { AddAppDrawer } from './components/AddAppDrawer'; +import { ImportProjectsDrawer } from './components/ImportProjectsDrawer'; +import { GlobalLogSearch } from './components/GlobalLogSearch'; +import { ToastHost } from './components/Toast'; +import { invokeOrToast } from './lib/invoke'; import { useTheme } from './hooks/useTheme'; export function App(): JSX.Element { const apps = useStore((s) => s.apps); + const loaded = useStore((s) => s.loaded); const view = useStore((s) => s.view); const selectedId = useStore((s) => s.selectedAppId); const setApps = useStore((s) => s.setApps); @@ -28,11 +33,15 @@ export function App(): JSX.Element { const applyTaskPorts = useStore((s) => s.applyTaskPorts); const applyEnvFileChange = useStore((s) => s.applyEnvFileChange); - const [adding, setAdding] = useState(false); - const [addError, setAddError] = useState(null); - const [pendingAddPath, setPendingAddPath] = useState(null); + const [addOpen, setAddOpen] = useState(false); + const [addInitialPath, setAddInitialPath] = useState(null); + // Whether the add-drawer should auto-detect its initial path. False when the path arrived + // from an untrusted devharbor:// deep link - the user must click "Scan this folder" first. + const [addAutoDetect, setAddAutoDetect] = useState(true); const [paletteOpen, setPaletteOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false); + const [importOpen, setImportOpen] = useState(false); + const [logSearchOpen, setLogSearchOpen] = useState(false); // Latest onAddApp without re-subscribing the event effect when it changes. const onAddAppRef = useRef<(() => Promise) | null>(null); @@ -50,21 +59,17 @@ export function App(): JSX.Element { setApps(list); setRunningApps(running); setRunningTasks(runningTasks); - // Pre-load tasks for every registered app so the Dashboard knows their - // task counts + port chips without waiting for the user to open each - // AppDetail. (Before this, `tasksByApp` was populated lazily on AppDetail - // mount, so multi-task apps the user only Started from the Dashboard had - // empty tasks → no port chips.) - await Promise.all( - list.map(async (a) => { - try { - const t = await window.api.invoke('tasks:list', { appId: a.id }); - useStore.getState().setTasksForApp(a.id, t); - } catch { - // best-effort - } - }) - ); + // Pre-load tasks for every registered app so the Dashboard knows their task counts + + // port chips without waiting for the user to open each AppDetail. One tasks:listAll + // round-trip instead of the old per-app N+1 loop (IMPROVEMENT-PLAN 9.7). + try { + const all = await window.api.invoke('tasks:listAll', undefined); + for (const a of list) { + useStore.getState().setTasksForApp(a.id, all[a.id] ?? []); + } + } catch { + // best-effort + } // Always land on Dashboard on cold boot / Cmd+R reload. App detail is // reached by clicking an app in the sidebar. setView('dashboard'); @@ -92,9 +97,12 @@ export function App(): JSX.Element { setSelected(appId); }); const offDeepUnknown = window.api.on('deepLink:unknownPath', ({ path }) => { - setPendingAddPath(path); + // Path came from a web-page-triggerable deep link: prefill but don't auto-read the FS. + setAddInitialPath(path); + setAddAutoDetect(false); + setAddOpen(true); }); - // A devharbor://start link asks to run an app's tasks. Confirm first — a link from any + // A devharbor://start link asks to run an app's tasks. Confirm first - a link from any // web page must not silently execute local shell commands. const offDeepConfirmStart = window.api.on('deepLink:confirmStart', ({ appId, appName }) => { setSelected(appId); @@ -103,7 +111,7 @@ export function App(): JSX.Element { description: 'A devharbor:// link asked to start this app, which runs its tasks (including any custom shell commands).', confirmLabel: 'Start app' }).then((ok) => { - if (ok) void window.api.invoke('proc:start', { id: appId }); + if (ok) void invokeOrToast('proc:start', { id: appId }, { context: 'Start failed' }); }); }); // macOS menu actions (Settings ⌘, / Add App ⌘N / Add Folder ⌘⇧N). @@ -112,6 +120,13 @@ export function App(): JSX.Element { const offMenuNewFolder = window.api.on('menu:newFolder', () => window.dispatchEvent(new CustomEvent('devharbor:new-folder')) ); + // Help-menu actions are forwarded here so their result surfaces in the renderer. + const offMenuCheckUpdates = window.api.on('menu:checkUpdates', () => { + void window.api.invoke('update:check', undefined); + }); + const offMenuOpenLogs = window.api.on('menu:openLogs', () => { + void window.api.invoke('logs:openFolder', undefined); + }); return () => { offTaskStatus(); offAppStatus(); @@ -124,6 +139,8 @@ export function App(): JSX.Element { offMenuSettings(); offMenuAddApp(); offMenuNewFolder(); + offMenuCheckUpdates(); + offMenuOpenLogs(); }; }, [ setApps, @@ -138,38 +155,27 @@ export function App(): JSX.Element { applyEnvFileChange ]); + // Open the add-app wizard. Folder browsing + the already-registered check now live + // inside the wizard's first step. const onAddApp = useCallback(async (): Promise => { - if (adding) return; - setAdding(true); - setAddError(null); - try { - const path = await window.api.invoke('dialog:browse', undefined); - if (!path) return; - // If this path is already registered, focus that app instead of re-adding. - const existing = await window.api.invoke('apps:findByPath', { path }); - if (existing) { - setSelected(existing.id as AppId); - setAddError(`"${existing.name}" is already registered — focused it.`); - return; - } - // Otherwise open the confirm-before-add drawer. - setPendingAddPath(path); - } catch (e) { - setAddError((e as Error).message); - } finally { - setAdding(false); - } - }, [adding, setSelected]); + setAddInitialPath(null); + setAddAutoDetect(true); + setAddOpen(true); + }, []); onAddAppRef.current = onAddApp; - // Global Cmd+K / Ctrl+K to toggle the command palette. + // Global Cmd+K (command palette) and Cmd+Shift+F (search all logs). useEffect(() => { const onKey = (e: KeyboardEvent): void => { if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k' && !e.shiftKey) { e.preventDefault(); setPaletteOpen((v) => !v); } + if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key.toLowerCase() === 'f') { + e.preventDefault(); + setLogSearchOpen((v) => !v); + } }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); @@ -178,7 +184,15 @@ export function App(): JSX.Element { const selected = apps.find((a) => a.id === selectedId); let main: JSX.Element; - if (view === 'dashboard') { + if (!loaded) { + // apps:list hasn't resolved yet - render a blank pane instead of flashing the + // first-run welcome screen at every boot/reload for users who DO have apps. + main =
; + } else if (apps.length === 0) { + // Brand-new user: the welcome / teaching screen is the zero-apps experience, + // regardless of which view is selected. + main = setImportOpen(true)} />; + } else if (view === 'dashboard') { main = ; } else if (selected) { main = ; @@ -192,33 +206,51 @@ export function App(): JSX.Element { onAddApp={onAddApp} onOpenSettings={() => setSettingsOpen(true)} onOpenPalette={() => setPaletteOpen(true)} + onImportProjects={() => setImportOpen(true)} /> {main} - {addError && ( -
- {addError} -
- )} setSettingsOpen(true)} + onImportProjects={() => setImportOpen(true)} + onSearchLogs={() => setLogSearchOpen(true)} /> {settingsOpen && setSettingsOpen(false)} />} - {pendingAddPath && ( + {addOpen && ( setPendingAddPath(null)} + initialPath={addInitialPath} + autoDetectInitial={addAutoDetect} + onCancel={() => setAddOpen(false)} onConfirm={(app) => { - setPendingAddPath(null); + setAddOpen(false); upsertApp(app); setSelected(app.id as AppId); }} /> )} + {importOpen && ( + setImportOpen(false)} + onImported={(created) => { + setImportOpen(false); + for (const app of created) upsertApp(app); + }} + /> + )} + {logSearchOpen && ( + setLogSearchOpen(false)} + onSelectApp={(id) => { + setLogSearchOpen(false); + setSelected(id); + }} + /> + )} + ); } diff --git a/src/renderer/components/AddAppDrawer.tsx b/src/renderer/components/AddAppDrawer.tsx index 5aee23e..acf15bb 100644 --- a/src/renderer/components/AddAppDrawer.tsx +++ b/src/renderer/components/AddAppDrawer.tsx @@ -1,142 +1,188 @@ -import { useEffect, useMemo, useState } from 'react'; -import { X, FolderOpen, ChevronDown, ChevronRight } from 'lucide-react'; -import type { App, DetectionResult, NodeVersionPref, PackageManager, EnvVar } from '@shared/types'; -import { ulid } from 'ulid'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { X, FolderOpen, AlertCircle, ChevronDown, ChevronRight } from 'lucide-react'; +import type { App, DetectionResult, NodeVersionPref, PackageManager } from '@shared/types'; +import type { CreateAppInput, CreateTaskSpec } from '@shared/ipc'; import { parseDotEnv, isSecretKey } from '@shared/dotenv'; import { ErrorBanner } from './ErrorBanner'; import { NodeVersionPicker } from './NodeVersionPicker'; +import { cn } from '../lib/cn'; +import { basename } from '../lib/processState'; +import { useDialog } from '../hooks/useDialog'; +import { invokeOrToast } from '../lib/invoke'; + +/** Stable id for the dialog heading so aria-labelledby can point at it. */ +const TITLE_ID = 'add-app-title'; const PMS: (PackageManager | null)[] = [null, 'npm', 'yarn', 'pnpm', 'bun']; /** - * Confirm-before-add drawer. Shows detected node version / package manager / scripts / - * .env files, and lets the user set: name, Node version, package manager, the default - * script (which becomes the app's first task), and optionally paste a .env blob. + * Add-app screen - a single, progressively-disclosed form (not a wizard). + * + * You browse for a project folder first; an inline error appears if it's already + * registered. Once a valid, not-yet-added folder is detected, the rest of the form reveals: + * how it runs (Node version, package manager, default script → first task) and an optional + * environment-variables section. Each section carries a short "why this matters" hint so a + * newcomer sees the whole shape of an app at once, while a regular adds in seconds. * - * Key behaviour (Phase 10 fix): the chosen default script is materialised into a real - * Task on add, so the app is immediately startable. Previously the script was only - * stored on the app row and no task existed until the next app restart → Start failed. + * The chosen default script is materialised into a real Task on add (so the app is + * immediately startable). The whole add is a SINGLE `apps:create` invoke - main commits the + * app + first task + env vars in one DB transaction, so a thrown step can never leave an + * orphan app row (no renderer-side rollback to get wrong). */ export function AddAppDrawer({ - path, + initialPath, + autoDetectInitial = true, onCancel, onConfirm }: { - path: string; + /** Pre-selected folder (e.g. from a devharbor:// deep link). Otherwise the user browses. */ + initialPath?: string | null; + /** + * Whether to immediately validate + detect `initialPath` on open. False for paths that + * arrived from an untrusted source (a devharbor:// deep link) - we prefill but require an + * explicit "Scan this folder" click before touching the filesystem, so a web page can't + * make DevHarbor probe arbitrary directories without a user gesture (IMPROVEMENT-PLAN 6.6). + */ + autoDetectInitial?: boolean; onCancel: () => void; onConfirm: (app: App) => void; }): JSX.Element { + const [path, setPath] = useState(initialPath ?? null); const [detection, setDetection] = useState(null); - const [name, setName] = useState(() => basename(path)); - const [defaultScript, setDefaultScript] = useState(null); + const [detecting, setDetecting] = useState(false); + const [pathError, setPathError] = useState(null); + + const [name, setName] = useState(''); const [nodePref, setNodePref] = useState({ kind: 'auto' }); const [pm, setPm] = useState(null); + const [defaultScript, setDefaultScript] = useState(null); + // When a monorepo is detected, the user can opt to materialise one task per workspace package + // instead of a single start-script task (IMPROVEMENT-PLAN 14.4). + const [perWorkspaceTasks, setPerWorkspaceTasks] = useState(false); const [envText, setEnvText] = useState(''); const [envOpen, setEnvOpen] = useState(false); + const [error, setError] = useState(null); const [adding, setAdding] = useState(false); - useEffect(() => { - let cancelled = false; - void window.api - .invoke('apps:detect', { path }) - .then((d) => { - if (cancelled) return; - setDetection(d); - setDefaultScript(d.suggestedDefaultScript); - setPm(d.packageManager); // pre-select detected PM; user can override - }) - .catch((e) => { - if (!cancelled) setError((e as Error).message); - }); - return () => { - cancelled = true; - }; - }, [path]); + // Focus trap / Escape-to-close / focus-restore + role=dialog wiring shared with every drawer. + const { dialogProps } = useDialog(onCancel, TITLE_ID); - const add = async (): Promise => { + // Validate + detect a freshly chosen folder. Sets the inline path error if it's already + // registered; otherwise runs detection to fill in sensible defaults. + const choosePath = useCallback(async (p: string): Promise => { setError(null); - setAdding(true); + setPathError(null); + setDetection(null); + setPath(p); + setName(basename(p)); try { - const app = await window.api.invoke('apps:add', { path }); - // apps:add committed the app row. Everything after must succeed or we roll the - // app back — otherwise a thrown tasks:add/env:setApp leaves an orphan in the DB - // that's invisible until reload. - try { - // Persist name / node version / package manager. We deliberately do NOT persist - // `defaultScript` on the app row: the task created below is the source of truth. - // Storing default_script would let the startup backfill resurrect a task the - // user later deletes (it seeds a task for any app that has default_script + 0 tasks). - const patched = await window.api.invoke('apps:update', { - id: app.id, - patch: { - name: name.trim() || app.name, - nodeVersionPref: nodePref, - packageManager: pm - } - }); - - // Materialise the chosen script into the app's first task so Start works - // immediately. Only if a script was actually chosen (not "don't create a task yet"). - if (defaultScript) { - await window.api.invoke('tasks:add', { - appId: app.id, - patch: { - name: defaultScript, - commandKind: 'script', - script: defaultScript, - enabled: true - } - }); - } - - // Optional: apply pasted .env to the app scope. - const parsed = parseDotEnv(envText); - const keys = Object.keys(parsed); - if (keys.length > 0) { - const vars: EnvVar[] = keys.map((key) => ({ - id: ulid(), - appId: app.id, - key, - value: parsed[key]!, - enabled: true, - isSecret: isSecretKey(key) - })); - await window.api.invoke('env:setApp', { id: app.id, vars }); - } - - onConfirm(patched); - } catch (inner) { - // Roll back the half-created app so we don't leave an orphan. - try { - await window.api.invoke('apps:remove', { id: app.id }); - } catch { - /* best-effort rollback */ - } - throw inner; + const existing = await window.api.invoke('apps:findByPath', { path: p }); + if (existing) { + setPathError(`This folder is already added as “${existing.name}”.`); + return; } + } catch (e) { + setError((e as Error).message); + return; + } + setDetecting(true); + try { + const d = await window.api.invoke('apps:detect', { path: p }); + setDetection(d); + setDefaultScript(d.suggestedDefaultScript); + setPm(d.packageManager); + // A fresh scan starts opted-out; the user re-opts per folder if it's a monorepo. + setPerWorkspaceTasks(false); } catch (e) { setError((e as Error).message); } finally { - setAdding(false); + setDetecting(false); } + }, []); + + // A trusted pre-filled folder is validated immediately. An untrusted one (deep link) is + // only prefilled - the user must click "Scan this folder" before any filesystem read. + useEffect(() => { + if (initialPath && autoDetectInitial) void choosePath(initialPath); + }, [initialPath, autoDetectInitial, choosePath]); + + const browse = async (): Promise => { + const p = await window.api.invoke('dialog:browse', undefined); + if (p) await choosePath(p); }; const scriptCount = detection ? Object.keys(detection.scripts).length : 0; - // Only re-parse when the env text changes (was re-parsing the whole blob every keystroke). - const envKeyCount = useMemo( - () => (envOpen ? Object.keys(parseDotEnv(envText)).length : 0), - [envText, envOpen] + const envKeyCount = useMemo(() => Object.keys(parseDotEnv(envText)).length, [envText]); + + // Monorepo workspace packages that have at least one runnable script. Packages with no + // scripts can't be materialised into a startable task, so we exclude them from both the + // count shown to the user and the apps:create payload below. + const workspaces = detection?.workspaces ?? []; + const runnableWorkspaces = useMemo( + // Derive from `detection` inside the memo so the dep array is exactly [detection]; the + // `workspaces` const above is just a render-time convenience for the JSX below. + () => (detection?.workspaces ?? []).filter((ws) => (ws.suggestedScript ?? ws.scripts[0]) != null), + [detection] ); + // Ready to add once a valid, not-already-added folder has finished detecting. + const ready = !!path && !pathError && !detecting && detection != null; + + const add = async (): Promise => { + if (!path || !ready) return; + setError(null); + setAdding(true); + + // Monorepo mode: the workspace packages ARE the tasks (one task per package, each pinned + // to its subdir via workingDirOverride), so we send `tasks` and clear firstTask. Otherwise + // keep the single start-script-as-first-task behavior. + const useWorkspaceTasks = perWorkspaceTasks && runnableWorkspaces.length > 0; + const tasks: CreateTaskSpec[] | undefined = useWorkspaceTasks + ? runnableWorkspaces.map((ws) => ({ + name: ws.name || ws.relPath, + commandKind: 'script', + script: ws.suggestedScript ?? ws.scripts[0], + workingDirOverride: ws.relPath + })) + : undefined; + + // One atomic create: main commits app + first task + env vars in a single transaction, + // so a failure mid-way can never strand an orphan app row (was a 4-call add + rollback). + const input: CreateAppInput = { + path, + name: name.trim() || undefined, + nodeVersionPref: nodePref, + packageManager: pm, + defaultScript: defaultScript ?? null, + firstTask: useWorkspaceTasks + ? null + : defaultScript + ? { name: defaultScript, commandKind: 'script', script: defaultScript } + : null, + tasks, + envVars: Object.entries(parseDotEnv(envText)).map(([key, value]) => ({ + key, + value, + isSecret: isSecretKey(key) + })) + }; + + const app = await invokeOrToast('apps:create', input, { context: 'Add failed' }); + setAdding(false); + if (app) onConfirm(app); + }; + return (
-
+
-
- -

Add app

-
+

+ Add app +

+ {error && ( setError(null)} className="m-3 rounded-md" /> )} +
-
-
Path
-
{path}
-
+ {/* 1 - Project folder */} + + + {pathError && ( +

+ {pathError} +

+ )} + {/* Deep-link path: require an explicit gesture before detecting (no auto fs read). */} + {path && !detection && !detecting && !pathError && ( + + )} + {detecting &&

Detecting…

} - {!detection ? ( -
Detecting…
- ) : ( + {/* Everything below reveals once a valid folder is detected. */} + {ready && detection && ( <> -
- 0 ? `${scriptCount} found` : 'none'} + {/* What we found - teaches by showing. */} +
+ 0 ? `${scriptCount} found` : 'none'} /> + - 0 ? detection.envFiles.join(', ') : 'none'} + value={detection.envFiles.length ? detection.envFiles.join(', ') : 'none'} />
- - setName(e.target.value)} - className="w-full rounded-md border border-border bg-surface px-2 py-1 text-sm text-fg" - /> - + {/* No package.json here: auto-detection can't help, but the folder is still + addable as a custom-command app - warn, don't block (IMPROVEMENT-PLAN 10.2). */} + {!detection.hasPackageJson && ( +

+ + + No package.json found here - you can still add it and run custom shell + commands, but auto-detection won't help. + +

+ )} - - setNodePref(v ?? { kind: 'auto' })} /> - + {/* 2 - How it runs */} +
+ + + setName(e.target.value)} + maxLength={60} + className="w-full rounded-md border border-border bg-surface px-2 py-1 text-sm text-fg" + /> + - - - + setNodePref(v ?? { kind: 'auto' })} /> + - {scriptCount > 0 ? ( - ) : ( - - No scripts detected. - - )} - {/* Optional env paste — collapsed by default to keep the drawer light. */} -
+ {scriptCount > 0 ? ( + + + + ) : ( + + No scripts detected. + + )} + + {/* Monorepo offer - when workspace packages are detected, let the user create + one task per package (each pinned to its subdir) instead of a single start + task. Only packages with a runnable script become tasks (IMPROVEMENT-PLAN 14.4). */} + {workspaces.length > 0 && ( +
+
+ Monorepo detected - {workspaces.length} workspace package + {workspaces.length === 1 ? '' : 's'} found +
+ + {perWorkspaceTasks && runnableWorkspaces.length > 0 && ( +

+ {runnableWorkspaces.length} task + {runnableWorkspaces.length === 1 ? '' : 's'} will be created, one per + package with a script + {runnableWorkspaces.length < workspaces.length && + ` (${workspaces.length - runnableWorkspaces.length} skipped - no scripts)`} + . The start-script task above is skipped. +

+ )} +
+ )} +
+ + {/* 3 - Environment variables (optional, collapsed) */} +
+ @@ -256,13 +403,12 @@ export function AddAppDrawer({