Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .changeset/template-test-lint-strategy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
---
'seamless-templates': minor
---

Give every template a working test, lint, and format setup out of the box.

All four starters (React Vite, React OAuth, Express, Fastify) now ship Vitest with tests that pass
on a fresh `npm install`, Prettier alongside the existing ESLint config, and the same script names:
`typecheck`, `lint`, `lint:fix`, `format`, `format:check`, `test`, `test:watch`, `test:coverage`,
and a `check` that runs the whole gate in one command.

The tests cover the configuration and startup logic that decides whether a scaffolded project runs
at all, and need no database, auth server, or network:

- API starters: connection-string resolution (including `sslmode` and credential escaping) and the
boot-time environment check, including the placeholders `seamless init` writes into a managed
`DATABASE_URL`.
- Web starters: API origin resolution across the container-injected config and `VITE_API_URL`, URL
joining and error handling in `apiFetch`, and a component test. The OAuth starter also covers its
callback route with the SDK and router stubbed.

Also in this change:

- ESLint now covers the whole project in the API starters rather than `src` only, and uses Node
globals instead of browser globals. That surfaced an unused catch binding in
`scripts/runMigrations.js`, now fixed.
- `eslint-config-prettier` is wired in so lint and format never disagree about the same line.
- The API starters build with a `tsconfig.build.json` that keeps tests out of `dist/`, while
`npm run typecheck` still checks them.
- CI runs typecheck, lint, format check, tests, and build for every template on pull requests.
24 changes: 20 additions & 4 deletions .github/workflows/validate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,15 +32,21 @@ jobs:
id: matrix
run: echo "matrix=$(node scripts/validate-templates.mjs --matrix)" >> "$GITHUB_OUTPUT"

build-smoke:
name: Build ${{ matrix.id }}
# Every step is --if-present so a template that has not adopted one of these yet
# is skipped rather than failed. A template that does declare the script has to
# pass it: this is the same gate `npm run check` gives a user locally.
template-checks:
name: Check ${{ matrix.id }}
needs: validate
if: needs.validate.outputs.matrix != '[]'
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include: ${{ fromJSON(needs.validate.outputs.matrix) }}
defaults:
run:
working-directory: ${{ matrix.path }}
steps:
- name: Checkout repo
uses: actions/checkout@v4
Expand All @@ -52,8 +58,18 @@ jobs:

- name: Install
run: npm install
working-directory: ${{ matrix.path }}

- name: Typecheck
run: npm run typecheck --if-present

- name: Lint
run: npm run lint --if-present

- name: Format check
run: npm run format:check --if-present

- name: Test
run: npm run test --if-present

- name: Build
run: npm run build --if-present
working-directory: ${{ matrix.path }}
13 changes: 10 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,8 +76,10 @@ validation step skips directory checks for those entries.

## CI

- On pull requests, CI runs `npm run validate`, then installs and builds every buildable template
(a `package.json` with a `build` script) as a smoke test. Keep templates green.
- On pull requests, CI runs `npm run validate`, then installs every buildable template and runs its
`typecheck`, `lint`, `format:check`, `test`, and `build` scripts. Each step is `--if-present`, so
a template that has not adopted one is skipped rather than failed, but a declared script has to
pass. Keep templates green.
- On a push to `main`, the release workflow opens or updates a "version packages" PR via Changesets;
merging it bumps the version, creates the tag the CLI pins, and publishes a GitHub Release for
that tag with notes drawn from `CHANGELOG.md`.
Expand All @@ -93,9 +95,14 @@ validation step skips directory checks for those entries.
hand-edit the version or `CHANGELOG.md`.
- Keep template projects minimal and idiomatic for their framework. They are the first thing a new
user sees, so they should run cleanly right after the CLI completes.
- Every template declares the same verification scripts: `typecheck`, `lint`, `lint:fix`, `format`,
`format:check`, `test`, `test:watch`, `test:coverage`, and a `check` that runs the gate in one
command. Tests are Vitest, colocated as `*.test.ts` / `*.test.tsx`, and must pass without a
database, an auth server, or network access. A new template adopts the same set.

## Before You Finish A Change

