diff --git a/.gitignore b/.gitignore index 7be55e2..517491e 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,7 @@ /storage/*.key /storage/pail /vendor +/storage/app/private/truss _ide_helper.php Homestead.json Homestead.yaml diff --git a/.mcp.json b/.mcp.json index 8c6715a..83b467f 100644 --- a/.mcp.json +++ b/.mcp.json @@ -6,6 +6,14 @@ "artisan", "boost:mcp" ] + }, + "truss": { + "command": "php", + "args": [ + "artisan", + "mcp:start", + "truss" + ] } } -} \ No newline at end of file +} diff --git a/README.md b/README.md index fbd2690..f16d388 100644 --- a/README.md +++ b/README.md @@ -280,6 +280,20 @@ composer test php artisan test ``` +## Schema viewer (Laravel Truss) + +[Laravel Truss](https://github.com/albertoarena/laravel-truss) is installed as a **dev** dependency. In `local`, open: + +``` +/truss +``` + +It shows a live ER diagram of your schema (structure only, never row data). Optional agent MCP: + +```bash +php artisan mcp:start truss +``` + ## License MIT diff --git a/composer.json b/composer.json index f69f8dc..c5c33db 100644 --- a/composer.json +++ b/composer.json @@ -18,6 +18,7 @@ "symfony/polyfill-iconv": "^1.37" }, "require-dev": { + "albertoarena/laravel-truss": "^1.8", "fakerphp/faker": "^1.23", "laravel/boost": "^2.5", "laravel/pail": "^1.2.5", diff --git a/composer.lock b/composer.lock index 7f34432..cf5b6e5 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "d2a98af0564164e3228a1b7712e36e87", + "content-hash": "eb8ccdae5381401eaa99d07ff82ce500", "packages": [ { "name": "aws/aws-crt-php", @@ -8941,6 +8941,87 @@ } ], "packages-dev": [ + { + "name": "albertoarena/laravel-truss", + "version": "v1.8.3", + "source": { + "type": "git", + "url": "https://github.com/albertoarena/laravel-truss.git", + "reference": "d8750756f5f239066c7f0d6e40b0eeb7e7e435bf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/albertoarena/laravel-truss/zipball/d8750756f5f239066c7f0d6e40b0eeb7e7e435bf", + "reference": "d8750756f5f239066c7f0d6e40b0eeb7e7e435bf", + "shasum": "" + }, + "require": { + "illuminate/contracts": "^12.0 || ^13.0", + "illuminate/support": "^12.0 || ^13.0", + "php": "^8.3", + "spatie/laravel-package-tools": "^1.16" + }, + "require-dev": { + "laravel/mcp": "^0.9", + "laravel/pint": "^1.18", + "orchestra/testbench": "^10.0 || ^11.0", + "pestphp/pest": "^3.5" + }, + "suggest": { + "laravel/mcp": "Enables the optional read-only, structure-only Truss MCP server (Truss as AI context). Opt in with `composer require laravel/mcp`. Needs Laravel >= 12.41.1 or 13." + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "AlbertoArena\\Truss\\TrussServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "AlbertoArena\\Truss\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Alberto Arena", + "email": "arena.alberto@gmail.com", + "role": "Developer" + } + ], + "description": "A live database structure viewer for Laravel. Renders your schema (never data) as a scrollable, zoomable ER diagram.", + "homepage": "https://trussphp.com", + "keywords": [ + "DBML", + "data-dictionary", + "database", + "database-diagram", + "er-diagram", + "erd", + "introspection", + "laravel", + "mermaid", + "migrations", + "schema" + ], + "support": { + "docs": "https://trussphp.com", + "issues": "https://github.com/albertoarena/laravel-truss/issues", + "source": "https://github.com/albertoarena/laravel-truss" + }, + "funding": [ + { + "url": "https://ko-fi.com/albertoarena", + "type": "ko-fi" + } + ], + "time": "2026-08-12T14:11:27+00:00" + }, { "name": "composer/semver", "version": "3.4.4", @@ -11403,6 +11484,67 @@ ], "time": "2025-02-07T05:00:38+00:00" }, + { + "name": "spatie/laravel-package-tools", + "version": "1.93.1", + "source": { + "type": "git", + "url": "https://github.com/spatie/laravel-package-tools.git", + "reference": "d5552849801f2642aea710557463234b59ef65eb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/laravel-package-tools/zipball/d5552849801f2642aea710557463234b59ef65eb", + "reference": "d5552849801f2642aea710557463234b59ef65eb", + "shasum": "" + }, + "require": { + "illuminate/contracts": "^10.0|^11.0|^12.0|^13.0", + "php": "^8.1" + }, + "require-dev": { + "mockery/mockery": "^1.5", + "orchestra/testbench": "^8.0|^9.2|^10.0|^11.0", + "pestphp/pest": "^2.1|^3.1|^4.0", + "phpunit/php-code-coverage": "^10.0|^11.0|^12.0", + "phpunit/phpunit": "^10.5|^11.5|^12.5", + "spatie/pest-plugin-test-time": "^2.2|^3.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Spatie\\LaravelPackageTools\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Freek Van der Herten", + "email": "freek@spatie.be", + "role": "Developer" + } + ], + "description": "Tools for creating Laravel packages", + "homepage": "https://github.com/spatie/laravel-package-tools", + "keywords": [ + "laravel-package-tools", + "spatie" + ], + "support": { + "issues": "https://github.com/spatie/laravel-package-tools/issues", + "source": "https://github.com/spatie/laravel-package-tools/tree/1.93.1" + }, + "funding": [ + { + "url": "https://github.com/spatie", + "type": "github" + } + ], + "time": "2026-05-19T14:06:37+00:00" + }, { "name": "staabm/side-effects-detector", "version": "1.0.5", diff --git a/config/truss.php b/config/truss.php new file mode 100644 index 0000000..0cb5110 --- /dev/null +++ b/config/truss.php @@ -0,0 +1,396 @@ + env('TRUSS_ROUTE_PREFIX', 'truss'), + + /* + |-------------------------------------------------------------------------- + | Enabled + |-------------------------------------------------------------------------- + | + | Global on/off switch. Defaults to enabled only in the local environment. + | Authorization is enforced separately by the fixed `viewTruss` gate. + | + */ + + 'enabled' => env('TRUSS_ENABLED', env('APP_ENV', 'production') === 'local'), + + /* + |-------------------------------------------------------------------------- + | Middleware + |-------------------------------------------------------------------------- + | + | The middleware stack applied to both Truss routes. Its job is to establish + | the auth context (session, cookies, the authenticated user) so the + | `viewTruss` gate can identify who is viewing — without it, the gate sees no + | user and denies everyone in non-local environments. The default `web` group + | covers session-based auth; swap it for a custom guard/Sanctum stack if your + | app authenticates differently. + | + | The fixed `viewTruss` authorization check is always appended after this and + | cannot be configured away — this list controls the auth *context*, not + | whether authorization runs. + | + */ + + 'middleware' => ['web'], + + /* + |-------------------------------------------------------------------------- + | Authorization + |-------------------------------------------------------------------------- + | + | Truss is gated by the fixed `viewTruss` gate (the ability name is not + | configurable). In non-local environments the shipped default gate admits + | only the emails listed here — the zero-code path for "let these admins in". + | Set them via TRUSS_ALLOWED_EMAILS as a comma-separated list, e.g. + | TRUSS_ALLOWED_EMAILS="ada@example.com,grace@example.com". + | + | The list is ignored in local (the gate is not consulted there) and ignored + | entirely if the host app defines its own `viewTruss` gate (e.g. a role + | check). An empty list fails closed: no one may view in non-local until you + | either add emails here or override the gate. + | + */ + + 'authorization' => [ + 'allowed_emails' => array_values(array_filter(array_map( + 'trim', + explode(',', (string) env('TRUSS_ALLOWED_EMAILS', '')), + ))), + ], + + /* + |-------------------------------------------------------------------------- + | Cache + |-------------------------------------------------------------------------- + | + | The schema snapshot is derived, disposable data cached via Laravel's Cache + | facade, keyed per connection. `ttl` is in seconds. + | + */ + + 'cache' => [ + 'ttl' => (int) env('TRUSS_CACHE_TTL', 3600), + ], + + /* + |-------------------------------------------------------------------------- + | Connections + |-------------------------------------------------------------------------- + | + | Which database connections are visualizable, and any per-connection + | overrides. When left empty, Truss uses the application's default + | connection (config('database.default')). + | + | Example: + | 'mysql' => ['excluded_tables' => ['legacy_import']], + | + */ + + 'connections' => [ + // + ], + + /* + |-------------------------------------------------------------------------- + | Excluded tables + |-------------------------------------------------------------------------- + | + | Tables hidden from the diagram by default (framework/infrastructure noise). + | Applied server-side: excluded tables never appear in the API response. + | + */ + + 'excluded_tables' => [ + 'migrations', + 'password_reset_tokens', + 'sessions', + 'cache', + 'cache_locks', + 'jobs', + 'job_batches', + 'failed_jobs', + ], + + /* + |-------------------------------------------------------------------------- + | Diagram + |-------------------------------------------------------------------------- + | + | Styling options passed through to the Mermaid theme, plus the default + | column-type label mode: + | 'native' → the full DB type (varchar(255), bigint unsigned) [default] + | 'laravel' → a best-effort Laravel-style short label (string, integer) + | The mode is user-toggleable in the UI; this is only the default. + | + */ + + 'diagram' => [ + 'type_labels' => env('TRUSS_TYPE_LABELS', 'native'), + + // Where the browser loads Mermaid from. Null (the default) self-hosts it + // from the package's own asset route — no CDN, so a strict CSP needs only + // `script-src 'self'`. Set a URL (e.g. a CDN or your own copy) to opt out + // of self-hosting: TRUSS_MERMAID_URL=https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js + 'mermaid_url' => env('TRUSS_MERMAID_URL'), + + // Lower bound for the automatic fit-to-screen: a large schema is never + // auto-zoomed below this (it stays legible and you pan). The "Fit" button + // ignores this and frames the whole diagram. 1.0 = 100%. + 'min_zoom' => (float) env('TRUSS_MIN_ZOOM', 0.7), + ], + + /* + |-------------------------------------------------------------------------- + | Theme + |-------------------------------------------------------------------------- + | + | Truss ships a light and dark "blueprint" theme. To match the app Truss is + | embedded in, redefine its colours and fonts here. Everything is optional: + | only the knobs you set are overridden, the rest stay on the default, so a + | handful of values re-skins the whole dashboard (chrome and diagram) in both + | light and dark. Config driven, no build step. + | + | It is delivered as a same-origin stylesheet, so a strict CSP still needs + | only style-src 'self' (no inline styles). Each value is validated before it + | is emitted; an invalid value is ignored and falls back to the default. + | + | Colours accept hex, rgb()/rgba()/hsl()/hsla(), or a CSS colour keyword. + | Fonts are family names only: name a font your app already loads or a system + | font (Truss serves no font files here). Set a knob under both 'light' and + | 'dark' to theme both modes; omit 'dark' to theme light only. + | + | Colour knobs and what each paints: + | accent primary accent: headings, PK badges, entity borders, focus ring + | accent-secondary secondary accent + | background the canvas / page background (and relationship-label backdrop) + | surface panels, table bodies, rows, and inputs + | surface-alt row striping + | text body and diagram text + | muted secondary text and the relationship lines / labels + | border table, panel, and field lines + | + */ + + 'theme' => [ + 'fonts' => [ + 'mono' => env('TRUSS_THEME_FONT_MONO'), + 'sans' => env('TRUSS_THEME_FONT_SANS'), + ], + + 'colors' => [ + 'light' => [ + // 'accent' => '#3730a3', + // 'background' => '#ffffff', + ], + 'dark' => [ + // 'accent' => '#a5b4fc', + // 'background' => '#0b1020', + ], + ], + ], + + /* + |-------------------------------------------------------------------------- + | Focus + |-------------------------------------------------------------------------- + | + | Focus mode reduces the diagram to a table and its foreign-key neighbours. + | `default_depth` is how many hops of neighbours are shown by default. + | + */ + + 'focus' => [ + 'default_depth' => (int) env('TRUSS_FOCUS_DEPTH', 1), + ], + + /* + |-------------------------------------------------------------------------- + | Large schema + |-------------------------------------------------------------------------- + | + | Table count above which the UI shows a "large schema — use focus/filter" + | warning before rendering everything at once. + | + */ + + 'large_schema' => [ + 'warn_above' => (int) env('TRUSS_LARGE_SCHEMA_WARN_ABOVE', 60), + ], + + /* + |-------------------------------------------------------------------------- + | Schema diff + |-------------------------------------------------------------------------- + | + | "What changed since the last migration". After each migration Truss keeps + | the previous schema snapshot as a baseline and compares it against the + | current one, surfacing added, removed, and changed tables, columns, indexes, + | and foreign keys in the dashboard "Changes" panel and via `truss:diff`. + | + | This is the only feature that writes to the filesystem: the baseline is a + | structure-only JSON file (never row data), stored on disk rather than in the + | cache because it cannot be rebuilt from the live database once a migration + | has run. + | + | `enabled`: master switch. When false, no baseline is captured, nothing is + | written to disk, the "Changes" toggle is hidden, and `truss:diff` reports the + | feature is off. Set it false if you do not want Truss touching your disk. + | + | `disk`: the filesystem disk the baseline is written to, `local` by default. + | The path is always `truss/baselines/{connection}`. This deliberately does not + | follow the application's default disk: the baseline is derived tooling state, + | not application data, so it should not land in a production bucket, cost + | money, or have several instances racing on one object. A failure to read or + | write it is never fatal; the diff is simply unavailable until it recovers. + | + */ + + 'diff' => [ + 'enabled' => (bool) env('TRUSS_DIFF_ENABLED', true), + 'disk' => env('TRUSS_DIFF_DISK', 'local'), + ], + + /* + |-------------------------------------------------------------------------- + | Doctor + |-------------------------------------------------------------------------- + | + | `truss:doctor` reviews the schema for problems visible from structure + | alone (a table with no primary key, an unindexed foreign key, and so on) + | and can fail CI. Structure only: it never reads row data and makes no + | network call. + | + | preset: recommended (high-confidence rules), strict (every rule), none. + | rules: per-rule overrides keyed by code: false disables, true enables + | (even a heuristic one), ['severity' => 'error'] changes severity. + | ignore: per-rule fnmatch patterns (table or table.column) to silence. + | fail_on: the severity at or above which the command exits non-zero. + | exclude: extra tables to skip, on top of truss.excluded_tables. + | dashboard: show the findings in the dashboard "Health" panel. When false, + | the schema endpoint sends no doctor payload and the panel and + | node badges never appear, leaving the CLI/CI doctor untouched. + | flag_tables: always mark tables that have findings on the diagram with a + | small severity count, even when the Health panel is closed. Set + | false to keep the diagram clean and surface findings only when + | the panel is open. + | + */ + + 'doctor' => [ + 'preset' => env('TRUSS_DOCTOR_PRESET', 'recommended'), + + 'rules' => [ + // 'TRUSS-INT-002' => true, + // 'TRUSS-IDX-001' => ['severity' => 'error'], + ], + + 'ignore' => [ + // 'TRUSS-IDX-001' => ['audit_log.actor_id'], + ], + + 'fail_on' => env('TRUSS_DOCTOR_FAIL_ON', 'error'), + + 'exclude' => [], + + 'dashboard' => (bool) env('TRUSS_DOCTOR_DASHBOARD', true), + + 'flag_tables' => (bool) env('TRUSS_DOCTOR_FLAG_TABLES', true), + ], + + /* + |-------------------------------------------------------------------------- + | Annotations + |-------------------------------------------------------------------------- + | + | Business meaning that cannot be introspected: that status = 1 means paid, + | that total_amount is integer cents, that legacy_orders is deprecated. It is + | declared here (and/or read from native schema comments) and rendered into + | the exports so a coding agent has grounding a type alone cannot give. + | + | This is still structure only: native comments are part of the CREATE TABLE + | definition, not row content, the same boundary as column defaults. + | + | source: ordered precedence for resolving an annotation; first match + | wins. 'config' reads the maps below; 'database' reads native + | table/column comments (MySQL and Postgres; SQLite and SQL Server + | have none and are skipped). Drop 'database' to ignore DB comments. + | notes: global notes rendered in a header block where the format has one. + | tables: per-table annotations, keyed by table name. + | columns: per-column annotations, keyed by "table.column". + | + | --no-annotations (and the facade withoutAnnotations()) strip them all. + | + */ + + 'annotations' => [ + 'source' => ['config', 'database'], + + 'notes' => [ + // 'All timestamps are UTC.', + // 'Monetary columns are integer cents unless stated.', + ], + + 'tables' => [ + // 'orders' => 'One row per order, not per line item.', + ], + + 'columns' => [ + // 'orders.status' => '0 draft, 1 paid, 2 refunded', + ], + ], + + /* + |-------------------------------------------------------------------------- + | Export + |-------------------------------------------------------------------------- + | + | `default_format`: the format truss:export and the facade use when none is + | given. One of dbml, json, csv, markdown, mermaid, or llm. + | + */ + + 'export' => [ + 'default_format' => env('TRUSS_EXPORT_FORMAT', 'dbml'), + ], + + /* + |-------------------------------------------------------------------------- + | MCP server + |-------------------------------------------------------------------------- + | + | The optional read-only, structure-only MCP server exposes the live schema + | to a coding agent (structure only, never row data). It requires the + | optional first-party `laravel/mcp` package: install it with + | `composer require laravel/mcp`. When that package is absent this switch has + | no effect (the server is only registered when the package is present), so a + | host that does not opt in is unaffected. + | + | `enabled`: master switch for registering the server once laravel/mcp is + | installed. Defaults on, so installing the package is the only opt-in step. + | + */ + + 'mcp' => [ + 'enabled' => (bool) env('TRUSS_MCP_ENABLED', true), + ], + +];