init project
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [0.6.0] - 2026-07-31
|
||||
|
||||
### Added
|
||||
- Post-v1 **v1.5** polish:
|
||||
- Laravel Sail-aware `devCommand`: uses `vendor/bin/sail up` (or `docker compose up`) when `laravel/sail` + compose file are present.
|
||||
- `git_diff_summarize` (numstat file/totals summary; read-only).
|
||||
- Richer `lighthouse_audit` output: category titles/descriptions, cross-category opportunities, `failingByCategory`.
|
||||
- `visual-diff-ci` prompt for baseline → visual-diff CI workflow.
|
||||
- `generate_django_resource`, `generate_rails_resource`, `generate_symfony_resource` file-based stubs.
|
||||
|
||||
## [0.5.0] - 2026-07-31
|
||||
|
||||
### Added
|
||||
- Post-v1 **v1.4** new adapters:
|
||||
- `FrameworkKind` values `vue`, `nuxt`, `django`, `rails`, `symfony`.
|
||||
- Vue and Nuxt `FrameworkAdapter`s (install/dev/build/test/lint via package scripts).
|
||||
- Django, Rails, and Symfony detection stubs with install/dev/test commands.
|
||||
- Detection priority: Nuxt > Vue > React (frontend); Laravel → CodeIgniter → Symfony → Django → Rails (backend).
|
||||
- `generate_component` writes Vue SFCs (`.vue`) for Vue/Nuxt projects.
|
||||
- Allow-listed binaries for Python/Ruby tooling (`python`, `pip`, `poetry`, `uv`, `ruby`, `bundle`, `rails`, `gem`).
|
||||
|
||||
## [0.4.0] - 2026-07-31
|
||||
|
||||
### Added
|
||||
- Post-v1 **v1.3** platform tools:
|
||||
- `detect_workspace` + monorepo package scanning; `package` scoping on workflow tools; `detect_framework` `includeWorkspace` / `package`.
|
||||
- `generate_dockerfile`, `generate_compose`, `generate_sail` (`confirm: true`).
|
||||
- `docker_push_image` (`confirm: true`); `deploy_project` optional `registryPush`.
|
||||
|
||||
## [0.3.0] - 2026-07-31
|
||||
|
||||
### Added
|
||||
- Post-v1 **v1.2** framework depth:
|
||||
- Next.js: `ReactAdapter.describe()` now includes `router` / `rsc`; `generate_next_page`, `generate_next_layout`; `generate_component` `clientComponent`; RSC heuristics in `analyze_react_component`.
|
||||
- Laravel: `generate_eloquent_relation`, `generate_filament_resource`, `generate_nova_resource`, `laravel_queue_status`; artisan allow-list adds `queue:batches`, `queue:clear`, `reverb:start`.
|
||||
- CodeIgniter: `generate_codeigniter_shield_auth`, `spark_discover`; `generate_codeigniter_resource` gains `apiVersion` / `resourceController`.
|
||||
|
||||
## [0.2.0] - 2026-07-31
|
||||
|
||||
### Added
|
||||
- Post-v1 **v1.1** agent productivity tools: `git_status`, `git_diff`, `git_log`, `git_branch`, `git_commit` (writes require `confirm: true`).
|
||||
- `browser_screenshot_baseline` and `browser_visual_diff` (pixelmatch) with baselines under `.web-dev-mcp/baselines/`.
|
||||
- `lighthouse_audit` for headless Lighthouse category scores and top opportunities.
|
||||
- Expanded `PLAN.md` §9 into full post-v1 catalogs and milestones v1.1–v1.5.
|
||||
|
||||
## [0.1.0] - 2026-07-31
|
||||
|
||||
### Added
|
||||
- Framework-aware workflow tools: `detect_framework`, `run_install`, `run_script`, `start_dev_server`, `stop_process`, `get_process_logs`, `list_processes`, `run_lint`, `run_tests`, `build_project`, `deploy_project`.
|
||||
- Scaffolding tools: `scaffold_project`, `generate_component`, `generate_api_route`, `add_dependency`.
|
||||
- First-class Laravel tools: `artisan`, `generate_laravel_resource`, `composer_require`, `composer_remove`, `run_phpunit`, `run_pest`, `laravel_tinker_eval`.
|
||||
- First-class CodeIgniter 4 tools: `spark`, `generate_codeigniter_resource`, `run_codeigniter_tests`.
|
||||
- First-class React tools: `generate_react_hook`, `add_react_route`, `run_react_tests`, `analyze_react_component`.
|
||||
- Browser automation tools backed by Playwright: `browser_navigate`, `browser_screenshot`, `browser_get_console_logs`, `browser_inspect_dom`, `browser_click`, `browser_fill`, `browser_eval`.
|
||||
- Docker tools: `docker_build_image`, `docker_run_container`, `docker_list_containers`, `docker_get_container_logs`, `docker_exec`, `docker_stop_container`, `docker_remove_container`, `docker_compose_up`, `docker_compose_down`.
|
||||
- Docs and package lookup tools: `search_package_docs`, `search_mdn`, `search_laravel_docs`, `search_codeigniter_docs`, `get_package_info`.
|
||||
- Resources: `workspace://project.json`, `workspace://logs/{processId}`, `workspace://containers/{containerId}/logs`.
|
||||
- Prompts: `new-feature`, `new-laravel-resource`, `new-codeigniter-resource`, `new-react-component`.
|
||||
- Both stdio and Streamable HTTP transports, with bearer-token auth for HTTP.
|
||||
- Production Dockerfile for containerized deployment.
|
||||
- Vitest test suite covering sandbox, process management, framework detection, allow-lists, confirm-flag enforcement, and Docker guardrails.
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
FROM node:24-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
|
||||
# Optional: provide a custom CA certificate for npm TLS (e.g. behind a corporate proxy).
|
||||
# Build with: docker build --secret id=ca-cert,src=/path/to/ca-bundle.pem -t web-dev-mcp .
|
||||
RUN --mount=type=secret,id=ca-cert,dst=/tmp/ca-bundle.pem \
|
||||
if [ -f /tmp/ca-bundle.pem ]; then export NODE_EXTRA_CA_CERTS=/tmp/ca-bundle.pem; fi && \
|
||||
npm ci
|
||||
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM node:24-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
|
||||
RUN --mount=type=secret,id=ca-cert,dst=/tmp/ca-bundle.pem \
|
||||
if [ -f /tmp/ca-bundle.pem ]; then export NODE_EXTRA_CA_CERTS=/tmp/ca-bundle.pem; fi && \
|
||||
npm ci --omit=dev && npm cache clean --force
|
||||
|
||||
COPY --from=builder /app/dist ./dist
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV WORKSPACE_ROOT=/workspace
|
||||
|
||||
EXPOSE 3939
|
||||
|
||||
CMD ["node", "dist/transports/http.js"]
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 web-dev-mcp contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,291 @@
|
||||
# Web Dev MCP Server — Implementation Plan
|
||||
|
||||
## 1. Goal
|
||||
|
||||
Build a general-purpose **Model Context Protocol (MCP) server** that gives any MCP-compatible AI agent (Cursor, Claude Desktop, etc.) a full toolkit for web development: scaffolding code, running the dev workflow (install/build/test/lint/deploy), running/managing Docker containers for containerized dev & testing, inspecting a live app in a real browser, and looking up framework/library/API docs. The generic tool layer stays framework-agnostic (Vue, Svelte, plain HTML/CSS/JS, etc.), with **first-class, dedicated support for Laravel and CodeIgniter (PHP backends) and React (JS/TS frontend)** — including combined setups (e.g. Laravel/CodeIgniter API backend + React frontend).
|
||||
|
||||
- **Framework detection & adapters:** the server auto-detects the project type (`composer.json` + `artisan` → Laravel; `spark` CLI + `app/Config` → CodeIgniter 4; `package.json` with `react` dependency → React/Vite/CRA/Next) and exposes both generic tools (dispatched through the right adapter) and framework-specific tools (`artisan_*`/`spark_*`, React component/hook generators).
|
||||
|
||||
- **Language/runtime:** Node.js + TypeScript
|
||||
- **Transports:** stdio (local, for Cursor/Claude Desktop) **and** HTTP/SSE (Streamable HTTP) for remote/shared use
|
||||
- **SDK:** `@modelcontextprotocol/sdk` (official TypeScript SDK)
|
||||
|
||||
## 2. High-Level Architecture
|
||||
|
||||
```
|
||||
web-dev-mcp/
|
||||
├── src/
|
||||
│ ├── server.ts # MCP server bootstrap, tool/resource registration
|
||||
│ ├── transports/
|
||||
│ │ ├── stdio.ts # stdio transport entrypoint (bin)
|
||||
│ │ └── http.ts # Streamable HTTP/SSE transport (Express)
|
||||
│ ├── tools/
|
||||
│ │ ├── scaffold/ # project & code generation tools
|
||||
│ │ ├── workflow/ # install/build/test/lint/run/deploy
|
||||
│ │ ├── browser/ # Playwright-backed inspection tools
|
||||
│ │ ├── docker/ # Docker image/container tools
|
||||
│ │ ├── laravel/ # artisan/composer-backed Laravel tools
|
||||
│ │ ├── codeigniter/ # spark/composer-backed CodeIgniter tools
|
||||
│ │ ├── react/ # React component/hook/route generators
|
||||
│ │ ├── docs/ # docs/package lookup tools
|
||||
│ │ └── fs/ # safe file read/write/search helpers
|
||||
│ ├── resources/ # MCP resources (e.g. exposing project files, logs)
|
||||
│ ├── prompts/ # reusable prompt templates (e.g. "new component")
|
||||
│ ├── lib/
|
||||
│ │ ├── processManager.ts # tracks long-running child processes (dev servers)
|
||||
│ │ ├── containerManager.ts # tracks Docker containers started by the server
|
||||
│ │ ├── sandbox.ts # path/command allow-listing & workspace confinement
|
||||
│ │ ├── frameworks/
|
||||
│ │ │ ├── types.ts # FrameworkAdapter interface
|
||||
│ │ │ ├── detect.ts # inspects the workspace, returns detected framework(s)
|
||||
│ │ │ ├── laravel.ts # Laravel adapter (composer, artisan, phpunit/pest)
|
||||
│ │ │ ├── codeigniter.ts # CodeIgniter adapter (composer, spark CLI, phpunit)
|
||||
│ │ │ ├── react.ts # React adapter (vite/CRA/Next-aware, npm/pnpm/yarn)
|
||||
│ │ │ └── generic.ts # fallback adapter for unrecognized projects
|
||||
│ │ └── logger.ts
|
||||
│ └── config.ts # env vars, workspace root, allow-lists
|
||||
├── test/
|
||||
├── package.json
|
||||
├── tsconfig.json
|
||||
├── Dockerfile
|
||||
└── README.md
|
||||
```
|
||||
|
||||
Core design principles:
|
||||
|
||||
- **Workspace-scoped**: every tool operates relative to a configured project root; no path may escape it (prevent path traversal).
|
||||
- **Stateless tool calls, stateful process registry**: dev servers/watchers started by a tool persist in an in-memory `processManager` keyed by an id returned to the agent, so the agent can `logs`/`stop` them later.
|
||||
- **Composable, small tools** rather than a few "do everything" mega-tools, so the agent can chain them and results stay predictable.
|
||||
- **Adapter pattern for frameworks**: generic tools (`run_install`, `start_dev_server`, `run_tests`, `run_lint`, `build_project`) delegate to a `FrameworkAdapter` resolved via `detect.ts`. `LaravelAdapter`, `CodeIgniterAdapter`, and `ReactAdapter` are first-class, purpose-built implementations; anything else falls back to `GenericAdapter` (plain npm-script/Makefile heuristics). A project can have **two active adapters at once** (e.g. Laravel or CodeIgniter API + separate React SPA, or Laravel + Inertia/React in `resources/js`), in which case tools accept a `target: "backend" | "frontend"` argument to disambiguate. Laravel and CodeIgniter share a common `PhpFrameworkAdapter` base (Composer install/require, PHPUnit test running) and each override the CLI-specific bits (`artisan` vs `spark`).
|
||||
|
||||
## 3. Tool Catalog
|
||||
|
||||
### 3.1 Scaffolding & Code Generation
|
||||
- `scaffold_project` — create a new project from a template: `laravel` (`composer create-project laravel/laravel`), `codeigniter` (`composer create-project codeigniter4/appstarter`), `laravel-react`/`codeigniter-react` (PHP API backend + a separate `vite-react` frontend in a sibling folder, or Laravel + Inertia/React starter kit), `vite-react`, `vite-vue`, `next`, `express-api`, `static-html`, etc.
|
||||
- `generate_component` — generic UI component generator for non-React frameworks (Vue SFC, Svelte, etc.); for React projects this delegates to `generate_react_component` (§3.7) so React gets its richer, convention-aware generator.
|
||||
- `generate_api_route` — scaffold a backend route/handler for the detected framework (Laravel route + controller, CodeIgniter route + controller via `spark make:controller`, Next.js API route, Express router, Fastify, etc.).
|
||||
- `add_dependency` — add/remove a dependency, auto-detecting npm/pnpm/yarn (`package.json`) vs. Composer (`composer.json`) from lockfiles/manifest presence; for combined projects takes an explicit `ecosystem: "npm" | "composer"` argument.
|
||||
|
||||
### 3.2 Dev Workflow
|
||||
- `detect_framework` — inspect the workspace and return the detected adapter(s) (`laravel`, `codeigniter`, `react`, a combination like `laravel+react`, or `generic`) plus key metadata (PHP/Composer version, Node/package-manager, Vite/CRA/Next/Inertia presence).
|
||||
- `run_install` — install dependencies for the detected project(s): `composer install` for Laravel/CodeIgniter, `npm`/`pnpm`/`yarn`/`bun install` for React; runs **both** when a project has `composer.json` and `package.json` side by side.
|
||||
- `run_script` — run a `package.json` script (e.g. `dev`, `build`, `test`) as a tracked background or foreground process.
|
||||
- `start_dev_server` — start the dev server, wait for the "ready" URL/port, return the URL + process id. For Laravel: `php artisan serve` (or Laravel Sail via `docker_compose_up`). For CodeIgniter: `php spark serve`. For React: the framework's own dev server (Vite/CRA/Next).
|
||||
- `stop_process` / `get_process_logs` — manage previously started long-running processes.
|
||||
- `run_lint` / `run_format` — run ESLint/Prettier for React, Laravel Pint/PHP-CS-Fixer for Laravel, or PHP_CodeSniffer/PHP-CS-Fixer for CodeIgniter (whichever is configured), and return structured diagnostics.
|
||||
- `run_tests` — run the test runner: Vitest/Jest/Playwright for React, PHPUnit/Pest for Laravel, PHPUnit (with CIUnit test case support) for CodeIgniter; returns parsed pass/fail results either way, with a `target` argument for combined projects.
|
||||
- `build_project` — production build, surfacing build errors/warnings (JS bundle build for React; `composer install --no-dev --optimize-autoloader` plus `artisan config:cache`/`route:cache`/`view:cache` for Laravel, or CodeIgniter's equivalent config/route caching where applicable).
|
||||
- `deploy_project` — optional pluggable deploy step (Vercel/Netlify CLI for React, Forge/Envoyer/custom script for Laravel, or a generic PHP-hosting/rsync script for CodeIgniter) behind explicit opt-in config.
|
||||
|
||||
### 3.3 Browser Preview & Inspection (Playwright-backed)
|
||||
- `browser_navigate` — open a URL (e.g. the local dev server) in a headless/headed browser session.
|
||||
- `browser_screenshot` — capture a screenshot (full page or element selector) for visual verification.
|
||||
- `browser_get_console_logs` — return console/network errors captured since navigation.
|
||||
- `browser_inspect_dom` — query the DOM (selector → outerHTML/text/computed styles) for a given page.
|
||||
- `browser_click` / `browser_fill` / `browser_eval` — minimal interaction primitives for smoke-testing UI flows.
|
||||
|
||||
### 3.4 Docker (Container Management)
|
||||
- `docker_build_image` — build an image from a Dockerfile in the workspace (`docker build`), streaming build output back to the agent.
|
||||
- `docker_run_container` — run a container from an image (a locally built one, or an allow-listed base image like `node`, `nginx`, `postgres`), with port mapping, env vars, and volume mounts restricted to `WORKSPACE_ROOT`; returns a container id.
|
||||
- `docker_list_containers` — list containers started by this server (id, image, status, ports).
|
||||
- `docker_get_container_logs` — tail logs from a running/stopped container.
|
||||
- `docker_exec` — run a one-off command inside a running container (e.g. `npm test` inside the container) and return stdout/stderr.
|
||||
- `docker_stop_container` / `docker_remove_container` — stop/remove a previously started container.
|
||||
- `docker_compose_up` / `docker_compose_down` — optional: bring up/down a `docker-compose.yml`-defined stack for multi-service local dev (app + DB + cache, etc.).
|
||||
|
||||
Use case: let the agent validate that the app builds and runs correctly in a clean containerized environment (not just the host machine), or spin up dependent services (Postgres, Redis) for local development/testing. Also doubles as the runtime for **Laravel Sail** — `docker_compose_up` on a Sail-generated `docker-compose.yml` gives Laravel projects PHP/MySQL/Redis without any local PHP install.
|
||||
|
||||
### 3.5 Laravel Tools (PHP backend)
|
||||
- `artisan` — run a whitelisted `php artisan` subcommand and return stdout/stderr; allow-listed commands include `make:controller`, `make:model`, `make:migration`, `make:seeder`, `make:factory`, `make:request`, `make:resource`, `make:middleware`, `migrate`, `migrate:rollback`, `migrate:fresh` (requires `confirm: true`), `db:seed`, `route:list`, `config:clear`, `cache:clear`, `queue:work` (tracked as a background process like a dev server).
|
||||
- `generate_laravel_resource` — one-shot scaffold of a full resource (model + migration + factory + seeder + `apiResource`/`resource` controller) for a given name, wrapping the relevant `artisan make:*` calls and wiring the route.
|
||||
- `composer_require` / `composer_remove` — add/remove a Composer dependency (mirrors `add_dependency` but for PHP packages).
|
||||
- `run_phpunit` / `run_pest` — run the Laravel test suite (auto-picked based on which is configured in `composer.json`/`phpunit.xml`), returning parsed pass/fail results.
|
||||
- `laravel_tinker_eval` — evaluate a short PHP expression/snippet via `artisan tinker --execute` for quick data/model inspection (read-only by convention; write operations require `confirm: true`).
|
||||
|
||||
### 3.6 CodeIgniter Tools (PHP backend)
|
||||
- `spark` — run a whitelisted `php spark` subcommand and return stdout/stderr; allow-listed commands include `make:controller`, `make:model`, `make:migration`, `make:seeder`, `make:entity`, `make:filter`, `make:command`, `make:validation`, `migrate`, `migrate:rollback`, `migrate:refresh` (requires `confirm: true`), `db:seed`, `routes` (list routes), `cache:clear`.
|
||||
- `generate_codeigniter_resource` — one-shot scaffold of a full resource (model + migration + seeder + `ResourceController`) for a given name, wrapping the relevant `spark make:*` calls and wiring the route in `app/Config/Routes.php`.
|
||||
- `run_codeigniter_tests` — thin wrapper over `run_tests` for CodeIgniter's PHPUnit-based test suite (`CIUnitTestCase`), returning parsed pass/fail results.
|
||||
|
||||
CodeIgniter shares `composer_require`/`composer_remove` (§3.5) and `search_package_docs`/`get_package_info` (§3.7) with Laravel, since both are Composer-based PHP frameworks — only the CLI (`spark` vs `artisan`) and generated file conventions differ.
|
||||
|
||||
### 3.7 React Tools (JS/TS frontend)
|
||||
- `generate_react_component` — scaffold a function component (TSX/JSX, matching the project's existing TS/JS + styling convention — CSS Modules/Tailwind/styled-components) with an optional colocated test file (React Testing Library) and Storybook story.
|
||||
- `generate_react_hook` — scaffold a custom hook file (`useXyz.ts`) plus a matching test.
|
||||
- `add_react_route` — add a route entry for the detected router (`react-router`, Next.js App/Pages Router file conventions, or TanStack Router).
|
||||
- `run_react_tests` — thin wrapper over `run_tests` specialized to parse Jest/Vitest + React Testing Library output (e.g. surfacing which assertions/queries failed).
|
||||
- `analyze_react_component` — static scan of a component file for common issues (missing `key` in lists, hook-rule violations, unused props) as a fast pre-check before/instead of a full lint run.
|
||||
|
||||
### 3.8 Docs & Package Lookup
|
||||
- `search_package_docs` — fetch README/docs for an npm **or Composer/Packagist** package to ground the agent in the actual installed version's API.
|
||||
- `search_mdn` — look up a web platform API on MDN.
|
||||
- `search_laravel_docs` — look up a topic in the Laravel documentation, version-matched to the project's `laravel/framework` constraint in `composer.json`.
|
||||
- `search_codeigniter_docs` — look up a topic in the CodeIgniter 4 user guide, version-matched to the project's `codeigniter4/framework` constraint in `composer.json`.
|
||||
- `get_package_info` — installed version, available scripts/artisan/spark commands, and key deps from `package.json`/`composer.json` (whichever applies).
|
||||
|
||||
### 3.9 Filesystem Helpers (sandboxed to workspace root)
|
||||
- `read_file` / `write_file` / `list_dir` / `search_code` — thin, sandboxed wrappers (useful when this server is used standalone, without an IDE's native file tools).
|
||||
|
||||
### 3.10 Resources & Prompts
|
||||
- Resource: `workspace://project.json` — exposes detected project metadata (framework/adapter(s), package manager, scripts, PHP/Node versions) so the agent doesn't need a tool round-trip.
|
||||
- Resource: `workspace://logs/{processId}` — live log tail for a running process.
|
||||
- Resource: `workspace://containers/{containerId}/logs` — live log tail for a running container.
|
||||
- Prompt: `new-feature` — a template prompt guiding the agent through scaffold → implement → test → verify-in-browser.
|
||||
- Prompt: `new-laravel-resource` — guides the agent through `generate_laravel_resource` → migrate → write feature test → verify via `route:list`/tinker.
|
||||
- Prompt: `new-codeigniter-resource` — guides the agent through `generate_codeigniter_resource` → migrate → write a PHPUnit test → verify via `spark routes`.
|
||||
- Prompt: `new-react-component` — guides the agent through `generate_react_component` → wire into a route/page → verify via browser screenshot.
|
||||
|
||||
## 4. Transport Design
|
||||
|
||||
- **stdio**: default entrypoint (`bin/web-dev-mcp`), used when launched by Cursor/Claude Desktop via their MCP config (`command` + `args`). No auth needed (local process).
|
||||
- **HTTP/SSE (Streamable HTTP)**: `src/transports/http.ts` runs an Express app exposing the MCP Streamable HTTP endpoint, so the same tool implementations can be reused remotely (e.g. a shared team server, or containerized in CI). Add a lightweight bearer-token auth middleware for this mode since it's network-reachable.
|
||||
- Both transports share the same `createServer()` factory from `src/server.ts` — only the transport wiring differs.
|
||||
|
||||
## 5. Safety & Sandboxing
|
||||
|
||||
- All filesystem/tool paths resolved against a configured `WORKSPACE_ROOT` and rejected if they resolve outside it.
|
||||
- Shell/process execution restricted to an allow-list of known package-manager/tool binaries (npm, pnpm, yarn, node, npx, git, php, composer, artisan-via-php, spark-via-php) — no arbitrary shell strings from the model.
|
||||
- `artisan`, `spark`, and `composer_require`/`composer_remove` further restrict *which subcommands* may run via their own allow-lists (see §3.5/§3.6); anything destructive (`migrate:fresh`, `migrate:reset`, `migrate:refresh`, `db:wipe`) requires `confirm: true`.
|
||||
- Long-running processes (dev servers) capped with idle/max-lifetime timeouts and a max concurrent count.
|
||||
- Destructive operations (`deploy_project`, dependency removal, `git` write ops) require an explicit `confirm: true` argument in the tool schema, so the agent must deliberately opt in.
|
||||
- HTTP transport requires an auth token env var (`MCP_HTTP_TOKEN`); refuses to bind to non-localhost without it set.
|
||||
- **Docker-specific guardrails:**
|
||||
- Volume mounts are restricted to paths inside `WORKSPACE_ROOT` (read-write) — no mounting of arbitrary host paths.
|
||||
- `docker_run_container` rejects `--privileged`, host network mode, and host PID/IPC namespace sharing.
|
||||
- Non-locally-built images are restricted to a configurable allow-list (e.g. `node`, `nginx`, `postgres`, `redis`) to reduce supply-chain risk; running an arbitrary/unlisted image requires `confirm: true`.
|
||||
- Default CPU/memory limits applied to every container unless overridden; a max-concurrent-container cap is enforced.
|
||||
- All containers started by the server are tracked in `containerManager` and force-stopped/removed on server shutdown.
|
||||
- Tool calls fail fast with a clear error if the Docker daemon isn't running/reachable, rather than hanging.
|
||||
|
||||
## 6. Tech Stack
|
||||
|
||||
| Concern | Choice |
|
||||
|---|---|
|
||||
| MCP SDK | `@modelcontextprotocol/sdk` |
|
||||
| Language | TypeScript (strict mode), compiled to ESM |
|
||||
| HTTP server | Express (for the HTTP/SSE transport) |
|
||||
| Browser automation | Playwright |
|
||||
| Container management | `dockerode` (Docker Engine API client) — falls back to a clear error if the Docker daemon/socket isn't available |
|
||||
| Process management | `execa` (child process handling) |
|
||||
| Schema validation | `zod` (tool input schemas) |
|
||||
| Testing | Vitest |
|
||||
| Linting/formatting | ESLint + Prettier |
|
||||
| Packaging | npm package with a `bin` entry; optional Docker image for the HTTP mode |
|
||||
|
||||
> **Prerequisite for Docker tools:** Docker Engine (Docker Desktop or `dockerd`) must be installed and running on the host; the server talks to it via the local Docker socket/named pipe. This is a runtime dependency of the *tools*, not of the MCP server process itself.
|
||||
>
|
||||
> **Prerequisite for Laravel/CodeIgniter tools:** PHP (matching the project's `composer.json` constraint) and Composer must be on `PATH`, **or** the project uses Laravel Sail/a `docker-compose.yml` and all `artisan`/`spark`/`composer_*`/test calls are transparently routed through `docker_exec` into the app container instead of the host shell. The adapter picks whichever is available at `detect_framework` time. CodeIgniter has no Sail-equivalent bundled, but the same Docker routing applies if the project ships its own `docker-compose.yml`.
|
||||
>
|
||||
> **Prerequisite for React tools:** none beyond Node.js — React itself is just an npm dependency, so the React adapter reuses the same Node/npm/pnpm/yarn tooling as the generic JS workflow tools, with Vite/CRA/Next-specific conventions layered on top.
|
||||
|
||||
## 7. Implementation Milestones
|
||||
|
||||
1. **Bootstrap** — repo scaffold, `package.json`, TS config, MCP server skeleton that registers zero tools and runs over stdio; verify it connects in Cursor.
|
||||
2. **Framework adapter foundation** — `FrameworkAdapter` interface, shared `PhpFrameworkAdapter` base, `detect.ts` (Laravel/CodeIgniter/React/generic detection), `GenericAdapter`, `ReactAdapter`, `LaravelAdapter`, `CodeIgniterAdapter` skeletons, and `detect_framework` tool.
|
||||
3. **Core workflow tools** — `run_install`, `run_script`, `start_dev_server`, `stop_process`, `get_process_logs`, `run_lint`, `run_tests`, `build_project`, all dispatched through the resolved adapter(s). Includes the `processManager` and sandbox path/command guards.
|
||||
4. **Scaffolding tools** — `scaffold_project` (incl. `laravel`/`codeigniter`/`laravel-react`/`codeigniter-react` templates), `generate_component`, `generate_api_route`, `add_dependency`.
|
||||
5. **Laravel tools** — `artisan` (with its subcommand allow-list), `generate_laravel_resource`, `composer_require`/`composer_remove`, `run_phpunit`/`run_pest`, `laravel_tinker_eval`.
|
||||
6. **CodeIgniter tools** — `spark` (with its subcommand allow-list), `generate_codeigniter_resource`, `run_codeigniter_tests` (reusing `composer_require`/`composer_remove` from the Laravel milestone via the shared `PhpFrameworkAdapter` base).
|
||||
7. **React tools** — `generate_react_component`, `generate_react_hook`, `add_react_route`, `run_react_tests`, `analyze_react_component`.
|
||||
8. **Browser tools** — integrate Playwright, implement `browser_navigate`, `browser_screenshot`, `browser_get_console_logs`, `browser_inspect_dom`, interaction primitives.
|
||||
9. **Docker tools** — integrate `dockerode`, implement `containerManager`, `docker_build_image`, `docker_run_container`, `docker_list_containers`, `docker_get_container_logs`, `docker_exec`, `docker_stop_container`/`docker_remove_container`, and `docker_compose_up`/`docker_compose_down` (incl. Laravel Sail routing); enforce the Docker-specific guardrails from §5.
|
||||
10. **Docs/package lookup tools** — `search_package_docs`, `search_mdn`, `search_laravel_docs`, `search_codeigniter_docs`, `get_package_info`.
|
||||
11. **Resources & prompts** — project metadata resource, process/container log-tail resources, `new-feature`/`new-laravel-resource`/`new-codeigniter-resource`/`new-react-component` prompt templates.
|
||||
12. **HTTP/SSE transport** — Express wiring, auth token middleware, containerization (Dockerfile).
|
||||
13. **Hardening & tests** — Vitest suite per tool (mocking child processes/Playwright/dockerode/artisan/spark calls), confirm-flag enforcement tests, path-traversal tests, Docker guardrail tests, Laravel/CodeIgniter subcommand allow-list tests.
|
||||
14. **Docs & distribution** — README with Cursor/Claude Desktop config snippets, publish as an npm package (`npx web-dev-mcp`).
|
||||
|
||||
## 8. Example Cursor MCP Config (stdio mode, post-implementation)
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"web-dev": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "web-dev-mcp"],
|
||||
"env": { "WORKSPACE_ROOT": "${workspaceFolder}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 9. Post-v1 Enhancements
|
||||
|
||||
### 9.0 Goal
|
||||
|
||||
Extend `web-dev-mcp` beyond v1 without breaking existing tools/adapters. Work ships in phased releases **v1.1–v1.5**. Each phase adds tools that reuse the existing sandbox, `processManager`, `FrameworkAdapter`, and `confirm: true` patterns.
|
||||
|
||||
### 9.1 Architecture deltas
|
||||
|
||||
- **New `FrameworkKind` values** (v1.4): `vue`, `nuxt`, `django`, `rails`, `symfony`.
|
||||
- **Monorepo model** (v1.3): `DetectedWorkspace { root, packages: DetectedPackage[] }` alongside today’s `DetectedProject`; workflow tools gain an optional `package` / scoped `cwd` still confined to `WORKSPACE_ROOT`.
|
||||
- **Screenshot baseline store** (v1.1): PNGs under `.web-dev-mcp/baselines/{name}.png`, resolved via sandbox helpers.
|
||||
- **Audit binary allow-list** (v1.1): `lighthouse` (and `npx`) for performance audits.
|
||||
- **Sail-aware Laravel** (v1.5): when `laravel/sail` + compose file exist, prefer Sail/`docker compose` for `devCommand`.
|
||||
|
||||
### 9.2 Tool catalog — v1.1 Agent productivity
|
||||
|
||||
| Tool | Behavior |
|
||||
|------|----------|
|
||||
| `git_status` | `git status --porcelain=v1` + branch + ahead/behind; read-only |
|
||||
| `git_diff` | staged/unstaged/file-scoped diff; truncated via `maxToolOutputChars` |
|
||||
| `git_log` | recent commits (`-n`, optional path); read-only |
|
||||
| `git_branch` | list or create branch (`create` requires `confirm: true`) |
|
||||
| `git_commit` | stage workspace-only paths + commit message; **`confirm: true`**; no amend/force |
|
||||
| `browser_screenshot_baseline` | save current page/element PNG under `.web-dev-mcp/baselines/{name}.png` |
|
||||
| `browser_visual_diff` | compare live screenshot vs baseline (pixelmatch); return diff PNG + mismatch % |
|
||||
| `lighthouse_audit` | run Lighthouse headless against a URL; return category scores + top opportunities |
|
||||
|
||||
### 9.3 Tool catalog — v1.2 Framework depth
|
||||
|
||||
**Next.js** (still `kind: 'react'`): enrich `describe()` with `router` / `rsc`; `generate_next_page` / `generate_next_layout`; `clientComponent` on generators; RSC-aware `analyze_react_component`.
|
||||
|
||||
**Laravel:** `generate_eloquent_relation`; `generate_filament_resource` / `generate_nova_resource` (detect package or hint install); expanded artisan allow-list (`queue:*`, `reverb:start` as background); `laravel_queue_status`.
|
||||
|
||||
**CodeIgniter:** `generate_codeigniter_shield_auth` (`confirm` + package detect); versioned `ResourceController` options on resource generator; `spark_discover` (filtered `php spark list`).
|
||||
|
||||
### 9.4 Tool catalog — v1.3 Platform
|
||||
|
||||
| Tool / change | Behavior |
|
||||
|---------------|----------|
|
||||
| `detect_workspace` / enhanced `detect_framework` | Scan `apps/*`, `packages/*`, composer paths; list packages + adapters |
|
||||
| `package` / scoped `cwd` on workflow tools | Scope install/dev/test/build to one package root |
|
||||
| `generate_dockerfile` | Emit stack-aware Dockerfile templates |
|
||||
| `generate_compose` | Emit `docker-compose.yml` (app + db + redis presets) |
|
||||
| `generate_sail` | Wrap `php artisan sail:install` when Laravel detected |
|
||||
| `docker_push_image` | Push local/allow-listed image; **`confirm: true`**; credentials via env |
|
||||
| `deploy_project` | Optional `registryPush` after build; keep script + confirm |
|
||||
|
||||
### 9.5 Tool catalog — v1.4 New adapters
|
||||
|
||||
- Detect Vue (`vue` / `.vue` + Vite) and Nuxt (`nuxt` / `nuxt.config.*`); adapters implement `FrameworkAdapter`.
|
||||
- Wire SFC-aware `generate_component`; reuse existing `vite-vue` scaffold template.
|
||||
- Django / Rails / Symfony: detection + install/dev/test first; deeper generators later.
|
||||
|
||||
### 9.6 Tool catalog — v1.5 Polish
|
||||
|
||||
| Tool / change | Behavior |
|
||||
|---------------|----------|
|
||||
| Laravel Sail `devCommand` | When `laravel/sail` + compose file exist, `start_dev_server` runs `vendor/bin/sail up` (else `docker compose up`) |
|
||||
| `git_diff_summarize` | Read-only `git diff --numstat` summary: per-file + total insertions/deletions |
|
||||
| `lighthouse_audit` (richer) | Returns category details, cross-category opportunities, `failingByCategory` |
|
||||
| `visual-diff-ci` prompt | Baseline create / visual-diff / threshold pass-fail CI helper |
|
||||
| `generate_django_resource` | File stubs: models/views/urls/admin for a Django app |
|
||||
| `generate_rails_resource` | File stubs: model + controller + optional routes.rb wire |
|
||||
| `generate_symfony_resource` | File stubs: Entity + Repository + Controller |
|
||||
|
||||
### 9.7 Safety (all post-v1 phases)
|
||||
|
||||
- Workspace path confinement for every file/baseline/git path argument.
|
||||
- Binary allow-list only; no arbitrary shell strings.
|
||||
- Destructive / write ops (`git_commit`, branch create, `docker_push_image`, Shield install, deploy) require `confirm: true`.
|
||||
- Never `git push --force`; never mount the Docker socket.
|
||||
- No secrets in tool args — registry auth via environment only.
|
||||
|
||||
### 9.8 Milestones
|
||||
|
||||
1. **v1.1** — Git helpers, screenshot baselines, visual diff, Lighthouse audit (+ tests/docs).
|
||||
2. **v1.2** — Next.js App Router depth; Laravel Eloquent/Filament/Nova/queue; CodeIgniter Shield/versioning/`spark_discover`.
|
||||
3. **v1.3** — Monorepo detection/scoping; Dockerfile/compose/Sail generators; `docker_push_image`.
|
||||
4. **v1.4** — Vue/Nuxt adapters (+ Django/Rails/Symfony detection stubs or full adapters).
|
||||
5. **v1.5** — Sail-default dev, polish prompts/summaries, remaining framework generators.
|
||||
@@ -1,2 +1,250 @@
|
||||
# mcp_web_dev_server
|
||||
# web-dev-mcp
|
||||
|
||||
An MCP (Model Context Protocol) server that gives AI coding agents a framework-aware toolkit for
|
||||
web development: project/dependency workflow (install, dev server, lint, test, build), and
|
||||
code/project scaffolding, with first-class support for **Laravel**, **CodeIgniter 4**,
|
||||
**React**, **Vue**, and **Nuxt**, plus detection stubs for **Django**, **Rails**, and **Symfony**
|
||||
— including combined backend+frontend setups.
|
||||
|
||||
See [`PLAN.md`](./PLAN.md) for the full design and roadmap. This README covers what's
|
||||
implemented today and how to run it.
|
||||
|
||||
## Implementation status
|
||||
|
||||
Implemented **v1** (PLAN.md milestones 1–14) plus post-v1 **v1.1–v1.5**:
|
||||
|
||||
- **Framework detection & adapters** — `detect_framework` auto-detects Laravel (Sail-aware), CodeIgniter 4,
|
||||
Symfony, Django, Rails backends and React / Vue / Nuxt frontends, including a PHP/Python/Ruby
|
||||
backend + separate frontend living side-by-side (`frontend/`, `client/`, `web/`).
|
||||
- **Core workflow tools** — `detect_framework`, `detect_workspace` (monorepo package scan),
|
||||
`run_install`, `run_script`, `start_dev_server` (Sail `up` when present), `stop_process`,
|
||||
`get_process_logs`, `list_processes`, `run_lint`, `run_tests`, `build_project`,
|
||||
`deploy_project` (opt-in `confirm: true`; optional `registryPush`). Most workflow tools accept
|
||||
an optional `package` argument to scope to a monorepo package.
|
||||
- **Scaffolding tools** — `scaffold_project` (vite-react, vite-vue, next, express-api,
|
||||
static-html, laravel, codeigniter, laravel-react, codeigniter-react), `generate_component`
|
||||
(React TSX/JSX or Vue SFC), `generate_api_route`, `add_dependency`.
|
||||
- **Laravel tools** — `artisan` (allow-listed subcommands; destructive ones need `confirm: true`;
|
||||
`queue:work`/`queue:listen`/`reverb:start` run as tracked background processes), `generate_laravel_resource`,
|
||||
`generate_eloquent_relation`, `generate_filament_resource`, `generate_nova_resource`, `laravel_queue_status`,
|
||||
`composer_require` / `composer_remove` (shared with CodeIgniter backends), `run_phpunit` /
|
||||
`run_pest`, `laravel_tinker_eval` (write-looking expressions need `confirm: true`).
|
||||
- **CodeIgniter tools** — `spark` (allow-listed subcommands; `migrate:refresh` needs
|
||||
`confirm: true`), `spark_discover`, `generate_codeigniter_resource` (optional `apiVersion` /
|
||||
ResourceController), `generate_codeigniter_shield_auth` (`confirm: true`), `run_codeigniter_tests`.
|
||||
- **Django / Rails / Symfony** — `generate_django_resource`, `generate_rails_resource`,
|
||||
`generate_symfony_resource` (file-based stubs).
|
||||
- **React tools** — `generate_react_hook`, `add_react_route` (Next.js / react-router /
|
||||
TanStack Router), `generate_next_page` / `generate_next_layout` (App Router), `run_react_tests`,
|
||||
`analyze_react_component` (incl. missing `"use client"` / server→client prop heuristics).
|
||||
`generate_component` supports `clientComponent: true`.
|
||||
- **Browser tools** — `browser_navigate`, `browser_screenshot`, `browser_screenshot_baseline`,
|
||||
`browser_visual_diff`, `browser_get_console_logs`, `browser_inspect_dom`, `browser_click`,
|
||||
`browser_fill`, `browser_eval` (Playwright-backed; Chromium binaries via `npx playwright install chromium`).
|
||||
- **Git helpers** — `git_status`, `git_diff`, `git_diff_summarize`, `git_log`, `git_branch` (create needs
|
||||
`confirm: true`), `git_commit` (`confirm: true`; no amend/force/push).
|
||||
- **Lighthouse audit** — `lighthouse_audit` (category details, opportunities, `failingByCategory`).
|
||||
- **Prompts** — `new-feature`, `new-laravel-resource`, `new-codeigniter-resource`, `new-react-component`,
|
||||
`visual-diff-ci`.
|
||||
- **Docker tools** — `docker_build_image`, `docker_run_container` (with workspace-confined volume
|
||||
mounts, image allow-list, and resource limits), `docker_list_containers`, `docker_get_container_logs`,
|
||||
`docker_exec`, `docker_stop_container`, `docker_remove_container`, `docker_compose_up`,
|
||||
`docker_compose_down`, `generate_dockerfile`, `generate_compose`, `generate_sail` (`confirm: true`),
|
||||
`docker_push_image` (`confirm: true`).
|
||||
- **Docs & package lookup** — `search_package_docs` (npm README via unpkg, Composer metadata via
|
||||
Packagist), `search_mdn`, `search_laravel_docs`, `search_codeigniter_docs`, `get_package_info`.
|
||||
- **Resources & prompts** — `workspace://project.json`, `workspace://logs/{processId}`,
|
||||
`workspace://containers/{containerId}/logs`, and reusable prompts `new-feature`,
|
||||
`new-laravel-resource`, `new-codeigniter-resource`, `new-react-component`.
|
||||
- **Safety** — every filesystem path is confined to `WORKSPACE_ROOT`
|
||||
(`src/lib/sandbox.ts`), spawned binaries are restricted to an allow-list, and
|
||||
destructive ops (`deploy_project`, `composer_remove`, `migrate:fresh`, `migrate:refresh`,
|
||||
`git_commit`, …) require an explicit `confirm: true`. Docker containers are tracked and
|
||||
force-removed on server shutdown.
|
||||
- **Both transports work today**: stdio (for Cursor/Claude Desktop) and Streamable HTTP (for
|
||||
remote/shared use), sharing one `createServer()` implementation. The HTTP transport includes
|
||||
bearer-token auth and a production-ready `Dockerfile`.
|
||||
- **Automated test suite** — Vitest tests for sandbox/path confinement, binary allow-list,
|
||||
process management, Laravel/CodeIgniter subcommand allow-lists, destructive-command `confirm: true`
|
||||
enforcement, Docker guardrails, framework detection, baselines/visual diff, and Lighthouse parsing.
|
||||
- **Distribution-ready** — install via `npx web-dev-mcp` or add to your MCP client config.
|
||||
|
||||
Post-v1 roadmap (see `PLAN.md` §9): **v1.4** Vue/Nuxt adapters, **v1.5** Sail/polish.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Node.js >= 18.17 (developed/tested on Node 24)
|
||||
- For Laravel/CodeIgniter tools: PHP + Composer on `PATH`
|
||||
- For scaffolding Laravel/CodeIgniter projects: Composer on `PATH`
|
||||
- For browser tools: `npx playwright install chromium` (run once after `npm install`)
|
||||
- For Docker tools: Docker Engine (Docker Desktop or `dockerd`) running and accessible to the current user
|
||||
- For `lighthouse_audit`: Chrome/Chromium available (Lighthouse is pulled via `npx` on demand)
|
||||
- For git tools: `git` on `PATH` and a git workspace under `WORKSPACE_ROOT`
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run build
|
||||
```
|
||||
|
||||
## Running tests
|
||||
|
||||
```bash
|
||||
npm run test
|
||||
```
|
||||
|
||||
## Running
|
||||
|
||||
**stdio** (what Cursor/Claude Desktop use):
|
||||
|
||||
```bash
|
||||
npm run dev # ts-node/tsx, no build step needed
|
||||
# or, after `npm run build`:
|
||||
npm start
|
||||
```
|
||||
|
||||
**Streamable HTTP** (remote/shared use):
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
npm run start:http
|
||||
```
|
||||
|
||||
Environment variables:
|
||||
|
||||
| Variable | Default | Purpose |
|
||||
|---|---|---|
|
||||
| `WORKSPACE_ROOT` | current working directory | The project root every tool is confined to. |
|
||||
| `MCP_HTTP_HOST` | `127.0.0.1` | Host the HTTP transport binds to. |
|
||||
| `MCP_HTTP_PORT` | `3939` | Port the HTTP transport binds to. |
|
||||
| `MCP_HTTP_TOKEN` | _(unset)_ | Bearer token required on `/mcp` when set. **Required** if binding to a non-localhost host. |
|
||||
|
||||
**Docker / containerization**:
|
||||
|
||||
```bash
|
||||
docker build -t web-dev-mcp .
|
||||
docker run -p 3939:3939 -e MCP_HTTP_TOKEN=change-me -v /path/to/project:/workspace web-dev-mcp
|
||||
```
|
||||
|
||||
The image exposes the Streamable HTTP transport on port `3939` and uses `/workspace` as the confined `WORKSPACE_ROOT`. The `Dockerfile` uses a multi-stage build so the final image only contains production dependencies plus the compiled `dist/` directory.
|
||||
|
||||
## Using it from Cursor
|
||||
|
||||
Add to your MCP config (Cursor Settings → MCP, or `.cursor/mcp.json`):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"web-dev": {
|
||||
"command": "node",
|
||||
"args": ["/path/to/web-dev-mcp/dist/transports/stdio.js"],
|
||||
"env": { "WORKSPACE_ROOT": "${workspaceFolder}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Published npm package (recommended)
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"web-dev": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "web-dev-mcp"],
|
||||
"env": { "WORKSPACE_ROOT": "${workspaceFolder}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Claude Desktop config
|
||||
|
||||
Add to `claude_desktop_config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"web-dev": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "web-dev-mcp"],
|
||||
"env": { "WORKSPACE_ROOT": "/path/to/your/project" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Local checkout
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"web-dev": {
|
||||
"command": "node",
|
||||
"args": ["/path/to/web-dev-mcp/dist/transports/stdio.js"],
|
||||
"env": { "WORKSPACE_ROOT": "${workspaceFolder}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Project layout
|
||||
|
||||
```
|
||||
src/
|
||||
config.ts # env vars, allow-lists, timeouts
|
||||
server.ts # createServer(): registers all tools/resources
|
||||
transports/
|
||||
stdio.ts # stdio entrypoint (bin)
|
||||
http.ts # Streamable HTTP entrypoint
|
||||
lib/
|
||||
logger.ts # stderr-only logging (stdout is reserved for JSON-RPC)
|
||||
sandbox.ts # workspace path confinement + binary allow-list
|
||||
runCommand.ts # foreground command execution with capped output
|
||||
processManager.ts # tracks background processes (dev servers, backgrounded scripts)
|
||||
strings.ts # pascal/camel/kebab-case helpers for codegen
|
||||
frameworks/
|
||||
types.ts # FrameworkAdapter interface
|
||||
detect.ts # workspace inspection -> detected adapter(s)
|
||||
resolve.ts # picks backend/frontend/primary adapter for a tool call
|
||||
generic.ts, react.ts, vue.ts, djangoRailsSymfony.ts, laravel.ts, codeigniter.ts, phpBase.ts
|
||||
packageManager.ts, composer.ts # npm/pnpm/yarn/bun and Composer helpers
|
||||
tools/
|
||||
shared.ts # zod schema + result helpers shared by tools
|
||||
workflow/ # detect_framework, run_install, run_script, start_dev_server,
|
||||
# stop_process, get_process_logs, list_processes, run_lint,
|
||||
# run_tests, build_project, deploy_project
|
||||
scaffold/ # scaffold_project (+ templates/), generate_component,
|
||||
# generate_api_route, add_dependency
|
||||
laravel/ # artisan, generate_laravel_resource, composer_require/remove,
|
||||
# run_phpunit/run_pest, laravel_tinker_eval
|
||||
codeigniter/ # spark, generate_codeigniter_resource, run_codeigniter_tests
|
||||
react/ # generate_react_hook, add_react_route, run_react_tests,
|
||||
# analyze_react_component
|
||||
browser/ # browserManager, navigate, screenshot, consoleLogs,
|
||||
# inspectDom, interact
|
||||
docker/ # dockerManager, buildImage, runContainer, listContainers,
|
||||
# containerLogs, exec, stopRemove, compose
|
||||
docs/ # searchPackageDocs, searchMdn, searchLaravelDocs,
|
||||
# searchCodeIgniterDocs, getPackageInfo
|
||||
git/ # git_status, git_diff, git_log, git_branch, git_commit
|
||||
audit/ # lighthouse_audit
|
||||
resources/ # process/container log resources
|
||||
prompts/ # new-feature / laravel / codeigniter / react prompts
|
||||
```
|
||||
|
||||
Screenshot baselines for `browser_visual_diff` are stored under `.web-dev-mcp/baselines/` inside
|
||||
the workspace (you may commit them for CI, or ignore them locally).
|
||||
|
||||
## Publishing
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
npm run test
|
||||
npm version <patch|minor|major>
|
||||
npm publish
|
||||
```
|
||||
|
||||
The package is published as `web-dev-mcp` and exposes `dist/transports/stdio.js` as the `bin` entry,
|
||||
so `npx web-dev-mcp` launches the stdio transport directly.
|
||||
|
||||
Vendored
+77
@@ -0,0 +1,77 @@
|
||||
import path from 'node:path';
|
||||
function resolveWorkspaceRoot() {
|
||||
const fromEnv = process.env.WORKSPACE_ROOT;
|
||||
const raw = fromEnv && fromEnv.trim().length > 0 ? fromEnv : process.cwd();
|
||||
return path.resolve(raw);
|
||||
}
|
||||
export const config = {
|
||||
/** Absolute path every tool call is confined to. */
|
||||
workspaceRoot: resolveWorkspaceRoot(),
|
||||
/** HTTP transport settings (only used by transports/http.ts). */
|
||||
httpHost: process.env.MCP_HTTP_HOST ?? '127.0.0.1',
|
||||
httpPort: Number.parseInt(process.env.MCP_HTTP_PORT ?? '3939', 10),
|
||||
httpToken: process.env.MCP_HTTP_TOKEN,
|
||||
/** Binaries the server is ever allowed to spawn. Basenames only (extension-insensitive). */
|
||||
allowedBinaries: [
|
||||
'npm',
|
||||
'pnpm',
|
||||
'yarn',
|
||||
'bun',
|
||||
'node',
|
||||
'npx',
|
||||
'git',
|
||||
'php',
|
||||
'composer',
|
||||
'powershell',
|
||||
'sh',
|
||||
'bash',
|
||||
// Composer vendor/bin tools invoked by the Laravel/CodeIgniter adapters.
|
||||
// These are only ever resolved to a path inside the detected project's
|
||||
// own vendor/bin directory (see PhpFrameworkAdapter.vendorBin), never
|
||||
// taken verbatim from tool-call input.
|
||||
'phpunit',
|
||||
'pest',
|
||||
'pint',
|
||||
'phpcs',
|
||||
'phpcbf',
|
||||
// Docker CLI used by docker_compose_up/down.
|
||||
'docker',
|
||||
// Lighthouse CLI used by lighthouse_audit (also invokable via npx).
|
||||
'lighthouse',
|
||||
// v1.4 adapters: Django / Rails tooling.
|
||||
'python',
|
||||
'pip',
|
||||
'poetry',
|
||||
'uv',
|
||||
'ruby',
|
||||
'bundle',
|
||||
'rails',
|
||||
'gem',
|
||||
],
|
||||
/** Hard caps to keep tool output/process usage bounded. */
|
||||
maxBufferedLogLines: 2000,
|
||||
maxToolOutputChars: 20_000,
|
||||
defaultCommandTimeoutMs: 120_000,
|
||||
scaffoldCommandTimeoutMs: 5 * 60_000,
|
||||
defaultReadyTimeoutMs: 30_000,
|
||||
maxConcurrentProcesses: 10,
|
||||
/** Docker-specific guardrails. */
|
||||
dockerAllowedBaseImages: [
|
||||
'node',
|
||||
'nginx',
|
||||
'postgres',
|
||||
'redis',
|
||||
'mysql',
|
||||
'mariadb',
|
||||
'mongo',
|
||||
'php',
|
||||
'composer',
|
||||
'python',
|
||||
'alpine',
|
||||
'busybox',
|
||||
],
|
||||
maxConcurrentContainers: 10,
|
||||
defaultContainerMemory: '512m',
|
||||
defaultContainerCpus: '1.0',
|
||||
};
|
||||
//# sourceMappingURL=config.js.map
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"config.js","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAAA,OAAO,IAAI,MAAM,WAAW,CAAC;AAE7B,SAAS,oBAAoB;IAC3B,MAAM,OAAO,GAAG,OAAO,CAAC,GAAG,CAAC,cAAc,CAAC;IAC3C,MAAM,GAAG,GAAG,OAAO,IAAI,OAAO,CAAC,IAAI,EAAE,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,OAAO,CAAC,GAAG,EAAE,CAAC;IAC3E,OAAO,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC;AAC3B,CAAC;AAED,MAAM,CAAC,MAAM,MAAM,GAAG;IACpB,oDAAoD;IACpD,aAAa,EAAE,oBAAoB,EAAE;IAErC,iEAAiE;IACjE,QAAQ,EAAE,OAAO,CAAC,GAAG,CAAC,aAAa,IAAI,WAAW;IAClD,QAAQ,EAAE,MAAM,CAAC,QAAQ,CAAC,OAAO,CAAC,GAAG,CAAC,aAAa,IAAI,MAAM,EAAE,EAAE,CAAC;IAClE,SAAS,EAAE,OAAO,CAAC,GAAG,CAAC,cAAc;IAErC,4FAA4F;IAC5F,eAAe,EAAE;QACf,KAAK;QACL,MAAM;QACN,MAAM;QACN,KAAK;QACL,MAAM;QACN,KAAK;QACL,KAAK;QACL,KAAK;QACL,UAAU;QACV,YAAY;QACZ,IAAI;QACJ,MAAM;QACN,yEAAyE;QACzE,uEAAuE;QACvE,sEAAsE;QACtE,uCAAuC;QACvC,SAAS;QACT,MAAM;QACN,MAAM;QACN,OAAO;QACP,QAAQ;QACR,6CAA6C;QAC7C,QAAQ;QACR,oEAAoE;QACpE,YAAY;QACZ,yCAAyC;QACzC,QAAQ;QACR,KAAK;QACL,QAAQ;QACR,IAAI;QACJ,MAAM;QACN,QAAQ;QACR,OAAO;QACP,KAAK;KACG;IAEV,2DAA2D;IAC3D,mBAAmB,EAAE,IAAI;IACzB,kBAAkB,EAAE,MAAM;IAC1B,uBAAuB,EAAE,OAAO;IAChC,wBAAwB,EAAE,CAAC,GAAG,MAAM;IACpC,qBAAqB,EAAE,MAAM;IAC7B,sBAAsB,EAAE,EAAE;IAE1B,kCAAkC;IAClC,uBAAuB,EAAE;QACvB,MAAM;QACN,OAAO;QACP,UAAU;QACV,OAAO;QACP,OAAO;QACP,SAAS;QACT,OAAO;QACP,KAAK;QACL,UAAU;QACV,QAAQ;QACR,QAAQ;QACR,SAAS;KACD;IACV,uBAAuB,EAAE,EAAE;IAC3B,sBAAsB,EAAE,MAAM;IAC9B,oBAAoB,EAAE,KAAK;CACnB,CAAC"}
|
||||
Vendored
+52
@@ -0,0 +1,52 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { config } from '../config.js';
|
||||
import { resolveWorkspacePath, SandboxViolationError } from './sandbox.js';
|
||||
const BASELINES_DIR = path.join('.web-dev-mcp', 'baselines');
|
||||
function sanitizeBaselineName(name) {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) {
|
||||
throw new SandboxViolationError('Baseline name must be a non-empty string.');
|
||||
}
|
||||
if (trimmed.includes('..') || trimmed.includes('/') || trimmed.includes('\\') || trimmed.includes(':')) {
|
||||
throw new SandboxViolationError(`Invalid baseline name "${name}": use a simple name without path separators or "..".`);
|
||||
}
|
||||
return trimmed.endsWith('.png') ? trimmed : `${trimmed}.png`;
|
||||
}
|
||||
export function resolveBaselinePath(name) {
|
||||
const fileName = sanitizeBaselineName(name);
|
||||
const relative = path.join(BASELINES_DIR, fileName);
|
||||
return resolveWorkspacePath(config.workspaceRoot, relative);
|
||||
}
|
||||
export function ensureBaselinesDir() {
|
||||
const dir = resolveWorkspacePath(config.workspaceRoot, BASELINES_DIR);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
return dir;
|
||||
}
|
||||
export function writeBaseline(name, png) {
|
||||
ensureBaselinesDir();
|
||||
const absolutePath = resolveBaselinePath(name);
|
||||
fs.writeFileSync(absolutePath, png);
|
||||
return {
|
||||
absolutePath,
|
||||
relativePath: path.relative(config.workspaceRoot, absolutePath),
|
||||
};
|
||||
}
|
||||
export function readBaseline(name) {
|
||||
const absolutePath = resolveBaselinePath(name);
|
||||
if (!fs.existsSync(absolutePath)) {
|
||||
throw new Error(`Baseline not found at "${path.relative(config.workspaceRoot, absolutePath)}". ` +
|
||||
'Capture one first with browser_screenshot_baseline.');
|
||||
}
|
||||
return fs.readFileSync(absolutePath);
|
||||
}
|
||||
export function listBaselines() {
|
||||
const dir = resolveWorkspacePath(config.workspaceRoot, BASELINES_DIR);
|
||||
if (!fs.existsSync(dir))
|
||||
return [];
|
||||
return fs
|
||||
.readdirSync(dir)
|
||||
.filter((entry) => entry.toLowerCase().endsWith('.png'))
|
||||
.sort();
|
||||
}
|
||||
//# sourceMappingURL=baselines.js.map
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"baselines.js","sourceRoot":"","sources":["../../src/lib/baselines.ts"],"names":[],"mappings":"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"}
|
||||
Vendored
+28
@@ -0,0 +1,28 @@
|
||||
import { PhpFrameworkAdapter } from './phpBase.js';
|
||||
export class CodeIgniterAdapter extends PhpFrameworkAdapter {
|
||||
kind = 'codeigniter';
|
||||
label = 'CodeIgniter 4';
|
||||
devCommand() {
|
||||
return { command: 'php', args: ['spark', 'serve'], cwd: this.root };
|
||||
}
|
||||
buildCommand() {
|
||||
// CodeIgniter has no dedicated production-build step beyond a lean install;
|
||||
// callers that need config/route caching should extend this via `spark`.
|
||||
return { command: 'composer', args: ['install', '--no-dev', '--optimize-autoloader'], cwd: this.root };
|
||||
}
|
||||
readyPattern() {
|
||||
// `php spark serve` prints: "CodeIgniter development server started on http://localhost:8080"
|
||||
return /CodeIgniter development server started on (https?:\/\/\S+)/i;
|
||||
}
|
||||
describe() {
|
||||
return {
|
||||
kind: this.kind,
|
||||
label: this.label,
|
||||
root: this.root,
|
||||
packageManager: this.packageManager,
|
||||
codeigniterVersion: this.composerJson?.require?.['codeigniter4/framework'] ?? null,
|
||||
devUrl: 'http://localhost:8080',
|
||||
};
|
||||
}
|
||||
}
|
||||
//# sourceMappingURL=codeigniter.js.map
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"codeigniter.js","sourceRoot":"","sources":["../../../src/lib/frameworks/codeigniter.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,mBAAmB,EAAE,MAAM,cAAc,CAAC;AAEnD,MAAM,OAAO,kBAAmB,SAAQ,mBAAmB;IAChD,IAAI,GAAG,aAAsB,CAAC;IAC9B,KAAK,GAAG,eAAe,CAAC;IAEjC,UAAU;QACR,OAAO,EAAE,OAAO,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC,OAAO,EAAE,OAAO,CAAC,EAAE,GAAG,EAAE,IAAI,CAAC,IAAI,EAAE,CAAC;IACtE,CAAC;IAED,YAAY;QACV,4EAA4E;QAC5E,yEAAyE;QACzE,OAAO,EAAE,OAAO,EAAE,UAAU,EAAE,IAAI,EAAE,CAAC,SAAS,EAAE,UAAU,EAAE,uBAAuB,CAAC,EAAE,GAAG,EAAE,IAAI,CAAC,IAAI,EAAE,CAAC;IACzG,CAAC;IAED,YAAY;QACV,8FAA8F;QAC9F,OAAO,6DAA6D,CAAC;IACvE,CAAC;IAED,QAAQ;QACN,OAAO;YACL,IAAI,EAAE,IAAI,CAAC,IAAI;YACf,KAAK,EAAE,IAAI,CAAC,KAAK;YACjB,IAAI,EAAE,IAAI,CAAC,IAAI;YACf,cAAc,EAAE,IAAI,CAAC,cAAc;YACnC,kBAAkB,EAAE,IAAI,CAAC,YAAY,EAAE,OAAO,EAAE,CAAC,wBAAwB,CAAC,IAAI,IAAI;YAClF,MAAM,EAAE,uBAAuB;SAChC,CAAC;IACJ,CAAC;CACF"}
|
||||
Vendored
+23
@@ -0,0 +1,23 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
export function readComposerJson(root) {
|
||||
const file = path.join(root, 'composer.json');
|
||||
if (!fs.existsSync(file))
|
||||
return null;
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||
}
|
||||
catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
export function hasComposerDependency(pkg, name) {
|
||||
if (!pkg)
|
||||
return false;
|
||||
return Boolean(pkg.require?.[name] || pkg['require-dev']?.[name]);
|
||||
}
|
||||
export function vendorBinExists(root, bin) {
|
||||
const suffix = process.platform === 'win32' ? '.bat' : '';
|
||||
return fs.existsSync(path.join(root, 'vendor', 'bin', `${bin}${suffix}`)) || fs.existsSync(path.join(root, 'vendor', 'bin', bin));
|
||||
}
|
||||
//# sourceMappingURL=composer.js.map
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"composer.js","sourceRoot":"","sources":["../../../src/lib/frameworks/composer.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,SAAS,CAAC;AACzB,OAAO,IAAI,MAAM,WAAW,CAAC;AAS7B,MAAM,UAAU,gBAAgB,CAAC,IAAY;IAC3C,MAAM,IAAI,GAAG,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,eAAe,CAAC,CAAC;IAC9C,IAAI,CAAC,EAAE,CAAC,UAAU,CAAC,IAAI,CAAC;QAAE,OAAO,IAAI,CAAC;IACtC,IAAI,CAAC;QACH,OAAO,IAAI,CAAC,KAAK,CAAC,EAAE,CAAC,YAAY,CAAC,IAAI,EAAE,MAAM,CAAC,CAAiB,CAAC;IACnE,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,IAAI,CAAC;IACd,CAAC;AACH,CAAC;AAED,MAAM,UAAU,qBAAqB,CAAC,GAAwB,EAAE,IAAY;IAC1E,IAAI,CAAC,GAAG;QAAE,OAAO,KAAK,CAAC;IACvB,OAAO,OAAO,CAAC,GAAG,CAAC,OAAO,EAAE,CAAC,IAAI,CAAC,IAAI,GAAG,CAAC,aAAa,CAAC,EAAE,CAAC,IAAI,CAAC,CAAC,CAAC;AACpE,CAAC;AAED,MAAM,UAAU,eAAe,CAAC,IAAY,EAAE,GAAW;IACvD,MAAM,MAAM,GAAG,OAAO,CAAC,QAAQ,KAAK,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,CAAC;IAC1D,OAAO,EAAE,CAAC,UAAU,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,QAAQ,EAAE,KAAK,EAAE,GAAG,GAAG,GAAG,MAAM,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC,UAAU,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,QAAQ,EAAE,KAAK,EAAE,GAAG,CAAC,CAAC,CAAC;AACpI,CAAC"}
|
||||
Vendored
+259
@@ -0,0 +1,259 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { readComposerJson, hasComposerDependency } from './composer.js';
|
||||
import { hasDependency, readPackageJson } from './packageManager.js';
|
||||
import { LaravelAdapter } from './laravel.js';
|
||||
import { CodeIgniterAdapter } from './codeigniter.js';
|
||||
import { ReactAdapter } from './react.js';
|
||||
import { VueAdapter, NuxtAdapter } from './vue.js';
|
||||
import { DjangoAdapter, RailsAdapter, SymfonyAdapter } from './djangoRailsSymfony.js';
|
||||
import { GenericAdapter } from './generic.js';
|
||||
const FRONTEND_SUBDIRS = ['frontend', 'client', 'web', 'resources/js-app'];
|
||||
const MONOREPO_GLOBS = ['apps', 'packages', 'services', 'libs'];
|
||||
function fileExists(root, names) {
|
||||
return names.some((name) => fs.existsSync(path.join(root, name)));
|
||||
}
|
||||
function hasVueSfc(root) {
|
||||
const src = path.join(root, 'src');
|
||||
if (!fs.existsSync(src))
|
||||
return false;
|
||||
try {
|
||||
const stack = [src];
|
||||
let checked = 0;
|
||||
while (stack.length > 0 && checked < 200) {
|
||||
const dir = stack.pop();
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
checked += 1;
|
||||
if (entry.isDirectory() && entry.name !== 'node_modules') {
|
||||
stack.push(path.join(dir, entry.name));
|
||||
}
|
||||
else if (entry.isFile() && entry.name.endsWith('.vue')) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
function detectPhpOrSymfonyBackend(root) {
|
||||
const composerJson = readComposerJson(root);
|
||||
if (!composerJson)
|
||||
return null;
|
||||
const hasArtisan = fs.existsSync(path.join(root, 'artisan'));
|
||||
const hasSpark = fs.existsSync(path.join(root, 'spark'));
|
||||
if (hasComposerDependency(composerJson, 'laravel/framework') || hasArtisan) {
|
||||
return new LaravelAdapter(root);
|
||||
}
|
||||
if (hasComposerDependency(composerJson, 'codeigniter4/framework') || hasSpark) {
|
||||
return new CodeIgniterAdapter(root);
|
||||
}
|
||||
if (hasComposerDependency(composerJson, 'symfony/framework-bundle') ||
|
||||
hasComposerDependency(composerJson, 'symfony/symfony') ||
|
||||
fileExists(root, ['bin/console', 'symfony.lock'])) {
|
||||
return new SymfonyAdapter(root);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function detectDjangoAt(root) {
|
||||
if (fileExists(root, ['manage.py']))
|
||||
return new DjangoAdapter(root);
|
||||
for (const name of ['requirements.txt', 'requirements-dev.txt', 'pyproject.toml']) {
|
||||
const file = path.join(root, name);
|
||||
if (!fs.existsSync(file))
|
||||
continue;
|
||||
const text = fs.readFileSync(file, 'utf8').toLowerCase();
|
||||
if (/\bdjango\b/.test(text))
|
||||
return new DjangoAdapter(root);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function detectRailsAt(root) {
|
||||
const gemfile = path.join(root, 'Gemfile');
|
||||
if (fs.existsSync(gemfile)) {
|
||||
const text = fs.readFileSync(gemfile, 'utf8').toLowerCase();
|
||||
if (/\brails\b/.test(text) || fileExists(root, ['bin/rails', 'config/application.rb'])) {
|
||||
return new RailsAdapter(root);
|
||||
}
|
||||
}
|
||||
if (fileExists(root, ['bin/rails', 'config/application.rb'])) {
|
||||
return new RailsAdapter(root);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function detectBackend(root) {
|
||||
return detectPhpOrSymfonyBackend(root) ?? detectDjangoAt(root) ?? detectRailsAt(root);
|
||||
}
|
||||
function detectFrontendAt(root) {
|
||||
const pkg = readPackageJson(root);
|
||||
if (hasDependency(pkg, 'nuxt') ||
|
||||
fileExists(root, ['nuxt.config.js', 'nuxt.config.ts', 'nuxt.config.mjs'])) {
|
||||
return new NuxtAdapter(root);
|
||||
}
|
||||
if (hasDependency(pkg, 'vue') ||
|
||||
(hasVueSfc(root) && fileExists(root, ['vite.config.js', 'vite.config.ts', 'vite.config.mjs']))) {
|
||||
return new VueAdapter(root);
|
||||
}
|
||||
if (hasDependency(pkg, 'react'))
|
||||
return new ReactAdapter(root);
|
||||
return null;
|
||||
}
|
||||
/**
|
||||
* Inspects `workspaceRoot` and returns the detected backend/frontend
|
||||
* adapter(s). Supports PHP (Laravel/CI/Symfony), Django, Rails backends and
|
||||
* React/Vue/Nuxt frontends, including sibling frontend folders.
|
||||
*/
|
||||
export function detectProject(workspaceRoot) {
|
||||
const backend = detectBackend(workspaceRoot);
|
||||
let frontend = detectFrontendAt(workspaceRoot);
|
||||
if (backend && !frontend) {
|
||||
for (const subdir of FRONTEND_SUBDIRS) {
|
||||
const candidateRoot = path.join(workspaceRoot, subdir);
|
||||
if (!fs.existsSync(candidateRoot))
|
||||
continue;
|
||||
const candidate = detectFrontendAt(candidateRoot);
|
||||
if (candidate) {
|
||||
frontend = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Pure frontend-only Vue/Nuxt/React at root without backend.
|
||||
if (!backend && !frontend) {
|
||||
frontend = detectFrontendAt(workspaceRoot);
|
||||
}
|
||||
if (backend) {
|
||||
return { backend, frontend, primary: backend };
|
||||
}
|
||||
if (frontend) {
|
||||
return { backend: null, frontend, primary: frontend };
|
||||
}
|
||||
const generic = new GenericAdapter(workspaceRoot);
|
||||
return { backend: null, frontend: null, primary: generic };
|
||||
}
|
||||
export function summarizeDetection(detected) {
|
||||
return {
|
||||
backend: detected.backend?.describe() ?? null,
|
||||
frontend: detected.frontend?.describe() ?? null,
|
||||
primary: detected.primary.kind,
|
||||
combined: Boolean(detected.backend && detected.frontend),
|
||||
};
|
||||
}
|
||||
function readWorkspaceGlobs(root) {
|
||||
const globs = new Set([...MONOREPO_GLOBS, ...FRONTEND_SUBDIRS.map((d) => d.split('/')[0]).filter(Boolean)]);
|
||||
const pkg = readPackageJson(root);
|
||||
const workspaces = pkg?.workspaces;
|
||||
if (Array.isArray(workspaces)) {
|
||||
for (const entry of workspaces) {
|
||||
if (typeof entry === 'string' && entry.includes('/*')) {
|
||||
globs.add(entry.replace(/\/\*$/, ''));
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (workspaces && typeof workspaces === 'object' && Array.isArray(workspaces.packages)) {
|
||||
for (const entry of workspaces.packages) {
|
||||
if (entry.includes('/*'))
|
||||
globs.add(entry.replace(/\/\*$/, ''));
|
||||
}
|
||||
}
|
||||
const pnpm = path.join(root, 'pnpm-workspace.yaml');
|
||||
if (fs.existsSync(pnpm)) {
|
||||
const text = fs.readFileSync(pnpm, 'utf8');
|
||||
for (const match of text.matchAll(/['"]?([A-Za-z0-9_-]+)\/\*['"]?/g)) {
|
||||
if (match[1])
|
||||
globs.add(match[1]);
|
||||
}
|
||||
}
|
||||
return [...globs];
|
||||
}
|
||||
function listImmediateSubdirs(dir) {
|
||||
if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory())
|
||||
return [];
|
||||
return fs
|
||||
.readdirSync(dir, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory() && !entry.name.startsWith('.') && entry.name !== 'node_modules' && entry.name !== 'vendor')
|
||||
.map((entry) => entry.name);
|
||||
}
|
||||
function packageLooksLikeProject(dir) {
|
||||
return (fs.existsSync(path.join(dir, 'package.json')) ||
|
||||
fs.existsSync(path.join(dir, 'composer.json')) ||
|
||||
fs.existsSync(path.join(dir, 'artisan')) ||
|
||||
fs.existsSync(path.join(dir, 'spark')) ||
|
||||
fs.existsSync(path.join(dir, 'manage.py')) ||
|
||||
fs.existsSync(path.join(dir, 'Gemfile')) ||
|
||||
fs.existsSync(path.join(dir, 'bin', 'console')));
|
||||
}
|
||||
/**
|
||||
* Scan the workspace for monorepo packages under apps/*, packages/*, services/*,
|
||||
* frontend/client/web, plus the workspace root itself.
|
||||
*/
|
||||
export function detectWorkspace(workspaceRoot) {
|
||||
const absoluteRoot = path.resolve(workspaceRoot);
|
||||
const packages = [];
|
||||
const seen = new Set();
|
||||
const addPackage = (name, absolutePath) => {
|
||||
const normalized = path.resolve(absolutePath);
|
||||
if (seen.has(normalized.toLowerCase()))
|
||||
return;
|
||||
if (!packageLooksLikeProject(normalized) && normalized !== absoluteRoot)
|
||||
return;
|
||||
seen.add(normalized.toLowerCase());
|
||||
const relativePath = path.relative(absoluteRoot, normalized) || '.';
|
||||
packages.push({
|
||||
name,
|
||||
relativePath: relativePath === '' ? '.' : relativePath.replace(/\\/g, '/'),
|
||||
absolutePath: normalized,
|
||||
detected: detectProject(normalized),
|
||||
});
|
||||
};
|
||||
addPackage(path.basename(absoluteRoot) || 'root', absoluteRoot);
|
||||
for (const globRoot of readWorkspaceGlobs(absoluteRoot)) {
|
||||
const base = path.join(absoluteRoot, globRoot);
|
||||
for (const child of listImmediateSubdirs(base)) {
|
||||
addPackage(child, path.join(base, child));
|
||||
}
|
||||
if (FRONTEND_SUBDIRS.some((d) => d === globRoot || d.startsWith(`${globRoot}/`)) && packageLooksLikeProject(base)) {
|
||||
addPackage(globRoot, base);
|
||||
}
|
||||
}
|
||||
return { root: absoluteRoot, packages };
|
||||
}
|
||||
export function summarizeWorkspace(workspace) {
|
||||
return {
|
||||
root: workspace.root,
|
||||
packageCount: workspace.packages.length,
|
||||
packages: workspace.packages.map((pkg) => ({
|
||||
name: pkg.name,
|
||||
path: pkg.relativePath,
|
||||
primary: pkg.detected.primary.kind,
|
||||
combined: Boolean(pkg.detected.backend && pkg.detected.frontend),
|
||||
backend: pkg.detected.backend?.kind ?? null,
|
||||
frontend: pkg.detected.frontend?.kind ?? null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Resolve a package name or relative path to a DetectedPackage inside the workspace.
|
||||
*/
|
||||
export function resolvePackage(workspaceRoot, packageNameOrPath) {
|
||||
const workspace = detectWorkspace(workspaceRoot);
|
||||
if (!packageNameOrPath) {
|
||||
const rootPkg = workspace.packages.find((p) => p.relativePath === '.') ?? workspace.packages[0];
|
||||
if (!rootPkg)
|
||||
throw new Error('No packages detected in the workspace.');
|
||||
return { workspace, pkg: rootPkg };
|
||||
}
|
||||
const needle = packageNameOrPath.replace(/\\/g, '/').replace(/^\.\//, '');
|
||||
const match = workspace.packages.find((p) => p.name === packageNameOrPath ||
|
||||
p.relativePath === needle ||
|
||||
p.relativePath === packageNameOrPath ||
|
||||
p.absolutePath.replace(/\\/g, '/').endsWith(`/${needle}`));
|
||||
if (!match) {
|
||||
const available = workspace.packages.map((p) => `${p.name} (${p.relativePath})`).join(', ');
|
||||
throw new Error(`Unknown package "${packageNameOrPath}". Available: ${available || '(none)'}.`);
|
||||
}
|
||||
return { workspace, pkg: match };
|
||||
}
|
||||
//# sourceMappingURL=detect.js.map
|
||||
Vendored
+1
File diff suppressed because one or more lines are too long
+184
@@ -0,0 +1,184 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
function fileExists(root, names) {
|
||||
return names.some((name) => fs.existsSync(path.join(root, name)));
|
||||
}
|
||||
function readRequirements(root) {
|
||||
for (const name of ['requirements.txt', 'requirements-dev.txt', 'pyproject.toml']) {
|
||||
const file = path.join(root, name);
|
||||
if (fs.existsSync(file))
|
||||
return fs.readFileSync(file, 'utf8').toLowerCase();
|
||||
}
|
||||
return '';
|
||||
}
|
||||
/**
|
||||
* Django detection stub: install/dev/test via manage.py / common Python tooling.
|
||||
* Deeper generators land in a later phase.
|
||||
*/
|
||||
export class DjangoAdapter {
|
||||
root;
|
||||
kind = 'django';
|
||||
label = 'Django';
|
||||
constructor(root) {
|
||||
this.root = root;
|
||||
}
|
||||
installCommand() {
|
||||
if (fileExists(this.root, ['pyproject.toml']) && fileExists(this.root, ['uv.lock'])) {
|
||||
return { command: 'uv', args: ['sync'], cwd: this.root };
|
||||
}
|
||||
if (fileExists(this.root, ['poetry.lock', 'pyproject.toml'])) {
|
||||
return { command: 'poetry', args: ['install'], cwd: this.root };
|
||||
}
|
||||
if (fileExists(this.root, ['requirements.txt'])) {
|
||||
return { command: 'pip', args: ['install', '-r', 'requirements.txt'], cwd: this.root };
|
||||
}
|
||||
throw new Error(`No Django dependency file found at "${this.root}" (expected requirements.txt, poetry.lock, or uv.lock).`);
|
||||
}
|
||||
devCommand() {
|
||||
if (!fileExists(this.root, ['manage.py'])) {
|
||||
throw new Error(`No manage.py found at "${this.root}".`);
|
||||
}
|
||||
return { command: 'python', args: ['manage.py', 'runserver', '127.0.0.1:8000'], cwd: this.root };
|
||||
}
|
||||
buildCommand() {
|
||||
if (!fileExists(this.root, ['manage.py']))
|
||||
return null;
|
||||
return { command: 'python', args: ['manage.py', 'collectstatic', '--noinput'], cwd: this.root };
|
||||
}
|
||||
testCommand(extraArgs = []) {
|
||||
if (!fileExists(this.root, ['manage.py']))
|
||||
return null;
|
||||
return { command: 'python', args: ['manage.py', 'test', ...extraArgs], cwd: this.root };
|
||||
}
|
||||
lintCommand() {
|
||||
return null;
|
||||
}
|
||||
readyPattern() {
|
||||
return /Starting development server at (https?:\/\/\S+)/i;
|
||||
}
|
||||
describe() {
|
||||
const req = readRequirements(this.root);
|
||||
return {
|
||||
kind: this.kind,
|
||||
label: this.label,
|
||||
root: this.root,
|
||||
hasManagePy: fileExists(this.root, ['manage.py']),
|
||||
notes: 'install/dev/test + generate_*_resource stubs available.',
|
||||
hintsDjango: /django/.test(req),
|
||||
};
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Rails detection stub: bundle install + bin/rails server / test.
|
||||
*/
|
||||
export class RailsAdapter {
|
||||
root;
|
||||
kind = 'rails';
|
||||
label = 'Rails';
|
||||
constructor(root) {
|
||||
this.root = root;
|
||||
}
|
||||
installCommand() {
|
||||
return { command: 'bundle', args: ['install'], cwd: this.root };
|
||||
}
|
||||
devCommand() {
|
||||
const binRails = path.join(this.root, 'bin', 'rails');
|
||||
if (fs.existsSync(binRails)) {
|
||||
return { command: 'ruby', args: [binRails, 'server', '-b', '127.0.0.1', '-p', '3000'], cwd: this.root };
|
||||
}
|
||||
return { command: 'rails', args: ['server', '-b', '127.0.0.1', '-p', '3000'], cwd: this.root };
|
||||
}
|
||||
buildCommand() {
|
||||
const binRails = path.join(this.root, 'bin', 'rails');
|
||||
if (fs.existsSync(binRails)) {
|
||||
return { command: 'ruby', args: [binRails, 'assets:precompile'], cwd: this.root };
|
||||
}
|
||||
return { command: 'rails', args: ['assets:precompile'], cwd: this.root };
|
||||
}
|
||||
testCommand(extraArgs = []) {
|
||||
const binRails = path.join(this.root, 'bin', 'rails');
|
||||
if (fs.existsSync(binRails)) {
|
||||
return { command: 'ruby', args: [binRails, 'test', ...extraArgs], cwd: this.root };
|
||||
}
|
||||
return { command: 'rails', args: ['test', ...extraArgs], cwd: this.root };
|
||||
}
|
||||
lintCommand() {
|
||||
return null;
|
||||
}
|
||||
readyPattern() {
|
||||
return /Listening on (https?:\/\/\S+)|Puma starting/i;
|
||||
}
|
||||
describe() {
|
||||
return {
|
||||
kind: this.kind,
|
||||
label: this.label,
|
||||
root: this.root,
|
||||
hasBinRails: fs.existsSync(path.join(this.root, 'bin', 'rails')),
|
||||
notes: 'install/dev/test + generate_*_resource stubs available.',
|
||||
};
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Symfony detection stub: composer + symfony CLI / php -S patterns via composer scripts when present.
|
||||
*/
|
||||
export class SymfonyAdapter {
|
||||
root;
|
||||
kind = 'symfony';
|
||||
label = 'Symfony';
|
||||
constructor(root) {
|
||||
this.root = root;
|
||||
}
|
||||
installCommand() {
|
||||
return { command: 'composer', args: ['install'], cwd: this.root };
|
||||
}
|
||||
devCommand() {
|
||||
// Prefer composer script when present; otherwise PHP built-in server on public/
|
||||
const composerPath = path.join(this.root, 'composer.json');
|
||||
if (fs.existsSync(composerPath)) {
|
||||
try {
|
||||
const pkg = JSON.parse(fs.readFileSync(composerPath, 'utf8'));
|
||||
if (pkg.scripts?.['serve'] || pkg.scripts?.dev) {
|
||||
const script = pkg.scripts.dev ? 'dev' : 'serve';
|
||||
return { command: 'composer', args: ['run', script], cwd: this.root };
|
||||
}
|
||||
}
|
||||
catch {
|
||||
// fall through
|
||||
}
|
||||
}
|
||||
return {
|
||||
command: 'php',
|
||||
args: ['-S', '127.0.0.1:8000', '-t', 'public'],
|
||||
cwd: this.root,
|
||||
};
|
||||
}
|
||||
buildCommand() {
|
||||
return { command: 'composer', args: ['install', '--no-dev', '--optimize-autoloader'], cwd: this.root };
|
||||
}
|
||||
testCommand(extraArgs = []) {
|
||||
const phpunit = path.join(this.root, 'bin', 'phpunit');
|
||||
if (fs.existsSync(phpunit) || fs.existsSync(`${phpunit}.bat`)) {
|
||||
return { command: 'php', args: ['bin/phpunit', ...extraArgs], cwd: this.root };
|
||||
}
|
||||
const vendor = path.join(this.root, 'vendor', 'bin', 'phpunit');
|
||||
if (fs.existsSync(vendor) || fs.existsSync(`${vendor}.bat`)) {
|
||||
return { command: 'php', args: [path.join('vendor', 'bin', 'phpunit'), ...extraArgs], cwd: this.root };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
lintCommand() {
|
||||
return null;
|
||||
}
|
||||
readyPattern() {
|
||||
return /(?:Development Server|started|listening).*(https?:\/\/\S+)/i;
|
||||
}
|
||||
describe() {
|
||||
return {
|
||||
kind: this.kind,
|
||||
label: this.label,
|
||||
root: this.root,
|
||||
notes: 'install/dev/test + generate_*_resource stubs available.',
|
||||
};
|
||||
}
|
||||
}
|
||||
//# sourceMappingURL=djangoRailsSymfony.js.map
|
||||
File diff suppressed because one or more lines are too long
Vendored
+70
@@ -0,0 +1,70 @@
|
||||
import { detectJsPackageManager, installCommandFor, readPackageJson, runScriptCommandFor, } from './packageManager.js';
|
||||
/**
|
||||
* Fallback adapter for any project that isn't recognized as Laravel,
|
||||
* CodeIgniter, or React. Drives everything off `package.json` scripts when
|
||||
* one exists; otherwise tool calls fail with a clear, actionable message
|
||||
* instead of guessing.
|
||||
*/
|
||||
export class GenericAdapter {
|
||||
root;
|
||||
kind = 'generic';
|
||||
label;
|
||||
packageManager;
|
||||
pkg;
|
||||
constructor(root) {
|
||||
this.root = root;
|
||||
this.pkg = readPackageJson(root);
|
||||
this.packageManager = this.pkg ? detectJsPackageManager(root) : undefined;
|
||||
this.label = this.pkg ? `Generic Node project (${this.packageManager})` : 'Unrecognized project';
|
||||
}
|
||||
installCommand() {
|
||||
if (!this.packageManager) {
|
||||
throw new Error(`No package.json found at "${this.root}" and no recognized framework detected. ` +
|
||||
`Nothing for run_install to do here.`);
|
||||
}
|
||||
const { command, args } = installCommandFor(this.packageManager);
|
||||
return { command, args, cwd: this.root };
|
||||
}
|
||||
devCommand() {
|
||||
return this.scriptCommand(['dev', 'start', 'serve']);
|
||||
}
|
||||
buildCommand() {
|
||||
return this.scriptCommandOrNull(['build']);
|
||||
}
|
||||
testCommand(extraArgs = []) {
|
||||
return this.scriptCommandOrNull(['test'], extraArgs);
|
||||
}
|
||||
lintCommand(fix = false) {
|
||||
return this.scriptCommandOrNull(fix ? ['format', 'lint:fix'] : ['lint']);
|
||||
}
|
||||
readyPattern() {
|
||||
return /(https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0)[:\d]*\S*)|ready|listening/i;
|
||||
}
|
||||
describe() {
|
||||
return {
|
||||
kind: this.kind,
|
||||
label: this.label,
|
||||
root: this.root,
|
||||
packageManager: this.packageManager ?? null,
|
||||
scripts: this.pkg?.scripts ?? {},
|
||||
};
|
||||
}
|
||||
scriptCommand(candidates) {
|
||||
const result = this.scriptCommandOrNull(candidates);
|
||||
if (!result) {
|
||||
throw new Error(`No package.json script found for any of [${candidates.join(', ')}] in "${this.root}". ` +
|
||||
`Add one of these scripts, or use run_script with an explicit script name.`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
scriptCommandOrNull(candidates, extraArgs = []) {
|
||||
if (!this.pkg?.scripts || !this.packageManager)
|
||||
return null;
|
||||
const script = candidates.find((name) => this.pkg?.scripts?.[name]);
|
||||
if (!script)
|
||||
return null;
|
||||
const { command, args } = runScriptCommandFor(this.packageManager, script, extraArgs);
|
||||
return { command, args, cwd: this.root };
|
||||
}
|
||||
}
|
||||
//# sourceMappingURL=generic.js.map
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"generic.js","sourceRoot":"","sources":["../../../src/lib/frameworks/generic.ts"],"names":[],"mappings":"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"}
|
||||
Vendored
+75
@@ -0,0 +1,75 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { hasComposerDependency, readComposerJson } from './composer.js';
|
||||
import { PhpFrameworkAdapter } from './phpBase.js';
|
||||
const DEV_HOST = '127.0.0.1';
|
||||
const DEV_PORT = '8000';
|
||||
function composeFileExists(root) {
|
||||
return ['docker-compose.yml', 'docker-compose.yaml', 'compose.yml', 'compose.yaml'].some((name) => fs.existsSync(path.join(root, name)));
|
||||
}
|
||||
function sailScriptPath(root) {
|
||||
const candidates = [
|
||||
path.join(root, 'vendor', 'bin', 'sail'),
|
||||
path.join(root, 'vendor', 'bin', 'sail.bat'),
|
||||
];
|
||||
return candidates.find((file) => fs.existsSync(file)) ?? null;
|
||||
}
|
||||
/** True when laravel/sail is required and a compose file is present. */
|
||||
export function laravelUsesSail(root) {
|
||||
return Boolean(hasComposerDependency(readComposerJson(root), 'laravel/sail') && composeFileExists(root));
|
||||
}
|
||||
export class LaravelAdapter extends PhpFrameworkAdapter {
|
||||
kind = 'laravel';
|
||||
label = 'Laravel';
|
||||
get usesSail() {
|
||||
return hasComposerDependency(this.composerJson, 'laravel/sail') && composeFileExists(this.root);
|
||||
}
|
||||
devCommand() {
|
||||
if (this.usesSail) {
|
||||
const sail = sailScriptPath(this.root);
|
||||
if (sail) {
|
||||
// Sail is a bash script; invoke via bash so Windows+Git Bash/WSL and Unix share one path.
|
||||
return {
|
||||
command: 'bash',
|
||||
args: [sail.replace(/\\/g, '/'), 'up'],
|
||||
cwd: this.root,
|
||||
};
|
||||
}
|
||||
return {
|
||||
command: 'docker',
|
||||
args: ['compose', 'up'],
|
||||
cwd: this.root,
|
||||
};
|
||||
}
|
||||
return {
|
||||
command: 'php',
|
||||
args: ['artisan', 'serve', `--host=${DEV_HOST}`, `--port=${DEV_PORT}`],
|
||||
cwd: this.root,
|
||||
};
|
||||
}
|
||||
readyPattern() {
|
||||
if (this.usesSail) {
|
||||
return /(?:Container|Service).+(?:Started|Healthy|Running)|Listening on (https?:\/\/\S+)|APP_URL[=:].*(https?:\/\/\S+)|Local:\s+(https?:\/\/\S+)|Server running on \[?(https?:\/\/[^\s\]]+)/i;
|
||||
}
|
||||
// `php artisan serve` prints: "INFO Server running on [http://127.0.0.1:8000]."
|
||||
return /Server running on \[?(https?:\/\/[^\s\]]+)/i;
|
||||
}
|
||||
describe() {
|
||||
const sail = this.usesSail;
|
||||
return {
|
||||
kind: this.kind,
|
||||
label: this.label,
|
||||
root: this.root,
|
||||
packageManager: this.packageManager,
|
||||
laravelVersion: this.composerJson?.require?.['laravel/framework'] ?? null,
|
||||
usesPest: Boolean(this.composerJson?.['require-dev']?.['pestphp/pest']),
|
||||
usesSail: sail,
|
||||
sailScript: sail ? sailScriptPath(this.root) : null,
|
||||
devUrl: sail ? 'http://localhost' : `http://${DEV_HOST}:${DEV_PORT}`,
|
||||
notes: sail
|
||||
? 'Sail detected: start_dev_server uses `vendor/bin/sail up` (or docker compose up).'
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
//# sourceMappingURL=laravel.js.map
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"laravel.js","sourceRoot":"","sources":["../../../src/lib/frameworks/laravel.ts"],"names":[],"mappings":"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"}
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
export function detectJsPackageManager(root) {
|
||||
if (fs.existsSync(path.join(root, 'pnpm-lock.yaml')))
|
||||
return 'pnpm';
|
||||
if (fs.existsSync(path.join(root, 'yarn.lock')))
|
||||
return 'yarn';
|
||||
if (fs.existsSync(path.join(root, 'bun.lockb')) || fs.existsSync(path.join(root, 'bun.lock')))
|
||||
return 'bun';
|
||||
return 'npm';
|
||||
}
|
||||
export function installCommandFor(pm) {
|
||||
switch (pm) {
|
||||
case 'pnpm':
|
||||
return { command: 'pnpm', args: ['install'] };
|
||||
case 'yarn':
|
||||
return { command: 'yarn', args: ['install'] };
|
||||
case 'bun':
|
||||
return { command: 'bun', args: ['install'] };
|
||||
case 'npm':
|
||||
default:
|
||||
return { command: 'npm', args: ['install'] };
|
||||
}
|
||||
}
|
||||
export function runScriptCommandFor(pm, script, extraArgs = []) {
|
||||
switch (pm) {
|
||||
case 'pnpm':
|
||||
return { command: 'pnpm', args: ['run', script, ...extraArgs] };
|
||||
case 'yarn':
|
||||
return { command: 'yarn', args: [script, ...extraArgs] };
|
||||
case 'bun':
|
||||
return { command: 'bun', args: ['run', script, ...extraArgs] };
|
||||
case 'npm':
|
||||
default:
|
||||
return { command: 'npm', args: ['run', script, ...(extraArgs.length ? ['--', ...extraArgs] : [])] };
|
||||
}
|
||||
}
|
||||
export function addDependencyCommandFor(pm, packages, options) {
|
||||
if (options.remove) {
|
||||
switch (pm) {
|
||||
case 'pnpm':
|
||||
return { command: 'pnpm', args: ['remove', ...packages] };
|
||||
case 'yarn':
|
||||
return { command: 'yarn', args: ['remove', ...packages] };
|
||||
case 'bun':
|
||||
return { command: 'bun', args: ['remove', ...packages] };
|
||||
case 'npm':
|
||||
default:
|
||||
return { command: 'npm', args: ['uninstall', ...packages] };
|
||||
}
|
||||
}
|
||||
switch (pm) {
|
||||
case 'pnpm':
|
||||
return { command: 'pnpm', args: ['add', ...(options.dev ? ['-D'] : []), ...packages] };
|
||||
case 'yarn':
|
||||
return { command: 'yarn', args: ['add', ...(options.dev ? ['-D'] : []), ...packages] };
|
||||
case 'bun':
|
||||
return { command: 'bun', args: ['add', ...(options.dev ? ['-d'] : []), ...packages] };
|
||||
case 'npm':
|
||||
default:
|
||||
return { command: 'npm', args: ['install', ...(options.dev ? ['-D'] : []), ...packages] };
|
||||
}
|
||||
}
|
||||
export function readPackageJson(root) {
|
||||
const file = path.join(root, 'package.json');
|
||||
if (!fs.existsSync(file))
|
||||
return null;
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||
}
|
||||
catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
export function hasDependency(pkg, name) {
|
||||
if (!pkg)
|
||||
return false;
|
||||
return Boolean(pkg.dependencies?.[name] || pkg.devDependencies?.[name]);
|
||||
}
|
||||
//# sourceMappingURL=packageManager.js.map
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"packageManager.js","sourceRoot":"","sources":["../../../src/lib/frameworks/packageManager.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,SAAS,CAAC;AACzB,OAAO,IAAI,MAAM,WAAW,CAAC;AAI7B,MAAM,UAAU,sBAAsB,CAAC,IAAY;IACjD,IAAI,EAAE,CAAC,UAAU,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,gBAAgB,CAAC,CAAC;QAAE,OAAO,MAAM,CAAC;IACpE,IAAI,EAAE,CAAC,UAAU,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,WAAW,CAAC,CAAC;QAAE,OAAO,MAAM,CAAC;IAC/D,IAAI,EAAE,CAAC,UAAU,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,WAAW,CAAC,CAAC,IAAI,EAAE,CAAC,UAAU,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,UAAU,CAAC,CAAC;QAAE,OAAO,KAAK,CAAC;IAC5G,OAAO,KAAK,CAAC;AACf,CAAC;AAED,MAAM,UAAU,iBAAiB,CAAC,EAAoB;IACpD,QAAQ,EAAE,EAAE,CAAC;QACX,KAAK,MAAM;YACT,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,SAAS,CAAC,EAAE,CAAC;QAChD,KAAK,MAAM;YACT,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,SAAS,CAAC,EAAE,CAAC;QAChD,KAAK,KAAK;YACR,OAAO,EAAE,OAAO,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC,SAAS,CAAC,EAAE,CAAC;QAC/C,KAAK,KAAK,CAAC;QACX;YACE,OAAO,EAAE,OAAO,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC,SAAS,CAAC,EAAE,CAAC;IACjD,CAAC;AACH,CAAC;AAED,MAAM,UAAU,mBAAmB,CAAC,EAAoB,EAAE,MAAc,EAAE,YAAsB,EAAE;IAChG,QAAQ,EAAE,EAAE,CAAC;QACX,KAAK,MAAM;YACT,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,SAAS,CAAC,EAAE,CAAC;QAClE,KAAK,MAAM;YACT,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,MAAM,EAAE,GAAG,SAAS,CAAC,EAAE,CAAC;QAC3D,KAAK,KAAK;YACR,OAAO,EAAE,OAAO,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,SAAS,CAAC,EAAE,CAAC;QACjE,KAAK,KAAK,CAAC;QACX;YACE,OAAO,EAAE,OAAO,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,CAAC,SAAS,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE,GAAG,SAAS,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE,CAAC;IACxG,CAAC;AACH,CAAC;AAED,MAAM,UAAU,uBAAuB,CACrC,EAAoB,EACpB,QAAkB,EAClB,OAA4C;IAE5C,IAAI,OAAO,CAAC,MAAM,EAAE,CAAC;QACnB,QAAQ,EAAE,EAAE,CAAC;YACX,KAAK,MAAM;gBACT,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,QAAQ,EAAE,GAAG,QAAQ,CAAC,EAAE,CAAC;YAC5D,KAAK,MAAM;gBACT,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,QAAQ,EAAE,GAAG,QAAQ,CAAC,EAAE,CAAC;YAC5D,KAAK,KAAK;gBACR,OAAO,EAAE,OAAO,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC,QAAQ,EAAE,GAAG,QAAQ,CAAC,EAAE,CAAC;YAC3D,KAAK,KAAK,CAAC;YACX;gBACE,OAAO,EAAE,OAAO,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC,WAAW,EAAE,GAAG,QAAQ,CAAC,EAAE,CAAC;QAChE,CAAC;IACH,CAAC;IAED,QAAQ,EAAE,EAAE,CAAC;QACX,KAAK,MAAM;YACT,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,KAAK,EAAE,GAAG,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,GAAG,QAAQ,CAAC,EAAE,CAAC;QACzF,KAAK,MAAM;YACT,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,KAAK,EAAE,GAAG,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,GAAG,QAAQ,CAAC,EAAE,CAAC;QACzF,KAAK,KAAK;YACR,OAAO,EAAE,OAAO,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC,KAAK,EAAE,GAAG,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,GAAG,QAAQ,CAAC,EAAE,CAAC;QACxF,KAAK,KAAK,CAAC;QACX;YACE,OAAO,EAAE,OAAO,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC,SAAS,EAAE,GAAG,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,GAAG,QAAQ,CAAC,EAAE,CAAC;IAC9F,CAAC;AACH,CAAC;AAUD,MAAM,UAAU,eAAe,CAAC,IAAY;IAC1C,MAAM,IAAI,GAAG,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,cAAc,CAAC,CAAC;IAC7C,IAAI,CAAC,EAAE,CAAC,UAAU,CAAC,IAAI,CAAC;QAAE,OAAO,IAAI,CAAC;IACtC,IAAI,CAAC;QACH,OAAO,IAAI,CAAC,KAAK,CAAC,EAAE,CAAC,YAAY,CAAC,IAAI,EAAE,MAAM,CAAC,CAAgB,CAAC;IAClE,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,IAAI,CAAC;IACd,CAAC;AACH,CAAC;AAED,MAAM,UAAU,aAAa,CAAC,GAAuB,EAAE,IAAY;IACjE,IAAI,CAAC,GAAG;QAAE,OAAO,KAAK,CAAC;IACvB,OAAO,OAAO,CAAC,GAAG,CAAC,YAAY,EAAE,CAAC,IAAI,CAAC,IAAI,GAAG,CAAC,eAAe,EAAE,CAAC,IAAI,CAAC,CAAC,CAAC;AAC1E,CAAC"}
|
||||
Vendored
+46
@@ -0,0 +1,46 @@
|
||||
import path from 'node:path';
|
||||
import { readComposerJson, vendorBinExists } from './composer.js';
|
||||
/**
|
||||
* Shared behaviour for Composer-based PHP frameworks (Laravel, CodeIgniter).
|
||||
* Subclasses only need to override the bits that actually differ: the CLI
|
||||
* used for scaffolding/migrations (artisan vs spark), the dev-server "ready"
|
||||
* line, and which test runner is used.
|
||||
*/
|
||||
export class PhpFrameworkAdapter {
|
||||
root;
|
||||
packageManager = 'composer';
|
||||
composerJson;
|
||||
constructor(root) {
|
||||
this.root = root;
|
||||
this.composerJson = readComposerJson(root);
|
||||
}
|
||||
installCommand() {
|
||||
return { command: 'composer', args: ['install'], cwd: this.root };
|
||||
}
|
||||
buildCommand() {
|
||||
return { command: 'composer', args: ['install', '--no-dev', '--optimize-autoloader'], cwd: this.root };
|
||||
}
|
||||
testCommand(extraArgs = []) {
|
||||
if (vendorBinExists(this.root, 'pest')) {
|
||||
return { command: this.vendorBin('pest'), args: extraArgs, cwd: this.root };
|
||||
}
|
||||
if (vendorBinExists(this.root, 'phpunit')) {
|
||||
return { command: this.vendorBin('phpunit'), args: extraArgs, cwd: this.root };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
lintCommand(fix = false) {
|
||||
if (vendorBinExists(this.root, 'pint')) {
|
||||
return { command: this.vendorBin('pint'), args: fix ? [] : ['--test'], cwd: this.root };
|
||||
}
|
||||
if (vendorBinExists(this.root, 'phpcs')) {
|
||||
return { command: this.vendorBin(fix ? 'phpcbf' : 'phpcs'), args: [], cwd: this.root };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
vendorBin(bin) {
|
||||
const suffix = process.platform === 'win32' ? '.bat' : '';
|
||||
return path.join(this.root, 'vendor', 'bin', `${bin}${suffix}`);
|
||||
}
|
||||
}
|
||||
//# sourceMappingURL=phpBase.js.map
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"phpBase.js","sourceRoot":"","sources":["../../../src/lib/frameworks/phpBase.ts"],"names":[],"mappings":"AAAA,OAAO,IAAI,MAAM,WAAW,CAAC;AAE7B,OAAO,EAAE,gBAAgB,EAAE,eAAe,EAAqB,MAAM,eAAe,CAAC;AAErF;;;;;GAKG;AACH,MAAM,OAAgB,mBAAmB;IAMlB;IAHZ,cAAc,GAAG,UAAU,CAAC;IAClB,YAAY,CAAsB;IAErD,YAAqB,IAAY;QAAZ,SAAI,GAAJ,IAAI,CAAQ;QAC/B,IAAI,CAAC,YAAY,GAAG,gBAAgB,CAAC,IAAI,CAAC,CAAC;IAC7C,CAAC;IAED,cAAc;QACZ,OAAO,EAAE,OAAO,EAAE,UAAU,EAAE,IAAI,EAAE,CAAC,SAAS,CAAC,EAAE,GAAG,EAAE,IAAI,CAAC,IAAI,EAAE,CAAC;IACpE,CAAC;IAED,YAAY;QACV,OAAO,EAAE,OAAO,EAAE,UAAU,EAAE,IAAI,EAAE,CAAC,SAAS,EAAE,UAAU,EAAE,uBAAuB,CAAC,EAAE,GAAG,EAAE,IAAI,CAAC,IAAI,EAAE,CAAC;IACzG,CAAC;IAED,WAAW,CAAC,YAAsB,EAAE;QAClC,IAAI,eAAe,CAAC,IAAI,CAAC,IAAI,EAAE,MAAM,CAAC,EAAE,CAAC;YACvC,OAAO,EAAE,OAAO,EAAE,IAAI,CAAC,SAAS,CAAC,MAAM,CAAC,EAAE,IAAI,EAAE,SAAS,EAAE,GAAG,EAAE,IAAI,CAAC,IAAI,EAAE,CAAC;QAC9E,CAAC;QACD,IAAI,eAAe,CAAC,IAAI,CAAC,IAAI,EAAE,SAAS,CAAC,EAAE,CAAC;YAC1C,OAAO,EAAE,OAAO,EAAE,IAAI,CAAC,SAAS,CAAC,SAAS,CAAC,EAAE,IAAI,EAAE,SAAS,EAAE,GAAG,EAAE,IAAI,CAAC,IAAI,EAAE,CAAC;QACjF,CAAC;QACD,OAAO,IAAI,CAAC;IACd,CAAC;IAED,WAAW,CAAC,GAAG,GAAG,KAAK;QACrB,IAAI,eAAe,CAAC,IAAI,CAAC,IAAI,EAAE,MAAM,CAAC,EAAE,CAAC;YACvC,OAAO,EAAE,OAAO,EAAE,IAAI,CAAC,SAAS,CAAC,MAAM,CAAC,EAAE,IAAI,EAAE,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,EAAE,GAAG,EAAE,IAAI,CAAC,IAAI,EAAE,CAAC;QAC1F,CAAC;QACD,IAAI,eAAe,CAAC,IAAI,CAAC,IAAI,EAAE,OAAO,CAAC,EAAE,CAAC;YACxC,OAAO,EAAE,OAAO,EAAE,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,OAAO,CAAC,EAAE,IAAI,EAAE,EAAE,EAAE,GAAG,EAAE,IAAI,CAAC,IAAI,EAAE,CAAC;QACzF,CAAC;QACD,OAAO,IAAI,CAAC;IACd,CAAC;IAES,SAAS,CAAC,GAAW;QAC7B,MAAM,MAAM,GAAG,OAAO,CAAC,QAAQ,KAAK,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,CAAC;QAC1D,OAAO,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,QAAQ,EAAE,KAAK,EAAE,GAAG,GAAG,GAAG,MAAM,EAAE,CAAC,CAAC;IAClE,CAAC;CAKF"}
|
||||
Vendored
+112
@@ -0,0 +1,112 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { detectJsPackageManager, hasDependency, installCommandFor, readPackageJson, runScriptCommandFor, } from './packageManager.js';
|
||||
function detectBundler(root, pkg) {
|
||||
if (hasDependency(pkg, 'next') || fileExists(root, ['next.config.js', 'next.config.mjs', 'next.config.ts'])) {
|
||||
return 'next';
|
||||
}
|
||||
if (fileExists(root, ['vite.config.js', 'vite.config.ts', 'vite.config.mjs']))
|
||||
return 'vite';
|
||||
if (hasDependency(pkg, 'react-scripts'))
|
||||
return 'create-react-app';
|
||||
return 'unknown';
|
||||
}
|
||||
function detectNextRouter(root) {
|
||||
const appLayouts = [
|
||||
path.join(root, 'app', 'layout.tsx'),
|
||||
path.join(root, 'app', 'layout.jsx'),
|
||||
path.join(root, 'src', 'app', 'layout.tsx'),
|
||||
path.join(root, 'src', 'app', 'layout.jsx'),
|
||||
];
|
||||
if (appLayouts.some((p) => fs.existsSync(p)))
|
||||
return 'app';
|
||||
const pagesIndexes = [
|
||||
path.join(root, 'pages', 'index.tsx'),
|
||||
path.join(root, 'pages', 'index.jsx'),
|
||||
path.join(root, 'pages', 'index.js'),
|
||||
path.join(root, 'src', 'pages', 'index.tsx'),
|
||||
path.join(root, 'src', 'pages', 'index.jsx'),
|
||||
];
|
||||
if (pagesIndexes.some((p) => fs.existsSync(p)))
|
||||
return 'pages';
|
||||
return 'unknown';
|
||||
}
|
||||
function fileExists(root, names) {
|
||||
return names.some((name) => fs.existsSync(path.join(root, name)));
|
||||
}
|
||||
export class ReactAdapter {
|
||||
root;
|
||||
kind = 'react';
|
||||
label;
|
||||
packageManager;
|
||||
pkg;
|
||||
bundler;
|
||||
nextRouter;
|
||||
constructor(root) {
|
||||
this.root = root;
|
||||
this.pkg = readPackageJson(root);
|
||||
this.packageManager = detectJsPackageManager(root);
|
||||
this.bundler = detectBundler(root, this.pkg);
|
||||
this.nextRouter = this.bundler === 'next' ? detectNextRouter(root) : 'unknown';
|
||||
this.label = `React (${this.bundler})`;
|
||||
}
|
||||
installCommand() {
|
||||
const { command, args } = installCommandFor(this.packageManager);
|
||||
return { command, args, cwd: this.root };
|
||||
}
|
||||
devCommand() {
|
||||
const script = this.findScript(['dev', 'start']);
|
||||
if (!script) {
|
||||
throw new Error(`No "dev" or "start" script found in package.json at "${this.root}".`);
|
||||
}
|
||||
const { command, args } = runScriptCommandFor(this.packageManager, script);
|
||||
return { command, args, cwd: this.root };
|
||||
}
|
||||
buildCommand() {
|
||||
return this.scriptCommandOrNull(['build']);
|
||||
}
|
||||
testCommand(extraArgs = []) {
|
||||
return this.scriptCommandOrNull(['test'], extraArgs);
|
||||
}
|
||||
lintCommand(fix = false) {
|
||||
return this.scriptCommandOrNull(fix ? ['format', 'lint:fix'] : ['lint']);
|
||||
}
|
||||
readyPattern() {
|
||||
switch (this.bundler) {
|
||||
case 'vite':
|
||||
return /Local:\s+(https?:\/\/\S+)/i;
|
||||
case 'next':
|
||||
return /(?:Local|ready)[^:]*:\s*(https?:\/\/\S+)|started server on[^,]*,\s*url:\s*(https?:\/\/\S+)/i;
|
||||
case 'create-react-app':
|
||||
return /Local:\s+(https?:\/\/\S+)|Compiled successfully/i;
|
||||
default:
|
||||
return /(https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0)[:\d]*\S*)/i;
|
||||
}
|
||||
}
|
||||
describe() {
|
||||
const isNext = this.bundler === 'next';
|
||||
return {
|
||||
kind: this.kind,
|
||||
label: this.label,
|
||||
root: this.root,
|
||||
packageManager: this.packageManager,
|
||||
bundler: this.bundler,
|
||||
router: isNext ? this.nextRouter : null,
|
||||
rsc: isNext && this.nextRouter === 'app',
|
||||
reactVersion: this.pkg?.dependencies?.react ?? this.pkg?.devDependencies?.react ?? null,
|
||||
typescript: hasDependency(this.pkg, 'typescript') || fileExists(this.root, ['tsconfig.json']),
|
||||
scripts: this.pkg?.scripts ?? {},
|
||||
};
|
||||
}
|
||||
findScript(candidates) {
|
||||
return candidates.find((name) => this.pkg?.scripts?.[name]);
|
||||
}
|
||||
scriptCommandOrNull(candidates, extraArgs = []) {
|
||||
const script = this.findScript(candidates);
|
||||
if (!script)
|
||||
return null;
|
||||
const { command, args } = runScriptCommandFor(this.packageManager, script, extraArgs);
|
||||
return { command, args, cwd: this.root };
|
||||
}
|
||||
}
|
||||
//# sourceMappingURL=react.js.map
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"react.js","sourceRoot":"","sources":["../../../src/lib/frameworks/react.ts"],"names":[],"mappings":"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"}
|
||||
Vendored
+29
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Picks which adapter a workflow tool call should act on.
|
||||
* - If the caller passed an explicit `target`, honor it (error if that side wasn't detected).
|
||||
* - If only one side was detected, use it regardless of `target`.
|
||||
* - If both sides were detected and no `target` was given, default to backend
|
||||
* (matches the common "start the API, then the frontend separately" flow)
|
||||
* but callers that care about the frontend by default (e.g. dev server) can
|
||||
* pass `defaultTarget: 'frontend'`.
|
||||
*/
|
||||
export function resolveTarget(detected, target, defaultTarget = 'backend') {
|
||||
if (target === 'backend') {
|
||||
if (!detected.backend) {
|
||||
throw new Error('target "backend" was requested, but no backend (Laravel/CodeIgniter/Symfony/Django/Rails) project was detected.');
|
||||
}
|
||||
return { adapter: detected.backend, target: 'backend' };
|
||||
}
|
||||
if (target === 'frontend') {
|
||||
if (!detected.frontend) {
|
||||
throw new Error('target "frontend" was requested, but no frontend (React/Vue/Nuxt) project was detected.');
|
||||
}
|
||||
return { adapter: detected.frontend, target: 'frontend' };
|
||||
}
|
||||
if (detected.backend && detected.frontend) {
|
||||
const adapter = defaultTarget === 'frontend' ? detected.frontend : detected.backend;
|
||||
return { adapter, target: defaultTarget };
|
||||
}
|
||||
return { adapter: detected.primary, target: 'primary' };
|
||||
}
|
||||
//# sourceMappingURL=resolve.js.map
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"resolve.js","sourceRoot":"","sources":["../../../src/lib/frameworks/resolve.ts"],"names":[],"mappings":"AAEA;;;;;;;;GAQG;AACH,MAAM,UAAU,aAAa,CAC3B,QAAyB,EACzB,MAAiC,EACjC,gBAA+B,SAAS;IAExC,IAAI,MAAM,KAAK,SAAS,EAAE,CAAC;QACzB,IAAI,CAAC,QAAQ,CAAC,OAAO,EAAE,CAAC;YACtB,MAAM,IAAI,KAAK,CACb,iHAAiH,CAClH,CAAC;QACJ,CAAC;QACD,OAAO,EAAE,OAAO,EAAE,QAAQ,CAAC,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,CAAC;IAC1D,CAAC;IACD,IAAI,MAAM,KAAK,UAAU,EAAE,CAAC;QAC1B,IAAI,CAAC,QAAQ,CAAC,QAAQ,EAAE,CAAC;YACvB,MAAM,IAAI,KAAK,CACb,yFAAyF,CAC1F,CAAC;QACJ,CAAC;QACD,OAAO,EAAE,OAAO,EAAE,QAAQ,CAAC,QAAQ,EAAE,MAAM,EAAE,UAAU,EAAE,CAAC;IAC5D,CAAC;IAED,IAAI,QAAQ,CAAC,OAAO,IAAI,QAAQ,CAAC,QAAQ,EAAE,CAAC;QAC1C,MAAM,OAAO,GAAG,aAAa,KAAK,UAAU,CAAC,CAAC,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC,CAAC,QAAQ,CAAC,OAAO,CAAC;QACpF,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,aAAa,EAAE,CAAC;IAC5C,CAAC;IAED,OAAO,EAAE,OAAO,EAAE,QAAQ,CAAC,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,CAAC;AAC1D,CAAC"}
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
export {};
|
||||
//# sourceMappingURL=types.js.map
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"types.js","sourceRoot":"","sources":["../../../src/lib/frameworks/types.ts"],"names":[],"mappings":""}
|
||||
Vendored
+127
@@ -0,0 +1,127 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { detectJsPackageManager, hasDependency, installCommandFor, readPackageJson, runScriptCommandFor, } from './packageManager.js';
|
||||
function fileExists(root, names) {
|
||||
return names.some((name) => fs.existsSync(path.join(root, name)));
|
||||
}
|
||||
/**
|
||||
* Vite + Vue SFC projects (non-Nuxt).
|
||||
*/
|
||||
export class VueAdapter {
|
||||
root;
|
||||
kind = 'vue';
|
||||
label;
|
||||
packageManager;
|
||||
pkg;
|
||||
constructor(root) {
|
||||
this.root = root;
|
||||
this.pkg = readPackageJson(root);
|
||||
this.packageManager = detectJsPackageManager(root);
|
||||
this.label = 'Vue';
|
||||
}
|
||||
installCommand() {
|
||||
const { command, args } = installCommandFor(this.packageManager);
|
||||
return { command, args, cwd: this.root };
|
||||
}
|
||||
devCommand() {
|
||||
return this.requireScript(['dev', 'start']);
|
||||
}
|
||||
buildCommand() {
|
||||
return this.scriptOrNull(['build']);
|
||||
}
|
||||
testCommand(extraArgs = []) {
|
||||
return this.scriptOrNull(['test'], extraArgs);
|
||||
}
|
||||
lintCommand(fix = false) {
|
||||
return this.scriptOrNull(fix ? ['format', 'lint:fix'] : ['lint']);
|
||||
}
|
||||
readyPattern() {
|
||||
return /Local:\s+(https?:\/\/\S+)/i;
|
||||
}
|
||||
describe() {
|
||||
return {
|
||||
kind: this.kind,
|
||||
label: this.label,
|
||||
root: this.root,
|
||||
packageManager: this.packageManager,
|
||||
vueVersion: this.pkg?.dependencies?.vue ?? this.pkg?.devDependencies?.vue ?? null,
|
||||
typescript: hasDependency(this.pkg, 'typescript') || fileExists(this.root, ['tsconfig.json']),
|
||||
vite: fileExists(this.root, ['vite.config.js', 'vite.config.ts', 'vite.config.mjs']),
|
||||
scripts: this.pkg?.scripts ?? {},
|
||||
};
|
||||
}
|
||||
requireScript(candidates) {
|
||||
const cmd = this.scriptOrNull(candidates);
|
||||
if (!cmd) {
|
||||
throw new Error(`No "${candidates.join('"/"')}" script found in package.json at "${this.root}".`);
|
||||
}
|
||||
return cmd;
|
||||
}
|
||||
scriptOrNull(candidates, extraArgs = []) {
|
||||
const script = candidates.find((name) => this.pkg?.scripts?.[name]);
|
||||
if (!script)
|
||||
return null;
|
||||
const { command, args } = runScriptCommandFor(this.packageManager, script, extraArgs);
|
||||
return { command, args, cwd: this.root };
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Nuxt (Vue meta-framework) projects.
|
||||
*/
|
||||
export class NuxtAdapter {
|
||||
root;
|
||||
kind = 'nuxt';
|
||||
label = 'Nuxt';
|
||||
packageManager;
|
||||
pkg;
|
||||
constructor(root) {
|
||||
this.root = root;
|
||||
this.pkg = readPackageJson(root);
|
||||
this.packageManager = detectJsPackageManager(root);
|
||||
}
|
||||
installCommand() {
|
||||
const { command, args } = installCommandFor(this.packageManager);
|
||||
return { command, args, cwd: this.root };
|
||||
}
|
||||
devCommand() {
|
||||
return this.requireScript(['dev', 'start']);
|
||||
}
|
||||
buildCommand() {
|
||||
return this.scriptOrNull(['build', 'generate']);
|
||||
}
|
||||
testCommand(extraArgs = []) {
|
||||
return this.scriptOrNull(['test'], extraArgs);
|
||||
}
|
||||
lintCommand(fix = false) {
|
||||
return this.scriptOrNull(fix ? ['format', 'lint:fix'] : ['lint']);
|
||||
}
|
||||
readyPattern() {
|
||||
return /Local:\s+(https?:\/\/\S+)|Nuxt\s+.+ready/i;
|
||||
}
|
||||
describe() {
|
||||
return {
|
||||
kind: this.kind,
|
||||
label: this.label,
|
||||
root: this.root,
|
||||
packageManager: this.packageManager,
|
||||
nuxtVersion: this.pkg?.dependencies?.nuxt ?? this.pkg?.devDependencies?.nuxt ?? null,
|
||||
typescript: hasDependency(this.pkg, 'typescript') || fileExists(this.root, ['tsconfig.json']),
|
||||
scripts: this.pkg?.scripts ?? {},
|
||||
};
|
||||
}
|
||||
requireScript(candidates) {
|
||||
const cmd = this.scriptOrNull(candidates);
|
||||
if (!cmd) {
|
||||
throw new Error(`No "${candidates.join('"/"')}" script found in package.json at "${this.root}".`);
|
||||
}
|
||||
return cmd;
|
||||
}
|
||||
scriptOrNull(candidates, extraArgs = []) {
|
||||
const script = candidates.find((name) => this.pkg?.scripts?.[name]);
|
||||
if (!script)
|
||||
return null;
|
||||
const { command, args } = runScriptCommandFor(this.packageManager, script, extraArgs);
|
||||
return { command, args, cwd: this.root };
|
||||
}
|
||||
}
|
||||
//# sourceMappingURL=vue.js.map
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"vue.js","sourceRoot":"","sources":["../../../src/lib/frameworks/vue.ts"],"names":[],"mappings":"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"}
|
||||
Vendored
+16
@@ -0,0 +1,16 @@
|
||||
function write(level, message, meta) {
|
||||
const line = `[${new Date().toISOString()}] [${level.toUpperCase()}] ${message}`;
|
||||
if (meta !== undefined) {
|
||||
process.stderr.write(`${line} ${JSON.stringify(meta)}\n`);
|
||||
}
|
||||
else {
|
||||
process.stderr.write(`${line}\n`);
|
||||
}
|
||||
}
|
||||
export const logger = {
|
||||
debug: (message, meta) => write('debug', message, meta),
|
||||
info: (message, meta) => write('info', message, meta),
|
||||
warn: (message, meta) => write('warn', message, meta),
|
||||
error: (message, meta) => write('error', message, meta),
|
||||
};
|
||||
//# sourceMappingURL=logger.js.map
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"logger.js","sourceRoot":"","sources":["../../src/lib/logger.ts"],"names":[],"mappings":"AAOA,SAAS,KAAK,CAAC,KAAY,EAAE,OAAe,EAAE,IAAc;IAC1D,MAAM,IAAI,GAAG,IAAI,IAAI,IAAI,EAAE,CAAC,WAAW,EAAE,MAAM,KAAK,CAAC,WAAW,EAAE,KAAK,OAAO,EAAE,CAAC;IACjF,IAAI,IAAI,KAAK,SAAS,EAAE,CAAC;QACvB,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,GAAG,IAAI,IAAI,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IAC5D,CAAC;SAAM,CAAC;QACN,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,GAAG,IAAI,IAAI,CAAC,CAAC;IACpC,CAAC;AACH,CAAC;AAED,MAAM,CAAC,MAAM,MAAM,GAAG;IACpB,KAAK,EAAE,CAAC,OAAe,EAAE,IAAc,EAAE,EAAE,CAAC,KAAK,CAAC,OAAO,EAAE,OAAO,EAAE,IAAI,CAAC;IACzE,IAAI,EAAE,CAAC,OAAe,EAAE,IAAc,EAAE,EAAE,CAAC,KAAK,CAAC,MAAM,EAAE,OAAO,EAAE,IAAI,CAAC;IACvE,IAAI,EAAE,CAAC,OAAe,EAAE,IAAc,EAAE,EAAE,CAAC,KAAK,CAAC,MAAM,EAAE,OAAO,EAAE,IAAI,CAAC;IACvE,KAAK,EAAE,CAAC,OAAe,EAAE,IAAc,EAAE,EAAE,CAAC,KAAK,CAAC,OAAO,EAAE,OAAO,EAAE,IAAI,CAAC;CAC1E,CAAC"}
|
||||
Vendored
+148
@@ -0,0 +1,148 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { execa } from 'execa';
|
||||
import { config } from '../config.js';
|
||||
import { logger } from './logger.js';
|
||||
class ProcessManager {
|
||||
processes = new Map();
|
||||
start(options) {
|
||||
if (this.runningCount() >= config.maxConcurrentProcesses) {
|
||||
throw new Error(`Refusing to start another process: already at the max of ${config.maxConcurrentProcesses} concurrent managed processes. Stop one first with stop_process.`);
|
||||
}
|
||||
const id = randomUUID();
|
||||
const emitter = new EventEmitter();
|
||||
const subprocess = execa(options.command, options.args, {
|
||||
cwd: options.cwd,
|
||||
env: options.env,
|
||||
reject: false,
|
||||
stdin: 'ignore',
|
||||
forceKillAfterDelay: 5000,
|
||||
});
|
||||
const managed = {
|
||||
id,
|
||||
label: options.label,
|
||||
command: options.command,
|
||||
args: options.args,
|
||||
cwd: options.cwd,
|
||||
pid: subprocess.pid,
|
||||
status: 'running',
|
||||
exitCode: null,
|
||||
startedAt: new Date().toISOString(),
|
||||
emitter,
|
||||
logs: [],
|
||||
subprocess,
|
||||
};
|
||||
const appendLog = (chunk) => {
|
||||
const text = chunk.toString();
|
||||
for (const line of text.split(/\r?\n/)) {
|
||||
if (line.length === 0)
|
||||
continue;
|
||||
managed.logs.push(line);
|
||||
if (managed.logs.length > config.maxBufferedLogLines) {
|
||||
managed.logs.splice(0, managed.logs.length - config.maxBufferedLogLines);
|
||||
}
|
||||
emitter.emit('log', line);
|
||||
}
|
||||
};
|
||||
subprocess.stdout?.on('data', appendLog);
|
||||
subprocess.stderr?.on('data', appendLog);
|
||||
subprocess
|
||||
.then((result) => {
|
||||
managed.status = managed.status === 'stopped' ? 'stopped' : 'exited';
|
||||
managed.exitCode = result.exitCode ?? null;
|
||||
managed.exitedAt = new Date().toISOString();
|
||||
emitter.emit('exit');
|
||||
})
|
||||
.catch((error) => {
|
||||
managed.status = 'error';
|
||||
managed.exitedAt = new Date().toISOString();
|
||||
appendLog(`[process error] ${error instanceof Error ? error.message : String(error)}`);
|
||||
emitter.emit('exit');
|
||||
logger.error(`managed process ${id} failed`, { error: error instanceof Error ? error.message : error });
|
||||
});
|
||||
this.processes.set(id, managed);
|
||||
return this.toInfo(managed);
|
||||
}
|
||||
/**
|
||||
* Resolves once a log line matches `pattern`, the process exits, or `timeoutMs` elapses.
|
||||
* Used by start_dev_server so it can report the actual "ready" URL instead of guessing.
|
||||
*/
|
||||
waitForLog(id, pattern, timeoutMs) {
|
||||
const managed = this.require(id);
|
||||
const existing = managed.logs.find((line) => pattern.test(line));
|
||||
if (existing)
|
||||
return Promise.resolve({ matchedLine: existing, timedOut: false });
|
||||
return new Promise((resolve) => {
|
||||
const cleanup = () => {
|
||||
clearTimeout(timer);
|
||||
managed.emitter.off('log', onLog);
|
||||
managed.emitter.off('exit', onExit);
|
||||
};
|
||||
const onLog = (line) => {
|
||||
if (pattern.test(line)) {
|
||||
cleanup();
|
||||
resolve({ matchedLine: line, timedOut: false });
|
||||
}
|
||||
};
|
||||
const onExit = () => {
|
||||
cleanup();
|
||||
resolve({ matchedLine: null, timedOut: false });
|
||||
};
|
||||
const timer = setTimeout(() => {
|
||||
cleanup();
|
||||
resolve({ matchedLine: null, timedOut: true });
|
||||
}, timeoutMs);
|
||||
managed.emitter.on('log', onLog);
|
||||
managed.emitter.on('exit', onExit);
|
||||
});
|
||||
}
|
||||
stop(id) {
|
||||
const managed = this.require(id);
|
||||
if (managed.status === 'running') {
|
||||
managed.status = 'stopped';
|
||||
managed.subprocess.kill('SIGTERM');
|
||||
}
|
||||
return this.toInfo(managed);
|
||||
}
|
||||
getLogs(id, tail) {
|
||||
const managed = this.require(id);
|
||||
const logs = tail && tail > 0 ? managed.logs.slice(-tail) : managed.logs.slice();
|
||||
return { info: this.toInfo(managed), logs };
|
||||
}
|
||||
list() {
|
||||
return Array.from(this.processes.values()).map((p) => this.toInfo(p));
|
||||
}
|
||||
stopAll() {
|
||||
for (const managed of this.processes.values()) {
|
||||
if (managed.status === 'running') {
|
||||
managed.subprocess.kill('SIGTERM');
|
||||
}
|
||||
}
|
||||
}
|
||||
runningCount() {
|
||||
return Array.from(this.processes.values()).filter((p) => p.status === 'running').length;
|
||||
}
|
||||
require(id) {
|
||||
const managed = this.processes.get(id);
|
||||
if (!managed) {
|
||||
throw new Error(`Unknown processId "${id}". It may have already exited and been forgotten, or never existed.`);
|
||||
}
|
||||
return managed;
|
||||
}
|
||||
toInfo(p) {
|
||||
return {
|
||||
id: p.id,
|
||||
label: p.label,
|
||||
command: p.command,
|
||||
args: p.args,
|
||||
cwd: p.cwd,
|
||||
pid: p.pid,
|
||||
status: p.status,
|
||||
exitCode: p.exitCode,
|
||||
startedAt: p.startedAt,
|
||||
exitedAt: p.exitedAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
export const processManager = new ProcessManager();
|
||||
//# sourceMappingURL=processManager.js.map
|
||||
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+37
@@ -0,0 +1,37 @@
|
||||
import { execa } from 'execa';
|
||||
import { config } from '../config.js';
|
||||
import { assertAllowedBinary } from './sandbox.js';
|
||||
/**
|
||||
* Runs a command to completion (foreground) and returns its combined,
|
||||
* length-capped output. Intended for install/build/lint/test-style commands
|
||||
* that the agent needs the final result of, as opposed to long-running dev
|
||||
* servers (see processManager.ts for those).
|
||||
*/
|
||||
export async function runCommand(command, args, options) {
|
||||
assertAllowedBinary(command);
|
||||
const timeoutMs = options.timeoutMs ?? config.defaultCommandTimeoutMs;
|
||||
const result = await execa(command, args, {
|
||||
cwd: options.cwd,
|
||||
env: options.env,
|
||||
timeout: timeoutMs,
|
||||
reject: false,
|
||||
all: true,
|
||||
});
|
||||
const rawOutput = result.all ?? [result.stdout, result.stderr].filter(Boolean).join('\n');
|
||||
return {
|
||||
command,
|
||||
args,
|
||||
cwd: options.cwd,
|
||||
exitCode: result.exitCode ?? null,
|
||||
timedOut: result.timedOut,
|
||||
failed: result.failed,
|
||||
output: truncate(String(rawOutput ?? ''), config.maxToolOutputChars),
|
||||
};
|
||||
}
|
||||
function truncate(text, max) {
|
||||
if (text.length <= max)
|
||||
return text;
|
||||
const omitted = text.length - max;
|
||||
return `${text.slice(0, max)}\n... [truncated ${omitted} more characters] ...`;
|
||||
}
|
||||
//# sourceMappingURL=runCommand.js.map
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"runCommand.js","sourceRoot":"","sources":["../../src/lib/runCommand.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,MAAM,OAAO,CAAC;AAC9B,OAAO,EAAE,MAAM,EAAE,MAAM,cAAc,CAAC;AACtC,OAAO,EAAE,mBAAmB,EAAE,MAAM,cAAc,CAAC;AAkBnD;;;;;GAKG;AACH,MAAM,CAAC,KAAK,UAAU,UAAU,CAC9B,OAAe,EACf,IAAc,EACd,OAA0B;IAE1B,mBAAmB,CAAC,OAAO,CAAC,CAAC;IAC7B,MAAM,SAAS,GAAG,OAAO,CAAC,SAAS,IAAI,MAAM,CAAC,uBAAuB,CAAC;IAEtE,MAAM,MAAM,GAAG,MAAM,KAAK,CAAC,OAAO,EAAE,IAAI,EAAE;QACxC,GAAG,EAAE,OAAO,CAAC,GAAG;QAChB,GAAG,EAAE,OAAO,CAAC,GAAG;QAChB,OAAO,EAAE,SAAS;QAClB,MAAM,EAAE,KAAK;QACb,GAAG,EAAE,IAAI;KACV,CAAC,CAAC;IAEH,MAAM,SAAS,GAAG,MAAM,CAAC,GAAG,IAAI,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IAC1F,OAAO;QACL,OAAO;QACP,IAAI;QACJ,GAAG,EAAE,OAAO,CAAC,GAAG;QAChB,QAAQ,EAAE,MAAM,CAAC,QAAQ,IAAI,IAAI;QACjC,QAAQ,EAAE,MAAM,CAAC,QAAQ;QACzB,MAAM,EAAE,MAAM,CAAC,MAAM;QACrB,MAAM,EAAE,QAAQ,CAAC,MAAM,CAAC,SAAS,IAAI,EAAE,CAAC,EAAE,MAAM,CAAC,kBAAkB,CAAC;KACrE,CAAC;AACJ,CAAC;AAED,SAAS,QAAQ,CAAC,IAAY,EAAE,GAAW;IACzC,IAAI,IAAI,CAAC,MAAM,IAAI,GAAG;QAAE,OAAO,IAAI,CAAC;IACpC,MAAM,OAAO,GAAG,IAAI,CAAC,MAAM,GAAG,GAAG,CAAC;IAClC,OAAO,GAAG,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,GAAG,CAAC,oBAAoB,OAAO,uBAAuB,CAAC;AACjF,CAAC"}
|
||||
Vendored
+42
@@ -0,0 +1,42 @@
|
||||
import path from 'node:path';
|
||||
import { config } from '../config.js';
|
||||
export class SandboxViolationError extends Error {
|
||||
constructor(message) {
|
||||
super(message);
|
||||
this.name = 'SandboxViolationError';
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Resolves `relativePath` against `root` and guarantees the result stays
|
||||
* inside `root`. Throws SandboxViolationError on any attempt to escape
|
||||
* (e.g. via `..`, absolute paths outside the root, or symlink-style tricks
|
||||
* handled at the OS level by the caller when it actually touches the fs).
|
||||
*/
|
||||
export function resolveWorkspacePath(root, relativePath) {
|
||||
const absoluteRoot = path.resolve(root);
|
||||
const target = path.resolve(absoluteRoot, relativePath || '.');
|
||||
const normalizedRoot = absoluteRoot.toLowerCase();
|
||||
const normalizedTarget = target.toLowerCase();
|
||||
if (normalizedTarget !== normalizedRoot && !normalizedTarget.startsWith(normalizedRoot + path.sep)) {
|
||||
throw new SandboxViolationError(`Refusing to access "${relativePath}": it resolves outside the workspace root (${absoluteRoot}).`);
|
||||
}
|
||||
return target;
|
||||
}
|
||||
function basenameNoExt(binary) {
|
||||
const base = path.basename(binary);
|
||||
return base.replace(/\.(exe|cmd|bat|ps1)$/i, '');
|
||||
}
|
||||
/**
|
||||
* Throws unless `binary` is on the configured allow-list. Command
|
||||
* construction elsewhere in the codebase always passes `args` as a separate
|
||||
* array (never a single shell string), so this allow-list is the only line
|
||||
* of defense needed against arbitrary command execution.
|
||||
*/
|
||||
export function assertAllowedBinary(binary) {
|
||||
const name = basenameNoExt(binary).toLowerCase();
|
||||
const allowed = config.allowedBinaries.some((candidate) => candidate.toLowerCase() === name);
|
||||
if (!allowed) {
|
||||
throw new SandboxViolationError(`Refusing to run "${binary}": it is not on the allow-list (${config.allowedBinaries.join(', ')}).`);
|
||||
}
|
||||
}
|
||||
//# sourceMappingURL=sandbox.js.map
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"sandbox.js","sourceRoot":"","sources":["../../src/lib/sandbox.ts"],"names":[],"mappings":"AAAA,OAAO,IAAI,MAAM,WAAW,CAAC;AAC7B,OAAO,EAAE,MAAM,EAAE,MAAM,cAAc,CAAC;AAEtC,MAAM,OAAO,qBAAsB,SAAQ,KAAK;IAC9C,YAAY,OAAe;QACzB,KAAK,CAAC,OAAO,CAAC,CAAC;QACf,IAAI,CAAC,IAAI,GAAG,uBAAuB,CAAC;IACtC,CAAC;CACF;AAED;;;;;GAKG;AACH,MAAM,UAAU,oBAAoB,CAAC,IAAY,EAAE,YAAoB;IACrE,MAAM,YAAY,GAAG,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC;IACxC,MAAM,MAAM,GAAG,IAAI,CAAC,OAAO,CAAC,YAAY,EAAE,YAAY,IAAI,GAAG,CAAC,CAAC;IAE/D,MAAM,cAAc,GAAG,YAAY,CAAC,WAAW,EAAE,CAAC;IAClD,MAAM,gBAAgB,GAAG,MAAM,CAAC,WAAW,EAAE,CAAC;IAE9C,IAAI,gBAAgB,KAAK,cAAc,IAAI,CAAC,gBAAgB,CAAC,UAAU,CAAC,cAAc,GAAG,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC;QACnG,MAAM,IAAI,qBAAqB,CAC7B,uBAAuB,YAAY,8CAA8C,YAAY,IAAI,CAClG,CAAC;IACJ,CAAC;IAED,OAAO,MAAM,CAAC;AAChB,CAAC;AAED,SAAS,aAAa,CAAC,MAAc;IACnC,MAAM,IAAI,GAAG,IAAI,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC;IACnC,OAAO,IAAI,CAAC,OAAO,CAAC,uBAAuB,EAAE,EAAE,CAAC,CAAC;AACnD,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,mBAAmB,CAAC,MAAc;IAChD,MAAM,IAAI,GAAG,aAAa,CAAC,MAAM,CAAC,CAAC,WAAW,EAAE,CAAC;IACjD,MAAM,OAAO,GAAI,MAAM,CAAC,eAAqC,CAAC,IAAI,CAChE,CAAC,SAAS,EAAE,EAAE,CAAC,SAAS,CAAC,WAAW,EAAE,KAAK,IAAI,CAChD,CAAC;IACF,IAAI,CAAC,OAAO,EAAE,CAAC;QACb,MAAM,IAAI,qBAAqB,CAC7B,oBAAoB,MAAM,mCAAmC,MAAM,CAAC,eAAe,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,CACnG,CAAC;IACJ,CAAC;AACH,CAAC"}
|
||||
Vendored
+21
@@ -0,0 +1,21 @@
|
||||
function words(input) {
|
||||
return input
|
||||
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
||||
.split(/[\s_-]+/)
|
||||
.filter(Boolean);
|
||||
}
|
||||
export function pascalCase(input) {
|
||||
return words(input)
|
||||
.map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
|
||||
.join('');
|
||||
}
|
||||
export function camelCase(input) {
|
||||
const pascal = pascalCase(input);
|
||||
return pascal.charAt(0).toLowerCase() + pascal.slice(1);
|
||||
}
|
||||
export function kebabCase(input) {
|
||||
return words(input)
|
||||
.map((w) => w.toLowerCase())
|
||||
.join('-');
|
||||
}
|
||||
//# sourceMappingURL=strings.js.map
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"strings.js","sourceRoot":"","sources":["../../src/lib/strings.ts"],"names":[],"mappings":"AAAA,SAAS,KAAK,CAAC,KAAa;IAC1B,OAAO,KAAK;SACT,OAAO,CAAC,oBAAoB,EAAE,OAAO,CAAC;SACtC,KAAK,CAAC,SAAS,CAAC;SAChB,MAAM,CAAC,OAAO,CAAC,CAAC;AACrB,CAAC;AAED,MAAM,UAAU,UAAU,CAAC,KAAa;IACtC,OAAO,KAAK,CAAC,KAAK,CAAC;SAChB,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,WAAW,EAAE,GAAG,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,WAAW,EAAE,CAAC;SAChE,IAAI,CAAC,EAAE,CAAC,CAAC;AACd,CAAC;AAED,MAAM,UAAU,SAAS,CAAC,KAAa;IACrC,MAAM,MAAM,GAAG,UAAU,CAAC,KAAK,CAAC,CAAC;IACjC,OAAO,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,WAAW,EAAE,GAAG,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;AAC1D,CAAC;AAED,MAAM,UAAU,SAAS,CAAC,KAAa;IACrC,OAAO,KAAK,CAAC,KAAK,CAAC;SAChB,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,WAAW,EAAE,CAAC;SAC3B,IAAI,CAAC,GAAG,CAAC,CAAC;AACf,CAAC"}
|
||||
Vendored
+57
@@ -0,0 +1,57 @@
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { config } from './config.js';
|
||||
import { detectProject, summarizeDetection } from './lib/frameworks/detect.js';
|
||||
import { registerWorkflowTools } from './tools/workflow/index.js';
|
||||
import { registerScaffoldTools } from './tools/scaffold/index.js';
|
||||
import { registerLaravelTools } from './tools/laravel/index.js';
|
||||
import { registerCodeIgniterTools } from './tools/codeigniter/index.js';
|
||||
import { registerReactTools } from './tools/react/index.js';
|
||||
import { registerBrowserTools } from './tools/browser/index.js';
|
||||
import { registerDockerTools } from './tools/docker/index.js';
|
||||
import { registerDocsTools } from './tools/docs/index.js';
|
||||
import { registerResources } from './tools/resources/index.js';
|
||||
import { registerPrompts } from './tools/prompts/index.js';
|
||||
import { registerGitTools } from './tools/git/index.js';
|
||||
import { registerAuditTools } from './tools/audit/index.js';
|
||||
import { registerBackendResourceTools } from './tools/backends/index.js';
|
||||
const SERVER_VERSION = '0.6.0';
|
||||
export function createServer() {
|
||||
const server = new McpServer({ name: 'web-dev-mcp', version: SERVER_VERSION }, {
|
||||
instructions: 'Tools for web development: framework-aware workflow for Laravel (Sail-aware), CodeIgniter, Symfony, Django, Rails, ' +
|
||||
'React, Vue, and Nuxt (with monorepo detect_workspace + package scoping), scaffolding, ' +
|
||||
'Laravel/CodeIgniter/React/Next + generate_django/rails/symfony_resource tools, browser_*, docker_*, docs, ' +
|
||||
'git_* (incl. git_diff_summarize), lighthouse_audit, resources, and prompts (incl. visual-diff-ci). ' +
|
||||
'Call detect_framework or detect_workspace first.',
|
||||
});
|
||||
registerWorkflowTools(server);
|
||||
registerScaffoldTools(server);
|
||||
registerLaravelTools(server);
|
||||
registerCodeIgniterTools(server);
|
||||
registerReactTools(server);
|
||||
registerBackendResourceTools(server);
|
||||
registerBrowserTools(server);
|
||||
registerDockerTools(server);
|
||||
registerDocsTools(server);
|
||||
registerGitTools(server);
|
||||
registerAuditTools(server);
|
||||
registerResources(server);
|
||||
registerPrompts(server);
|
||||
server.registerResource('project-info', 'workspace://project.json', {
|
||||
title: 'Project Info',
|
||||
description: 'Detected framework adapter(s) and metadata for the current workspace.',
|
||||
mimeType: 'application/json',
|
||||
}, async (uri) => {
|
||||
const detected = detectProject(config.workspaceRoot);
|
||||
return {
|
||||
contents: [
|
||||
{
|
||||
uri: uri.href,
|
||||
mimeType: 'application/json',
|
||||
text: JSON.stringify(summarizeDetection(detected), null, 2),
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
return server;
|
||||
}
|
||||
//# sourceMappingURL=server.js.map
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"server.js","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AACpE,OAAO,EAAE,MAAM,EAAE,MAAM,aAAa,CAAC;AACrC,OAAO,EAAE,aAAa,EAAE,kBAAkB,EAAE,MAAM,4BAA4B,CAAC;AAC/E,OAAO,EAAE,qBAAqB,EAAE,MAAM,2BAA2B,CAAC;AAClE,OAAO,EAAE,qBAAqB,EAAE,MAAM,2BAA2B,CAAC;AAClE,OAAO,EAAE,oBAAoB,EAAE,MAAM,0BAA0B,CAAC;AAChE,OAAO,EAAE,wBAAwB,EAAE,MAAM,8BAA8B,CAAC;AACxE,OAAO,EAAE,kBAAkB,EAAE,MAAM,wBAAwB,CAAC;AAC5D,OAAO,EAAE,oBAAoB,EAAE,MAAM,0BAA0B,CAAC;AAChE,OAAO,EAAE,mBAAmB,EAAE,MAAM,yBAAyB,CAAC;AAC9D,OAAO,EAAE,iBAAiB,EAAE,MAAM,uBAAuB,CAAC;AAC1D,OAAO,EAAE,iBAAiB,EAAE,MAAM,4BAA4B,CAAC;AAC/D,OAAO,EAAE,eAAe,EAAE,MAAM,0BAA0B,CAAC;AAC3D,OAAO,EAAE,gBAAgB,EAAE,MAAM,sBAAsB,CAAC;AACxD,OAAO,EAAE,kBAAkB,EAAE,MAAM,wBAAwB,CAAC;AAC5D,OAAO,EAAE,4BAA4B,EAAE,MAAM,2BAA2B,CAAC;AAEzE,MAAM,cAAc,GAAG,OAAO,CAAC;AAE/B,MAAM,UAAU,YAAY;IAC1B,MAAM,MAAM,GAAG,IAAI,SAAS,CAC1B,EAAE,IAAI,EAAE,aAAa,EAAE,OAAO,EAAE,cAAc,EAAE,EAChD;QACE,YAAY,EACV,qHAAqH;YACrH,wFAAwF;YACxF,4GAA4G;YAC5G,qGAAqG;YACrG,kDAAkD;KACrD,CACF,CAAC;IAEF,qBAAqB,CAAC,MAAM,CAAC,CAAC;IAC9B,qBAAqB,CAAC,MAAM,CAAC,CAAC;IAC9B,oBAAoB,CAAC,MAAM,CAAC,CAAC;IAC7B,wBAAwB,CAAC,MAAM,CAAC,CAAC;IACjC,kBAAkB,CAAC,MAAM,CAAC,CAAC;IAC3B,4BAA4B,CAAC,MAAM,CAAC,CAAC;IACrC,oBAAoB,CAAC,MAAM,CAAC,CAAC;IAC7B,mBAAmB,CAAC,MAAM,CAAC,CAAC;IAC5B,iBAAiB,CAAC,MAAM,CAAC,CAAC;IAC1B,gBAAgB,CAAC,MAAM,CAAC,CAAC;IACzB,kBAAkB,CAAC,MAAM,CAAC,CAAC;IAC3B,iBAAiB,CAAC,MAAM,CAAC,CAAC;IAC1B,eAAe,CAAC,MAAM,CAAC,CAAC;IAExB,MAAM,CAAC,gBAAgB,CACrB,cAAc,EACd,0BAA0B,EAC1B;QACE,KAAK,EAAE,cAAc;QACrB,WAAW,EAAE,uEAAuE;QACpF,QAAQ,EAAE,kBAAkB;KAC7B,EACD,KAAK,EAAE,GAAG,EAAE,EAAE;QACZ,MAAM,QAAQ,GAAG,aAAa,CAAC,MAAM,CAAC,aAAa,CAAC,CAAC;QACrD,OAAO;YACL,QAAQ,EAAE;gBACR;oBACE,GAAG,EAAE,GAAG,CAAC,IAAI;oBACb,QAAQ,EAAE,kBAAkB;oBAC5B,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,kBAAkB,CAAC,QAAQ,CAAC,EAAE,IAAI,EAAE,CAAC,CAAC;iBAC5D;aACF;SACF,CAAC;IACJ,CAAC,CACF,CAAC;IAEF,OAAO,MAAM,CAAC;AAChB,CAAC"}
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
import { registerLighthouseAuditTool } from './lighthouse.js';
|
||||
export function registerAuditTools(server) {
|
||||
registerLighthouseAuditTool(server);
|
||||
}
|
||||
//# sourceMappingURL=index.js.map
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/tools/audit/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,2BAA2B,EAAE,MAAM,iBAAiB,CAAC;AAE9D,MAAM,UAAU,kBAAkB,CAAC,MAAiB;IAClD,2BAA2B,CAAC,MAAM,CAAC,CAAC;AACtC,CAAC"}
|
||||
Vendored
+140
@@ -0,0 +1,140 @@
|
||||
import { z } from 'zod';
|
||||
import { config } from '../../config.js';
|
||||
import { runCommand } from '../../lib/runCommand.js';
|
||||
import { errorResult, jsonResult, toErrorMessage } from '../shared.js';
|
||||
function scorePercent(score) {
|
||||
if (score == null)
|
||||
return null;
|
||||
return Number((score * 100).toFixed(1));
|
||||
}
|
||||
function extractScores(report) {
|
||||
const scores = {};
|
||||
for (const [id, category] of Object.entries(report.categories ?? {})) {
|
||||
scores[id] = scorePercent(category.score);
|
||||
}
|
||||
return scores;
|
||||
}
|
||||
function extractCategoryDetails(report) {
|
||||
return Object.entries(report.categories ?? {}).map(([id, category]) => ({
|
||||
id,
|
||||
title: category.title ?? id,
|
||||
description: category.description ?? null,
|
||||
score: scorePercent(category.score),
|
||||
auditCount: category.auditRefs?.length ?? 0,
|
||||
}));
|
||||
}
|
||||
function categoryForAudit(report, auditId) {
|
||||
for (const [categoryId, category] of Object.entries(report.categories ?? {})) {
|
||||
if (category.auditRefs?.some((ref) => ref.id === auditId))
|
||||
return categoryId;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function weightForAudit(report, auditId) {
|
||||
for (const category of Object.values(report.categories ?? {})) {
|
||||
const ref = category.auditRefs?.find((r) => r.id === auditId);
|
||||
if (ref?.weight != null)
|
||||
return ref.weight;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
function topOpportunities(report, limit = 12) {
|
||||
const audits = report.audits ?? {};
|
||||
const candidates = Object.entries(audits)
|
||||
.filter(([, audit]) => typeof audit.score === 'number' && (audit.score ?? 1) < 0.9)
|
||||
.filter(([, audit]) => audit.scoreDisplayMode !== 'informative' && audit.scoreDisplayMode !== 'manual')
|
||||
.map(([id, audit]) => {
|
||||
const details = audit.details;
|
||||
return {
|
||||
id,
|
||||
title: audit.title ?? id,
|
||||
category: categoryForAudit(report, id),
|
||||
score: audit.score ?? null,
|
||||
displayValue: audit.displayValue ?? null,
|
||||
weight: weightForAudit(report, id),
|
||||
savingsMs: details?.overallSavingsMs ?? null,
|
||||
savingsBytes: details?.overallSavingsBytes ?? null,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => (b.weight ?? 0) - (a.weight ?? 0) ||
|
||||
(b.savingsMs ?? 0) - (a.savingsMs ?? 0) ||
|
||||
(a.score ?? 1) - (b.score ?? 1));
|
||||
return candidates.slice(0, limit);
|
||||
}
|
||||
function failingByCategory(report, perCategory = 5) {
|
||||
const byCategory = {};
|
||||
for (const opportunity of topOpportunities(report, 50)) {
|
||||
const cat = opportunity.category ?? 'other';
|
||||
if (!byCategory[cat])
|
||||
byCategory[cat] = [];
|
||||
if (byCategory[cat].length < perCategory)
|
||||
byCategory[cat].push(opportunity);
|
||||
}
|
||||
return byCategory;
|
||||
}
|
||||
export function parseLighthouseOutput(raw) {
|
||||
// Lighthouse may print non-JSON noise before/after the JSON blob when run via npx.
|
||||
const start = raw.indexOf('{');
|
||||
const end = raw.lastIndexOf('}');
|
||||
if (start === -1 || end === -1 || end <= start) {
|
||||
throw new Error('Could not parse Lighthouse JSON output.');
|
||||
}
|
||||
const report = JSON.parse(raw.slice(start, end + 1));
|
||||
return {
|
||||
scores: extractScores(report),
|
||||
categories: extractCategoryDetails(report),
|
||||
opportunities: topOpportunities(report),
|
||||
failingByCategory: failingByCategory(report),
|
||||
};
|
||||
}
|
||||
export function registerLighthouseAuditTool(server) {
|
||||
server.registerTool('lighthouse_audit', {
|
||||
title: 'Lighthouse Audit',
|
||||
description: 'Run a headless Lighthouse audit against a URL (prefer localhost / the running dev server). Returns per-category ' +
|
||||
'scores and titles, top opportunities across categories (with savings when available), and failing audits grouped by category.',
|
||||
inputSchema: {
|
||||
url: z.string().url().describe('URL to audit, e.g. http://127.0.0.1:5173/'),
|
||||
categories: z
|
||||
.array(z.enum(['performance', 'accessibility', 'best-practices', 'seo', 'pwa']))
|
||||
.optional()
|
||||
.describe('Optional subset of Lighthouse categories. Defaults to performance, accessibility, best-practices, seo (add pwa explicitly if needed).'),
|
||||
},
|
||||
}, async ({ url, categories }) => {
|
||||
try {
|
||||
const selected = categories && categories.length > 0
|
||||
? categories
|
||||
: ['performance', 'accessibility', 'best-practices', 'seo'];
|
||||
// Prefer npx so lighthouse need not be globally installed; npx is already allow-listed.
|
||||
const result = await runCommand('npx', [
|
||||
'--yes',
|
||||
'lighthouse',
|
||||
url,
|
||||
'--output=json',
|
||||
'--quiet',
|
||||
'--chrome-flags=--headless --no-sandbox',
|
||||
...selected.flatMap((category) => ['--only-categories', category]),
|
||||
], {
|
||||
cwd: config.workspaceRoot,
|
||||
timeoutMs: config.scaffoldCommandTimeoutMs,
|
||||
});
|
||||
if (result.failed && !result.output.includes('{')) {
|
||||
return errorResult(`Lighthouse failed (exit ${result.exitCode}). Ensure Chrome/Chromium is available.\n${result.output}`);
|
||||
}
|
||||
const parsed = parseLighthouseOutput(result.output);
|
||||
return jsonResult({
|
||||
url,
|
||||
requestedCategories: selected,
|
||||
scores: parsed.scores,
|
||||
categories: parsed.categories,
|
||||
opportunities: parsed.opportunities,
|
||||
failingByCategory: parsed.failingByCategory,
|
||||
timedOut: result.timedOut,
|
||||
exitCode: result.exitCode,
|
||||
});
|
||||
}
|
||||
catch (error) {
|
||||
return errorResult(toErrorMessage(error));
|
||||
}
|
||||
});
|
||||
}
|
||||
//# sourceMappingURL=lighthouse.js.map
|
||||
Vendored
+1
File diff suppressed because one or more lines are too long
+346
@@ -0,0 +1,346 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { z } from 'zod';
|
||||
import { config } from '../../config.js';
|
||||
import { detectProject } from '../../lib/frameworks/detect.js';
|
||||
import { kebabCase, pascalCase, camelCase } from '../../lib/strings.js';
|
||||
import { resolveWorkspacePath } from '../../lib/sandbox.js';
|
||||
import { errorResult, jsonResult, toErrorMessage } from '../shared.js';
|
||||
function pluralize(word) {
|
||||
const lower = word.toLowerCase();
|
||||
if (lower.endsWith('ies'))
|
||||
return word;
|
||||
if (lower.endsWith('y') && !/[aeiou]y$/i.test(word))
|
||||
return `${word.slice(0, -1)}ies`;
|
||||
if (/(s|x|z|ch|sh)$/i.test(word))
|
||||
return `${word}es`;
|
||||
if (lower.endsWith('s'))
|
||||
return word;
|
||||
return `${word}s`;
|
||||
}
|
||||
function requireDjango() {
|
||||
const detected = detectProject(config.workspaceRoot);
|
||||
if (detected.backend?.kind !== 'django') {
|
||||
throw new Error('No Django project detected. Expected manage.py or a Django dependency file.');
|
||||
}
|
||||
return detected.backend;
|
||||
}
|
||||
function assertWritable(file, overwrite) {
|
||||
if (!overwrite && fs.existsSync(file)) {
|
||||
throw new Error(`"${file}" already exists. Pass overwrite: true to replace it.`);
|
||||
}
|
||||
}
|
||||
export function registerGenerateDjangoResourceTool(server) {
|
||||
server.registerTool('generate_django_resource', {
|
||||
title: 'Generate Django Resource',
|
||||
description: 'Scaffold a Django app resource: models.py model class, views (APIView-style stubs), urls.py, and admin registration. ' +
|
||||
'Writes files under the given app directory (created if missing). Does not run migrate.',
|
||||
inputSchema: {
|
||||
name: z.string().min(1).describe('Resource/model name, e.g. "Post" or "blog_post".'),
|
||||
app: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Django app directory relative to the project root. Defaults to a kebab-case plural of the name.'),
|
||||
overwrite: z.boolean().optional().default(false),
|
||||
},
|
||||
}, async ({ name, app, overwrite }) => {
|
||||
try {
|
||||
const django = requireDjango();
|
||||
const model = pascalCase(name);
|
||||
const appName = app?.trim() || kebabCase(pluralize(model)).replace(/-/g, '_');
|
||||
const appDir = resolveWorkspacePath(django.root, appName);
|
||||
fs.mkdirSync(appDir, { recursive: true });
|
||||
const files = [];
|
||||
const modelFile = path.join(appDir, 'models.py');
|
||||
const viewsFile = path.join(appDir, 'views.py');
|
||||
const urlsFile = path.join(appDir, 'urls.py');
|
||||
const adminFile = path.join(appDir, 'admin.py');
|
||||
const appsFile = path.join(appDir, 'apps.py');
|
||||
const initFile = path.join(appDir, '__init__.py');
|
||||
for (const file of [modelFile, viewsFile, urlsFile, adminFile, appsFile]) {
|
||||
assertWritable(file, overwrite ?? false);
|
||||
}
|
||||
if (!fs.existsSync(initFile)) {
|
||||
fs.writeFileSync(initFile, '');
|
||||
files.push(initFile);
|
||||
}
|
||||
fs.writeFileSync(modelFile, `from django.db import models
|
||||
|
||||
|
||||
class ${model}(models.Model):
|
||||
title = models.CharField(max_length=255)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.title
|
||||
`);
|
||||
files.push(modelFile);
|
||||
fs.writeFileSync(viewsFile, `from django.http import JsonResponse
|
||||
from django.views import View
|
||||
|
||||
from .models import ${model}
|
||||
|
||||
|
||||
class ${model}ListView(View):
|
||||
def get(self, request):
|
||||
items = list(${model}.objects.values("id", "title", "created_at"))
|
||||
return JsonResponse({"results": items})
|
||||
`);
|
||||
files.push(viewsFile);
|
||||
const routeName = kebabCase(pluralize(model));
|
||||
fs.writeFileSync(urlsFile, `from django.urls import path
|
||||
|
||||
from .views import ${model}ListView
|
||||
|
||||
urlpatterns = [
|
||||
path("${routeName}/", ${model}ListView.as_view(), name="${routeName}-list"),
|
||||
]
|
||||
`);
|
||||
files.push(urlsFile);
|
||||
fs.writeFileSync(adminFile, `from django.contrib import admin
|
||||
|
||||
from .models import ${model}
|
||||
|
||||
admin.site.register(${model})
|
||||
`);
|
||||
files.push(adminFile);
|
||||
fs.writeFileSync(appsFile, `from django.apps import AppConfig
|
||||
|
||||
|
||||
class ${pascalCase(appName)}Config(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "${appName}"
|
||||
`);
|
||||
files.push(appsFile);
|
||||
return jsonResult({
|
||||
kind: 'django',
|
||||
model,
|
||||
app: appName,
|
||||
route: `/${routeName}/`,
|
||||
files,
|
||||
nextSteps: [
|
||||
`Add "${appName}" to INSTALLED_APPS.`,
|
||||
`Include path("${appName}/", include("${appName}.urls")) in the project urls.py.`,
|
||||
'Run python manage.py makemigrations && python manage.py migrate.',
|
||||
],
|
||||
});
|
||||
}
|
||||
catch (error) {
|
||||
return errorResult(toErrorMessage(error));
|
||||
}
|
||||
});
|
||||
}
|
||||
export function registerGenerateRailsResourceTool(server) {
|
||||
server.registerTool('generate_rails_resource', {
|
||||
title: 'Generate Rails Resource',
|
||||
description: 'Scaffold Rails model + controller stubs and append a resources route hint to config/routes.rb when present. ' +
|
||||
'File-based (does not invoke `rails generate`).',
|
||||
inputSchema: {
|
||||
name: z.string().min(1).describe('Resource name, e.g. "Post" or "blog_post".'),
|
||||
api: z.boolean().optional().default(true).describe('If true, generate an API-style controller under app/controllers/api.'),
|
||||
overwrite: z.boolean().optional().default(false),
|
||||
},
|
||||
}, async ({ name, api, overwrite }) => {
|
||||
try {
|
||||
const detected = detectProject(config.workspaceRoot);
|
||||
if (detected.backend?.kind !== 'rails') {
|
||||
return errorResult('No Rails project detected. Expected a Gemfile with rails and/or bin/rails.');
|
||||
}
|
||||
const root = detected.backend.root;
|
||||
const model = pascalCase(name);
|
||||
const plural = pluralize(model);
|
||||
const controllerName = `${plural}Controller`;
|
||||
const table = kebabCase(plural).replace(/-/g, '_');
|
||||
const modelFile = resolveWorkspacePath(root, path.join('app', 'models', `${kebabCase(model).replace(/-/g, '_')}.rb`));
|
||||
const controllerRel = api
|
||||
? path.join('app', 'controllers', 'api', `${kebabCase(plural).replace(/-/g, '_')}_controller.rb`)
|
||||
: path.join('app', 'controllers', `${kebabCase(plural).replace(/-/g, '_')}_controller.rb`);
|
||||
const controllerFile = resolveWorkspacePath(root, controllerRel);
|
||||
assertWritable(modelFile, overwrite ?? false);
|
||||
assertWritable(controllerFile, overwrite ?? false);
|
||||
fs.mkdirSync(path.dirname(modelFile), { recursive: true });
|
||||
fs.mkdirSync(path.dirname(controllerFile), { recursive: true });
|
||||
const files = [];
|
||||
fs.writeFileSync(modelFile, `class ${model} < ApplicationRecord
|
||||
# self.table_name = "${table}"
|
||||
end
|
||||
`);
|
||||
files.push(modelFile);
|
||||
const parent = api ? 'ApplicationController' : 'ApplicationController';
|
||||
fs.writeFileSync(controllerFile, api
|
||||
? `module Api
|
||||
class ${controllerName} < ${parent}
|
||||
def index
|
||||
render json: ${model}.all
|
||||
end
|
||||
|
||||
def show
|
||||
render json: ${model}.find(params[:id])
|
||||
end
|
||||
end
|
||||
end
|
||||
`
|
||||
: `class ${controllerName} < ${parent}
|
||||
def index
|
||||
@${camelCase(plural)} = ${model}.all
|
||||
end
|
||||
|
||||
def show
|
||||
@${camelCase(model)} = ${model}.find(params[:id])
|
||||
end
|
||||
end
|
||||
`);
|
||||
files.push(controllerFile);
|
||||
const routesFile = path.join(root, 'config', 'routes.rb');
|
||||
let routeWired = false;
|
||||
const routeLine = api
|
||||
? ` namespace :api do\n resources :${table}, only: %i[index show]\n end`
|
||||
: ` resources :${table}, only: %i[index show]`;
|
||||
if (fs.existsSync(routesFile)) {
|
||||
const text = fs.readFileSync(routesFile, 'utf8');
|
||||
if (!text.includes(`resources :${table}`)) {
|
||||
const wired = text.includes('Rails.application.routes.draw')
|
||||
? text.replace(/Rails\.application\.routes\.draw do\s*\n/, (match) => `${match}${routeLine}\n`)
|
||||
: `${text.trimEnd()}\n${routeLine}\n`;
|
||||
fs.writeFileSync(routesFile, wired);
|
||||
routeWired = true;
|
||||
files.push(routesFile);
|
||||
}
|
||||
}
|
||||
return jsonResult({
|
||||
kind: 'rails',
|
||||
model,
|
||||
controller: controllerName,
|
||||
api: Boolean(api),
|
||||
route: api ? `/api/${table}` : `/${table}`,
|
||||
routeWired,
|
||||
files,
|
||||
nextSteps: [
|
||||
'Add a migration for the model (bin/rails g migration Create...).',
|
||||
routeWired ? 'Review config/routes.rb for the new resources entry.' : 'Add a resources route in config/routes.rb.',
|
||||
],
|
||||
});
|
||||
}
|
||||
catch (error) {
|
||||
return errorResult(toErrorMessage(error));
|
||||
}
|
||||
});
|
||||
}
|
||||
export function registerGenerateSymfonyResourceTool(server) {
|
||||
server.registerTool('generate_symfony_resource', {
|
||||
title: 'Generate Symfony Resource',
|
||||
description: 'Scaffold a Symfony Entity + Repository + Controller stub under src/. File-based (does not invoke MakerBundle).',
|
||||
inputSchema: {
|
||||
name: z.string().min(1).describe('Resource/entity name, e.g. "Post" or "blog_post".'),
|
||||
overwrite: z.boolean().optional().default(false),
|
||||
},
|
||||
}, async ({ name, overwrite }) => {
|
||||
try {
|
||||
const detected = detectProject(config.workspaceRoot);
|
||||
if (detected.backend?.kind !== 'symfony') {
|
||||
return errorResult('No Symfony project detected. Expected symfony/framework-bundle or bin/console.');
|
||||
}
|
||||
const root = detected.backend.root;
|
||||
const entity = pascalCase(name);
|
||||
const route = kebabCase(pluralize(entity));
|
||||
const entityFile = resolveWorkspacePath(root, path.join('src', 'Entity', `${entity}.php`));
|
||||
const repoFile = resolveWorkspacePath(root, path.join('src', 'Repository', `${entity}Repository.php`));
|
||||
const controllerFile = resolveWorkspacePath(root, path.join('src', 'Controller', `${entity}Controller.php`));
|
||||
for (const file of [entityFile, repoFile, controllerFile]) {
|
||||
assertWritable(file, overwrite ?? false);
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
}
|
||||
fs.writeFileSync(entityFile, `<?php
|
||||
|
||||
namespace App\\Entity;
|
||||
|
||||
use App\\Repository\\${entity}Repository;
|
||||
use Doctrine\\ORM\\Mapping as ORM;
|
||||
|
||||
#[ORM\\Entity(repositoryClass: ${entity}Repository::class)]
|
||||
class ${entity}
|
||||
{
|
||||
#[ORM\\Id]
|
||||
#[ORM\\GeneratedValue]
|
||||
#[ORM\\Column]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\\Column(length: 255)]
|
||||
private ?string $title = null;
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getTitle(): ?string
|
||||
{
|
||||
return $this->title;
|
||||
}
|
||||
|
||||
public function setTitle(string $title): static
|
||||
{
|
||||
$this->title = $title;
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
`);
|
||||
fs.writeFileSync(repoFile, `<?php
|
||||
|
||||
namespace App\\Repository;
|
||||
|
||||
use App\\Entity\\${entity};
|
||||
use Doctrine\\Bundle\\DoctrineBundle\\Repository\\ServiceEntityRepository;
|
||||
use Doctrine\\Persistence\\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<${entity}>
|
||||
*/
|
||||
class ${entity}Repository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, ${entity}::class);
|
||||
}
|
||||
}
|
||||
`);
|
||||
fs.writeFileSync(controllerFile, `<?php
|
||||
|
||||
namespace App\\Controller;
|
||||
|
||||
use App\\Repository\\${entity}Repository;
|
||||
use Symfony\\Bundle\\FrameworkBundle\\Controller\\AbstractController;
|
||||
use Symfony\\Component\\HttpFoundation\\JsonResponse;
|
||||
use Symfony\\Component\\Routing\\Attribute\\Route;
|
||||
|
||||
class ${entity}Controller extends AbstractController
|
||||
{
|
||||
#[Route('/${route}', name: 'app_${route}_index', methods: ['GET'])]
|
||||
public function index(${entity}Repository $repository): JsonResponse
|
||||
{
|
||||
$items = array_map(
|
||||
static fn (${entity} $item) => ['id' => $item->getId(), 'title' => $item->getTitle()],
|
||||
$repository->findAll(),
|
||||
);
|
||||
|
||||
return $this->json(['results' => $items]);
|
||||
}
|
||||
}
|
||||
`);
|
||||
return jsonResult({
|
||||
kind: 'symfony',
|
||||
entity,
|
||||
route: `/${route}`,
|
||||
files: [entityFile, repoFile, controllerFile],
|
||||
nextSteps: [
|
||||
'Run doctrine migrations if Doctrine is configured (php bin/console make:migration / doctrine:migrations:migrate).',
|
||||
'Ensure Doctrine ORM and attributes are enabled for the Entity mapping.',
|
||||
],
|
||||
});
|
||||
}
|
||||
catch (error) {
|
||||
return errorResult(toErrorMessage(error));
|
||||
}
|
||||
});
|
||||
}
|
||||
//# sourceMappingURL=generateResources.js.map
|
||||
File diff suppressed because one or more lines are too long
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
import { registerGenerateDjangoResourceTool, registerGenerateRailsResourceTool, registerGenerateSymfonyResourceTool, } from './generateResources.js';
|
||||
export function registerBackendResourceTools(server) {
|
||||
registerGenerateDjangoResourceTool(server);
|
||||
registerGenerateRailsResourceTool(server);
|
||||
registerGenerateSymfonyResourceTool(server);
|
||||
}
|
||||
//# sourceMappingURL=index.js.map
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/tools/backends/index.ts"],"names":[],"mappings":"AACA,OAAO,EACL,kCAAkC,EAClC,iCAAiC,EACjC,mCAAmC,GACpC,MAAM,wBAAwB,CAAC;AAEhC,MAAM,UAAU,4BAA4B,CAAC,MAAiB;IAC5D,kCAAkC,CAAC,MAAM,CAAC,CAAC;IAC3C,iCAAiC,CAAC,MAAM,CAAC,CAAC;IAC1C,mCAAmC,CAAC,MAAM,CAAC,CAAC;AAC9C,CAAC"}
|
||||
Vendored
+48
@@ -0,0 +1,48 @@
|
||||
import { z } from 'zod';
|
||||
import { writeBaseline, listBaselines } from '../../lib/baselines.js';
|
||||
import { errorResult, jsonResult, toErrorMessage } from '../shared.js';
|
||||
import { browserManager } from './browserManager.js';
|
||||
export function registerBrowserScreenshotBaselineTool(server) {
|
||||
server.registerTool('browser_screenshot_baseline', {
|
||||
title: 'Save Screenshot Baseline',
|
||||
description: 'Capture the current page (or a CSS selector) and save it as a PNG baseline under `.web-dev-mcp/baselines/{name}.png` for later visual diffs.',
|
||||
inputSchema: {
|
||||
name: z
|
||||
.string()
|
||||
.min(1)
|
||||
.describe('Baseline name (no path separators), e.g. "home" or "login-form".'),
|
||||
selector: z.string().optional().describe('Optional CSS selector; if omitted, captures the page.'),
|
||||
fullPage: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.default(false)
|
||||
.describe('Capture the full scrollable page when selector is omitted.'),
|
||||
},
|
||||
}, async ({ name, selector, fullPage }) => {
|
||||
try {
|
||||
const session = await browserManager.getSession();
|
||||
let buffer;
|
||||
if (selector) {
|
||||
const element = await session.page.locator(selector).first();
|
||||
await element.waitFor({ state: 'visible', timeout: 5000 });
|
||||
buffer = await element.screenshot({ type: 'png' });
|
||||
}
|
||||
else {
|
||||
buffer = await session.page.screenshot({ fullPage, type: 'png' });
|
||||
}
|
||||
const saved = writeBaseline(name, buffer);
|
||||
return jsonResult({
|
||||
name,
|
||||
selector: selector ?? null,
|
||||
fullPage: selector ? false : fullPage,
|
||||
sizeBytes: buffer.length,
|
||||
relativePath: saved.relativePath,
|
||||
baselines: listBaselines(),
|
||||
});
|
||||
}
|
||||
catch (error) {
|
||||
return errorResult(toErrorMessage(error));
|
||||
}
|
||||
});
|
||||
}
|
||||
//# sourceMappingURL=baseline.js.map
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"baseline.js","sourceRoot":"","sources":["../../../src/tools/browser/baseline.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,OAAO,EAAE,aAAa,EAAE,aAAa,EAAE,MAAM,wBAAwB,CAAC;AACtE,OAAO,EAAE,WAAW,EAAE,UAAU,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AACvE,OAAO,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AAErD,MAAM,UAAU,qCAAqC,CAAC,MAAiB;IACrE,MAAM,CAAC,YAAY,CACjB,6BAA6B,EAC7B;QACE,KAAK,EAAE,0BAA0B;QACjC,WAAW,EACT,8IAA8I;QAChJ,WAAW,EAAE;YACX,IAAI,EAAE,CAAC;iBACJ,MAAM,EAAE;iBACR,GAAG,CAAC,CAAC,CAAC;iBACN,QAAQ,CAAC,kEAAkE,CAAC;YAC/E,QAAQ,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,uDAAuD,CAAC;YACjG,QAAQ,EAAE,CAAC;iBACR,OAAO,EAAE;iBACT,QAAQ,EAAE;iBACV,OAAO,CAAC,KAAK,CAAC;iBACd,QAAQ,CAAC,4DAA4D,CAAC;SAC1E;KACF,EACD,KAAK,EAAE,EAAE,IAAI,EAAE,QAAQ,EAAE,QAAQ,EAAE,EAAE,EAAE;QACrC,IAAI,CAAC;YACH,MAAM,OAAO,GAAG,MAAM,cAAc,CAAC,UAAU,EAAE,CAAC;YAClD,IAAI,MAAc,CAAC;YACnB,IAAI,QAAQ,EAAE,CAAC;gBACb,MAAM,OAAO,GAAG,MAAM,OAAO,CAAC,IAAI,CAAC,OAAO,CAAC,QAAQ,CAAC,CAAC,KAAK,EAAE,CAAC;gBAC7D,MAAM,OAAO,CAAC,OAAO,CAAC,EAAE,KAAK,EAAE,SAAS,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC,CAAC;gBAC3D,MAAM,GAAG,MAAM,OAAO,CAAC,UAAU,CAAC,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC,CAAC;YACrD,CAAC;iBAAM,CAAC;gBACN,MAAM,GAAG,MAAM,OAAO,CAAC,IAAI,CAAC,UAAU,CAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC,CAAC;YACpE,CAAC;YAED,MAAM,KAAK,GAAG,aAAa,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC;YAC1C,OAAO,UAAU,CAAC;gBAChB,IAAI;gBACJ,QAAQ,EAAE,QAAQ,IAAI,IAAI;gBAC1B,QAAQ,EAAE,QAAQ,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,QAAQ;gBACrC,SAAS,EAAE,MAAM,CAAC,MAAM;gBACxB,YAAY,EAAE,KAAK,CAAC,YAAY;gBAChC,SAAS,EAAE,aAAa,EAAE;aAC3B,CAAC,CAAC;QACL,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,OAAO,WAAW,CAAC,cAAc,CAAC,KAAK,CAAC,CAAC,CAAC;QAC5C,CAAC;IACH,CAAC,CACF,CAAC;AACJ,CAAC"}
|
||||
Vendored
+68
@@ -0,0 +1,68 @@
|
||||
import { chromium } from 'playwright';
|
||||
import { logger } from '../../lib/logger.js';
|
||||
class BrowserManager {
|
||||
session = null;
|
||||
async getSession(headless = true) {
|
||||
if (this.session)
|
||||
return this.session;
|
||||
try {
|
||||
const browser = await chromium.launch({ headless });
|
||||
const context = await browser.newContext({ viewport: { width: 1280, height: 720 } });
|
||||
const page = await context.newPage();
|
||||
const consoleLogs = [];
|
||||
page.on('console', (msg) => {
|
||||
const location = msg.location();
|
||||
consoleLogs.push({
|
||||
type: msg.type(),
|
||||
text: msg.text(),
|
||||
location: `${location.url}:${location.lineNumber}:${location.columnNumber}`,
|
||||
time: new Date().toISOString(),
|
||||
});
|
||||
});
|
||||
page.on('pageerror', (error) => {
|
||||
consoleLogs.push({
|
||||
type: 'error',
|
||||
text: `Page error: ${error.message}`,
|
||||
time: new Date().toISOString(),
|
||||
});
|
||||
});
|
||||
page.on('requestfailed', (request) => {
|
||||
consoleLogs.push({
|
||||
type: 'error',
|
||||
text: `Network request failed: ${request.method()} ${request.url()}`,
|
||||
time: new Date().toISOString(),
|
||||
});
|
||||
});
|
||||
this.session = { browser, context, page, consoleLogs };
|
||||
return this.session;
|
||||
}
|
||||
catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (message.includes("Executable doesn't exist") || message.includes('browserType.launch')) {
|
||||
throw new Error('Playwright Chromium browser is not installed. ' +
|
||||
'Install it with: npx playwright install chromium');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
async close() {
|
||||
if (!this.session)
|
||||
return;
|
||||
try {
|
||||
await this.session.browser.close();
|
||||
}
|
||||
catch (error) {
|
||||
logger.error('error closing browser', { error: error instanceof Error ? error.message : error });
|
||||
}
|
||||
finally {
|
||||
this.session = null;
|
||||
}
|
||||
}
|
||||
resetLogs() {
|
||||
if (this.session) {
|
||||
this.session.consoleLogs.length = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
export const browserManager = new BrowserManager();
|
||||
//# sourceMappingURL=browserManager.js.map
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"browserManager.js","sourceRoot":"","sources":["../../../src/tools/browser/browserManager.ts"],"names":[],"mappings":"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"}
|
||||
Vendored
+40
@@ -0,0 +1,40 @@
|
||||
import { z } from 'zod';
|
||||
import { errorResult, jsonResult, toErrorMessage } from '../shared.js';
|
||||
import { browserManager } from './browserManager.js';
|
||||
export function registerBrowserConsoleLogsTool(server) {
|
||||
server.registerTool('browser_get_console_logs', {
|
||||
title: 'Browser Console Logs',
|
||||
description: 'Return console logs, JavaScript page errors, and failed network requests captured since the last browser_navigate call (or since the browser session started).',
|
||||
inputSchema: {
|
||||
level: z
|
||||
.enum(['all', 'error', 'warn', 'log', 'info', 'debug'])
|
||||
.optional()
|
||||
.default('all')
|
||||
.describe('Filter logs by type.'),
|
||||
limit: z
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.optional()
|
||||
.default(100)
|
||||
.describe('Maximum number of logs to return.'),
|
||||
},
|
||||
}, async ({ level, limit }) => {
|
||||
try {
|
||||
const session = await browserManager.getSession();
|
||||
let logs = session.consoleLogs.slice(-limit);
|
||||
if (level !== 'all') {
|
||||
logs = logs.filter((log) => log.type === level);
|
||||
}
|
||||
return jsonResult({
|
||||
totalCaptured: session.consoleLogs.length,
|
||||
returned: logs.length,
|
||||
logs,
|
||||
});
|
||||
}
|
||||
catch (error) {
|
||||
return errorResult(toErrorMessage(error));
|
||||
}
|
||||
});
|
||||
}
|
||||
//# sourceMappingURL=consoleLogs.js.map
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"consoleLogs.js","sourceRoot":"","sources":["../../../src/tools/browser/consoleLogs.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,OAAO,EAAE,WAAW,EAAE,UAAU,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AACvE,OAAO,EAAE,cAAc,EAAmB,MAAM,qBAAqB,CAAC;AAEtE,MAAM,UAAU,8BAA8B,CAAC,MAAiB;IAC9D,MAAM,CAAC,YAAY,CACjB,0BAA0B,EAC1B;QACE,KAAK,EAAE,sBAAsB;QAC7B,WAAW,EACT,gKAAgK;QAClK,WAAW,EAAE;YACX,KAAK,EAAE,CAAC;iBACL,IAAI,CAAC,CAAC,KAAK,EAAE,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,OAAO,CAAC,CAAC;iBACtD,QAAQ,EAAE;iBACV,OAAO,CAAC,KAAK,CAAC;iBACd,QAAQ,CAAC,sBAAsB,CAAC;YACnC,KAAK,EAAE,CAAC;iBACL,MAAM,EAAE;iBACR,GAAG,EAAE;iBACL,QAAQ,EAAE;iBACV,QAAQ,EAAE;iBACV,OAAO,CAAC,GAAG,CAAC;iBACZ,QAAQ,CAAC,mCAAmC,CAAC;SACjD;KACF,EACD,KAAK,EAAE,EAAE,KAAK,EAAE,KAAK,EAAE,EAAE,EAAE;QACzB,IAAI,CAAC;YACH,MAAM,OAAO,GAAG,MAAM,cAAc,CAAC,UAAU,EAAE,CAAC;YAClD,IAAI,IAAI,GAAiB,OAAO,CAAC,WAAW,CAAC,KAAK,CAAC,CAAC,KAAK,CAAC,CAAC;YAC3D,IAAI,KAAK,KAAK,KAAK,EAAE,CAAC;gBACpB,IAAI,GAAG,IAAI,CAAC,MAAM,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,GAAG,CAAC,IAAI,KAAK,KAAK,CAAC,CAAC;YAClD,CAAC;YACD,OAAO,UAAU,CAAC;gBAChB,aAAa,EAAE,OAAO,CAAC,WAAW,CAAC,MAAM;gBACzC,QAAQ,EAAE,IAAI,CAAC,MAAM;gBACrB,IAAI;aACL,CAAC,CAAC;QACL,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,OAAO,WAAW,CAAC,cAAc,CAAC,KAAK,CAAC,CAAC,CAAC;QAC5C,CAAC;IACH,CAAC,CACF,CAAC;AACJ,CAAC"}
|
||||
Vendored
+18
@@ -0,0 +1,18 @@
|
||||
import { registerBrowserNavigateTool } from './navigate.js';
|
||||
import { registerBrowserScreenshotTool } from './screenshot.js';
|
||||
import { registerBrowserConsoleLogsTool } from './consoleLogs.js';
|
||||
import { registerBrowserInspectDomTool } from './inspectDom.js';
|
||||
import { registerBrowserInteractTools } from './interact.js';
|
||||
import { registerBrowserScreenshotBaselineTool } from './baseline.js';
|
||||
import { registerBrowserVisualDiffTool } from './visualDiff.js';
|
||||
export function registerBrowserTools(server) {
|
||||
registerBrowserNavigateTool(server);
|
||||
registerBrowserScreenshotTool(server);
|
||||
registerBrowserScreenshotBaselineTool(server);
|
||||
registerBrowserVisualDiffTool(server);
|
||||
registerBrowserConsoleLogsTool(server);
|
||||
registerBrowserInspectDomTool(server);
|
||||
registerBrowserInteractTools(server);
|
||||
}
|
||||
export { browserManager } from './browserManager.js';
|
||||
//# sourceMappingURL=index.js.map
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/tools/browser/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,2BAA2B,EAAE,MAAM,eAAe,CAAC;AAC5D,OAAO,EAAE,6BAA6B,EAAE,MAAM,iBAAiB,CAAC;AAChE,OAAO,EAAE,8BAA8B,EAAE,MAAM,kBAAkB,CAAC;AAClE,OAAO,EAAE,6BAA6B,EAAE,MAAM,iBAAiB,CAAC;AAChE,OAAO,EAAE,4BAA4B,EAAE,MAAM,eAAe,CAAC;AAC7D,OAAO,EAAE,qCAAqC,EAAE,MAAM,eAAe,CAAC;AACtE,OAAO,EAAE,6BAA6B,EAAE,MAAM,iBAAiB,CAAC;AAEhE,MAAM,UAAU,oBAAoB,CAAC,MAAiB;IACpD,2BAA2B,CAAC,MAAM,CAAC,CAAC;IACpC,6BAA6B,CAAC,MAAM,CAAC,CAAC;IACtC,qCAAqC,CAAC,MAAM,CAAC,CAAC;IAC9C,6BAA6B,CAAC,MAAM,CAAC,CAAC;IACtC,8BAA8B,CAAC,MAAM,CAAC,CAAC;IACvC,6BAA6B,CAAC,MAAM,CAAC,CAAC;IACtC,4BAA4B,CAAC,MAAM,CAAC,CAAC;AACvC,CAAC;AAED,OAAO,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC"}
|
||||
Vendored
+42
@@ -0,0 +1,42 @@
|
||||
/// <reference lib="dom" />
|
||||
import { z } from 'zod';
|
||||
import { errorResult, jsonResult, toErrorMessage } from '../shared.js';
|
||||
import { browserManager } from './browserManager.js';
|
||||
export function registerBrowserInspectDomTool(server) {
|
||||
server.registerTool('browser_inspect_dom', {
|
||||
title: 'Browser Inspect DOM',
|
||||
description: 'Query the DOM of the current page by CSS selector and return the outerHTML, inner text, and selected computed styles of the first matching element.',
|
||||
inputSchema: {
|
||||
selector: z.string().min(1).describe('CSS selector to query.'),
|
||||
includeStyles: z
|
||||
.array(z.string())
|
||||
.optional()
|
||||
.default(['color', 'backgroundColor', 'fontSize', 'display'])
|
||||
.describe('Computed CSS properties to return.'),
|
||||
},
|
||||
}, async ({ selector, includeStyles }) => {
|
||||
try {
|
||||
const session = await browserManager.getSession();
|
||||
const element = session.page.locator(selector).first();
|
||||
await element.waitFor({ state: 'attached', timeout: 5000 });
|
||||
const outerHTML = await element.evaluate((el) => el.outerHTML).catch(() => null);
|
||||
const text = await element.innerText().catch(() => null);
|
||||
const styles = await element.evaluate((el, props) => {
|
||||
const computed = window.getComputedStyle(el);
|
||||
return Object.fromEntries(props.map((p) => [p, computed.getPropertyValue(p)]));
|
||||
}, includeStyles);
|
||||
const count = await session.page.locator(selector).count();
|
||||
return jsonResult({
|
||||
selector,
|
||||
matchCount: count,
|
||||
outerHTML,
|
||||
text,
|
||||
styles,
|
||||
});
|
||||
}
|
||||
catch (error) {
|
||||
return errorResult(toErrorMessage(error));
|
||||
}
|
||||
});
|
||||
}
|
||||
//# sourceMappingURL=inspectDom.js.map
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"inspectDom.js","sourceRoot":"","sources":["../../../src/tools/browser/inspectDom.ts"],"names":[],"mappings":"AAAA,2BAA2B;AAC3B,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,OAAO,EAAE,WAAW,EAAE,UAAU,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AACvE,OAAO,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AAErD,MAAM,UAAU,6BAA6B,CAAC,MAAiB;IAC7D,MAAM,CAAC,YAAY,CACjB,qBAAqB,EACrB;QACE,KAAK,EAAE,qBAAqB;QAC5B,WAAW,EACT,qJAAqJ;QACvJ,WAAW,EAAE;YACX,QAAQ,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,wBAAwB,CAAC;YAC9D,aAAa,EAAE,CAAC;iBACb,KAAK,CAAC,CAAC,CAAC,MAAM,EAAE,CAAC;iBACjB,QAAQ,EAAE;iBACV,OAAO,CAAC,CAAC,OAAO,EAAE,iBAAiB,EAAE,UAAU,EAAE,SAAS,CAAC,CAAC;iBAC5D,QAAQ,CAAC,oCAAoC,CAAC;SAClD;KACF,EACD,KAAK,EAAE,EAAE,QAAQ,EAAE,aAAa,EAAE,EAAE,EAAE;QACpC,IAAI,CAAC;YACH,MAAM,OAAO,GAAG,MAAM,cAAc,CAAC,UAAU,EAAE,CAAC;YAClD,MAAM,OAAO,GAAG,OAAO,CAAC,IAAI,CAAC,OAAO,CAAC,QAAQ,CAAC,CAAC,KAAK,EAAE,CAAC;YACvD,MAAM,OAAO,CAAC,OAAO,CAAC,EAAE,KAAK,EAAE,UAAU,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC,CAAC;YAE5D,MAAM,SAAS,GAAG,MAAM,OAAO,CAAC,QAAQ,CAAC,CAAC,EAAE,EAAE,EAAE,CAAC,EAAE,CAAC,SAAS,CAAC,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,IAAI,CAAC,CAAC;YACjF,MAAM,IAAI,GAAG,MAAM,OAAO,CAAC,SAAS,EAAE,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,IAAI,CAAC,CAAC;YACzD,MAAM,MAAM,GAAG,MAAM,OAAO,CAAC,QAAQ,CACnC,CAAC,EAAE,EAAE,KAAK,EAAE,EAAE;gBACZ,MAAM,QAAQ,GAAG,MAAM,CAAC,gBAAgB,CAAC,EAAE,CAAC,CAAC;gBAC7C,OAAO,MAAM,CAAC,WAAW,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,EAAE,QAAQ,CAAC,gBAAgB,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;YACjF,CAAC,EACD,aAAa,CACd,CAAC;YACF,MAAM,KAAK,GAAG,MAAM,OAAO,CAAC,IAAI,CAAC,OAAO,CAAC,QAAQ,CAAC,CAAC,KAAK,EAAE,CAAC;YAE3D,OAAO,UAAU,CAAC;gBAChB,QAAQ;gBACR,UAAU,EAAE,KAAK;gBACjB,SAAS;gBACT,IAAI;gBACJ,MAAM;aACP,CAAC,CAAC;QACL,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,OAAO,WAAW,CAAC,cAAc,CAAC,KAAK,CAAC,CAAC,CAAC;QAC5C,CAAC;IACH,CAAC,CACF,CAAC;AACJ,CAAC"}
|
||||
Vendored
+68
@@ -0,0 +1,68 @@
|
||||
import { z } from 'zod';
|
||||
import { errorResult, jsonResult, toErrorMessage } from '../shared.js';
|
||||
import { browserManager } from './browserManager.js';
|
||||
export function registerBrowserInteractTools(server) {
|
||||
server.registerTool('browser_click', {
|
||||
title: 'Browser Click',
|
||||
description: 'Click the first element matching a CSS selector on the current page.',
|
||||
inputSchema: {
|
||||
selector: z.string().min(1).describe('CSS selector of the element to click.'),
|
||||
},
|
||||
}, async ({ selector }) => {
|
||||
try {
|
||||
const session = await browserManager.getSession();
|
||||
await session.page.locator(selector).first().click();
|
||||
return jsonResult({ clicked: true, selector });
|
||||
}
|
||||
catch (error) {
|
||||
return errorResult(toErrorMessage(error));
|
||||
}
|
||||
});
|
||||
server.registerTool('browser_fill', {
|
||||
title: 'Browser Fill',
|
||||
description: 'Fill an input/textarea element with the provided text.',
|
||||
inputSchema: {
|
||||
selector: z.string().min(1).describe('CSS selector of the input element.'),
|
||||
value: z.string().describe('Text to type into the element.'),
|
||||
clearFirst: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.default(true)
|
||||
.describe('Clear the existing value before filling.'),
|
||||
},
|
||||
}, async ({ selector, value, clearFirst }) => {
|
||||
try {
|
||||
const session = await browserManager.getSession();
|
||||
const locator = session.page.locator(selector).first();
|
||||
if (clearFirst)
|
||||
await locator.fill(value);
|
||||
else
|
||||
await locator.pressSequentially(value);
|
||||
return jsonResult({ filled: true, selector, value });
|
||||
}
|
||||
catch (error) {
|
||||
return errorResult(toErrorMessage(error));
|
||||
}
|
||||
});
|
||||
server.registerTool('browser_eval', {
|
||||
title: 'Browser Eval',
|
||||
description: 'Evaluate a JavaScript function in the context of the current page and return the result. The function is stringified and executed in the browser, so it can access the DOM (document, window, etc.).',
|
||||
inputSchema: {
|
||||
script: z
|
||||
.string()
|
||||
.min(1)
|
||||
.describe('JavaScript function body to evaluate. Must be a function expression returning a JSON-serializable value.'),
|
||||
},
|
||||
}, async ({ script }) => {
|
||||
try {
|
||||
const session = await browserManager.getSession();
|
||||
const wrapped = `(async () => { ${script} })()`;
|
||||
const result = await session.page.evaluate(wrapped);
|
||||
return jsonResult({ result });
|
||||
}
|
||||
catch (error) {
|
||||
return errorResult(toErrorMessage(error));
|
||||
}
|
||||
});
|
||||
}
|
||||
//# sourceMappingURL=interact.js.map
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"interact.js","sourceRoot":"","sources":["../../../src/tools/browser/interact.ts"],"names":[],"mappings":"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"}
|
||||
Vendored
+41
@@ -0,0 +1,41 @@
|
||||
import { z } from 'zod';
|
||||
import { errorResult, jsonResult, toErrorMessage } from '../shared.js';
|
||||
import { browserManager } from './browserManager.js';
|
||||
export function registerBrowserNavigateTool(server) {
|
||||
server.registerTool('browser_navigate', {
|
||||
title: 'Browser Navigate',
|
||||
description: 'Open a URL in a headless/headed Playwright browser session. Reuses the same session across calls, so later tools (screenshot, inspect_dom, click, etc.) operate on the same page.',
|
||||
inputSchema: {
|
||||
url: z.string().url().describe('URL to navigate to, e.g. the local dev server URL from start_dev_server.'),
|
||||
headless: z.boolean().optional().default(true).describe('Run browser headlessly.'),
|
||||
waitUntil: z
|
||||
.enum(['load', 'domcontentloaded', 'networkidle'])
|
||||
.optional()
|
||||
.default('load')
|
||||
.describe('When to consider navigation complete.'),
|
||||
resetLogs: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.default(true)
|
||||
.describe('Clear previously captured console logs before navigating.'),
|
||||
},
|
||||
}, async ({ url, headless, waitUntil, resetLogs: shouldResetLogs }) => {
|
||||
try {
|
||||
const session = await browserManager.getSession(headless);
|
||||
if (shouldResetLogs)
|
||||
browserManager.resetLogs();
|
||||
const response = await session.page.goto(url, { waitUntil });
|
||||
const title = await session.page.title().catch(() => '');
|
||||
return jsonResult({
|
||||
url,
|
||||
title,
|
||||
status: response?.status() ?? null,
|
||||
finalUrl: response?.url() ?? url,
|
||||
});
|
||||
}
|
||||
catch (error) {
|
||||
return errorResult(toErrorMessage(error));
|
||||
}
|
||||
});
|
||||
}
|
||||
//# sourceMappingURL=navigate.js.map
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"navigate.js","sourceRoot":"","sources":["../../../src/tools/browser/navigate.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,OAAO,EAAE,WAAW,EAAE,UAAU,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AACvE,OAAO,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AAErD,MAAM,UAAU,2BAA2B,CAAC,MAAiB;IAC3D,MAAM,CAAC,YAAY,CACjB,kBAAkB,EAClB;QACE,KAAK,EAAE,kBAAkB;QACzB,WAAW,EACT,mLAAmL;QACrL,WAAW,EAAE;YACX,GAAG,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,GAAG,EAAE,CAAC,QAAQ,CAAC,0EAA0E,CAAC;YAC1G,QAAQ,EAAE,CAAC,CAAC,OAAO,EAAE,CAAC,QAAQ,EAAE,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,QAAQ,CAAC,yBAAyB,CAAC;YAClF,SAAS,EAAE,CAAC;iBACT,IAAI,CAAC,CAAC,MAAM,EAAE,kBAAkB,EAAE,aAAa,CAAC,CAAC;iBACjD,QAAQ,EAAE;iBACV,OAAO,CAAC,MAAM,CAAC;iBACf,QAAQ,CAAC,uCAAuC,CAAC;YACpD,SAAS,EAAE,CAAC;iBACT,OAAO,EAAE;iBACT,QAAQ,EAAE;iBACV,OAAO,CAAC,IAAI,CAAC;iBACb,QAAQ,CAAC,2DAA2D,CAAC;SACzE;KACF,EACD,KAAK,EAAE,EAAE,GAAG,EAAE,QAAQ,EAAE,SAAS,EAAE,SAAS,EAAE,eAAe,EAAE,EAAE,EAAE;QACjE,IAAI,CAAC;YACH,MAAM,OAAO,GAAG,MAAM,cAAc,CAAC,UAAU,CAAC,QAAQ,CAAC,CAAC;YAC1D,IAAI,eAAe;gBAAE,cAAc,CAAC,SAAS,EAAE,CAAC;YAChD,MAAM,QAAQ,GAAG,MAAM,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,EAAE,EAAE,SAAS,EAAE,CAAC,CAAC;YAC7D,MAAM,KAAK,GAAG,MAAM,OAAO,CAAC,IAAI,CAAC,KAAK,EAAE,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,EAAE,CAAC,CAAC;YACzD,OAAO,UAAU,CAAC;gBAChB,GAAG;gBACH,KAAK;gBACL,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,IAAI,IAAI;gBAClC,QAAQ,EAAE,QAAQ,EAAE,GAAG,EAAE,IAAI,GAAG;aACjC,CAAC,CAAC;QACL,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,OAAO,WAAW,CAAC,cAAc,CAAC,KAAK,CAAC,CAAC,CAAC;QAC5C,CAAC;IACH,CAAC,CACF,CAAC;AACJ,CAAC"}
|
||||
Vendored
+45
@@ -0,0 +1,45 @@
|
||||
import { z } from 'zod';
|
||||
import { config } from '../../config.js';
|
||||
import { errorResult, jsonResult, toErrorMessage } from '../shared.js';
|
||||
import { browserManager } from './browserManager.js';
|
||||
export function registerBrowserScreenshotTool(server) {
|
||||
server.registerTool('browser_screenshot', {
|
||||
title: 'Browser Screenshot',
|
||||
description: 'Capture a screenshot of the current page or a specific element. Returns the image as a base64 data URI (PNG).',
|
||||
inputSchema: {
|
||||
selector: z.string().optional().describe('CSS selector of the element to screenshot. If omitted, captures the full page.'),
|
||||
fullPage: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.default(false)
|
||||
.describe('Capture the full scrollable page (only used when selector is omitted).'),
|
||||
},
|
||||
}, async ({ selector, fullPage }) => {
|
||||
try {
|
||||
const session = await browserManager.getSession();
|
||||
let buffer;
|
||||
if (selector) {
|
||||
const element = await session.page.locator(selector).first();
|
||||
await element.waitFor({ state: 'visible', timeout: 5000 });
|
||||
buffer = await element.screenshot({ type: 'png' });
|
||||
}
|
||||
else {
|
||||
buffer = await session.page.screenshot({ fullPage, type: 'png' });
|
||||
}
|
||||
const base64 = buffer.toString('base64');
|
||||
const dataUri = `data:image/png;base64,${base64}`;
|
||||
return jsonResult({
|
||||
selector: selector ?? null,
|
||||
fullPage: selector ? false : fullPage,
|
||||
sizeBytes: buffer.length,
|
||||
dataUri,
|
||||
truncatedDataUri: dataUri.length > config.maxToolOutputChars ? `${dataUri.slice(0, config.maxToolOutputChars)}...` : dataUri,
|
||||
note: 'The full dataUri is returned above. If it is truncated, use the returned base64 value directly from your client.',
|
||||
});
|
||||
}
|
||||
catch (error) {
|
||||
return errorResult(toErrorMessage(error));
|
||||
}
|
||||
});
|
||||
}
|
||||
//# sourceMappingURL=screenshot.js.map
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"screenshot.js","sourceRoot":"","sources":["../../../src/tools/browser/screenshot.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,OAAO,EAAE,MAAM,EAAE,MAAM,iBAAiB,CAAC;AACzC,OAAO,EAAE,WAAW,EAAE,UAAU,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AACvE,OAAO,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AAErD,MAAM,UAAU,6BAA6B,CAAC,MAAiB;IAC7D,MAAM,CAAC,YAAY,CACjB,oBAAoB,EACpB;QACE,KAAK,EAAE,oBAAoB;QAC3B,WAAW,EACT,+GAA+G;QACjH,WAAW,EAAE;YACX,QAAQ,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,gFAAgF,CAAC;YAC1H,QAAQ,EAAE,CAAC;iBACR,OAAO,EAAE;iBACT,QAAQ,EAAE;iBACV,OAAO,CAAC,KAAK,CAAC;iBACd,QAAQ,CAAC,wEAAwE,CAAC;SACtF;KACF,EACD,KAAK,EAAE,EAAE,QAAQ,EAAE,QAAQ,EAAE,EAAE,EAAE;QAC/B,IAAI,CAAC;YACH,MAAM,OAAO,GAAG,MAAM,cAAc,CAAC,UAAU,EAAE,CAAC;YAClD,IAAI,MAAc,CAAC;YACnB,IAAI,QAAQ,EAAE,CAAC;gBACb,MAAM,OAAO,GAAG,MAAM,OAAO,CAAC,IAAI,CAAC,OAAO,CAAC,QAAQ,CAAC,CAAC,KAAK,EAAE,CAAC;gBAC7D,MAAM,OAAO,CAAC,OAAO,CAAC,EAAE,KAAK,EAAE,SAAS,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC,CAAC;gBAC3D,MAAM,GAAG,MAAM,OAAO,CAAC,UAAU,CAAC,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC,CAAC;YACrD,CAAC;iBAAM,CAAC;gBACN,MAAM,GAAG,MAAM,OAAO,CAAC,IAAI,CAAC,UAAU,CAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC,CAAC;YACpE,CAAC;YACD,MAAM,MAAM,GAAG,MAAM,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC;YACzC,MAAM,OAAO,GAAG,yBAAyB,MAAM,EAAE,CAAC;YAClD,OAAO,UAAU,CAAC;gBAChB,QAAQ,EAAE,QAAQ,IAAI,IAAI;gBAC1B,QAAQ,EAAE,QAAQ,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,QAAQ;gBACrC,SAAS,EAAE,MAAM,CAAC,MAAM;gBACxB,OAAO;gBACP,gBAAgB,EAAE,OAAO,CAAC,MAAM,GAAG,MAAM,CAAC,kBAAkB,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,KAAK,CAAC,CAAC,EAAE,MAAM,CAAC,kBAAkB,CAAC,KAAK,CAAC,CAAC,CAAC,OAAO;gBAC5H,IAAI,EAAE,kHAAkH;aACzH,CAAC,CAAC;QACL,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,OAAO,WAAW,CAAC,cAAc,CAAC,KAAK,CAAC,CAAC,CAAC;QAC5C,CAAC;IACH,CAAC,CACF,CAAC;AACJ,CAAC"}
|
||||
Vendored
+89
@@ -0,0 +1,89 @@
|
||||
import { PNG } from 'pngjs';
|
||||
import pixelmatch from 'pixelmatch';
|
||||
import { z } from 'zod';
|
||||
import { config } from '../../config.js';
|
||||
import { readBaseline, resolveBaselinePath } from '../../lib/baselines.js';
|
||||
import { errorResult, jsonResult, toErrorMessage } from '../shared.js';
|
||||
import { browserManager } from './browserManager.js';
|
||||
function toDataUri(png) {
|
||||
return `data:image/png;base64,${png.toString('base64')}`;
|
||||
}
|
||||
export function comparePngBuffers(baselinePng, currentPng, threshold = 0.1) {
|
||||
const baseline = PNG.sync.read(baselinePng);
|
||||
const current = PNG.sync.read(currentPng);
|
||||
if (baseline.width !== current.width || baseline.height !== current.height) {
|
||||
throw new Error(`Screenshot dimensions differ from baseline (${baseline.width}x${baseline.height} vs ${current.width}x${current.height}). ` +
|
||||
'Recapture the baseline at the same viewport/selector size.');
|
||||
}
|
||||
const { width, height } = baseline;
|
||||
const diff = new PNG({ width, height });
|
||||
const mismatchedPixels = pixelmatch(baseline.data, current.data, diff.data, width, height, {
|
||||
threshold,
|
||||
});
|
||||
const total = width * height;
|
||||
const mismatchPercent = total === 0 ? 0 : (mismatchedPixels / total) * 100;
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
mismatchedPixels,
|
||||
mismatchPercent,
|
||||
diffPng: PNG.sync.write(diff),
|
||||
};
|
||||
}
|
||||
export function registerBrowserVisualDiffTool(server) {
|
||||
server.registerTool('browser_visual_diff', {
|
||||
title: 'Visual Screenshot Diff',
|
||||
description: 'Capture the current page/element and compare it to a previously saved baseline with pixelmatch. Returns mismatch stats and a diff PNG data URI.',
|
||||
inputSchema: {
|
||||
name: z.string().min(1).describe('Baseline name previously saved with browser_screenshot_baseline.'),
|
||||
selector: z.string().optional().describe('Optional CSS selector to screenshot for comparison.'),
|
||||
fullPage: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.default(false)
|
||||
.describe('Capture the full scrollable page when selector is omitted.'),
|
||||
threshold: z
|
||||
.number()
|
||||
.min(0)
|
||||
.max(1)
|
||||
.optional()
|
||||
.default(0.1)
|
||||
.describe('pixelmatch threshold (0–1). Lower is stricter.'),
|
||||
},
|
||||
}, async ({ name, selector, fullPage, threshold }) => {
|
||||
try {
|
||||
const baselinePng = readBaseline(name);
|
||||
const session = await browserManager.getSession();
|
||||
let currentPng;
|
||||
if (selector) {
|
||||
const element = await session.page.locator(selector).first();
|
||||
await element.waitFor({ state: 'visible', timeout: 5000 });
|
||||
currentPng = await element.screenshot({ type: 'png' });
|
||||
}
|
||||
else {
|
||||
currentPng = await session.page.screenshot({ fullPage, type: 'png' });
|
||||
}
|
||||
const comparison = comparePngBuffers(baselinePng, currentPng, threshold ?? 0.1);
|
||||
const diffDataUri = toDataUri(comparison.diffPng);
|
||||
return jsonResult({
|
||||
name,
|
||||
baselinePath: resolveBaselinePath(name),
|
||||
selector: selector ?? null,
|
||||
width: comparison.width,
|
||||
height: comparison.height,
|
||||
mismatchedPixels: comparison.mismatchedPixels,
|
||||
mismatchPercent: Number(comparison.mismatchPercent.toFixed(4)),
|
||||
passed: comparison.mismatchedPixels === 0,
|
||||
threshold: threshold ?? 0.1,
|
||||
diffDataUri,
|
||||
truncatedDiffDataUri: diffDataUri.length > config.maxToolOutputChars
|
||||
? `${diffDataUri.slice(0, config.maxToolOutputChars)}...`
|
||||
: diffDataUri,
|
||||
});
|
||||
}
|
||||
catch (error) {
|
||||
return errorResult(toErrorMessage(error));
|
||||
}
|
||||
});
|
||||
}
|
||||
//# sourceMappingURL=visualDiff.js.map
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"visualDiff.js","sourceRoot":"","sources":["../../../src/tools/browser/visualDiff.ts"],"names":[],"mappings":"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"}
|
||||
@@ -0,0 +1,144 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { z } from 'zod';
|
||||
import { config } from '../../config.js';
|
||||
import { runCommand } from '../../lib/runCommand.js';
|
||||
import { kebabCase, pascalCase } from '../../lib/strings.js';
|
||||
import { errorResult, jsonResult, toErrorMessage } from '../shared.js';
|
||||
import { requireCodeIgniter } from './requireCodeIgniter.js';
|
||||
function pluralize(word) {
|
||||
const lower = word.toLowerCase();
|
||||
if (lower.endsWith('ies'))
|
||||
return word;
|
||||
if (lower.endsWith('y') && !/[aeiou]y$/i.test(word))
|
||||
return `${word.slice(0, -1)}ies`;
|
||||
if (/(s|x|z|ch|sh)$/i.test(word))
|
||||
return `${word}es`;
|
||||
if (lower.endsWith('s'))
|
||||
return word;
|
||||
return `${word}s`;
|
||||
}
|
||||
export function registerGenerateCodeIgniterResourceTool(server) {
|
||||
server.registerTool('generate_codeigniter_resource', {
|
||||
title: 'Generate CodeIgniter Resource',
|
||||
description: 'One-shot scaffold of a full CodeIgniter 4 resource: model + migration + seeder + ResourceController, ' +
|
||||
'then wire the route in app/Config/Routes.php. Supports an optional API version path prefix.',
|
||||
inputSchema: {
|
||||
name: z
|
||||
.string()
|
||||
.min(1)
|
||||
.describe('Resource name, e.g. "Post" or "blog_post". Converted to PascalCase for the model/controller.'),
|
||||
migrate: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.default(false)
|
||||
.describe('If true, run `php spark migrate` after scaffolding. Requires a configured database.'),
|
||||
apiVersion: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Optional API version prefix for the route, e.g. "v1" → resource at /v1/{name}.'),
|
||||
resourceController: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.default(true)
|
||||
.describe('If true (default), generate a ResourceController via spark --resource.'),
|
||||
},
|
||||
}, async ({ name, migrate, apiVersion, resourceController }) => {
|
||||
try {
|
||||
const ci = requireCodeIgniter();
|
||||
const model = pascalCase(name);
|
||||
const controller = `${model}Controller`;
|
||||
const routeName = kebabCase(pluralize(model));
|
||||
const versionPrefix = apiVersion?.replace(/^\/+|\/+$/g, '') || '';
|
||||
const resourcePath = versionPrefix ? `${versionPrefix}/${routeName}` : routeName;
|
||||
const steps = [];
|
||||
const modelResult = await runCommand('php', ['spark', 'make:model', model, '--no-interaction'], {
|
||||
cwd: ci.root,
|
||||
timeoutMs: config.scaffoldCommandTimeoutMs,
|
||||
});
|
||||
steps.push({ step: 'make:model', exitCode: modelResult.exitCode, failed: modelResult.failed, output: modelResult.output });
|
||||
const migrationResult = await runCommand('php', ['spark', 'make:migration', `create_${kebabCase(pluralize(model))}_table`, '--no-interaction'], { cwd: ci.root, timeoutMs: config.scaffoldCommandTimeoutMs });
|
||||
steps.push({
|
||||
step: 'make:migration',
|
||||
exitCode: migrationResult.exitCode,
|
||||
failed: migrationResult.failed,
|
||||
output: migrationResult.output,
|
||||
});
|
||||
const seederResult = await runCommand('php', ['spark', 'make:seeder', `${model}Seeder`, '--no-interaction'], {
|
||||
cwd: ci.root,
|
||||
timeoutMs: config.scaffoldCommandTimeoutMs,
|
||||
});
|
||||
steps.push({
|
||||
step: 'make:seeder',
|
||||
exitCode: seederResult.exitCode,
|
||||
failed: seederResult.failed,
|
||||
output: seederResult.output,
|
||||
});
|
||||
const controllerArgs = ['spark', 'make:controller', controller, '--no-interaction'];
|
||||
if (resourceController !== false)
|
||||
controllerArgs.splice(3, 0, '--resource');
|
||||
const controllerResult = await runCommand('php', controllerArgs, {
|
||||
cwd: ci.root,
|
||||
timeoutMs: config.scaffoldCommandTimeoutMs,
|
||||
});
|
||||
steps.push({
|
||||
step: 'make:controller',
|
||||
exitCode: controllerResult.exitCode,
|
||||
failed: controllerResult.failed,
|
||||
output: controllerResult.output,
|
||||
});
|
||||
const routesFile = path.join(ci.root, 'app', 'Config', 'Routes.php');
|
||||
const routeLine = `$routes->resource('${resourcePath}', ['controller' => '\\App\\Controllers\\${controller}']);`;
|
||||
const routeWired = wireRoute(routesFile, routeLine);
|
||||
steps.push({ step: 'wire-route', routesFile, routeLine, wired: routeWired });
|
||||
let migrateResult = null;
|
||||
if (migrate) {
|
||||
migrateResult = await runCommand('php', ['spark', 'migrate', '--no-interaction'], {
|
||||
cwd: ci.root,
|
||||
timeoutMs: config.scaffoldCommandTimeoutMs,
|
||||
});
|
||||
steps.push({
|
||||
step: 'migrate',
|
||||
exitCode: migrateResult.exitCode,
|
||||
failed: migrateResult.failed,
|
||||
output: migrateResult.output,
|
||||
});
|
||||
}
|
||||
return jsonResult({
|
||||
model,
|
||||
controller,
|
||||
apiVersion: versionPrefix || null,
|
||||
route: `resource /${resourcePath}`,
|
||||
resourceController: resourceController !== false,
|
||||
routesFile,
|
||||
routeWired,
|
||||
steps,
|
||||
});
|
||||
}
|
||||
catch (error) {
|
||||
return errorResult(toErrorMessage(error));
|
||||
}
|
||||
});
|
||||
}
|
||||
function wireRoute(routesFile, routeLine) {
|
||||
fs.mkdirSync(path.dirname(routesFile), { recursive: true });
|
||||
if (!fs.existsSync(routesFile)) {
|
||||
fs.writeFileSync(routesFile, `<?php\n\nuse CodeIgniter\\Router\\RouteCollection;\n\n/**\n * @var RouteCollection $routes\n */\n\n${routeLine}\n`);
|
||||
return true;
|
||||
}
|
||||
const existing = fs.readFileSync(routesFile, 'utf8');
|
||||
if (existing.includes(routeLine.trim()))
|
||||
return false;
|
||||
let next = existing;
|
||||
if (!/use\s+CodeIgniter\\Router\\RouteCollection\s*;/.test(next)) {
|
||||
if (next.startsWith('<?php')) {
|
||||
next = next.replace('<?php', "<?php\n\nuse CodeIgniter\\Router\\RouteCollection;");
|
||||
}
|
||||
else {
|
||||
next = `<?php\n\nuse CodeIgniter\\Router\\RouteCollection;\n\n${next}`;
|
||||
}
|
||||
}
|
||||
fs.writeFileSync(routesFile, `${next.trimEnd()}\n${routeLine}\n`);
|
||||
return true;
|
||||
}
|
||||
//# sourceMappingURL=generateCodeIgniterResource.js.map
|
||||
File diff suppressed because one or more lines are too long
+61
@@ -0,0 +1,61 @@
|
||||
import { z } from 'zod';
|
||||
import { config } from '../../config.js';
|
||||
import { hasComposerDependency, readComposerJson } from '../../lib/frameworks/composer.js';
|
||||
import { runCommand } from '../../lib/runCommand.js';
|
||||
import { errorResult, jsonResult, toErrorMessage } from '../shared.js';
|
||||
import { requireCodeIgniter } from './requireCodeIgniter.js';
|
||||
export function registerGenerateCodeIgniterShieldAuthTool(server) {
|
||||
server.registerTool('generate_codeigniter_shield_auth', {
|
||||
title: 'Scaffold CodeIgniter Shield Auth',
|
||||
description: 'Install/setup CodeIgniter Shield auth helpers. Requires codeigniter4/shield. ' +
|
||||
'Runs `php spark shield:setup` (and optionally composer require). Requires confirm: true.',
|
||||
inputSchema: {
|
||||
installPackage: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.default(false)
|
||||
.describe('If true and Shield is missing, run composer require codeigniter4/shield first.'),
|
||||
confirm: z.boolean().optional().describe('Required as true — Shield setup modifies project files.'),
|
||||
},
|
||||
}, async ({ installPackage, confirm }) => {
|
||||
try {
|
||||
if (confirm !== true) {
|
||||
return errorResult('generate_codeigniter_shield_auth requires confirm: true.');
|
||||
}
|
||||
const ci = requireCodeIgniter();
|
||||
const composer = readComposerJson(ci.root);
|
||||
const steps = [];
|
||||
let hasShield = hasComposerDependency(composer, 'codeigniter4/shield');
|
||||
if (!hasShield && installPackage) {
|
||||
const requireResult = await runCommand('composer', ['require', 'codeigniter4/shield', '--no-interaction'], { cwd: ci.root, timeoutMs: config.scaffoldCommandTimeoutMs });
|
||||
steps.push({
|
||||
step: 'composer-require',
|
||||
exitCode: requireResult.exitCode,
|
||||
failed: requireResult.failed,
|
||||
output: requireResult.output,
|
||||
});
|
||||
if (requireResult.failed) {
|
||||
return errorResult(`Failed to install codeigniter4/shield:\n${requireResult.output}`);
|
||||
}
|
||||
hasShield = true;
|
||||
}
|
||||
if (!hasShield) {
|
||||
return errorResult('codeigniter4/shield is not installed. Pass installPackage: true (with confirm: true) ' +
|
||||
'or run composer_require with package "codeigniter4/shield" first.');
|
||||
}
|
||||
const setup = await runCommand('php', ['spark', 'shield:setup', '--no-interaction'], {
|
||||
cwd: ci.root,
|
||||
timeoutMs: config.scaffoldCommandTimeoutMs,
|
||||
});
|
||||
steps.push({ step: 'shield:setup', exitCode: setup.exitCode, failed: setup.failed, output: setup.output });
|
||||
return jsonResult({
|
||||
package: 'codeigniter4/shield',
|
||||
steps,
|
||||
});
|
||||
}
|
||||
catch (error) {
|
||||
return errorResult(toErrorMessage(error));
|
||||
}
|
||||
});
|
||||
}
|
||||
//# sourceMappingURL=generateShieldAuth.js.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"generateShieldAuth.js","sourceRoot":"","sources":["../../../src/tools/codeigniter/generateShieldAuth.ts"],"names":[],"mappings":"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"}
|
||||
Vendored
+13
@@ -0,0 +1,13 @@
|
||||
import { registerSparkTool } from './spark.js';
|
||||
import { registerGenerateCodeIgniterResourceTool } from './generateCodeIgniterResource.js';
|
||||
import { registerCodeIgniterTestTool } from './runCodeIgniterTests.js';
|
||||
import { registerGenerateCodeIgniterShieldAuthTool } from './generateShieldAuth.js';
|
||||
import { registerSparkDiscoverTool } from './sparkDiscover.js';
|
||||
export function registerCodeIgniterTools(server) {
|
||||
registerSparkTool(server);
|
||||
registerGenerateCodeIgniterResourceTool(server);
|
||||
registerCodeIgniterTestTool(server);
|
||||
registerGenerateCodeIgniterShieldAuthTool(server);
|
||||
registerSparkDiscoverTool(server);
|
||||
}
|
||||
//# sourceMappingURL=index.js.map
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/tools/codeigniter/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAC;AAC/C,OAAO,EAAE,uCAAuC,EAAE,MAAM,kCAAkC,CAAC;AAC3F,OAAO,EAAE,2BAA2B,EAAE,MAAM,0BAA0B,CAAC;AACvE,OAAO,EAAE,yCAAyC,EAAE,MAAM,yBAAyB,CAAC;AACpF,OAAO,EAAE,yBAAyB,EAAE,MAAM,oBAAoB,CAAC;AAE/D,MAAM,UAAU,wBAAwB,CAAC,MAAiB;IACxD,iBAAiB,CAAC,MAAM,CAAC,CAAC;IAC1B,uCAAuC,CAAC,MAAM,CAAC,CAAC;IAChD,2BAA2B,CAAC,MAAM,CAAC,CAAC;IACpC,yCAAyC,CAAC,MAAM,CAAC,CAAC;IAClD,yBAAyB,CAAC,MAAM,CAAC,CAAC;AACpC,CAAC"}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
import { config } from '../../config.js';
|
||||
import { detectProject } from '../../lib/frameworks/detect.js';
|
||||
import { CodeIgniterAdapter } from '../../lib/frameworks/codeigniter.js';
|
||||
export function requireCodeIgniter() {
|
||||
const detected = detectProject(config.workspaceRoot);
|
||||
if (detected.backend instanceof CodeIgniterAdapter) {
|
||||
return detected.backend;
|
||||
}
|
||||
throw new Error('No CodeIgniter 4 project detected in WORKSPACE_ROOT. ' +
|
||||
'Expected composer.json with codeigniter4/framework (or a spark file) at the workspace root.');
|
||||
}
|
||||
//# sourceMappingURL=requireCodeIgniter.js.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"requireCodeIgniter.js","sourceRoot":"","sources":["../../../src/tools/codeigniter/requireCodeIgniter.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,iBAAiB,CAAC;AACzC,OAAO,EAAE,aAAa,EAAE,MAAM,gCAAgC,CAAC;AAC/D,OAAO,EAAE,kBAAkB,EAAE,MAAM,qCAAqC,CAAC;AAEzE,MAAM,UAAU,kBAAkB;IAChC,MAAM,QAAQ,GAAG,aAAa,CAAC,MAAM,CAAC,aAAa,CAAC,CAAC;IACrD,IAAI,QAAQ,CAAC,OAAO,YAAY,kBAAkB,EAAE,CAAC;QACnD,OAAO,QAAQ,CAAC,OAAO,CAAC;IAC1B,CAAC;IACD,MAAM,IAAI,KAAK,CACb,uDAAuD;QACrD,6FAA6F,CAChG,CAAC;AACJ,CAAC"}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
import path from 'node:path';
|
||||
import { z } from 'zod';
|
||||
import { config } from '../../config.js';
|
||||
import { vendorBinExists } from '../../lib/frameworks/composer.js';
|
||||
import { runCommand } from '../../lib/runCommand.js';
|
||||
import { errorResult, jsonResult, toErrorMessage } from '../shared.js';
|
||||
import { requireCodeIgniter } from './requireCodeIgniter.js';
|
||||
function vendorBin(root, bin) {
|
||||
const suffix = process.platform === 'win32' ? '.bat' : '';
|
||||
return path.join(root, 'vendor', 'bin', `${bin}${suffix}`);
|
||||
}
|
||||
function parsePhpUnitSummary(output) {
|
||||
const ok = output.match(/OK\s*\((\d+)\s+tests?,\s*(\d+)\s+assertions?\)/i);
|
||||
if (ok) {
|
||||
return { passed: true, tests: Number(ok[1]), assertions: Number(ok[2]), failures: 0, errors: 0 };
|
||||
}
|
||||
const summary = output.match(/Tests:\s*(\d+),\s*Assertions:\s*(\d+)(?:,\s*Errors:\s*(\d+))?(?:,\s*Failures:\s*(\d+))?(?:,\s*Skipped:\s*(\d+))?/i);
|
||||
if (summary) {
|
||||
return {
|
||||
passed: !/FAILURES!|ERRORS!/i.test(output),
|
||||
tests: Number(summary[1]),
|
||||
assertions: Number(summary[2]),
|
||||
errors: Number(summary[3] ?? 0),
|
||||
failures: Number(summary[4] ?? 0),
|
||||
skipped: Number(summary[5] ?? 0),
|
||||
};
|
||||
}
|
||||
return { passed: null, note: 'Could not parse a pass/fail summary from the runner output.' };
|
||||
}
|
||||
export function registerCodeIgniterTestTool(server) {
|
||||
server.registerTool('run_codeigniter_tests', {
|
||||
title: 'Run CodeIgniter Tests',
|
||||
description: "Run the CodeIgniter 4 project's PHPUnit-based test suite (CIUnitTestCase) via vendor/bin/phpunit, " +
|
||||
'or `php spark test` as a fallback. Returns parsed pass/fail counts when possible.',
|
||||
inputSchema: {
|
||||
extraArgs: z
|
||||
.array(z.string())
|
||||
.optional()
|
||||
.describe('Extra CLI args, e.g. ["--filter", "UserTest"].'),
|
||||
},
|
||||
}, async ({ extraArgs }) => {
|
||||
try {
|
||||
const ci = requireCodeIgniter();
|
||||
const args = extraArgs ?? [];
|
||||
let command;
|
||||
let cmdArgs;
|
||||
if (vendorBinExists(ci.root, 'phpunit')) {
|
||||
command = vendorBin(ci.root, 'phpunit');
|
||||
cmdArgs = args;
|
||||
}
|
||||
else if (vendorBinExists(ci.root, 'spark')) {
|
||||
// CI4 does not ship a built-in `spark test` command out of the box,
|
||||
// but projects sometimes add one. If phpunit is missing, try it.
|
||||
command = 'php';
|
||||
cmdArgs = ['spark', 'test', ...args];
|
||||
}
|
||||
else {
|
||||
return errorResult(`No vendor/bin/phpunit found at ${ci.root}. Run composer install (or run_install) first.`);
|
||||
}
|
||||
const result = await runCommand(command, cmdArgs, {
|
||||
cwd: ci.root,
|
||||
timeoutMs: config.scaffoldCommandTimeoutMs,
|
||||
});
|
||||
return jsonResult({ runner: 'phpunit', summary: parsePhpUnitSummary(result.output), ...result });
|
||||
}
|
||||
catch (error) {
|
||||
return errorResult(toErrorMessage(error));
|
||||
}
|
||||
});
|
||||
}
|
||||
//# sourceMappingURL=runCodeIgniterTests.js.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"runCodeIgniterTests.js","sourceRoot":"","sources":["../../../src/tools/codeigniter/runCodeIgniterTests.ts"],"names":[],"mappings":"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"}
|
||||
Vendored
+57
@@ -0,0 +1,57 @@
|
||||
import { z } from 'zod';
|
||||
import { config } from '../../config.js';
|
||||
import { runCommand } from '../../lib/runCommand.js';
|
||||
import { errorResult, jsonResult, toErrorMessage } from '../shared.js';
|
||||
import { isAllowedSparkCommand, SPARK_ALLOWED, SPARK_DESTRUCTIVE } from './sparkAllowList.js';
|
||||
import { requireCodeIgniter } from './requireCodeIgniter.js';
|
||||
export function registerSparkTool(server) {
|
||||
server.registerTool('spark', {
|
||||
title: 'Run Spark Command',
|
||||
description: 'Run a whitelisted `php spark` subcommand in the detected CodeIgniter 4 project and return stdout/stderr. ' +
|
||||
`Allowed commands: ${SPARK_ALLOWED.join(', ')}. ` +
|
||||
'Destructive command (migrate:refresh) requires confirm: true.',
|
||||
inputSchema: {
|
||||
command: z
|
||||
.string()
|
||||
.min(1)
|
||||
.describe('Spark subcommand, e.g. "make:controller" or "migrate".'),
|
||||
args: z
|
||||
.array(z.string())
|
||||
.optional()
|
||||
.default([])
|
||||
.describe('Arguments forwarded after the subcommand, e.g. ["PostController", "--resource"].'),
|
||||
confirm: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe('Required as true for destructive command (migrate:refresh).'),
|
||||
},
|
||||
}, async ({ command, args, confirm }) => {
|
||||
try {
|
||||
if (!isAllowedSparkCommand(command)) {
|
||||
return errorResult(`Spark command "${command}" is not on the allow-list. Allowed: ${SPARK_ALLOWED.join(', ')}.`);
|
||||
}
|
||||
if (SPARK_DESTRUCTIVE.has(command) && confirm !== true) {
|
||||
return errorResult(`Spark command "${command}" is destructive and requires confirm: true.`);
|
||||
}
|
||||
const ci = requireCodeIgniter();
|
||||
const phpArgs = ['spark', command, ...(args ?? [])];
|
||||
const result = await runCommand('php', phpArgs, {
|
||||
cwd: ci.root,
|
||||
timeoutMs: config.scaffoldCommandTimeoutMs,
|
||||
});
|
||||
return jsonResult({
|
||||
sparkCommand: command,
|
||||
sparkArgs: args ?? [],
|
||||
exitCode: result.exitCode,
|
||||
timedOut: result.timedOut,
|
||||
failed: result.failed,
|
||||
output: result.output,
|
||||
cwd: result.cwd,
|
||||
});
|
||||
}
|
||||
catch (error) {
|
||||
return errorResult(toErrorMessage(error));
|
||||
}
|
||||
});
|
||||
}
|
||||
//# sourceMappingURL=spark.js.map
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"spark.js","sourceRoot":"","sources":["../../../src/tools/codeigniter/spark.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,OAAO,EAAE,MAAM,EAAE,MAAM,iBAAiB,CAAC;AAEzC,OAAO,EAAE,UAAU,EAAE,MAAM,yBAAyB,CAAC;AACrD,OAAO,EAAE,WAAW,EAAE,UAAU,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AACvE,OAAO,EAAE,qBAAqB,EAAE,aAAa,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAC;AAC9F,OAAO,EAAE,kBAAkB,EAAE,MAAM,yBAAyB,CAAC;AAE7D,MAAM,UAAU,iBAAiB,CAAC,MAAiB;IACjD,MAAM,CAAC,YAAY,CACjB,OAAO,EACP;QACE,KAAK,EAAE,mBAAmB;QAC1B,WAAW,EACT,2GAA2G;YAC3G,qBAAqB,aAAa,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI;YACjD,+DAA+D;QACjE,WAAW,EAAE;YACX,OAAO,EAAE,CAAC;iBACP,MAAM,EAAE;iBACR,GAAG,CAAC,CAAC,CAAC;iBACN,QAAQ,CAAC,wDAAwD,CAAC;YACrE,IAAI,EAAE,CAAC;iBACJ,KAAK,CAAC,CAAC,CAAC,MAAM,EAAE,CAAC;iBACjB,QAAQ,EAAE;iBACV,OAAO,CAAC,EAAE,CAAC;iBACX,QAAQ,CAAC,kFAAkF,CAAC;YAC/F,OAAO,EAAE,CAAC;iBACP,OAAO,EAAE;iBACT,QAAQ,EAAE;iBACV,QAAQ,CAAC,6DAA6D,CAAC;SAC3E;KACF,EACD,KAAK,EAAE,EAAE,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,EAAE,EAAE;QACnC,IAAI,CAAC;YACH,IAAI,CAAC,qBAAqB,CAAC,OAAO,CAAC,EAAE,CAAC;gBACpC,OAAO,WAAW,CAChB,kBAAkB,OAAO,wCAAwC,aAAa,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,CAC7F,CAAC;YACJ,CAAC;YACD,IAAI,iBAAiB,CAAC,GAAG,CAAC,OAAO,CAAC,IAAI,OAAO,KAAK,IAAI,EAAE,CAAC;gBACvD,OAAO,WAAW,CAChB,kBAAkB,OAAO,8CAA8C,CACxE,CAAC;YACJ,CAAC;YAED,MAAM,EAAE,GAAG,kBAAkB,EAAE,CAAC;YAChC,MAAM,OAAO,GAAG,CAAC,OAAO,EAAE,OAAO,EAAE,GAAG,CAAC,IAAI,IAAI,EAAE,CAAC,CAAC,CAAC;YAEpD,MAAM,MAAM,GAAG,MAAM,UAAU,CAAC,KAAK,EAAE,OAAO,EAAE;gBAC9C,GAAG,EAAE,EAAE,CAAC,IAAI;gBACZ,SAAS,EAAE,MAAM,CAAC,wBAAwB;aAC3C,CAAC,CAAC;YACH,OAAO,UAAU,CAAC;gBAChB,YAAY,EAAE,OAAO;gBACrB,SAAS,EAAE,IAAI,IAAI,EAAE;gBACrB,QAAQ,EAAE,MAAM,CAAC,QAAQ;gBACzB,QAAQ,EAAE,MAAM,CAAC,QAAQ;gBACzB,MAAM,EAAE,MAAM,CAAC,MAAM;gBACrB,MAAM,EAAE,MAAM,CAAC,MAAM;gBACrB,GAAG,EAAE,MAAM,CAAC,GAAG;aAChB,CAAC,CAAC;QACL,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,OAAO,WAAW,CAAC,cAAc,CAAC,KAAK,CAAC,CAAC,CAAC;QAC5C,CAAC;IACH,CAAC,CACF,CAAC;AACJ,CAAC"}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Spark subcommands the `spark` tool is willing to run.
|
||||
* Anything not on this list is rejected so the agent cannot pass arbitrary
|
||||
* spark commands (e.g. `spark make:migration` with a bad path, destructive
|
||||
* migrations without confirm, etc.).
|
||||
*/
|
||||
export const SPARK_ALLOWED = [
|
||||
'make:controller',
|
||||
'make:model',
|
||||
'make:entity',
|
||||
'make:filter',
|
||||
'make:command',
|
||||
'make:validation',
|
||||
'make:migration',
|
||||
'make:seeder',
|
||||
'make:test',
|
||||
'make:config',
|
||||
'migrate',
|
||||
'migrate:rollback',
|
||||
'migrate:refresh',
|
||||
'migrate:status',
|
||||
'db:seed',
|
||||
'routes',
|
||||
'cache:clear',
|
||||
'optimize',
|
||||
'list',
|
||||
'about',
|
||||
];
|
||||
export const SPARK_DESTRUCTIVE = new Set(['migrate:refresh']);
|
||||
export function isAllowedSparkCommand(command) {
|
||||
return SPARK_ALLOWED.includes(command);
|
||||
}
|
||||
//# sourceMappingURL=sparkAllowList.js.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"sparkAllowList.js","sourceRoot":"","sources":["../../../src/tools/codeigniter/sparkAllowList.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,MAAM,CAAC,MAAM,aAAa,GAAG;IAC3B,iBAAiB;IACjB,YAAY;IACZ,aAAa;IACb,aAAa;IACb,cAAc;IACd,iBAAiB;IACjB,gBAAgB;IAChB,aAAa;IACb,WAAW;IACX,aAAa;IACb,SAAS;IACT,kBAAkB;IAClB,iBAAiB;IACjB,gBAAgB;IAChB,SAAS;IACT,QAAQ;IACR,aAAa;IACb,UAAU;IACV,MAAM;IACN,OAAO;CACC,CAAC;AAIX,MAAM,CAAC,MAAM,iBAAiB,GAAG,IAAI,GAAG,CAAS,CAAC,iBAAiB,CAAC,CAAC,CAAC;AAEtE,MAAM,UAAU,qBAAqB,CAAC,OAAe;IACnD,OAAQ,aAAmC,CAAC,QAAQ,CAAC,OAAO,CAAC,CAAC;AAChE,CAAC"}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
import { config } from '../../config.js';
|
||||
import { runCommand } from '../../lib/runCommand.js';
|
||||
import { errorResult, jsonResult, toErrorMessage } from '../shared.js';
|
||||
import { isAllowedSparkCommand, SPARK_ALLOWED } from './sparkAllowList.js';
|
||||
import { requireCodeIgniter } from './requireCodeIgniter.js';
|
||||
export function registerSparkDiscoverTool(server) {
|
||||
server.registerTool('spark_discover', {
|
||||
title: 'Discover Spark Commands',
|
||||
description: 'Run `php spark list` and return only commands that are on the server allow-list (intersected with SPARK_ALLOWED).',
|
||||
inputSchema: {},
|
||||
}, async () => {
|
||||
try {
|
||||
const ci = requireCodeIgniter();
|
||||
const result = await runCommand('php', ['spark', 'list', '--no-interaction'], {
|
||||
cwd: ci.root,
|
||||
timeoutMs: config.defaultCommandTimeoutMs,
|
||||
});
|
||||
const discovered = new Set();
|
||||
for (const line of result.output.split(/\r?\n/)) {
|
||||
const match = line.match(/^\s*([a-z][\w:-]*)\s+/i);
|
||||
const command = match?.[1];
|
||||
if (command && isAllowedSparkCommand(command)) {
|
||||
discovered.add(command);
|
||||
}
|
||||
}
|
||||
// Also include allow-listed commands even if spark list formatting hid them.
|
||||
for (const allowed of SPARK_ALLOWED) {
|
||||
if (result.output.includes(allowed))
|
||||
discovered.add(allowed);
|
||||
}
|
||||
return jsonResult({
|
||||
exitCode: result.exitCode,
|
||||
failed: result.failed,
|
||||
allowed: [...SPARK_ALLOWED],
|
||||
discovered: [...discovered].sort(),
|
||||
note: 'Only allow-listed spark commands are returned. Use the spark tool to run one.',
|
||||
});
|
||||
}
|
||||
catch (error) {
|
||||
return errorResult(toErrorMessage(error));
|
||||
}
|
||||
});
|
||||
}
|
||||
//# sourceMappingURL=sparkDiscover.js.map
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"sparkDiscover.js","sourceRoot":"","sources":["../../../src/tools/codeigniter/sparkDiscover.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,MAAM,EAAE,MAAM,iBAAiB,CAAC;AACzC,OAAO,EAAE,UAAU,EAAE,MAAM,yBAAyB,CAAC;AACrD,OAAO,EAAE,WAAW,EAAE,UAAU,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AACvE,OAAO,EAAE,qBAAqB,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAC3E,OAAO,EAAE,kBAAkB,EAAE,MAAM,yBAAyB,CAAC;AAE7D,MAAM,UAAU,yBAAyB,CAAC,MAAiB;IACzD,MAAM,CAAC,YAAY,CACjB,gBAAgB,EAChB;QACE,KAAK,EAAE,yBAAyB;QAChC,WAAW,EACT,mHAAmH;QACrH,WAAW,EAAE,EAAE;KAChB,EACD,KAAK,IAAI,EAAE;QACT,IAAI,CAAC;YACH,MAAM,EAAE,GAAG,kBAAkB,EAAE,CAAC;YAChC,MAAM,MAAM,GAAG,MAAM,UAAU,CAAC,KAAK,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,kBAAkB,CAAC,EAAE;gBAC5E,GAAG,EAAE,EAAE,CAAC,IAAI;gBACZ,SAAS,EAAE,MAAM,CAAC,uBAAuB;aAC1C,CAAC,CAAC;YAEH,MAAM,UAAU,GAAG,IAAI,GAAG,EAAU,CAAC;YACrC,KAAK,MAAM,IAAI,IAAI,MAAM,CAAC,MAAM,CAAC,KAAK,CAAC,OAAO,CAAC,EAAE,CAAC;gBAChD,MAAM,KAAK,GAAG,IAAI,CAAC,KAAK,CAAC,wBAAwB,CAAC,CAAC;gBACnD,MAAM,OAAO,GAAG,KAAK,EAAE,CAAC,CAAC,CAAC,CAAC;gBAC3B,IAAI,OAAO,IAAI,qBAAqB,CAAC,OAAO,CAAC,EAAE,CAAC;oBAC9C,UAAU,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC;gBAC1B,CAAC;YACH,CAAC;YAED,6EAA6E;YAC7E,KAAK,MAAM,OAAO,IAAI,aAAa,EAAE,CAAC;gBACpC,IAAI,MAAM,CAAC,MAAM,CAAC,QAAQ,CAAC,OAAO,CAAC;oBAAE,UAAU,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC;YAC/D,CAAC;YAED,OAAO,UAAU,CAAC;gBAChB,QAAQ,EAAE,MAAM,CAAC,QAAQ;gBACzB,MAAM,EAAE,MAAM,CAAC,MAAM;gBACrB,OAAO,EAAE,CAAC,GAAG,aAAa,CAAC;gBAC3B,UAAU,EAAE,CAAC,GAAG,UAAU,CAAC,CAAC,IAAI,EAAE;gBAClC,IAAI,EAAE,+EAA+E;aACtF,CAAC,CAAC;QACL,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,OAAO,WAAW,CAAC,cAAc,CAAC,KAAK,CAAC,CAAC,CAAC;QAC5C,CAAC;IACH,CAAC,CACF,CAAC;AACJ,CAAC"}
|
||||
Vendored
+92
@@ -0,0 +1,92 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { z } from 'zod';
|
||||
import { config } from '../../config.js';
|
||||
import { resolveWorkspacePath } from '../../lib/sandbox.js';
|
||||
import { errorResult, jsonResult, toErrorMessage } from '../shared.js';
|
||||
import { dockerManager } from './dockerManager.js';
|
||||
export function registerDockerBuildImageTool(server) {
|
||||
server.registerTool('docker_build_image', {
|
||||
title: 'Docker Build Image',
|
||||
description: 'Build a Docker image from a Dockerfile in the workspace. Streams the build output back and returns the resulting image tag.',
|
||||
inputSchema: {
|
||||
tag: z.string().min(1).describe('Image tag, e.g. "my-app:latest".'),
|
||||
dockerfile: z
|
||||
.string()
|
||||
.optional()
|
||||
.default('Dockerfile')
|
||||
.describe('Path to the Dockerfile, relative to the workspace root.'),
|
||||
buildContext: z
|
||||
.string()
|
||||
.optional()
|
||||
.default('.')
|
||||
.describe('Build context directory, relative to the workspace root.'),
|
||||
},
|
||||
}, async ({ tag, dockerfile, buildContext }) => {
|
||||
try {
|
||||
await dockerManager.ensureReachable();
|
||||
const dockerfilePath = resolveWorkspacePath(config.workspaceRoot, dockerfile);
|
||||
const contextPath = resolveWorkspacePath(config.workspaceRoot, buildContext);
|
||||
if (!fs.existsSync(dockerfilePath)) {
|
||||
return errorResult(`Dockerfile not found: ${dockerfilePath}`);
|
||||
}
|
||||
const stream = await dockerManager.getDocker().buildImage({
|
||||
context: contextPath,
|
||||
src: [path.basename(dockerfilePath), ...listContextFiles(contextPath)],
|
||||
}, { t: tag, dockerfile: path.basename(dockerfilePath) });
|
||||
const output = [];
|
||||
await new Promise((resolve, reject) => {
|
||||
dockerManager
|
||||
.getDocker()
|
||||
.modem.followProgress(stream, (err, res) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
const last = res?.[res.length - 1];
|
||||
if (last && 'error' in last && last.error) {
|
||||
reject(new Error(String(last.error)));
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
}, (event) => {
|
||||
const line = typeof event === 'string' ? event : JSON.stringify(event);
|
||||
output.push(line);
|
||||
if (output.length > config.maxBufferedLogLines) {
|
||||
output.splice(0, output.length - config.maxBufferedLogLines);
|
||||
}
|
||||
});
|
||||
});
|
||||
return jsonResult({
|
||||
tag,
|
||||
dockerfile: dockerfilePath,
|
||||
context: contextPath,
|
||||
output: output.join('\n').slice(-config.maxToolOutputChars),
|
||||
});
|
||||
}
|
||||
catch (error) {
|
||||
return errorResult(toErrorMessage(error));
|
||||
}
|
||||
});
|
||||
}
|
||||
function listContextFiles(root) {
|
||||
const files = [];
|
||||
const walk = (dir) => {
|
||||
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const full = path.join(dir, entry.name);
|
||||
const rel = path.relative(root, full);
|
||||
if (entry.name === 'node_modules' || entry.name === '.git' || entry.name === 'dist')
|
||||
continue;
|
||||
if (entry.isDirectory()) {
|
||||
walk(full);
|
||||
}
|
||||
else {
|
||||
files.push(rel);
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(root);
|
||||
return files;
|
||||
}
|
||||
//# sourceMappingURL=buildImage.js.map
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"buildImage.js","sourceRoot":"","sources":["../../../src/tools/docker/buildImage.ts"],"names":[],"mappings":"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"}
|
||||
Vendored
+78
@@ -0,0 +1,78 @@
|
||||
import { z } from 'zod';
|
||||
import { config } from '../../config.js';
|
||||
import { runCommand } from '../../lib/runCommand.js';
|
||||
import { errorResult, jsonResult, toErrorMessage } from '../shared.js';
|
||||
/**
|
||||
* Docker Compose is invoked via the local `docker compose` or `docker-compose`
|
||||
* CLI. The server does not parse compose files itself; it delegates to the
|
||||
* official tool and streams the output back, just like workflow tools.
|
||||
*/
|
||||
export function registerDockerComposeTools(server) {
|
||||
server.registerTool('docker_compose_up', {
|
||||
title: 'Docker Compose Up',
|
||||
description: 'Bring up a docker-compose.yml stack in the workspace root. Detached by default so the server does not block.',
|
||||
inputSchema: {
|
||||
file: z
|
||||
.string()
|
||||
.optional()
|
||||
.default('docker-compose.yml')
|
||||
.describe('Compose file path, relative to the workspace root.'),
|
||||
services: z
|
||||
.array(z.string())
|
||||
.optional()
|
||||
.describe('Specific services to start. Omit to start all.'),
|
||||
build: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.default(false)
|
||||
.describe('Build images before starting.'),
|
||||
},
|
||||
}, async ({ file, services, build }) => {
|
||||
try {
|
||||
const args = ['compose', '-f', file, 'up', '-d'];
|
||||
if (build)
|
||||
args.push('--build');
|
||||
if (services && services.length > 0)
|
||||
args.push(...services);
|
||||
const result = await runCommand('docker', args, {
|
||||
cwd: config.workspaceRoot,
|
||||
timeoutMs: config.scaffoldCommandTimeoutMs,
|
||||
});
|
||||
return jsonResult({ file, ...result });
|
||||
}
|
||||
catch (error) {
|
||||
return errorResult(toErrorMessage(error));
|
||||
}
|
||||
});
|
||||
server.registerTool('docker_compose_down', {
|
||||
title: 'Docker Compose Down',
|
||||
description: 'Shut down a docker-compose.yml stack in the workspace root.',
|
||||
inputSchema: {
|
||||
file: z
|
||||
.string()
|
||||
.optional()
|
||||
.default('docker-compose.yml')
|
||||
.describe('Compose file path, relative to the workspace root.'),
|
||||
volumes: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.default(false)
|
||||
.describe('Also remove named volumes declared in the volumes section.'),
|
||||
},
|
||||
}, async ({ file, volumes }) => {
|
||||
try {
|
||||
const args = ['compose', '-f', file, 'down'];
|
||||
if (volumes)
|
||||
args.push('-v');
|
||||
const result = await runCommand('docker', args, {
|
||||
cwd: config.workspaceRoot,
|
||||
timeoutMs: config.defaultCommandTimeoutMs,
|
||||
});
|
||||
return jsonResult({ file, ...result });
|
||||
}
|
||||
catch (error) {
|
||||
return errorResult(toErrorMessage(error));
|
||||
}
|
||||
});
|
||||
}
|
||||
//# sourceMappingURL=compose.js.map
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"compose.js","sourceRoot":"","sources":["../../../src/tools/docker/compose.ts"],"names":[],"mappings":"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"}
|
||||
Vendored
+52
@@ -0,0 +1,52 @@
|
||||
import { z } from 'zod';
|
||||
import { config } from '../../config.js';
|
||||
import { errorResult, jsonResult, toErrorMessage } from '../shared.js';
|
||||
import { dockerManager } from './dockerManager.js';
|
||||
export function registerDockerContainerLogsTool(server) {
|
||||
server.registerTool('docker_get_container_logs', {
|
||||
title: 'Docker Get Container Logs',
|
||||
description: 'Fetch logs from a running or stopped Docker container started by this server.',
|
||||
inputSchema: {
|
||||
containerId: z.string().min(1).describe('Container ID (short or long) returned by docker_run_container.'),
|
||||
tail: z
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.optional()
|
||||
.default(200)
|
||||
.describe('Only return the last N lines.'),
|
||||
follow: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.default(false)
|
||||
.describe('Stream logs (not implemented; kept for API compatibility).'),
|
||||
},
|
||||
}, async ({ containerId, tail, follow }) => {
|
||||
try {
|
||||
await dockerManager.ensureReachable();
|
||||
if (follow) {
|
||||
return jsonResult({
|
||||
containerId,
|
||||
note: 'Live log streaming is not supported via this tool; use docker_get_container_logs with follow:false to poll.',
|
||||
logs: '',
|
||||
});
|
||||
}
|
||||
const docker = dockerManager.getDocker();
|
||||
const buffer = await docker.getContainer(containerId).logs({
|
||||
stdout: true,
|
||||
stderr: true,
|
||||
tail,
|
||||
});
|
||||
const raw = Buffer.isBuffer(buffer) ? buffer.toString('utf8') : String(buffer);
|
||||
return jsonResult({
|
||||
containerId,
|
||||
logs: raw.slice(-config.maxToolOutputChars),
|
||||
bytes: raw.length,
|
||||
});
|
||||
}
|
||||
catch (error) {
|
||||
return errorResult(toErrorMessage(error));
|
||||
}
|
||||
});
|
||||
}
|
||||
//# sourceMappingURL=containerLogs.js.map
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"containerLogs.js","sourceRoot":"","sources":["../../../src/tools/docker/containerLogs.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,OAAO,EAAE,MAAM,EAAE,MAAM,iBAAiB,CAAC;AACzC,OAAO,EAAE,WAAW,EAAE,UAAU,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AACvE,OAAO,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAC;AAEnD,MAAM,UAAU,+BAA+B,CAAC,MAAiB;IAC/D,MAAM,CAAC,YAAY,CACjB,2BAA2B,EAC3B;QACE,KAAK,EAAE,2BAA2B;QAClC,WAAW,EACT,+EAA+E;QACjF,WAAW,EAAE;YACX,WAAW,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,gEAAgE,CAAC;YACzG,IAAI,EAAE,CAAC;iBACJ,MAAM,EAAE;iBACR,GAAG,EAAE;iBACL,QAAQ,EAAE;iBACV,QAAQ,EAAE;iBACV,OAAO,CAAC,GAAG,CAAC;iBACZ,QAAQ,CAAC,+BAA+B,CAAC;YAC5C,MAAM,EAAE,CAAC;iBACN,OAAO,EAAE;iBACT,QAAQ,EAAE;iBACV,OAAO,CAAC,KAAK,CAAC;iBACd,QAAQ,CAAC,4DAA4D,CAAC;SAC1E;KACF,EACD,KAAK,EAAE,EAAE,WAAW,EAAE,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE;QACtC,IAAI,CAAC;YACH,MAAM,aAAa,CAAC,eAAe,EAAE,CAAC;YACtC,IAAI,MAAM,EAAE,CAAC;gBACX,OAAO,UAAU,CAAC;oBAChB,WAAW;oBACX,IAAI,EAAE,6GAA6G;oBACnH,IAAI,EAAE,EAAE;iBACT,CAAC,CAAC;YACL,CAAC;YAED,MAAM,MAAM,GAAG,aAAa,CAAC,SAAS,EAAE,CAAC;YACzC,MAAM,MAAM,GAAG,MAAM,MAAM,CAAC,YAAY,CAAC,WAAW,CAAC,CAAC,IAAI,CAAC;gBACzD,MAAM,EAAE,IAAI;gBACZ,MAAM,EAAE,IAAI;gBACZ,IAAI;aACL,CAAC,CAAC;YACH,MAAM,GAAG,GAAG,MAAM,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC;YAC/E,OAAO,UAAU,CAAC;gBAChB,WAAW;gBACX,IAAI,EAAE,GAAG,CAAC,KAAK,CAAC,CAAC,MAAM,CAAC,kBAAkB,CAAC;gBAC3C,KAAK,EAAE,GAAG,CAAC,MAAM;aAClB,CAAC,CAAC;QACL,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,OAAO,WAAW,CAAC,cAAc,CAAC,KAAK,CAAC,CAAC,CAAC;QAC5C,CAAC;IACH,CAAC,CACF,CAAC;AACJ,CAAC"}
|
||||
Vendored
+102
@@ -0,0 +1,102 @@
|
||||
import Docker from 'dockerode';
|
||||
import { config } from '../../config.js';
|
||||
import { resolveWorkspacePath } from '../../lib/sandbox.js';
|
||||
import { logger } from '../../lib/logger.js';
|
||||
class DockerManager {
|
||||
docker;
|
||||
containers = new Map();
|
||||
daemonReachable = null;
|
||||
constructor() {
|
||||
this.docker = new Docker();
|
||||
}
|
||||
async ensureReachable() {
|
||||
if (this.daemonReachable === true)
|
||||
return;
|
||||
try {
|
||||
await this.docker.ping();
|
||||
this.daemonReachable = true;
|
||||
}
|
||||
catch (error) {
|
||||
this.daemonReachable = false;
|
||||
throw new Error('Docker daemon is not reachable. Make sure Docker is running and the current user can access it. ' +
|
||||
`Original error: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
getDocker() {
|
||||
return this.docker;
|
||||
}
|
||||
track(containerId, image, name, ports) {
|
||||
if (this.containers.size >= config.maxConcurrentContainers) {
|
||||
throw new Error(`Refusing to start another container: already at the max of ${config.maxConcurrentContainers} concurrent containers.`);
|
||||
}
|
||||
const shortId = containerId.substring(0, 12);
|
||||
const managed = {
|
||||
id: shortId,
|
||||
image,
|
||||
name,
|
||||
startedAt: new Date().toISOString(),
|
||||
ports,
|
||||
};
|
||||
this.containers.set(shortId, managed);
|
||||
return managed;
|
||||
}
|
||||
getTracked(id) {
|
||||
return this.containers.get(id) ?? this.containers.get(id.substring(0, 12));
|
||||
}
|
||||
listTracked() {
|
||||
return Array.from(this.containers.values());
|
||||
}
|
||||
untrack(id) {
|
||||
this.containers.delete(id.substring(0, 12));
|
||||
}
|
||||
async cleanupAll() {
|
||||
for (const [id, managed] of this.containers.entries()) {
|
||||
try {
|
||||
const container = this.docker.getContainer(id);
|
||||
await container.stop({ t: 5 }).catch(() => null);
|
||||
await container.remove({ force: true }).catch(() => null);
|
||||
}
|
||||
catch (error) {
|
||||
logger.error(`failed to cleanup container ${managed.name} (${id})`, {
|
||||
error: error instanceof Error ? error.message : error,
|
||||
});
|
||||
}
|
||||
finally {
|
||||
this.containers.delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
export const dockerManager = new DockerManager();
|
||||
/**
|
||||
* Validates that a volume mount specification keeps the source path inside the
|
||||
* workspace root. Rejects Docker socket mounts, host-relative escapes, etc.
|
||||
*/
|
||||
export function validateVolumeMount(spec) {
|
||||
const parts = spec.split(':');
|
||||
if (parts.length < 2) {
|
||||
throw new Error(`Invalid volume mount "${spec}": expected format "source:target[:mode]".`);
|
||||
}
|
||||
const source = parts[0] ?? '';
|
||||
const target = parts[1] ?? '';
|
||||
const mode = parts[2] ?? 'rw';
|
||||
if (!source || !target) {
|
||||
throw new Error(`Invalid volume mount "${spec}": source and target are required.`);
|
||||
}
|
||||
const absoluteSource = resolveWorkspacePath(config.workspaceRoot, source);
|
||||
if (absoluteSource.toLowerCase().startsWith('\\\\.\\pipe\\docker_engine') || source.includes('/var/run/docker.sock')) {
|
||||
throw new Error(`Refusing to mount the Docker socket: "${source}".`);
|
||||
}
|
||||
return { source: absoluteSource, target, mode };
|
||||
}
|
||||
export function isAllowListedImage(image) {
|
||||
const base = (image.split(':')[0] ?? '').split('/').pop()?.toLowerCase() ?? '';
|
||||
return config.dockerAllowedBaseImages.some((allowed) => base === allowed || base.startsWith(allowed));
|
||||
}
|
||||
export function isLocallyBuiltImage(image) {
|
||||
// Heuristic: an image built by this server via docker_build_image won't have
|
||||
// a registry prefix. We treat "my-app" or "web-dev-mcp" as local; anything
|
||||
// with a registry host (contains '.' or '/') is not.
|
||||
return !/[/.]/.test(image);
|
||||
}
|
||||
//# sourceMappingURL=dockerManager.js.map
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"dockerManager.js","sourceRoot":"","sources":["../../../src/tools/docker/dockerManager.ts"],"names":[],"mappings":"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"}
|
||||
Vendored
+47
@@ -0,0 +1,47 @@
|
||||
import { z } from 'zod';
|
||||
import { config } from '../../config.js';
|
||||
import { errorResult, jsonResult, toErrorMessage } from '../shared.js';
|
||||
import { dockerManager } from './dockerManager.js';
|
||||
export function registerDockerExecTool(server) {
|
||||
server.registerTool('docker_exec', {
|
||||
title: 'Docker Exec',
|
||||
description: 'Run a one-off command inside a running Docker container started by this server and return stdout/stderr.',
|
||||
inputSchema: {
|
||||
containerId: z.string().min(1).describe('Container ID (short or long) returned by docker_run_container.'),
|
||||
command: z.array(z.string()).min(1).describe('Command and arguments to execute inside the container.'),
|
||||
workingDir: z.string().optional().describe('Working directory inside the container.'),
|
||||
env: z.record(z.string()).optional().describe('Additional environment variables for the exec.'),
|
||||
},
|
||||
}, async ({ containerId, command, workingDir, env }) => {
|
||||
try {
|
||||
await dockerManager.ensureReachable();
|
||||
const docker = dockerManager.getDocker();
|
||||
const exec = await docker.getContainer(containerId).exec({
|
||||
Cmd: command,
|
||||
WorkingDir: workingDir,
|
||||
Env: env ? Object.entries(env).map(([k, v]) => `${k}=${v}`) : undefined,
|
||||
AttachStdout: true,
|
||||
AttachStderr: true,
|
||||
});
|
||||
const stream = await exec.start({ Detach: false, Tty: false });
|
||||
const chunks = [];
|
||||
await new Promise((resolve, reject) => {
|
||||
stream.on('data', (chunk) => chunks.push(chunk));
|
||||
stream.on('end', resolve);
|
||||
stream.on('error', reject);
|
||||
});
|
||||
const raw = Buffer.concat(chunks).toString('utf8');
|
||||
const result = await exec.inspect();
|
||||
return jsonResult({
|
||||
containerId,
|
||||
command,
|
||||
exitCode: result.ExitCode ?? null,
|
||||
output: raw.slice(-config.maxToolOutputChars),
|
||||
});
|
||||
}
|
||||
catch (error) {
|
||||
return errorResult(toErrorMessage(error));
|
||||
}
|
||||
});
|
||||
}
|
||||
//# sourceMappingURL=exec.js.map
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"exec.js","sourceRoot":"","sources":["../../../src/tools/docker/exec.ts"],"names":[],"mappings":"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"}
|
||||
Vendored
+232
@@ -0,0 +1,232 @@
|
||||
import fs from 'node:fs';
|
||||
import { z } from 'zod';
|
||||
import { config } from '../../config.js';
|
||||
import { detectProject, resolvePackage } from '../../lib/frameworks/detect.js';
|
||||
import { resolveWorkspacePath } from '../../lib/sandbox.js';
|
||||
import { runCommand } from '../../lib/runCommand.js';
|
||||
import { requireLaravel } from '../laravel/requireLaravel.js';
|
||||
import { errorResult, jsonResult, packageSchema, toErrorMessage } from '../shared.js';
|
||||
function nodeDockerfile() {
|
||||
return `# Generated by web-dev-mcp
|
||||
FROM node:24-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM node:24-alpine
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
COPY package*.json ./
|
||||
RUN npm ci --omit=dev && npm cache clean --force
|
||||
COPY --from=builder /app/dist ./dist
|
||||
EXPOSE 3000
|
||||
CMD ["node", "dist/index.js"]
|
||||
`;
|
||||
}
|
||||
function phpDockerfile(kind) {
|
||||
const publicDir = kind === 'laravel' ? 'public' : 'public';
|
||||
return `# Generated by web-dev-mcp for ${kind}
|
||||
FROM php:8.3-cli-alpine AS vendor
|
||||
WORKDIR /app
|
||||
COPY composer.json composer.lock* ./
|
||||
RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer \\
|
||||
&& composer install --no-dev --optimize-autoloader --no-interaction --prefer-dist || composer install --no-interaction --prefer-dist
|
||||
|
||||
FROM php:8.3-apache
|
||||
WORKDIR /var/www/html
|
||||
RUN docker-php-ext-install pdo pdo_mysql
|
||||
COPY --from=vendor /app/vendor ./vendor
|
||||
COPY . .
|
||||
ENV APACHE_DOCUMENT_ROOT=/var/www/html/${publicDir}
|
||||
RUN sed -ri -e 's!/var/www/html!\\\${APACHE_DOCUMENT_ROOT}!g' /etc/apache2/sites-available/*.conf \\
|
||||
&& a2enmod rewrite
|
||||
EXPOSE 80
|
||||
`;
|
||||
}
|
||||
function nginxNodeCompose() {
|
||||
return `# Generated by web-dev-mcp
|
||||
services:
|
||||
app:
|
||||
build: .
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
depends_on:
|
||||
- redis
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_USER: app
|
||||
POSTGRES_PASSWORD: app
|
||||
POSTGRES_DB: app
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- db_data:/var/lib/postgresql/data
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
ports:
|
||||
- "6379:6379"
|
||||
volumes:
|
||||
db_data:
|
||||
`;
|
||||
}
|
||||
function phpCompose() {
|
||||
return `# Generated by web-dev-mcp
|
||||
services:
|
||||
app:
|
||||
build: .
|
||||
ports:
|
||||
- "8080:80"
|
||||
environment:
|
||||
APP_ENV: local
|
||||
DB_HOST: db
|
||||
DB_DATABASE: app
|
||||
DB_USERNAME: app
|
||||
DB_PASSWORD: app
|
||||
REDIS_HOST: redis
|
||||
depends_on:
|
||||
- db
|
||||
- redis
|
||||
db:
|
||||
image: mysql:8.0
|
||||
environment:
|
||||
MYSQL_DATABASE: app
|
||||
MYSQL_USER: app
|
||||
MYSQL_PASSWORD: app
|
||||
MYSQL_ROOT_PASSWORD: root
|
||||
ports:
|
||||
- "3306:3306"
|
||||
volumes:
|
||||
- db_data:/var/lib/mysql
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
ports:
|
||||
- "6379:6379"
|
||||
volumes:
|
||||
db_data:
|
||||
`;
|
||||
}
|
||||
export function registerGenerateDockerfileTool(server) {
|
||||
server.registerTool('generate_dockerfile', {
|
||||
title: 'Generate Dockerfile',
|
||||
description: 'Write a stack-aware Dockerfile at the workspace (or package) root based on detected framework (Node multi-stage or PHP/Apache for Laravel/CodeIgniter).',
|
||||
inputSchema: {
|
||||
package: packageSchema,
|
||||
overwrite: z.boolean().optional().default(false),
|
||||
},
|
||||
}, async ({ package: packageName, overwrite }) => {
|
||||
try {
|
||||
const root = packageName
|
||||
? resolvePackage(config.workspaceRoot, packageName).pkg.absolutePath
|
||||
: config.workspaceRoot;
|
||||
const detected = detectProject(root);
|
||||
const file = resolveWorkspacePath(root, 'Dockerfile');
|
||||
if (!overwrite && fs.existsSync(file)) {
|
||||
return errorResult(`"${file}" already exists. Pass overwrite: true to replace it.`);
|
||||
}
|
||||
let contents;
|
||||
let stack;
|
||||
if (detected.backend?.kind === 'laravel' ||
|
||||
detected.backend?.kind === 'codeigniter' ||
|
||||
detected.backend?.kind === 'symfony') {
|
||||
stack = detected.backend.kind;
|
||||
contents = phpDockerfile(detected.backend.kind === 'codeigniter' ? 'codeigniter' : 'laravel');
|
||||
}
|
||||
else {
|
||||
const front = detected.frontend?.kind;
|
||||
stack =
|
||||
front === 'react' || front === 'vue' || front === 'nuxt'
|
||||
? `${front}/node`
|
||||
: detected.backend?.kind === 'django' || detected.backend?.kind === 'rails'
|
||||
? detected.backend.kind
|
||||
: 'node';
|
||||
contents = nodeDockerfile();
|
||||
}
|
||||
fs.writeFileSync(file, contents);
|
||||
return jsonResult({ file, stack, package: packageName ?? null });
|
||||
}
|
||||
catch (error) {
|
||||
return errorResult(toErrorMessage(error));
|
||||
}
|
||||
});
|
||||
}
|
||||
export function registerGenerateComposeTool(server) {
|
||||
server.registerTool('generate_compose', {
|
||||
title: 'Generate docker-compose.yml',
|
||||
description: 'Write a docker-compose.yml with app + db + redis presets inferred from the detected stack (Postgres for Node, MySQL for PHP).',
|
||||
inputSchema: {
|
||||
package: packageSchema,
|
||||
overwrite: z.boolean().optional().default(false),
|
||||
},
|
||||
}, async ({ package: packageName, overwrite }) => {
|
||||
try {
|
||||
const root = packageName
|
||||
? resolvePackage(config.workspaceRoot, packageName).pkg.absolutePath
|
||||
: config.workspaceRoot;
|
||||
const detected = detectProject(root);
|
||||
const file = resolveWorkspacePath(root, 'docker-compose.yml');
|
||||
if (!overwrite && fs.existsSync(file)) {
|
||||
return errorResult(`"${file}" already exists. Pass overwrite: true to replace it.`);
|
||||
}
|
||||
const isPhp = detected.backend?.kind === 'laravel' ||
|
||||
detected.backend?.kind === 'codeigniter' ||
|
||||
detected.backend?.kind === 'symfony';
|
||||
const contents = isPhp ? phpCompose() : nginxNodeCompose();
|
||||
fs.writeFileSync(file, contents);
|
||||
return jsonResult({
|
||||
file,
|
||||
stack: isPhp
|
||||
? detected.backend?.kind
|
||||
: (detected.frontend?.kind ?? detected.backend?.kind ?? 'node'),
|
||||
services: ['app', 'db', 'redis'],
|
||||
package: packageName ?? null,
|
||||
});
|
||||
}
|
||||
catch (error) {
|
||||
return errorResult(toErrorMessage(error));
|
||||
}
|
||||
});
|
||||
}
|
||||
export function registerGenerateSailTool(server) {
|
||||
server.registerTool('generate_sail', {
|
||||
title: 'Install Laravel Sail',
|
||||
description: 'Run `php artisan sail:install` in a detected Laravel project. Requires confirm: true because it modifies docker-compose and related files.',
|
||||
inputSchema: {
|
||||
with: z
|
||||
.array(z.string())
|
||||
.optional()
|
||||
.describe('Optional Sail services, e.g. ["mysql", "redis", "mailpit"].'),
|
||||
confirm: z.boolean().optional().describe('Required as true to run sail:install.'),
|
||||
},
|
||||
}, async ({ with: services, confirm }) => {
|
||||
try {
|
||||
if (confirm !== true) {
|
||||
return errorResult('generate_sail requires confirm: true.');
|
||||
}
|
||||
const laravel = requireLaravel();
|
||||
const args = ['artisan', 'sail:install', '--no-interaction'];
|
||||
if (services && services.length > 0) {
|
||||
args.push(`--with=${services.join(',')}`);
|
||||
}
|
||||
const result = await runCommand('php', args, {
|
||||
cwd: laravel.root,
|
||||
timeoutMs: config.scaffoldCommandTimeoutMs,
|
||||
});
|
||||
return jsonResult({
|
||||
services: services ?? null,
|
||||
exitCode: result.exitCode,
|
||||
failed: result.failed,
|
||||
output: result.output,
|
||||
cwd: result.cwd,
|
||||
});
|
||||
}
|
||||
catch (error) {
|
||||
return errorResult(toErrorMessage(error));
|
||||
}
|
||||
});
|
||||
}
|
||||
//# sourceMappingURL=generate.js.map
|
||||
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+24
@@ -0,0 +1,24 @@
|
||||
import { registerDockerBuildImageTool } from './buildImage.js';
|
||||
import { registerDockerRunContainerTool } from './runContainer.js';
|
||||
import { registerDockerListContainersTool } from './listContainers.js';
|
||||
import { registerDockerContainerLogsTool } from './containerLogs.js';
|
||||
import { registerDockerExecTool } from './exec.js';
|
||||
import { registerDockerStopRemoveTools } from './stopRemove.js';
|
||||
import { registerDockerComposeTools } from './compose.js';
|
||||
import { registerGenerateComposeTool, registerGenerateDockerfileTool, registerGenerateSailTool, } from './generate.js';
|
||||
import { registerDockerPushImageTool } from './pushImage.js';
|
||||
export function registerDockerTools(server) {
|
||||
registerDockerBuildImageTool(server);
|
||||
registerDockerRunContainerTool(server);
|
||||
registerDockerListContainersTool(server);
|
||||
registerDockerContainerLogsTool(server);
|
||||
registerDockerExecTool(server);
|
||||
registerDockerStopRemoveTools(server);
|
||||
registerDockerComposeTools(server);
|
||||
registerGenerateDockerfileTool(server);
|
||||
registerGenerateComposeTool(server);
|
||||
registerGenerateSailTool(server);
|
||||
registerDockerPushImageTool(server);
|
||||
}
|
||||
export { dockerManager } from './dockerManager.js';
|
||||
//# sourceMappingURL=index.js.map
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user