mirror of
https://github.com/jakejarvis/stanza.git
synced 2026-07-16 18:05:58 -04:00
feat: wire Changesets publish pipeline, mark internal packages private, and fix add-vite-plugin multiline indent
- Initialize Changesets: `.changeset/config.json` with `@changesets/changelog-github` for PR-linked changelog entries, `access: "public"`, `baseBranch: "main"`, `updateInternalDependencies: "patch"` - Add `.github/workflows/release.yml` — on push to main, `changesets/action@v1` opens a "Version Packages" PR when changesets are queued; merging that PR re-runs the workflow and calls `pnpm release` (build CLI + create-stanza, then `changeset publish`); provenance attestations enabled via `id-token: write` + `NPM_CONFIG_PROVENANCE=true`; requires `NPM_TOKEN` in repo secrets - Add root `pnpm changeset` and `pnpm release` scripts; add `@changesets/cli` + `@changesets/changelog-github` to root devDependencies - `@stanza/codemods` and `@stanza/registry` marked `private: true` — they are inlined into the CLI bundle by tsdown and never published independently; drop the now-redundant `files` field from codemods `package.json` - `apps/cli/package.json` and `packages/create-stanza/package.json` gain `keywords`, `homepage`, `bugs`, `author`, `repository`, and `publishConfig.access: "public"` — npm metadata required for a clean first publish - Fix `add-vite-plugin` multiline-array indent regression — when inserting into a `plugins: [...]` array that already spans multiple lines, ts-morph was reformatting the inserted and following elements to 8-space indent while leaving the first element at 4; fix preserves the existing element indent; add a dedicated test case covering the regression - Drop stale bun shebang comment from CI workflow; remove `packages/internal` from README; mark npm publish TODO done; update CLAUDE.md with Changesets workflow, `private: true` invariant, and `pnpm release` entry
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
# Changesets
|
||||
|
||||
Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works
|
||||
with multi-package repos, or single-package repos to help you version and publish your code. You can
|
||||
find the full documentation for it [in our repository](https://github.com/changesets/changesets).
|
||||
|
||||
We have a quick list of common questions to get you started engaging with this project in
|
||||
[our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md).
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"$schema": "https://unpkg.com/@changesets/config@3.1.4/schema.json",
|
||||
"changelog": ["@changesets/changelog-github", { "repo": "jakejarvis/stanza" }],
|
||||
"commit": false,
|
||||
"fixed": [],
|
||||
"linked": [],
|
||||
"access": "public",
|
||||
"baseBranch": "main",
|
||||
"updateInternalDependencies": "patch",
|
||||
"ignore": []
|
||||
}
|
||||
@@ -28,10 +28,6 @@ jobs:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
|
||||
# The root maintainer scripts (`pnpm registry:build`, `pnpm module:new`)
|
||||
# are bun-shebanged for local dev convenience. Their bodies are pure
|
||||
# node-API and could run under tsx too, but we install bun here so the
|
||||
# CI invocation mirrors the dev one exactly.
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
concurrency:
|
||||
group: release-${{ github.workflow }}-${{ github.ref }}
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
release:
|
||||
name: Version + publish
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
# Changesets needs full history to compute the changelog.
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build registry
|
||||
run: pnpm registry:build
|
||||
|
||||
- name: Lint + typecheck + test
|
||||
run: |
|
||||
pnpm lint
|
||||
pnpm fmt:check
|
||||
pnpm check-types
|
||||
pnpm test
|
||||
|
||||
- name: Create release PR or publish
|
||||
id: changesets
|
||||
uses: changesets/action@v1
|
||||
with:
|
||||
version: pnpm changeset version
|
||||
publish: pnpm release
|
||||
title: "chore: release"
|
||||
commit: "chore: release"
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
# Enable provenance — requires `id-token: write` above.
|
||||
NPM_CONFIG_PROVENANCE: "true"
|
||||
@@ -22,7 +22,7 @@ Three things differentiate stanza from other scaffolders:
|
||||
- `packages/registry/` — shared schema, slot/peer resolver, Zod manifest validator
|
||||
- `packages/codemods/` — ts-morph helpers (idempotent + reversible)
|
||||
- `packages/create-stanza/` — `pnpm create stanza` shim
|
||||
- `scripts/` — repo-root maintainer helpers (not shipped): `registry-build.ts` emits static CDN JSON, `module-new.ts` scaffolds a new module
|
||||
- `scripts/` — repo-root maintainer helpers (not shipped): `registry-build.ts` emits static CDN JSON
|
||||
- `registry/modules/<slot>-<id>/` — first-party modules: `module.ts` + `templates/` (modules don't ship codemod code; see Architecture rules)
|
||||
|
||||
In a **generated project**, `auth`, `db`, and `orm` modules install into their own internal workspace package at `packages/<dir>/` (named `@<manifest.name>/<dir>`); the app consumes them via `workspace:*` deps. `framework` and `styling` stay app-scoped because they wire the app shell itself. The mapping lives in the canonical [`SLOTS`](packages/registry/src/module.ts) array — `auth → "auth"`, both `db` and `orm → "db"` (they share a single `packages/db/` package). The `SLOT_PACKAGE_DIR` lookup and `slotLabel` helper are both derived from this array.
|
||||
@@ -32,19 +32,21 @@ In a **generated project**, `auth`, `db`, and `orm` modules install into their o
|
||||
- `pnpm --filter @stanza/cli dev -- <verb>` — run CLI directly via `tsx watch ./src/bin.ts`; no build step. Or `tsx apps/cli/src/bin.ts <verb>` for a one-shot run
|
||||
- `pnpm --filter @stanza/cli build` — build the publishable CLI via tsdown (compiles to ESM JS at `apps/cli/dist/`, externalizes npm deps, inlines workspace packages). Same for `create-stanza`
|
||||
- `pnpm registry:build` — regenerate `dist/registry/{index,modules/*}.json`. Uses bun for maintainer convenience (the script body is portable; `tsx scripts/registry-build.ts` works too)
|
||||
- `pnpm module:new [slot] [id]` — scaffold a new module under `registry/modules/`
|
||||
- `pnpm --filter @stanza/web dev` — TanStack Start dev server. `prebuild` invokes [`apps/web/scripts/copy-registry.sh`](apps/web/scripts/copy-registry.sh) which copies the prebuilt `dist/registry/` into `apps/web/public/registry/`. Pure bash — no node/tsx prerequisite on the deploy target. The deployed site ships this directory as static assets, so CLI and web consume the same JSON. Run `pnpm registry:build` before the first web build
|
||||
- `pnpm lint` / `pnpm lint:fix` — Oxlint across the whole repo (config: `.oxlintrc.json`)
|
||||
- `pnpm fmt` / `pnpm fmt:check` — oxfmt across the whole repo (config: `.oxfmtrc.json`)
|
||||
- `pnpm test` / `pnpm check-types` — fan out to every workspace via turbo
|
||||
- `cd apps/web && node_modules/.bin/vite build` — generates `src/routeTree.gen.ts` (required before first typecheck)
|
||||
- E2E smoke: seed `$TMPDIR/x` with `stanza.json` + `apps/web/package.json`, then `tsx apps/cli/src/bin.ts add <slot> <module>`
|
||||
- `pnpm changeset` — create a new changeset describing what changed (run after a substantive PR)
|
||||
- `pnpm release` — build CLI/create-stanza and publish to npm (only runs in CI; locally use `pnpm pack` to inspect tarballs)
|
||||
|
||||
## Toolchain invariants
|
||||
|
||||
- **Node-only at runtime.** The CLI source uses node APIs and is dev-run via `tsx`; the published binary is plain ESM JS (`#!/usr/bin/env node`). The only place bun appears is the shebang on root maintainer scripts (`scripts/*.ts`) for our own convenience — those scripts don't use any `Bun.*` APIs and run fine under tsx/node
|
||||
- **Build pipeline**: `tsdown` compiles each publishable package to ESM JS in `dist/`. External npm deps are _not_ bundled (users install them via the normal dep chain); workspace deps are _inlined_ (we don't publish `@stanza/registry` and `@stanza/codemods` separately). Transitive runtime deps (`ts-morph`, `zod`) MUST be declared as direct `dependencies` of the publishable package or tsdown will inline them into the bundle
|
||||
- **Per-workspace `dist/` paths**: `main`/`types` in the source `package.json` still point at `./src/` so other workspaces resolve `.ts` directly during dev. The published tarball overrides via `publishConfig` to point at `./dist/<x>.mjs`/`.d.mts`
|
||||
- **Publishing**: only `@stanza/cli` and `create-stanza` ship to npm. `@stanza/codemods` + `@stanza/registry` are marked `private: true` (inlined into the CLI bundle by tsdown); `@stanza/web` is private (deployed as a Vercel site, not an npm package); the `registry/modules/*` packages are also private (they're registry data, not npm packages). Releases go through **Changesets**: drop a markdown file via `pnpm changeset`, push to main → the [release workflow](.github/workflows/release.yml) opens a "Version Packages" PR; merging that PR triggers the same workflow to run `pnpm release` (build CLI + create-stanza, then `changeset publish` to npm). Requires `NPM_TOKEN` in repo secrets; provenance attestations are emitted via `id-token: write` + `NPM_CONFIG_PROVENANCE=true`
|
||||
- pnpm 10 + `node-linker: isolated` — each workspace MUST declare `@types/node` in its own devDeps and set `types: ["node"]` in tsconfig (auto-discovery doesn't reach into the isolated `node_modules/@types`)
|
||||
- TypeScript 6 — `allowImportingTsExtensions: true` + `noEmit: true` is set in `tsconfig.json`; the CLI/create-stanza emit JS via tsdown, the registry/codemods packages stay source-only and never emit
|
||||
- `tsconfig.json` excludes `**/templates/**` globally — template files target user projects, not this repo
|
||||
|
||||
@@ -20,7 +20,6 @@ packages/
|
||||
registry/ # shared schema, slot/peer/capability resolver
|
||||
codemods/ # ts-morph helpers for region-aware patching
|
||||
create-stanza/ # `pnpm create stanza` shim
|
||||
internal/ # maintenance scripts (registry-build, module-new)
|
||||
registry/
|
||||
modules/ # first-party modules (framework, orm, db, auth, styling)
|
||||
```
|
||||
|
||||
@@ -70,7 +70,7 @@ The full first-party module roadmap lives in [REGISTRY.md](REGISTRY.md). These a
|
||||
- [ ] Golden snapshot tests per module combination (per the plan's verification section) — for each valid `(framework, orm, db, auth, styling, pm)` tuple, run `stanza init` headless, snapshot the tree, compare against fixture
|
||||
- [ ] Integration test for the canonical stack — Docker Postgres + Playwright sign-up flow
|
||||
- [ ] Registry deploy pipeline — on push to main, build `dist/registry/` and push to Vercel/CF
|
||||
- [ ] npm publish workflow for `@stanza/cli` + `create-stanza` (Changesets recommended; no changesets config yet)
|
||||
- [x] npm publish workflow — Changesets configured (`.changeset/config.json`, public access, `@changesets/changelog-github` for PR links). [Release workflow](.github/workflows/release.yml) runs `changesets/action@v1` on push to main: opens a "Version Packages" PR when changesets queue up; merging publishes via `pnpm release` (build CLI + create-stanza, then `changeset publish`). Provenance attestations enabled. Tarball dry-run verified: `@stanza/cli` is 20 KB, `create-stanza` is 1.7 KB; both `npm install`-able from a clean tmpdir and `--version` / `--help` work. Repo needs `NPM_TOKEN` secret before the first publish
|
||||
- [ ] `.env.example` at repo root listing `STANZA_REGISTRY`, PostHog key, etc.
|
||||
|
||||
## Open items from the plan
|
||||
|
||||
+18
-1
@@ -2,7 +2,23 @@
|
||||
"name": "@stanza/cli",
|
||||
"version": "0.1.0",
|
||||
"description": "Modular monorepo template CLI — shadcn for stacks.",
|
||||
"keywords": [
|
||||
"boilerplate",
|
||||
"cli",
|
||||
"monorepo",
|
||||
"scaffold",
|
||||
"shadcn",
|
||||
"template"
|
||||
],
|
||||
"homepage": "https://stanza.tools",
|
||||
"bugs": "https://github.com/jakejarvis/stanza/issues",
|
||||
"license": "MIT",
|
||||
"author": "Jake Jarvis <jake@jarv.is>",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/jakejarvis/stanza.git",
|
||||
"directory": "apps/cli"
|
||||
},
|
||||
"bin": {
|
||||
"stanza": "./dist/bin.mjs"
|
||||
},
|
||||
@@ -23,7 +39,8 @@
|
||||
"types": "./dist/index.d.mts",
|
||||
"default": "./dist/index.mjs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"access": "public"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsdown",
|
||||
|
||||
+4
-1
@@ -15,9 +15,12 @@
|
||||
"fmt": "oxfmt",
|
||||
"fmt:check": "oxfmt --check",
|
||||
"registry:build": "bun scripts/registry-build.ts",
|
||||
"module:new": "bun scripts/module-new.ts"
|
||||
"changeset": "changeset",
|
||||
"release": "pnpm --filter @stanza/cli --filter create-stanza build && changeset publish"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@changesets/changelog-github": "^0.7.0",
|
||||
"@changesets/cli": "^2.31.0",
|
||||
"@stanza/registry": "workspace:*",
|
||||
"@types/node": "^25.9.1",
|
||||
"oxfmt": "^0.51.0",
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
{
|
||||
"name": "@stanza/codemods",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"files": [
|
||||
"src"
|
||||
],
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
|
||||
@@ -13,6 +13,18 @@ export default defineConfig({
|
||||
});
|
||||
`;
|
||||
|
||||
const MULTILINE_VITE_CONFIG = `import { tanstackStart } from "@tanstack/react-start/plugin/vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
tanstackStart(),
|
||||
react(),
|
||||
],
|
||||
});
|
||||
`;
|
||||
|
||||
/**
|
||||
* Spin up a ts-morph project with a single in-memory vite.config.ts at the
|
||||
* given content, plus a mock `CodemodContext` that records claimed regions.
|
||||
@@ -147,6 +159,20 @@ describe("add-vite-plugin", () => {
|
||||
expect(() => addVitePlugin.apply(ctx, TAILWIND_ARGS)).toThrow(/needs a .*array literal/);
|
||||
});
|
||||
|
||||
it("preserves the existing indent when inserting into a multi-line array", () => {
|
||||
const { ctx, sf } = setup(MULTILINE_VITE_CONFIG);
|
||||
addVitePlugin.apply(ctx, TAILWIND_ARGS);
|
||||
const text = sf.getFullText();
|
||||
// All three elements should share the same 4-space indent — earlier regression
|
||||
// had ts-morph's default 4-space-per-level reformat the inserted+following
|
||||
// elements to 8 spaces while leaving the first element at 4.
|
||||
expect(text).toContain(
|
||||
[" plugins: [", " tanstackStart(),", " tailwindcss(),", " react(),", " ],"].join(
|
||||
"\n",
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("lets two modules insert different plugins without colliding", () => {
|
||||
const { ctx, sf, claimed } = setup();
|
||||
addVitePlugin.apply(ctx, TAILWIND_ARGS);
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
SyntaxKind,
|
||||
} from "../index";
|
||||
|
||||
type ArrayLiteral = ReturnType<typeof findPluginsArray>;
|
||||
|
||||
/**
|
||||
* Splice a plugin into an existing `vite.config.ts`'s `defineConfig({ plugins: [...] })`
|
||||
* array. Lets multiple modules layer plugins on top of the framework's base
|
||||
@@ -68,7 +70,7 @@ const addVitePlugin: Codemod<AddVitePluginArgs> = {
|
||||
}
|
||||
|
||||
const index = resolveIndex(plugins, args.position ?? "end", args.importName);
|
||||
plugins.insertElement(index, args.call);
|
||||
insertPluginCall(sf, plugins, index, args.call);
|
||||
|
||||
ctx.claimRegion(rel, `vite.plugins.${args.importName}`);
|
||||
|
||||
@@ -137,6 +139,54 @@ function findPluginsArray(sf: SourceFile) {
|
||||
return pluginsArr;
|
||||
}
|
||||
|
||||
/**
|
||||
* Splice a new call expression into the plugins array while preserving the
|
||||
* indent style of existing elements. ts-morph's `insertElement` reformats the
|
||||
* array with its own `manipulationSettings.indentationText` (4 spaces by
|
||||
* default), which doesn't match 2-space configs and produces over-indented
|
||||
* output. We do the text edit ourselves: capture the leading whitespace of an
|
||||
* existing element and reuse it. The empty-array case has no element to copy
|
||||
* from, so we fall back to ts-morph's default formatting.
|
||||
*/
|
||||
function insertPluginCall(
|
||||
sf: SourceFile,
|
||||
plugins: ArrayLiteral,
|
||||
index: number,
|
||||
call: string,
|
||||
): void {
|
||||
const elements = plugins.getElements();
|
||||
if (elements.length === 0) {
|
||||
plugins.insertElement(index, call);
|
||||
return;
|
||||
}
|
||||
|
||||
const probe = elements[Math.min(index, elements.length - 1)];
|
||||
if (!probe) {
|
||||
plugins.insertElement(index, call);
|
||||
return;
|
||||
}
|
||||
|
||||
const trivia = sf.getFullText().slice(probe.getFullStart(), probe.getStart());
|
||||
const newlineIndent = trivia.match(/\n([ \t]*)$/);
|
||||
|
||||
if (newlineIndent) {
|
||||
const indent = newlineIndent[1];
|
||||
if (index < elements.length) {
|
||||
sf.insertText(probe.getStart(), `${call},\n${indent}`);
|
||||
} else {
|
||||
const last = elements[elements.length - 1]!;
|
||||
sf.insertText(last.getEnd(), `,\n${indent}${call}`);
|
||||
}
|
||||
} else {
|
||||
if (index < elements.length) {
|
||||
sf.insertText(probe.getStart(), `${call}, `);
|
||||
} else {
|
||||
const last = elements[elements.length - 1]!;
|
||||
sf.insertText(last.getEnd(), `, ${call}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resolveIndex(
|
||||
plugins: ReturnType<typeof findPluginsArray>,
|
||||
position: NonNullable<AddVitePluginArgs["position"]>,
|
||||
|
||||
@@ -1,8 +1,23 @@
|
||||
{
|
||||
"name": "create-stanza",
|
||||
"version": "0.1.0",
|
||||
"description": "`pnpm create stanza` — the canonical entry point to the stanza wizard.",
|
||||
"description": "`npm init stanza` — the canonical entry point to the stanza wizard.",
|
||||
"keywords": [
|
||||
"cli",
|
||||
"create",
|
||||
"monorepo",
|
||||
"scaffold",
|
||||
"shadcn"
|
||||
],
|
||||
"homepage": "https://stanza.tools",
|
||||
"bugs": "https://github.com/jakejarvis/stanza/issues",
|
||||
"license": "MIT",
|
||||
"author": "Jake Jarvis <jake@jarv.is>",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/jakejarvis/stanza.git",
|
||||
"directory": "packages/create-stanza"
|
||||
},
|
||||
"bin": {
|
||||
"create-stanza": "./dist/bin.mjs"
|
||||
},
|
||||
@@ -10,6 +25,9 @@
|
||||
"dist"
|
||||
],
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsdown",
|
||||
"check-types": "tsc --noEmit"
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
{
|
||||
"name": "@stanza/registry",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"files": [
|
||||
"src",
|
||||
"dist"
|
||||
],
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
|
||||
Generated
+587
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user