mirror of
https://github.com/jakejarvis/stanza.git
synced 2026-07-16 18:05:58 -04:00
326 lines
12 KiB
TypeScript
326 lines
12 KiB
TypeScript
import { z } from "zod";
|
|
|
|
import {
|
|
APP_KINDS,
|
|
type AppKind,
|
|
categoryCardinality,
|
|
categoryHome,
|
|
type CategoryId,
|
|
KNOWN_CATEGORIES,
|
|
type ModuleId,
|
|
} from "./category";
|
|
import { type PackageManager, PackageManagerSchema } from "./package-manager";
|
|
import { type RegistryConfig, RegistriesSchema } from "./registry-config";
|
|
|
|
export const CURRENT_MANIFEST_VERSION = "0.4" as const;
|
|
|
|
/**
|
|
* Past schema versions a new CLI can still read. Since each bump so far has
|
|
* only added optional fields, older manifests parse cleanly under the current
|
|
* schema — `readManifest` re-stamps the version to {@link CURRENT_MANIFEST_VERSION}
|
|
* on load so the in-memory object is always current, and the next write
|
|
* persists the upgrade. Add prior versions here as the schema evolves.
|
|
*/
|
|
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
|
|
* module records use in their `apps` field. `dir` is repo-relative; `kind`
|
|
* determines which framework modules can install into it.
|
|
*/
|
|
export type AppSpec = {
|
|
id: string;
|
|
dir: string;
|
|
kind: AppKind;
|
|
};
|
|
|
|
export type StanzaModuleRecord = {
|
|
id: ModuleId;
|
|
/**
|
|
* Pinned module version at install time. Recorded now so the upcoming
|
|
* `swap` and `update` verbs can read it; not consumed yet.
|
|
*/
|
|
version: string;
|
|
/** Adapter key chosen at install time (function of peer categories). */
|
|
adapter: string;
|
|
/**
|
|
* Which apps this install targets, by `AppSpec.id`. Semantics depend on the
|
|
* module's category `home`:
|
|
*
|
|
* - `home: "app"` — **required** (length ≥ 1). For single-cardinality
|
|
* categories there is ≤ 1 record per app id; multi-cardinality categories
|
|
* can hold multiple records per app.
|
|
* - `home: "package"` — optional. Omitted means "ship app-scoped shims
|
|
* into every app"; an explicit list restricts which apps receive shims.
|
|
* The package itself is always written once at `packages/<dir>/`.
|
|
* - `home: "repo"` — must be omitted. Repo-home modules are project-wide.
|
|
*/
|
|
apps?: string[];
|
|
/**
|
|
* Namespace this module was installed from, e.g. `"@acme"`. Omitted means
|
|
* the default first-party namespace (`"@stanza"`). `stanza remove` and the
|
|
* upcoming `update`/`swap` verbs read this to refetch from the original
|
|
* registry.
|
|
*/
|
|
namespace?: string;
|
|
/**
|
|
* Snapshot of the codemod invocations the adapter ran. `stanza remove`
|
|
* reads these to drive reverts so the operation works even if the upstream
|
|
* registry has since renamed the adapter or its codemod list.
|
|
*/
|
|
codemods?: Array<{ id: string; args?: Record<string, unknown> }>;
|
|
/** Module-level `consumesPackages`, snapshotted so revert can rebuild a render context offline. */
|
|
consumesPackages?: string[];
|
|
};
|
|
|
|
/**
|
|
* Per-file region ownership. Keys are dot-paths inside the file
|
|
* (e.g. "imports", "providers", "dependencies.better-auth"). Values are
|
|
* the owning module id. Written today; the `swap`/`update` verbs (and the
|
|
* deeper `remove` reversal) will use it to scope codemods back to the
|
|
* regions a single module owns.
|
|
*/
|
|
export type RegionMap = Record<string, ModuleId>;
|
|
export type RegionOwnership = Record<string, RegionMap>;
|
|
|
|
export type StanzaManifest = {
|
|
/** Editor-facing pointer to the published JSON Schema. */
|
|
$schema?: string;
|
|
/**
|
|
* Schema version of this manifest. Always {@link CURRENT_MANIFEST_VERSION}
|
|
* on write — {@link SUPPORTED_MANIFEST_VERSIONS} lists the prior versions
|
|
* the reader still accepts, which get re-stamped to the current version
|
|
* on the next save.
|
|
*/
|
|
version: (typeof SUPPORTED_MANIFEST_VERSIONS)[number];
|
|
projectShape: "monorepo";
|
|
packageManager: PackageManager;
|
|
/** Display name; usually the repo root name. */
|
|
name: string;
|
|
/**
|
|
* Apps in the monorepo. Length ≥ 1. The init wizard ships a single web app
|
|
* today; multi-app init is a planned follow-up but the schema is ready.
|
|
*/
|
|
apps: AppSpec[];
|
|
/**
|
|
* Installed modules, keyed by category. Each category holds an array;
|
|
* cardinality is enforced **per app** for `home: "app"` categories
|
|
* (≤ 1 record per app id when `cardinality: "one"`) and **per project**
|
|
* for the rest.
|
|
*/
|
|
modules: Partial<Record<CategoryId, StanzaModuleRecord[]>>;
|
|
regions: RegionOwnership;
|
|
/**
|
|
* Third-party module registries. Keys are namespace strings (e.g. `"@acme"`);
|
|
* values are either a URL prefix (Stanza's default layout) or a full
|
|
* `RegistryConfig` with URL template, headers, and params. Modules from
|
|
* these registries are addressed as `<category> @<ns>/<id>` on the CLI;
|
|
* the `@stanza` default namespace is bundled in the CLI and cannot be
|
|
* redeclared here (override its URL via the `STANZA_REGISTRY` env var).
|
|
*/
|
|
registries?: Record<string, RegistryConfig>;
|
|
/**
|
|
* SHA-256 of the README.md Stanza last wrote. When `stanza add`/`remove`
|
|
* regenerate the README they compare the current file's hash against this
|
|
* value — a mismatch means the user edited it, and the refresh is skipped.
|
|
* Absent on legacy manifests; treated as "user-owned" in that case.
|
|
*/
|
|
readmeChecksum?: string;
|
|
};
|
|
|
|
const appSpecSchema = z.object({
|
|
id: z.string(),
|
|
dir: z.string(),
|
|
kind: z.enum(APP_KINDS),
|
|
}) satisfies z.ZodType<AppSpec>;
|
|
|
|
export const StanzaManifestSchema = z
|
|
.object({
|
|
$schema: z.string().optional(),
|
|
// Accept past versions on read (schema is purely additive so far); the
|
|
// CLI re-stamps to CURRENT_MANIFEST_VERSION on the next write.
|
|
version: z.enum(SUPPORTED_MANIFEST_VERSIONS),
|
|
projectShape: z.literal("monorepo"),
|
|
packageManager: PackageManagerSchema,
|
|
name: z.string(),
|
|
apps: z.array(appSpecSchema).min(1),
|
|
// Zod 4: partialRecord because not every category is filled. Every category
|
|
// holds an array (single-choice categories carry 0 or 1 records per app).
|
|
modules: z.partialRecord(
|
|
z.enum(KNOWN_CATEGORIES),
|
|
z.array(
|
|
z.object({
|
|
id: z.string(),
|
|
version: z.string(),
|
|
adapter: z.string(),
|
|
apps: z.array(z.string()).optional(),
|
|
namespace: z.string().optional(),
|
|
codemods: z
|
|
.array(
|
|
z.object({
|
|
id: z.string(),
|
|
args: z.record(z.string(), z.unknown()).optional(),
|
|
}),
|
|
)
|
|
.optional(),
|
|
consumesPackages: z.array(z.string()).optional(),
|
|
}),
|
|
),
|
|
),
|
|
regions: z.record(z.string(), z.record(z.string(), z.string())),
|
|
registries: RegistriesSchema.optional(),
|
|
readmeChecksum: z.string().optional(),
|
|
})
|
|
.strict()
|
|
.superRefine((m, ctx) => {
|
|
// The CLI enforces cardinality at apply-time; this catches hand-edited or
|
|
// third-party-synthesized manifests so `selectedOne` callers can rely on it.
|
|
const appIds = m.apps.map((a) => a.id);
|
|
for (const category of KNOWN_CATEGORIES) {
|
|
const records = m.modules[category];
|
|
if (!records || categoryCardinality(category) !== "one") continue;
|
|
if (categoryHome(category).kind === "app") {
|
|
const counts = new Map<string, number>();
|
|
for (const r of records) {
|
|
const targets = r.apps?.length ? r.apps : appIds;
|
|
for (const id of targets) counts.set(id, (counts.get(id) ?? 0) + 1);
|
|
}
|
|
for (const [id, count] of counts) {
|
|
if (count > 1) {
|
|
ctx.addIssue({
|
|
code: "custom",
|
|
message: `Category "${category}" allows ≤ 1 module per app, but app "${id}" has ${count}.`,
|
|
path: ["modules", category],
|
|
});
|
|
}
|
|
}
|
|
} else if (records.length > 1) {
|
|
ctx.addIssue({
|
|
code: "custom",
|
|
message: `Category "${category}" allows ≤ 1 module per project, found ${records.length}.`,
|
|
path: ["modules", category],
|
|
});
|
|
}
|
|
}
|
|
}) satisfies z.ZodType<StanzaManifest>;
|
|
|
|
/**
|
|
* Built-in default app: a single web app at `apps/web` with id `"web"`. The
|
|
* init wizard hands this back today; once multi-app init lands, callers can
|
|
* pass a custom `apps` array to `emptyManifest`.
|
|
*/
|
|
export function defaultWebApp(): AppSpec {
|
|
return { id: "web", dir: "apps/web", kind: "web" };
|
|
}
|
|
|
|
export function emptyManifest(input: {
|
|
name: string;
|
|
apps?: AppSpec[];
|
|
packageManager?: PackageManager;
|
|
}): StanzaManifest {
|
|
return {
|
|
$schema: MANIFEST_SCHEMA_URL,
|
|
version: CURRENT_MANIFEST_VERSION,
|
|
projectShape: "monorepo",
|
|
packageManager: input.packageManager ?? "pnpm",
|
|
name: input.name,
|
|
apps: input.apps && input.apps.length > 0 ? input.apps : [defaultWebApp()],
|
|
modules: {},
|
|
regions: {},
|
|
};
|
|
}
|
|
|
|
/** Find an app by id; throws when missing (caller is misusing the helper). */
|
|
export function getApp(manifest: StanzaManifest, id: string): AppSpec {
|
|
const app = manifest.apps.find((a) => a.id === id);
|
|
if (!app) {
|
|
throw new Error(
|
|
`No app "${id}" in manifest. Available: ${manifest.apps.map((a) => a.id).join(", ")}.`,
|
|
);
|
|
}
|
|
return app;
|
|
}
|
|
|
|
/**
|
|
* Resolve a module record's targeted apps. Empty/undefined `apps` means
|
|
* "every app in the manifest" — used for package-home modules whose shims
|
|
* should ship into all consumers by default.
|
|
*/
|
|
export function appsForRecord(manifest: StanzaManifest, record: StanzaModuleRecord): AppSpec[] {
|
|
if (!record.apps?.length) return manifest.apps;
|
|
return record.apps.map((id) => getApp(manifest, id));
|
|
}
|
|
|
|
/**
|
|
* The single installed record for a category, or `undefined`. When `appId`
|
|
* is given, filters to records that target that app (or are global — `apps`
|
|
* omitted). For `cardinality: "one"` categories.
|
|
*/
|
|
export function selectedOne(
|
|
manifest: StanzaManifest,
|
|
category: CategoryId,
|
|
appId?: string,
|
|
): StanzaModuleRecord | undefined {
|
|
const records = manifest.modules[category] ?? [];
|
|
if (appId === undefined) return records[0];
|
|
return records.find((r) => !r.apps || r.apps.includes(appId));
|
|
}
|
|
|
|
/**
|
|
* All installed records for a category (empty when none). When `appId` is
|
|
* given, filters to records that target that app (or are global).
|
|
*/
|
|
export function selectedAll(
|
|
manifest: StanzaManifest,
|
|
category: CategoryId,
|
|
appId?: string,
|
|
): StanzaModuleRecord[] {
|
|
const records = manifest.modules[category] ?? [];
|
|
if (appId === undefined) return records;
|
|
return records.filter((r) => !r.apps || r.apps.includes(appId));
|
|
}
|
|
|
|
/**
|
|
* Sorted, deduped list of env var names every installed module has claimed in
|
|
* `.env.example` — the same set that ends up in the file. Backs the `{{env}}`
|
|
* render context entry (and, transitively, `monorepo-turbo`'s `globalEnv` so
|
|
* Turborepo's task hashes track the right variables).
|
|
*
|
|
* Returns `[]` when nothing has been claimed yet (fresh init, or a project
|
|
* with no env-declaring modules).
|
|
*/
|
|
export function declaredEnvNames(manifest: StanzaManifest): string[] {
|
|
const envRegions = manifest.regions[".env.example"];
|
|
if (!envRegions) return [];
|
|
return Object.keys(envRegions).toSorted();
|
|
}
|