Contributing
How to work on Aphex itself — the monorepo, dev workflow, releases, and the studio → template → CLI sync chain.
Aphex is open source and contributions are welcome — bug fixes, new field types, database or storage adapters, UI improvements, and docs all help. Browse the open issues for ideas.
Prerequisites
- Node.js 22+ — one supported version across the repo. It's the
enginesfloor in everypackage.json, the base image the templates'Dockerfileruns (node:22-alpine), and what CI installs. Newer works; older is not tested. - pnpm 10 — pinned by
packageManager, socorepack enablegets you the exact version rather than whatever is installed globally. - Docker + Docker Compose — only for the Postgres path and Mailpit; the SQLite path needs neither.
- Git with SSH keys configured
Repository layout
aphex/
├── apps/
│ └── studio/ # Reference SvelteKit app — features land here first
├── packages/
│ ├── cms-core/ # Core engine + admin UI (adapter-agnostic via ports)
│ ├── auth/ # Better Auth wiring in one call
│ ├── postgresql-adapter/
│ ├── sqlite-adapter/ # libsql — a first-class alternative, not a reduced tier
│ ├── storage-s3/
│ ├── nodemailer-adapter/
│ ├── resend-adapter/
│ ├── ai-openai/ # (ai-ollama and ai-anthropic exist but are private)
│ ├── ui/ # Shared shadcn-svelte components
│ ├── visual-editing/ # Live preview overlay + stega helpers
│ └── create-aphex/ # `pnpm create aphex` scaffolder
├── plugins/
│ ├── plugin-forms/ # Editor-composed forms
│ ├── plugin-seo/
│ └── plugin-color-picker/
├── templates/
│ ├── base/ # Starter template (mirrored to aphex-base)
│ └── website/ # Page builder, posts, forms, SEO (mirrored to aphex-website)
├── docs/
│ └── aphex-docs/ # This site (mirrored to aphex-docs)
└── .github/workflows/ # release.yml, sync-template.yml, sync-docs.ymlLocal setup
git clone [email protected]:IcelandicIcecream/aphex.git
cd aphex
pnpm install
cp apps/studio/.env.example apps/studio/.env
echo 'APHEX_DATABASE=sqlite' >> apps/studio/.env
pnpm dev # studio + cms-core via TurborepoThat's the no-Docker path: SQLite pushes its schema at boot, so there is nothing to start and nothing to migrate. For Postgres instead — worth doing before touching anything RLS-related, since row-level security is Postgres-only:
pnpm db:start # Postgres + Mailpit via Docker
pnpm db:migrate # apply committed migrations
pnpm devpnpm db:push is a dev-only shortcut and must never be pointed at a production Postgres database
— it can drop columns silently. Generate, review and commit a migration instead.
Admin UI lands at http://localhost:5173/admin. The first user to sign up at /login becomes super admin and gets a default org.
Hot reload behavior
| Change | Behavior |
|---|---|
| Schema files | Picked up on the next request. |
| Component changes | Instant via Vite HMR. |
cms-core source | Live — consumed from source via the workspace protocol. |
Adapter sources (postgresql-adapter, sqlite-adapter, storage-s3, email, AI) | Require a rebuild + dev server restart — they're consumed from dist, unlike cms-core. |
Plugin sources (plugins/*) | Same: built to dist and consumed from it. |
| Drizzle schema changes | pnpm db:push (dev) or generate + migrate cycle. |
Common commands
pnpm dev # studio + cms-core
pnpm build # build everything (Turborepo)
pnpm check # type-check all packages
pnpm lint # Prettier + ESLint
pnpm format # write Prettier formatting
pnpm test:package # build + type-check cms-core
pnpm shadcn <name> # add shadcn-svelte components to @aphexcms/uiTests live in apps/studio/tests/:
pnpm -F @aphexcms/studio test # all
pnpm -F @aphexcms/studio test:local # Local API only
pnpm -F @aphexcms/studio test:http # HTTP API only
pnpm -F @aphexcms/studio test:graphql # GraphQL onlyCommits & PRs
Conventional Commits — feat:, fix:, docs:, refactor:, chore:, test:. One feature or fix per PR; aim for under 500 lines of diff.
If your PR touches a published package, add a changeset:
pnpm changesetPick the affected packages, the bump type (patch / minor / major), and write a one-liner. Commit the generated .changeset/*.md file with the rest of your PR. Skip the changeset for docs-only or studio-only changes.
Releases & publishing
Releases run through Changesets and .github/workflows/release.yml. The flow on every push to main:
Pending changesets exist → workflow opens (or updates) a chore: version packages PR that
bumps versions in every affected package.json and regenerates each package's CHANGELOG.md.
Version PR merges → workflow runs pnpm release which is turbo build --filter='./packages/*' && changeset publish --provenance. Packages ship to npm with a signed SLSA
provenance statement.
changesets/action.What gets published
Everything in packages/ and plugins/ that isn't marked private — fifteen packages:
| Group | Packages |
|---|---|
| Core | @aphexcms/cms-core, @aphexcms/auth, @aphexcms/ui, @aphexcms/visual-editing |
| Database | @aphexcms/postgresql-adapter, @aphexcms/sqlite-adapter |
| Storage | @aphexcms/storage-s3 |
@aphexcms/nodemailer-adapter, @aphexcms/resend-adapter | |
| AI | @aphexcms/ai-openai |
| Plugins | @aphexcms/plugin-forms, @aphexcms/plugin-seo, @aphexcms/plugin-color-picker |
| Tooling | create-aphex (the pnpm create aphex scaffolder) |
@aphexcms/ai-ollama and @aphexcms/ai-anthropic are marked private and do not
publish. Neither do the apps and templates: @aphexcms/studio, @aphexcms/base,
@aphexcms/website and aphex-docs are listed in .changeset/config.json's
ignore array, because the templates reach users as mirror repositories rather than npm
packages.
The list above is derived from private in each package.json, so adding a package to
packages/ or plugins/ without that flag publishes it on the next release. node scripts/verify-packed-packages.mjs enumerates exactly the same set — run it to see what a release
would ship.
How templates reach users
Templates are not published to npm. .github/workflows/sync-template.yml pushes each
templates/* folder to its own standalone repository — aphex-base and aphex-website —
which is what create-aphex downloads from and what the Deploy buttons point at.
Two things about the timing are easy to get wrong:
- The mirror runs only after a release that actually published, because a mirrored
template pins its
@aphexcms/*dependencies by version and the workflow installs them from npm. Mirroring first fails with "No matching version found". - A release run that only opens the version packages PR publishes nothing, and the sync
is skipped on it. That's why
release.ymlcalls the sync workflow explicitly rather than triggering onworkflow_run— aworkflow_runtrigger can't read the triggering run's outputs, so it can't tell the two apart.
Each mirror needs a deploy key in this repository's secrets (TEMPLATE_REPO_DEPLOY_KEY,
WEBSITE_TEMPLATE_REPO_DEPLOY_KEY). A missing key fails that template's job — it used
to warn on an otherwise green run, which made "the release succeeded" and "the template was
mirrored" look identical.
Required repo secrets:
| Secret | Purpose |
|---|---|
GITHUB_TOKEN | Auto-provided. Used to open the version PR. |
NPM_TOKEN | Required unless npm trusted publishing is configured for the @aphexcms scope. |
Studio → template sync
apps/studio is the working reference. templates/base/ is the starter shipped to end users via pnpm create aphex (or npm create aphex@latest). To flow studio changes into the template:
./scripts/sync-template.sh # dry run — preview changes
./scripts/sync-template.sh --apply # actually copy filesThe script is template-driven: it walks every file tracked in templates/base/ and copies the matching file from apps/studio/ if it exists. Studio-only files (tests, seed routes) don't get copied because the template has no matching path. Three special cases:
src/lib/schemaTypes/**— skipped. Template keeps itspost.tsexample, not studio's fixtures.src/app.css— skipped. Template usesnode_modules/@aphexcms/*/distpaths for Tailwind@source.package.json— merged. Studio content wins; template'snameandversionare preserved.
If studio adds a brand-new file or directory, create a placeholder in templates/base/ first so the next sync picks it up.
After applying, update templates/base/CHANGELOG.md under ## Unreleased so downstream users know what changed — the template is meant to be customized, so changes don't auto-apply to anyone's existing project.
How a template change reaches pnpm create aphex
The scaffolder bundles no templates. create-aphex publishes only dist/, and at
run time giget downloads a tarball from the mirror at a tag named after the
scaffolder's own version:
github:IcelandicIcecream/aphex-base#create-aphex-v<version>So a template edit reaches users in three hops, and the last one is the part that catches people out:
- Merge the change —
sync-template.ymlmirrorstemplates/*toaphex-base/aphex-websiteon the next release that publishes something. - That workflow cuts
create-aphex-v<version>only if the tag doesn't exist yet. - Which means the tag only moves when
create-aphexitself bumps.
A template-only fix therefore needs a create-aphex changeset to ship, even though
nothing in packages/create-aphex/ changed. Without one, the mirror's main updates
and npm create aphex keeps scaffolding the old snapshot — the version pin is doing
exactly its job, which is why the fix is to cut a new pin rather than move an old one.
Smoke-test the built scaffolder against the live mirror:
pnpm -F create-aphex build
node packages/create-aphex/dist/index.js my-test-app --template websiteMirror workflows
Two GitHub Actions push subdirectories of the monorepo to standalone public repos:
| Workflow | What | Destination | Secret |
|---|---|---|---|
.github/workflows/sync-template.yml | templates/base/ (with workspace:* resolved) | IcelandicIcecream/aphex-base | TEMPLATE_REPO_DEPLOY_KEY |
.github/workflows/sync-docs.yml | docs/aphex-docs/ | IcelandicIcecream/aphex-docs | DOCS_REPO_DEPLOY_KEY |
Both run on pushes to main that touch the relevant directory and use s0/git-publish-subdir-action under the hood.
Setting up a deploy key
ssh-keygen -t ed25519 -f my_repo_deploy -N ""Public key (my_repo_deploy.pub) → destination repo → Settings → Deploy keys → Add deploy
key. Tick "Allow write access".
Private key (full file content, including the -----BEGIN/-----END markers and trailing
newline) → source repo → Settings → Secrets and variables → Actions → New repository secret.
Name it to match the workflow (TEMPLATE_REPO_DEPLOY_KEY or DOCS_REPO_DEPLOY_KEY).
Push a change touching the relevant directory and watch the Actions tab — first run prints the SSH handshake.
Adding new things
| What you're adding | Where it goes |
|---|---|
| New field type | packages/cms-core/src/lib/types/schemas.ts + an editor in components/admin/fields/ |
| New database adapter | New package implementing DatabaseAdapter and its sub-interfaces |
| New storage adapter | New package implementing StorageAdapter |
| New email adapter | New package implementing EmailAdapter |
| Custom HTTP route or middleware | api(app) hook in aphex.config.ts (no plugin system) |
See the architecture overview for how the ports-and-adapters seam is laid out.
See also
Last updated on