mirror of
https://github.com/jakejarvis/stanza.git
synced 2026-07-16 16:55:58 -04:00
refactor: serve registry and manifest schema from Vercel Blob as canonical origin (#18)
This commit is contained in:
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -1,2 +1 @@
|
||||
public/registry/
|
||||
public/schema.json
|
||||
.registry/
|
||||
|
||||
@@ -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
@@ -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:",
|
||||
|
||||
@@ -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 } }));
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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}`);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+721
-596
File diff suppressed because it is too large
Load Diff
+9
-3
@@ -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
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@stanza-modules/ui-tailwind",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
+6
-1
@@ -1,4 +1,9 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": ["config:recommended"]
|
||||
"extends": [
|
||||
"config:recommended",
|
||||
":configMigration",
|
||||
"helpers:pinGitHubActionDigests",
|
||||
"security:minimumReleaseAgeNpm"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
@@ -1,16 +1,17 @@
|
||||
/**
|
||||
* Static registry build. Scans `registry/modules/*`, imports each module's
|
||||
* 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>/index.json — the main file (per-module
|
||||
* metadata, each carrying `path`)
|
||||
* - <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++) {
|
||||
|
||||
@@ -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
@@ -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/**",
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user