plutonium-app

$npx mdskill add radioactive-labs/plutonium-core/plutonium-app

Initialize Plutonium apps with generators and configure portals.

  • Automates bootstrap, package creation, and route registration tasks.
  • Depends on Pu generators for core, auth, packages, and resources.
  • Selects base.rb or plutonium.rb based on existing app status.
  • Executes unattended installs using specific command flags.

SKILL.md

.github/skills/plutonium-appView on GitHub ↗
---
name: plutonium-app
description: Use BEFORE installing Plutonium, creating a portal or feature package, mounting an engine, or registering resources/routes. Covers initial setup, the package system, portal engines, route registration (including singular and custom routes), and resource-to-portal wiring.
---

# Plutonium App — Installation, Packages, Portals, Routes

How a Plutonium app is assembled: the install bootstrap, the package system (feature vs portal), portal engines, and the routing surface that exposes resources to the web.

For the resources themselves (model + definition + scaffold options), see [[plutonium-resource]]. For controllers/policies/interactions, see [[plutonium-behavior]]. For multi-tenancy, see [[plutonium-tenancy]].

## 🚨 Critical (read first)

- **Use the generators for everything.** `pu:core:install`, `pu:rodauth:install`, `pu:pkg:portal`, `pu:pkg:package`, `pu:res:scaffold`, `pu:res:conn`. Never hand-write base controllers, engine files, layouts, or portal route registration.
- **Existing app → `base.rb`. New app → `plutonium.rb`.** The `plutonium.rb` template re-runs full bootstrap (dotenv, annotate, solid_*, asset config) and creates generic "initial commit" commits that clobber history. For any pre-existing app use `base.rb`.
- **Pass `--dest`, `--auth`, `--force`, `--skip-bundle`** etc. for unattended runs so generators don't block on prompts.
- **Feature vs portal is a hard split.** Feature packages hold models/policies/definitions/interactions. Portal packages hold controllers/views/routes/auth. Don't mix.
- **Package classes are auto-namespaced** — `packages/blogging/app/models/blogging/post.rb` → `Blogging::Post`. Don't fight it.
- **Always connect resources with `pu:res:conn`** — until connected, a resource has no portal routes and is invisible.
- **For custom routes on a registered resource, pass `as:`** — otherwise `resource_url_for` can't build URLs.

---

# Part 1 — Installation

## Fresh Rails app (recommended)

```bash
rails new myapp -a propshaft -j esbuild -c tailwind \
  -m https://radioactive-labs.github.io/plutonium-core/templates/plutonium.rb
```

Configures Rails + Propshaft + esbuild + TailwindCSS + Plutonium in one shot.

## Existing Rails app

⚠️ Use `base.rb`, **not** `plutonium.rb`.

```bash
# Option 1 — template
bin/rails app:template \
  LOCATION=https://radioactive-labs.github.io/plutonium-core/templates/base.rb

# Option 2 — manual
# Add `gem "plutonium"` to Gemfile, then:
bundle install
rails generate pu:core:install
```

## Full setup workflow

```bash
# 1. Core install (base controllers/policies/definitions/layouts)
rails generate pu:core:install

# 2. Auth (if needed)
rails generate pu:rodauth:install
rails generate pu:rodauth:account user

# 3. Portal
rails generate pu:pkg:portal admin --auth=user

# 4. First resource
rails generate pu:res:scaffold Post user:belongs_to title:string 'content:text?' --dest=main_app
rails db:migrate

# 5. Connect resource to portal
rails generate pu:res:conn Post --dest=admin_portal

# 6. Mount portal in config/routes.rb
#    mount AdminPortal::Engine, at: "/admin"

# 7. Start
rails server
```

## What `pu:core:install` creates