- Run `npm run validate`.
- If you added or touched a template, install and build it locally the way CI will.
- If you added or touched a template, install it and run `npm run check` in it locally, the way CI
will.
- Add a changeset for any user-facing change.
16 changes: 15 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,21 @@ The CLI computes the shared values and resolves the `{{...}}` placeholders in `e
4. Add an entry to `registry.json`.
5. Run `npm run validate` and open a pull request.

CI validates the registry and every manifest, then installs and builds each template to confirm it works before it ships.
CI validates the registry and every manifest, then installs each template and runs its typecheck, lint, format check, tests, and build to confirm it works before it ships.

### Checks every template ships

A scaffolded project is expected to be verifiable on the first `npm install`, so each template declares the same script names. CI runs them with `--if-present`, and a user gets the whole set locally with `npm run check`.

| Script | Purpose |
| --- | --- |
| `typecheck` | TypeScript with no emit |
| `lint` | ESLint flat config over the project |
| `format:check` | Prettier, with `eslint-config-prettier` keeping the two from disagreeing |
| `test` | Vitest, no database or network needed |
| `check` | All of the above in one command |

Tests sit next to the code they cover as `*.test.ts` / `*.test.tsx`. They are meant to be a starting point a user extends, not exhaustive coverage: they cover the configuration and startup logic that decides whether a fresh scaffold runs at all.

---

Expand Down
5 changes: 5 additions & 0 deletions templates/api/express/.prettierignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
dist
coverage
logs
node_modules
package-lock.json
5 changes: 5 additions & 0 deletions templates/api/express/.prettierrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"semi": true,
"singleQuote": false,
"trailingComma": "all"
}
99 changes: 67 additions & 32 deletions templates/api/express/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@ current user from the session, and protects an example route by role.
problem at once.
- Sequelize + Postgres with migrations that run automatically on boot.
- Docker Compose for a local Postgres plus the API.
- ESLint (flat config) and a Node 24 / ESM TypeScript setup.
- ESLint (flat config), Prettier, and a Node 24 / ESM TypeScript setup.
- Vitest with unit tests covering the database and environment resolution, and a single
`npm run check` gate that runs typecheck, lint, format, and tests together.

## Environment variables

Expand All @@ -33,20 +35,20 @@ The committed contract lives in [.env.example](.env.example). Copy it before run
cp .env.example .env
```

| Variable | Purpose |
| --- | --- |
| `NODE_ENV` | `development` enables the dev messaging handlers that log OTP and magic-link tokens locally; set to `production` before deploying |
| `AUTH_SERVER_URL` | URL of your Seamless Auth server |
| `SERVE_ADMIN_CONSOLE` | `true` to serve the admin dashboard from this API at `/console`; `false` when it is hosted elsewhere |
| `UI_ORIGINS` | Comma-separated web origins allowed by CORS |
| `COOKIE_DOMAIN` | Optional cookie domain for production, for example `.example.com` |
| `COOKIE_SIGNING_KEY` | Secret used to sign API-generated cookies |
| `API_SERVICE_TOKEN` | Service token shared with Seamless Auth (from the portal) |
| `JWKS_KID` | JWKS key id the auth server signs with |
| `DATABASE_URL` | Full Postgres connection string. Wins over the `DB_*` values when set |
| `DB_HOST`, `DB_PORT`, `DB_USER`, `DB_PASSWORD`, `DB_NAME` | Postgres connection, used when `DATABASE_URL` is empty |
| `DB_SSL_REJECT_UNAUTHORIZED` | Set to `false` only for a certificate that does not chain to a public CA |
| `DB_LOGGING` | Set to `true` to log SQL in development |
| Variable | Purpose |
| --------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| `NODE_ENV` | `development` enables the dev messaging handlers that log OTP and magic-link tokens locally; set to `production` before deploying |
| `AUTH_SERVER_URL` | URL of your Seamless Auth server |
| `SERVE_ADMIN_CONSOLE` | `true` to serve the admin dashboard from this API at `/console`; `false` when it is hosted elsewhere |
| `UI_ORIGINS` | Comma-separated web origins allowed by CORS |
| `COOKIE_DOMAIN` | Optional cookie domain for production, for example `.example.com` |
| `COOKIE_SIGNING_KEY` | Secret used to sign API-generated cookies |
| `API_SERVICE_TOKEN` | Service token shared with Seamless Auth (from the portal) |
| `JWKS_KID` | JWKS key id the auth server signs with |
| `DATABASE_URL` | Full Postgres connection string. Wins over the `DB_*` values when set |
| `DB_HOST`, `DB_PORT`, `DB_USER`, `DB_PASSWORD`, `DB_NAME` | Postgres connection, used when `DATABASE_URL` is empty |
| `DB_SSL_REJECT_UNAUTHORIZED` | Set to `false` only for a certificate that does not chain to a public CA |
| `DB_LOGGING` | Set to `true` to log SQL in development |

`assertEnvironment` in [src/lib/env.ts](src/lib/env.ts) runs before the server is built. The auth
options are read once at startup, so a missing value used to surface as a 500 on the first
Expand All @@ -67,11 +69,11 @@ When you scaffold with `seamless init` against a managed instance, the CLI fills
into `.env` from your logged-in profile, so the API points at the managed auth server instead of
localhost:

| `.env` key | Filled from |
| --- | --- |
| `AUTH_SERVER_URL` | `{{authServerUrl}}` (your managed instance URL) |
| `API_SERVICE_TOKEN` | `{{apiToken}}` (portal-issued service token) |
| `JWKS_KID` | `{{jwksKid}}` |
| `.env` key | Filled from |
| -------------------- | ------------------------------------------------ |
| `AUTH_SERVER_URL` | `{{authServerUrl}}` (your managed instance URL) |
| `API_SERVICE_TOKEN` | `{{apiToken}}` (portal-issued service token) |
| `JWKS_KID` | `{{jwksKid}}` |
| `COOKIE_SIGNING_KEY` | `{{secret:32}}` (freshly generated per scaffold) |

