From a4ddbd628edd5a336fd97048215db76b6501b110 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Fri, 10 Jul 2026 15:20:34 -0400 Subject: [PATCH 01/35] feat(desktop): Electron desktop app skeleton (read-only v1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Docker-Desktop-style dashboard for non-technical users: shows control plane health (GET /health) and the locally installed agent nodes from ~/.agentfield/installed.yaml, cross-checked against GET /api/v1/nodes for a running/stopped/unknown badge per agent. Polls every 5s with a manual Refresh button and graceful empty states. Electron + electron-vite + React + TypeScript, plain CSS. Secure defaults: contextIsolation on, nodeIntegration off, sandboxed renderer, single contextBridge API. All data access is isolated in src/main/agentfield.ts with a marked seam to later swap the registry read to `af list -o json`. 29 vitest unit tests cover registry parsing, health mapping, and badge derivation. Self-contained under desktop/ (own package.json); no packaging (electron-builder) yet, and the GUI is untested in this headless environment — typecheck, production build, and unit tests all pass. Co-Authored-By: Claude Fable 5 --- desktop/.gitignore | 4 + desktop/README.md | 70 + desktop/electron.vite.config.ts | 14 + desktop/package-lock.json | 3221 +++++++++++++++++ desktop/package.json | 31 + desktop/src/main/agentfield.test.ts | 301 ++ desktop/src/main/agentfield.ts | 216 ++ desktop/src/main/index.ts | 43 + desktop/src/preload/index.ts | 9 + desktop/src/renderer/index.html | 16 + desktop/src/renderer/src/App.tsx | 61 + .../renderer/src/components/AgentsList.tsx | 80 + .../src/components/ControlPlaneCard.tsx | 38 + desktop/src/renderer/src/env.d.ts | 6 + desktop/src/renderer/src/main.tsx | 10 + desktop/src/renderer/src/styles.css | 232 ++ desktop/src/shared/types.ts | 59 + desktop/tsconfig.json | 21 + 18 files changed, 4432 insertions(+) create mode 100644 desktop/.gitignore create mode 100644 desktop/README.md create mode 100644 desktop/electron.vite.config.ts create mode 100644 desktop/package-lock.json create mode 100644 desktop/package.json create mode 100644 desktop/src/main/agentfield.test.ts create mode 100644 desktop/src/main/agentfield.ts create mode 100644 desktop/src/main/index.ts create mode 100644 desktop/src/preload/index.ts create mode 100644 desktop/src/renderer/index.html create mode 100644 desktop/src/renderer/src/App.tsx create mode 100644 desktop/src/renderer/src/components/AgentsList.tsx create mode 100644 desktop/src/renderer/src/components/ControlPlaneCard.tsx create mode 100644 desktop/src/renderer/src/env.d.ts create mode 100644 desktop/src/renderer/src/main.tsx create mode 100644 desktop/src/renderer/src/styles.css create mode 100644 desktop/src/shared/types.ts create mode 100644 desktop/tsconfig.json diff --git a/desktop/.gitignore b/desktop/.gitignore new file mode 100644 index 000000000..b9e9034b3 --- /dev/null +++ b/desktop/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +out/ +*.log diff --git a/desktop/README.md b/desktop/README.md new file mode 100644 index 000000000..4f4fc208f --- /dev/null +++ b/desktop/README.md @@ -0,0 +1,70 @@ +# AgentField Desktop + +A read-only v1 desktop dashboard for AgentField, in the spirit of Docker Desktop: +one window that shows the health of the local control plane and the agent nodes +installed on this machine. + +What it shows: + +- **Control plane health** — polls `GET http://localhost:8080/health` and renders + Running (healthy), Reachable (unhealthy), or Not reachable. +- **Installed agent nodes** — reads `~/.agentfield/installed.yaml` (name, version, + language, port, PID) and derives a status badge per agent by cross-checking the + registry against the control plane's `GET /api/v1/nodes` view: + - `running` — registry says running and the control plane sees the node + - `stopped` — registry says stopped and the control plane does not see it + - `unknown` — registry and control plane disagree (stale registry / conflict), + or the registry status is unrecognized + +The renderer polls a single snapshot over IPC every 5 seconds, with a manual +Refresh button. + +## Prerequisites + +- Node.js 20+ (developed on Node 22) +- An AgentField control plane on `http://localhost:8080` (optional — the app + degrades gracefully when it is not running) +- Optionally, agents installed via `af install ...` (populates + `~/.agentfield/installed.yaml`) + +## Development + +```bash +cd desktop +npm install +npm run dev # electron-vite dev server + Electron window (needs a display) +``` + +## Build, typecheck, test + +```bash +npm run typecheck # tsc --noEmit over main, preload, shared, and renderer +npm run build # typecheck + electron-vite production build into out/ +npm test # vitest unit tests for the data-access module (headless) +``` + +## Architecture + +- **All Node-side data access lives in one module:** `src/main/agentfield.ts` + (registry parsing, control-plane HTTP probes, badge derivation, snapshot + composition). It has no Electron imports, so it is unit-tested directly with + Vitest. +- **Secure Electron layout:** the renderer runs with `contextIsolation: true`, + `nodeIntegration: false`, `sandbox: true`. The preload + (`src/preload/index.ts`) exposes exactly one method — `window.agentfield.getSnapshot()` + — via `contextBridge`/`ipcRenderer.invoke`. +- **Shared IPC types** live in `src/shared/types.ts` and are imported type-only + by main, preload, and renderer. +- Standard electron-vite project layout: `src/main`, `src/preload`, + `src/renderer` (with `src/renderer/index.html`), built into `out/`. + +## Current limitations + +- **Read-only** — no install/start/stop actions; it only observes. +- Control plane URL is hard-coded to `http://localhost:8080` (not configurable yet). +- The registry is read directly from `~/.agentfield/installed.yaml`; once + `af list -o json` lands, the app should shell out to the CLI instead so the CLI + stays the single source of truth for registry parsing (see the `TODO(af-cli)` + seam in `src/main/agentfield.ts`). +- Not packaged — no electron-builder / installer targets yet. +- Developed headless (WSL); the GUI is untested on Windows/macOS so far. diff --git a/desktop/electron.vite.config.ts b/desktop/electron.vite.config.ts new file mode 100644 index 000000000..be5eddd5c --- /dev/null +++ b/desktop/electron.vite.config.ts @@ -0,0 +1,14 @@ +import react from '@vitejs/plugin-react' +import { defineConfig } from 'electron-vite' + +// electron-vite defaults (kept deliberately): +// main: src/main/index.ts -> out/main/index.js (CJS, package has no "type": "module") +// preload: src/preload/index.ts -> out/preload/index.js (CJS — required for sandbox: true) +// renderer: src/renderer/index.html + src/renderer/src -> out/renderer +export default defineConfig({ + main: {}, + preload: {}, + renderer: { + plugins: [react()] + } +}) diff --git a/desktop/package-lock.json b/desktop/package-lock.json new file mode 100644 index 000000000..27f388fb8 --- /dev/null +++ b/desktop/package-lock.json @@ -0,0 +1,3221 @@ +{ + "name": "agentfield-desktop", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "agentfield-desktop", + "version": "0.1.0", + "dependencies": { + "js-yaml": "^4.1.0" + }, + "devDependencies": { + "@types/js-yaml": "^4.0.9", + "@types/node": "^22.10.1", + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "electron": "^33.2.1", + "electron-vite": "^3.1.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "typescript": "^5.7.2", + "vite": "^6.0.5", + "vitest": "^3.0.5" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-arrow-functions": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.29.7.tgz", + "integrity": "sha512-N7zArUXWzAMzm+/N0uPBeVB3Fam5lMxtUwMmDK5f/IBBS7a7p1qeUoxd/6CckXoxUdgsntq1Dh8xNW06maZbDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@electron/get": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@electron/get/-/get-2.0.3.tgz", + "integrity": "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "env-paths": "^2.2.0", + "fs-extra": "^8.1.0", + "got": "^11.8.5", + "progress": "^2.0.3", + "semver": "^6.2.0", + "sumchecker": "^3.0.1" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "global-agent": "^3.0.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@sindresorhus/is": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", + "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@szmarczak/http-timer": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", + "integrity": "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==", + "dev": true, + "license": "MIT", + "dependencies": { + "defer-to-connect": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/cacheable-request": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz", + "integrity": "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-cache-semantics": "*", + "@types/keyv": "^3.1.4", + "@types/node": "*", + "@types/responselike": "^1.0.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/js-yaml": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", + "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/keyv": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz", + "integrity": "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/node": { + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@types/responselike": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz", + "integrity": "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/yauzl": { + "version": "2.10.3", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", + "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@vitest/expect": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", + "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz", + "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.7", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", + "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz", + "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.7", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz", + "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz", + "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz", + "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.42", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.42.tgz", + "integrity": "sha512-c/jurFrDLyui7o1J86yLkRu4LMsTYcBohveus7/I2Hzdn9KIP2bdJPTue/lR1KH46enoPbD77GKeSYNdyPoD3Q==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/boolean": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", + "integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/browserslist": { + "version": "4.28.5", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.5.tgz", + "integrity": "sha512-Cu2E6QejHWzuDMTkuwgpABFgDfZrXLQq5V13YOACZx4mFAG4IwGTbTfHPMr4WtxlHoXSM8FIuRwYYCz5XiabaQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.42", + "caniuse-lite": "^1.0.30001800", + "electron-to-chromium": "^1.5.387", + "node-releases": "^2.0.50", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cacheable-lookup": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", + "integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.6.0" + } + }, + "node_modules/cacheable-request": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.4.tgz", + "integrity": "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==", + "dev": true, + "license": "MIT", + "dependencies": { + "clone-response": "^1.0.2", + "get-stream": "^5.1.0", + "http-cache-semantics": "^4.0.0", + "keyv": "^4.0.0", + "lowercase-keys": "^2.0.0", + "normalize-url": "^6.0.1", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001803", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001803.tgz", + "integrity": "sha512-g/uHREV2ZpK9qMalCsWaxmA6ol+DX8GYhuf3T40RKoP+oL7vhRJh8LNt73PCjpnR6l14FzfPrB5Yux4PKm2meg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/clone-response": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz", + "integrity": "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-response": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/decompress-response/node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/defer-to-connect": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", + "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/detect-node": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", + "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/electron": { + "version": "33.4.11", + "resolved": "https://registry.npmjs.org/electron/-/electron-33.4.11.tgz", + "integrity": "sha512-xmdAs5QWRkInC7TpXGNvzo/7exojubk+72jn1oJL7keNeIlw7xNglf8TGtJtkR4rWC5FJq0oXiIXPS9BcK2Irg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@electron/get": "^2.0.0", + "@types/node": "^20.9.0", + "extract-zip": "^2.0.1" + }, + "bin": { + "electron": "cli.js" + }, + "engines": { + "node": ">= 12.20.55" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.389", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.389.tgz", + "integrity": "sha512-cEto7aeOqBfU1D+c5py5pE+ooscKE75JifxLBdFUZsqAxRS6y7kebtxAZvICszSl05gPjYHDTjY+lXpyGvpJbg==", + "dev": true, + "license": "ISC" + }, + "node_modules/electron-vite": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/electron-vite/-/electron-vite-3.1.0.tgz", + "integrity": "sha512-M7aAzaRvSl5VO+6KN4neJCYLHLpF/iWo5ztchI/+wMxIieDZQqpbCYfaEHHHPH6eupEzfvZdLYdPdmvGqoVe0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.26.10", + "@babel/plugin-transform-arrow-functions": "^7.25.9", + "cac": "^6.7.14", + "esbuild": "^0.25.1", + "magic-string": "^0.30.17", + "picocolors": "^1.1.1" + }, + "bin": { + "electron-vite": "bin/electron-vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "@swc/core": "^1.0.0", + "vite": "^4.0.0 || ^5.0.0 || ^6.0.0" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + } + } + }, + "node_modules/electron/node_modules/@types/node": { + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es6-error": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", + "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/extract-zip": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", + "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "debug": "^4.1.1", + "get-stream": "^5.1.0", + "yauzl": "^2.10.0" + }, + "bin": { + "extract-zip": "cli.js" + }, + "engines": { + "node": ">= 10.17.0" + }, + "optionalDependencies": { + "@types/yauzl": "^2.9.1" + } + }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/global-agent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz", + "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "boolean": "^3.0.1", + "es6-error": "^4.1.1", + "matcher": "^3.0.0", + "roarr": "^2.15.3", + "semver": "^7.3.2", + "serialize-error": "^7.0.1" + }, + "engines": { + "node": ">=10.0" + } + }, + "node_modules/global-agent/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/got": { + "version": "11.8.6", + "resolved": "https://registry.npmjs.org/got/-/got-11.8.6.tgz", + "integrity": "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/is": "^4.0.0", + "@szmarczak/http-timer": "^4.0.5", + "@types/cacheable-request": "^6.0.1", + "@types/responselike": "^1.0.0", + "cacheable-lookup": "^5.0.3", + "cacheable-request": "^7.0.2", + "decompress-response": "^6.0.0", + "http2-wrapper": "^1.0.0-beta.5.2", + "lowercase-keys": "^2.0.0", + "p-cancelable": "^2.0.0", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=10.19.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/got?sponsor=1" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/http2-wrapper": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz", + "integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "quick-lru": "^5.1.1", + "resolve-alpn": "^1.0.0" + }, + "engines": { + "node": ">=10.19.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "dev": true, + "license": "MIT", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lowercase-keys": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", + "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/matcher": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", + "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "escape-string-regexp": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/mimic-response": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", + "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.51", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", + "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/normalize-url": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", + "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/p-cancelable": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", + "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/quick-lru": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", + "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-alpn": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", + "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/responselike": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.1.tgz", + "integrity": "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lowercase-keys": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/roarr": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz", + "integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "boolean": "^3.0.1", + "detect-node": "^2.0.4", + "globalthis": "^1.0.1", + "json-stringify-safe": "^5.0.1", + "semver-compare": "^1.0.0", + "sprintf-js": "^1.1.2" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/semver-compare": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz", + "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/serialize-error": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz", + "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "type-fest": "^0.13.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sprintf-js": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/sumchecker": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz", + "integrity": "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.1.0" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/type-fest": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", + "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz", + "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.7", + "@vitest/mocker": "3.2.7", + "@vitest/pretty-format": "^3.2.7", + "@vitest/runner": "3.2.7", + "@vitest/snapshot": "3.2.7", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.7", + "@vitest/ui": "3.2.7", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + } + } +} diff --git a/desktop/package.json b/desktop/package.json new file mode 100644 index 000000000..3ea024d05 --- /dev/null +++ b/desktop/package.json @@ -0,0 +1,31 @@ +{ + "name": "agentfield-desktop", + "version": "0.1.0", + "private": true, + "description": "Read-only desktop dashboard for the AgentField control plane and locally installed agent nodes", + "main": "./out/main/index.js", + "scripts": { + "dev": "electron-vite dev", + "typecheck": "tsc --noEmit", + "build": "npm run typecheck && electron-vite build", + "preview": "electron-vite preview", + "test": "vitest run" + }, + "dependencies": { + "js-yaml": "^4.1.0" + }, + "devDependencies": { + "@types/js-yaml": "^4.0.9", + "@types/node": "^22.10.1", + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "electron": "^33.2.1", + "electron-vite": "^3.1.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "typescript": "^5.7.2", + "vite": "^6.0.5", + "vitest": "^3.0.5" + } +} diff --git a/desktop/src/main/agentfield.test.ts b/desktop/src/main/agentfield.test.ts new file mode 100644 index 000000000..416ac5a83 --- /dev/null +++ b/desktop/src/main/agentfield.test.ts @@ -0,0 +1,301 @@ +import { promises as fs } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + checkControlPlane, + deriveAgentBadge, + fetchControlPlaneNodes, + getAgentFieldHome, + getSnapshot, + readInstalledAgents, + type FetchLike +} from './agentfield' + +const tmpDirs: string[] = [] + +async function makeHome(installedYaml?: string): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'agentfield-desktop-test-')) + tmpDirs.push(dir) + if (installedYaml !== undefined) { + await fs.writeFile(path.join(dir, 'installed.yaml'), installedYaml, 'utf8') + } + return dir +} + +afterEach(async () => { + await Promise.all( + tmpDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })) + ) +}) + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' } + }) +} + +const REGISTRY_FIXTURE = `installed: + pr-af: + name: pr-af + version: 0.1.0 + description: Opens draft pull requests from a task description + path: /home/abir/.agentfield/packages/pr-af + source: local + source_path: ./fix-praf + installed_at: "2026-07-08T10:35:03-04:00" + status: running + language: python + runtime: + port: 9001 + pid: 4242 + started_at: "2026-07-08T10:36:00-04:00" + log_file: /home/abir/.agentfield/logs/pr-af.log + swe-af: + version: 0.2.1 + description: Software engineering agent + status: stopped + runtime: + port: null + pid: null + started_at: null + log_file: /home/abir/.agentfield/logs/swe-af.log +` + +describe('getAgentFieldHome', () => { + it('is /.agentfield', () => { + expect(getAgentFieldHome()).toBe(path.join(os.homedir(), '.agentfield')) + }) +}) + +describe('readInstalledAgents', () => { + // Contract: registry with running + stopped entries (including null runtime + // fields and a missing optional language) parses into a correct agents array. + it('parses running and stopped entries, null runtime fields, optional language', async () => { + const home = await makeHome(REGISTRY_FIXTURE) + const result = await readInstalledAgents(home) + + expect(result.exists).toBe(true) + expect(result.error).toBeUndefined() + expect(result.agents).toHaveLength(2) + + const prAf = result.agents.find((a) => a.name === 'pr-af') + expect(prAf).toEqual({ + name: 'pr-af', + version: '0.1.0', + description: 'Opens draft pull requests from a task description', + language: 'python', + status: 'running', + port: 9001, + pid: 4242 + }) + + // Entry without a `name` field falls back to its registry key; nulls stay null. + const sweAf = result.agents.find((a) => a.name === 'swe-af') + expect(sweAf).toEqual({ + name: 'swe-af', + version: '0.2.1', + description: 'Software engineering agent', + language: undefined, + status: 'stopped', + port: null, + pid: null + }) + }) + + // Contract: missing installed.yaml (or missing ~/.agentfield entirely) is a + // graceful empty state, not an error. + it('returns { exists: false, agents: [] } when installed.yaml is missing', async () => { + const home = await makeHome() // dir exists, no installed.yaml + expect(await readInstalledAgents(home)).toEqual({ exists: false, agents: [] }) + }) + + it('returns { exists: false, agents: [] } when the home dir itself is missing', async () => { + const home = await makeHome() + const missing = path.join(home, 'does-not-exist') + expect(await readInstalledAgents(missing)).toEqual({ exists: false, agents: [] }) + }) + + // Contract: malformed YAML surfaces as an error string — never throws. + it('surfaces malformed YAML as an error string without throwing', async () => { + const home = await makeHome('installed:\n pr-af: [unclosed\n') + const result = await readInstalledAgents(home) + expect(result.exists).toBe(true) + expect(result.agents).toEqual([]) + expect(result.error).toContain('installed.yaml') + }) + + it('treats a YAML doc without an installed map as an empty registry', async () => { + const home = await makeHome('something_else: true\n') + const result = await readInstalledAgents(home) + expect(result).toEqual({ exists: true, agents: [] }) + }) +}) + +describe('deriveAgentBadge', () => { + // Contract: full truth table. + it.each([ + // [registryStatus, cpReachable, nodeSeen, expected] + // CP view unavailable -> trust the registry + ['running', false, false, 'running'], + ['running', false, true, 'running'], + ['stopped', false, false, 'stopped'], + ['stopped', false, true, 'stopped'], + ['error', false, false, 'unknown'], + [undefined, false, false, 'unknown'], + // CP view available -> cross-check + ['running', true, true, 'running'], + ['running', true, false, 'unknown'], // stale registry + ['stopped', true, true, 'unknown'], // conflict + ['stopped', true, false, 'stopped'], + ['error', true, true, 'unknown'], + [undefined, true, false, 'unknown'] + ] as const)( + 'status=%s reachable=%s seen=%s -> %s', + (status, reachable, seen, expected) => { + expect(deriveAgentBadge(status, reachable, seen)).toBe(expected) + } + ) +}) + +describe('checkControlPlane', () => { + // Contract: 200 healthy body -> reachable + healthy. + it('maps a 200 healthy body to reachable/healthy', async () => { + const body = { + status: 'healthy', + timestamp: '2026-07-10T12:00:00Z', + version: '0.1.107', + checks: {} + } + const fetchImpl: FetchLike = async () => jsonResponse(body, 200) + const result = await checkControlPlane('http://localhost:8080', fetchImpl) + expect(result).toEqual({ reachable: true, healthy: true, raw: body }) + }) + + // Contract: 503 with an unhealthy body still means reachable, just not healthy. + it('maps a 503 unhealthy body to reachable but not healthy', async () => { + const body = { status: 'unhealthy', checks: { database: 'down' } } + const fetchImpl: FetchLike = async () => jsonResponse(body, 503) + const result = await checkControlPlane('http://localhost:8080', fetchImpl) + expect(result.reachable).toBe(true) + expect(result.healthy).toBe(false) + expect(result.raw).toEqual(body) + }) + + // Contract: network error / timeout -> not reachable, error captured. + it('maps a rejected fetch to unreachable with an error message', async () => { + const fetchImpl: FetchLike = async () => { + throw new TypeError('fetch failed') + } + const result = await checkControlPlane('http://localhost:8080', fetchImpl) + expect(result).toEqual({ reachable: false, healthy: false, error: 'fetch failed' }) + }) + + it('probes {baseUrl}/health', async () => { + let requested = '' + const fetchImpl: FetchLike = async (input) => { + requested = String(input) + return jsonResponse({ status: 'healthy' }) + } + await checkControlPlane('http://example.test:1234', fetchImpl) + expect(requested).toBe('http://example.test:1234/health') + }) +}) + +describe('fetchControlPlaneNodes', () => { + it('returns node ids from a 200 nodes payload', async () => { + const fetchImpl: FetchLike = async () => + jsonResponse({ + nodes: [ + { id: 'pr-af', health_status: 'active' }, + { id: 'swe-af', health_status: 'active' } + ], + count: 2 + }) + expect(await fetchControlPlaneNodes('http://localhost:8080', fetchImpl)).toEqual([ + 'pr-af', + 'swe-af' + ]) + }) + + it('returns null on a non-200 response', async () => { + const fetchImpl: FetchLike = async () => jsonResponse({ error: 'nope' }, 500) + expect(await fetchControlPlaneNodes('http://localhost:8080', fetchImpl)).toBeNull() + }) + + it('returns null when fetch rejects', async () => { + const fetchImpl: FetchLike = async () => { + throw new TypeError('fetch failed') + } + expect(await fetchControlPlaneNodes('http://localhost:8080', fetchImpl)).toBeNull() + }) + + it('returns null on an unexpected payload shape', async () => { + const fetchImpl: FetchLike = async () => jsonResponse({ items: [] }) + expect(await fetchControlPlaneNodes('http://localhost:8080', fetchImpl)).toBeNull() + }) +}) + +describe('getSnapshot', () => { + function routedFetch(routes: Record Response>): FetchLike { + return async (input) => { + const url = String(input) + const route = Object.keys(routes).find((suffix) => url.endsWith(suffix)) + if (!route) throw new TypeError(`unexpected fetch: ${url}`) + return routes[route]() + } + } + + it('composes control plane + registry with cross-checked badges', async () => { + const home = await makeHome(REGISTRY_FIXTURE) + const fetchImpl = routedFetch({ + '/health': () => jsonResponse({ status: 'healthy' }), + // Control plane sees pr-af but not swe-af. + '/api/v1/nodes': () => jsonResponse({ nodes: [{ id: 'pr-af' }], count: 1 }) + }) + + const snapshot = await getSnapshot({ homeDir: home, fetchImpl }) + + expect(snapshot.controlPlane.baseUrl).toBe('http://localhost:8080') + expect(snapshot.controlPlane.reachable).toBe(true) + expect(snapshot.controlPlane.healthy).toBe(true) + expect(snapshot.registry.exists).toBe(true) + expect(Date.parse(snapshot.fetchedAt)).not.toBeNaN() + + const badges = Object.fromEntries( + snapshot.registry.agents.map((a) => [a.name, a.badge]) + ) + expect(badges).toEqual({ + 'pr-af': 'running', // registry running + seen on CP + 'swe-af': 'stopped' // registry stopped + not seen + }) + }) + + it('falls back to registry status when the nodes endpoint fails', async () => { + const home = await makeHome(REGISTRY_FIXTURE) + const fetchImpl = routedFetch({ + '/health': () => jsonResponse({ status: 'healthy' }), + '/api/v1/nodes': () => jsonResponse({ error: 'boom' }, 500) + }) + + const snapshot = await getSnapshot({ homeDir: home, fetchImpl }) + const badges = Object.fromEntries( + snapshot.registry.agents.map((a) => [a.name, a.badge]) + ) + // Nodes view unavailable -> trust registry statuses directly. + expect(badges).toEqual({ 'pr-af': 'running', 'swe-af': 'stopped' }) + }) + + it('reports an unreachable control plane and an absent registry gracefully', async () => { + const home = await makeHome() + const missing = path.join(home, 'nope') + const fetchImpl: FetchLike = async () => { + throw new TypeError('fetch failed') + } + + const snapshot = await getSnapshot({ homeDir: missing, fetchImpl }) + expect(snapshot.controlPlane.reachable).toBe(false) + expect(snapshot.registry).toEqual({ exists: false, agents: [], error: undefined }) + }) +}) diff --git a/desktop/src/main/agentfield.ts b/desktop/src/main/agentfield.ts new file mode 100644 index 000000000..3d68108d4 --- /dev/null +++ b/desktop/src/main/agentfield.ts @@ -0,0 +1,216 @@ +// TODO(af-cli): this module currently reads ~/.agentfield/installed.yaml directly; +// a sibling branch is adding `af list -o json` — swap readInstalledAgents() to shell +// out to that once it lands, so the CLI stays the single source of truth for +// registry parsing. +// +// This is THE single data-access module for AgentField Desktop. Everything that +// touches the AgentField installation (~/.agentfield) or the control plane HTTP +// API lives here and nowhere else. It deliberately does NOT import from +// 'electron' so it stays unit-testable under plain vitest. + +import { promises as fs } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import yaml from 'js-yaml' +import type { + AgentBadge, + AgentFieldSnapshot, + ControlPlaneStatus, + InstalledAgent, + RegistryResult +} from '../shared/types' + +export const DEFAULT_BASE_URL = 'http://localhost:8080' + +const HTTP_TIMEOUT_MS = 3000 + +/** Injectable fetch so tests never hit the network. */ +export type FetchLike = typeof fetch + +/** Root of the local AgentField installation. os.homedir() is platform-aware + * (resolves %USERPROFILE% on Windows, $HOME elsewhere). */ +export function getAgentFieldHome(): string { + return path.join(os.homedir(), '.agentfield') +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function errorMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err) +} + +/** + * Probe GET {baseUrl}/health. + * - 200 {"status":"healthy",...} -> { reachable: true, healthy: true } + * - 503 {"status":"unhealthy",...} -> { reachable: true, healthy: false } + * (an HTTP response — even 503 — still means the control plane is reachable) + * - network error / timeout (3s) -> { reachable: false, healthy: false, error } + */ +export async function checkControlPlane( + baseUrl: string = DEFAULT_BASE_URL, + fetchImpl: FetchLike = fetch +): Promise { + try { + const res = await fetchImpl(`${baseUrl}/health`, { + signal: AbortSignal.timeout(HTTP_TIMEOUT_MS) + }) + let raw: unknown + try { + raw = await res.json() + } catch { + raw = undefined + } + return { reachable: true, healthy: res.ok, raw } + } catch (err) { + return { reachable: false, healthy: false, error: errorMessage(err) } + } +} + +function toInstalledAgent(key: string, entry: unknown): InstalledAgent { + const record = isRecord(entry) ? entry : {} + const runtime = isRecord(record.runtime) ? record.runtime : {} + return { + name: typeof record.name === 'string' && record.name !== '' ? record.name : key, + version: typeof record.version === 'string' ? record.version : '', + description: typeof record.description === 'string' ? record.description : '', + language: typeof record.language === 'string' ? record.language : undefined, + status: typeof record.status === 'string' ? record.status : 'unknown', + port: typeof runtime.port === 'number' ? runtime.port : null, + pid: typeof runtime.pid === 'number' ? runtime.pid : null + } +} + +/** + * Read /installed.yaml (the local agent-node registry). + * - Missing file or missing ~/.agentfield dir -> { exists: false, agents: [] } + * (graceful empty state, NOT an error). + * - Malformed YAML -> error surfaced as a string in the result; never throws, + * so nothing blows up across the IPC boundary. + */ +export async function readInstalledAgents( + homeDir: string = getAgentFieldHome() +): Promise { + const registryPath = path.join(homeDir, 'installed.yaml') + let text: string + try { + text = await fs.readFile(registryPath, 'utf8') + } catch (err) { + const code = (err as NodeJS.ErrnoException).code + if (code === 'ENOENT' || code === 'ENOTDIR') { + return { exists: false, agents: [] } + } + return { exists: false, agents: [], error: errorMessage(err) } + } + + let doc: unknown + try { + doc = yaml.load(text) + } catch (err) { + return { + exists: true, + agents: [], + error: `Failed to parse ${registryPath}: ${errorMessage(err)}` + } + } + + const installed = isRecord(doc) && isRecord(doc.installed) ? doc.installed : {} + const agents = Object.entries(installed).map(([key, entry]) => toInstalledAgent(key, entry)) + return { exists: true, agents } +} + +/** + * GET {baseUrl}/api/v1/nodes -> {"nodes":[{"id":...,"health_status":...},...],"count":N} + * (the server's default filter returns active nodes only). + * Returns the list of node ids, or null on any failure — callers treat null as + * "control plane view unavailable" and fall back to registry status alone. + */ +export async function fetchControlPlaneNodes( + baseUrl: string = DEFAULT_BASE_URL, + fetchImpl: FetchLike = fetch +): Promise { + try { + const res = await fetchImpl(`${baseUrl}/api/v1/nodes`, { + signal: AbortSignal.timeout(HTTP_TIMEOUT_MS) + }) + if (!res.ok) return null + const body: unknown = await res.json() + if (!isRecord(body) || !Array.isArray(body.nodes)) return null + return body.nodes + .filter(isRecord) + .map((node) => (typeof node.id === 'string' ? node.id : '')) + .filter((id) => id.length > 0) + } catch { + return null + } +} + +/** + * Pure badge derivation. `controlPlaneReachable` here means "we have a usable + * control-plane node view" (health reachable AND the nodes list fetched). + * + * CP view unavailable — trust the registry: + * 'running' -> 'running' | 'stopped' -> 'stopped' | other/absent -> 'unknown' + * CP view available — cross-check: + * registry running + node seen -> 'running' + * registry running + node NOT seen -> 'unknown' (stale registry) + * registry stopped + node seen -> 'unknown' (conflict) + * registry stopped + node NOT seen -> 'stopped' + * other/absent registry status -> 'unknown' + */ +export function deriveAgentBadge( + registryStatus: string | undefined, + controlPlaneReachable: boolean, + nodeSeenOnControlPlane: boolean +): AgentBadge { + if (!controlPlaneReachable) { + if (registryStatus === 'running') return 'running' + if (registryStatus === 'stopped') return 'stopped' + return 'unknown' + } + if (registryStatus === 'running') { + return nodeSeenOnControlPlane ? 'running' : 'unknown' + } + if (registryStatus === 'stopped') { + return nodeSeenOnControlPlane ? 'unknown' : 'stopped' + } + return 'unknown' +} + +export interface SnapshotOptions { + baseUrl?: string + homeDir?: string + fetchImpl?: FetchLike +} + +/** + * Compose everything into the single IPC payload the renderer polls. + * Options exist only for tests; production callers use the defaults. + */ +export async function getSnapshot(options: SnapshotOptions = {}): Promise { + const baseUrl = options.baseUrl ?? DEFAULT_BASE_URL + const fetchImpl = options.fetchImpl ?? fetch + + const [controlPlane, registry] = await Promise.all([ + checkControlPlane(baseUrl, fetchImpl), + readInstalledAgents(options.homeDir) + ]) + + const nodeIds = controlPlane.reachable + ? await fetchControlPlaneNodes(baseUrl, fetchImpl) + : null + const hasControlPlaneView = nodeIds !== null + const seen = new Set(nodeIds ?? []) + + const agents = registry.agents.map((agent) => ({ + ...agent, + badge: deriveAgentBadge(agent.status, hasControlPlaneView, seen.has(agent.name)) + })) + + return { + controlPlane: { ...controlPlane, baseUrl }, + registry: { exists: registry.exists, agents, error: registry.error }, + fetchedAt: new Date().toISOString() + } +} diff --git a/desktop/src/main/index.ts b/desktop/src/main/index.ts new file mode 100644 index 000000000..665ee73e7 --- /dev/null +++ b/desktop/src/main/index.ts @@ -0,0 +1,43 @@ +import { join } from 'node:path' +import { BrowserWindow, app, ipcMain } from 'electron' +import { getSnapshot } from './agentfield' + +function createWindow(): void { + const win = new BrowserWindow({ + width: 1080, + height: 720, + title: 'AgentField Desktop', + backgroundColor: '#111418', + webPreferences: { + preload: join(__dirname, '../preload/index.js'), + contextIsolation: true, + nodeIntegration: false, + sandbox: true + } + }) + + // Read-only dashboard: never open child windows. + win.webContents.setWindowOpenHandler(() => ({ action: 'deny' })) + + // electron-vite convention: dev server URL in dev, built file in production. + const devUrl = process.env['ELECTRON_RENDERER_URL'] + if (devUrl) { + void win.loadURL(devUrl) + } else { + void win.loadFile(join(__dirname, '../renderer/index.html')) + } +} + +app.whenReady().then(() => { + ipcMain.handle('agentfield:snapshot', () => getSnapshot()) + + createWindow() + + app.on('activate', () => { + if (BrowserWindow.getAllWindows().length === 0) createWindow() + }) +}) + +app.on('window-all-closed', () => { + if (process.platform !== 'darwin') app.quit() +}) diff --git a/desktop/src/preload/index.ts b/desktop/src/preload/index.ts new file mode 100644 index 000000000..4317803ca --- /dev/null +++ b/desktop/src/preload/index.ts @@ -0,0 +1,9 @@ +import { contextBridge, ipcRenderer } from 'electron' +import type { AgentFieldApi } from '../shared/types' + +// Sandboxed preload: only contextBridge/ipcRenderer are used, no Node APIs. +const api: AgentFieldApi = { + getSnapshot: () => ipcRenderer.invoke('agentfield:snapshot') +} + +contextBridge.exposeInMainWorld('agentfield', api) diff --git a/desktop/src/renderer/index.html b/desktop/src/renderer/index.html new file mode 100644 index 000000000..6ca8ff3b2 --- /dev/null +++ b/desktop/src/renderer/index.html @@ -0,0 +1,16 @@ + + + + + + + AgentField Desktop + + +
+ + + diff --git a/desktop/src/renderer/src/App.tsx b/desktop/src/renderer/src/App.tsx new file mode 100644 index 000000000..ef267b47c --- /dev/null +++ b/desktop/src/renderer/src/App.tsx @@ -0,0 +1,61 @@ +import { useCallback, useEffect, useState } from 'react' +import type { AgentFieldSnapshot } from '../../shared/types' +import { AgentsList } from './components/AgentsList' +import { ControlPlaneCard } from './components/ControlPlaneCard' + +const POLL_INTERVAL_MS = 5000 + +export default function App() { + const [snapshot, setSnapshot] = useState(null) + const [ipcError, setIpcError] = useState(null) + const [refreshing, setRefreshing] = useState(false) + + const refresh = useCallback(async () => { + setRefreshing(true) + try { + const next = await window.agentfield.getSnapshot() + setSnapshot(next) + setIpcError(null) + } catch (err) { + setIpcError(err instanceof Error ? err.message : String(err)) + } finally { + setRefreshing(false) + } + }, []) + + useEffect(() => { + void refresh() + const timer = setInterval(() => void refresh(), POLL_INTERVAL_MS) + return () => clearInterval(timer) + }, [refresh]) + + const lastUpdated = snapshot + ? new Date(snapshot.fetchedAt).toLocaleTimeString() + : null + + return ( +
+
+
+

AgentField Desktop

+

Read-only dashboard · polls every {POLL_INTERVAL_MS / 1000}s

+
+
+ {lastUpdated && Last updated {lastUpdated}} + +
+
+ + {ipcError && ( +
Failed to fetch snapshot: {ipcError}
+ )} + +
+ + +
+
+ ) +} diff --git a/desktop/src/renderer/src/components/AgentsList.tsx b/desktop/src/renderer/src/components/AgentsList.tsx new file mode 100644 index 000000000..bd412ff33 --- /dev/null +++ b/desktop/src/renderer/src/components/AgentsList.tsx @@ -0,0 +1,80 @@ +import type { AgentFieldSnapshot } from '../../../shared/types' + +interface AgentsListProps { + registry: AgentFieldSnapshot['registry'] | null +} + +export function AgentsList({ registry }: AgentsListProps) { + return ( +
+
+

Installed agents

+ {registry && registry.exists && ( + {registry.agents.length} installed + )} +
+ +
+ ) +} + +function AgentsListBody({ registry }: AgentsListProps) { + if (!registry) { + return

Loading…

+ } + + if (registry.error) { + return
{registry.error}
+ } + + if (!registry.exists) { + return ( +
+

No AgentField installation found (~/.agentfield missing).

+

+ Install an agent with af install <source> to get started. +

+
+ ) + } + + if (registry.agents.length === 0) { + return ( +
+

No agents installed yet.

+
+ ) + } + + return ( + + + + + + + + + + + + + {registry.agents.map((agent) => ( + + + + + + + + + ))} + +
NameVersionLanguagePortPIDStatus
+ {agent.name} + {agent.description && — {agent.description}} + {agent.version || '—'}{agent.language ?? '—'}{agent.port ?? '—'}{agent.pid ?? '—'} + {agent.badge} +
+ ) +} diff --git a/desktop/src/renderer/src/components/ControlPlaneCard.tsx b/desktop/src/renderer/src/components/ControlPlaneCard.tsx new file mode 100644 index 000000000..7554cead9 --- /dev/null +++ b/desktop/src/renderer/src/components/ControlPlaneCard.tsx @@ -0,0 +1,38 @@ +import type { AgentFieldSnapshot } from '../../../shared/types' + +interface ControlPlaneCardProps { + controlPlane: AgentFieldSnapshot['controlPlane'] | null +} + +export function ControlPlaneCard({ controlPlane }: ControlPlaneCardProps) { + let dotClass = 'gray' + let label = 'Checking…' + if (controlPlane) { + if (controlPlane.reachable && controlPlane.healthy) { + dotClass = 'green' + label = 'Running' + } else if (controlPlane.reachable) { + dotClass = 'yellow' + label = 'Reachable (unhealthy)' + } else { + dotClass = 'red' + label = 'Not reachable' + } + } + + return ( +
+
+

Control plane

+ {controlPlane?.baseUrl ?? 'http://localhost:8080'} +
+
+
+ {controlPlane && !controlPlane.reachable && controlPlane.error && ( +

{controlPlane.error}

+ )} +
+ ) +} diff --git a/desktop/src/renderer/src/env.d.ts b/desktop/src/renderer/src/env.d.ts new file mode 100644 index 000000000..c74e20608 --- /dev/null +++ b/desktop/src/renderer/src/env.d.ts @@ -0,0 +1,6 @@ +declare module '*.css' + +interface Window { + /** Exposed by src/preload/index.ts via contextBridge. */ + agentfield: import('../../shared/types').AgentFieldApi +} diff --git a/desktop/src/renderer/src/main.tsx b/desktop/src/renderer/src/main.tsx new file mode 100644 index 000000000..241cf437d --- /dev/null +++ b/desktop/src/renderer/src/main.tsx @@ -0,0 +1,10 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import App from './App' +import './styles.css' + +ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( + + + +) diff --git a/desktop/src/renderer/src/styles.css b/desktop/src/renderer/src/styles.css new file mode 100644 index 000000000..fb47bc6c0 --- /dev/null +++ b/desktop/src/renderer/src/styles.css @@ -0,0 +1,232 @@ +/* AgentField Desktop — minimal dark dashboard styles. Plain CSS, no framework. */ + +:root { + --bg: #111418; + --surface: #1a1f26; + --border: #2a313b; + --text: #e6e9ed; + --text-muted: #8b95a3; + --green: #3fb950; + --yellow: #d29922; + --red: #f85149; + --gray: #6e7681; + --accent: #4493f8; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: + system-ui, + -apple-system, + 'Segoe UI', + Roboto, + sans-serif; + background: var(--bg); + color: var(--text); + font-size: 14px; + line-height: 1.5; +} + +.app { + max-width: 960px; + margin: 0 auto; + padding: 24px; +} + +.app-header { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 16px; + margin-bottom: 20px; +} + +.app-header h1 { + margin: 0; + font-size: 20px; + font-weight: 600; +} + +.subtitle { + margin: 2px 0 0; + color: var(--text-muted); + font-size: 12px; +} + +.header-actions { + display: flex; + align-items: center; + gap: 12px; +} + +button { + background: var(--accent); + color: #fff; + border: none; + border-radius: 6px; + padding: 6px 14px; + font-size: 13px; + cursor: pointer; +} + +button:hover:not(:disabled) { + filter: brightness(1.1); +} + +button:disabled { + opacity: 0.6; + cursor: default; +} + +.app-main { + display: flex; + flex-direction: column; + gap: 16px; +} + +.card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 8px; + padding: 16px; +} + +.card-header { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; +} + +.card-header h2 { + margin: 0; + font-size: 15px; + font-weight: 600; +} + +.status-line { + display: flex; + align-items: center; + gap: 8px; +} + +.status-dot { + width: 10px; + height: 10px; + border-radius: 50%; + display: inline-block; +} + +.status-dot.green { + background: var(--green); +} + +.status-dot.yellow { + background: var(--yellow); +} + +.status-dot.red { + background: var(--red); +} + +.status-dot.gray { + background: var(--gray); +} + +.status-label { + font-weight: 500; +} + +.agents-table { + width: 100%; + border-collapse: collapse; +} + +.agents-table th, +.agents-table td { + text-align: left; + padding: 8px 10px; + border-bottom: 1px solid var(--border); +} + +.agents-table th { + color: var(--text-muted); + font-size: 12px; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.agents-table tbody tr:last-child td { + border-bottom: none; +} + +.agent-name { + font-weight: 600; +} + +.badge { + display: inline-block; + padding: 2px 10px; + border-radius: 999px; + font-size: 12px; + font-weight: 600; + text-transform: capitalize; +} + +.badge.running { + background: rgba(63, 185, 80, 0.15); + color: var(--green); +} + +.badge.stopped { + background: rgba(110, 118, 129, 0.2); + color: var(--text-muted); +} + +.badge.unknown { + background: rgba(210, 153, 34, 0.15); + color: var(--yellow); +} + +.banner { + border-radius: 6px; + padding: 10px 14px; + margin-bottom: 16px; +} + +.banner.error { + background: rgba(248, 81, 73, 0.12); + border: 1px solid rgba(248, 81, 73, 0.4); + color: var(--red); +} + +.empty-state { + padding: 20px 0; + text-align: center; +} + +.empty-state p { + margin: 4px 0; +} + +.muted { + color: var(--text-muted); +} + +.small { + font-size: 12px; +} + +code { + font-family: ui-monospace, 'Cascadia Mono', 'Fira Code', monospace; + font-size: 12px; + background: rgba(110, 118, 129, 0.15); + padding: 1px 6px; + border-radius: 4px; +} diff --git a/desktop/src/shared/types.ts b/desktop/src/shared/types.ts new file mode 100644 index 000000000..dac8815f7 --- /dev/null +++ b/desktop/src/shared/types.ts @@ -0,0 +1,59 @@ +// Shared types crossing the main / preload / renderer IPC boundary. +// Import these type-only from every layer — this file must stay runtime-free. + +/** Result of probing GET {baseUrl}/health on the control plane. */ +export interface ControlPlaneStatus { + /** An HTTP response came back (any status code, including 503). */ + reachable: boolean + /** The health endpoint answered 200 (body reports "healthy"). */ + healthy: boolean + /** Raw JSON body of the health response, when one was parseable. */ + raw?: unknown + /** Network/timeout error message when unreachable. */ + error?: string +} + +/** One entry parsed from ~/.agentfield/installed.yaml. */ +export interface InstalledAgent { + name: string + version: string + description: string + /** Optional on newer registry entries (python/go); absent on older ones. */ + language?: string + /** Raw registry status string (e.g. "running", "stopped"). */ + status: string + port: number | null + pid: number | null +} + +/** Registry read result. Missing file/dir is a graceful empty state, not an error. */ +export interface RegistryResult { + exists: boolean + agents: InstalledAgent[] + /** Set when the registry file exists but could not be parsed. */ + error?: string +} + +/** Status badge shown in the UI, derived from registry + control-plane view. */ +export type AgentBadge = 'running' | 'stopped' | 'unknown' + +export interface SnapshotAgent extends InstalledAgent { + badge: AgentBadge +} + +/** The single payload shipped over IPC to the renderer. */ +export interface AgentFieldSnapshot { + controlPlane: ControlPlaneStatus & { baseUrl: string } + registry: { + exists: boolean + agents: SnapshotAgent[] + error?: string + } + /** ISO timestamp of when this snapshot was assembled. */ + fetchedAt: string +} + +/** Surface exposed on window.agentfield by the preload script. */ +export interface AgentFieldApi { + getSnapshot(): Promise +} diff --git a/desktop/tsconfig.json b/desktop/tsconfig.json new file mode 100644 index 000000000..85821ea66 --- /dev/null +++ b/desktop/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "jsx": "react-jsx", + "types": ["node"], + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "isolatedModules": true, + "resolveJsonModule": true, + "forceConsistentCasingInFileNames": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "skipLibCheck": true + }, + "include": ["electron.vite.config.ts", "src"] +} From ab490326bf67459aa5f91dee584c73b3344e59b4 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Fri, 10 Jul 2026 15:20:46 -0400 Subject: [PATCH 02/35] feat(release): add windows/amd64 build target to goreleaser Replaces the stale commented-out windows block with a real agentfield-windows-amd64 build mirroring the linux/darwin ones (goreleaser appends .exe on its own). Groundwork only: the release workflow's build matrix filters by --id and does not build this id yet; shipping the artifact needs a follow-up matrix entry (windows runner, or mingw-w64 on the linux runner for the CGO sqlite dependency). Also modernizes archives.builds/format to ids/formats so `goreleaser check` passes clean again (both were deprecated). Co-Authored-By: Claude Fable 5 --- .goreleaser.yml | 54 +++++++++++++++++++++++++++---------------------- 1 file changed, 30 insertions(+), 24 deletions(-) diff --git a/.goreleaser.yml b/.goreleaser.yml index a4141fff2..6668dbff8 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -121,41 +121,47 @@ builds: - -X main.commit={{ .ShortCommit }} - -X main.date={{ .Date }} - # Windows build disabled - focus on Linux and macOS for now - # - id: agentfield-windows-amd64 - # dir: control-plane - # main: ./cmd/af - # binary: agentfield - # env: - # - CGO_ENABLED=1 - # - CC=gcc - # goos: - # - windows - # goarch: - # - amd64 - # ldflags: - # - -s -w - # - -X main.version={{ .Version }} - # - -X main.commit={{ .ShortCommit }} - # - -X main.date={{ .Date }} - # tags: - # - embedded - # - sqlite_fts5 - # no_unique_dist_dir: true + # Windows CLI/server binary (goreleaser appends .exe automatically). + # Groundwork only: the release workflow's build matrix filters by --id and + # does not build this id yet. CGO is required (mattn/go-sqlite3 with + # sqlite_fts5), so building it needs either a windows runner (drop the CC + # override) or mingw-w64 on the linux runner (mirrors the aarch64 + # cross-compile approach). + - id: agentfield-windows-amd64 + dir: control-plane + main: ./cmd/af + binary: agentfield-windows-amd64 + env: + - CGO_ENABLED=1 + - CC=x86_64-w64-mingw32-gcc + goos: + - windows + goarch: + - amd64 + ldflags: + - -s -w + - -X main.version={{ .Version }} + - -X main.commit={{ .ShortCommit }} + - -X main.date={{ .Date }} + tags: + - embedded + - sqlite_fts5 # Don't create archives - ship raw binaries archives: - id: default - builds: + ids: - agentfield-linux-amd64 - agentfield-linux-arm64 - agentfield-darwin-amd64 - agentfield-darwin-arm64 + - agentfield-windows-amd64 - agentfield-tray-darwin-amd64 - agentfield-tray-darwin-arm64 - format: binary + formats: [binary] # Each build's `binary:` is already the exact asset name we want - # (agentfield-- and agentfield-tray-darwin-). + # (agentfield-- and agentfield-tray-darwin-); goreleaser + # appends .exe for the windows build on its own. name_template: "{{ .Binary }}" checksum: From 0300bdbb857120c67d4340abd1b53984e8a12b53 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Fri, 10 Jul 2026 15:20:57 -0400 Subject: [PATCH 03/35] fix(control-plane): build-tagged process stop/liveness for windows `af stop` used process.Signal(os.Interrupt) and a signal-0 probe, both unsupported on Windows (and os.FindProcess always succeeds there, so the liveness check was meaningless). Extract the two process operations into build-tagged helpers: proc_unix.go keeps the existing SIGINT + signal-0 behaviour; proc_windows.go uses taskkill for the graceful request and a tasklist PID query for liveness. stop.go changes are limited to swapping the two call sites and the now-unused syscall import. Windows paths are compile-verified only (GOOS=windows cross-build), not yet tested on a real Windows machine. Co-Authored-By: Claude Fable 5 --- control-plane/internal/cli/proc_unix.go | 21 ++++++++ control-plane/internal/cli/proc_unix_test.go | 52 ++++++++++++++++++++ control-plane/internal/cli/proc_windows.go | 38 ++++++++++++++ control-plane/internal/cli/stop.go | 7 ++- 4 files changed, 114 insertions(+), 4 deletions(-) create mode 100644 control-plane/internal/cli/proc_unix.go create mode 100644 control-plane/internal/cli/proc_unix_test.go create mode 100644 control-plane/internal/cli/proc_windows.go diff --git a/control-plane/internal/cli/proc_unix.go b/control-plane/internal/cli/proc_unix.go new file mode 100644 index 000000000..4b3be5e97 --- /dev/null +++ b/control-plane/internal/cli/proc_unix.go @@ -0,0 +1,21 @@ +//go:build !windows + +package cli + +import ( + "os" + "syscall" +) + +// signalGracefulStop asks a process to shut down gracefully. On Unix this +// sends SIGINT (os.Interrupt), matching the historical `af stop` behaviour. +// Callers fall back to process.Kill() when it returns an error. +func signalGracefulStop(process *os.Process) error { + return process.Signal(os.Interrupt) +} + +// isProcessAlive reports whether the process is still running. On Unix, +// signal 0 probes for liveness without delivering an actual signal. +func isProcessAlive(process *os.Process) bool { + return process.Signal(syscall.Signal(0)) == nil +} diff --git a/control-plane/internal/cli/proc_unix_test.go b/control-plane/internal/cli/proc_unix_test.go new file mode 100644 index 000000000..dfcbf2f4c --- /dev/null +++ b/control-plane/internal/cli/proc_unix_test.go @@ -0,0 +1,52 @@ +//go:build !windows + +package cli + +import ( + "os" + "os/exec" + "testing" +) + +// Contract: isProcessAlive must report true for a live process and false once +// the process has exited. `af stop` uses this to decide whether the graceful +// shutdown worked or a force-kill is needed. +func TestIsProcessAlive(t *testing.T) { + self, err := os.FindProcess(os.Getpid()) + if err != nil { + t.Fatalf("FindProcess(self): %v", err) + } + if !isProcessAlive(self) { + t.Fatal("isProcessAlive(current process) = false; want true") + } + + cmd := exec.Command("true") + if err := cmd.Start(); err != nil { + t.Fatalf("start: %v", err) + } + if err := cmd.Wait(); err != nil { + t.Fatalf("wait: %v", err) + } + if isProcessAlive(cmd.Process) { + t.Fatal("isProcessAlive(exited process) = true; want false") + } +} + +// Contract: signalGracefulStop must deliver an interrupt that terminates a +// well-behaved (default-signal-disposition) child process. +func TestSignalGracefulStop(t *testing.T) { + cmd := exec.Command("sleep", "30") + if err := cmd.Start(); err != nil { + t.Fatalf("start: %v", err) + } + if err := signalGracefulStop(cmd.Process); err != nil { + t.Fatalf("signalGracefulStop: %v", err) + } + // The child dies from the signal, so Wait returns a non-nil ExitError. + if err := cmd.Wait(); err == nil { + t.Fatal("child exited cleanly; want interrupt-terminated") + } + if isProcessAlive(cmd.Process) { + t.Fatal("process still alive after graceful stop") + } +} diff --git a/control-plane/internal/cli/proc_windows.go b/control-plane/internal/cli/proc_windows.go new file mode 100644 index 000000000..9cd8aed27 --- /dev/null +++ b/control-plane/internal/cli/proc_windows.go @@ -0,0 +1,38 @@ +//go:build windows + +package cli + +// NOTE: compile-verified only (GOOS=windows cross-build); not yet exercised on +// a real Windows machine. + +import ( + "os" + "os/exec" + "strconv" + "strings" +) + +// signalGracefulStop asks a process to shut down gracefully. Windows cannot +// deliver SIGINT/SIGTERM to an unrelated process (os.Process.Signal only +// supports os.Kill there), so use `taskkill` without /F, which requests the +// target to close. Callers fall back to process.Kill() when it returns an +// error. +func signalGracefulStop(process *os.Process) error { + return exec.Command("taskkill", "/PID", strconv.Itoa(process.Pid)).Run() +} + +// isProcessAlive reports whether the process is still running. On Windows +// os.FindProcess always succeeds and signal-0 probing is unsupported, so ask +// tasklist whether the PID is present. When the probe itself fails, report +// not-alive so callers skip the force-kill rather than killing blindly. +func isProcessAlive(process *os.Process) bool { + out, err := exec.Command( + "tasklist", "/FI", "PID eq "+strconv.Itoa(process.Pid), "/NH", "/FO", "CSV", + ).Output() + if err != nil { + return false + } + // CSV rows quote every field; a live PID appears as "...","",... + // A no-match run prints an INFO message instead and still exits 0. + return strings.Contains(string(out), `"`+strconv.Itoa(process.Pid)+`"`) +} diff --git a/control-plane/internal/cli/stop.go b/control-plane/internal/cli/stop.go index 6e96f3268..f9a13c850 100644 --- a/control-plane/internal/cli/stop.go +++ b/control-plane/internal/cli/stop.go @@ -7,7 +7,6 @@ import ( "net/http" "os" "path/filepath" - "syscall" "time" "github.com/Agent-Field/agentfield/control-plane/internal/packages" @@ -130,8 +129,8 @@ func (as *AgentNodeStopper) StopAgentNode(agentNodeName string) error { if !httpShutdownSuccess { fmt.Printf("🔄 Falling back to process signal shutdown for agent %s\n", agentNodeName) - // Send SIGTERM for graceful shutdown - if err := process.Signal(os.Interrupt); err != nil { + // Ask for graceful shutdown (SIGINT on Unix, taskkill on Windows) + if err := signalGracefulStop(process); err != nil { // If graceful shutdown fails, force kill if err := process.Kill(); err != nil { return fmt.Errorf("failed to kill process: %w", err) @@ -141,7 +140,7 @@ func (as *AgentNodeStopper) StopAgentNode(agentNodeName string) error { time.Sleep(3 * time.Second) // Check if process is still running - if err := process.Signal(syscall.Signal(0)); err == nil { + if isProcessAlive(process) { // Process still running, force kill fmt.Printf("⚠️ Process still running, force killing agent %s\n", agentNodeName) if err := process.Kill(); err != nil { From df6121d7f0b39369dd714889aa01e0b6e6cc065a Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Fri, 10 Jul 2026 15:21:06 -0400 Subject: [PATCH 04/35] fix(control-plane): platform-aware log tailing for af logs `af logs` shelled out to tail(1), which does not exist on Windows. The tail/follow commands now go through one tailCommand helper: unchanged tail(1) invocations on Unix, PowerShell Get-Content -Tail (-Wait for follow) on Windows, with proper single-quote escaping of the log path. Windows path is compile-verified only, not yet run on a real machine. Co-Authored-By: Claude Fable 5 --- control-plane/internal/cli/logs.go | 32 ++++++++++++-- control-plane/internal/cli/logs_tail_test.go | 44 ++++++++++++++++++++ 2 files changed, 73 insertions(+), 3 deletions(-) create mode 100644 control-plane/internal/cli/logs_tail_test.go diff --git a/control-plane/internal/cli/logs.go b/control-plane/internal/cli/logs.go index c08917c76..53c9f8c8c 100644 --- a/control-plane/internal/cli/logs.go +++ b/control-plane/internal/cli/logs.go @@ -4,8 +4,10 @@ import ( "errors" "fmt" "os" - "os/exec" // Added missing import + "os/exec" "path/filepath" + "runtime" + "strings" "github.com/Agent-Field/agentfield/control-plane/internal/logger" "github.com/Agent-Field/agentfield/control-plane/internal/packages" @@ -104,7 +106,7 @@ func (lv *LogViewer) ViewLogs(agentNodeName string) error { // tailLogs shows the last N lines of the log file func (lv *LogViewer) tailLogs(logFile string, lines int) error { - cmd := exec.Command("tail", "-n", fmt.Sprintf("%d", lines), logFile) + cmd := tailCommand(logFile, lines, false) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr return cmd.Run() @@ -112,8 +114,32 @@ func (lv *LogViewer) tailLogs(logFile string, lines int) error { // followLogs follows the log file in real-time func (lv *LogViewer) followLogs(logFile string) error { - cmd := exec.Command("tail", "-f", logFile) + cmd := tailCommand(logFile, 10, true) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr return cmd.Run() } + +// tailCommand builds the platform command that prints the last n lines of a +// file, optionally following it. Unix uses tail(1); Windows has no tail, so +// PowerShell's Get-Content stands in (compile-verified only, not yet tested on +// a real Windows machine). +func tailCommand(logFile string, n int, follow bool) *exec.Cmd { + if runtime.GOOS == "windows" { + script := fmt.Sprintf("Get-Content -LiteralPath %s -Tail %d", psSingleQuote(logFile), n) + if follow { + script += " -Wait" + } + return exec.Command("powershell", "-NoProfile", "-Command", script) + } + if follow { + return exec.Command("tail", "-n", fmt.Sprintf("%d", n), "-f", logFile) + } + return exec.Command("tail", "-n", fmt.Sprintf("%d", n), logFile) +} + +// psSingleQuote quotes s as a PowerShell single-quoted string literal, where +// the only escape is doubling embedded single quotes. +func psSingleQuote(s string) string { + return "'" + strings.ReplaceAll(s, "'", "''") + "'" +} diff --git a/control-plane/internal/cli/logs_tail_test.go b/control-plane/internal/cli/logs_tail_test.go new file mode 100644 index 000000000..954093a61 --- /dev/null +++ b/control-plane/internal/cli/logs_tail_test.go @@ -0,0 +1,44 @@ +package cli + +import ( + "runtime" + "strings" + "testing" +) + +// Contract: on Unix, `af logs` shells out to tail(1) with the requested line +// count, adding -f when following. (The Windows branch builds a PowerShell +// Get-Content command instead; it is compile-verified via the windows +// cross-build and exercised only on a real Windows machine.) +func TestTailCommandUnix(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("unix tail arguments are not used on windows") + } + + cmd := tailCommand("/var/log/agent.log", 7, false) + want := []string{"tail", "-n", "7", "/var/log/agent.log"} + if got := strings.Join(cmd.Args, " "); got != strings.Join(want, " ") { + t.Fatalf("tailCommand(no follow) args = %q; want %q", got, strings.Join(want, " ")) + } + + follow := tailCommand("/var/log/agent.log", 10, true) + wantFollow := []string{"tail", "-n", "10", "-f", "/var/log/agent.log"} + if got := strings.Join(follow.Args, " "); got != strings.Join(wantFollow, " ") { + t.Fatalf("tailCommand(follow) args = %q; want %q", got, strings.Join(wantFollow, " ")) + } +} + +// Contract: psSingleQuote produces a PowerShell single-quoted literal where +// embedded single quotes are doubled — the only escape that quoting form has. +func TestPSSingleQuote(t *testing.T) { + cases := map[string]string{ + `C:\logs\agent.log`: `'C:\logs\agent.log'`, + `C:\it's here\a.log`: `'C:\it''s here\a.log'`, + ``: `''`, + } + for in, want := range cases { + if got := psSingleQuote(in); got != want { + t.Errorf("psSingleQuote(%q) = %s; want %s", in, got, want) + } + } +} From bbe63dc09b4d248ae250aad470f63af7753af7db Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Fri, 10 Jul 2026 15:21:17 -0400 Subject: [PATCH 05/35] fix(control-plane): .exe-aware Go node binary naming on windows Go agent nodes declare unix-style binary paths in their manifests (entrypoint.start: bin/foo). On Windows the install-time `go build -o` output now carries the conventional .exe extension, and the runner's GoBinaryProgram resolves an extensionless start path to the built .exe when present. No behaviour change on other platforms; windows path is compile-verified only. Co-Authored-By: Claude Fable 5 --- control-plane/internal/packages/gointerp.go | 30 ++++++++++++++++++--- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/control-plane/internal/packages/gointerp.go b/control-plane/internal/packages/gointerp.go index e5e90bf79..8c033f150 100644 --- a/control-plane/internal/packages/gointerp.go +++ b/control-plane/internal/packages/gointerp.go @@ -7,6 +7,7 @@ import ( "os" "os/exec" "path/filepath" + "runtime" "strconv" "strings" ) @@ -221,19 +222,31 @@ func (m *PackageMetadata) goBuildTarget() (buildPkg, outBin string) { // The output is the start token when start is a bare binary path (not a // `go run ...` command). Otherwise derive a sensible default under bin/. if len(start) > 0 && start[0] != "go" { - return build, start[0] + return build, withExeSuffix(start[0]) } return build, defaultGoBinName(build) } // defaultGoBinName derives a bin/ output path from a Go build package spec -// like "./cmd/swe-planner" -> "bin/swe-planner". +// like "./cmd/swe-planner" -> "bin/swe-planner" ("bin/swe-planner.exe" on +// Windows). func defaultGoBinName(buildPkg string) string { name := filepath.Base(strings.TrimSpace(buildPkg)) if name == "" || name == "." || name == "/" || name == "..." { name = "app" } - return filepath.Join("bin", name) + return withExeSuffix(filepath.Join("bin", name)) +} + +// withExeSuffix appends ".exe" to a binary path on Windows (manifests declare +// unix-style paths like "bin/swe-planner"; the compiled output must carry the +// conventional executable extension there). A no-op on other platforms and on +// paths that already end in .exe. +func withExeSuffix(path string) string { + if runtime.GOOS == "windows" && !strings.EqualFold(filepath.Ext(path), ".exe") { + return path + ".exe" + } + return path } // hasVendorDir reports whether the package ships a Go vendor/ directory, which @@ -369,7 +382,16 @@ func GoBinaryProgram(packageDir, program string) string { return program } if strings.ContainsRune(program, '/') || strings.ContainsRune(program, filepath.Separator) { - return filepath.Join(packageDir, program) + resolved := filepath.Join(packageDir, program) + // Manifests declare unix-style binary paths ("bin/swe-planner"); on + // Windows the install-time build produced "bin/swe-planner.exe", so + // resolve to that when the extensionless path is absent. + if !fileExists(resolved) { + if withExe := withExeSuffix(resolved); withExe != resolved && fileExists(withExe) { + return withExe + } + } + return resolved } return program } From ba52ae293f2c718fa733fd5bba94a3f477f162fb Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Fri, 10 Jul 2026 16:21:25 -0400 Subject: [PATCH 06/35] test(control-plane): cover windows-only branches via goos-parameterized helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The patch-coverage gate flagged the runtime.GOOS-gated windows branches (PowerShell tail construction in logs.go, .exe naming/resolution in gointerp.go) as untestable on linux CI. Extract each into a pure helper taking an explicit goos string — tailCommandArgs, withExeSuffixFor, goBinaryProgramFor — with the exported wrappers passing runtime.GOOS, so behavior is unchanged while both platform paths are unit-testable anywhere. Table-driven tests cover the windows tail command (incl. single-quote escaping), .exe suffixing, and the built-.exe fallback resolution; refactored regions now profile with zero uncovered blocks. Co-Authored-By: Claude Fable 5 --- control-plane/internal/cli/logs.go | 22 ++-- control-plane/internal/cli/logs_tail_test.go | 82 ++++++++++--- .../internal/packages/exe_suffix_test.go | 115 ++++++++++++++++++ control-plane/internal/packages/gointerp.go | 16 ++- 4 files changed, 209 insertions(+), 26 deletions(-) create mode 100644 control-plane/internal/packages/exe_suffix_test.go diff --git a/control-plane/internal/cli/logs.go b/control-plane/internal/cli/logs.go index 53c9f8c8c..4b4fbad72 100644 --- a/control-plane/internal/cli/logs.go +++ b/control-plane/internal/cli/logs.go @@ -121,21 +121,29 @@ func (lv *LogViewer) followLogs(logFile string) error { } // tailCommand builds the platform command that prints the last n lines of a -// file, optionally following it. Unix uses tail(1); Windows has no tail, so -// PowerShell's Get-Content stands in (compile-verified only, not yet tested on -// a real Windows machine). +// file, optionally following it. func tailCommand(logFile string, n int, follow bool) *exec.Cmd { - if runtime.GOOS == "windows" { + program, args := tailCommandArgs(runtime.GOOS, logFile, n, follow) + return exec.Command(program, args...) +} + +// tailCommandArgs returns the program and arguments that tail a log file on +// the given GOOS. Unix uses tail(1); Windows has no tail, so PowerShell's +// Get-Content stands in (compile-verified only, not yet tested on a real +// Windows machine). Pure so both platform branches are unit-testable anywhere. +func tailCommandArgs(goos, logFile string, n int, follow bool) (string, []string) { + if goos == "windows" { script := fmt.Sprintf("Get-Content -LiteralPath %s -Tail %d", psSingleQuote(logFile), n) if follow { script += " -Wait" } - return exec.Command("powershell", "-NoProfile", "-Command", script) + return "powershell", []string{"-NoProfile", "-Command", script} } + args := []string{"-n", fmt.Sprintf("%d", n)} if follow { - return exec.Command("tail", "-n", fmt.Sprintf("%d", n), "-f", logFile) + args = append(args, "-f") } - return exec.Command("tail", "-n", fmt.Sprintf("%d", n), logFile) + return "tail", append(args, logFile) } // psSingleQuote quotes s as a PowerShell single-quoted string literal, where diff --git a/control-plane/internal/cli/logs_tail_test.go b/control-plane/internal/cli/logs_tail_test.go index 954093a61..421d7c396 100644 --- a/control-plane/internal/cli/logs_tail_test.go +++ b/control-plane/internal/cli/logs_tail_test.go @@ -1,30 +1,78 @@ package cli import ( + "reflect" "runtime" - "strings" "testing" ) -// Contract: on Unix, `af logs` shells out to tail(1) with the requested line -// count, adding -f when following. (The Windows branch builds a PowerShell -// Get-Content command instead; it is compile-verified via the windows -// cross-build and exercised only on a real Windows machine.) -func TestTailCommandUnix(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("unix tail arguments are not used on windows") +// Contract: `af logs` tails via tail(1) on Unix and via PowerShell +// Get-Content on Windows (which has no tail), preserving the requested line +// count, the follow flag, and safe quoting of the log path. The helper is +// parameterized by GOOS so both platform branches are exercised regardless of +// the host running the tests. +func TestTailCommandArgs(t *testing.T) { + cases := []struct { + name string + goos string + file string + n int + follow bool + wantProg string + wantArgs []string + }{ + { + name: "unix no follow", goos: "linux", + file: "/var/log/agent.log", n: 7, + wantProg: "tail", + wantArgs: []string{"-n", "7", "/var/log/agent.log"}, + }, + { + name: "unix follow", goos: "darwin", + file: "/var/log/agent.log", n: 10, follow: true, + wantProg: "tail", + wantArgs: []string{"-n", "10", "-f", "/var/log/agent.log"}, + }, + { + name: "windows no follow", goos: "windows", + file: `C:\logs\agent.log`, n: 7, + wantProg: "powershell", + wantArgs: []string{ + "-NoProfile", "-Command", + `Get-Content -LiteralPath 'C:\logs\agent.log' -Tail 7`, + }, + }, + { + name: "windows follow with quote escaping", goos: "windows", + file: `C:\it's here\agent.log`, n: 10, follow: true, + wantProg: "powershell", + wantArgs: []string{ + "-NoProfile", "-Command", + `Get-Content -LiteralPath 'C:\it''s here\agent.log' -Tail 10 -Wait`, + }, + }, } - - cmd := tailCommand("/var/log/agent.log", 7, false) - want := []string{"tail", "-n", "7", "/var/log/agent.log"} - if got := strings.Join(cmd.Args, " "); got != strings.Join(want, " ") { - t.Fatalf("tailCommand(no follow) args = %q; want %q", got, strings.Join(want, " ")) + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + prog, args := tailCommandArgs(tc.goos, tc.file, tc.n, tc.follow) + if prog != tc.wantProg { + t.Fatalf("program = %q; want %q", prog, tc.wantProg) + } + if !reflect.DeepEqual(args, tc.wantArgs) { + t.Fatalf("args = %q; want %q", args, tc.wantArgs) + } + }) } +} - follow := tailCommand("/var/log/agent.log", 10, true) - wantFollow := []string{"tail", "-n", "10", "-f", "/var/log/agent.log"} - if got := strings.Join(follow.Args, " "); got != strings.Join(wantFollow, " ") { - t.Fatalf("tailCommand(follow) args = %q; want %q", got, strings.Join(wantFollow, " ")) +// Contract: tailCommand builds an exec.Cmd from the host platform's +// tailCommandArgs — the first Args entry is the program itself. +func TestTailCommandUsesHostGOOS(t *testing.T) { + cmd := tailCommand("/var/log/agent.log", 5, false) + prog, args := tailCommandArgs(runtime.GOOS, "/var/log/agent.log", 5, false) + want := append([]string{prog}, args...) + if !reflect.DeepEqual(cmd.Args, want) { + t.Fatalf("cmd.Args = %q; want %q", cmd.Args, want) } } diff --git a/control-plane/internal/packages/exe_suffix_test.go b/control-plane/internal/packages/exe_suffix_test.go new file mode 100644 index 000000000..7e45d1a61 --- /dev/null +++ b/control-plane/internal/packages/exe_suffix_test.go @@ -0,0 +1,115 @@ +package packages + +import ( + "os" + "path/filepath" + "runtime" + "testing" +) + +// Contract: compiled Go node binaries get the conventional .exe extension on +// Windows and stay untouched elsewhere; already-suffixed paths (any case) are +// never double-suffixed. +func TestWithExeSuffixFor(t *testing.T) { + cases := []struct { + name string + goos string + in string + want string + }{ + {"windows adds exe", "windows", "bin/swe-planner", "bin/swe-planner.exe"}, + {"windows keeps existing exe", "windows", "bin/swe-planner.exe", "bin/swe-planner.exe"}, + {"windows keeps uppercase exe", "windows", "bin/APP.EXE", "bin/APP.EXE"}, + {"linux untouched", "linux", "bin/swe-planner", "bin/swe-planner"}, + {"darwin untouched", "darwin", "bin/swe-planner", "bin/swe-planner"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := withExeSuffixFor(tc.goos, tc.in); got != tc.want { + t.Fatalf("withExeSuffixFor(%q, %q) = %q; want %q", tc.goos, tc.in, got, tc.want) + } + }) + } +} + +// Contract: an unusable build-package base falls back to bin/app (suffixed on +// Windows like every other derived binary name). +func TestDefaultGoBinNameFallback(t *testing.T) { + want := withExeSuffix(filepath.Join("bin", "app")) + if got := defaultGoBinName("..."); got != want { + t.Fatalf("defaultGoBinName(\"...\") = %q; want %q", got, want) + } +} + +// Contract: on Windows, a manifest's unix-style start path ("bin/app") +// resolves to the .exe the install-time build produced when the extensionless +// file is absent; everywhere else (and whenever the extensionless file exists) +// the plain resolved path wins. +func TestGoBinaryProgramForWindowsExeFallback(t *testing.T) { + writeFile := func(t *testing.T, dir, rel string) { + t.Helper() + p := filepath.Join(dir, rel) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte("bin"), 0o755); err != nil { + t.Fatal(err) + } + } + + t.Run("windows falls back to built exe", func(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, filepath.Join("bin", "app.exe")) + want := filepath.Join(dir, "bin", "app.exe") + if got := goBinaryProgramFor("windows", dir, "bin/app"); got != want { + t.Fatalf("goBinaryProgramFor = %q; want %q", got, want) + } + }) + + t.Run("windows prefers extensionless file when present", func(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, filepath.Join("bin", "app")) + writeFile(t, dir, filepath.Join("bin", "app.exe")) + want := filepath.Join(dir, "bin", "app") + if got := goBinaryProgramFor("windows", dir, "bin/app"); got != want { + t.Fatalf("goBinaryProgramFor = %q; want %q", got, want) + } + }) + + t.Run("windows with neither file returns resolved path", func(t *testing.T) { + dir := t.TempDir() + want := filepath.Join(dir, "bin", "app") + if got := goBinaryProgramFor("windows", dir, "bin/app"); got != want { + t.Fatalf("goBinaryProgramFor = %q; want %q", got, want) + } + }) + + t.Run("non-windows never substitutes exe", func(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, filepath.Join("bin", "app.exe")) + want := filepath.Join(dir, "bin", "app") + if got := goBinaryProgramFor("linux", dir, "bin/app"); got != want { + t.Fatalf("goBinaryProgramFor = %q; want %q", got, want) + } + }) + + t.Run("bare and special program tokens pass through", func(t *testing.T) { + dir := t.TempDir() + for _, program := range []string{"", "go", "app"} { + if got := goBinaryProgramFor("windows", dir, program); got != program { + t.Fatalf("goBinaryProgramFor(%q) = %q; want it unchanged", program, got) + } + } + abs := filepath.Join(dir, "bin", "app") + if got := goBinaryProgramFor("windows", dir, abs); got != abs { + t.Fatalf("goBinaryProgramFor(abs) = %q; want %q", got, abs) + } + }) + + t.Run("exported wrapper uses host GOOS", func(t *testing.T) { + dir := t.TempDir() + if got, want := GoBinaryProgram(dir, "bin/app"), goBinaryProgramFor(runtime.GOOS, dir, "bin/app"); got != want { + t.Fatalf("GoBinaryProgram = %q; want %q", got, want) + } + }) +} diff --git a/control-plane/internal/packages/gointerp.go b/control-plane/internal/packages/gointerp.go index 8c033f150..212c7d5dc 100644 --- a/control-plane/internal/packages/gointerp.go +++ b/control-plane/internal/packages/gointerp.go @@ -243,7 +243,13 @@ func defaultGoBinName(buildPkg string) string { // conventional executable extension there). A no-op on other platforms and on // paths that already end in .exe. func withExeSuffix(path string) string { - if runtime.GOOS == "windows" && !strings.EqualFold(filepath.Ext(path), ".exe") { + return withExeSuffixFor(runtime.GOOS, path) +} + +// withExeSuffixFor is withExeSuffix for an explicit GOOS, pure so the Windows +// branch is unit-testable on any platform. +func withExeSuffixFor(goos, path string) string { + if goos == "windows" && !strings.EqualFold(filepath.Ext(path), ".exe") { return path + ".exe" } return path @@ -378,6 +384,12 @@ func applyGoReplaceOverrides(goCmd, packagePath string) error { // already-absolute path is returned unchanged. It is the Go counterpart to the // venv-python substitution the runner does for Python nodes. func GoBinaryProgram(packageDir, program string) string { + return goBinaryProgramFor(runtime.GOOS, packageDir, program) +} + +// goBinaryProgramFor is GoBinaryProgram for an explicit GOOS, pure so the +// Windows .exe fallback is unit-testable on any platform. +func goBinaryProgramFor(goos, packageDir, program string) string { if program == "" || program == "go" || filepath.IsAbs(program) { return program } @@ -387,7 +399,7 @@ func GoBinaryProgram(packageDir, program string) string { // Windows the install-time build produced "bin/swe-planner.exe", so // resolve to that when the extensionless path is absent. if !fileExists(resolved) { - if withExe := withExeSuffix(resolved); withExe != resolved && fileExists(withExe) { + if withExe := withExeSuffixFor(goos, resolved); withExe != resolved && fileExists(withExe) { return withExe } } From b04f0b6dea3547ea9382da6eeec5c8c10fe695e1 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Mon, 13 Jul 2026 10:36:22 -0400 Subject: [PATCH 07/35] fix(control-plane): remove agentfield.yaml symlink that breaks startup on Windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit control-plane/agentfield.yaml was a checked-in symlink to config/agentfield.yaml. On Windows checkouts (core.symlinks=false, the default) git materializes it as a plain text file containing the literal target path, which Viper then finds via its . search path and fails to parse: `cannot unmarshal !!str config/...` — a fatal error on every `af server` run from the control-plane directory. The symlink is redundant on every platform: both cmd/af and cmd/agentfield-server already search ./config and /config, which resolve to the same file. Co-Authored-By: Claude Fable 5 --- control-plane/agentfield.yaml | 1 - 1 file changed, 1 deletion(-) delete mode 120000 control-plane/agentfield.yaml diff --git a/control-plane/agentfield.yaml b/control-plane/agentfield.yaml deleted file mode 120000 index dc9d0c54d..000000000 --- a/control-plane/agentfield.yaml +++ /dev/null @@ -1 +0,0 @@ -config/agentfield.yaml \ No newline at end of file From 0ce7a00e343f10f2a5035bd408e7deb93297d35f Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Mon, 13 Jul 2026 10:37:58 -0400 Subject: [PATCH 08/35] fix(control-plane): default local storage paths whenever mode is local The af binary only filled in DatabasePath/KVStorePath when storage.mode was absent entirely. A config file that sets mode: "local" while leaving the paths empty (which the repo's own sample config/agentfield.yaml does, and which af picks up automatically when running next to it) skipped the defaulting block and failed startup with "database path is empty". Default the paths whenever the effective mode is local, mirroring cmd/agentfield-server which already had this shape. Found on Windows but platform-independent. Co-Authored-By: Claude Fable 5 --- control-plane/cmd/af/main.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/control-plane/cmd/af/main.go b/control-plane/cmd/af/main.go index 2f790bff7..0637ae0ec 100644 --- a/control-plane/cmd/af/main.go +++ b/control-plane/cmd/af/main.go @@ -338,6 +338,14 @@ func loadConfig(configFile string) (*config.Config, error) { // Set default storage mode to local if not specified if cfg.Storage.Mode == "" { cfg.Storage.Mode = "local" + } + // Default the local storage paths whenever they are unset — not only when + // the mode was defaulted. A config file may set mode: "local" while leaving + // the paths empty (the sample config/agentfield.yaml does exactly that, + // and it is picked up automatically when af runs next to it), which + // previously skipped this block and failed startup with "database path is + // empty". Mirrors cmd/agentfield-server. + if cfg.Storage.Mode == "local" { // Use the universal path management system if cfg.Storage.Local.DatabasePath == "" { dbPath, err := utils.GetDatabasePath() From 6e0b4796da115cbff91ac00d2a61ff40713bcb01 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Mon, 13 Jul 2026 10:37:58 -0400 Subject: [PATCH 09/35] fix(control-plane): probe python candidates for a working interpreter exec.LookPath alone cannot pick a Python on Windows: stock machines ship Microsoft Store "app execution alias" stubs named python3.exe (and often python.exe) that resolve like real binaries but exit 9009 without running anything. resolveVenvInterpreter took the first PATH hit and only version-checked that one, so a node declaring requires-python failed with "no compatible interpreter" even when a perfectly good python was next in line. Verified live on Windows 11: python3 -> dead Store stub (exit 9009), python -> real 3.11.9, never consulted. ambientPythonInterpreter now probes each candidate by actually running it (-c version query) and takes the first that answers. The candidate list gains "py", the Windows launcher: python.org installers register it even when "add python to PATH" is left unchecked (the default), where it is the only working entry. It does not exist on Unix, so probing it there is a no-op. The legacy no-requires-python path in InstallPythonDependencies now uses the same probe instead of blindly running python3 -m venv and falling back, and fails with an actionable error listing the probed candidates when nothing on PATH runs. Test stubs that previously only handled -m venv now answer the -c version probe, matching how real interpreters behave; the venv-creation-failure contracts now expect the earlier, more actionable error. Co-Authored-By: Claude Fable 5 --- .../core/services/coverage_gap_test.go | 6 +- .../internal/packages/install_deps_test.go | 5 +- control-plane/internal/packages/installer.go | 27 ++--- .../packages/installer_additional_test.go | 9 +- .../packages/installer_coverage_test.go | 8 +- control-plane/internal/packages/pyinterp.go | 43 +++++-- .../internal/packages/pyinterp_test.go | 110 ++++++++++++++++++ 7 files changed, 179 insertions(+), 29 deletions(-) diff --git a/control-plane/internal/core/services/coverage_gap_test.go b/control-plane/internal/core/services/coverage_gap_test.go index a4a19972d..765b52e63 100644 --- a/control-plane/internal/core/services/coverage_gap_test.go +++ b/control-plane/internal/core/services/coverage_gap_test.go @@ -36,7 +36,9 @@ func TestCoverageGapInstallDependenciesErrors(t *testing.T) { Dependencies: packages.DependencyConfig{Python: []string{"requests"}}, }) require.Error(t, err) - assert.Contains(t, err.Error(), "failed to create virtual environment") + // Both stubs fail to run at all, so interpreter selection reports the + // actionable no-working-interpreter error (the stock-Windows shape). + assert.Contains(t, err.Error(), "no working Python interpreter found on PATH") }) t.Run("requirements installation fails", func(t *testing.T) { @@ -46,6 +48,7 @@ func TestCoverageGapInstallDependenciesErrors(t *testing.T) { require.NoError(t, os.WriteFile(filepath.Join(packagePath, "requirements.txt"), []byte("broken\n"), 0o644)) python3Script := []byte(`#!/bin/sh +if [ "$1" = "-c" ]; then echo "3.12.0"; exit 0; fi mkdir -p "$3/bin" cat > "$3/bin/pip" <<'SH' #!/bin/sh @@ -75,6 +78,7 @@ chmod +x "$3/bin/pip" require.NoError(t, os.MkdirAll(fakeBin, 0o755)) python3Script := []byte(`#!/bin/sh +if [ "$1" = "-c" ]; then echo "3.12.0"; exit 0; fi mkdir -p "$3/bin" cat > "$3/bin/pip" <<'SH' #!/bin/sh diff --git a/control-plane/internal/packages/install_deps_test.go b/control-plane/internal/packages/install_deps_test.go index 2f80bcc60..446332e02 100644 --- a/control-plane/internal/packages/install_deps_test.go +++ b/control-plane/internal/packages/install_deps_test.go @@ -9,11 +9,14 @@ import ( // fakePythonOnPath installs a stub `python3` whose `-m venv ` creates a // venv layout with a no-op `pip`, so dependency installation can be exercised -// offline without invoking real Python/pip. +// offline without invoking real Python/pip. It also answers the `-c` version +// probe, since interpreter selection only accepts candidates that actually +// run and report a version (the guard against Windows Store alias stubs). func fakePythonOnPath(t *testing.T) { t.Helper() binDir := t.TempDir() py := "#!/bin/sh\n" + + "if [ \"$1\" = \"-c\" ]; then echo \"3.12.0\"; exit 0; fi\n" + "if [ \"$1\" = \"-m\" ] && [ \"$2\" = \"venv\" ]; then\n" + " mkdir -p \"$3/bin\"\n" + " printf '#!/bin/sh\\nexit 0\\n' > \"$3/bin/pip\"\n" + diff --git a/control-plane/internal/packages/installer.go b/control-plane/internal/packages/installer.go index 45b66d175..12261a0cf 100644 --- a/control-plane/internal/packages/installer.go +++ b/control-plane/internal/packages/installer.go @@ -855,20 +855,21 @@ func InstallPythonDependencies(packagePath string, pyDeps, systemDeps []string) return err } - var cmd *exec.Cmd - if interp != "" { - cmd = exec.Command(interp, "-m", "venv", venvPath) - if output, err := cmd.CombinedOutput(); err != nil { - return fmt.Errorf("failed to create virtual environment with %s: %w\nOutput: %s", interp, err, output) - } - } else { - cmd = exec.Command("python3", "-m", "venv", venvPath) - if _, err := cmd.CombinedOutput(); err != nil { - cmd = exec.Command("python", "-m", "venv", venvPath) - if output, err := cmd.CombinedOutput(); err != nil { - return fmt.Errorf("failed to create virtual environment: %w\nOutput: %s", err, output) - } + if interp == "" { + // Legacy path (no requires-python declared): pick the first ambient + // interpreter that actually runs. Blindly trying "python3 -m venv" + // first breaks on stock Windows, where python3 (and often python) is + // a Microsoft Store stub that exits 9009; on default python.org + // installs (PATH box unchecked) only the "py" launcher works. + ambient, _, ok := ambientPythonInterpreter() + if !ok { + return fmt.Errorf("no working Python interpreter found on PATH (tried %s) - install Python 3, ensure it is on PATH, then run `af install` again", strings.Join(pythonCandidates, ", ")) } + interp = ambient + } + cmd := exec.Command(interp, "-m", "venv", venvPath) + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("failed to create virtual environment with %s: %w\nOutput: %s", interp, err, output) } pipPath := filepath.Join(venvPath, "bin", "pip") diff --git a/control-plane/internal/packages/installer_additional_test.go b/control-plane/internal/packages/installer_additional_test.go index b94628097..102686609 100644 --- a/control-plane/internal/packages/installer_additional_test.go +++ b/control-plane/internal/packages/installer_additional_test.go @@ -39,6 +39,10 @@ exit 0 pythonImpl := `#!/usr/bin/env bash set -eu +if [ "${1:-}" = "-c" ]; then + echo "3.12.0" + exit 0 +fi if [ "${1:-}" = "-m" ] && [ "${2:-}" = "venv" ]; then venv_path="${3:-}" mkdir -p "$venv_path/bin" @@ -104,11 +108,14 @@ func TestInstallDependenciesAdditionalCoverage(t *testing.T) { }, }, { + // Both candidates are dead stubs (the stock-Windows shape: Store + // aliases that exit non-zero) — interpreter selection must fail with + // the actionable no-working-interpreter error before venv creation. name: "venv creation failure", withRequirements: true, failPython3: true, failPython: true, - wantErr: "failed to create virtual environment", + wantErr: "no working Python interpreter found on PATH", }, { name: "requirements install failure", diff --git a/control-plane/internal/packages/installer_coverage_test.go b/control-plane/internal/packages/installer_coverage_test.go index f2d5e67ff..90723b969 100644 --- a/control-plane/internal/packages/installer_coverage_test.go +++ b/control-plane/internal/packages/installer_coverage_test.go @@ -58,7 +58,7 @@ func TestInstallerCoverage(t *testing.T) { t.Setenv("PATH", "") err := pi.installDependencies(pkgPath, metadata) - if err == nil || !strings.Contains(err.Error(), "failed to create virtual environment") { + if err == nil || !strings.Contains(err.Error(), "no working Python interpreter found on PATH") { t.Fatalf("expected python to fail, got %v", err) } }) @@ -72,10 +72,12 @@ func TestInstallerCoverage(t *testing.T) { }, } - // create a fake python that will succeed, but no pip + // create a fake python that answers the version probe and "succeeds" + // at venv creation without producing a pip fakebin := t.TempDir() pythonPath := filepath.Join(fakebin, "python3") - if err := os.WriteFile(pythonPath, []byte("#!/bin/sh\nexit 0"), 0755); err != nil { + script := "#!/bin/sh\nif [ \"$1\" = \"-c\" ]; then echo \"3.12.0\"; fi\nexit 0" + if err := os.WriteFile(pythonPath, []byte(script), 0755); err != nil { t.Fatal(err) } t.Setenv("PATH", fakebin) diff --git a/control-plane/internal/packages/pyinterp.go b/control-plane/internal/packages/pyinterp.go index 1c96a297d..516012f8c 100644 --- a/control-plane/internal/packages/pyinterp.go +++ b/control-plane/internal/packages/pyinterp.go @@ -212,11 +212,9 @@ func resolveVenvInterpreter(packagePath string) (string, error) { } // 1. Ambient interpreter already satisfies? - ambient := firstOnPath("python3", "python") - if ambient != "" { - if v, ok := interpreterVersion(ambient); ok && constraintSatisfied(v, requires) { - return ambient, nil - } + ambient, ambientV, ambientOK := ambientPythonInterpreter() + if ambientOK && constraintSatisfied(ambientV, requires) { + return ambient, nil } // 2. Provision via uv (downloads a standalone interpreter if needed). @@ -233,11 +231,9 @@ func resolveVenvInterpreter(packagePath string) (string, error) { return interp, nil } - found := "no python3 found on PATH" - if ambient != "" { - if v, ok := interpreterVersion(ambient); ok { - found = fmt.Sprintf("found %s (Python %s)", ambient, v) - } + found := "no working python3/python found on PATH" + if ambientOK { + found = fmt.Sprintf("found %s (Python %s)", ambient, ambientV) } req := uvRequest(requires) if req == "" { @@ -254,6 +250,8 @@ func resolveVenvInterpreter(packagePath string) (string, error) { } // firstOnPath returns the first of the given commands found on PATH, or "". +// Note: existence on PATH does not prove the command runs — for Python +// candidates use ambientPythonInterpreter, which also probes execution. func firstOnPath(cmds ...string) string { for _, c := range cmds { if _, err := exec.LookPath(c); err == nil { @@ -263,6 +261,31 @@ func firstOnPath(cmds ...string) string { return "" } +// pythonCandidates are the interpreter commands probed for an ambient Python, +// in preference order. "py" is the Windows launcher, which python.org +// installers register even when "add python to PATH" is left unchecked (the +// default) — on such machines it is the only working entry. It does not exist +// on Unix, so probing it there is a no-op. +var pythonCandidates = []string{"python3", "python", "py"} + +// ambientPythonInterpreter returns the first pythonCandidates entry that is +// both on PATH and actually runs, along with its reported version. Merely +// existing on PATH is not enough: stock Windows ships Microsoft Store "app +// execution alias" stubs named python3.exe/python.exe that exec.LookPath +// resolves like real binaries but that exit 9009 without running anything. +// Probing with interpreterVersion filters those out. +func ambientPythonInterpreter() (string, pyVersion, bool) { + for _, c := range pythonCandidates { + if _, err := exec.LookPath(c); err != nil { + continue + } + if v, ok := interpreterVersion(c); ok { + return c, v, true + } + } + return "", pyVersion{}, false +} + // displayVersion returns interp's version as a string for logging, or the // interpreter path itself when the version cannot be determined. func displayVersion(interp string) string { diff --git a/control-plane/internal/packages/pyinterp_test.go b/control-plane/internal/packages/pyinterp_test.go index 800d40467..a6a615544 100644 --- a/control-plane/internal/packages/pyinterp_test.go +++ b/control-plane/internal/packages/pyinterp_test.go @@ -145,6 +145,26 @@ func TestResolveVenvInterpreter(t *testing.T) { } }) + // The stock-Windows scenario: python3 resolves on PATH but is the + // Microsoft Store app-execution-alias stub, which exits non-zero without + // running anything. Resolution must skip it and use the real python. + t.Run("skips a dead python3 stub and uses the working python", func(t *testing.T) { + bin := t.TempDir() + stubBrokenPython(t, bin, "python3") + stubPython(t, bin, "python", "3.12.5") + t.Setenv("PATH", bin) + dir := t.TempDir() + writeFile(t, dir, "pyproject.toml", "[project]\nrequires-python = \">=3.12\"\n") + + interp, err := resolveVenvInterpreter(dir) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if interp != "python" { + t.Fatalf("got %q; want python (the working interpreter after the dead python3 stub)", interp) + } + }) + t.Run("incompatible and no provisioner -> actionable error", func(t *testing.T) { bin := t.TempDir() stubPython(t, bin, "python3", "3.10.13") // too old, no uv/pyenv on PATH @@ -185,6 +205,85 @@ func TestResolveVenvInterpreter(t *testing.T) { }) } +// TestAmbientPythonInterpreter pins the candidate-probing contract: a PATH +// hit alone is not enough — the interpreter must actually run. This is what +// keeps stock-Windows Store aliases (python3.exe stubs that exit 9009) from +// being selected, and what lets the "py" launcher serve as a last resort. +func TestAmbientPythonInterpreter(t *testing.T) { + t.Run("prefers python3 when it works", func(t *testing.T) { + bin := t.TempDir() + stubPython(t, bin, "python3", "3.12.5") + stubPython(t, bin, "python", "3.10.1") + t.Setenv("PATH", bin) + interp, v, ok := ambientPythonInterpreter() + if !ok || interp != "python3" || v.String() != "3.12.5" { + t.Fatalf("got (%q,%v,%v); want (python3,3.12.5,true)", interp, v, ok) + } + }) + + t.Run("skips dead stubs in order python3 -> python -> py", func(t *testing.T) { + bin := t.TempDir() + stubBrokenPython(t, bin, "python3") + stubBrokenPython(t, bin, "python") + stubPython(t, bin, "py", "3.11.9") + t.Setenv("PATH", bin) + interp, v, ok := ambientPythonInterpreter() + if !ok || interp != "py" || v.String() != "3.11.9" { + t.Fatalf("got (%q,%v,%v); want (py,3.11.9,true)", interp, v, ok) + } + }) + + t.Run("reports not-ok when nothing on PATH runs", func(t *testing.T) { + bin := t.TempDir() + stubBrokenPython(t, bin, "python3") + t.Setenv("PATH", bin) + if interp, _, ok := ambientPythonInterpreter(); ok { + t.Fatalf("got (%q,true); want not-ok", interp) + } + }) +} + +// TestInstallPythonDependencies_LegacyPathSkipsDeadStub drives the public API +// through the no-requires-python branch: the dead python3 stub must be +// skipped and the venv built with the working python. +func TestInstallPythonDependencies_LegacyPathSkipsDeadStub(t *testing.T) { + bin := t.TempDir() + stubBrokenPython(t, bin, "python3") + stubVenvPython(t, bin, "python", "3.12.5") + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + pkg := t.TempDir() + writeFile(t, pkg, "requirements.txt", "") + + if err := InstallPythonDependencies(pkg, nil, nil); err != nil { + t.Fatalf("InstallPythonDependencies: %v", err) + } + if _, err := os.Stat(filepath.Join(pkg, "venv", "bin", "pip")); err != nil { + t.Fatalf("expected a venv built via the working interpreter: %v", err) + } +} + +// TestInstallPythonDependencies_LegacyPathNoWorkingInterpreter pins the +// actionable error when every candidate is a dead stub (a stock Windows +// machine with no Python installed). +func TestInstallPythonDependencies_LegacyPathNoWorkingInterpreter(t *testing.T) { + bin := t.TempDir() + stubBrokenPython(t, bin, "python3") + stubBrokenPython(t, bin, "python") + stubBrokenPython(t, bin, "py") + t.Setenv("PATH", bin) + pkg := t.TempDir() + writeFile(t, pkg, "requirements.txt", "") + + err := InstallPythonDependencies(pkg, nil, nil) + if err == nil { + t.Fatal("expected an error when no candidate interpreter runs") + } + msg := err.Error() + if !strings.Contains(msg, "python3, python, py") || !strings.Contains(msg, "af install") { + t.Fatalf("error should list the probed candidates and tell the user what to do: %q", msg) + } +} + func TestProvisionViaPyenv(t *testing.T) { bin := t.TempDir() root := t.TempDir() @@ -273,6 +372,17 @@ func stubPython(t *testing.T, dir, name, version string) string { return p } +// stubBrokenPython writes an executable that mimics the Microsoft Store app +// execution alias: it prints the Store hint and exits non-zero without ever +// behaving like an interpreter. +func stubBrokenPython(t *testing.T, dir, name string) { + t.Helper() + script := "#!/bin/sh\n" + + "echo 'Python was not found; run without arguments to install from the Microsoft Store' >&2\n" + + "exit 49\n" + writeExecutable(t, filepath.Join(dir, name), script) +} + // stubUv writes a fake `uv` that returns findPath for `uv python find ...`. func stubUv(t *testing.T, dir, findPath string) { t.Helper() From fe972e2ae00251a5124d779bdfbee6119dcc3f14 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Mon, 13 Jul 2026 10:38:27 -0400 Subject: [PATCH 10/35] fix(control-plane): force PYTHONUTF8=1 for spawned python agent nodes Spawned agents log through stdout/stderr redirected to a log file. On Windows, Python then encodes with the legacy ANSI code page (cp1252), which cannot represent the SDK's emoji log prefixes - every heartbeat flooded the log with UnicodeEncodeError tracebacks from the logging module. Verified live: with PYTHONUTF8=1 the same agent logs cleanly. Applied in both spawn paths (services.buildProcessConfig, which backs af run, and the legacy packages runner). An explicit PYTHONUTF8 in the caller's environment wins. UTF-8 mode is a no-op where UTF-8 is already the default, so this is safe on every platform. Co-Authored-By: Claude Fable 5 --- .../internal/core/services/agent_service.go | 1 + control-plane/internal/packages/runner.go | 16 ++++++++++++++++ control-plane/internal/packages/runner_test.go | 17 +++++++++++++++++ 3 files changed, 34 insertions(+) diff --git a/control-plane/internal/core/services/agent_service.go b/control-plane/internal/core/services/agent_service.go index 5abf9a78f..4d678ff6c 100644 --- a/control-plane/internal/core/services/agent_service.go +++ b/control-plane/internal/core/services/agent_service.go @@ -521,6 +521,7 @@ func (as *DefaultAgentService) buildProcessConfig(agentNode packages.InstalledPa env = append(env, "AGENTFIELD_STRICT_PORT=1") env = append(env, fmt.Sprintf("AGENTFIELD_SERVER=%s", serverURL)) env = append(env, fmt.Sprintf("AGENTFIELD_SERVER_URL=%s", serverURL)) + env = packages.PythonUTF8Env(env) // Resolve declared variables from the encrypted secret store. Secrets are // injected only into this child process — never written to disk in plaintext. diff --git a/control-plane/internal/packages/runner.go b/control-plane/internal/packages/runner.go index edbc32974..e51e246ca 100644 --- a/control-plane/internal/packages/runner.go +++ b/control-plane/internal/packages/runner.go @@ -141,6 +141,7 @@ func (ar *AgentNodeRunner) startAgentNodeProcess(agentNode InstalledPackage, por env = append(env, fmt.Sprintf("PORT=%d", port)) env = append(env, fmt.Sprintf("AGENTFIELD_SERVER=%s", serverURL)) env = append(env, fmt.Sprintf("AGENTFIELD_SERVER_URL=%s", serverURL)) + env = PythonUTF8Env(env) // Resolve declared variables from the encrypted secret store, prompting for // missing required ones and persisting them. Secrets are only ever injected @@ -232,6 +233,21 @@ func (ar *AgentNodeRunner) startNodeDependencies(node InstalledPackage, inProgre } } +// PythonUTF8Env appends PYTHONUTF8=1 unless the environment already pins a +// value. Spawned agents log through a redirected stdout/stderr; on Windows +// Python then encodes with the legacy ANSI code page (e.g. cp1252), which +// cannot represent the SDK's emoji log prefixes and floods the log file with +// UnicodeEncodeError tracebacks. UTF-8 mode is a no-op where UTF-8 is already +// the default, so this is safe to set on every platform. +func PythonUTF8Env(env []string) []string { + for _, kv := range env { + if strings.HasPrefix(kv, "PYTHONUTF8=") { + return env + } + } + return append(env, "PYTHONUTF8=1") +} + // venvPython returns the venv python interpreter path, or "" if no venv exists. func venvPython(venvPath string) string { if p := filepath.Join(venvPath, "bin", "python"); fileExists(p) { diff --git a/control-plane/internal/packages/runner_test.go b/control-plane/internal/packages/runner_test.go index 7a95bfa2e..81fb8c3bd 100644 --- a/control-plane/internal/packages/runner_test.go +++ b/control-plane/internal/packages/runner_test.go @@ -79,6 +79,23 @@ func TestAgentNodeRunnerPortEnvAndRegistry(t *testing.T) { } } +func TestPythonUTF8Env(t *testing.T) { + t.Run("appends PYTHONUTF8=1 when unset", func(t *testing.T) { + env := PythonUTF8Env([]string{"PORT=8001", "PATH=/usr/bin"}) + if env[len(env)-1] != "PYTHONUTF8=1" { + t.Fatalf("expected PYTHONUTF8=1 appended, got %v", env) + } + }) + + t.Run("respects an explicit caller value", func(t *testing.T) { + in := []string{"PYTHONUTF8=0", "PORT=8001"} + env := PythonUTF8Env(in) + if len(env) != len(in) { + t.Fatalf("caller's PYTHONUTF8 must win, got %v", env) + } + }) +} + func TestAgentNodeRunnerWaitDisplayAndStartProcess(t *testing.T) { // Cannot use t.Parallel() — this test modifies PATH via t.Setenv, // which is process-global and unsafe to share with parallel tests. From 6bc5fc456d48ef974f1b3defa670dfb207739192 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Mon, 13 Jul 2026 10:38:27 -0400 Subject: [PATCH 11/35] test(control-plane): make the services package compile under GOOS=windows dev_service.go is build-tagged !windows (Windows gets a stub service), but two pieces of its test surface were not: - mockFileSystemAdapter lived in dev_service_test.go (!windows) while untagged test files (package_service_test.go, coverage_additional_ test.go) use it. Moved to a new untagged mocks_fs_test.go. - Three tests in the untagged coverage_additional_test.go exercised Unix-only DefaultDevService methods (loadDevEnvFile, startDevProcess, port helpers) that the Windows stub does not define. Moved into the !windows-tagged dev_service_test.go. Before this, go vet / go test of internal/core/services failed to compile on Windows. Also carries the one-line stub update from the interpreter-probe change (the fake python in coverage_additional_test.go now answers the -c version query); no other test logic changed. Co-Authored-By: Claude Fable 5 --- .../core/services/coverage_additional_test.go | 61 +-------- .../core/services/dev_service_test.go | 129 ++++++++++-------- .../internal/core/services/mocks_fs_test.go | 68 +++++++++ 3 files changed, 138 insertions(+), 120 deletions(-) create mode 100644 control-plane/internal/core/services/mocks_fs_test.go diff --git a/control-plane/internal/core/services/coverage_additional_test.go b/control-plane/internal/core/services/coverage_additional_test.go index da0bb2d20..1c41b5973 100644 --- a/control-plane/internal/core/services/coverage_additional_test.go +++ b/control-plane/internal/core/services/coverage_additional_test.go @@ -233,6 +233,7 @@ func TestPackageServiceHelpers(t *testing.T) { require.NoError(t, os.MkdirAll(fakeBin, 0o755)) python3Path := filepath.Join(fakeBin, "python3") python3Script := fmt.Sprintf(`#!/bin/sh +if [ "$1" = "-c" ]; then echo "3.12.0"; exit 0; fi venv_path="$3" mkdir -p "$venv_path/bin" cat > "$venv_path/bin/pip" <<'EOF' @@ -351,66 +352,6 @@ exit 0 }) } -func TestDevServiceLoadDevEnvFile(t *testing.T) { - dir := t.TempDir() - require.NoError(t, os.WriteFile(filepath.Join(dir, ".env"), []byte(strings.Join([]string{ - "# comment", - "FOO=bar", - `QUOTED="hello world"`, - "SINGLE='quoted'", - "INVALID", - }, "\n")), 0o644)) - - service := &DefaultDevService{} - envVars, err := service.loadDevEnvFile(dir) - require.NoError(t, err) - assert.Equal(t, map[string]string{ - "FOO": "bar", - "QUOTED": "hello world", - "SINGLE": "quoted", - }, envVars) -} - -func TestDevServiceStartDevProcess(t *testing.T) { - dir := t.TempDir() - venvBin := filepath.Join(dir, "venv", "bin") - require.NoError(t, os.MkdirAll(venvBin, 0o755)) - outputPath := filepath.Join(dir, "env-output.txt") - script := fmt.Sprintf("#!/bin/sh\nprintf '%%s\\n' \"$PORT\" > %s\nprintf '%%s\\n' \"$AGENTFIELD_SERVER_URL\" >> %s\nprintf '%%s\\n' \"$AGENTFIELD_DEV_MODE\" >> %s\nprintf '%%s\\n' \"$TOKEN\" >> %s\n", outputPath, outputPath, outputPath, outputPath) - require.NoError(t, os.WriteFile(filepath.Join(venvBin, "python"), []byte(script), 0o755)) - require.NoError(t, os.WriteFile(filepath.Join(dir, ".env"), []byte("TOKEN=dev-secret\n"), 0o644)) - require.NoError(t, os.WriteFile(filepath.Join(dir, "main.py"), []byte("print('ignored')\n"), 0o644)) - - service := &DefaultDevService{} - cmd, err := service.startDevProcess(dir, 8124, domain.DevOptions{Verbose: true}) - require.NoError(t, err) - require.NoError(t, cmd.Wait()) - - data, err := os.ReadFile(outputPath) - require.NoError(t, err) - assert.Equal(t, "8124\nhttp://localhost:8080\ntrue\ndev-secret\n", string(data)) -} - -func TestDevServicePortHelpersWithoutManager(t *testing.T) { - service := &DefaultDevService{} - - port, err := service.getFreePort() - require.NoError(t, err) - assert.True(t, port >= 8001 && port <= 8999) - - busyListener, err := net.Listen("tcp", "127.0.0.1:0") - require.NoError(t, err) - defer busyListener.Close() - busyPort := busyListener.Addr().(*net.TCPAddr).Port - assert.False(t, service.isPortAvailable(busyPort)) - - freeListener, err := net.Listen("tcp", "127.0.0.1:0") - require.NoError(t, err) - freePort := freeListener.Addr().(*net.TCPAddr).Port - require.NoError(t, freeListener.Close()) - assert.True(t, service.isPortAvailable(freePort)) -} - func TestAgentServiceRunStopAndStatusWithLiveProcess(t *testing.T) { t.Run("run agent success", func(t *testing.T) { home := t.TempDir() diff --git a/control-plane/internal/core/services/dev_service_test.go b/control-plane/internal/core/services/dev_service_test.go index 1d2d996f8..6991bd835 100644 --- a/control-plane/internal/core/services/dev_service_test.go +++ b/control-plane/internal/core/services/dev_service_test.go @@ -5,8 +5,11 @@ package services import ( "context" "errors" + "fmt" + "net" "os" "path/filepath" + "strings" "testing" "time" @@ -15,66 +18,6 @@ import ( "github.com/stretchr/testify/require" ) -// Mock FileSystemAdapter for testing -type mockFileSystemAdapter struct { - readFileFunc func(string) ([]byte, error) - writeFileFunc func(string, []byte) error - existsFunc func(string) bool - createDirFunc func(string) error - listDirectoryFunc func(string) ([]string, error) - files map[string][]byte - directories map[string]bool -} - -func newMockFileSystemAdapter() *mockFileSystemAdapter { - return &mockFileSystemAdapter{ - files: make(map[string][]byte), - directories: make(map[string]bool), - } -} - -func (m *mockFileSystemAdapter) ReadFile(path string) ([]byte, error) { - if m.readFileFunc != nil { - return m.readFileFunc(path) - } - if data, ok := m.files[path]; ok { - return data, nil - } - return nil, errors.New("file not found") -} - -func (m *mockFileSystemAdapter) WriteFile(path string, data []byte) error { - if m.writeFileFunc != nil { - return m.writeFileFunc(path, data) - } - m.files[path] = data - return nil -} - -func (m *mockFileSystemAdapter) Exists(path string) bool { - if m.existsFunc != nil { - return m.existsFunc(path) - } - _, fileExists := m.files[path] - _, dirExists := m.directories[path] - return fileExists || dirExists -} - -func (m *mockFileSystemAdapter) CreateDirectory(path string) error { - if m.createDirFunc != nil { - return m.createDirFunc(path) - } - m.directories[path] = true - return nil -} - -func (m *mockFileSystemAdapter) ListDirectory(path string) ([]string, error) { - if m.listDirectoryFunc != nil { - return m.listDirectoryFunc(path) - } - return []string{}, nil -} - func TestNewDevService(t *testing.T) { processManager := newMockProcessManager() portManager := newMockPortManager() @@ -365,3 +308,69 @@ KEY=value assert.Equal(t, "value", envVars["KEY"]) assert.NotContains(t, envVars, "INVALID_LINE_WITHOUT_EQUALS") } + +// The three tests below were moved from coverage_additional_test.go: they +// exercise Unix-only DefaultDevService methods (loadDevEnvFile, +// startDevProcess, port helpers) that the Windows stub does not define, so +// they must live in this !windows-tagged file for the services package to +// compile under GOOS=windows. + +func TestDevServiceLoadDevEnvFile(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, ".env"), []byte(strings.Join([]string{ + "# comment", + "FOO=bar", + `QUOTED="hello world"`, + "SINGLE='quoted'", + "INVALID", + }, "\n")), 0o644)) + + service := &DefaultDevService{} + envVars, err := service.loadDevEnvFile(dir) + require.NoError(t, err) + assert.Equal(t, map[string]string{ + "FOO": "bar", + "QUOTED": "hello world", + "SINGLE": "quoted", + }, envVars) +} + +func TestDevServiceStartDevProcess(t *testing.T) { + dir := t.TempDir() + venvBin := filepath.Join(dir, "venv", "bin") + require.NoError(t, os.MkdirAll(venvBin, 0o755)) + outputPath := filepath.Join(dir, "env-output.txt") + script := fmt.Sprintf("#!/bin/sh\nprintf '%%s\\n' \"$PORT\" > %s\nprintf '%%s\\n' \"$AGENTFIELD_SERVER_URL\" >> %s\nprintf '%%s\\n' \"$AGENTFIELD_DEV_MODE\" >> %s\nprintf '%%s\\n' \"$TOKEN\" >> %s\n", outputPath, outputPath, outputPath, outputPath) + require.NoError(t, os.WriteFile(filepath.Join(venvBin, "python"), []byte(script), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, ".env"), []byte("TOKEN=dev-secret\n"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "main.py"), []byte("print('ignored')\n"), 0o644)) + + service := &DefaultDevService{} + cmd, err := service.startDevProcess(dir, 8124, domain.DevOptions{Verbose: true}) + require.NoError(t, err) + require.NoError(t, cmd.Wait()) + + data, err := os.ReadFile(outputPath) + require.NoError(t, err) + assert.Equal(t, "8124\nhttp://localhost:8080\ntrue\ndev-secret\n", string(data)) +} + +func TestDevServicePortHelpersWithoutManager(t *testing.T) { + service := &DefaultDevService{} + + port, err := service.getFreePort() + require.NoError(t, err) + assert.True(t, port >= 8001 && port <= 8999) + + busyListener, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + defer busyListener.Close() + busyPort := busyListener.Addr().(*net.TCPAddr).Port + assert.False(t, service.isPortAvailable(busyPort)) + + freeListener, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + freePort := freeListener.Addr().(*net.TCPAddr).Port + require.NoError(t, freeListener.Close()) + assert.True(t, service.isPortAvailable(freePort)) +} diff --git a/control-plane/internal/core/services/mocks_fs_test.go b/control-plane/internal/core/services/mocks_fs_test.go new file mode 100644 index 000000000..917ec0aee --- /dev/null +++ b/control-plane/internal/core/services/mocks_fs_test.go @@ -0,0 +1,68 @@ +package services + +// mockFileSystemAdapter is shared by test files across the package. It lives +// in its own untagged file (rather than dev_service_test.go, which is +// !windows) so that the platform-neutral tests that use it still compile +// under GOOS=windows. + +import "errors" + +// Mock FileSystemAdapter for testing +type mockFileSystemAdapter struct { + readFileFunc func(string) ([]byte, error) + writeFileFunc func(string, []byte) error + existsFunc func(string) bool + createDirFunc func(string) error + listDirectoryFunc func(string) ([]string, error) + files map[string][]byte + directories map[string]bool +} + +func newMockFileSystemAdapter() *mockFileSystemAdapter { + return &mockFileSystemAdapter{ + files: make(map[string][]byte), + directories: make(map[string]bool), + } +} + +func (m *mockFileSystemAdapter) ReadFile(path string) ([]byte, error) { + if m.readFileFunc != nil { + return m.readFileFunc(path) + } + if data, ok := m.files[path]; ok { + return data, nil + } + return nil, errors.New("file not found") +} + +func (m *mockFileSystemAdapter) WriteFile(path string, data []byte) error { + if m.writeFileFunc != nil { + return m.writeFileFunc(path, data) + } + m.files[path] = data + return nil +} + +func (m *mockFileSystemAdapter) Exists(path string) bool { + if m.existsFunc != nil { + return m.existsFunc(path) + } + _, fileExists := m.files[path] + _, dirExists := m.directories[path] + return fileExists || dirExists +} + +func (m *mockFileSystemAdapter) CreateDirectory(path string) error { + if m.createDirFunc != nil { + return m.createDirFunc(path) + } + m.directories[path] = true + return nil +} + +func (m *mockFileSystemAdapter) ListDirectory(path string) ([]string, error) { + if m.listDirectoryFunc != nil { + return m.listDirectoryFunc(path) + } + return []string{}, nil +} From 097d511ecca01a870727109742821712fadfa8b7 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Mon, 13 Jul 2026 10:38:27 -0400 Subject: [PATCH 12/35] fix(desktop): don't report an unrelated service on 8080 as a running control plane checkControlPlane treated any HTTP 200 from {baseUrl}/health as "healthy", so anything squatting on the popular default port lit the dashboard green. Found live on Windows: an unrelated dev server answering {"status":"alive"} on /health showed as a running control plane. The probe now recognizes an AgentField control plane by its health payload shape (status: healthy|unhealthy, per routes_core.go). Anything else reachable on the port reports recognized: false with an explanatory error, renders as a yellow "Another service is on this port" state, and is excluded from the nodes cross-check so a foreign /api/v1/nodes response cannot corrupt agent badges. Co-Authored-By: Claude Fable 5 --- desktop/src/main/agentfield.test.ts | 54 ++++++++++++++++++- desktop/src/main/agentfield.ts | 29 +++++++--- .../src/components/ControlPlaneCard.tsx | 11 ++-- desktop/src/shared/types.ts | 10 +++- 4 files changed, 89 insertions(+), 15 deletions(-) diff --git a/desktop/src/main/agentfield.test.ts b/desktop/src/main/agentfield.test.ts index 416ac5a83..0eb823700 100644 --- a/desktop/src/main/agentfield.test.ts +++ b/desktop/src/main/agentfield.test.ts @@ -170,7 +170,7 @@ describe('checkControlPlane', () => { } const fetchImpl: FetchLike = async () => jsonResponse(body, 200) const result = await checkControlPlane('http://localhost:8080', fetchImpl) - expect(result).toEqual({ reachable: true, healthy: true, raw: body }) + expect(result).toEqual({ reachable: true, recognized: true, healthy: true, raw: body }) }) // Contract: 503 with an unhealthy body still means reachable, just not healthy. @@ -179,17 +179,46 @@ describe('checkControlPlane', () => { const fetchImpl: FetchLike = async () => jsonResponse(body, 503) const result = await checkControlPlane('http://localhost:8080', fetchImpl) expect(result.reachable).toBe(true) + expect(result.recognized).toBe(true) expect(result.healthy).toBe(false) expect(result.raw).toEqual(body) }) + // Contract: a 200 from something that is NOT an AgentField control plane + // (default port 8080 is popular) must not read as healthy. Found live on + // Windows: an unrelated dev server answering {"status":"alive"} on /health + // lit the dashboard green. + it('rejects a foreign 200 /health payload as unrecognized', async () => { + const body = { status: 'alive', uptime_s: 3714 } + const fetchImpl: FetchLike = async () => jsonResponse(body, 200) + const result = await checkControlPlane('http://localhost:8080', fetchImpl) + expect(result.reachable).toBe(true) + expect(result.recognized).toBe(false) + expect(result.healthy).toBe(false) + expect(result.error).toContain('does not look like an AgentField control plane') + }) + + it('rejects a non-JSON 200 response as unrecognized', async () => { + const fetchImpl: FetchLike = async () => + new Response('hi', { status: 200, headers: { 'content-type': 'text/html' } }) + const result = await checkControlPlane('http://localhost:8080', fetchImpl) + expect(result.reachable).toBe(true) + expect(result.recognized).toBe(false) + expect(result.healthy).toBe(false) + }) + // Contract: network error / timeout -> not reachable, error captured. it('maps a rejected fetch to unreachable with an error message', async () => { const fetchImpl: FetchLike = async () => { throw new TypeError('fetch failed') } const result = await checkControlPlane('http://localhost:8080', fetchImpl) - expect(result).toEqual({ reachable: false, healthy: false, error: 'fetch failed' }) + expect(result).toEqual({ + reachable: false, + recognized: false, + healthy: false, + error: 'fetch failed' + }) }) it('probes {baseUrl}/health', async () => { @@ -287,6 +316,27 @@ describe('getSnapshot', () => { expect(badges).toEqual({ 'pr-af': 'running', 'swe-af': 'stopped' }) }) + it('does not consult the nodes view of an unrecognized service on the port', async () => { + const home = await makeHome(REGISTRY_FIXTURE) + const requested: string[] = [] + const fetchImpl: FetchLike = async (input) => { + requested.push(String(input)) + // A foreign service that would answer BOTH endpoints with junk. + return jsonResponse({ status: 'alive', nodes: [] }) + } + + const snapshot = await getSnapshot({ homeDir: home, fetchImpl }) + + expect(snapshot.controlPlane.recognized).toBe(false) + // Badges fall back to registry statuses — the foreign 200 on /api/v1/nodes + // must not flip a running agent to unknown. + const badges = Object.fromEntries( + snapshot.registry.agents.map((a) => [a.name, a.badge]) + ) + expect(badges).toEqual({ 'pr-af': 'running', 'swe-af': 'stopped' }) + expect(requested.some((url) => url.endsWith('/api/v1/nodes'))).toBe(false) + }) + it('reports an unreachable control plane and an absent registry gracefully', async () => { const home = await makeHome() const missing = path.join(home, 'nope') diff --git a/desktop/src/main/agentfield.ts b/desktop/src/main/agentfield.ts index 3d68108d4..f3a4feec0 100644 --- a/desktop/src/main/agentfield.ts +++ b/desktop/src/main/agentfield.ts @@ -43,10 +43,14 @@ function errorMessage(err: unknown): string { /** * Probe GET {baseUrl}/health. - * - 200 {"status":"healthy",...} -> { reachable: true, healthy: true } - * - 503 {"status":"unhealthy",...} -> { reachable: true, healthy: false } + * - 200 {"status":"healthy",...} -> { reachable: true, recognized: true, healthy: true } + * - 503 {"status":"unhealthy",...} -> { reachable: true, recognized: true, healthy: false } * (an HTTP response — even 503 — still means the control plane is reachable) - * - network error / timeout (3s) -> { reachable: false, healthy: false, error } + * - any response whose body is not an AgentField health payload + * -> { reachable: true, recognized: false, healthy: false, error } + * (default port 8080 is popular — an unrelated dev server answering 200 on + * /health must not light up the dashboard as a running control plane) + * - network error / timeout (3s) -> { reachable: false, recognized: false, healthy: false, error } */ export async function checkControlPlane( baseUrl: string = DEFAULT_BASE_URL, @@ -62,9 +66,20 @@ export async function checkControlPlane( } catch { raw = undefined } - return { reachable: true, healthy: res.ok, raw } + const status = isRecord(raw) && typeof raw.status === 'string' ? raw.status : undefined + const recognized = status === 'healthy' || status === 'unhealthy' + if (!recognized) { + return { + reachable: true, + recognized: false, + healthy: false, + raw, + error: `A service answered ${baseUrl}/health but does not look like an AgentField control plane — another app may be using the port.` + } + } + return { reachable: true, recognized: true, healthy: res.ok && status === 'healthy', raw } } catch (err) { - return { reachable: false, healthy: false, error: errorMessage(err) } + return { reachable: false, recognized: false, healthy: false, error: errorMessage(err) } } } @@ -197,7 +212,9 @@ export async function getSnapshot(options: SnapshotOptions = {}): Promise