@sidebase/base-config
v0.1.0
Published
Shared @sidebase base configuration for Nuxt repos: ESLint / Prisma / tsconfig factories on npm, plus the streamctl file-sync preset payload
Readme
@sidebase/base-config
Shared @sidebase Nuxt configuration, delivered over two channels in one package:
- npm channel: the root export
@sidebase/base-configplus the subpath exports@sidebase/base-config/config,@sidebase/base-config/eslint,@sidebase/base-config/prisma, and@sidebase/base-config/tsconfig.base. - file-sync channel: the bundled
presets/payload read by the@sidebase/streamctlCLI.
Install
For the npm channel, install the package plus what the subpaths you use require:
pnpm add -D @sidebase/base-config eslint jitieslint (^10.5.0) and jiti (>= 2.2.0) are what /eslint needs; jiti is only
required because the config file is TypeScript. prisma and @prisma/client (^6.19)
are optional peers, install them only if you use /prisma.
jiti is not a declared peer -- peerDependencies is eslint, prisma and
@prisma/client. It is in the command above because /eslint genuinely needs it, but
since nothing declares it, pnpm emits no missing-peer warning if you leave it out.
This README is the only thing that will tell you. See Requirements below.
For the file-sync channel, see Adopting below. The CLI wires the dependency itself.
Adopting
The payload is the install: @sidebase/streamctl
reads presets/ straight from this package in a consuming repo's node_modules.
pnpm streamctl init --package @sidebase/base-config # scaffold + first sync
pnpm streamctl check # CI drift gate
pnpm streamctl upgrade --to <version> # move the pin forwardNeither package needs a registry token or private-registry setup: both target the public
@sidebase scope on npm.
Coming from @sidestream-tech/nuxt-config? That rename is a manual migration, not an
upgrade: streamctl upgrade moves the version pin and never rewrites package:. Follow
docs/migration.md, and note that the step order is load-bearing.
Presets and profiles
Two separate axes in streamctl.config.ts:
baseis the preset, the set of managed files you get.nuxt-appnames the repo type: it ships a Dockerfile, a postgres/prisma CI pipeline, and PR-preview cleanup.profileis the version baseline, the dependency pins reconciled into yourpackage.json. It names the framework and its major:nuxt-4.
The major is in the profile name on purpose. Each profile carries its own
detect.majorIs, so init picks the right one from your package.json and a future
nuxt-5 profile can sit alongside nuxt-4 while repos migrate one at a time.
nuxt-4 is the only profile shipped today, so this payload is Nuxt 4 only. There is no
nuxt-3 profile and none is planned. Running init in a Nuxt 3 repo fails with
"No version profile could be detected", because no profile's detect.majorIs matches the
nuxt version in your package.json. Move the repo to Nuxt 4 before adopting.
A profile name must match a versionProfiles key in the preset chain. If it does not, the
CLI fails with CONFIG_INVALID: profile "..." is not declared in presets/manifest.json
profiles[]. pnpm validate:presets catches the same mismatch at build time, so a broken
profile name cannot ship in the first place.
Reconciled scripts.lint
The nuxt-4 profile reconciles your package.json scripts.lint to
oxlint -c .oxlintrc.json --deny-warnings && eslint --max-warnings 0 ., so the fleet
runs oxlint (already pinned, with the shipped .oxlintrc.json) before ESLint.
Unlike a version pin, a script has no forward-version floor to protect it, because a
script is not a semver, so this is a plain overwrite: every sync replaces whatever
your scripts.lint currently is. A repo that wants its own (for example
... && LINT_TYPEAWARE=true eslint --max-warnings 0 . to opt into type-aware linting)
keeps it by excluding the key:
export default {
// ...
versionSyncExclude: ["scripts.lint"],
};Overriding a managed file
.editorconfig is block-managed: the payload owns a marked region, delimited by
# BEGIN streamctl MANAGED BLOCK editorconfig / # END ..., and preserves everything
outside it byte-for-byte. On first sync the managed block is appended to the end of your
file. Because EditorConfig applies later matching sections over earlier ones, you keep a
project override by placing it below the managed block:
# BEGIN streamctl MANAGED BLOCK editorconfig
[*]
indent_size = 2
# END streamctl MANAGED BLOCK editorconfig
# your override wins because it comes after the managed block
[*]
indent_size = 4That way the payload keeps updating its region on every sync while your indent_size = 4
survives. (A section placed above the block would be overridden by the block's
indent_size = 2, so keep overrides below the END marker.)
First-sync hazard. If your repo already has an .editorconfig, the block writer appends
without looking at what is there, so all of your existing content ends up above the block
and therefore loses. streamctl check exits 0 either way, because the block strategy only
inspects its own markers. This applies to a fresh streamctl init just as much as to a
migration, and you have to fix it by hand once. Step 8 of
docs/migration.md has the detector and the two-case fix.
If you instead want to stop managing a file entirely, opt it out per-path:
files: { ".editorconfig": "off" },The cost of off is total: streamctl no longer touches that file, so every future
payload fix to it stops reaching your repo, including new rules, security bumps, and
format corrections. Prefer the block override above when you only need to change part of
a file.
This repo's CLI dependency
The payload is validated and dogfooded against the CLI, so @sidebase/streamctl is a
devDependency here, pinned to the published semver range.
validate:presets imports the CLI's exported manifest schema and the e2e dry-run drives the
real CLI binary, so both resolve @sidebase/streamctl from the registry.
The root streamctl.config.ts this payload documents needs streamctl >= 0.2.0, so the
pin and the pnpm-workspace.yaml c12 override both move when that release lands. See
"Config location" below.
Typed config
The root export @sidebase/base-config and the subpath @sidebase/base-config/config
are the same module. Both provide defineNuxtBaseConfig and the NuxtBaseConfig type,
which is what makes a streamctl.config.ts typed. Every consuming repo has this file,
and streamctl init scaffolds it importing from the root export:
// streamctl.config.ts
import { defineNuxtBaseConfig } from "@sidebase/base-config";
export default defineNuxtBaseConfig({
package: "@sidebase/base-config",
base: "nuxt-app",
version: "0.1.0",
profile: "nuxt-4",
ci: { unitTests: true },
});Config location
streamctl >= 0.2.0 reads streamctl.config.ts from the repo root, and init scaffolds it
there. The older .streamctl/config.ts is still resolved, so an existing repo keeps working
untouched and can move the file whenever it likes.
Move it and nothing else changes: the contents are identical, and the CLI warns rather than guesses if both exist, naming the root file as the one it read. Keeping the legacy path is fine too. The one thing not to do is leave both in place, since only the root file is read and edits to the other will look like they do nothing.
If you set ignoresTypeAware yourself, list whichever path your repo uses. The default
covers both.
defineNuxtBaseConfig returns the config unchanged. Its job is typed editor inference
for this payload's knobs: completion and type checking on ci, versions, docker,
automation, security, pnpm, and the whole eslint option surface, plus profile
narrowed to the profile names that actually exist. Without it the object is an untyped
literal, so a typo such as ci: { unitTest: true } stays silent in your editor.
The CLI shape-checks the config against the manifest when it loads it, so a sync still works without the helper. The helper is what moves that feedback into your editor.
Import NuxtBaseConfig directly when you need the type on its own:
import type { NuxtBaseConfig } from "@sidebase/base-config/config";Shared tsconfig
@sidebase/base-config/tsconfig.base is the shared strict TypeScript base, exported as a
JSON file so a tsconfig.json can extend it by package name. The nuxt-app preset ships
a managed tsconfig.json that chains it with Nuxt's generated config:
{
"extends": [
"@sidebase/base-config/tsconfig.base",
"./.nuxt/tsconfig.json"
]
}Order matters: the shared base comes first, so Nuxt's generated config applies over it.
Naming convention
Two rules cover every exported name, so you can predict them:
- Types describing this payload's config shape are
NuxtBase*:NuxtBaseConfig,NuxtBaseCiConfig,defineNuxtBaseConfig, and theNUXT_BASE_*_KEYSlists. The prefix is doing real work here, because these types describe the config of this specific payload rather than anything generic. - Everything else is domain-named with no package prefix:
buildPrismaConfig,applyPrismaDevEnv,createSidebaseEslint,resolveEslintOptions. The import path already states where a symbol comes from, so repeating it in the identifier adds nothing.
streamctl is reserved for the CLI (@sidebase/streamctl) and the files it owns:
streamctl.config.ts in the repo root, and the legacy .streamctl/ directory it still
reads. Nothing exported from this package carries that name, because nothing
exported from this package comes from the CLI. The ESLint layers this package builds are
named sidebase/* (sidebase/defaults, sidebase/console, and so on), which is what you
see in lint output and what you target if you .override() a layer by name. They are
built at runtime by this package; the CLI never sees them.
ESLint factory
@sidebase/base-config/eslint exports createSidebaseEslint(options?), which wraps
@antfu/eslint-config (a dependency of this
package, so do not add @antfu/eslint-config directly) and returns its
FlatConfigComposer, so you chain .append() / .override() for local rules:
// eslint.config.ts
import { createSidebaseEslint } from "@sidebase/base-config/eslint";
export default createSidebaseEslint({ zod: "full", trpcGuard: true })
.append({ rules: { "vue/multi-word-component-names": "off" } });Options
| Option | Default | Effect |
| ------ | ------- | ------ |
| zod | "none" | "full" = ban .extend()/.merge()/.passthrough() + enforce import * as z; "import-style" = import style only; "none" = off |
| console | "error" | "error" bans all console.*; an array is the allow-list (e.g. ["warn", "error"]) |
| trpcGuard | false | Ban publicProcedure in server/trpc/routers/** |
| prismaImportGuard | false | Ban ~~/prisma/ client imports on the app side |
| typeDefStyle | "interface" | ts/consistent-type-definitions |
| autoImportPaths | ["utils/", "composables/", "~~/shared/types/"] | Paths banned from direct import (Nuxt auto-imports) |
| autoImportTypeOnly | [] | Subset of autoImportPaths where import type { ... } stays allowed (value imports still banned) |
| ignoresTypeAware | ["prisma.config.ts", "eslint.config.ts", "streamctl.config.ts", ".streamctl/**/*.ts"] | Files excluded from antfu's type-aware program (only relevant when type-aware). Covers both config locations, since .streamctl/config.ts stays supported after the root default lands |
| testFilePattern | ["**/*.{test,spec}.{ts,tsx}", "**/*.stories.{ts,tsx}"] | Test/spec/story globs where the process.env + auto-import bans are relaxed |
Rare per-repo rules belong in your local .append(...), not in the option surface.
Rule IDs & inline suppression
The custom bans map to these ESLint rule IDs. Use the exact ID in an
// eslint-disable-next-line ... directive to suppress one line:
| Ban | Rule ID |
| --- | ------- |
| Direct process.env access, as a member expression (process.env.X, const { env } = process) | no-restricted-properties |
| ANY import or re-export of node:process or process that can reach env (import { env }, import { env as e }, import * as proc, import proc from "node:process", export { env } from "node:process", export * from "node:process") | ts/no-restricted-imports |
| Auto-import path imports (autoImportPaths) | ts/no-restricted-imports |
| App-side Prisma client import (prismaImportGuard) | ts/no-restricted-imports |
| Zod named-z import style (zod: "import-style"/"full") | no-restricted-syntax |
| Zod .extend()/.merge()/.passthrough() (zod: "full") | no-restricted-syntax |
| publicProcedure in routers (trpcGuard) | no-restricted-syntax |
"Re-export" is not a figure of speech: a barrel file doing export * from "node:process"
reports, as do export { env } from "node:process" and export { default as p } from
"node:process". The one shape that does not report is a bare side-effect import with
no bindings, import "node:process" -- it cannot reach env, so there is nothing to ban.
The process.env ban is enforced by two rules, and which one reports depends on the
shape, so suppressing the wrong one fails twice over: the directive does not suppress
anything, and ESLint additionally warns that it was unused.
// eslint-disable-next-line no-restricted-properties
import { env } from 'node:process'
1:1 warning Unused eslint-disable directive (no problems were reported from 'no-restricted-properties')
2:10 error 'env' import from 'node:process' is restricted... ts/no-restricted-importsRequirements
eslint is a peer dependency (^10.5.0), so consumers of /eslint must supply ESLint
themselves. The peer range matches the version baseline, so streamctl version-sync
keeps a repo's ESLint in step with what this package is tested against.
Flat config only. To load a TypeScript eslint.config.ts on Node, consumers add
jiti (>= 2.2.0) as a devDependency: ESLint has no native TS loader, and ESLint 10
rejects jiti below 2.2.0. Writing eslint.config.mjs instead drops the jiti requirement
entirely, as does running on Deno or Bun, which import TypeScript directly.
Type-aware linting prerequisite
Type-aware linting is gated behind LINT_TYPEAWARE=true (off by default), so this only
matters on the opt-in / CI path. When type-aware lint is on, the Prisma client and Nuxt types must be
generated before lint runs, otherwise type-aware rules fail on missing generated types. This is
satisfied by ordering, not a preflight check:
- the managed
ci.ymlrunsprisma generate(andnuxi prepare) beforepnpm lint, and scripts.postinstall: "nuxt prepare"regenerates Nuxt types on install.
So a standard pnpm install + the managed CI ordering covers the prerequisite; no separate check is run.
Prisma factory
@sidebase/base-config/prisma exports buildPrismaConfig({ views?, typedSql? }),
which returns a Prisma 6.19 config object you wrap in defineConfig:
// prisma.config.ts
import { defineConfig } from "prisma/config";
import { buildPrismaConfig } from "@sidebase/base-config/prisma";
export default defineConfig(buildPrismaConfig({ views: true, typedSql: true }));It reads DATABASE_URL / DIRECT_DATABASE_URL / SHADOW_DATABASE_URL from the
environment (Pattern B): the schema engine gets a pgbouncer-free direct
connection: DIRECT_DATABASE_URL if set, otherwise DATABASE_URL with
pooler-only query params (pgbouncer, connection_limit, and so on) stripped via ufo.
SHADOW_DATABASE_URL is wired only when present; with no database URL the engine
block is omitted so the schema's own datasource applies.
| Option | Default | Effect |
| ------ | ------- | ------ |
| views | false | Configure the views feature (prisma/views) |
| typedSql | false | Configure the typedSql preview feature (prisma/sql) |
applyPrismaDevEnv
The same subpath exports applyPrismaDevEnv(env?), the companion for local development.
It seeds DIRECT_DATABASE_URL, DATABASE_URL, and SHADOW_DATABASE_URL with localhost
defaults, but only when they are unset, so a real .env or shell value always wins. It
writes to the environment record you pass and defaults to process.env, which is a
deliberate side effect.
You need it because once a prisma.config.ts exists, Prisma stops auto-loading .env.
A schema that reads env("DATABASE_URL") then fails with no environment at all. Call it
in the config file, after loading dotenv, and the Prisma CLI runs against local postgres
with zero setup:
// prisma.config.ts
import "dotenv/config";
import { defineConfig } from "prisma/config";
import { applyPrismaDevEnv, buildPrismaConfig } from "@sidebase/base-config/prisma";
applyPrismaDevEnv();
export default defineConfig(buildPrismaConfig());buildPrismaConfig alone is enough wherever the connection variables are already set,
which is every deployed environment and CI. Add applyPrismaDevEnv when you want
prisma migrate dev and friends to work on a fresh checkout without a .env file. The
defaults are direct = local postgres, pooled = the direct URL plus pgbouncer=1, and
shadow = the direct URL on a /prisma-shadow database.
The URL helpers never log a connection string and never put one in an error message, so a password cannot reach your terminal or CI output through them. Keep that property if you wrap them.
prisma / @prisma/client are peer dependencies (^6.19).
Prisma 7 is out of scope for now. v7 changes the config shape (
env()helper,directUrlbecomesurl, nopackage.jsonprisma block); abuildPrismaConfigv7 variant is a deliberate later bump.
Supply-chain cooldown (pnpm-workspace.yaml)
The base preset fully owns pnpm-workspace.yaml, pnpm's settings file. It ships two
supply-chain controls, both on by default for every repo:
packages: []
minimumReleaseAge: 10080 # 7 days, in minutes
minimumReleaseAgeExclude:
- "@sidebase/*"
onlyBuiltDependencies: # the postinstall-script allowlist
- "@prisma/client"
- "esbuild"
- "prisma"minimumReleaseAge refuses to resolve a version until it has been published for
7 days, so a compromised release has time to be caught and yanked before the fleet
installs it. It needs pnpm 10.16 or newer and gates fresh resolution only, so
pnpm install --frozen-lockfile replays the lockfile untouched and CI is unaffected.
The pnpm baseline shipped to consumers is 10.28.1 (presets/*/preset.json). This repo's
own packageManager pin tracks separately and is usually newer; the two are independent
by design, not drift.
Exclusions match package names, not dependency trees: an excluded package's own
dependencies still face the cooldown, and pnpm resolves the newest eligible older
version in range. Only when nothing in range is old enough does resolution fail, with
a misleading ERR_PNPM_NO_MATCHING_VERSION (pnpm#9998).
@sidebase/* is exempt: the first-party scope covering both this payload and the
streamctl CLI, published by the org itself, so a payload release reaches the fleet
the same day instead of waiting out its own cooldown.
packages: [] is load-bearing. pnpm defaults it to ** whenever a
pnpm-workspace.yaml exists, which would silently promote every nested package.json
(test fixtures, examples) to a workspace project and break --frozen-lockfile.
onlyBuiltDependencies is the install-script allowlist. Everything else installs
with its lifecycle scripts blocked. Add extras through the config, not through
pnpm approve-builds (which writes to the managed file and is reverted on the next
sync):
export default {
// ...
security: { minimumReleaseAge: "10080" }, // minutes; "0" disables the cooldown
pnpm: { onlyBuiltDependencies: ["sharp"] }, // on top of the baked-in baseline
};| Option | Default | Effect |
| ------ | ------- | ------ |
| security.minimumReleaseAge | "10080" | Minutes a release must age before pnpm installs it. A string, because the manifest's configKeys has no number type, same as the versions.* pins. |
| pnpm.onlyBuiltDependencies | [] | Extra packages allowed to run install scripts, appended below the baked-in baseline |
Because the file is full-owned, a repo that already has a pnpm-workspace.yaml
raises a one-time adoption conflict on the first sync; reconcile it with
streamctl sync --interactive.
Adopting it discards your existing keys. The rendered file is the payload's, so an
existing onlyBuiltDependencies allowlist and ignoredBuiltDependencies are both replaced
by the baked-in baseline, and nothing warns about it. Most affected packages ship a
prebuilt native binary and keep working either way, so the practical breakage is limited to
architectures with no prebuild and to postinstalls doing essential non-native work. Capture
the old list before you adopt and re-add it via pnpm.onlyBuiltDependencies above. A repo that genuinely needs its own packages: list
(a real monorepo) or its own exclude list should opt the file out entirely instead:
files: { "pnpm-workspace.yaml": "off" },Dockerfile knobs
The nuxt-app preset ships a managed Dockerfile. Beyond docker.aptPackages
(an openssl-plus allowlist), six docker.* knobs inject raw text at fixed
positions so every fleet repo can express its own build without opting the file
out:
| Knob | Position | Default |
| ---- | -------- | ------- |
| docker.preInstall | build stage, before pnpm install | "" |
| docker.buildArgs | build stage, before COPY . . | "" |
| docker.buildSteps | the build-command block | nuxi prepare / prisma generate / run build |
| docker.finalStage | final stage, before CMD | "" |
| docker.prismaRuntime | final stage | copy the schema + install the migrate deploy CLI |
| docker.startCommand | inside CMD | prisma migrate deploy then the node server |
export default {
// ...
docker: {
preInstall: "COPY ./vendor ./vendor", // vendored dep needed at install time
buildSteps: "RUN pnpm nuxi prepare\nRUN pnpm run build", // a repo with no Prisma
prismaRuntime: "", // drop Prisma entirely
startCommand: "node .output/server/index.mjs",
},
};These six values land verbatim. They are
string, notstring[], so order is preserved (a sorted array would scramble orderedRUNsteps), and no shell-metacharacter check runs on them. The trust level is exactly that offiles: { "Dockerfile": "off" }, which any consumer can already set: whoever can editstreamctl.config.tscan already replace the whole file. TheUSER nodeswitch and the.outputownership stay FIXED outside every knob, so an override cannot re-root the container or drop the runtime user.
Setting a knob replaces its default outright, it does not append. So a docker.buildSteps
override must restate every build command you still want, and docker.prismaRuntime: ""
removes the Prisma runtime block entirely. The three knobs that default to real content
(buildSteps, prismaRuntime, startCommand) are the ones where this matters; the other
three default to "".
prismaRuntime and startCommand are coupled -- override one and you must override the
other. prismaRuntime is what installs the Prisma CLI into the final stage, and the
DEFAULT startCommand is npm exec prisma migrate deploy && node .... Setting only
prismaRuntime: "" therefore produces an image whose CMD invokes a CLI that is no longer
installed, and the container fails to start. The example above overrides both, which is why
it is safe to copy; a single-knob change is not. Drop the migrate deploy half of
startCommand at the same time.
Global installs in docker.preInstall must use npm i -g <tool>, not
pnpm add -g: the image sets no PNPM_HOME, so pnpm's global bin dir is
undefined.
Upgrade-PR workflow
The base preset ships .github/workflows/streamctl-upgrade.yml, a scheduled workflow
that opens a PR when a payload update is available. It is an enabledBy-gated managed
file, off by default: it only lands once a repo opts in, and it is inert until the
required token exists.
Enable it in streamctl.config.ts, then streamctl sync:
export default {
// ...
automation: { upgradePr: true },
// optional: version pins, default to the payload's baseline. `node` feeds
// ci.yml + the Dockerfile; `pnpm` feeds the Dockerfile only (CI pins pnpm
// via package.json#packageManager):
// versions: { node: "24.13.0", pnpm: "10.28.1" },
};Required secrets:
| Secret | Purpose |
| ------ | ------- |
| STREAMCTL_PR_TOKEN | A GitHub App installation token or machine-account PAT used to open the PR. Not the default GITHUB_TOKEN: a PR opened with GITHUB_TOKEN does not trigger pull_request workflows, so the repo's own check gate would never run on the bot PR. |
That is the only one, because the payload is public npm, so the install step needs no registry token. A repo whose other dependencies are private must opt this full-owned workflow out and wire its own auth.
The workflow branches on the CLI exit codes: check exit 4 opens a PR; exit 0 stops;
exit 3 (pre-existing drift) opens a drift issue instead. A clean upgrade (exit 0)
produces a reconcile PR; a conflicted upgrade (exit 2, rolled back) produces a
plan-only PR labelled needs-interactive-upgrade for a human to finish with
streamctl sync --interactive.
The org bot identity is still being decided. Until the runbook lands, leave the workflow disabled. Design only; nothing is enabled anywhere.
Action pinning policy
Every action in every managed workflow and CI job fragment is pinned by full commit
SHA (with the version tag in a trailing comment), since moving tags can be re-pointed, so
tags are never trusted, secrets or not. test/workflow-pins.test.ts sweeps all preset
templates and fails on any uses: that is not a 40-hex SHA.
The shipped pins track actions/checkout v6, actions/setup-node v6, and
pnpm/action-setup v6. These majors run on the node24 action runtime, which needs a
GitHub Actions runner >= v2.327.1. GitHub-hosted ubuntu-latest is far past that; only
a repo that adds its own self-hosted runner needs to keep it above that floor.
