This repository is the Seamless Auth command-line tool (published as seamless-cli, invoked as
seamless or npx seamless-cli). It does two things:
- Scaffold a working Seamless Auth project (
seamless init): generates a React frontend, an Express adapter, the auth server, a Docker Compose file, and config. - Verify the whole auth surface (
seamless verify): a cross-package conformance harness that runs an api / adapter / react matrix against the ecosystem.
Use this file as the fast path. The verify harness has its own moving parts under verify/ (a Docker Compose stack plus a Playwright harness).
These rules apply to every repository in the fells-code org. Repo-specific guidance may extend them but must not contradict them.
- Commit and open PRs solely under the repository owner's identity. Never commit under an agent or assistant identity.
- Never attribute work to an AI assistant: no
Co-Authored-By: Claude(or any assistant) trailers, no "Generated with" / "Created with Claude" notes, and no assistant branding or emoji anywhere in commit messages, PR or issue titles and descriptions, changesets, code comments, or docs.
- Comment only when the code genuinely needs explaining: a non-obvious reason, a gotcha, or an invariant. Never narrate what the code plainly does.
- Every
TODO/FIXMEmust reference a ticket, e.g.// TODO(#123): .... Do not leave a bare TODO. If no ticket exists, create one first.
- Conventional Commits (
feat:,fix:,chore:,docs:,ci:,test:). - Descriptive branch names (
feat/...,fix/...); never aclaude/or other tool-generated prefix.
- No em dashes in commit messages, code comments, PR or issue text, changesets, or docs. Use a comma, parentheses, or a separate sentence.
- All code quality checks must pass before you open a PR or call the work done. Run them and report the real output; do not open a PR while any check is failing.
- Commands:
npm run build(runstsc, which type-checks) andnpm test(vitest run);npm run coverageenforces the coverage thresholds. There is no separate lint/format tooling configured yet. Never claim a change works without running these. - Match the surrounding code's style, naming, and comment density.
- Install dependencies:
npm install - Build (type-check and emit):
npm run build(tsc, output indist/) - Run from source:
npm run dev -- <command>(tsx); or after building,node dist/index.js <command> - Commands:
init [name],templates,check,verify [flags],apps, and the instance-management commandsprofile,login,whoami,logout,sessions,config,users,org(all dispatched fromsrc/index.ts)
The entry point is src/index.ts, which dispatches to a command module in
src/commands/.
- init (src/commands/init.ts) scaffolds a project, driven by
src/prompts/. The web and api starters come from the registry-driven template source (src/core/templates.ts): it readsregistry.jsonfrom thefells-code/seamless-templatesmonorepo (pinned bySEAMLESS_TEMPLATES_COMMIT, the commitSEAMLESS_TEMPLATES_REFresolved to, in src/core/images.ts), downloads the selected templates, and applies each template'stemplate.jsonenv contract. The auth, docker, config, and root agent guide (AGENTS.md,CLAUDE.md) pieces are generated locally insrc/generators/*. Override the template source for development withSEAMLESS_TEMPLATES_DIR(a local checkout) orSEAMLESS_TEMPLATES_REF(a different ref, used as given).- Every archive entry is resolved through
containedPath(src/core/archive.ts), which fails the scaffold on a../that would write outside the project. The admin dashboard download (--admin=source) uses it too. - A
--<id>or--<alias>flag (e.g.seamless init --react-oauth,seamless init --oauth) preselects the matching template and skips that layer's prompt. Both spellings live in the registry, so no per-flag code.resolveTemplateAliasesruns inrunCLIbefore the project directory is created and before the non-empty-directory confirmation, so an unknown flag can never route through a destructive prompt on its way to an error. --yesruns the whole thing without prompting: every question has a flag (--web,--api,--email,--auth,--admin) and anything unspecified falls back to the option the prompt marks "(recommended)".--yesis never enough for a destructive step: overwriting a non-empty directory and rotating an existing service token both require--force, and choosing between a managed application and a local stack requires--appor--local. Flag parsing lives inparseInitArgs(src/index.ts); everything it produces is validated inrunCLIbefore a directory is created.- Every prompt is fronted by
requireInteractive(src/core/tty.ts), so a run without a TTY on stdin fails naming the flag that answers the question instead of rendering a prompt nobody can answer. This holds across every command, not justinit. When adding a prompt anywhere, guard it the same way. - templates (src/commands/templates.ts) lists the registry
(
seamless templates list [--json]) so those ids and flags are discoverable without a checkout. It reads the same sourceinitdoes and needs no login. - A template can declare
setup.oauthin itstemplate.jsonto trigger the OAuth provider prompts (src/prompts/oauthSetup.ts, catalog in src/core/oauthProviders.ts). The chosen providers are wired into the auth server env (OAUTH_PROVIDERS, per-provider*_CLIENT_SECRET, theoauthlogin method) bybuildAuthEnvin src/generators/docker/docker.ts.
- Every archive entry is resolved through
- destructive confirmations go through
confirmDestructive(src/core/confirmAction.ts), which answers itself when--forceis set and otherwise asks.--forceis the standing spelling for "do it without asking";hasForceFlagalso accepts--yesand-y, becauseconfig oauth-providers remove --yesshipped before the convention existed.--yesmeans something narrower oninit(answer the ordinary questions, never the destructive ones), so do not add--yesalone to a destructive step. Cancelling a confirmation reads as declining, not as an error. - check health-checks a running stack (local or managed).
- verify (src/commands/verify.ts) runs the conformance harness (below).
- instance management —
profile(targets, plusprofile login),logout/whoami,sessions,config(system config + OAuth providers),users, andorgall talk to a running instance and are authenticated by the stored session. - help —
seamless --help,seamless <command> -h/--help, andseamless help <command>all render from the single registry in src/commands/helpTopics.ts (src/commands/help.ts does the formatting, andCOMMANDSthere is also the dispatcher's known-command list). Document a new command or flag in that registry, not in the help template.src/index.tsanswers the help flag before a command parses its own args. - portal —
loginsigns in to the Seamless portal, a separate account from any instance profile. Its session lives beside the profile map inconfig.jsonand is the only oneinituses to connect a managed application (src/core/authClient.ts exposescreatePortalClientfor it).
seamless verify stands up the ecosystem with Docker Compose and runs a Playwright matrix, then
prints a flow x layer pass/fail grid (plus JUnit and HTML reports).
- verify/docker-compose.verify.yml: postgres, the auth API, and
both adapters, plus the React starter behind the
reactcompose profile. The mock OIDC provider runs in-process inglobal-setup(it is not a container). - verify/adapter-app (port 3000) and
verify/adapter-fastify-app (port 3001): minimal adopter backends on
@seamless-auth/expressand@seamless-auth/fastify, each with a capture transport so the harness can read OTP / magic-link codes the adapter would otherwise strip. They are deliberately twins: the same routes on the same env contract, so a spec cannot tell which one answered and any difference in behaviour is a real one. Keep them in step when either changes. - verify/harness: the Playwright projects (
api,adapter,adapter-fastify,react,react-dev,nextjs,nextjs-dev),lib/helpers,mock-oidc.ts,global-setup.ts, andlib/matrixReporter.ts(the printed grid). It has its ownnode_modulesand browsers.- The two adapter projects run the same specs from
./adapter; only theadapterUrlproject option differs (lib/fixtures.ts). Adding an adopter framework is a project entry plus a compose service, never a copy of the suite. Because they share a directory,matrixReportertakes the layer from the Playwright project name, not the spec's path.
- The two adapter projects run the same specs from
Modes and sibling repos:
--localbuilds the@seamless-auth/*packages from source (pre-publish contract testing); the default uses the published packages.--devadds a development-server pass after each browser template's production pass: thereact-dev(vite) andnextjs-dev(next dev) compose services, on the same :5173, driven by thereact-dev/nextjs-devPlaywright projects running the same specs. That is the only pass that runs under React Strict Mode (every effect twice), which is where non-idempotent effects break (fells-code/seamless-auth-react#161: a single-use magic link verified twice). CI passes--devon every pull request (thedevinput ofverify-conformance.yml, default true): the bugs it catches are introduced in SDK and template PRs, so a schedule would find them only after they merged.- Full-stack templates (
kind: fullstack) run from their own Dockerfile (runtimetarget, anddevfor--dev) as thenextjscompose service, and are driven by./nextjsspecs written against the starter's own screens. The manifest'sverify.project(falling back to the registryframework) picks the specs; a full-stack template with none is announced and skipped. The starter serves/authitself, so it reads codes from its own capture readout (/api/verify-capture/<recipient>, on only withSEAMLESS_VERIFY_CAPTURE=true) rather than the adapter's. It builds from its own lockfile, so it runs its pinned SDKs even with--local. - Chromium resolves
localhostto 127.0.0.1 (--host-resolver-rules), because Docker publishes on IPv4 and a developer's own server on[::1]:5173would otherwise answer. The page origin has to staylocalhost(passkey RP ID, allowed origins). The harness's own requests take their URLs fromSEAMLESS_API_URL,SEAMLESS_ADAPTER_URL, andSEAMLESS_FASTIFY_ADAPTER_URL, whichverifynow leaves alone when set (for examplehttp://127.0.0.1:3000when another process holdslocalhost:3000on IPv6). - The browser layer runs once per web template, not once.
verifyreads the templates registry and drives everykind: webentry that is notcoming-soon, each served at :5173 in turn and scoped to the flow tags itstemplate.jsondeclares inverify.flows(the whole suite when it declares none). Soreact-oauthruns only@oauth, andreact-viteruns everything. - The sibling repos are resolved relative to this repo, overridable with
SEAMLESS_API_DIR,SEAMLESS_SERVER_DIR,SEAMLESS_REACT_SDK_DIR(the React SDK), andSEAMLESS_TEMPLATES_DIR(the templates checkout the web templates come from, defaulting to../seamless-templates).SEAMLESS_REACT_DIRis the narrower override: it names a single template directory and runs that one instead of the registry's set. - Useful flags:
--api-only,--no-react,--dev,--filter=<flow>(the=form; a space-separated--filter <flow>is not parsed),--keep-up.
- src/commands: one file per CLI command
- src/generators: locally generated scaffolding (auth, docker, config)
- src/core: shared helpers (templates, exec, env, fetch, secrets, paths, package manager, output)
- src/prompts: interactive setup prompts (
@clack/prompts) - verify: the conformance harness (shipped with the package)
Templates are not in this repo — they live in the seamless-templates monorepo
(SEAMLESS_TEMPLATES_REPO) and are fetched at scaffold time.
- TypeScript, ESM (
"type": "module"). Local imports use.jsextensions (NodeNext resolution). - Auth API responses are parsed with
@seamless-auth/typesschemas (#144), not probed field by field. The CLI talks to instances older and newer than its types, so object schemas are.loose()d: a key the schema does not know is kept (and reaches--jsonoutput) rather than stripped, or rejected by a strict schema such asApiUserSchema. A response missing a required field fails with the path that tripped it. See src/core/admin.ts. - Shared test fixtures live in
src/**/*.fixtures.ts(for example src/core/admin.fixtures.ts). That suffix is excluded from the build and from coverage; a*.test.tshelper would instead run as an empty test file. - Commit, comment, TODO, and attribution rules live in Working Standards above.
- Releases use Changesets. A user-facing change needs a changeset (
npm run changeset). A push tomainopens a "version packages" PR that bumps the version and writesCHANGELOG.md; merging that PR publishes to npm. Do not hand-edit the version orCHANGELOG.md. - npm publish token. The release workflow publishes with the
NPM_TOKENrepo secret. It must be a classic Automation token (full publish rights, bypasses 2FA) owned by an account with publish access toseamless-cli; a granular token restricted to a package allowlist cannot create or publish it and the registry returns a confusingE404on thePUT. - Templates ref bump. Shipping a change that depends on a new templates release is a two-step,
cross-repo dance: release
seamless-templatesfirst, then bumpSEAMLESS_TEMPLATES_REF(src/core/images.ts) to that tag andSEAMLESS_TEMPLATES_COMMITto the commit it points at (git ls-remote https://github.com/fells-code/seamless-templates 'refs/tags/<tag>^{}'). Scaffolds download by the commit, so a moved tag cannot change what they get.npm run check:templates-pin(a CI step) fails when the two disagree. - Coverage badge.
README.mdshows a line-coverage badge (resources/coverage-badge.svg) regenerated locally by a Huskypre-commithook (.husky/pre-commit): it runsnpm run coverage(src/**/*.test.tsonly, so it never sweeps the Playwright specs underverify/), thennpm run coverage:badge(scripts/updateCoverageBadge.mjs) to rewrite the SVG fromcoverage/coverage-summary.json, stages it, and rebuilds. We standardized on the pre-commit hook (matchingseamless-auth-api) rather than a CI staleness check, so the committed badge always reflects the latest local run. If you change coverage, let the hook regenerate the badge; do not hand-edit the SVG.
.github/workflows/scaffold-smoke.yml runs
scripts/scaffold-smoke.sh on every PR: it scaffolds with
init --local --yes --auth=docker --admin=none, brings up db and auth from the
generated compose, then writes a row (and a probe file in the auth server's key
volume), recreates the containers, and reads both back.
It exists because every other job asserts the generated files as strings. This is the only one that hands them to Docker, so it is the only one that can catch a compose file that is well-formed and wrong. Bring-up alone is not enough: a volume mounted where the image does not store data leaves a database that starts, passes its healthcheck, serves queries, and quietly writes to the container layer. The read-back is what catches that.
If you change src/generators/docker/docker.ts, expect
this job to be the one that fails. Run it locally with ./scripts/scaffold-smoke.sh; set
SMOKE_EXTRA_COMPOSE to an override file if you already have something on 5432 or 5312,
since the generated compose pins both the ports and the container names.
- Run
npm run build(the root package's only build step). - If you touched the harness:
cd verify/harness && npx tsc --noEmit, then runseamless verify(--localto exercise local SDK source, or--api-onlyfor a fast pass). - Add a changeset for any user-facing change.
- Sibling-repo branches: every sibling repo (api, server, react SDK, seamless-templates) is checked out
at its default branch (
main) when no explicit*-refis passed to the verify CI workflow. --localneeds SDK dependencies: it builds the server (pnpm) and the React SDK (npm) from source on the host, so those repos must have their dependencies installed first. CI installs them explicitly.- OAuth mock networking: the in-process mock OIDC is reached by the browser and harness via
localhost, but by the API container viahost.docker.internal, so the provider config splits the authorize URL from the token / userinfo URLs. - Adapter OTP limiter: the adapter funnels all OTP through one client IP, so the API's per-IP OTP limiter (10 per 15 minutes, hardcoded) bounds adapter / react OTP traffic. Keep specs off it where possible (for example, magic-link login instead of a second email-OTP round trip).
- Version pins: verify/adapter-app pins
@seamless-auth/express, verify/adapter-fastify-app pins@seamless-auth/fastify, and each web template pins@seamless-auth/react. Bump these when new versions publish. - The four pins in src/core/images.ts are what a scaffold gets, and each
drifts on its own:
SEAMLESS_AUTH_API_VERSION(the auth server image), the admin dashboard image and ref, andSEAMLESS_TEMPLATES_REF(with itsSEAMLESS_TEMPLATES_COMMIT). Check them against the sibling repos' latest tags before a release; nothing fails when they lag, the scaffold just quietly ships an older stack. --auth=localis not pinned: itgit clonesseamless-auth-apiat its default branch (src/generators/auth/auth.ts) while--auth=dockerruns the pinned image, so the two auth modes can scaffold different servers from the same CLI version.- Config keys ahead of the API:
WRITABLE_KEYSin src/core/systemConfig.ts is whatconfig setandconfig applysend, and the instance's patch schema is strict, so adding a key before a released API accepts it makesconfig setfail against every live instance. A@seamless-auth/typesbump often adds such keys first. Every key inSystemConfigPatchSchemamust be in eitherWRITABLE_KEYSorNOT_YET_WRITABLE(with the ticket that tracks it), and a unit test fails when a bump adds one that is in neither. Move a key across once an API release accepts it. - Config reads are not parsed with the config schemas.
SystemConfigSchemaandOAuthProviderConfigSchemacarry defaults, so parsing a read would report keys and settings an older instance never stored, and their value rules would makeconfig getfail on a value the instance accepted. Reads check the shape and are typed with the schemas' input side.