# Stacks Plugins

> Use when working with the Stacks preload chain and Bun plugins - the env plugin, the framework preloader, how auto-imports get injected into globalThis, why a command does or does not see framework globals, the bun-plugin-stx static-serve plugin, or writing a Bun plugin. Covers bunfig.toml preload and storage/framework/defaults/resources/plugins/preloader.ts.

- Skill: `stacksjs/stacks-plugins` (Agent Skill)
- Install (CLI): `npx skillmds@latest add stacksjs/stacks-plugins`
- Raw SKILL.md: https://api.skillmd.com/api/skills/stacksjs/stacks-plugins/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- License: MIT
- Author: stacksjs (https://skillmd.com/u/stacksjs)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/stacksjs/stacks-plugins

---


# Stacks Plugins and the Preload Chain

Stacks has **no `@stacksjs/plugins` package**. "Plugins" here means Bun's native
plugin API plus the two modules `bunfig.toml` preloads before any Stacks code
runs. If you are chasing "why is `User` undefined in this command" or "why did
`.env` not decrypt", this is the skill.

## Key Paths

- Preload declaration: `bunfig.toml` (`preload`, at the root and under `[test]`)
- Env plugin: `storage/framework/core/env/plugin.ts`
- Framework preloader: `storage/framework/defaults/resources/plugins/preloader.ts`
- Static-serve plugin: `bunfig.toml`, `[serve.static] plugins = ["bun-plugin-stx"]`

## The preload chain

`bunfig.toml` preloads two modules, **in this order**:

```toml
preload = [
  "./storage/framework/core/env/plugin.ts",
  "./storage/framework/defaults/resources/plugins/preloader.ts",
]
```

1. **Env plugin** loads `.env` and decrypts dotenvx-style encrypted values, so
   everything downstream reads plaintext from `process.env`.
2. **Preloader** loads env files for the resolved environment, then injects the
   framework's server auto-imports into `globalThis`.

Order is load-bearing: the preloader reads decrypted secrets, so it has to run
second.

Tests preload a different pair - the env plugin and `tests/setup.ts` - so the
suite gets production env semantics without the auto-import graph.

## What the preloader does

### 1. Resolves the environment

Before loading any `.env` file it works out `APP_ENV`, because that decides
*which* file gets decrypted:

- `deploy staging` and `deploy --staging` both resolve to `staging`. Both forms
  are handled; CI uses the flag form.
- `cloud:remove`, `cloud:destroy`, `cloud:cleanup`, `undeploy` force `production`.
- Bare `deploy` defaults to `production`.

### 2. Loads and decrypts env files

Gated on **`isRepl` / `isPostinstall` only** - never on the fast-command list
below. Fast commands (`migrate`, `build`, `seed`, ...) genuinely need decrypted
config; gating them here caused stacksjs/stacks#2048, where an encrypted
`.env.<env>` silently never decrypted even with the key present.

Postinstall skips because `@stacksjs/env` may not be linked yet mid-install.

### 3. Injects auto-imports into `globalThis`

Loads ~24 framework packages plus everything in `app/Jobs/` and
`resources/functions/`, assigning every named export onto `globalThis`. That is
why `await User.find(1)` and `response.json(...)` work in an action with no
import statement. See `stacks-auto-imports` for the full manifest.

A `protectedGlobals` set guards the runtime: `process`, `fetch`, `Promise`,
`Bun`, `console` and friends are never overwritten, whatever a package exports.

## The fast-command skip (the usual gotcha)

Auto-import injection is **skipped** when the first CLI argument matches:

```
dev  build  test  lint  migrate  fresh  seed  generate  make
key:generate  scaffold:crud  version  help  --version  -v  --help  -h
```

Prefix matches count, so `generate:migrations` skips along with `generate`.

Two reasons: cold-start speed, and correctness. Codegen commands must not pull
the router and ORM graph in before `bun-query-builder` can diff schemas - a
broken `@stacksjs/bun-router` install used to make `generate:migrations` exit 1
with no output at all, because the preloader died loading `@stacksjs/router`.

**Consequence:** inside those commands, framework globals do not exist. Import
what you need explicitly.

The REPL skips too (`!process.argv[1]`), as does anything running under
`npm_lifecycle_event === 'postinstall'`.

### Opting back in

The preloader exports `loadAutoImports()` so a server entrypoint can pull the
graph in deliberately:

```ts
const { loadAutoImports } = await import('../../../defaults/resources/plugins/preloader.ts')
await loadAutoImports()
```

`dev/api.ts` does exactly this. Bun consumes `--watch`, so a directly-run server
script sees an empty `argv.slice(2)` and would otherwise be indistinguishable
from a bare invocation.

## Writing a Bun plugin

Use Bun's native API directly - there is no framework wrapper to import:

```ts
import { plugin } from 'bun'

plugin({
  name: 'my-loader',
  setup(build) {
    build.onLoad({ filter: /\.custom$/ }, async (args) => {
      const contents = await Bun.file(args.path).text()
      return { contents: transform(contents), loader: 'ts' }
    })
  },
})
```

Register it by adding the module to `preload` in `bunfig.toml`. A plugin only
affects the process that preloaded it.

## Gotchas

- **No `@stacksjs/plugins` package.** Import `plugin` from `bun`.
- **Preload order matters.** Env decryption has to precede the preloader.
- **Fast commands have no globals.** If a `make:*` or `generate:*` command needs
  a framework symbol, import it - do not rely on auto-imports.
- **`bun-plugin-stx` is a static-serve plugin**, wired through
  `[serve.static] plugins` in `bunfig.toml`. The preloader's own
  `import 'bun-plugin-stx'` line is commented out; stx compilation in the dev
  server runs through the stx package, not that import.
- **Preloader failures are silent per package.** Each import sits in its own
  `try {} catch {}`, so a package that fails to build leaves its globals
  undefined instead of crashing the process. A mysteriously undefined global is
  usually an unbuilt package - check `storage/framework/core/<pkg>/dist`.
- **Adding a global?** Export it from a package already in the preloader's list,
  or from `resources/functions/`, then run `buddy generate --types` so the
  declarations match the runtime.

