mirror of
https://github.com/jakejarvis/stanza.git
synced 2026-07-16 18:05:58 -04:00
fix: harden module-id validation, parallelize registry loading, and redact third-party ids in telemetry
- Add `isLikelyNamespaceTypo` and `isValidModuleId` to `@stanza/registry` and gate both `add` and `remove` on them before any registry fetch or disk write — bare `@ns` specs and path-traversal-style ids now fail fast with a targeted hint instead of an opaque 404
- Add `captureModule` to the telemetry client: mirrors `capture("cli_module", ...)` but redacts the module id when the namespace is not `@stanza` so proprietary ids never leave the user's machine; replace all `capture("cli_module", ...)` call sites in `add`, `remove`, and `init`
- Parallelize `loadRegistries` via `Promise.all` so custom-namespace index fetches happen concurrently; split `tryFetchIndex` into three discrete try/catch blocks that differentiate URL-build errors (surfaced as warnings), network failures (silent), 404s (legitimate no-index), and schema-invalid 200s (warned)
- Re-stamp `version` to `CURRENT_MANIFEST_VERSION` in `readManifest` so older manifests are transparently upgraded in memory and the next `writeManifest` persists the new version; export `CURRENT_MANIFEST_VERSION` and `SUPPORTED_MANIFEST_VERSIONS` from `@stanza/registry`
- Move codemod-catalog id validation before `ensureSlotPackage` in `applyModule` so an unknown codemod id can't bootstrap a slot package and leave it orphaned on a mid-flight throw; validation still runs on `--dry-run`
- Add a dedicated empty-state branch in `search` for namespaces that are configured but expose no browsable index, directing users to `stanza add <category> <ns>/<id>` instead of a generic "No modules found"
This commit is contained in:
@@ -4,7 +4,9 @@ import {
|
||||
categoryHome,
|
||||
DEFAULT_NAMESPACE,
|
||||
isCategoryId,
|
||||
isLikelyNamespaceTypo,
|
||||
isMulti,
|
||||
isValidModuleId,
|
||||
KNOWN_CATEGORIES,
|
||||
parseModuleSpec,
|
||||
resolveAdapter,
|
||||
@@ -52,10 +54,33 @@ export async function cmdAdd(args: CliArgs): Promise<void> {
|
||||
const category = slot;
|
||||
const group = category;
|
||||
|
||||
// Catch the `@bare` typo before parsing — without this, the spec falls
|
||||
// through to a literal id of "@bare" and the registry returns an opaque
|
||||
// 404. Explicit hint is friendlier.
|
||||
if (isLikelyNamespaceTypo(rawModuleId)) {
|
||||
p.log.error(
|
||||
`"${rawModuleId}" looks like a namespace but is missing the module id. ` +
|
||||
`Did you mean \`${rawModuleId}/<id>\`?`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
// Split `@ns/id` into a namespace + id. Bare ids implicitly mean `@stanza`,
|
||||
// which we leave as `undefined` on the record (omitted = default).
|
||||
const { namespace, id: moduleId } = parseModuleSpec(rawModuleId);
|
||||
|
||||
// The id is about to be interpolated into a registry URL — reject anything
|
||||
// that could escape its segment (path traversal, query strings, encoded
|
||||
// bytes). See `isValidModuleId` in @stanza/registry for the exact shape.
|
||||
if (!isValidModuleId(moduleId)) {
|
||||
p.log.error(
|
||||
`Invalid module id "${moduleId}". Ids must be alphanumeric segments ` +
|
||||
`(letters, digits, dashes, underscores) joined by "/".`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const projectRoot = findProjectRoot();
|
||||
if (!projectRoot) {
|
||||
p.log.error("No stanza.json found in this or any parent directory.");
|
||||
@@ -189,10 +214,10 @@ export async function cmdAdd(args: CliArgs): Promise<void> {
|
||||
}
|
||||
|
||||
// Always counted in the aggregate install total; the `namespace` property
|
||||
// lets the stats page exclude third-party modules from per-category
|
||||
// leaderboards (where ranking private/proprietary ids alongside first-party
|
||||
// ones would be misleading).
|
||||
telemetry.capture("cli_module", {
|
||||
// lets the stats page bucket per-namespace. `captureModule` redacts the
|
||||
// module id for non-@stanza namespaces so private/proprietary ids never
|
||||
// leave the user's machine.
|
||||
telemetry.captureModule({
|
||||
action: "install",
|
||||
group,
|
||||
module: mod.id,
|
||||
|
||||
@@ -184,7 +184,7 @@ export async function cmdInit(args: CliArgs): Promise<void> {
|
||||
// Init always installs from the first-party `@stanza` registry.
|
||||
// Mirrors the event shape `add`/`remove` use so the stats query can
|
||||
// bucket on `properties.namespace` uniformly.
|
||||
telemetry.capture("cli_module", {
|
||||
telemetry.captureModule({
|
||||
action: "install",
|
||||
group: category,
|
||||
module: mod.id,
|
||||
|
||||
@@ -9,7 +9,9 @@ import {
|
||||
categoryHome,
|
||||
DEFAULT_NAMESPACE,
|
||||
isCategoryId,
|
||||
isLikelyNamespaceTypo,
|
||||
isMulti,
|
||||
isValidModuleId,
|
||||
PACKAGE_DIRS,
|
||||
parseModuleSpec,
|
||||
selectedAll,
|
||||
@@ -59,7 +61,23 @@ export async function cmdRemove(args: CliArgs): Promise<void> {
|
||||
// Accept `@ns/id` so users can disambiguate when two registries ship a
|
||||
// module under the same id. We match against `record.id` (the namespace
|
||||
// hint is informational + persisted on the record on install).
|
||||
if (rawModuleId && isLikelyNamespaceTypo(rawModuleId)) {
|
||||
p.log.error(
|
||||
`"${rawModuleId}" looks like a namespace but is missing the module id. ` +
|
||||
`Did you mean \`${rawModuleId}/<id>\`?`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const moduleId = rawModuleId ? parseModuleSpec(rawModuleId).id : undefined;
|
||||
if (moduleId !== undefined && !isValidModuleId(moduleId)) {
|
||||
p.log.error(
|
||||
`Invalid module id "${moduleId}". Ids must be alphanumeric segments ` +
|
||||
`(letters, digits, dashes, underscores) joined by "/".`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (!isCategoryId(slot)) {
|
||||
p.log.error(`Unknown category: ${slot}`);
|
||||
process.exitCode = 1;
|
||||
@@ -253,9 +271,10 @@ export async function cmdRemove(args: CliArgs): Promise<void> {
|
||||
p.log.warn("Skipped README.md refresh (user-modified). Delete the file to regenerate.");
|
||||
}
|
||||
|
||||
// Mirrors `add`: the namespace property lets the stats page bucket
|
||||
// first-party vs third-party correctly without losing the aggregate count.
|
||||
telemetry.capture("cli_module", {
|
||||
// Mirrors `add`: `captureModule` redacts the module id for third-party
|
||||
// namespaces so private/proprietary ids never leave the user's machine,
|
||||
// while still bucketing aggregate counts per namespace.
|
||||
telemetry.captureModule({
|
||||
action: "remove",
|
||||
group,
|
||||
module: installed.id,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { DEFAULT_NAMESPACE, parseModuleSpec } from "@stanza/registry";
|
||||
import { DEFAULT_NAMESPACE, parseModuleSpec, type StanzaManifest } from "@stanza/registry";
|
||||
import { defineCommand } from "citty";
|
||||
import pc from "picocolors";
|
||||
|
||||
@@ -25,15 +25,14 @@ export async function cmdSearch(args: CliArgs): Promise<void> {
|
||||
// is searchable. A malformed manifest just disables the fan-out — don't
|
||||
// break `search` because of an unrelated parse error.
|
||||
const projectRoot = findProjectRoot();
|
||||
const manifest = projectRoot
|
||||
? (() => {
|
||||
try {
|
||||
return readManifest(projectRoot);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
})()
|
||||
: undefined;
|
||||
let manifest: StanzaManifest | undefined;
|
||||
if (projectRoot) {
|
||||
try {
|
||||
manifest = readManifest(projectRoot);
|
||||
} catch {
|
||||
// Leave `manifest` undefined — fan-out disabled, @stanza still works.
|
||||
}
|
||||
}
|
||||
const registry = await loadRegistries(manifest);
|
||||
|
||||
const raw = typeof args.query === "string" ? args.query.trim() : "";
|
||||
@@ -66,6 +65,15 @@ export async function cmdSearch(args: CliArgs): Promise<void> {
|
||||
if (hits.length === 0) {
|
||||
if (nsFilter && !registry.namespaces().includes(nsFilter)) {
|
||||
console.log(pc.dim(`Unknown registry "${nsFilter}". Add it to stanza.json.`));
|
||||
} else if (nsFilter && !indices.some(({ namespace }) => namespace === nsFilter)) {
|
||||
// Namespace IS configured but exposes no browsable index (no `indexUrl`,
|
||||
// or it 404'd / parse-failed). Modules are still reachable by name.
|
||||
console.log(
|
||||
pc.dim(
|
||||
`Registry "${nsFilter}" has no browsable index — use ` +
|
||||
`\`stanza add <category> ${nsFilter}/<id>\` to fetch by name.`,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
console.log(pc.dim("No modules found."));
|
||||
}
|
||||
|
||||
@@ -82,6 +82,19 @@ export async function applyModule(args: {
|
||||
let manifest = args.manifest;
|
||||
const touchedFiles = new Set<string>();
|
||||
|
||||
// Validate catalog ids before any disk writes — third-party modules may
|
||||
// reference first-party codemod ids but can't ship new ones, and we don't
|
||||
// want `ensureSlotPackage` (below) to bootstrap a slot package only to
|
||||
// throw mid-flight and leave it orphaned. Runs on dry-run too, so users
|
||||
// catch typos without --apply.
|
||||
for (const invocation of adapter.codemods ?? []) {
|
||||
if (!CODEMOD_CATALOG[invocation.id]) {
|
||||
throw new Error(
|
||||
`Codemod "${invocation.id}" referenced by ${module.category}/${module.id} (adapter "${adapter.key}") is not in the catalog. Add it to packages/codemods/src/builtins/ and register in builtins/index.ts.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const home = categoryHome(module.category);
|
||||
if (home.kind === "app" && targetApps.length !== 1) {
|
||||
throw new Error(
|
||||
@@ -317,15 +330,6 @@ export async function applyModule(args: {
|
||||
touchedFiles.add(".env.example");
|
||||
}
|
||||
|
||||
// Validate up front so dry-run catches missing ids too.
|
||||
for (const invocation of adapter.codemods ?? []) {
|
||||
if (!CODEMOD_CATALOG[invocation.id]) {
|
||||
throw new Error(
|
||||
`Codemod "${invocation.id}" referenced by ${module.category}/${module.id} (adapter "${adapter.key}") is not in the catalog. Add it to packages/codemods/src/builtins/ and register in builtins/index.ts.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!dryRun) {
|
||||
const record = recordFor(module, adapter, targetApps, namespace);
|
||||
// Push into the category's array, replacing any same-(id, apps-key) record
|
||||
|
||||
@@ -3,6 +3,7 @@ import path from "node:path";
|
||||
|
||||
import {
|
||||
type AppSpec,
|
||||
CURRENT_MANIFEST_VERSION,
|
||||
emptyManifest,
|
||||
MANIFEST_SCHEMA_URL,
|
||||
StanzaManifestSchema,
|
||||
@@ -28,7 +29,10 @@ export function readManifest(projectRoot: string): StanzaManifest {
|
||||
.join("\n")}`,
|
||||
);
|
||||
}
|
||||
return parsed.data;
|
||||
// Re-stamp the version so the in-memory object always reflects the current
|
||||
// schema. Past-version manifests parsed via SUPPORTED_MANIFEST_VERSIONS get
|
||||
// upgraded transparently; the next `writeManifest` persists it.
|
||||
return { ...parsed.data, version: CURRENT_MANIFEST_VERSION };
|
||||
}
|
||||
|
||||
export function writeManifest(projectRoot: string, manifest: StanzaManifest): void {
|
||||
|
||||
@@ -61,13 +61,21 @@ type NamespaceLoader = {
|
||||
* `stanza init` that only ever hit the default namespace.
|
||||
*/
|
||||
export async function loadRegistries(manifest?: StanzaManifest): Promise<Registries> {
|
||||
const loaders = new Map<string, NamespaceLoader>();
|
||||
loaders.set(DEFAULT_NAMESPACE, await buildDefaultLoader());
|
||||
const customEntries = Object.entries(manifest?.registries ?? {}).filter(
|
||||
// Schema also forbids this; double-guard so a hand-edited manifest can't
|
||||
// shadow the default.
|
||||
([ns]) => ns !== DEFAULT_NAMESPACE,
|
||||
);
|
||||
// Build default + every custom loader in parallel — each one issues an
|
||||
// independent index fetch and there's no ordering dependency between them.
|
||||
const [defaultLoader, ...customLoaders] = await Promise.all([
|
||||
buildDefaultLoader(),
|
||||
...customEntries.map(([ns, cfg]) => buildCustomLoader(ns, cfg)),
|
||||
]);
|
||||
|
||||
for (const [ns, cfg] of Object.entries(manifest?.registries ?? {})) {
|
||||
if (ns === DEFAULT_NAMESPACE) continue; // schema also forbids this; double-guard.
|
||||
loaders.set(ns, await buildCustomLoader(cfg));
|
||||
}
|
||||
const loaders = new Map<string, NamespaceLoader>();
|
||||
loaders.set(DEFAULT_NAMESPACE, defaultLoader);
|
||||
customEntries.forEach(([ns], i) => loaders.set(ns, customLoaders[i]!));
|
||||
|
||||
return {
|
||||
namespaces: () => [...loaders.keys()],
|
||||
@@ -140,11 +148,11 @@ async function buildDefaultLoader(): Promise<NamespaceLoader> {
|
||||
return loadHttpRegistry(DEFAULT_REGISTRY_URL);
|
||||
}
|
||||
|
||||
async function buildCustomLoader(cfg: RegistryConfig): Promise<NamespaceLoader> {
|
||||
async function buildCustomLoader(namespace: string, cfg: RegistryConfig): Promise<NamespaceLoader> {
|
||||
const resolved = resolveConfig(cfg);
|
||||
// Try to grab an index up front; absent or 404 just means the namespace is
|
||||
// fetch-by-name only (won't appear in `searchableIndices`).
|
||||
const index = await tryFetchIndex(resolved);
|
||||
const index = await tryFetchIndex(namespace, resolved);
|
||||
return {
|
||||
index,
|
||||
async loadModule(category, id) {
|
||||
@@ -208,14 +216,44 @@ function appendParams(url: string, params?: Record<string, string>): string {
|
||||
return u.toString();
|
||||
}
|
||||
|
||||
async function tryFetchIndex(cfg: ResolvedConfig): Promise<RegistryIndex | undefined> {
|
||||
async function tryFetchIndex(
|
||||
namespace: string,
|
||||
cfg: ResolvedConfig,
|
||||
): Promise<RegistryIndex | undefined> {
|
||||
if (!cfg.indexUrl) return undefined;
|
||||
let url: string;
|
||||
try {
|
||||
const url = appendParams(cfg.indexUrl, cfg.params);
|
||||
const res = await fetch(url, { headers: buildHeaders(cfg.headers) });
|
||||
if (!res.ok) return undefined;
|
||||
return RegistryIndexSchema.parse(await res.json());
|
||||
url = appendParams(cfg.indexUrl, cfg.params);
|
||||
} catch (err) {
|
||||
// appendParams throws on unset env vars — surface that since it's a
|
||||
// config error the user can fix, not a missing-index condition.
|
||||
console.warn(
|
||||
`Registry "${namespace}" index skipped: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(url, { headers: buildHeaders(cfg.headers) });
|
||||
} catch {
|
||||
// Network failure (DNS, offline, etc.) — the namespace stays
|
||||
// fetch-by-name-only this run. Silent: typical when offline.
|
||||
return undefined;
|
||||
}
|
||||
// 404 means "no index advertised" — legitimate for fetch-by-name-only
|
||||
// registries. Other non-OK statuses (401, 403, 5xx) likely indicate a
|
||||
// misconfiguration the user should see.
|
||||
if (res.status === 404) return undefined;
|
||||
if (!res.ok) {
|
||||
console.warn(`Registry "${namespace}" index returned ${res.status} ${res.statusText}.`);
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
return RegistryIndexSchema.parse(await res.json());
|
||||
} catch (err) {
|
||||
// 200 but schema-invalid → config error worth telling the user about.
|
||||
const detail = err instanceof Error ? err.message.split("\n")[0] : String(err);
|
||||
console.warn(`Registry "${namespace}" index is malformed: ${detail}`);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,6 +70,30 @@ export function capture(event: string, properties: Record<string, unknown> = {})
|
||||
});
|
||||
}
|
||||
|
||||
const DEFAULT_NAMESPACE = "@stanza";
|
||||
|
||||
/**
|
||||
* Module install/remove event with namespace-aware id redaction. The actual
|
||||
* `module` string is only emitted for first-party `@stanza` installs;
|
||||
* third-party module ids (potentially private/proprietary) are replaced with
|
||||
* `"<redacted>"` so per-namespace install counts still aggregate without
|
||||
* leaking customer-internal names to first-party analytics.
|
||||
*/
|
||||
export function captureModule(args: {
|
||||
action: "install" | "remove";
|
||||
group: string;
|
||||
module: string;
|
||||
namespace: string;
|
||||
}): void {
|
||||
const isFirstParty = args.namespace === DEFAULT_NAMESPACE;
|
||||
capture("cli_module", {
|
||||
action: args.action,
|
||||
group: args.group,
|
||||
module: isFirstParty ? args.module : "<redacted>",
|
||||
namespace: args.namespace,
|
||||
});
|
||||
}
|
||||
|
||||
export async function flush(): Promise<void> {
|
||||
if (disabled || queue.length === 0) return;
|
||||
const url = process.env.STANZA_TELEMETRY_URL ?? DEFAULT_TELEMETRY_URL;
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useLocation, useNavigate } from "@tanstack/react-router";
|
||||
import type { ReactNode } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
|
||||
import { FILE_TREE_ICONS } from "@/components/builder/file-tree-icons";
|
||||
import { useTheme } from "@/components/theme-provider";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
|
||||
@@ -141,6 +142,7 @@ export function FilePreview({
|
||||
paths: filePaths,
|
||||
search: false,
|
||||
unsafeCSS: TRUNCATE_FIX_CSS,
|
||||
icons: FILE_TREE_ICONS,
|
||||
onSelectionChange,
|
||||
});
|
||||
modelRef.current = model;
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { FileTreeIcons } from "@pierre/trees";
|
||||
|
||||
// Note: The outer <svg> needs `width="0" height="0"` (and `aria-hidden`),
|
||||
// without them the browser gives the wrapper its default 300×150 box and the
|
||||
// tree gets shoved down.
|
||||
const SPRITE_SHEET = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="0" height="0" aria-hidden="true">
|
||||
<symbol id="stanza" viewBox="0 0 24 24">
|
||||
<path style="fill: var(--brand)" d="M10.975 3.002a1 1 0 0 1-.754 1.196a8 8 0 1 0 8.446 3.379a1 1 0 1 1 1.666-1.107A9.96 9.96 0 0 1 22 12c0 5.523-4.477 10-10 10S2 17.523 2 12c0-4.76 3.325-8.742 7.779-9.752a1 1 0 0 1 1.196.754M13 3.014a1.01 1.01 0 0 1 1.214-.99l.115.031l2.987.996a1 1 0 0 1-.52 1.928l-.112-.03L15 4.387V12a3 3 0 1 1-2.19-2.89l.19.06V3.015Z"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
`;
|
||||
|
||||
export const FILE_TREE_ICONS = {
|
||||
set: "complete",
|
||||
spriteSheet: SPRITE_SHEET,
|
||||
byFileName: {
|
||||
"stanza.json": "stanza",
|
||||
},
|
||||
} satisfies FileTreeIcons;
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
const relativeFormatter = new Intl.RelativeTimeFormat("en-US", { numeric: "auto" });
|
||||
|
||||
const MINUTE = 60;
|
||||
const HOUR = 60 * MINUTE;
|
||||
const DAY = 24 * HOUR;
|
||||
const MONTH = 30 * DAY;
|
||||
const YEAR = 365 * DAY;
|
||||
|
||||
function format(iso: string, now: Date): string {
|
||||
const date = new Date(iso);
|
||||
if (Number.isNaN(date.getTime())) return iso;
|
||||
const diffSec = Math.round((date.getTime() - now.getTime()) / 1000);
|
||||
const abs = Math.abs(diffSec);
|
||||
if (abs < MINUTE) return relativeFormatter.format(diffSec, "second");
|
||||
if (abs < HOUR) return relativeFormatter.format(Math.round(diffSec / MINUTE), "minute");
|
||||
if (abs < DAY) return relativeFormatter.format(Math.round(diffSec / HOUR), "hour");
|
||||
if (abs < MONTH) return relativeFormatter.format(Math.round(diffSec / DAY), "day");
|
||||
if (abs < YEAR) return relativeFormatter.format(Math.round(diffSec / MONTH), "month");
|
||||
return relativeFormatter.format(Math.round(diffSec / YEAR), "year");
|
||||
}
|
||||
|
||||
/**
|
||||
* Reactive `Intl.RelativeTimeFormat` — re-renders on a fixed interval so the
|
||||
* label stays fresh while the tab is open. SSR returns a value computed against
|
||||
* server time; gate the rendered element on client-only state if exact
|
||||
* second-level accuracy at hydration matters.
|
||||
*/
|
||||
export function useTimeAgo(iso: string, intervalMs = 30_000): string {
|
||||
const [now, setNow] = useState(() => new Date());
|
||||
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setNow(new Date()), intervalMs);
|
||||
return () => clearInterval(id);
|
||||
}, [intervalMs]);
|
||||
|
||||
return format(iso, now);
|
||||
}
|
||||
@@ -4,9 +4,11 @@ import { createFileRoute, Link, useLoaderData } from "@tanstack/react-router";
|
||||
import { lazy, Suspense, useEffect, useState } from "react";
|
||||
|
||||
import { ModuleLogo } from "@/components/module-logo";
|
||||
import { BarList } from "@/components/ui/bar-list";
|
||||
import { BarList } from "@/components/stats/bar-list";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { useTimeAgo } from "@/hooks/use-time-ago";
|
||||
import { buildHead } from "@/lib/seo";
|
||||
import { getStats, type Stats } from "@/server/stats.functions";
|
||||
|
||||
@@ -43,8 +45,26 @@ function formatGeneratedAt(iso: string): string {
|
||||
});
|
||||
}
|
||||
|
||||
function moduleHref(category: CategoryId, id: string): string {
|
||||
return `/registry/${category}/${id}`;
|
||||
function LastRefreshed({ iso }: { iso: string }) {
|
||||
const ago = useTimeAgo(iso);
|
||||
return (
|
||||
<p>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
nativeButton={false}
|
||||
render={
|
||||
<time
|
||||
dateTime={iso}
|
||||
className="cursor-help font-mono text-xs text-muted-foreground/70 tabular-nums"
|
||||
/>
|
||||
}
|
||||
>
|
||||
Last refreshed {ago}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{formatGeneratedAt(iso)} UTC</TooltipContent>
|
||||
</Tooltip>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
function StatsPage() {
|
||||
@@ -79,21 +99,15 @@ function StatsPage() {
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl px-4 py-10 sm:px-6">
|
||||
<header className="mb-8 max-w-2xl">
|
||||
<h1 className="text-3xl font-semibold tracking-tight">Stats</h1>
|
||||
<p className="mt-2 text-pretty text-muted-foreground">
|
||||
<h1 className="mb-2 text-3xl font-semibold tracking-tight">Stats</h1>
|
||||
<p className="mb-4 text-pretty text-muted-foreground">
|
||||
What modules developers actually pick, aggregated from anonymous CLI telemetry —{" "}
|
||||
<a href="#telemetry" className="text-primary underline underline-offset-3">
|
||||
see exactly what’s collected
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
{stats ? (
|
||||
<p className="mt-4 font-mono text-xs text-muted-foreground/70 tabular-nums">
|
||||
Last refreshed {formatGeneratedAt(stats.generatedAt)} UTC
|
||||
</p>
|
||||
) : (
|
||||
<Skeleton className="mt-4 h-4 w-72" />
|
||||
)}
|
||||
{stats ? <LastRefreshed iso={stats.generatedAt} /> : <Skeleton className="h-4 w-48" />}
|
||||
</header>
|
||||
|
||||
<section className="mb-4 grid gap-4 sm:grid-cols-2">
|
||||
@@ -179,7 +193,7 @@ function StatsPage() {
|
||||
leading: <ModuleLogo logo={summary?.logo} label={label} size="sm" />,
|
||||
trailingSecondary: numberFormatter.format(entry.count),
|
||||
trailing: percentFormatter.format(entry.share),
|
||||
href: moduleHref(category, entry.id),
|
||||
href: `/registry/${category}/${entry.id}`,
|
||||
};
|
||||
})}
|
||||
/>
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-selection: var(--selection);
|
||||
--color-selection-foreground: var(--selection-foreground);
|
||||
--color-brand: var(--brand);
|
||||
}
|
||||
|
||||
:root {
|
||||
@@ -93,6 +94,7 @@
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
--selection: oklch(0.145 0 0);
|
||||
--selection-foreground: oklch(1 0 0);
|
||||
--brand: oklch(0.55 0.19 258);
|
||||
}
|
||||
|
||||
.dark {
|
||||
@@ -130,6 +132,7 @@
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
--selection: oklch(0.922 0 0);
|
||||
--selection-foreground: oklch(0.205 0 0);
|
||||
--brand: oklch(0.75 0.13 248);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
|
||||
@@ -43,6 +43,7 @@ export type {
|
||||
export {
|
||||
StanzaManifestSchema,
|
||||
CURRENT_MANIFEST_VERSION,
|
||||
SUPPORTED_MANIFEST_VERSIONS,
|
||||
MANIFEST_SCHEMA_URL,
|
||||
declaredEnvNames,
|
||||
defaultWebApp,
|
||||
@@ -58,7 +59,9 @@ export type { RegistryConfig } from "./registry-config";
|
||||
export {
|
||||
DEFAULT_NAMESPACE,
|
||||
expandEnv,
|
||||
isLikelyNamespaceTypo,
|
||||
isNamespace,
|
||||
isValidModuleId,
|
||||
parseModuleSpec,
|
||||
RegistriesSchema,
|
||||
RegistryConfigSchema,
|
||||
|
||||
@@ -136,6 +136,38 @@ describe("StanzaManifestSchema", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("version compatibility", () => {
|
||||
it("accepts a manifest tagged with a prior schema version", () => {
|
||||
// 0.3 is structurally a strict subset of 0.4 (the new fields are
|
||||
// optional), so the schema should accept it. readManifest re-stamps
|
||||
// to CURRENT_MANIFEST_VERSION on load so callers always see current.
|
||||
const v03 = {
|
||||
version: "0.3",
|
||||
projectShape: "monorepo",
|
||||
packageManager: "pnpm",
|
||||
name: "acme",
|
||||
apps: [{ id: "web", dir: "apps/web", kind: "web" }],
|
||||
modules: {},
|
||||
regions: {},
|
||||
};
|
||||
const parsed = StanzaManifestSchema.parse(v03);
|
||||
expect(parsed.version).toBe("0.3");
|
||||
});
|
||||
|
||||
it("rejects an unknown schema version", () => {
|
||||
const future = {
|
||||
version: "9.9",
|
||||
projectShape: "monorepo",
|
||||
packageManager: "pnpm",
|
||||
name: "acme",
|
||||
apps: [{ id: "web", dir: "apps/web", kind: "web" }],
|
||||
modules: {},
|
||||
regions: {},
|
||||
};
|
||||
expect(() => StanzaManifestSchema.parse(future)).toThrow(/version/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("third-party registries", () => {
|
||||
it("round-trips a manifest with a registries map and namespaced records", () => {
|
||||
const manifest = {
|
||||
|
||||
@@ -13,6 +13,15 @@ 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";
|
||||
|
||||
@@ -72,7 +81,13 @@ export type RegionOwnership = Record<string, RegionMap>;
|
||||
export type StanzaManifest = {
|
||||
/** Editor-facing pointer to the published JSON Schema. */
|
||||
$schema?: string;
|
||||
version: typeof CURRENT_MANIFEST_VERSION;
|
||||
/**
|
||||
* 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: "pnpm" | "bun" | "npm";
|
||||
/** Display name; usually the repo root name. */
|
||||
@@ -117,7 +132,9 @@ const appSpecSchema = z.object({
|
||||
export const StanzaManifestSchema = z
|
||||
.object({
|
||||
$schema: z.string().optional(),
|
||||
version: z.literal(CURRENT_MANIFEST_VERSION),
|
||||
// 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: z.enum(["pnpm", "bun", "npm"]),
|
||||
name: z.string(),
|
||||
|
||||
@@ -3,7 +3,9 @@ import { describe, expect, it } from "vite-plus/test";
|
||||
import {
|
||||
DEFAULT_NAMESPACE,
|
||||
expandEnv,
|
||||
isLikelyNamespaceTypo,
|
||||
isNamespace,
|
||||
isValidModuleId,
|
||||
parseModuleSpec,
|
||||
RegistriesSchema,
|
||||
RegistryConfigSchema,
|
||||
@@ -22,12 +24,63 @@ describe("parseModuleSpec", () => {
|
||||
expect(parseModuleSpec("@acme/foo/bar")).toEqual({ namespace: "@acme", id: "foo/bar" });
|
||||
});
|
||||
|
||||
it("rejects an unscoped @ id (no slash)", () => {
|
||||
// No slash → treated as a bare id (whoever consumes it will fail on the `@`).
|
||||
it("preserves the lenient behavior for an unscoped @ id (no slash)", () => {
|
||||
// parseModuleSpec stays lenient — callers wanting strictness should
|
||||
// check isLikelyNamespaceTypo first.
|
||||
expect(parseModuleSpec("@bare")).toEqual({ id: "@bare" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("isLikelyNamespaceTypo", () => {
|
||||
it("flags a leading-@ input with no slash", () => {
|
||||
expect(isLikelyNamespaceTypo("@bare")).toBe(true);
|
||||
expect(isLikelyNamespaceTypo("@acme")).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts well-formed @ns/id specs", () => {
|
||||
expect(isLikelyNamespaceTypo("@acme/foo")).toBe(false);
|
||||
expect(isLikelyNamespaceTypo("@acme/foo/bar")).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts bare ids", () => {
|
||||
expect(isLikelyNamespaceTypo("vitest")).toBe(false);
|
||||
expect(isLikelyNamespaceTypo("better-auth")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isValidModuleId", () => {
|
||||
it("accepts single-segment ids", () => {
|
||||
expect(isValidModuleId("vitest")).toBe(true);
|
||||
expect(isValidModuleId("better-auth")).toBe(true);
|
||||
expect(isValidModuleId("a1")).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts nested segments", () => {
|
||||
expect(isValidModuleId("foo/bar")).toBe(true);
|
||||
expect(isValidModuleId("a/b/c")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects path traversal and url-injection patterns", () => {
|
||||
expect(isValidModuleId("..")).toBe(false);
|
||||
expect(isValidModuleId("foo/..")).toBe(false);
|
||||
expect(isValidModuleId("../etc")).toBe(false);
|
||||
expect(isValidModuleId("foo?x=1")).toBe(false);
|
||||
expect(isValidModuleId("foo#frag")).toBe(false);
|
||||
expect(isValidModuleId("foo%20bar")).toBe(false);
|
||||
expect(isValidModuleId("foo bar")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects leading/trailing/double slashes", () => {
|
||||
expect(isValidModuleId("/foo")).toBe(false);
|
||||
expect(isValidModuleId("foo/")).toBe(false);
|
||||
expect(isValidModuleId("foo//bar")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects empty input", () => {
|
||||
expect(isValidModuleId("")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isNamespace", () => {
|
||||
it("accepts valid scopes", () => {
|
||||
expect(isNamespace("@stanza")).toBe(true);
|
||||
@@ -53,6 +106,12 @@ describe("expandEnv", () => {
|
||||
expect(expandEnv("a=${A} b=${B}", { A: "1" })).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when a var is set to the empty string", () => {
|
||||
// Otherwise `Authorization: Bearer ${TOKEN}` with TOKEN="" produces
|
||||
// a literal `Bearer ` header with a trailing space.
|
||||
expect(expandEnv("Bearer ${TOKEN}", { TOKEN: "" })).toBeNull();
|
||||
});
|
||||
|
||||
it("passes input through when there are no tokens", () => {
|
||||
expect(expandEnv("static", {})).toBe("static");
|
||||
});
|
||||
|
||||
@@ -20,6 +20,10 @@ const SPEC_RE = /^(@[a-zA-Z0-9][a-zA-Z0-9-_]*[a-zA-Z0-9])\/(.+)$/;
|
||||
* Split a CLI module identifier. `"@acme/foo"` →
|
||||
* `{ namespace: "@acme", id: "foo" }`; a bare id → `{ id }` (default namespace
|
||||
* applies). The id portion may itself contain slashes.
|
||||
*
|
||||
* Lenient: a leading-`@` input with no slash (e.g. `"@bare"`) returns
|
||||
* `{ id: "@bare" }`. Callers that require a real module spec should check
|
||||
* {@link isLikelyNamespaceTypo} first and surface a clearer error.
|
||||
*/
|
||||
export function parseModuleSpec(spec: string): { namespace?: string; id: string } {
|
||||
const m = SPEC_RE.exec(spec);
|
||||
@@ -27,6 +31,30 @@ export function parseModuleSpec(spec: string): { namespace?: string; id: string
|
||||
return { id: spec };
|
||||
}
|
||||
|
||||
/**
|
||||
* Heuristic for "you typed a namespace but forgot the module id".
|
||||
* `"@acme"` → true; `"@acme/foo"` → false; `"vitest"` → false.
|
||||
* `add`/`remove` should reject these with a clear hint; `search` treats
|
||||
* them as a literal search string and leaves them alone.
|
||||
*/
|
||||
export function isLikelyNamespaceTypo(spec: string): boolean {
|
||||
return spec.startsWith("@") && !spec.includes("/");
|
||||
}
|
||||
|
||||
/**
|
||||
* Module ids are interpolated into registry URLs via `{id}` substitution.
|
||||
* Restricting them to a strict, slash-segmented identifier shape closes off
|
||||
* path traversal (`..`) and URL-injection (`?`, `#`, encoded chars) attacks
|
||||
* via crafted CLI args or manifest entries. Each segment must start with an
|
||||
* alphanumeric and contain only `[a-zA-Z0-9-_]`; nested segments use `/`.
|
||||
*/
|
||||
const MODULE_ID_RE = /^[a-zA-Z0-9][a-zA-Z0-9-_]*(?:\/[a-zA-Z0-9][a-zA-Z0-9-_]*)*$/;
|
||||
|
||||
/** True when `id` is a syntactically safe module id. */
|
||||
export function isValidModuleId(id: string): boolean {
|
||||
return MODULE_ID_RE.test(id);
|
||||
}
|
||||
|
||||
/** True when `value` is a syntactically valid namespace key. */
|
||||
export function isNamespace(value: string): boolean {
|
||||
return NAMESPACE_RE.test(value);
|
||||
@@ -34,14 +62,16 @@ export function isNamespace(value: string): boolean {
|
||||
|
||||
/**
|
||||
* Replace `${VAR}` tokens with values from `env`. Returns `null` when any
|
||||
* referenced variable is unset, leaving the caller to react: header builders
|
||||
* drop the header (matches shadcn), URL builders treat it as an error.
|
||||
* referenced variable is unset OR set to the empty string, leaving the caller
|
||||
* to react: header builders drop the header, URL builders treat it as an
|
||||
* error. Treating `""` as missing avoids sending a literal `Authorization:
|
||||
* Bearer ` header with a trailing space when `TOKEN=""`.
|
||||
*/
|
||||
export function expandEnv(input: string, env: NodeJS.ProcessEnv = process.env): string | null {
|
||||
let missing = false;
|
||||
const result = input.replace(/\$\{(\w+)\}/g, (_, name: string) => {
|
||||
const v = env[name];
|
||||
if (v === undefined) {
|
||||
if (v === undefined || v === "") {
|
||||
missing = true;
|
||||
return "";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user