```
app/
├── controllers/
│   ├── plutonium_controller.rb       # non-resource base
│   └── resource_controller.rb        # CRUD base — see plutonium-behavior
├── definitions/resource_definition.rb
├── interactions/resource_interaction.rb
├── models/resource_record.rb         # abstract model — includes Plutonium::Resource::Record
├── policies/resource_policy.rb
└── views/layouts/resource.html.erb

config/
├── initializers/plutonium.rb
└── packages.rb                       # auto-loads packages/**/lib/engine.rb

packages/.keep
```

The base classes (`ResourceController`, `ResourcePolicy`, `ResourceDefinition`, `ResourceRecord`, `ResourceInteraction`) are where you put app-wide defaults; resource-specific subclasses come from `pu:res:scaffold`.

## Converting an existing model to a resource

```ruby
# 1. Include the module on your model
class Post < ApplicationRecord
  include Plutonium::Resource::Record
end
```

```bash
# 2. Generate supporting files (skips model + migration)
rails g pu:res:scaffold Post --no-migration --dest=main_app

# 3. Connect to portal
rails g pu:res:conn Post --dest=admin_portal
```

## Configuration

```ruby
# config/initializers/plutonium.rb
Plutonium.configure do |config|
  config.load_defaults 1.0

  # Page chrome. Default :modern (topbar + icon rail).
  # :classic preserves the legacy header + sidebar (only when upgrading).
  # config.shell = :classic

  # Custom assets
  # config.assets.stylesheet = "custom_stylesheet"
  # config.assets.script     = "custom_script"
  # config.assets.logo       = "custom_logo.png"
end
```

---

# Part 2 — The Package System

Two kinds, hard split:

| Type | Purpose | Generator | Examples |
|---|---|---|---|
| **Feature** | Business logic (models, policies, definitions, interactions, migrations) | `pu:pkg:package NAME` | `blogging`, `billing`, `inventory` |
| **Portal** | Web interface (controllers, views, routes, auth) | `pu:pkg:portal NAME` | `admin_portal`, `customer_portal`, `public_portal` |

## Feature packages

```bash
rails g pu:pkg:package blogging
```

Structure:

```
packages/blogging/
├── app/
│   ├── models/blogging/                 # Blogging::Post
│   ├── definitions/blogging/            # Blogging::PostDefinition
│   ├── policies/blogging/               # Blogging::PostPolicy
│   └── interactions/blogging/           # Blogging::PublishPostInteraction
├── db/migrate/
└── lib/engine.rb
```

Engine:

```ruby
module Blogging
  class Engine < Rails::Engine
    include Plutonium::Package::Engine
  end
end
```

Auto-namespacing: every file under `app/<kind>/blogging/` resolves to `Blogging::*`.

### Creating resources in a feature package

```bash
rails g pu:res:scaffold Blogging::Post title:string --dest=blogging
```

`--dest=<package_name>` puts model/migration in the package. Cross-package references use the full namespace:

```bash
rails g pu:res:scaffold Comment user:belongs_to blogging/post:belongs_to body:text --dest=comments
```

## Portal packages

```bash
rails g pu:pkg:portal admin
```

Structure:

```
packages/admin_portal/
├── app/
│   ├── controllers/admin_portal/
│   │   ├── concerns/controller.rb       # auth + shared filters
│   │   ├── dashboard_controller.rb
│   │   ├── plutonium_controller.rb
│   │   └── resource_controller.rb
│   ├── definitions/admin_portal/        # per-portal overrides
│   ├── policies/admin_portal/           # per-portal overrides
│   └── views/layouts/admin_portal.html.erb
├── config/routes.rb
└── lib/engine.rb
```

See Part 3 for engine configuration and Part 5 for resource connection.

## Package loading

`config/packages.rb` (created by `pu:core:install`):

```ruby
Dir.glob(File.expand_path("../packages/**/lib/engine.rb", __dir__)) do |package|
  load package
end
```

This is loaded from `config/application.rb`. Migrations from all packages are picked up by `rails db:migrate` automatically.

## When to use which

