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
timeout-minutes: 10
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:
node-version: 24
cache: true
@@ -35,3 +35,31 @@ jobs:
run: |
node apps/cli/dist/bin.mjs --version
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: {}
jobs:
release:
name: Version & publish
npm:
name: Publish to NPM
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
@@ -19,12 +19,12 @@ jobs:
pull-requests: write
id-token: write
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # ratchet:actions/checkout@v6
with:
# Changesets needs full history to compute the changelog.
fetch-depth: 0
- uses: voidzero-dev/setup-vp@v1
- uses: voidzero-dev/setup-vp@2dec1e33f4ab2c6d5bce1b0c4607961bb1a3f7a1 # ratchet:voidzero-dev/setup-vp@v1
with:
node-version: 24
cache: true
@@ -43,7 +43,7 @@ jobs:
- name: Create release PR or publish
id: changesets
uses: changesets/action@v1
uses: changesets/action@a45c4d594aa4e2c509dc14a9f2b3b67ba3780d0d # ratchet:changesets/action@v1
with:
version: vp run version
publish: vp run release
@@ -51,3 +51,37 @@ jobs:
commit: "chore: release"
env:
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 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 @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
- 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
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
- **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 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)
## Module authoring
+1 -1
View File
@@ -47,7 +47,7 @@
"build": "vp pack"
},
"dependencies": {
"@clack/prompts": "^1.5.0",
"@clack/prompts": "^1.5.1",
"citty": "^0.2.2",
"handlebars": "^4.7.9",
"jsonc-parser": "^3.3.1",
+10 -10
View File
@@ -29,8 +29,8 @@ let fixtureMain: string;
beforeAll(() => {
fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), "stanza-reg-"));
// 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
// production build path rather than an in-process import.
// same entrypoint CI and the web's compile-registry task use — so the test
// exercises the production build path rather than an in-process import.
execFileSync(
path.join(repoRoot, "node_modules/.bin/jiti"),
["scripts/compile-registry.ts", fixtureRoot],
@@ -554,14 +554,14 @@ describe("third-party registries", () => {
};
return Object.assign({}, m, {
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 });
return;
}
// Per-module full manifests at the `path` advertised by the index.
const match = /^\/modules\/([^?]+)\.json/.exec(url);
// Per-module full manifests at the flat `path` advertised by the index.
const match = /^\/([^/?]+)\.json/.exec(url);
const payload = match ? fixture.modules[match[1]!] : undefined;
if (!payload) {
res.statusCode = 404;
@@ -625,7 +625,7 @@ describe("third-party registries", () => {
await cmdRemove(args({ slot: "testing", moduleId: "@fixture/cosmos" }));
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"));
expect(manifest.modules.testing).toBeUndefined();
@@ -650,7 +650,7 @@ describe("third-party registries", () => {
let captured: Record<string, string | string[] | undefined> | undefined;
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";
@@ -775,7 +775,7 @@ describe("filesystem main-file registry", () => {
// resolve relative to the file. No directory or filename inference.
it("loads the index and modules from a main-file URI", async () => {
const root = path.join(tmp, "reg");
fs.mkdirSync(path.join(root, "modules"), { recursive: true });
fs.mkdirSync(root, { recursive: true });
const cosmos = cosmosModule();
const mainFile = path.join(root, "main.json"); // any filename works
fs.writeFileSync(
@@ -788,12 +788,12 @@ describe("filesystem main-file registry", () => {
{
...cosmos,
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;
const registry = await loadRegistries();
+1 -2
View File
@@ -1,2 +1 @@
public/registry/
public/schema.json
.registry/
+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:
```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
# Or point it elsewhere (this is what the web app's prebuild does):
jiti scripts/compile-registry.ts apps/web/public/registry
# Or point it elsewhere (this is what the web app's compile-registry task does):
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.
+11 -11
View File
@@ -22,11 +22,11 @@
"@tanstack/react-devtools": "^0.10.5",
"@tanstack/react-hotkeys": "^0.10.0",
"@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-start": "^1.168.18",
"@tanstack/react-start": "^1.168.19",
"@vercel/analytics": "^2.0.1",
"@vercel/functions": "^3.6.1",
"@vercel/functions": "^3.6.2",
"@withstanza/registry": "workspace:*",
"@withstanza/schema": "workspace:*",
"class-variance-authority": "^0.7.1",
@@ -36,14 +36,14 @@
"fumadocs-ui": "npm:@fumadocs/base-ui@^16.9.3",
"lru-cache": "^11.5.1",
"motion": "^12.40.0",
"nitro": "^3.0.260522-beta",
"posthog-node": "^5.35.6",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"nitro": "3.0.260603-beta",
"posthog-node": "^5.35.13",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-resizable-panels": "^4.11.2",
"recharts": "^3.8.1",
"shadcn": "^4.8.3",
"shiki": "^4.1.0",
"shadcn": "^4.10.0",
"shiki": "^4.2.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.6.0",
"tailwindcss": "^4.3.0",
@@ -57,10 +57,10 @@
"@testing-library/react": "^16.3.2",
"@types/mdx": "^2.0.13",
"@types/node": "^25.9.1",
"@types/react": "^19.2.15",
"@types/react": "^19.2.16",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.2",
"@vitejs/plugin-rsc": "^0.5.26",
"@vitejs/plugin-rsc": "^0.5.27",
"jsdom": "^29.1.1",
"typescript": "^6.0.3",
"vite": "catalog:",
+4 -5
View File
@@ -29,20 +29,20 @@ function listDocsPaths(): string[] {
return out.toSorted();
}
// Read the registry from disk (populated by the `prebuild` script before vite
// runs). We avoid `@/server/registry-base.server` because it goes through
// Read the registry from disk (populated by the `compile-registry` task before
// vite runs). We avoid `@/server/registry-base.server` because it goes through
// Nitro's `useStorage`, which only exists at request time. Returns both the
// category landing pages (`/registry/<cat>`) and per-module detail pages
// (`/registry/<cat>/<id>`) so every public registry URL prerenders.
function listRegistryPaths(): string[] {
const registryPath = resolve(appRoot, "public/registry/index.json");
const registryPath = resolve(appRoot, ".registry/index.json");
let raw: string;
try {
raw = readFileSync(registryPath, "utf8");
} catch (error) {
throw new Error(
`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 },
);
}
@@ -67,7 +67,6 @@ export function listPrerenderPages() {
"/docs/llms-full.txt",
...registry,
"/stats",
"/schema.json",
];
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 StatsRouteImport } from './routes/stats'
import { Route as SchemaDotjsonRouteImport } from './routes/schema[.]json'
import { Route as OgDotwebpRouteImport } from './routes/og[.]webp'
import { Route as DocsDotmdRouteImport } from './routes/docs[.]md'
import { Route as IndexRouteImport } from './routes/index'
@@ -32,11 +31,6 @@ const StatsRoute = StatsRouteImport.update({
path: '/stats',
getParentRoute: () => rootRouteImport,
} as any)
const SchemaDotjsonRoute = SchemaDotjsonRouteImport.update({
id: '/schema.json',
path: '/schema.json',
getParentRoute: () => rootRouteImport,
} as any)
const OgDotwebpRoute = OgDotwebpRouteImport.update({
id: '/og.webp',
path: '/og.webp',
@@ -119,7 +113,6 @@ export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/docs.md': typeof DocsDotmdRoute
'/og.webp': typeof OgDotwebpRoute
'/schema.json': typeof SchemaDotjsonRoute
'/stats': typeof StatsRoute
'/api/events': typeof ApiEventsRoute
'/docs/$': typeof DocsSplatRoute
@@ -138,7 +131,6 @@ export interface FileRoutesByTo {
'/': typeof IndexRoute
'/docs.md': typeof DocsDotmdRoute
'/og.webp': typeof OgDotwebpRoute
'/schema.json': typeof SchemaDotjsonRoute
'/stats': typeof StatsRoute
'/api/events': typeof ApiEventsRoute
'/docs/$': typeof DocsSplatRoute
@@ -158,7 +150,6 @@ export interface FileRoutesById {
'/': typeof IndexRoute
'/docs.md': typeof DocsDotmdRoute
'/og.webp': typeof OgDotwebpRoute
'/schema.json': typeof SchemaDotjsonRoute
'/stats': typeof StatsRoute
'/api/events': typeof ApiEventsRoute
'/docs/$': typeof DocsSplatRoute
@@ -179,7 +170,6 @@ export interface FileRouteTypes {
| '/'
| '/docs.md'
| '/og.webp'
| '/schema.json'
| '/stats'
| '/api/events'
| '/docs/$'
@@ -198,7 +188,6 @@ export interface FileRouteTypes {
| '/'
| '/docs.md'
| '/og.webp'
| '/schema.json'
| '/stats'
| '/api/events'
| '/docs/$'
@@ -217,7 +206,6 @@ export interface FileRouteTypes {
| '/'
| '/docs.md'
| '/og.webp'
| '/schema.json'
| '/stats'
| '/api/events'
| '/docs/$'
@@ -237,7 +225,6 @@ export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
DocsDotmdRoute: typeof DocsDotmdRoute
OgDotwebpRoute: typeof OgDotwebpRoute
SchemaDotjsonRoute: typeof SchemaDotjsonRoute
StatsRoute: typeof StatsRoute
ApiEventsRoute: typeof ApiEventsRoute
DocsSplatRoute: typeof DocsSplatRoute
@@ -262,13 +249,6 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof StatsRouteImport
parentRoute: typeof rootRouteImport
}
'/schema.json': {
id: '/schema.json'
path: '/schema.json'
fullPath: '/schema.json'
preLoaderRoute: typeof SchemaDotjsonRouteImport
parentRoute: typeof rootRouteImport
}
'/og.webp': {
id: '/og.webp'
path: '/og.webp'
@@ -381,7 +361,6 @@ const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
DocsDotmdRoute: DocsDotmdRoute,
OgDotwebpRoute: OgDotwebpRoute,
SchemaDotjsonRoute: SchemaDotjsonRoute,
StatsRoute: StatsRoute,
ApiEventsRoute: ApiEventsRoute,
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/`
* is registered as a `serverAssets` dir (see vite.config.ts), so its contents
* are embedded into the server bundle at build time — readable on serverless
* hosts (Vercel) where `public/` lives only on the CDN, not in the function fs.
* The same files are still served statically at `/registry/` for the CLI.
* Reads registry data from the Nitro server-asset storage. `.registry/` (the
* build-time compiled copy, not committed) is registered as a `serverAssets`
* dir (see vite.config.ts), so its contents are embedded into the server bundle
* at build time — readable on serverless hosts (Vercel) where the function fs
* 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
* connections get refused.
* We read from storage instead of fetching the public URL because prod SSR
* loopback connections get refused.
*
* Dev caveat: TanStack Start's server functions execute in Vite's `ssr`
* 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) {
const { readFile } = await import("node:fs/promises");
const { resolve } = await import("node:path");
const filePath = resolve(process.cwd(), "public/registry", relativePath);
const filePath = resolve(process.cwd(), ".registry", relativePath);
try {
const parsed: T = JSON.parse(await readFile(filePath, "utf8"));
return parsed;
} catch {
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 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.
*/
export function getAllModules(): Promise<Record<string, Module>> {
+33 -11
View File
@@ -17,6 +17,32 @@ export default defineConfig({
plugins: lazyPlugins(async () => [
mdx(await import("./source.config.ts")),
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(),
tanstackStart({
rsc: { enabled: true },
@@ -32,14 +58,6 @@ export default defineConfig({
}),
rsc(),
react(),
nitro({
serverAssets: [
{
baseName: "registry",
dir: "public/registry",
},
],
}),
]),
resolve: {
tsconfigPaths: true,
@@ -55,9 +73,13 @@ export default defineConfig({
tasks: {
"compile-registry": {
cwd: "../..",
command: "jiti scripts/compile-registry.ts apps/web/public/registry",
input: [{ pattern: "registry/modules/**", base: "workspace" }],
output: [{ pattern: "public/registry/**", base: "package" }],
command: "jiti scripts/compile-registry.ts apps/web/.registry",
input: [
{ 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/cli": "^2.31.0",
"@types/node": "^25.9.1",
"@vercel/blob": "^2.4.0",
"@withstanza/schema": "workspace:*",
"jiti": "^2.7.0",
"svgo": "^4.0.1",
"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/);
});
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)", () => {
const baseCss = `html, body {\n margin: 0;\n}\n`;
const { ctx, abs } = setup("app/globals.css", baseCss);
@@ -68,6 +68,15 @@ export type AppendToFileArgs = {
* Defaults to `true`.
*/
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> = {
@@ -76,13 +85,14 @@ const appendToFile: Codemod<AppendToFileArgs> = {
apply(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(
`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 existingRange = findMarkerRange(current, args.marker, comment);
@@ -103,6 +113,7 @@ const appendToFile: Codemod<AppendToFileArgs> = {
position === "start"
? prependBlock(current, block, leadingBlank)
: appendBlock(current, block, leadingBlank);
if (!exists) fs.mkdirSync(path.dirname(fileAbs), { recursive: true });
fs.writeFileSync(fileAbs, next, "utf8");
ctx.claimRegion(fileRel, `append.${args.marker}`);
+2
View File
@@ -22,12 +22,14 @@ export type {
} from "./manifest";
export {
appsForRecord,
compileManifestJsonSchema,
CURRENT_MANIFEST_VERSION,
declaredEnvNames,
defaultWebApp,
emptyManifest,
getApp,
MANIFEST_SCHEMA_URL,
REGISTRY_BASE_URL,
selectedAll,
selectedOne,
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. */
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
* (`@<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/*
- registry/modules/*
catalog:
vite: npm:@voidzero-dev/vite-plus-core@^0.1.23
vite-plus: ^0.1.23
vitest: npm:@voidzero-dev/vite-plus-test@^0.1.23
vite: npm:@voidzero-dev/vite-plus-core@^0.1.24
vite-plus: ^0.1.24
vitest: npm:@voidzero-dev/vite-plus-test@^0.1.24
ignoredOptionalDependencies: []
overrides:
vite: "catalog:"
@@ -23,3 +23,9 @@ allowBuilds:
msw: true
protobufjs: true
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.
// Idempotent: fresh `stanza init` already lists `.turbo/`, so the marker
// 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: [
{
id: "append-to-file",
@@ -36,6 +37,7 @@ export default defineModule({
scope: "repo",
marker: "monorepo-turbo",
content: ".turbo/",
createIfMissing: true,
},
},
],
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@stanza-modules/ui-tailwind",
"version": "0.1.0",
"version": "0.2.0",
"private": true,
"license": "MIT",
"type": "module",
+6 -1
View File
@@ -1,4 +1,9 @@
{
"$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.");
+40 -16
View File
@@ -3,14 +3,15 @@
* default export, writes:
* - <out>/index.json — the main file (per-module
* 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
* redirect — e.g. the web app's `compile-registry` task points it at
* `apps/web/public/registry`.
* Output is flat (no `modules/` subdir) so it maps 1:1 onto the Blob store and
* the path-transparent `stanza.tools/registry/<file>.json` rewrites. The output
* 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
* Blob upload in CI, and the CLI test harness all invoke it via
* A standalone build tool (not part of any package): the web's compile-registry
* task, the Blob upload in CI, and the CLI test harness all invoke it via
* `jiti scripts/compile-registry.ts [outDir]`. `compileRegistry()` is also
* exported for any in-process caller.
*/
@@ -34,11 +35,13 @@ export async function compileRegistry(opts: {
const repoRoot = findRepoRoot(here);
const modulesDir = opts.modulesDir ?? path.join(repoRoot, "registry", "modules");
// Wipe + recreate so a renamed module's stale JSON doesn't linger and
// ghost-serve from the CDN.
const modulesOut = path.join(opts.outDir, "modules");
fs.rmSync(modulesOut, { recursive: true, force: true });
fs.mkdirSync(modulesOut, { recursive: true });
// Clear stale `*.json` (including a previous `index.json`) so a renamed or
// removed module's file doesn't linger and ghost-serve. Leaves any other dir
// contents alone; the out dir is recreated if absent.
fs.mkdirSync(opts.outDir, { recursive: true });
for (const f of fs.readdirSync(opts.outDir)) {
if (f.endsWith(".json")) fs.rmSync(path.join(opts.outDir, f));
}
const dirs = fs
.readdirSync(modulesDir, { withFileTypes: true })
@@ -59,8 +62,14 @@ export async function compileRegistry(opts: {
const templatesDir = path.join(modulesDir, dir, "templates");
const logo = readLogo(path.join(modulesDir, dir), 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 = {
...mod,
version,
...(logo ? { logo } : {}),
...(readme ? { readme } : {}),
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
// records each one's relative `path` so the loader never has to infer a
// filename. (The physical layout is the build's choice — the contract is
// the explicit `path`.)
const modulePath = `modules/${mod.category}-${mod.id}.json`;
// Per-module files live flat at `<category>-<id>.json`; the index records
// each one's relative `path` so the loader never has to infer a filename.
// (The physical layout is the build's choice — the contract is the explicit
// `path`, resolved relative to the index URL.)
const modulePath = `${mod.category}-${mod.id}.json`;
fs.writeFileSync(path.join(opts.outDir, modulePath), JSON.stringify(inlined, null, 2));
// The index keeps lightweight metadata — no template `content`, no
@@ -162,6 +171,21 @@ function readReadme(moduleDir: string): string | 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 {
let dir = start;
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/**",
"**/routeTree.gen.ts",
"**/coverage/**",
"apps/web/public/registry/**",
"apps/web/.registry/**",
"registry/modules/*/logo*.svg",
"registry/modules/*/templates/**",
],