Symfonicat/ coreNymfonicat
symfonicat/core

Symfonicat

One Symfony runtime. Many domains, packages, and native capabilities.

Symfonicat is a Symfony 8 multi-tenant frontend runtime and administration system. It composes host-aware shells from domains, reusable subdomains, endpoint patterns, parcels, modules, PSR-15 middleware, scoped environment values, generated Electron applications, package assets, and compiled C or Go extensions.

129core source files
15Doctrine entities
20runtime services
3native discovery roots

# Mental model

Symfonicat separates the public data plane from the administration control plane. Public traffic never depends on live CRUD queries: it builds an in-memory catalog from config/packages/symfonicat.yaml. The protected /core UI edits Doctrine rows, then automatically writes those rows back to the same YAML file.

REQUESTHost + pathexample.com/products/42
CATALOGRuntimeConfigYAML-backed object graph
COMPOSITIONScope stackparcel · env · module · middleware
RESPONSERuntimeRendererTwig through PSR-15
Domain

Bare host, top-level parcel, modules, middleware, env, and child subdomains.

Subdomain

An affix scoped to an optional domain. The same affix can be reused on multiple domains.

Endpoint

A repeatable path-argument pattern, optionally enforced against one domain or subdomain.

Parcel

A package-scoped frontend entry and its base environment values.

Module

An attachable PHP + JavaScript feature callable only inside an authorized runtime scope.

Application

A selected URL context used to generate a buildable Electron shell.

# Quick start

The repository is self-contained: clone it, build the multi-stage image, synchronize discovered package data, load the YAML catalog, create a two-factor administrator, and unlock the core UI.

bash
git clone https://github.com/symfonicat/core symfonicat
cd symfonicat
cp .env.example .env
docker compose up -d --build
docker exec -it php bin/console symfonicat:schema:update
docker exec php bin/console symfonicat:load
docker exec -it php bin/console symfonicat:admin:create <email>
touch symfonicat.lock
Local host routing

Add 127.0.0.1 example.com and 127.0.0.1 subdomain1.example.com to /etc/hosts. The lock file intentionally makes /core look like a 404 until it is enabled.

# Admin interface

The /core control plane is the operational center of Symfonicat. It is a server-rendered Bootstrap interface backed by Doctrine repositories and forms, giving administrators one place to configure the runtime without manually maintaining every relation in YAML. The dashboard opens on Domains and provides direct navigation to Domains, Subdomains, Endpoints, Parcels, Middleware, Applications, Environment, Files, schema synchronization, YAML dump/load, and logout.

1. Lock gate

AdminLockSubscriber runs at priority 4096. Until symfonicat.lock exists, every core route returns a private, cache-disabled, noindex 404. Caddy reinforces the same boundary before PHP handles the request.

2. Password security

Symfony form login uses AdminUserProvider, CSRF protection, a five-minute user snapshot cache, and a Redis-backed rate limiter capped at 10 attempts per five minutes.

3. TOTP verification

Each Admin owns a SHA-1, 30-second, six-digit TOTP secret created by symfonicat:admin:create. Verification is bound to both the administrator and an authorization-header fingerprint in session state.

4. Automatic YAML sync

Doctrine flushes originating from /core are inspected. Changes to any non-Admin Symfonicat entity or tracked association automatically dump the ordered symfonicat_* tables back to deterministic YAML.

What administrators configure

Domains

Create and edit bare hostnames, select their parcels, attach modules and middleware, assign environment values, and manage the top-level runtime context.

Subdomains

Create reusable affixes, optionally bind them to a Domain, select a parcel, enable catch behavior, and independently attach modules, middleware, and environment values.

Endpoints

Define literal and wildcard path arguments, trailing-path catch behavior, Domain or Subdomain enforcement, a parcel, scoped modules, middleware, and environment overrides.

Parcels

Review package-discovered frontend entries and choose which parcel implements the shell for each Domain, Subdomain, or Endpoint.

Applications

Create desktop application targets, choose their Domain/Subdomain/Endpoint context, set names and environment overrides, then generate Electron skeletons.

Environment

Manage grouped keys and their Parcel, Domain, Subdomain, Endpoint, and Application values through dedicated scoped forms.

Middleware

Review automatically discovered PSR-15 services and attach them to the runtime scopes where they should execute.

Public files

