refactor: serve registry and manifest schema from Vercel Blob as canonical origin (#18)

This commit is contained in:
2026-06-04 15:08:36 -04:00
committed by GitHub
parent ab4b2ed0af
commit 04a196e4e9
30 changed files with 1220 additions and 734 deletions
@@ -0,0 +1,7 @@
---
"stanza-cli": patch
---
Fix `stanza add monorepo turbo` hard-failing on existing projects that lack a root `.gitignore`.
The `append-to-file` codemod gains an optional `createIfMissing` arg: when set and the target file is absent, it creates the file containing just the wrapped marker block instead of throwing. `monorepo-turbo` now passes `createIfMissing: true` for its `.turbo/` `.gitignore` entry, so `stanza add monorepo turbo` works on a pre-existing project with no `.gitignore` (previously the whole add rolled back). The flag is additive and off by default — modules appending to peer-owned files (a Prisma schema, a framework's `globals.css`) keep the original "missing file is a peer-ordering bug" throw.
+12
View File
@@ -0,0 +1,12 @@
---
"@withstanza/schema": minor
"stanza-cli": patch
---
Serve the first-party registry and manifest schema from Vercel Blob as the single origin.
The registry index, per-module manifests, and the manifest JSON Schema are now hosted on Vercel Blob and served path-transparently from `stanza.tools` via rewrites: `stanza.tools/registry/index.json`, `stanza.tools/registry/<category>-<id>.json` (latest), `stanza.tools/registry/<category>-<id>@<version>.json` (immutable pin), and `stanza.tools/schema.json` / `schema@<version>.json`. The HTML browse pages (`/registry`, `/registry/<category>`, `/registry/<category>/<id>`) are unchanged. `DEFAULT_REGISTRY_URL` and `MANIFEST_SCHEMA_URL` keep their values, so the CLI and every manifest's `$schema` are unaffected.
`@withstanza/schema` gains `compileManifestJsonSchema()` (the shared schema compiler) and `REGISTRY_BASE_URL` (`https://stanza.tools/registry`). `scripts/publish-registry.ts` compiles the registry and uploads it to Blob on every push to `main` that touches `registry/**` or the schema source — so a module change goes live without a release. Latest files overwrite each run; `@version` pins are written once and immutable. A `pull_request` CI guard (`scripts/check-module-versions.ts`) fails when a changed module's content differs from its published pin without a version bump.
`compile-registry` now emits a flat layout (`<category>-<id>.json` + `index.json`, no `modules/` subdir) that maps 1:1 onto the Blob store, and a module's `package.json` version is the single source of truth (stamped into the compiled module; `module.ts`'s `version` field is no longer authoritative). The web app reads its own build-time compiled copy (`apps/web/.registry/`, gitignored) for prerendering and SSR; the schema is no longer served by an app route. No CLI behavior change — the read path for pinned versions is deferred to the upcoming `swap`/`update` verbs.
+30 -2
View File
@@ -15,9 +15,9 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 10 timeout-minutes: 10
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # ratchet:actions/checkout@v6
- uses: voidzero-dev/setup-vp@v1 - uses: voidzero-dev/setup-vp@2dec1e33f4ab2c6d5bce1b0c4607961bb1a3f7a1 # ratchet:voidzero-dev/setup-vp@v1
with: with:
node-version: 24 node-version: 24
cache: true cache: true
@@ -35,3 +35,31 @@ jobs:
run: | run: |
node apps/cli/dist/bin.mjs --version node apps/cli/dist/bin.mjs --version
node apps/cli/dist/bin.mjs --help node apps/cli/dist/bin.mjs --help
# Guards the immutability of the registry's per-module version pins on Blob: a
# module whose compiled content changed must bump its package.json version.
# Self-gating — no-ops when nothing under registry/modules/** changed, so it's
# cheap to run on every PR.
verify-registry:
name: Verify registry
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
timeout-minutes: 10
permissions:
contents: read
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # ratchet:actions/checkout@v6
with:
# The guard diffs against the PR base via merge-base — needs history.
fetch-depth: 0
- uses: voidzero-dev/setup-vp@2dec1e33f4ab2c6d5bce1b0c4607961bb1a3f7a1 # ratchet:voidzero-dev/setup-vp@v1
with:
node-version: 24
cache: true
- name: Fetch base branch
run: git fetch origin "$GITHUB_BASE_REF"
- name: Check module version bumps
run: vp exec jiti scripts/check-module-versions.ts
+39 -5
View File
@@ -10,8 +10,8 @@ concurrency:
permissions: {} permissions: {}
jobs: jobs:
release: npm:
name: Version & publish name: Publish to NPM
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 15 timeout-minutes: 15
permissions: permissions:
@@ -19,12 +19,12 @@ jobs:
pull-requests: write pull-requests: write
id-token: write id-token: write
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # ratchet:actions/checkout@v6
with: with:
# Changesets needs full history to compute the changelog. # Changesets needs full history to compute the changelog.
fetch-depth: 0 fetch-depth: 0
- uses: voidzero-dev/setup-vp@v1 - uses: voidzero-dev/setup-vp@2dec1e33f4ab2c6d5bce1b0c4607961bb1a3f7a1 # ratchet:voidzero-dev/setup-vp@v1
with: with:
node-version: 24 node-version: 24
cache: true cache: true
@@ -43,7 +43,7 @@ jobs:
- name: Create release PR or publish - name: Create release PR or publish
id: changesets id: changesets
uses: changesets/action@v1 uses: changesets/action@a45c4d594aa4e2c509dc14a9f2b3b67ba3780d0d # ratchet:changesets/action@v1
with: with:
version: vp run version version: vp run version
publish: vp run release publish: vp run release
@@ -51,3 +51,37 @@ jobs:
commit: "chore: release" commit: "chore: release"
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
registry:
name: Compile & upload registry
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # ratchet:actions/checkout@v6
with:
fetch-depth: 0
- name: Detect registry changes
id: changes
env:
BEFORE: ${{ github.event.before }}
run: |
if git diff --name-only "$BEFORE" "$GITHUB_SHA" | grep -qE '^(registry/modules/|packages/schema/|scripts/)'; then
echo "changed=true" >> "$GITHUB_OUTPUT"
else
echo "changed=false" >> "$GITHUB_OUTPUT"
fi
- uses: voidzero-dev/setup-vp@2dec1e33f4ab2c6d5bce1b0c4607961bb1a3f7a1 # ratchet:voidzero-dev/setup-vp@v1
if: steps.changes.outputs.changed == 'true'
with:
node-version: 24
cache: true
- name: Publish registry + schema to Blob
if: steps.changes.outputs.changed == 'true'
run: vp exec jiti scripts/publish-registry.ts
env:
BLOB_READ_WRITE_TOKEN: ${{ secrets.BLOB_READ_WRITE_TOKEN }}
+3 -3
View File
@@ -23,9 +23,9 @@ The repo runs on the **Vite+ toolchain** (`vp`). All config lives in the root [`
- `vp check` — format + lint + type-check (oxfmt + oxlint + type-aware `tsgolint`). **Use this for validation loops.** `--fix` autofixes - `vp check` — format + lint + type-check (oxfmt + oxlint + type-aware `tsgolint`). **Use this for validation loops.** `--fix` autofixes
- `vp test` — Vitest 4 via `vite-plus/test`; project selection via `test.projects` in root config - `vp test` — Vitest 4 via `vite-plus/test`; project selection via `test.projects` in root config
- `vp run -r build` — build everything (`vp pack` per package, wraps tsdown) - `vp run -r build` — build everything (`vp pack` per package, wraps tsdown)
- `vp run @withstanza/web#dev` — TanStack Start dev server. Its `dev`/`build` scripts first run the `compile-registry` vp task (defined under `run.tasks` in [`apps/web/vite.config.ts`](apps/web/vite.config.ts)) → `jiti scripts/compile-registry.ts apps/web/public/registry`, emitting `apps/web/public/registry/{index.json,modules/*.json}`. The manifest JSON Schema is no longer a build artifact — it's served on request by the `/schema.json` route (`apps/web/src/routes/schema[.]json.ts`) - `vp run @withstanza/web#dev` — TanStack Start dev server. Its `dev`/`build` scripts first run the `compile-registry` vp task (defined under `run.tasks` in [`apps/web/vite.config.ts`](apps/web/vite.config.ts)) → `jiti scripts/compile-registry.ts apps/web/.registry`, emitting flat `apps/web/.registry/{index.json,<category>-<id>.json}` (gitignored, Nitro `serverAssets` — the web's own render input). The public registry + manifest schema are served from Vercel Blob, not this dir (see "Registry serving" below)
- `src/routeTree.gen.ts` is generated by `vp run @withstanza/web#build` — run it before the first `vp check` if missing - `src/routeTree.gen.ts` is generated by `vp run @withstanza/web#build` — run it before the first `vp check` if missing
- E2E smoke: build the registry (`jiti scripts/compile-registry.ts $TMPDIR/reg` → writes `$TMPDIR/reg/index.json` + `$TMPDIR/reg/modules/*.json` directly under the out dir, no `registry/` wrapper), seed `$TMPDIR/x` with `stanza.json` + `apps/web/package.json`, then `STANZA_REGISTRY=$TMPDIR/reg/index.json tsx apps/cli/src/bin.ts add <category> <module>`. The CLI reads a built registry **main file** (full path/URL, any filename) — there is no source-tree loader; `STANZA_REGISTRY` unset hits the production registry - E2E smoke: build the registry (`jiti scripts/compile-registry.ts $TMPDIR/reg` → writes flat `$TMPDIR/reg/{index.json,<category>-<id>.json}` directly under the out dir, no `modules/` subdir), seed `$TMPDIR/x` with `stanza.json` + `apps/web/package.json`, then `STANZA_REGISTRY=$TMPDIR/reg/index.json tsx apps/cli/src/bin.ts add <category> <module>`. The CLI reads a built registry **main file** (full path/URL, any filename) — there is no source-tree loader; `STANZA_REGISTRY` unset hits the production registry
- `pnpm changeset` — drop a markdown file after a substantive PR; the release workflow handles versioning + publish. `stanza-cli`, `create-stanza`, and `@withstanza/schema` ship to npm; every other workspace is private - `pnpm changeset` — drop a markdown file after a substantive PR; the release workflow handles versioning + publish. `stanza-cli`, `create-stanza`, and `@withstanza/schema` ship to npm; every other workspace is private
Use `agent-browser` for web automation (`agent-browser --help`); prefer it over built-in browser tools. Use `agent-browser` for web automation (`agent-browser --help`); prefer it over built-in browser tools.
@@ -55,7 +55,7 @@ Use `agent-browser` for web automation (`agent-browser --help`); prefer it over
- **Region ownership** in `stanza.json` is the source of truth for `stanza remove`. Two modules claiming the same region throws `RegionConflictError`. Regions key on `module.id`, so disjoint dot-paths (vitest `scripts.test` vs playwright `scripts.test:e2e`) coexist. Package bootstrap files (`package.json`, `tsconfig.json`, workspace deps) are system-owned, not tracked in regions — `stanza remove`'s sweep over `PACKAGE_DIRS` cleans them when no claims remain. _Regions are not yet sufficient for `swap`_ — that verb needs adapter-region remapping; design pending - **Region ownership** in `stanza.json` is the source of truth for `stanza remove`. Two modules claiming the same region throws `RegionConflictError`. Regions key on `module.id`, so disjoint dot-paths (vitest `scripts.test` vs playwright `scripts.test:e2e`) coexist. Package bootstrap files (`package.json`, `tsconfig.json`, workspace deps) are system-owned, not tracked in regions — `stanza remove`'s sweep over `PACKAGE_DIRS` cleans them when no claims remain. _Regions are not yet sufficient for `swap`_ — that verb needs adapter-region remapping; design pending
- **Reserved manifest fields**: `modules[category][].version` and `regions` are written today but only fully consumed by upcoming `swap`/`update`. Don't drop them - **Reserved manifest fields**: `modules[category][].version` and `regions` are written today but only fully consumed by upcoming `swap`/`update`. Don't drop them
- **Web previews are SSR.** Shiki runs in [`highlighter.server.ts`](apps/web/src/server/highlighter.server.ts); the client-safe `Preview` type lives in [`highlighter.ts`](apps/web/src/server/highlighter.ts). Never import `shiki` from a client component. After `vp run @withstanza/web#build`, grep `.output/public/assets/*.js` for the runtime (`createHighlighter`/`codeToHtml`/`loadWasm`) — must be absent. A bare `grep shiki` yields false positives because MDX docs ship statically-rendered code blocks with `class="shiki"` + CSS vars (fine) - **Web previews are SSR.** Shiki runs in [`highlighter.server.ts`](apps/web/src/server/highlighter.server.ts); the client-safe `Preview` type lives in [`highlighter.ts`](apps/web/src/server/highlighter.ts). Never import `shiki` from a client component. After `vp run @withstanza/web#build`, grep `.output/public/assets/*.js` for the runtime (`createHighlighter`/`codeToHtml`/`loadWasm`) — must be absent. A bare `grep shiki` yields false positives because MDX docs ship statically-rendered code blocks with `class="shiki"` + CSS vars (fine)
- **Web hosts the canonical registry.** The `compile-registry` task writes the registry into `apps/web/public/registry/`; the deployed site serves it at the published CLI's default `DEFAULT_REGISTRY_URL`. `STANZA_REGISTRY` (URL or FS path) overrides for self-hosters / CI. `public/registry/` is also a Nitro `serverAssets` dir (vite.config.ts) — SSR reads via `useStorage("assets:registry")` (see [`registry-base.server.ts`](apps/web/src/server/registry-base.server.ts)), which is why Vercel works where `public/` is absent from the function fs. The deployed site also serves the manifest JSON Schema at `/schema.json` (route `apps/web/src/routes/schema[.]json.ts`), compiled from `@withstanza/schema`'s `StanzaManifestSchema` on request rather than emitted to `public/` — this is the URL `MANIFEST_SCHEMA_URL` / a manifest's `$schema` resolves to - **Vercel Blob is the canonical registry origin.** [`scripts/publish-registry.ts`](scripts/publish-registry.ts) compiles the registry and uploads it to Blob on **every push to `main`** (the self-gating `registry` job — "Compile & upload registry" — in [`.github/workflows/release.yml`](.github/workflows/release.yml), filtered to `registry/modules/**` + `packages/schema/**` + `scripts/**`, running in parallel with the release) — decoupled from releases, so a module change goes live on merge. Blob layout: `registry/index.json`, `registry/<category>-<id>.json` (latest, overwritten), `registry/<category>-<id>@<version>.json` (immutable pin, write-if-absent), `schema.json` + `schema@<version>.json`. `stanza.tools` serves these **path-transparently** via [`vercel.json`](vercel.json) **`routes`** scoped to `.json``stanza.tools/registry/<file>.json` and `stanza.tools/schema*.json` → Blob. Legacy `routes` (not `rewrites`) are required: on a Build Output API deploy, `rewrites` are appended _after_ the Nitro catch-all and never match, whereas `routes` run _before_ the filesystem and framework, so they win. That's exactly why the patterns are scoped to `.json` — the HTML browse routes (`/registry`, `/registry/<cat>`, `/registry/<cat>/<id>` — no `.json`) carry no extension and fall through to the framework untouched. Because `routes` win over the filesystem, keep nothing compiled at a matching `.json` path under `public/` (it would be shadowed by the Blob route). `DEFAULT_REGISTRY_URL` = `https://stanza.tools/registry/index.json` and `MANIFEST_SCHEMA_URL` = `https://stanza.tools/schema.json` are unchanged; `REGISTRY_BASE_URL` (`@withstanza/schema`) is the branded base. The web app reads its **own** build-time compiled copy at `apps/web/.registry/` (gitignored, Nitro `serverAssets`, via `useStorage("assets:registry")` in [`registry-base.server.ts`](apps/web/src/server/registry-base.server.ts)) for prerender + SSR — never the public URL (prod SSR loopback is refused). `STANZA_REGISTRY` (URL or FS path) overrides the CLI default for self-hosters / CI. **Seeding caveat:** `index.json`/`schema.json` have no static fallback (the `routes` rule points straight at Blob), so Blob must be seeded — run the publish job once via `workflow_dispatch` — before the routes go live
- **Third-party registries**: namespaces declared under `stanza.json#registries`. Modules addressed as `<category> @<ns>/<id>`. `telemetry.captureModule` redacts non-`@stanza` ids to `<redacted>`; stderr still prints full ids (CI log forwarders will see them) - **Third-party registries**: namespaces declared under `stanza.json#registries`. Modules addressed as `<category> @<ns>/<id>`. `telemetry.captureModule` redacts non-`@stanza` ids to `<redacted>`; stderr still prints full ids (CI log forwarders will see them)
## Module authoring ## Module authoring
+1 -1
View File
@@ -47,7 +47,7 @@
"build": "vp pack" "build": "vp pack"
}, },
"dependencies": { "dependencies": {
"@clack/prompts": "^1.5.0", "@clack/prompts": "^1.5.1",
"citty": "^0.2.2", "citty": "^0.2.2",
"handlebars": "^4.7.9", "handlebars": "^4.7.9",
"jsonc-parser": "^3.3.1", "jsonc-parser": "^3.3.1",
+10 -10
View File
@@ -29,8 +29,8 @@ let fixtureMain: string;
beforeAll(() => { beforeAll(() => {
fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), "stanza-reg-")); fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), "stanza-reg-"));
// Build the real first-party registry via the standalone build script — the // Build the real first-party registry via the standalone build script — the
// same entrypoint CI and the web prebuild use — so the test exercises the // same entrypoint CI and the web's compile-registry task use — so the test
// production build path rather than an in-process import. // exercises the production build path rather than an in-process import.
execFileSync( execFileSync(
path.join(repoRoot, "node_modules/.bin/jiti"), path.join(repoRoot, "node_modules/.bin/jiti"),
["scripts/compile-registry.ts", fixtureRoot], ["scripts/compile-registry.ts", fixtureRoot],
@@ -554,14 +554,14 @@ describe("third-party registries", () => {
}; };
return Object.assign({}, m, { return Object.assign({}, m, {
adapters: (m.adapters ?? []).map((a) => ({ key: a.key, match: a.match })), adapters: (m.adapters ?? []).map((a) => ({ key: a.key, match: a.match })),
path: `modules/${key}.json`, path: `${key}.json`,
}); });
}); });
sendJson({ generatedAt: "t", schemaVersion: 2, categories: [...CATEGORIES], modules }); sendJson({ generatedAt: "t", schemaVersion: 2, categories: [...CATEGORIES], modules });
return; return;
} }
// Per-module full manifests at the `path` advertised by the index. // Per-module full manifests at the flat `path` advertised by the index.
const match = /^\/modules\/([^?]+)\.json/.exec(url); const match = /^\/([^/?]+)\.json/.exec(url);
const payload = match ? fixture.modules[match[1]!] : undefined; const payload = match ? fixture.modules[match[1]!] : undefined;
if (!payload) { if (!payload) {
res.statusCode = 404; res.statusCode = 404;
@@ -625,7 +625,7 @@ describe("third-party registries", () => {
await cmdRemove(args({ slot: "testing", moduleId: "@fixture/cosmos" })); await cmdRemove(args({ slot: "testing", moduleId: "@fixture/cosmos" }));
expect(process.exitCode).toBeFalsy(); expect(process.exitCode).toBeFalsy();
expect(requests).toContain("/modules/testing-cosmos.json"); expect(requests).toContain("/testing-cosmos.json");
const manifest = JSON.parse(fs.readFileSync("stanza.json", "utf8")); const manifest = JSON.parse(fs.readFileSync("stanza.json", "utf8"));
expect(manifest.modules.testing).toBeUndefined(); expect(manifest.modules.testing).toBeUndefined();
@@ -650,7 +650,7 @@ describe("third-party registries", () => {
let captured: Record<string, string | string[] | undefined> | undefined; let captured: Record<string, string | string[] | undefined> | undefined;
fixture.onRequest = ({ url, headers }) => { fixture.onRequest = ({ url, headers }) => {
if (url === "/modules/testing-cosmos.json") captured = headers; if (url === "/testing-cosmos.json") captured = headers;
}; };
process.env.STANZA_TEST_TOKEN = "secret-xyz"; process.env.STANZA_TEST_TOKEN = "secret-xyz";
@@ -775,7 +775,7 @@ describe("filesystem main-file registry", () => {
// resolve relative to the file. No directory or filename inference. // resolve relative to the file. No directory or filename inference.
it("loads the index and modules from a main-file URI", async () => { it("loads the index and modules from a main-file URI", async () => {
const root = path.join(tmp, "reg"); const root = path.join(tmp, "reg");
fs.mkdirSync(path.join(root, "modules"), { recursive: true }); fs.mkdirSync(root, { recursive: true });
const cosmos = cosmosModule(); const cosmos = cosmosModule();
const mainFile = path.join(root, "main.json"); // any filename works const mainFile = path.join(root, "main.json"); // any filename works
fs.writeFileSync( fs.writeFileSync(
@@ -788,12 +788,12 @@ describe("filesystem main-file registry", () => {
{ {
...cosmos, ...cosmos,
adapters: cosmos.adapters.map((a) => ({ key: a.key, match: a.match })), adapters: cosmos.adapters.map((a) => ({ key: a.key, match: a.match })),
path: "modules/testing-cosmos.json", path: "testing-cosmos.json",
}, },
], ],
}), }),
); );
fs.writeFileSync(path.join(root, "modules", "testing-cosmos.json"), JSON.stringify(cosmos)); fs.writeFileSync(path.join(root, "testing-cosmos.json"), JSON.stringify(cosmos));
process.env.STANZA_REGISTRY = mainFile; process.env.STANZA_REGISTRY = mainFile;
const registry = await loadRegistries(); const registry = await loadRegistries();
+1 -2
View File
@@ -1,2 +1 @@
public/registry/ .registry/
public/schema.json
+3 -3
View File
@@ -316,11 +316,11 @@ Each per-module JSON is self-contained: template bodies, deps, env vars, codemod
To run the build: To run the build:
```sh ```sh
# Default output: <repoRoot>/dist (writes dist/index.json + dist/modules/*.json) # Default output: <repoRoot>/dist (writes flat dist/index.json + dist/<category>-<id>.json)
jiti scripts/compile-registry.ts jiti scripts/compile-registry.ts
# Or point it elsewhere (this is what the web app's prebuild does): # Or point it elsewhere (this is what the web app's compile-registry task does):
jiti scripts/compile-registry.ts apps/web/public/registry jiti scripts/compile-registry.ts apps/web/.registry
``` ```
The script is a thin wrapper over `@withstanza/schema` (`CATEGORIES` + the contract types) and SVGO — for a third-party registry, fork it (or copy it) against your own `registry/modules/` tree. `compileRegistry({ outDir })` is also exported for in-process callers. The output shape is identical. The script is a thin wrapper over `@withstanza/schema` (`CATEGORIES` + the contract types) and SVGO — for a third-party registry, fork it (or copy it) against your own `registry/modules/` tree. `compileRegistry({ outDir })` is also exported for in-process callers. The output shape is identical.
+11 -11
View File
@@ -22,11 +22,11 @@
"@tanstack/react-devtools": "^0.10.5", "@tanstack/react-devtools": "^0.10.5",
"@tanstack/react-hotkeys": "^0.10.0", "@tanstack/react-hotkeys": "^0.10.0",
"@tanstack/react-pacer": "^0.22.1", "@tanstack/react-pacer": "^0.22.1",
"@tanstack/react-router": "^1.170.10", "@tanstack/react-router": "^1.170.11",
"@tanstack/react-router-devtools": "^1.167.0", "@tanstack/react-router-devtools": "^1.167.0",
"@tanstack/react-start": "^1.168.18", "@tanstack/react-start": "^1.168.19",
"@vercel/analytics": "^2.0.1", "@vercel/analytics": "^2.0.1",
"@vercel/functions": "^3.6.1", "@vercel/functions": "^3.6.2",
"@withstanza/registry": "workspace:*", "@withstanza/registry": "workspace:*",
"@withstanza/schema": "workspace:*", "@withstanza/schema": "workspace:*",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
@@ -36,14 +36,14 @@
"fumadocs-ui": "npm:@fumadocs/base-ui@^16.9.3", "fumadocs-ui": "npm:@fumadocs/base-ui@^16.9.3",
"lru-cache": "^11.5.1", "lru-cache": "^11.5.1",
"motion": "^12.40.0", "motion": "^12.40.0",
"nitro": "^3.0.260522-beta", "nitro": "3.0.260603-beta",
"posthog-node": "^5.35.6", "posthog-node": "^5.35.13",
"react": "^19.2.6", "react": "^19.2.7",
"react-dom": "^19.2.6", "react-dom": "^19.2.7",
"react-resizable-panels": "^4.11.2", "react-resizable-panels": "^4.11.2",
"recharts": "^3.8.1", "recharts": "^3.8.1",
"shadcn": "^4.8.3", "shadcn": "^4.10.0",
"shiki": "^4.1.0", "shiki": "^4.2.0",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.6.0", "tailwind-merge": "^3.6.0",
"tailwindcss": "^4.3.0", "tailwindcss": "^4.3.0",
@@ -57,10 +57,10 @@
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.2",
"@types/mdx": "^2.0.13", "@types/mdx": "^2.0.13",
"@types/node": "^25.9.1", "@types/node": "^25.9.1",
"@types/react": "^19.2.15", "@types/react": "^19.2.16",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.2", "@vitejs/plugin-react": "^6.0.2",
"@vitejs/plugin-rsc": "^0.5.26", "@vitejs/plugin-rsc": "^0.5.27",
"jsdom": "^29.1.1", "jsdom": "^29.1.1",
"typescript": "^6.0.3", "typescript": "^6.0.3",
"vite": "catalog:", "vite": "catalog:",
+4 -5
View File
@@ -29,20 +29,20 @@ function listDocsPaths(): string[] {
return out.toSorted(); return out.toSorted();
} }
// Read the registry from disk (populated by the `prebuild` script before vite // Read the registry from disk (populated by the `compile-registry` task before
// runs). We avoid `@/server/registry-base.server` because it goes through // vite runs). We avoid `@/server/registry-base.server` because it goes through
// Nitro's `useStorage`, which only exists at request time. Returns both the // Nitro's `useStorage`, which only exists at request time. Returns both the
// category landing pages (`/registry/<cat>`) and per-module detail pages // category landing pages (`/registry/<cat>`) and per-module detail pages
// (`/registry/<cat>/<id>`) so every public registry URL prerenders. // (`/registry/<cat>/<id>`) so every public registry URL prerenders.
function listRegistryPaths(): string[] { function listRegistryPaths(): string[] {
const registryPath = resolve(appRoot, "public/registry/index.json"); const registryPath = resolve(appRoot, ".registry/index.json");
let raw: string; let raw: string;
try { try {
raw = readFileSync(registryPath, "utf8"); raw = readFileSync(registryPath, "utf8");
} catch (error) { } catch (error) {
throw new Error( throw new Error(
`Prerender enumeration failed: ${registryPath} is missing. ` + `Prerender enumeration failed: ${registryPath} is missing. ` +
`Run \`jiti scripts/compile-registry.ts apps/web/public/registry\` first.`, `Run \`jiti scripts/compile-registry.ts apps/web/.registry\` first.`,
{ cause: error }, { cause: error },
); );
} }
@@ -67,7 +67,6 @@ export function listPrerenderPages() {
"/docs/llms-full.txt", "/docs/llms-full.txt",
...registry, ...registry,
"/stats", "/stats",
"/schema.json",
]; ];
return paths.map((path) => ({ path, prerender: { enabled: true } })); return paths.map((path) => ({ path, prerender: { enabled: true } }));
} }
-21
View File
@@ -10,7 +10,6 @@
import { Route as rootRouteImport } from './routes/__root' import { Route as rootRouteImport } from './routes/__root'
import { Route as StatsRouteImport } from './routes/stats' import { Route as StatsRouteImport } from './routes/stats'
import { Route as SchemaDotjsonRouteImport } from './routes/schema[.]json'
import { Route as OgDotwebpRouteImport } from './routes/og[.]webp' import { Route as OgDotwebpRouteImport } from './routes/og[.]webp'
import { Route as DocsDotmdRouteImport } from './routes/docs[.]md' import { Route as DocsDotmdRouteImport } from './routes/docs[.]md'
import { Route as IndexRouteImport } from './routes/index' import { Route as IndexRouteImport } from './routes/index'
@@ -32,11 +31,6 @@ const StatsRoute = StatsRouteImport.update({
path: '/stats', path: '/stats',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } as any)
const SchemaDotjsonRoute = SchemaDotjsonRouteImport.update({
id: '/schema.json',
path: '/schema.json',
getParentRoute: () => rootRouteImport,
} as any)
const OgDotwebpRoute = OgDotwebpRouteImport.update({ const OgDotwebpRoute = OgDotwebpRouteImport.update({
id: '/og.webp', id: '/og.webp',
path: '/og.webp', path: '/og.webp',
@@ -119,7 +113,6 @@ export interface FileRoutesByFullPath {
'/': typeof IndexRoute '/': typeof IndexRoute
'/docs.md': typeof DocsDotmdRoute '/docs.md': typeof DocsDotmdRoute
'/og.webp': typeof OgDotwebpRoute '/og.webp': typeof OgDotwebpRoute
'/schema.json': typeof SchemaDotjsonRoute
'/stats': typeof StatsRoute '/stats': typeof StatsRoute
'/api/events': typeof ApiEventsRoute '/api/events': typeof ApiEventsRoute
'/docs/$': typeof DocsSplatRoute '/docs/$': typeof DocsSplatRoute
@@ -138,7 +131,6 @@ export interface FileRoutesByTo {
'/': typeof IndexRoute '/': typeof IndexRoute
'/docs.md': typeof DocsDotmdRoute '/docs.md': typeof DocsDotmdRoute
'/og.webp': typeof OgDotwebpRoute '/og.webp': typeof OgDotwebpRoute
'/schema.json': typeof SchemaDotjsonRoute
'/stats': typeof StatsRoute '/stats': typeof StatsRoute
'/api/events': typeof ApiEventsRoute '/api/events': typeof ApiEventsRoute
'/docs/$': typeof DocsSplatRoute '/docs/$': typeof DocsSplatRoute
@@ -158,7 +150,6 @@ export interface FileRoutesById {
'/': typeof IndexRoute '/': typeof IndexRoute
'/docs.md': typeof DocsDotmdRoute '/docs.md': typeof DocsDotmdRoute
'/og.webp': typeof OgDotwebpRoute '/og.webp': typeof OgDotwebpRoute
'/schema.json': typeof SchemaDotjsonRoute
'/stats': typeof StatsRoute '/stats': typeof StatsRoute
'/api/events': typeof ApiEventsRoute '/api/events': typeof ApiEventsRoute
'/docs/$': typeof DocsSplatRoute '/docs/$': typeof DocsSplatRoute
@@ -179,7 +170,6 @@ export interface FileRouteTypes {
| '/' | '/'
| '/docs.md' | '/docs.md'
| '/og.webp' | '/og.webp'
| '/schema.json'
| '/stats' | '/stats'
| '/api/events' | '/api/events'
| '/docs/$' | '/docs/$'
@@ -198,7 +188,6 @@ export interface FileRouteTypes {
| '/' | '/'
| '/docs.md' | '/docs.md'
| '/og.webp' | '/og.webp'
| '/schema.json'
| '/stats' | '/stats'
| '/api/events' | '/api/events'
| '/docs/$' | '/docs/$'
@@ -217,7 +206,6 @@ export interface FileRouteTypes {
| '/' | '/'
| '/docs.md' | '/docs.md'
| '/og.webp' | '/og.webp'
| '/schema.json'
| '/stats' | '/stats'
| '/api/events' | '/api/events'
| '/docs/$' | '/docs/$'
@@ -237,7 +225,6 @@ export interface RootRouteChildren {
IndexRoute: typeof IndexRoute IndexRoute: typeof IndexRoute
DocsDotmdRoute: typeof DocsDotmdRoute DocsDotmdRoute: typeof DocsDotmdRoute
OgDotwebpRoute: typeof OgDotwebpRoute OgDotwebpRoute: typeof OgDotwebpRoute
SchemaDotjsonRoute: typeof SchemaDotjsonRoute
StatsRoute: typeof StatsRoute StatsRoute: typeof StatsRoute
ApiEventsRoute: typeof ApiEventsRoute ApiEventsRoute: typeof ApiEventsRoute
DocsSplatRoute: typeof DocsSplatRoute DocsSplatRoute: typeof DocsSplatRoute
@@ -262,13 +249,6 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof StatsRouteImport preLoaderRoute: typeof StatsRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof rootRouteImport
} }
'/schema.json': {
id: '/schema.json'
path: '/schema.json'
fullPath: '/schema.json'
preLoaderRoute: typeof SchemaDotjsonRouteImport
parentRoute: typeof rootRouteImport
}
'/og.webp': { '/og.webp': {
id: '/og.webp' id: '/og.webp'
path: '/og.webp' path: '/og.webp'
@@ -381,7 +361,6 @@ const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute, IndexRoute: IndexRoute,
DocsDotmdRoute: DocsDotmdRoute, DocsDotmdRoute: DocsDotmdRoute,
OgDotwebpRoute: OgDotwebpRoute, OgDotwebpRoute: OgDotwebpRoute,
SchemaDotjsonRoute: SchemaDotjsonRoute,
StatsRoute: StatsRoute, StatsRoute: StatsRoute,
ApiEventsRoute: ApiEventsRoute, ApiEventsRoute: ApiEventsRoute,
DocsSplatRoute: DocsSplatRoute, DocsSplatRoute: DocsSplatRoute,
-24
View File
@@ -1,24 +0,0 @@
import { createFileRoute } from "@tanstack/react-router";
import { StanzaManifestSchema } from "@withstanza/schema";
import { z } from "zod";
export const Route = createFileRoute("/schema.json")({
server: {
handlers: {
GET: () => {
const compiled = {
$id: "https://stanza.tools/schema.json",
title: "Stanza manifest",
description: "Schema for stanza.json — a Stanza monorepo manifest.",
...z.toJSONSchema(StanzaManifestSchema),
};
return new Response(`${JSON.stringify(compiled, null, 2)}\n`, {
headers: {
"content-type": "application/json; charset=utf-8",
},
});
},
},
},
});
+11 -9
View File
@@ -1,12 +1,14 @@
/** /**
* Reads registry data from the Nitro server-asset storage. `public/registry/` * Reads registry data from the Nitro server-asset storage. `.registry/` (the
* is registered as a `serverAssets` dir (see vite.config.ts), so its contents * build-time compiled copy, not committed) is registered as a `serverAssets`
* are embedded into the server bundle at build time — readable on serverless * dir (see vite.config.ts), so its contents are embedded into the server bundle
* hosts (Vercel) where `public/` lives only on the CDN, not in the function fs. * at build time — readable on serverless hosts (Vercel) where the function fs
* The same files are still served statically at `/registry/` for the CLI. * is otherwise empty. This is the web's own render input; the public surface
* (CLI, editors) reads the same registry from Vercel Blob via the
* `stanza.tools/registry/*.json` rewrites.
* *
* We read from storage instead of fetching ourselves because prod SSR loopback * We read from storage instead of fetching the public URL because prod SSR
* connections get refused. * loopback connections get refused.
* *
* Dev caveat: TanStack Start's server functions execute in Vite's `ssr` * Dev caveat: TanStack Start's server functions execute in Vite's `ssr`
* environment, but Nitro only injects the real `#nitro/virtual/storage` * environment, but Nitro only injects the real `#nitro/virtual/storage`
@@ -23,13 +25,13 @@ export async function loadRegistryFile<T>(relativePath: string): Promise<T> {
if (import.meta.env.DEV) { if (import.meta.env.DEV) {
const { readFile } = await import("node:fs/promises"); const { readFile } = await import("node:fs/promises");
const { resolve } = await import("node:path"); const { resolve } = await import("node:path");
const filePath = resolve(process.cwd(), "public/registry", relativePath); const filePath = resolve(process.cwd(), ".registry", relativePath);
try { try {
const parsed: T = JSON.parse(await readFile(filePath, "utf8")); const parsed: T = JSON.parse(await readFile(filePath, "utf8"));
return parsed; return parsed;
} catch { } catch {
throw new Error( throw new Error(
`Registry asset not found: ${filePath} (run \`pnpm --filter @withstanza/web prebuild\` to populate public/registry/)`, `Registry asset not found: ${filePath} (run \`vp run compile-registry\` to populate apps/web/.registry/)`,
); );
} }
} }
@@ -7,7 +7,7 @@ let modulesPromise: Promise<Record<string, Module>> | undefined;
/** /**
* Per-process cache for the full module catalog. Registry data is immutable * Per-process cache for the full module catalog. Registry data is immutable
* per deployment, so cache lifetime = process lifetime (restart the dev server * per deployment, so cache lifetime = process lifetime (restart the dev server
* after `vp run @withstanza/web#prebuild` to pick up local edits). Per-module * after the `compile-registry` task reruns to pick up local edits). Per-module
* failures are isolated — the failing module is dropped and reported. * failures are isolated — the failing module is dropped and reported.
*/ */
export function getAllModules(): Promise<Record<string, Module>> { export function getAllModules(): Promise<Record<string, Module>> {
+33 -11
View File
@@ -17,6 +17,32 @@ export default defineConfig({
plugins: lazyPlugins(async () => [ plugins: lazyPlugins(async () => [
mdx(await import("./source.config.ts")), mdx(await import("./source.config.ts")),
devtools(), devtools(),
nitro({
serverAssets: [
{
baseName: "registry",
dir: ".registry",
},
],
vercel: {
config: {
version: 3,
routes: [
{
src: "^/(llms(?:-full)?\\.txt)$",
dest: "/docs/$1",
},
{
src: "^/(schema(@[\\d.]+)?\\.json|registry/.*(@[\\d.]+)?\\.json)$",
dest: "https://cti6xxqykwjha3x9.public.blob.vercel-storage.com/$1",
headers: {
"x-vercel-enable-rewrite-caching": "1",
},
},
],
},
},
}),
tailwindcss(), tailwindcss(),
tanstackStart({ tanstackStart({
rsc: { enabled: true }, rsc: { enabled: true },
@@ -32,14 +58,6 @@ export default defineConfig({
}), }),
rsc(), rsc(),
react(), react(),
nitro({
serverAssets: [
{
baseName: "registry",
dir: "public/registry",
},
],
}),
]), ]),
resolve: { resolve: {
tsconfigPaths: true, tsconfigPaths: true,
@@ -55,9 +73,13 @@ export default defineConfig({
tasks: { tasks: {
"compile-registry": { "compile-registry": {
cwd: "../..", cwd: "../..",
command: "jiti scripts/compile-registry.ts apps/web/public/registry", command: "jiti scripts/compile-registry.ts apps/web/.registry",
input: [{ pattern: "registry/modules/**", base: "workspace" }], input: [
output: [{ pattern: "public/registry/**", base: "package" }], { pattern: "registry/modules/**", base: "workspace" },
{ pattern: "packages/schema/**", base: "workspace" },
{ pattern: "scripts/compile-registry.ts", base: "workspace" },
],
output: [{ pattern: ".registry/**", base: "package" }],
}, },
}, },
}, },
+4 -2
View File
@@ -25,11 +25,13 @@
"@changesets/changelog-github": "^0.7.0", "@changesets/changelog-github": "^0.7.0",
"@changesets/cli": "^2.31.0", "@changesets/cli": "^2.31.0",
"@types/node": "^25.9.1", "@types/node": "^25.9.1",
"@vercel/blob": "^2.4.0",
"@withstanza/schema": "workspace:*", "@withstanza/schema": "workspace:*",
"jiti": "^2.7.0", "jiti": "^2.7.0",
"svgo": "^4.0.1", "svgo": "^4.0.1",
"typescript": "^6.0.3", "typescript": "^6.0.3",
"vite-plus": "catalog:" "vite-plus": "catalog:",
"vitest": "catalog:"
}, },
"packageManager": "pnpm@11.5.0" "packageManager": "pnpm@11.5.1"
} }
@@ -189,6 +189,69 @@ describe("append-to-file", () => {
).toThrow(/not found/); ).toThrow(/not found/);
}); });
it("creates the file with just the marker block when createIfMissing is set", () => {
const { ctx, claimed } = setup("prisma/schema.prisma", PRISMA_INITIAL);
const result = appendToFile.apply(ctx, {
file: ".gitignore",
content: ".turbo/",
marker: "monorepo-turbo",
createIfMissing: true,
}) as { touchedFiles: string[] };
expect(result.touchedFiles).toEqual(["apps/web/.gitignore"]);
const text = fs.readFileSync(path.join(tmp, "apps/web/.gitignore"), "utf8");
expect(text).toBe("# stanza:monorepo-turbo:start\n.turbo/\n# stanza:monorepo-turbo:end\n");
expect(claimed).toEqual([{ file: "apps/web/.gitignore", region: "append.monorepo-turbo" }]);
});
it("createIfMissing with scope: 'repo' writes to the repo root (monorepo-turbo's path)", () => {
const { ctx, claimed } = setup("prisma/schema.prisma", PRISMA_INITIAL);
const args = {
file: ".gitignore",
content: ".turbo/",
marker: "monorepo-turbo",
createIfMissing: true,
scope: "repo" as const,
};
const result = appendToFile.apply(ctx, args) as { touchedFiles: string[] };
expect(result.touchedFiles).toEqual([".gitignore"]);
const abs = path.join(tmp, ".gitignore");
expect(fs.readFileSync(abs, "utf8")).toBe(
"# stanza:monorepo-turbo:start\n.turbo/\n# stanza:monorepo-turbo:end\n",
);
expect(claimed).toEqual([{ file: ".gitignore", region: "append.monorepo-turbo" }]);
appendToFile.revert!(ctx, args);
expect(fs.readFileSync(abs, "utf8")).toBe("");
});
it("createIfMissing creates parent directories that don't exist", () => {
const { ctx } = setup("prisma/schema.prisma", PRISMA_INITIAL);
appendToFile.apply(ctx, {
file: "nested/dir/.env",
content: "FOO=bar",
marker: "m",
createIfMissing: true,
});
const text = fs.readFileSync(path.join(tmp, "apps/web/nested/dir/.env"), "utf8");
expect(text).toContain("# stanza:m:start");
expect(text).toContain("FOO=bar");
});
it("createIfMissing then revert removes the file's contents back to empty", () => {
const { ctx } = setup("prisma/schema.prisma", PRISMA_INITIAL);
const args = {
file: ".gitignore",
content: ".turbo/",
marker: "monorepo-turbo",
createIfMissing: true,
};
appendToFile.apply(ctx, args);
appendToFile.revert!(ctx, args);
expect(fs.readFileSync(path.join(tmp, "apps/web/.gitignore"), "utf8")).toBe("");
});
it("prepends with position: 'start' (required for CSS @import)", () => { it("prepends with position: 'start' (required for CSS @import)", () => {
const baseCss = `html, body {\n margin: 0;\n}\n`; const baseCss = `html, body {\n margin: 0;\n}\n`;
const { ctx, abs } = setup("app/globals.css", baseCss); const { ctx, abs } = setup("app/globals.css", baseCss);
@@ -68,6 +68,15 @@ export type AppendToFileArgs = {
* Defaults to `true`. * Defaults to `true`.
*/ */
leadingBlank?: boolean; leadingBlank?: boolean;
/**
* When the target file doesn't exist, create it containing just the wrapped
* marker block instead of throwing. Only set this for genuinely optional
* files where a marker-only file is itself valid (`.gitignore`, `.env`,
* `.dockerignore`). Leave it off for files another module owns (a Prisma
* schema, a framework's `globals.css`) — there, a missing file is a real
* peer-ordering bug and the throw should stand.
*/
createIfMissing?: boolean;
}; };
const appendToFile: Codemod<AppendToFileArgs> = { const appendToFile: Codemod<AppendToFileArgs> = {
@@ -76,13 +85,14 @@ const appendToFile: Codemod<AppendToFileArgs> = {
apply(ctx, args) { apply(ctx, args) {
const { fileAbs, fileRel, comment } = resolve(ctx, args); const { fileAbs, fileRel, comment } = resolve(ctx, args);
if (!fs.existsSync(fileAbs)) { const exists = fs.existsSync(fileAbs);
if (!exists && !args.createIfMissing) {
throw new Error( throw new Error(
`append-to-file: ${fileRel} not found. This is an append primitive; create the file via a template first.`, `append-to-file: ${fileRel} not found. This is an append primitive; create the file via a template first or pass createIfMissing.`,
); );
} }
const current = fs.readFileSync(fileAbs, "utf8"); const current = exists ? fs.readFileSync(fileAbs, "utf8") : "";
const block = wrapBlock(args.content, args.marker, comment); const block = wrapBlock(args.content, args.marker, comment);
const existingRange = findMarkerRange(current, args.marker, comment); const existingRange = findMarkerRange(current, args.marker, comment);
@@ -103,6 +113,7 @@ const appendToFile: Codemod<AppendToFileArgs> = {
position === "start" position === "start"
? prependBlock(current, block, leadingBlank) ? prependBlock(current, block, leadingBlank)
: appendBlock(current, block, leadingBlank); : appendBlock(current, block, leadingBlank);
if (!exists) fs.mkdirSync(path.dirname(fileAbs), { recursive: true });
fs.writeFileSync(fileAbs, next, "utf8"); fs.writeFileSync(fileAbs, next, "utf8");
ctx.claimRegion(fileRel, `append.${args.marker}`); ctx.claimRegion(fileRel, `append.${args.marker}`);
+2
View File
@@ -22,12 +22,14 @@ export type {
} from "./manifest"; } from "./manifest";
export { export {
appsForRecord, appsForRecord,
compileManifestJsonSchema,
CURRENT_MANIFEST_VERSION, CURRENT_MANIFEST_VERSION,
declaredEnvNames, declaredEnvNames,
defaultWebApp, defaultWebApp,
emptyManifest, emptyManifest,
getApp, getApp,
MANIFEST_SCHEMA_URL, MANIFEST_SCHEMA_URL,
REGISTRY_BASE_URL,
selectedAll, selectedAll,
selectedOne, selectedOne,
StanzaManifestSchema, StanzaManifestSchema,
+25
View File
@@ -26,6 +26,31 @@ export const SUPPORTED_MANIFEST_VERSIONS = ["0.3", "0.4"] as const;
/** Canonical public URL of the published `stanza.json` JSON Schema. */ /** Canonical public URL of the published `stanza.json` JSON Schema. */
export const MANIFEST_SCHEMA_URL = "https://stanza.tools/schema.json"; export const MANIFEST_SCHEMA_URL = "https://stanza.tools/schema.json";
/**
* Base URL the first-party registry is served from. Path-transparent rewrites
* map `stanza.tools/registry/<file>.json` onto the Vercel Blob store, so the
* store stays a swappable implementation detail behind this branded origin. The
* index lives at `<base>/index.json`, each module's latest at `<base>/<slug>.json`,
* and immutable version pins at `<base>/<slug>@<version>.json`. The CLI read
* path for pinned versions is deferred to `update`/`swap`.
*/
export const REGISTRY_BASE_URL = "https://stanza.tools/registry";
/**
* Compile the published JSON Schema for `stanza.json` from the Zod source of
* truth. The release-time publish script serializes this to Blob (`schema.json`
* + `schema@<version>.json`); it's exported so any caller produces byte-
* identical output. Returns a plain JSON-Schema object.
*/
export function compileManifestJsonSchema(): Record<string, unknown> {
return {
$id: MANIFEST_SCHEMA_URL,
title: "Stanza manifest",
description: "Schema for stanza.json — a Stanza monorepo manifest.",
...z.toJSONSchema(StanzaManifestSchema),
};
}
/** /**
* An app inside the monorepo. The `id` doubles as the workspace-package suffix * An app inside the monorepo. The `id` doubles as the workspace-package suffix
* (`@<manifest.name>/<id>`), the URL/CLI handle (`--app=<id>`), and the key * (`@<manifest.name>/<id>`), the URL/CLI handle (`--app=<id>`), and the key
+721 -596
View File
File diff suppressed because it is too large Load Diff
+9 -3
View File
@@ -3,9 +3,9 @@ packages:
- packages/* - packages/*
- registry/modules/* - registry/modules/*
catalog: catalog:
vite: npm:@voidzero-dev/vite-plus-core@^0.1.23 vite: npm:@voidzero-dev/vite-plus-core@^0.1.24
vite-plus: ^0.1.23 vite-plus: ^0.1.24
vitest: npm:@voidzero-dev/vite-plus-test@^0.1.23 vitest: npm:@voidzero-dev/vite-plus-test@^0.1.24
ignoredOptionalDependencies: [] ignoredOptionalDependencies: []
overrides: overrides:
vite: "catalog:" vite: "catalog:"
@@ -23,3 +23,9 @@ allowBuilds:
msw: true msw: true
protobufjs: true protobufjs: true
sharp: false sharp: false
minimumReleaseAgeExclude:
- "@voidzero-dev/*"
- "@withstanza/*"
- vite
- vite-plus
- vitest
+3 -1
View File
@@ -27,7 +27,8 @@ export default defineModule({
// Append `.turbo/` to .gitignore for projects that didn't init with it. // Append `.turbo/` to .gitignore for projects that didn't init with it.
// Idempotent: fresh `stanza init` already lists `.turbo/`, so the marker // Idempotent: fresh `stanza init` already lists `.turbo/`, so the marker
// block is a no-op there; `stanza add monorepo turbo` on an old project // block is a no-op there; `stanza add monorepo turbo` on an old project
// gains the entry. // gains the entry. `createIfMissing` covers existing projects with no
// root .gitignore at all — a marker-only file is a valid result here.
codemods: [ codemods: [
{ {
id: "append-to-file", id: "append-to-file",
@@ -36,6 +37,7 @@ export default defineModule({
scope: "repo", scope: "repo",
marker: "monorepo-turbo", marker: "monorepo-turbo",
content: ".turbo/", content: ".turbo/",
createIfMissing: true,
}, },
}, },
], ],
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@stanza-modules/ui-tailwind", "name": "@stanza-modules/ui-tailwind",
"version": "0.1.0", "version": "0.2.0",
"private": true, "private": true,
"license": "MIT", "license": "MIT",
"type": "module", "type": "module",
+6 -1
View File
@@ -1,4 +1,9 @@
{ {
"$schema": "https://docs.renovatebot.com/renovate-schema.json", "$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:recommended"] "extends": [
"config:recommended",
":configMigration",
"helpers:pinGitHubActionDigests",
"security:minimumReleaseAgeNpm"
]
} }
+74
View File
@@ -0,0 +1,74 @@
/**
* PR guard (tokenless, read-only). Protects the immutability of the registry's
* per-module version pins: for every changed `registry/modules/<slug>/**`,
* compile that module and compare it against the already-published
* `<base>/<slug>@<version>.json`. If the content changed but the version didn't,
* fail — the author must bump the module's `package.json` version (a published
* pin is immutable). A 404 (new version or new module) passes.
*
* Run via `jiti scripts/check-module-versions.ts`.
*/
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { REGISTRY_BASE_URL } from "@withstanza/schema";
import { compileRegistry } from "./compile-registry.ts";
const repoRoot = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
const registryBase = process.env.STANZA_REGISTRY_BASE ?? REGISTRY_BASE_URL;
const git = (args: string[]) =>
execFileSync("git", args, { cwd: repoRoot, encoding: "utf8" }).trim();
const base = process.env.GITHUB_BASE_REF ? `origin/${process.env.GITHUB_BASE_REF}` : "origin/main";
let range: string;
try {
range = git(["merge-base", base, "HEAD"]);
} catch {
range = "HEAD~1"; // local fallback
}
const slugs = new Set<string>();
for (const f of git(["diff", "--name-only", range, "HEAD"]).split("\n")) {
const m = /^registry\/modules\/([^/]+)\//.exec(f);
if (m) slugs.add(m[1]!);
}
if (slugs.size === 0) {
console.log("Module version guard passed (no module changes).");
process.exit(0);
}
const compiledDir = fs.mkdtempSync(path.join(os.tmpdir(), "stanza-check-"));
await compileRegistry({ outDir: compiledDir });
const errors: string[] = [];
for (const slug of slugs) {
const file = path.join(compiledDir, `${slug}.json`);
if (!fs.existsSync(file)) continue; // module deleted — nothing to pin
const text = fs.readFileSync(file, "utf8");
const { version }: { version: string } = JSON.parse(text);
const res = await fetch(`${registryBase}/${slug}@${version}.json`);
if (res.status === 404) continue; // new version or new module
if (!res.ok) {
errors.push(`Could not verify "${slug}" (HTTP ${res.status}).`);
continue;
}
if ((await res.text()) !== text) {
errors.push(
`Module "${slug}" changed but version ${version} is already published with different ` +
`content. Bump "version" in registry/modules/${slug}/package.json.`,
);
}
}
if (errors.length > 0) {
console.error("Module version guard failed:");
for (const e of errors) console.error(`${e}`);
process.exit(1);
}
console.log("Module version guard passed.");
+42 -18
View File
@@ -1,16 +1,17 @@
/** /**
* Static registry build. Scans `registry/modules/*`, imports each module's * Static registry build. Scans `registry/modules/*`, imports each module's
* default export, writes: * default export, writes:
* - <out>/index.json — the main file (per-module * - <out>/index.json — the main file (per-module
* metadata, each carrying `path`) * metadata, each carrying `path`)
* - <out>/modules/<slot>-<id>.json — per-module full manifests * - <out>/<category>-<id>.json — per-module full manifests
* *
* The output base defaults to `<repoRoot>/dist`. Pass a positional arg to * Output is flat (no `modules/` subdir) so it maps 1:1 onto the Blob store and
* redirect — e.g. the web app's `compile-registry` task points it at * the path-transparent `stanza.tools/registry/<file>.json` rewrites. The output
* `apps/web/public/registry`. * base defaults to `<repoRoot>/dist`. Pass a positional arg to redirect — e.g.
* the web app's `compile-registry` task points it at `apps/web/.registry`.
* *
* A standalone build tool (not part of any package): the web prebuild, the * A standalone build tool (not part of any package): the web's compile-registry
* Blob upload in CI, and the CLI test harness all invoke it via * task, the Blob upload in CI, and the CLI test harness all invoke it via
* `jiti scripts/compile-registry.ts [outDir]`. `compileRegistry()` is also * `jiti scripts/compile-registry.ts [outDir]`. `compileRegistry()` is also
* exported for any in-process caller. * exported for any in-process caller.
*/ */
@@ -34,11 +35,13 @@ export async function compileRegistry(opts: {
const repoRoot = findRepoRoot(here); const repoRoot = findRepoRoot(here);
const modulesDir = opts.modulesDir ?? path.join(repoRoot, "registry", "modules"); const modulesDir = opts.modulesDir ?? path.join(repoRoot, "registry", "modules");
// Wipe + recreate so a renamed module's stale JSON doesn't linger and // Clear stale `*.json` (including a previous `index.json`) so a renamed or
// ghost-serve from the CDN. // removed module's file doesn't linger and ghost-serve. Leaves any other dir
const modulesOut = path.join(opts.outDir, "modules"); // contents alone; the out dir is recreated if absent.
fs.rmSync(modulesOut, { recursive: true, force: true }); fs.mkdirSync(opts.outDir, { recursive: true });
fs.mkdirSync(modulesOut, { recursive: true }); for (const f of fs.readdirSync(opts.outDir)) {
if (f.endsWith(".json")) fs.rmSync(path.join(opts.outDir, f));
}
const dirs = fs const dirs = fs
.readdirSync(modulesDir, { withFileTypes: true }) .readdirSync(modulesDir, { withFileTypes: true })
@@ -59,8 +62,14 @@ export async function compileRegistry(opts: {
const templatesDir = path.join(modulesDir, dir, "templates"); const templatesDir = path.join(modulesDir, dir, "templates");
const logo = readLogo(path.join(modulesDir, dir), dir); const logo = readLogo(path.join(modulesDir, dir), dir);
const readme = readReadme(path.join(modulesDir, dir)); const readme = readReadme(path.join(modulesDir, dir));
// The module's `package.json` version is the single source of truth — it's
// what `stanza add` pins into `stanza.json` and the key the Blob archive
// pins each immutable per-module file under. `module.ts`'s own `version`
// is vestigial (the two had already drifted), so we override it here.
const version = readPackageVersion(path.join(modulesDir, dir));
const inlined: Module = { const inlined: Module = {
...mod, ...mod,
version,
...(logo ? { logo } : {}), ...(logo ? { logo } : {}),
...(readme ? { readme } : {}), ...(readme ? { readme } : {}),
adapters: mod.adapters.map((adapter) => ({ adapters: mod.adapters.map((adapter) => ({
@@ -72,11 +81,11 @@ export async function compileRegistry(opts: {
})), })),
}; };
// Per-module files live at `modules/<category>-<id>.json`; the index // Per-module files live flat at `<category>-<id>.json`; the index records
// records each one's relative `path` so the loader never has to infer a // each one's relative `path` so the loader never has to infer a filename.
// filename. (The physical layout is the build's choice — the contract is // (The physical layout is the build's choice — the contract is the explicit
// the explicit `path`.) // `path`, resolved relative to the index URL.)
const modulePath = `modules/${mod.category}-${mod.id}.json`; const modulePath = `${mod.category}-${mod.id}.json`;
fs.writeFileSync(path.join(opts.outDir, modulePath), JSON.stringify(inlined, null, 2)); fs.writeFileSync(path.join(opts.outDir, modulePath), JSON.stringify(inlined, null, 2));
// The index keeps lightweight metadata — no template `content`, no // The index keeps lightweight metadata — no template `content`, no
@@ -162,6 +171,21 @@ function readReadme(moduleDir: string): string | undefined {
return undefined; return undefined;
} }
/**
* Each module dir is a private workspace package; its `package.json` version is
* the source of truth for the module's published version (pinned into
* `stanza.json` and used as the Blob archive key). Throws if missing so a new
* module can't ship without a version.
*/
function readPackageVersion(moduleDir: string): string {
const file = path.join(moduleDir, "package.json");
const pkg: { version?: unknown } = JSON.parse(fs.readFileSync(file, "utf8"));
if (typeof pkg.version !== "string" || pkg.version.length === 0) {
throw new Error(`Module ${moduleDir} has no \`version\` in package.json.`);
}
return pkg.version;
}
function findRepoRoot(start: string): string { function findRepoRoot(start: string): string {
let dir = start; let dir = start;
for (let i = 0; i < 8; i++) { for (let i = 0; i < 8; i++) {
+89
View File
@@ -0,0 +1,89 @@
/**
* Publishes the registry + manifest JSON Schema to Vercel Blob, the single
* origin behind the path-transparent `stanza.tools/registry/*.json` and
* `stanza.tools/schema*.json` rewrites. Runs on every push to `main`, so a
* registry change goes live without a release. Blob layout:
*
* registry/index.json latest index (overwrite)
* registry/<category>-<id>.json latest module (overwrite)
* registry/<category>-<id>@<ver>.json immutable module pin (write-if-absent)
* schema.json latest schema (overwrite)
* schema@<ver>.json immutable schema pin (write-if-absent)
*
* "Latest" files overwrite each run; version pins are written once and skipped
* if already published (immutable — reproducing a pinned `stanza.json` relies
* on them never changing). Drift (changed content under an existing version) is
* caught on PRs by `check-module-versions.ts`.
*
* Run via `jiti scripts/publish-registry.ts` (reads `BLOB_READ_WRITE_TOKEN`).
*/
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { BlobNotFoundError, head, put } from "@vercel/blob";
import { compileManifestJsonSchema } from "@withstanza/schema";
import { compileRegistry } from "./compile-registry.ts";
const token = process.env.BLOB_READ_WRITE_TOKEN;
if (!token) {
console.error("BLOB_READ_WRITE_TOKEN is required to publish the registry.");
process.exit(1);
}
const repoRoot = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
const outDir = fs.mkdtempSync(path.join(os.tmpdir(), "stanza-publish-"));
await compileRegistry({ outDir });
async function exists(pathname: string): Promise<boolean> {
try {
await head(pathname, { token });
return true;
} catch (err) {
if (err instanceof BlobNotFoundError) return false;
throw err;
}
}
function upload(pathname: string, content: string, allowOverwrite: boolean): Promise<unknown> {
return put(pathname, content, {
access: "public",
addRandomSuffix: false,
allowOverwrite,
contentType: "application/json",
token,
});
}
/** Refresh `<base>.json` (overwrite) + write the immutable `<base>@<version>.json` pin once. */
async function publish(base: string, version: string, content: string): Promise<boolean> {
await upload(`${base}.json`, content, true);
const pin = `${base}@${version}.json`;
const fresh = !(await exists(pin));
if (fresh) await upload(pin, content, false);
return fresh;
}
// Modules: every compiled `<slug>.json` (the filename is the slug). `index.json`
// is the registry main file — uploaded as latest only (it carries the
// nondeterministic `generatedAt`, so it isn't version-pinned).
let newPins = 0;
for (const file of fs.readdirSync(outDir)) {
if (file === "index.json") continue;
const slug = file.replace(/\.json$/, "");
const text = fs.readFileSync(path.join(outDir, file), "utf8");
const { version }: { version: string } = JSON.parse(text);
if (await publish(`registry/${slug}`, version, text)) newPins++;
}
await upload("registry/index.json", fs.readFileSync(path.join(outDir, "index.json"), "utf8"), true);
const { version: schemaVersion }: { version: string } = JSON.parse(
fs.readFileSync(path.join(repoRoot, "packages", "schema", "package.json"), "utf8"),
);
const schemaJson = `${JSON.stringify(compileManifestJsonSchema(), null, 2)}\n`;
if (await publish("schema", schemaVersion, schemaJson)) newPins++;
console.log(`Published registry latest + ${newPins} new version pin(s).`);
+1 -1
View File
@@ -28,7 +28,7 @@ export default defineConfig({
"**/.source/**", "**/.source/**",
"**/routeTree.gen.ts", "**/routeTree.gen.ts",
"**/coverage/**", "**/coverage/**",
"apps/web/public/registry/**", "apps/web/.registry/**",
"registry/modules/*/logo*.svg", "registry/modules/*/logo*.svg",
"registry/modules/*/templates/**", "registry/modules/*/templates/**",
], ],