**Feature packages** — domain logic that:
- Could be reused across multiple portals (admin and customer both edit `Blogging::Post`)
- Has no inherent UI / auth (it's just behavior)
- You want to keep isolated from other domains (`billing` should not depend on `blogging`)

**Portal packages** — user-facing surfaces that:
- Have a specific auth flow (admin vs customer vs public)
- Render different views of the same underlying resources
- Need different policies / definitions per audience

---

# Part 3 — Portal Engines

A portal is a Rails engine mixing in `Plutonium::Portal::Engine`. It defines its own routes, controller concern, and (optionally) entity scoping.

## Generator

```bash
rails g pu:pkg:portal <name>
```

### Options

| Option | Description |
|---|---|
| `--auth=NAME` | Rodauth account to use (e.g. `--auth=user`) |
| `--public` | Public access — no auth |
| `--byo` | Bring your own auth |
| `--scope=CLASS` | Entity class for multi-tenancy (e.g. `--scope=Organization`) |

```bash
rails g pu:pkg:portal admin     --auth=admin
rails g pu:pkg:portal api       --public
rails g pu:pkg:portal custom    --byo
rails g pu:pkg:portal admin     --auth=admin --scope=Organization
```

Without flags, the generator prompts interactively.

## Engine file

```ruby
# packages/admin_portal/lib/engine.rb
module AdminPortal
  class Engine < Rails::Engine
    include Plutonium::Portal::Engine

    config.after_initialize do
      # Optional: multi-tenancy. See plutonium-tenancy for strategies.
      scope_to_entity Organization, strategy: :path
    end
  end
end
```

## Controller concern (auth)

Every portal has a `Concerns::Controller` mixed into its `ResourceController`. The generator wires this up; you customize it for auth / before_action hooks.

### Rodauth

```ruby
module AdminPortal::Concerns::Controller
  extend ActiveSupport::Concern
  include Plutonium::Portal::Controller
  include Plutonium::Auth::Rodauth(:user)
end
```

### Public access

```ruby
module AdminPortal::Concerns::Controller
  extend ActiveSupport::Concern
  include Plutonium::Portal::Controller
  include Plutonium::Auth::Public
end
```

### BYO auth

```ruby
module AdminPortal::Concerns::Controller
  extend ActiveSupport::Concern
  include Plutonium::Portal::Controller
  include Plutonium::Auth::Public        # disables Rodauth requirement

  def current_user
    @current_user ||= User.find_by(api_key: request.headers["X-API-Key"])
  end
end
```

## Mounting

```ruby
# config/routes.rb
Rails.application.routes.draw do
  # Authenticated mount
  constraints Rodauth::Rails.authenticate(:user) do
    mount AdminPortal::Engine, at: "/admin"
  end

  # Unconstrained (portal handles its own auth)
  mount PublicPortal::Engine, at: "/public"
end
```

## Controller hierarchy

Portal controllers inherit from the feature-package controller if one exists, OR from the portal's `ResourceController` otherwise.

```ruby
# Feature controller exists → inherit from it AND include portal concern
class AdminPortal::PostsController < ::PostsController
  include AdminPortal::Concerns::Controller
end

# No feature controller → inherit from portal's ResourceController
class AdminPortal::PostsController < AdminPortal::ResourceController
end
```

For non-resource portal pages (dashboard, settings):

```ruby
module AdminPortal
  class DashboardController < PlutoniumController
    def index; end
  end
end
```

## Per-portal overrides

```ruby
# Definition
class AdminPortal::PostDefinition < ::PostDefinition
  input :internal_notes, as: :text     # admins see this; customers don't
  scope :pending_review
end

# Policy
class AdminPortal::PostPolicy < ::PostPolicy
  include AdminPortal::ResourcePolicy
  def destroy? = true
  def permitted_attributes_for_create = %i[title content featured internal_notes]
end

# Controller
module AdminPortal
  class PostsController < ResourceController
    private
    def preferred_action_after_submit = "index"
  end
end
```

---

# Part 4 — Routes & `register_resource`

Portal routes live in `packages/<name>_portal/config/routes.rb`:

```ruby
AdminPortal::Engine.routes.draw do
  root to: "dashboard#index"

  register_resource ::Post
  register_resource Blogging::Comment

  # Non-resource pages
  get "settings", to: "settings#index"
end
```

## `register_resource` — what it does

For each call, Plutonium auto-generates:

- Top-level CRUD routes (`/posts`, `/posts/:id`, etc.)
- Nested routes for every registered `has_many` / `has_one` parent (prefixed `nested_`)
- Route names that `resource_url_for` can resolve

You list every resource the portal exposes. If a resource isn't registered, it has no URLs in that portal — `resource_url_for` will fail.

## Singular (singleton) resources

For resources with no collection — a single per-user `Profile`, app-wide `Settings`, etc.:

```ruby
register_resource ::Profile, singular: true
```

Generates singular routes (no `:id`, no index):

- `GET /profile`           → show
- `GET /profile/new`       → new
- `GET /profile/edit`      → edit
- `POST /profile`          → create
- `PATCH /profile`         → update
- `DELETE /profile`        → destroy

Use the `--singular` flag on `pu:res:conn`:

```bash
rails g pu:res:conn Profile --dest=customer_portal --singular
```

## Custom member / collection routes

```ruby
register_resource ::Post do
  member do
    get  :preview,    as: :preview
    get  :analytics,  as: :analytics
    post :publish,    as: :publish
  end
  collection do
    get  :archived,       as: :archived
    post :bulk_publish,   as: :bulk_publish
  end
end
```

**Always pass `as:`.** Without it, `resource_url_for(@post, action: :preview)` fails because there's no named route to look up — especially critical for nested resources.

For most operations with business logic, prefer **interactive actions** (definition + interaction — see [[plutonium-resource]] › Actions) over custom controller routes. The action routes are wired automatically with no `register_resource` block needed.

## Cross-package and nested URLs

See [[plutonium-behavior]] for full `resource_url_for` signature and [[plutonium-tenancy]] for nested routing semantics.

---

# Part 5 — Connecting Resources to Portals (`pu:res:conn`)

A resource is invisible until connected to at least one portal. The generator wires up the portal-specific controller, policy, definition, and route registration.

## Command syntax

```bash
rails g pu:res:conn RESOURCE [RESOURCE...] --dest=PORTAL_NAME [--singular]
```

Pass resources directly — avoids interactive prompts. No `--src` needed.

## Usage

```bash
# Main app resources
rails g pu:res:conn Post Comment Tag --dest=admin_portal

# Namespaced (from a feature package)
rails g pu:res:conn Blogging::Post Blogging::Comment --dest=admin_portal

# Singular (profile, settings, dashboard)
rails g pu:res:conn Profile --dest=customer_portal --singular
```

**Run after migrations** — the generator reads model columns to seed the policy's `permitted_attributes_for_*`.

## What gets generated

For `Post` connected to `admin_portal`:

```
packages/admin_portal/app/
├── controllers/admin_portal/posts_controller.rb
├── policies/admin_portal/post_policy.rb
└── definitions/admin_portal/post_definition.rb
```

Plus route registration appended to `packages/admin_portal/config/routes.rb`:

```ruby
register_resource ::Post
register_resource ::Profile, singular: true   # if --singular
```

Re-running `pu:res:conn` for the same resource is **idempotent** — already-registered entries report `identical` and are not duplicated. Insertion falls back gracefully when the conventional `# register resources above` marker is missing (uses the `routes.draw do` opening), and warns clearly if it can't find any anchor.

### Generated controller

```ruby
class AdminPortal::PostsController < ::PostsController
  include AdminPortal::Concerns::Controller
end
```

### Generated policy (seeded from model columns)

```ruby
class AdminPortal::PostPolicy < ::PostPolicy
  include AdminPortal::ResourcePolicy

  def permitted_attributes_for_create
    [:title, :content, :user_id]
  end

  def permitted_attributes_for_read
    [:title, :content, :user_id, :created_at, :updated_at]
  end

  def permitted_associations
    %i[]
  end
end
```

Review and trim — the generator is liberal. Especially: drop `_id` fields when the form uses the association name, and add `:price` (not `:price_cents`) for `has_cents` fields.

---

## Generator reference

| Generator | Purpose |
|---|---|
| `pu:core:install` | Initial Plutonium setup (base classes, config, layouts) |
| `pu:rodauth:install` | Set up Rodauth auth |
| `pu:rodauth:account NAME` | Create user account type |
| `pu:rodauth:admin NAME` | Admin account with 2FA, lockout, audit |
| `pu:saas:setup` | User + entity + membership in one shot |
| `pu:saas:user NAME` | SaaS user account |
| `pu:saas:entity NAME` | Entity model |
| `pu:saas:membership` | Membership join table |
| `pu:pkg:package NAME` | Feature package |
| `pu:pkg:portal NAME` | Portal package |
| `pu:res:scaffold NAME` | Resource (model, migration, controller, policy, definition) |
| `pu:res:conn NAME` | Connect resource to portal |
| `pu:invites:install` | Invite system (see [[plutonium-tenancy]]) |
| `pu:invites:invitable NAME` | Mark a model as invitable |
| `pu:eject:layout` | Eject layouts for customization |
| `pu:eject:shell` | Eject topbar/sidebar partials |
| `pu:core:update` | Update plutonium gem + npm |
| `pu:skills:sync` | Sync Claude Code skills to project |

---

## Related skills

- [[plutonium-resource]] — what a resource IS (model + definition + scaffold options)
- [[plutonium-behavior]] — controllers, policies, interactions
- [[plutonium-tenancy]] — entity scoping, nested resources, invites
- [[plutonium-auth]] — Rodauth account configuration
- [[plutonium-ui]] — layouts, page classes, custom Phlex components, assets
- [[plutonium-testing]] — testing portals, packages, controllers

More from radioactive-labs/plutonium-core

SkillDescription
plutoniumUse BEFORE starting any Plutonium work — new app, new feature, or first edit in an unfamiliar area. Routes you to the right skill and bootstraps greenfield work.
plutonium-authUse BEFORE installing Rodauth, configuring account types, building login/password flows, or wiring a profile / account-settings page. Covers the full auth surface — Rodauth installation, accounts, admin accounts, SaaS setup, profile resource, security section.
plutonium-behaviorUse BEFORE writing or overriding a Plutonium controller, policy, or interaction class. Covers controller hooks, policy methods, permitted attributes, relation_scope, interaction structure, outcomes, and chaining. The single source for "how does this resource actually do things".
plutonium-resourceUse BEFORE creating, scaffolding, or editing any Plutonium resource — model, definition, field types, scaffold options, has_cents, SGID, search/filters/scopes/sorting, custom actions, bulk actions, index views, page customization. The single source for "what is a resource and how do I configure one".
plutonium-tenancyUse BEFORE any multi-tenant work — scoping a model to a tenant, writing relation_scope, configuring portal entity strategies, setting up parent/child nested resources, or wiring user invitations. The single source for entity scoping, nested resources, and invites.
plutonium-testingUse BEFORE writing tests for a Plutonium resource, running pu:test:scaffold, or including Plutonium::Testing::* concerns. Covers the full testing toolkit — CRUD, policy, definition, interaction, model, nested, portal access, and auth helpers.
plutonium-uiUse BEFORE building or customizing any Plutonium UI — page classes, forms, displays, tables, custom Phlex components, layouts, Stimulus controllers, Tailwind config, design tokens, themes, or component classes. Covers the full view + asset toolchain.