Upload validated default, Domain, and Subdomain assets that symfonicat_asset() resolves according to the active request.

Schema and configuration workflow

The schema screen runs the same synchronization path as symfonicat:schema:update: it updates Doctrine metadata, reconciles tagged middleware, discovers package parcels and modules, and reports created, updated, or removed rows. The YAML controls expose explicit dump and load operations. Dumps exclude administrators, order tables by foreign-key dependency, and normalize JSON and booleans; loads validate tables and columns, delete children before parents, insert parents before children, reset sequences, and run inside a transaction.

Two planes, one interface

The admin interface uses the database for safe CRUD and validation. Its automatic YAML output becomes the deployable catalog read by public requests, so the public runtime can resolve tenants and features without depending on live control-plane queries.

# Docker and FrankenPHP stack

The Dockerfile starts from dunglas/frankenphp:builder-php8.4. Its base stage installs the C toolchain, Autoconf, Node 22, Composer, xcaddy, inotify, and the PHP extensions required by the application. It downloads FrankenPHP source because Symfonicat recompiles the server with project and vendor Go modules.

phpFrankenPHP worker, Caddy, HTTP/1–3, Mercure, native extensions, ports 80/443 TCP and 443 UDP.
npmOne-shot npm ci plus Encore build with parcel and module discovery.
messenger-worker × 8Redis-backed async workers with per-container consumer names, a 3,600-second time limit, 256 MiB memory limit, and 10-failure limit.
PostgreSQL 16Doctrine control-plane storage and health-checked persistence, available only on the Compose network by default.
Redis 7Sessions, locks, cache, Messenger queues, and rate limiting, with no host port published by default.
CaddyInternal or exported ACM TLS, Mercure, Brotli precompression, Zstd/Gzip, and module-body decompression.

The php, npm, and messenger-worker services share named vendor and node_modules volumes. The entrypoint seeds /symfonicat/vendor from the Composer tree baked into the image, writes the Caddy TLS snippet from ECS certificate environment values when present, and otherwise uses internal TLS. Set SYMFONICAT_NATIVE_WATCH=1 to replace the normal server process with the native rebuild watcher.

