MAXSTACK Docs Quickstart npm GitHub
docs / development.md

Development & releasing

Architecture boundaries #

Dependency direction between workspace members is mechanically enforced. pnpm check:boundaries (also a step in pnpm validate and CI) validates every static import against the policy in scripts/boundaries.config.json:

  • Layering (lowest first): @maxstack/spec, @maxstack/core, @maxstack/ui (foundation — no workspace imports; ui publishes standalone) → @maxstack/features, @maxstack/mcp, @maxstack/spec-derive. A package may only import packages its policy entry lists.
  • Apps (apps/*) may import any package; nothing may import an app.
  • Relative imports may not escape their own package root — import the package by name instead.

Remediating a violation, in order of preference:

  1. Move the code that needs the import down the stack (or the importing code up) so the dependency points the allowed way.
  2. Extract the shared piece into the lower-level package both sides can import.
  3. Only if the layering itself is wrong: change scripts/boundaries.config.json in its own reviewed commit explaining the new direction — never as a drive-by edit to make a feature branch pass.

A braceless if never guards a ;-prefixed statement (the format trap) #

This repo omits semicolons, so a statement beginning with ( or [ carries a leading ;. Never put that form in the body of a braceless if or else:

ts
if (cond) ;(expr)      // biome format rewrites this to:
if (cond);             //   an if guarding an empty statement
;(expr)                //   and an expression that now always runs

The rewrite is valid syntax, formats clean, typechecks and lints. It shipped once and turned a guarded assertion into one that fired on every path, with the test still passing for the wrong reason. Only reading the diff caught it.

pnpm check:guarded-statements (a step in pnpm validate) reads it mechanically instead: it rejects any braceless if/else whose body is an empty statement, in both the source shape and the formatted shape. Brace the body — an intentionally empty branch is {}, which noEmptyBlockStatements then judges on its own merits.

Client-persisted state in SSR components (the hydration trap) #

apps/web server-renders. Any component that branches on client-persisted state (the PreferenceStore behind useStore — theme, density, column visibility, saved queries, banner dismissal) can therefore render one thing on the server and another during hydration.

The trap is useSyncExternalStore's third argument. getServerSnapshot must return what the server rendered, and React calls it on the client during hydration too. Passing the same getSnapshot that reads localStorage looks SSR-safe and guarantees a mismatch for every visitor whose persisted value differs from the fallback. That shipped once and cost two bugs: a zombie cookie banner (SSR DOM stranded with dead handlers) and the banner nagging.

The rules, in order of preference:

  1. Just use useStore. It is hydration-safe: it server-snapshots the fallback and flips to the persisted value right after hydration. Do not hand-roll a mounted flag around it.
  2. If the flip is user-visible and unacceptable (a banner that must not flash for someone who dismissed it), use useHydratedStore(key, fallback)[value, setValue, hydrated] and render nothing until hydrated, or wrap the subtree in <ClientOnly>.
  3. Never pass a browser-reading function as getServerSnapshot in new useSyncExternalStore calls. useSystemTheme in prefs/theme.tsx is the reference: () => 'light', not matchMedia.

Review checklist item: for any component that renders during SSR — does it branch on client-persisted state? If yes, it must reach the value through useStore/useHydratedStore, not through a bespoke store read.

A client-only render() test cannot catch this class: it never exercises getServerSnapshot, which is exactly why the original bug shipped green. The regression tests in packages/ui/src/prefs/prefs-context.hydration.test.tsx drive a real renderToString + hydrateRoot and assert on React's onRecoverableError — copy that shape when testing SSR-sensitive UI, and note that asserting on the final DOM alone is not enough (React recovers from a mismatch and still lands on the right DOM).

Most user-visible defects live in the runtime (apps/web + the packages the build bundles), not in the spec layer — and the only honest way to test a fix is to watch it inside a project generated by an installed CLI. Do not rebuild @maxstack/web and copy build/ over the global maxstack-runtime install: that folk procedure patches one machine, is invisible afterwards, and the old bundle survives in a running dev server's memory anyway.

Link the checkout instead:

shell
maxstack runtime link "$PWD" ~/scratch-project   # from maxstack/ (the dir with apps/web)
maxstack dev ~/scratch-project                   # vite dev server from the checkout — HMR, no build step
maxstack runtime status ~/scratch-project        # which runtime resolves, and why
maxstack runtime unlink ~/scratch-project        # back to the installed runtime

The link is recorded in the project's data dir (gitignored — local to your machine, never inherited from a commit), and resolveRuntime reads it before anything else, so dev, demo, build and deploy all follow it. Every one of them prints a LINKED RUNTIME banner, because a linked runtime is unpublished code and an image built while linked contains it.

Source maps ship with every runtime build (build.sourcemap in apps/web/vite.config.ts, MAXSTACK_NO_SOURCEMAP=1 to opt out). They cost ~2 MB in the published tarball and buy real file/line names in browser devtools and — via --enable-source-maps, which maxstack dev and the Docker image pass — in server stack traces. Don't drop them to shrink the package; the reason they exist is that three shipped defects in a row were un-diagnosable without them .

Releasing to npm: maxstack + maxstack-runtime #

Two packages ship in lockstep versions, staged by one script (apps/maxstack/scripts/stage-npm.ts):

  • maxstack — the CLI. All five first-party @maxstack/* workspace packages are bundled into dist/lib/cli.js (they are never published on their own), plus the scaffold templates/. It depends on maxstack-runtime at an exact-pinned version.
  • maxstack-runtime — the web runtime, so dev/demo/build/deploy work from a bare npm install (no maxstack checkout): a prebuilt react-router build server (build/), the demo seeder bundled to plain node (seed-demo.mjs), and a workspace/ source snapshot that maxstack build vendors from (the same cloneWorkspace the checkout path uses). The CLI picks checkout vs package via src/lib/runtime.ts.

Two npm-packing landmines the staging script handles — keep them in mind if you touch it: npm always strips lockfiles from tarballs (the snapshot ships pnpm-lock.snapshot.yaml, restored on vendor), and it strips/interprets .gitignore/.npmignore/.npmrc (deleted from the snapshot).

npm owner: sys13. (The older standalone maxstack-core / maxstack-web-template 0.x packages are a previous iteration — deprecated, do not reuse.)

How the bundle works #

apps/maxstack/build.mjs runs esbuild with a resolve plugin that inlines anything starting with ., /, or @maxstack/, and leaves everything else (third-party packages + Node builtins) external. So:

  • First-party workspace code → compiled into the one output file.
  • Third-party deps → stay as normal dependencies in package.json, resolved from the user's node_modules at runtime.

Two things that are easy to break — don't:

  • Output path depth. The bundle is emitted to dist/lib/cli.js (two dirs deep) on purpose. src/lib/config.ts computes HUB_ROOT as ../.. from its own location to find templates/; emitting at the same depth keeps that resolving to the package root after install. Do not flatten it to dist/cli.js.
  • Shebang. esbuild preserves the entry file's #!/usr/bin/env node. Do not add an esbuild banner for it — you'll get a duplicate shebang and a syntax error at runtime.

build.mjs also asserts that the .version('x') string in src/cli.ts matches the version in package.json, so the two can't drift.

The dependency set (and why the smoke test is mandatory) #

Runtime dependencies must list every third-party package the bundled code touches. esbuild's first-level metafile only reports direct external imports — it misses deep transitive runtime needs. The known trap: drizzle-orm/pglite's driver eagerly imports @electric-sql/pglite, and the postgres-js path needs postgres — neither shows up as a direct import, but both must be declared or the CLI crashes at load time.

The way to catch these is to pack the tarball and install it in a clean dir that has only the declared deps. Always do this before publishing. Current set:

@anthropic-ai/sdk  @electric-sql/pglite  commander  diff
drizzle-orm  postgres  ts-morph  zod

One install is not enough, and this is the second trap. A dependency that declares a peer range nothing in the tree satisfies is still placed by the first install — npm writes a copy on disk that no dependency edge points at — so the CLI loads it and the smoke test goes green. The next npm install prunes it, because pruning walks edges, and only then does the CLI die at load with Cannot find package …. That is exactly how 0.11.11 shipped: npx maxstack init threw Cannot find package 'drizzle-orm' imported from @better-auth/drizzle-adapter for anyone whose npx cache had been written twice (#348). So the smoke test runs the CLI, reinstalls, and runs it again — in release.yml, in scripts/publish.ts, and in the recipe below. The other half of that fix is scripts/bundle-externals.mjs, a two-directional ratchet on the external set that fails the build (and pnpm test) when the bundle gains or loses a dependency without the list being updated deliberately.

The @maxstack/* packages are build-only devDependencies (workspace:*). They must NOT appear in the published manifest, so we publish from a staging dir with a cleaned package.json (devDependencies + scripts stripped) rather than from apps/maxstack directly — npm publish does not rewrite the workspace: protocol, so publishing in place would ship an invalid manifest.

Cutting a release #

One command, from maxstack/:

shell
pnpm release patch        # or minor | major | 1.2.3   (--dry-run prints the plan)

That is the whole release. scripts/cut-release.ts bumps the two version sites in lockstep, regenerates docs/cli-reference.md (it stamps the version, so the docs-reference gate goes red otherwise), commits chore(release): maxstack@X.Y.Z, tags vX.Y.Z and pushes — it never talks to npm. The tag push triggers .github/workflows/release.yml, which does everything else on a runner:

guard    version sites + tag agree; report what is already on the registry
stage    pnpm install --frozen-lockfile → scripts/stage-npm.ts → upload both .tgz
smoke    install both tarballs in a clean dir; --version, init, build --vendor-only,
         then reinstall and init again (catches a pruned unsatisfiable peer)
publish  maxstack-runtime FIRST, then maxstack (separate jobs, OIDC, no token)
verify   npm view both versions + the CLI's runtime pin
release  gh release create vX.Y.Z with the changelog section + both .tgz attached

There is no npm login, no OTP and no NPM_TOKEN anywhere in that path: npm trusted publishing trusts the named workflow in this repo through GitHub's OIDC token, and stamps provenance on both packages as a side effect. Because the runner builds from a clean clone at the tagged SHA, the tarballs also stop being a snapshot of whoever's laptop cut the release.

To release from anywhere (no checkout — a phone works): gh workflow run release.yml --ref main. The version comes from the committed package.json; pass -f version=X.Y.Z to assert which release you think you are cutting.

Preconditions are checks, not prompts — pnpm release aborts (it never asks) on a non-main branch, a dirty tree, drifted version sites, an existing tag, or a version already on the registry.

One-time npm setup (a package admin has to click this) #

Trusted publishing cannot be configured from a repo; someone with admin on both npm packages does this once, on npmjs.com:

  1. Sign in as the package owner and open https://www.npmjs.com/package/maxstack-runtimeSettings.
  2. Under Trusted publisher, choose GitHub Actions and enter:
    • Organization or user: sys13
    • Repository: maxstack
    • Workflow filename: release.yml
    • Environment: (leave empty — the workflow uses no GitHub environment) Save.
  3. On the same Settings page, set Publishing access to the most restrictive option — Require two-factor authentication and disallow bypass 2fa tokens. Every publishing-access option is compatible with a trusted publisher (npm says so inline on that page), so this setting does not gate CI at all; it only governs the human and token paths, and there is no reason to leave those loose. Trusted publishing plus the tightest token option is npm's own recommendation.
  4. Repeat steps 1–3 for https://www.npmjs.com/package/maxstack.
  5. Nothing else: no npm token is created, and no GitHub secret is added. If a stale NPM_TOKEN secret exists in the repo, delete it — it is unused and only widens the blast radius.

The workflow filename is part of the trust configuration: renaming release.yml breaks publishing until the npm-side entry is updated to match.

When a release half-lands #

npm refuses to replace a published version, so the recovery for "runtime published, CLI did not" is to publish only the missing half. That is automatic: each publish job re-reads the registry immediately before publishing and skips what is already there, so Re-run failed jobs on the same run publishes exactly the missing package. The release state job runs on both success and failure, prints the registry state to the job summary, and fails the run if one package is on the registry without the other — a half-release can never end in a green check.

Break-glass: publishing from a laptop #

If GitHub Actions is down, apps/maxstack's interactive npm run release (scripts/publish.ts) still does the whole cut locally — stage, smoke test, npm login (browser auth), publish runtime then CLI, verify, commit, GitHub release. It needs the npm account owner at the keyboard, which is exactly the dependency the CI path removes; use it only as a fallback. The manual equivalent of its two publish steps is:

shell
node --experimental-transform-types scripts/stage-npm.ts   # from apps/maxstack
npm publish <maxstack>/dist-npm/maxstack-runtime-<v>.tgz --access public
npm publish <maxstack>/dist-npm/maxstack-<v>.tgz --access public
npm view maxstack version dependencies   # == new version, pins maxstack-runtime
npm view maxstack-runtime version

Runtime first, always: the CLI pins the runtime exactly, so a CLI on the registry without its runtime is uninstallable for everyone.

The clean-dir smoke test the workflow runs is the condensed form of this, which is what catches deep transitive deps and HUB_ROOT/template-resolution regressions — worth running by hand when you touch the packaging:

shell
TEST=$(mktemp -d) && cd "$TEST" && npm init -y >/dev/null
npm install <maxstack>/dist-npm/maxstack-runtime-*.tgz <maxstack>/dist-npm/maxstack-*.tgz
./node_modules/.bin/maxstack --version          # prints the new version
./node_modules/.bin/maxstack init demo && cd demo
../node_modules/.bin/maxstack demo               # bundled seeder runs on plain node
PORT=3457 ../node_modules/.bin/maxstack dev &    # prebuilt server; curl /admin → 200
../node_modules/.bin/maxstack build --vendor-only  # snapshot vendors, lockfile restored
cd "$TEST" && npm install                        # re-resolve: prunes any orphaned peer
./node_modules/.bin/maxstack init demo2          # must still work — see #348 above

dist/ and dist-npm/ are gitignored (build artifacts); the only files a release commit touches are the two version sites, and pnpm release writes and commits both.

The changelog #

CHANGELOG.md is generated, not edited: stage-npm.ts rebuilds it from the chore(release): commits at release time and ships it inside both packages. Editing it by hand is pointless — the next release overwrites your edit.

Everything released before this repository was made public (≤ 0.11.11) was cut from a different repo, so those commits are not in this graph and cannot be regenerated. They are frozen in CHANGELOG.archive.md, which the generator appends verbatim below the sections it produces. That file is append-nothing, edit-never; it exists so that regenerating cannot silently delete history.