Skip to content

Commit 4573877

Browse files
committed
feat: build public registry items
1 parent bc0f307 commit 4573877

9 files changed

Lines changed: 340 additions & 4 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ Current local prototype:
113113
```bash
114114
node apps/cli/src/cli.mjs list
115115
node apps/cli/src/cli.mjs validate
116+
node apps/cli/src/cli.mjs build
116117
node apps/cli/src/cli.mjs add api-keys --target ../some-app --dry-run
117118
node apps/cli/src/cli.mjs diff api-keys --target ../some-app
118119
```

apps/cli/src/cli.mjs

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ function usage() {
1818
Usage:
1919
stackfoundry list
2020
stackfoundry validate
21+
stackfoundry build
2122
stackfoundry add <module> [--target <dir>] [--dry-run] [--force]
2223
stackfoundry diff <module> [--target <dir>]
2324
`);
@@ -241,6 +242,63 @@ async function diffModule(name, flags) {
241242
process.exitCode = changes > 0 ? 1 : 0;
242243
}
243244

245+
async function buildRegistry() {
246+
const outputDir = path.join(repoRoot, "public", "r");
247+
await mkdir(outputDir, { recursive: true });
248+
249+
const registry = await readJson(path.join(repoRoot, "registry.json"));
250+
const builtRegistry = {
251+
...registry,
252+
items: registry.items.map((item) => ({
253+
name: item.name,
254+
type: item.type,
255+
title: item.title,
256+
description: item.description,
257+
})),
258+
};
259+
260+
await writeFile(path.join(outputDir, "registry.json"), `${JSON.stringify(builtRegistry, null, 2)}\n`);
261+
262+
for (const item of registry.items) {
263+
const { dir, manifest } = await getModule(item.name);
264+
const filesDir = path.join(dir, "files");
265+
const files = [];
266+
267+
for (const file of manifest.files) {
268+
const source = path.join(filesDir, file.path);
269+
files.push({
270+
path: file.path,
271+
type: file.type,
272+
content: await readFile(source, "utf8"),
273+
});
274+
}
275+
276+
const registryItem = {
277+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
278+
name: manifest.name,
279+
type: item.type,
280+
title: manifest.title,
281+
description: manifest.description,
282+
dependencies: manifest.dependencies,
283+
devDependencies: manifest.devDependencies,
284+
registryDependencies: manifest.registryDependencies,
285+
files,
286+
meta: {
287+
category: manifest.category,
288+
env: manifest.env,
289+
status: manifest.status,
290+
agents: manifest.agents,
291+
drizzle: manifest.drizzle,
292+
},
293+
};
294+
295+
await writeFile(path.join(outputDir, `${manifest.name}.json`), `${JSON.stringify(registryItem, null, 2)}\n`);
296+
console.log(`built public/r/${manifest.name}.json`);
297+
}
298+
299+
console.log("built public/r/registry.json");
300+
}
301+
244302
async function main() {
245303
const { command, moduleName, flags } = parseArgs(process.argv.slice(2));
246304

@@ -250,6 +308,7 @@ async function main() {
250308
}
251309

252310
if (command === "list") return listModules();
311+
if (command === "build") return buildRegistry();
253312
if (command === "validate") {
254313
const errors = await validateRegistry();
255314
if (errors.length > 0) {

docs/registry.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,21 @@ Installers must not overwrite modified user files silently. The installer should
4141
```bash
4242
node apps/cli/src/cli.mjs list
4343
node apps/cli/src/cli.mjs validate
44+
node apps/cli/src/cli.mjs build
4445
node apps/cli/src/cli.mjs add drizzle-postgres --target /path/to/app
4546
node apps/cli/src/cli.mjs diff drizzle-postgres --target /path/to/app
4647
```
48+
49+
## Public Build Output
50+
51+
The `build` command generates registry-compatible JSON files under:
52+
53+
```text
54+
public/r/
55+
registry.json
56+
drizzle-postgres.json
57+
api-keys.json
58+
stripe-billing.json
59+
```
60+
61+
This mirrors the source-registry pattern: the registry item JSON embeds file contents while module metadata stays in `registry/modules/<module>/module.json`.

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,9 @@
1313
"pnpm": ">=10.11.0"
1414
},
1515
"scripts": {
16-
"check": "pnpm validate && pnpm lint && pnpm test",
16+
"check": "pnpm validate && pnpm lint && pnpm test && pnpm registry:build",
1717
"validate": "node apps/cli/src/cli.mjs validate",
18+
"registry:build": "node apps/cli/src/cli.mjs build",
1819
"lint": "node --check apps/cli/src/cli.mjs",
1920
"test": "rm -rf /tmp/stackfoundry-test && mkdir -p /tmp/stackfoundry-test && node apps/cli/src/cli.mjs list >/dev/null && node apps/cli/src/cli.mjs add api-keys --target /tmp/stackfoundry-test >/dev/null && node apps/cli/src/cli.mjs diff api-keys --target /tmp/stackfoundry-test >/dev/null",
2021
"build": "pnpm validate"

public/r/api-keys.json

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
{
2+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
3+
"name": "api-keys",
4+
"type": "registry:item",
5+
"title": "API Keys",
6+
"description": "API key lifecycle, scopes, hashed storage, usage metadata, and management UI.",
7+
"dependencies": [],
8+
"devDependencies": [],
9+
"registryDependencies": [
10+
"drizzle-postgres"
11+
],
12+
"files": [
13+
{
14+
"path": "packages/db/src/schema/api-keys.ts",
15+
"type": "registry:file",
16+
"content": "import { pgTable, text, timestamp, uuid } from \"drizzle-orm/pg-core\";\n\nexport const apiKeys = pgTable(\"api_keys\", {\n id: uuid(\"id\").primaryKey().defaultRandom(),\n ownerId: text(\"owner_id\").notNull(),\n name: text(\"name\").notNull(),\n prefix: text(\"prefix\").notNull(),\n hash: text(\"hash\").notNull(),\n scopes: text(\"scopes\").array().notNull().default([]),\n lastUsedAt: timestamp(\"last_used_at\", { withTimezone: true }),\n revokedAt: timestamp(\"revoked_at\", { withTimezone: true }),\n createdAt: timestamp(\"created_at\", { withTimezone: true }).defaultNow().notNull(),\n});\n"
17+
},
18+
{
19+
"path": "apps/web/src/lib/api-keys.ts",
20+
"type": "registry:file",
21+
"content": "import \"server-only\";\n\nimport { createHash, randomBytes, timingSafeEqual } from \"node:crypto\";\n\nconst KEY_PREFIX = \"sf\";\n\nexport function createApiKeySecret() {\n const secret = randomBytes(32).toString(\"base64url\");\n return `${KEY_PREFIX}_${secret}`;\n}\n\nexport function getApiKeyPrefix(secret: string) {\n return secret.slice(0, 12);\n}\n\nexport function hashApiKey(secret: string) {\n return createHash(\"sha256\").update(secret).digest(\"hex\");\n}\n\nexport function safeCompareHash(secret: string, expectedHash: string) {\n const actual = Buffer.from(hashApiKey(secret));\n const expected = Buffer.from(expectedHash);\n return actual.length === expected.length && timingSafeEqual(actual, expected);\n}\n"
22+
},
23+
{
24+
"path": "apps/web/src/app/(console)/api-keys/page.tsx",
25+
"type": "registry:page",
26+
"content": "export default function ApiKeysPage() {\n return (\n <main className=\"flex flex-col gap-4 p-6\">\n <div>\n <h1 className=\"text-2xl font-semibold\">API Keys</h1>\n <p className=\"text-muted-foreground\">Create, scope, rotate, and revoke API keys.</p>\n </div>\n </main>\n );\n}\n"
27+
}
28+
],
29+
"meta": {
30+
"category": "developer-platform",
31+
"env": [],
32+
"status": "experimental",
33+
"agents": {
34+
"skills": [
35+
"api-keys"
36+
],
37+
"rules": [
38+
"hash-keys",
39+
"never-store-plaintext",
40+
"scope-checks",
41+
"audit-key-events"
42+
]
43+
},
44+
"drizzle": {
45+
"schemaExports": [
46+
"apiKeys"
47+
],
48+
"migrationRecommended": true
49+
}
50+
}
51+
}

public/r/drizzle-postgres.json

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
{
2+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
3+
"name": "drizzle-postgres",
4+
"type": "registry:item",
5+
"title": "Drizzle Postgres",
6+
"description": "Postgres + Drizzle package, schema barrel, migrations, and server-only DB access.",
7+
"dependencies": [
8+
"drizzle-orm",
9+
"postgres"
10+
],
11+
"devDependencies": [
12+
"drizzle-kit",
13+
"dotenv"
14+
],
15+
"registryDependencies": [],
16+
"files": [
17+
{
18+
"path": "packages/db/package.json",
19+
"type": "registry:file",
20+
"content": "{\n \"name\": \"@workspace/db\",\n \"private\": true,\n \"type\": \"module\",\n \"exports\": {\n \".\": \"./src/index.ts\",\n \"./schema\": \"./src/schema/index.ts\"\n }\n}\n"
21+
},
22+
{
23+
"path": "packages/db/src/client.ts",
24+
"type": "registry:file",
25+
"content": "import { drizzle } from \"drizzle-orm/postgres-js\";\nimport postgres from \"postgres\";\nimport * as schema from \"./schema\";\n\nexport function createDb(connectionString: string) {\n const client = postgres(connectionString, { max: 1 });\n return drizzle(client, { schema });\n}\n"
26+
},
27+
{
28+
"path": "packages/db/src/index.ts",
29+
"type": "registry:file",
30+
"content": "export { createDb } from \"./client\";\nexport * from \"./schema\";\n"
31+
},
32+
{
33+
"path": "packages/db/src/schema/index.ts",
34+
"type": "registry:file",
35+
"content": "/**\n * Export every table from this barrel so Drizzle Kit and createDb share one schema graph.\n */\n"
36+
},
37+
{
38+
"path": "apps/web/src/lib/db.ts",
39+
"type": "registry:file",
40+
"content": "import \"server-only\";\n\nimport { createDb } from \"@workspace/db\";\n\nconst globalForDb = globalThis as typeof globalThis & {\n __stackfoundryDb?: ReturnType<typeof createDb>;\n};\n\nfunction getConnectionString() {\n const url = process.env.DATABASE_URL;\n if (!url) {\n throw new Error(\"DATABASE_URL is not set.\");\n }\n return url;\n}\n\nexport function getDb() {\n if (!globalForDb.__stackfoundryDb) {\n globalForDb.__stackfoundryDb = createDb(getConnectionString());\n }\n return globalForDb.__stackfoundryDb;\n}\n"
41+
},
42+
{
43+
"path": "drizzle.config.ts",
44+
"type": "registry:file",
45+
"content": "import { config } from \"dotenv\";\nimport { defineConfig } from \"drizzle-kit\";\n\nconfig({ path: \".env\" });\nconfig({ path: \".env.local\", override: true });\n\nexport default defineConfig({\n schema: \"./packages/db/src/schema/index.ts\",\n out: \"./packages/db/drizzle\",\n dialect: \"postgresql\",\n dbCredentials: {\n url: process.env.DATABASE_URL ?? \"\",\n },\n});\n"
46+
}
47+
],
48+
"meta": {
49+
"category": "database",
50+
"env": [
51+
"DATABASE_URL"
52+
],
53+
"status": "experimental",
54+
"agents": {
55+
"skills": [
56+
"drizzle-postgres"
57+
],
58+
"rules": [
59+
"server-only",
60+
"schema-barrel",
61+
"commit-migrations"
62+
]
63+
},
64+
"drizzle": {
65+
"schemaExports": [],
66+
"migrationRecommended": true
67+
}
68+
}
69+
}

public/r/registry.json

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
{
2+
"$schema": "https://ui.shadcn.com/schema/registry.json",
3+
"name": "stackfoundry",
4+
"homepage": "https://github.com/jesseoue/stackfoundry",
5+
"items": [
6+
{
7+
"name": "drizzle-postgres",
8+
"type": "registry:item",
9+
"title": "Drizzle Postgres",
10+
"description": "Postgres + Drizzle package, schema barrel, migrations, and server-only DB access."
11+
},
12+
{
13+
"name": "api-keys",
14+
"type": "registry:item",
15+
"title": "API Keys",
16+
"description": "API key lifecycle, scopes, hashed storage, usage metadata, and maintenance instructions."
17+
},
18+
{
19+
"name": "stripe-billing",
20+
"type": "registry:item",
21+
"title": "Stripe Billing",
22+
"description": "Billing core, Stripe checkout, portal, webhooks, entitlement mapping, and verification checklist."
23+
}
24+
]
25+
}

public/r/stripe-billing.json

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
{
2+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
3+
"name": "stripe-billing",
4+
"type": "registry:item",
5+
"title": "Stripe Billing",
6+
"description": "Stripe checkout, billing portal, subscription sync, webhook dedupe, and entitlement mapping.",
7+
"dependencies": [
8+
"stripe"
9+
],
10+
"devDependencies": [],
11+
"registryDependencies": [
12+
"drizzle-postgres"
13+
],
14+
"files": [
15+
{
16+
"path": "packages/db/src/schema/billing.ts",
17+
"type": "registry:file",
18+
"content": "import { pgTable, text, timestamp, uuid } from \"drizzle-orm/pg-core\";\n\nexport const billingCustomers = pgTable(\"billing_customers\", {\n id: uuid(\"id\").primaryKey().defaultRandom(),\n ownerId: text(\"owner_id\").notNull(),\n provider: text(\"provider\").notNull().default(\"stripe\"),\n providerCustomerId: text(\"provider_customer_id\").notNull(),\n createdAt: timestamp(\"created_at\", { withTimezone: true }).defaultNow().notNull(),\n});\n\nexport const subscriptions = pgTable(\"subscriptions\", {\n id: uuid(\"id\").primaryKey().defaultRandom(),\n ownerId: text(\"owner_id\").notNull(),\n provider: text(\"provider\").notNull().default(\"stripe\"),\n providerSubscriptionId: text(\"provider_subscription_id\").notNull(),\n status: text(\"status\").notNull(),\n currentPeriodEndsAt: timestamp(\"current_period_ends_at\", { withTimezone: true }),\n createdAt: timestamp(\"created_at\", { withTimezone: true }).defaultNow().notNull(),\n});\n\nexport const webhookEvents = pgTable(\"webhook_events\", {\n id: uuid(\"id\").primaryKey().defaultRandom(),\n provider: text(\"provider\").notNull(),\n providerEventId: text(\"provider_event_id\").notNull(),\n eventType: text(\"event_type\").notNull(),\n processedAt: timestamp(\"processed_at\", { withTimezone: true }),\n createdAt: timestamp(\"created_at\", { withTimezone: true }).defaultNow().notNull(),\n});\n"
19+
},
20+
{
21+
"path": "apps/web/src/lib/stripe/client.ts",
22+
"type": "registry:file",
23+
"content": "import \"server-only\";\n\nimport Stripe from \"stripe\";\n\nexport function getStripe() {\n const secretKey = process.env.STRIPE_SECRET_KEY;\n if (!secretKey) {\n throw new Error(\"STRIPE_SECRET_KEY is not set.\");\n }\n\n return new Stripe(secretKey);\n}\n"
24+
},
25+
{
26+
"path": "apps/web/src/app/api/webhooks/stripe/route.ts",
27+
"type": "registry:file",
28+
"content": "import { headers } from \"next/headers\";\n\nimport { getStripe } from \"@/lib/stripe/client\";\n\nexport async function POST(request: Request) {\n const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;\n if (!webhookSecret) {\n return Response.json({ error: \"STRIPE_WEBHOOK_SECRET is not set.\" }, { status: 500 });\n }\n\n const signature = (await headers()).get(\"stripe-signature\");\n if (!signature) {\n return Response.json({ error: \"Missing stripe-signature header.\" }, { status: 400 });\n }\n\n const body = await request.text();\n const event = getStripe().webhooks.constructEvent(body, signature, webhookSecret);\n\n // TODO: Dedupe event.id before applying side effects.\n return Response.json({ received: true, id: event.id, type: event.type });\n}\n"
29+
},
30+
{
31+
"path": "apps/web/src/app/(console)/billing/page.tsx",
32+
"type": "registry:page",
33+
"content": "export default function BillingPage() {\n return (\n <main className=\"flex flex-col gap-4 p-6\">\n <div>\n <h1 className=\"text-2xl font-semibold\">Billing</h1>\n <p className=\"text-muted-foreground\">Manage checkout, subscriptions, invoices, and entitlements.</p>\n </div>\n </main>\n );\n}\n"
34+
}
35+
],
36+
"meta": {
37+
"category": "billing",
38+
"env": [
39+
"STRIPE_SECRET_KEY",
40+
"STRIPE_WEBHOOK_SECRET",
41+
"NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY"
42+
],
43+
"status": "experimental",
44+
"agents": {
45+
"skills": [
46+
"stripe-billing"
47+
],
48+
"rules": [
49+
"verify-webhooks",
50+
"dedupe-events",
51+
"map-entitlements",
52+
"never-trust-client-prices"
53+
]
54+
},
55+
"drizzle": {
56+
"schemaExports": [
57+
"billingCustomers",
58+
"subscriptions",
59+
"webhookEvents"
60+
],
61+
"migrationRecommended": true
62+
}
63+
}
64+
}

0 commit comments

Comments
 (0)