# Public request lifecycle

  1. PublicRuntimeSubscriberSkips /core/* and /application/*, resolves Domain and Subdomain from the host, validates signed /m/* module requests, and selects the most-specific matching Endpoint.
  2. Normal Symfony routingRegular routes remain authoritative. Runtime catch-all routes use priority -1000 and require an explicit request attribute.
  3. RuntimeRendererChooses the active entity, resolves an override or default Twig shell, issues a signed module context, and renders domain/subdomain/endpoint/application variables.
  4. RuntimeMiddlewareRunnerBuilds a deduplicated PSR-15 chain from domain → subdomain → endpoint and converts between HttpFoundation and PSR-7.

Template precedence

text
templates/{domain,subdomain,endpoint}/overrides/{id}.html.twig
templates/{domain,subdomain,endpoint}/main.html.twig

For subdomains, the override id is the public affix. For domains and endpoints, it is the clean entity id. The renderer also exposes a signed request object used by frontend modules.

# Domains and reusable subdomains

A Domain id is always a bare hostname. DomainService finds the longest configured suffix, so both example.com and any host beneath it resolve to that domain. Public reads come from YAML; /core reads the database repository.

A Subdomain has an internal integer id, a public affix, and an optional Domain relation. Uniqueness is domain + affix, not affix alone. That means app.example.com and app.example.org can be distinct rows with different parcels, modules, middleware, and environment values while reusing the same public label.

Lookup rule

SubdomainService first extracts the leading host affix, then asks RuntimeConfig for the row keyed by domain|affix. A domainless row is a separate fallback lookup, not a collision.

# Endpoints

Endpoints turn paths into runtime targets. arguments is an ordered list of literals or * wildcards. catch allows trailing segments; an empty argument list with catch enabled acts as a wildcard for the current path. Endpoint rows can enforce a Domain or a Subdomain, then contribute their own parcel, modules, middleware, and env. Longer argument patterns are evaluated before shorter patterns so the most specific matching endpoint wins.

# Parcels and assets

A Parcel is the package-scoped frontend shell unit. Composer packages opt in with extra.symfonicat: true and place entries under assets/parcel/<name>/index.js. PackageDiscoveryService produces ids such as symfonicat/core/domainparcel; ParcelService synchronizes those paths and clears entity references when stale package parcels disappear.

Administrators choose which discovered Parcel implements each Domain, Subdomain, or Endpoint. This separates frontend implementation from tenant and route configuration: one Parcel can be reused by many runtime scopes, while any scope can switch to a different Parcel from the admin interface without changing its hostname, affix, or path rules.

Domain Parcel

Provides the default frontend shell for the bare host and its public paths.

Subdomain Parcel

Replaces the Domain shell for a reusable subdomain context.

Endpoint Parcel

Implements a specific matched path experience, with its own modules, middleware, and environment overrides.

Parcel Defaults

Each Parcel can contribute base environment values that later scopes override.

Private build entries

symfonicat:data:webpack reads the active catalog, scans root and installed vendor packages, and passes configured parcel/module entries to webpack.symfonicat.js. Encore produces versioned bundles under public/build, with async WebAssembly enabled.

Public files

symfonicat_asset(path) checks the active subdomain, then domain, then public/default. The admin file screen uploads domain/subdomain/default assets through validated form models.

# Modules and the hybrid package system

Modules are package-discovered features attachable to Domains, Subdomains, or Endpoints. The backend route exists only when its module is attached to the active runtime scope. This is enforced twice: signed request context validation in PublicRuntimeSubscriber, then attachment checks inside AbstractModuleController.

Runtime-aware services

A module is not limited to receiving JSON. Its controller inherits the core context services, and attributed action parameters are made callable during container compilation. Modules can therefore inspect the complete active request composition and respond differently for each tenant, path, application, or environment.

DomainService

Loads the active Domain and normalized host, allowing a module to make tenant-level decisions.

SubdomainService

Loads the active Subdomain, including its reusable affix and Domain relationship.

PathService

Exposes the current path, individual arguments, all arguments, and endpoint-style argument matching.

EnvService

Reads one environment key or the fully merged environment across Parcel, Domain, Subdomain, Endpoint, and Application scopes.

ApplicationService

Loads the selected Application, resolves one for the current context, and reports whether the request is operating as an Application.

RuntimeConfig

Provides the YAML-backed catalog of Domains, Subdomains, Endpoints, Applications, Parcels, Modules, and Middleware without public database queries.

ModuleService

Identifies the current module from its route and connects it to package discovery and attachment enforcement.

RequestStack

Exposes request attributes such as the resolved Endpoint and decoded module_json payload alongside standard headers and query data.

This service-based context model lets the same module implementation behave differently on separate Domains, on reused Subdomain affixes, at specific Endpoints, under different merged Env values, or inside a generated Application. Because these are normal Symfony services, modules can also request other typed application services in their #[Module] action signatures.

PHP module API

php
<?php

namespace Acme\Feature\Module;

use Symfonicat\Attribute\Module;
use Symfonicat\Attribute\ModuleRoute;
use Symfonicat\Controller\AbstractModuleController;
use Symfony\Component\HttpFoundation\Response;

#[ModuleRoute(path: '/m/acme/catalog')]
final class CatalogController extends AbstractModuleController
{
    #[Module(permission: 'PUBLIC_ACCESS')]
    public function product(): Response
    {
        $payload = $this->requestStack
            ?->getCurrentRequest()
            ?->attributes->get('module_json');

        return $this->json([
            'ok' => true,
            'productId' => $payload['productId'] ?? null,
            'domain' => $this->domainService->load()?->getTld(),
        ]);
    }

    #[Module]
    public function card(): Response
    {
        return $this->html('<article>Rendered by PHP</article>');
    }
}

#[ModuleRoute] targets a controller class and defaults to /m/<composer-package>. #[Module] targets a public action and defaults to PUBLIC_ACCESS. During container compilation, Symfonicat scans module actions and makes typed action dependencies callable.

JavaScript bridge

javascript
const mod = 'acme/catalog/main'

mod.log('module active')

// POST Brotli-compressed JSON to /m/acme/catalog/main/product
const product = await mod.json('product', {
    productId: 42,
})

// Request an HTML fragment from the same attached module.
const card = await mod.html('card', {
    productId: 42,
})

document.querySelector('#product-card').innerHTML = card
1String helpers.json(), .html(), and .log()
2Brotli bodyJSON compressed in-browser with quality 6
3Signed scopeContext id + HMAC token headers
4Guarded response404 unless attached to current scope

Caddy’s brotli_decompress Go handler expands every /m/* body before PHP. SymfonicatModuleSubscriber decodes JSON into the module_json request attribute. Empty JSON responses normalize to {}; HTML stays text.

One package, two back ends, one frontend

Symfonicat and Nymfonicat consume the same Composer-managed hybrid package. The shared package keeps browser code at its root and splits only the server implementation: Symfony/PHP code lives under src/, while Nymfonicat loads its Node.js counterpart from modules/.

text
symfonicat/analytics/
├── composer.json                 # extra.symfonicat: true
├── package.json                  # optional Node package metadata
├── assets/
│   ├── module/main/index.js     # one browser module for both runtimes
│   └── parcel/analyticsparcel/index.js
├── modules/main.js               # Nymfonicat Node.js backend
└── src/Module/AnalyticsModule.php # Symfonicat PHP backend
Shared module frontend

assets/module/main/index.js is compiled by both build systems and uses one stable module id and browser API.

Shared parcel frontend

assets/parcel/analyticsparcel/index.js supplies the same shell source for Domain, Subdomain, or Endpoint composition in either runtime.

PHP implementation

src/Module/AnalyticsModule.php declares attributed module actions and uses Symfonicat’s runtime services.

Node implementation

modules/main.js implements the equivalent Nymfonicat behavior and receives Express runtime context.

Hybrid lifecycle

  1. Composer discovers the packageextra.symfonicat: true opts the package into Symfonicat and is also accepted by Nymfonicat discovery.
  2. Each runtime registers its backendSymfonicat discovers attributed PHP actions; Nymfonicat resolves the package’s Node module path.
  3. Both builds find the same assetsassets/module supplies browser features and assets/parcel supplies shell entries from the installed package.
  4. Scopes select featuresAdministrators attach the module and choose the parcel for a Domain, Subdomain, or Endpoint.
  5. The browser uses one contractThe same frontend sends the same action and payload; the current runtime dispatches locally to PHP or Node.js.
Package-author rule

Keep the shared frontend entry, module id, action names, payloads, and JSON/HTML response shapes compatible. Put runtime-specific logic only in src/ and modules/. This yields one browser experience without forcing either server runtime to emulate the other.

# PSR-15 middleware

Every service implementing Psr\Http\Server\MiddlewareInterface is automatically tagged symfonicat.middleware. The YAML catalog stores class-backed middleware rows and join tables for each scope.

  1. Domain middlewareruns whenever a domain is active
  2. Subdomain middlewareadds to subdomain and endpoint renders
  3. Endpoint middlewareadds only for endpoint renders

The runner deduplicates by id/class, preserves scope order, passes Symfonicat request attributes into PSR-7, then converts the final PSR response back to HttpFoundation.

# Environment values

Environment keys are two-level names: an EnvParent such as colors plus an Env such as primary. Scope rows carry the actual string. EnvService recursively merges them and exposes the same grouped structure to Twig and window.env.

  1. Parcelbase defaults for the selected shell
  2. Domaintenant-wide overrides
  3. Subdomainsubdomain parcel + scoped overrides
  4. Endpointpath-context overrides
  5. Applicationhighest priority in application context
twig
{{ env('colors.primary') }}
<script>console.log(window.env.colors.primary)</script>

Infrastructure environment variables

Symfony loads .env, local overrides, and environment-specific files in increasing precedence; real process variables win. Compose rewrites database, Redis, Messenger, and Mercure addresses to their service names. The exported .env.example documents these runtime and AWS inputs:

dotenv
# Symfony and runtime
APP_ENV APP_DEBUG APP_SECRET APP_SHARE_DIR SERVER_NAME DEFAULT_URI
DATABASE_URL REDIS_URL LOCK_DSN
MESSENGER_TRANSPORT_DSN MESSENGER_FAILED_TRANSPORT_DSN
MESSENGER_CONSUMER_NAME MESSENGER_WORKERS
MERCURE_URL MERCURE_PUBLIC_URL MERCURE_JWT_SECRET
ASSET_BASE_URL SYMFONY_TRUSTED_PROXIES SYMFONICAT_NATIVE_WATCH

# Local PostgreSQL
POSTGRES_DB POSTGRES_USER POSTGRES_PASSWORD

# Shared AWS configuration
AWS_REGION AWS_PROFILE

# ECR image build and push
AWS_ECR_REGION AWS_ECR_ACCOUNT_ID AWS_ECR_REPOSITORY AWS_ECR_TAG
AWS_ECR_CONTEXT AWS_ECR_DOCKERFILE AWS_ECR_BUILD_TARGET AWS_ECR_LOCAL_IMAGE

# ECS task and service
AWS_ECS_REGION AWS_ECS_CLUSTER_NAME AWS_ECS_SERVICE_NAME AWS_ECS_CONTAINER_NAME
AWS_ECS_IMAGE_URI AWS_ECS_WAIT_FOR_STABLE AWS_ECS_TASK_FAMILY AWS_ECS_LAUNCH_TYPE
AWS_ECS_CPU AWS_ECS_MEMORY AWS_ECS_CONTAINER_PORT AWS_ECS_DESIRED_COUNT
AWS_ECS_ASSIGN_PUBLIC_IP AWS_ECS_SUBNETS_JSON AWS_ECS_SECURITY_GROUPS_JSON
AWS_ECS_TARGET_GROUP_ARN AWS_ECS_EXECUTION_ROLE_ARN AWS_ECS_TASK_ROLE_ARN

# ACM and Route 53
AWS_CERT_REGION AWS_ECS_TLS_FULLCHAIN_B64 AWS_ECS_TLS_PRIVATE_KEY_B64
AWS_ROUTE53_REGION AWS_ROUTE53_RECORDS_JSON

# symfonicat.yaml

This file is both the deployable runtime catalog and the serialized admin configuration. AdminYaml introspects every symfonicat_* table except admins, orders tables by foreign-key dependency, normalizes JSON/booleans, and writes deterministic YAML. Loading runs in a transaction, deletes children before parents, inserts parents before children, validates tables/columns, and resets auto-increment sequences.

PUBLIC REQUESTSYAML → RuntimeConfig

No database lookup is required to resolve domains, subdomains, endpoints, parcels, modules, middleware, applications, or env.

CORE REQUESTSDoctrine → YAML

Forms operate on repositories. A flush subscriber persists the resulting control-plane state back to YAML.

The catalog contains rows and join rows for parcels, domains, subdomains, endpoints, applications, env parents/keys/values, middleware, modules, and every attachment relation. Composer install runs symfonicat:purge; deployments then rebuild the schema and load clean configuration.

# Native C and Go system

Native code is a first-class package capability. Discovery scans three package layers: the application root, built-in core/, and every installed Composer package opted into Symfonicat. Each layer can ship traditional PHP C extensions and/or Go modules compiled into FrankenPHP.

text
native/
├── ext/
│   └── <php-extension>/
│       ├── config.m4
│       └── <extension>.c
└── go/
    └── <go-module>/
        ├── go.mod
        ├── *.go
        └── *.stub.php

core/native/{ext,go}/...
vendor/<vendor>/<package>/native/{ext,go}/...

C extension path

  1. DiscoverNativeDiscoveryService finds child directories under every native/ext root using exact and glob package patterns.
  2. Stagebin/native-build copies each directory into /usr/src/php/ext/<name>.
  3. Configureconfig.m4 is inspected for PHP_ARG_ENABLE or PHP_ARG_WITH.
  4. CompileAll discovered names are passed to docker-php-ext-install. Built-ins include the C remove_string function and the native module_routes scanner.

Go / FrankenPHP path

  1. DiscoverDirectories under every native/go root must include Go source; module-backed entries expose a path in go.mod.
  2. Generate PHP bridgeFiles containing export_php:function run through frankenphp extension-init, producing C headers, arginfo, stubs, and bridge code.
  3. Resolve modulesgo mod tidy validates each module before build flags are assembled.
  4. Compile serverxcaddy rebuilds FrankenPHP with CGO, Mercure, Vulcain, Caddy Brotli, project/vendor modules, and smaller no-database build tags.
reverse

Exports symfonicat_reverse(string): string from Go into PHP using FrankenPHP Zend-string conversion.

brotli

Registers Caddy handlers for build-file precompression and transparent module request decompression.

module_routes

A C extension reads Composer metadata and PSR-4 maps to accelerate discovery of opted-in module controllers.

remove_string

A conventional PHP C extension used by the generated native service proxy example.

Service proxy compilation

php
interface TextToolsInterface
{
    #[CompileNative]
    public function removeString(string $value, string $needle): string;
}

// NativeProxyCompilerPass detects the attribute and aliases the
// interface to Symfonicat\NativeProxy\Service\TextToolsNativeProxy.
// The proxy calls the compiled C extension function directly:
return \remove_string($value, $needle);

#[CompileNative] marks interface/service methods intended for native delegation. NativeProxyCompilerPass detects those methods, locates the matching Symfonicat\NativeProxy class, decorates the original service, and aliases supported App/Native/Symfonicat interfaces to the proxy.

Development watcher

With SYMFONICAT_NATIVE_WATCH=1, bin/native-watch snapshots root/core/vendor native files, prefers recursive inotify, falls back to polling, recompiles on change, and restarts FrankenPHP only after a successful native build. It records backend, PID, snapshot, timestamp, and last changed path in a sentinel file.

# Applications and Electron

An Application selects a Domain, Subdomain, or Endpoint context. Requests carrying the application id expose the entity in Twig and window.application; its environment rows override endpoint values.

symfonicat:application:build clears generated output and creates applications/<application.id>/main.js, package.json, and README.md. It resolves templates/application/overrides/<id>.js.twig before the default template, builds an HTTPS URL from domain + subdomain + endpoint arguments, and configures Electron Builder.

# Console command map

symfonicat:schema:update

Update the Doctrine schema and synchronize middleware, parcel, module, domain, and subdomain rows.

symfonicat:load

Load config/packages/symfonicat.yaml into the database-backed control plane.

symfonicat:dump

Export every non-admin symfonicat_* table to deterministic YAML.

symfonicat:admin:create

Create or update an administrator, password hash, TOTP secret, and provisioning QR.

symfonicat:core:delete

Delete an administrator; symfonicat:core:remove is an alias.

symfonicat:application:build

Generate Electron skeletons for every configured Application; symfonicat:electron:build is an alias.

symfonicat:data:webpack

Emit active parcel and module entry metadata for Encore.

symfonicat:data:dns

Output DNS-related runtime data.

symfonicat:discover:{ext,go}:{paths,names}

Inspect native directories across root, core, and opted-in vendor packages.

symfonicat:purge

Drop every symfonicat_* table across PostgreSQL, MySQL, or SQLite.

symfonicat:test:mercure

Publish a test update through the configured Mercure hub.

symfonicat:test:redis

Exercise the configured Redis connection.

# AWS deployment

The repository includes an end-to-end container path rather than generic cloud notes.

bin/ecr

Builds the runtime target and pushes a tag to private ECR.

bin/ecs

Creates or updates a Fargate cluster, task definition, service, networking, execution role, and optional load-balancer target.

bin/route53

Creates/reuses a hosted zone and publishes apex plus wildcard A records to the running task IP.

bin/cert

Requests an exportable ACM apex + wildcard certificate, publishes DNS validation, exports base64 TLS material, then refreshes ECS and DNS.

Defaults are 512 CPU units, 1024 MiB, one public Fargate task on port 443, with default-VPC discovery and an auto-created security group. The entrypoint converts exported certificate environment variables into Caddy-readable files.

# Complete core/src atlas

All 129 files are indexed below. Each group corresponds to an architectural responsibility; every filename links directly to its implementation.

Request pathSubscribers → services → runtime controller → renderer → middleware → Twig.
Control pathCore controllers → forms → entities/repositories → flush subscriber → YAML.
Package pathDiscovery → schema sync → module/parcel catalogs → Encore/native compilation.
Attributes3 files
Commands15 files
Contracts & dependency injection5 files
Controllers16 files
Entities16 files
Event subscribers6 files
Forms23 files
Middleware4 files
Repositories & security11 files
Services20 files
Twig extensions10 files

# Testing and the rest of the README

bash
docker exec -it php ./bin/phpunit

The test environment lowers password-hashing cost, uses dedicated cache/Doctrine/routing configuration, and the repository includes unit and integration coverage for runtime, admin, modules, native discovery, configuration, templates, middleware, and commands.

Picture of @dunglas at the zoo@dunglas at the zoo, included with the Symfonicat repository