The database and origin variables keep their `.env.example` defaults; adjust them for your
Expand Down Expand Up @@ -146,12 +148,12 @@ npm run db:create # create the database if it is missing

## API endpoints

| Method | Route | Description |
| --- | --- | --- |
| GET | `/` | Health check |
| ALL | `/auth/*` | Seamless Auth server-mode adapter |
| GET | `/console/*` | Seamless admin dashboard, reverse-proxied from the auth server (only when `SERVE_ADMIN_CONSOLE=true`) |
| GET | `/beta_users` | Example route, restricted to the `beta_user` role |
| Method | Route | Description |
| ------ | ------------- | ----------------------------------------------------------------------------------------------------- |
| GET | `/` | Health check |
| ALL | `/auth/*` | Seamless Auth server-mode adapter |
| GET | `/console/*` | Seamless admin dashboard, reverse-proxied from the auth server (only when `SERVE_ADMIN_CONSOLE=true`) |
| GET | `/beta_users` | Example route, restricted to the `beta_user` role |

## Admin console

Expand All @@ -175,15 +177,48 @@ Without it, sign-in and step-up both fail at the finish step even though the
challenge starts normally. The RP ID (`RPID`) ignores the port, so it does not
need to change.

## Testing, linting, and formatting

Tests run on [Vitest](https://vitest.dev). Test files sit next to the code they cover as
`*.test.ts`, so `src/lib/env.test.ts` covers `src/lib/env.ts`. There is no config file: the Vitest
defaults already pick those up and run them in a Node environment.

The shipped tests cover the two pieces of startup logic that decide whether the API can run at all,
and they need neither a database nor a running auth server:

- [src/lib/databaseUrl.test.ts](src/lib/databaseUrl.test.ts): which connection string wins, how
credentials are escaped, and when `sslmode=require` becomes a driver option.
- [src/lib/env.test.ts](src/lib/env.test.ts): the boot-time check, including the placeholders
`seamless init` writes into a managed `DATABASE_URL`.

Add your own alongside them. Anything that talks to Postgres or to the auth server belongs in an
integration test you run against the Docker Compose stack, not here.

ESLint uses the flat config in [eslint.config.ts](eslint.config.ts) and covers the whole project,
including `models/`, `migrations/`, and `scripts/`. Prettier owns formatting, and
`eslint-config-prettier` switches off the ESLint rules that would fight it, so the two never
disagree about the same line.

`npm run build` compiles with [tsconfig.build.json](tsconfig.build.json), which excludes `*.test.ts`
so tests stay out of `dist/`. `npm run typecheck` uses the full `tsconfig.json` and does check them.

## Scripts

```bash
npm run dev # run migrations, then start with hot reload
npm run build # compile TypeScript to dist/
npm run start # run migrations, then start the compiled build
npm run lint # eslint src
npm run migrate # run pending migrations
npm run db:create # create the database if missing
npm run dev # run migrations, then start with hot reload
npm run build # compile TypeScript to dist/
npm run start # run migrations, then start the compiled build
npm run check # typecheck, lint, format check, and tests
npm run typecheck # tsc --noEmit, tests included
npm run lint # eslint
npm run lint:fix # eslint --fix
npm run format # prettier --write
npm run format:check # prettier --check
npm test # vitest run
npm run test:watch # vitest in watch mode
npm run test:coverage # vitest with a v8 coverage report
npm run migrate # run pending migrations
npm run db:create # create the database if missing
```

## License
Expand Down
6 changes: 5 additions & 1 deletion templates/api/express/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,11 @@ services:
# start. See docker-library/postgres#1259.
- pgdata:/var/lib/postgresql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-myuser} -d ${DB_NAME:-seamless_api}"]
test:
[
"CMD-SHELL",
"pg_isready -U ${DB_USER:-myuser} -d ${DB_NAME:-seamless_api}",
]
interval: 5s
timeout: 5s
retries: 10
Expand Down
8 changes: 6 additions & 2 deletions templates/api/express/eslint.config.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
import js from "@eslint/js";
import globals from "globals";
import tseslint from "typescript-eslint";
import { defineConfig } from "eslint/config";
import prettier from "eslint-config-prettier/flat";
import { defineConfig, globalIgnores } from "eslint/config";

export default defineConfig([
globalIgnores(["dist", "coverage", "node_modules"]),
{
files: ["**/*.{js,mjs,cjs,ts,mts,cts}"],
plugins: { js },
extends: ["js/recommended"],
languageOptions: { globals: globals.browser },
languageOptions: { globals: globals.node },
},
tseslint.configs.recommended,
// Last, so formatting rules that would fight Prettier are switched off.
prettier,
]);
Loading
Loading