From eab7f6413ab71b6d795b28f97178248bd2c88290 Mon Sep 17 00:00:00 2001 From: Caplets Test Date: Sat, 27 Jun 2026 12:13:18 -0400 Subject: [PATCH 01/16] feat(catalog): add public caplets catalog --- .changeset/config.json | 2 +- .changeset/fuzzy-catalogs-smile.md | 7 + .github/workflows/deploy.yml | 2 +- .oxfmtrc.json | 2 +- CONCEPTS.md | 12 + alchemy.run.ts | 37 +- apps/catalog/astro.config.mjs | 50 + apps/catalog/migrations/0001_catalog.sql | 42 + apps/catalog/package.json | 40 + apps/catalog/public/icon-header-dark.png | Bin 0 -> 2115 bytes apps/catalog/public/icon.png | Bin 0 -> 166667 bytes apps/catalog/src/cloudflare-dev-middleware.ts | 3 + apps/catalog/src/cloudflare-workers-dev.ts | 1 + .../catalog/src/components/CapletDetail.astro | 116 +++ .../catalog/src/components/CapletResult.astro | 50 + .../src/components/CatalogHeader.astro | 65 ++ apps/catalog/src/components/FilterBar.astro | 37 + apps/catalog/src/components/HugeIcon.astro | 39 + .../src/components/InstallCommand.astro | 34 + apps/catalog/src/components/ResultList.astro | 33 + .../catalog/src/components/SafetyNotice.astro | 33 + apps/catalog/src/components/SearchShell.astro | 68 ++ apps/catalog/src/components/ThemeInit.astro | 3 + apps/catalog/src/components/ThemeToggle.astro | 77 ++ .../src/components/starwind/badge/Badge.astro | 17 + .../src/components/starwind/badge/index.ts | 7 + .../src/components/starwind/badge/variants.ts | 41 + .../components/starwind/button/Button.astro | 26 + .../src/components/starwind/button/index.ts | 7 + .../components/starwind/button/variants.ts | 38 + .../src/components/starwind/card/Card.astro | 19 + .../components/starwind/card/CardAction.astro | 13 + .../starwind/card/CardContent.astro | 13 + .../starwind/card/CardDescription.astro | 13 + .../components/starwind/card/CardFooter.astro | 13 + .../components/starwind/card/CardHeader.astro | 13 + .../components/starwind/card/CardTitle.astro | 13 + .../src/components/starwind/card/index.ts | 46 + .../src/components/starwind/card/variants.ts | 45 + .../src/components/starwind/table/Table.astro | 16 + .../components/starwind/table/TableBody.astro | 14 + .../starwind/table/TableCaption.astro | 14 + .../components/starwind/table/TableCell.astro | 14 + .../components/starwind/table/TableFoot.astro | 14 + .../components/starwind/table/TableHead.astro | 14 + .../starwind/table/TableHeader.astro | 14 + .../components/starwind/table/TableRow.astro | 14 + .../src/components/starwind/table/index.ts | 51 + .../src/components/starwind/table/variants.ts | 33 + apps/catalog/src/data/official-catalog.json | 878 ++++++++++++++++++ apps/catalog/src/env.d.ts | 14 + apps/catalog/src/lib/catalog-env.ts | 11 + apps/catalog/src/lib/catalog-response.ts | 31 + apps/catalog/src/lib/catalog-store.ts | 78 ++ apps/catalog/src/lib/counts.ts | 6 + apps/catalog/src/lib/entry-route.ts | 3 + apps/catalog/src/lib/ingest.ts | 353 +++++++ apps/catalog/src/lib/markdown.ts | 92 ++ apps/catalog/src/lib/public-source.ts | 11 + apps/catalog/src/lib/rate-limit.ts | 22 + apps/catalog/src/lib/search-filter.ts | 50 + apps/catalog/src/lib/search-row.ts | 74 ++ apps/catalog/src/lib/suppression.ts | 16 + apps/catalog/src/lib/theme.ts | 31 + apps/catalog/src/middleware.ts | 8 + .../api/v1/catalog/entries/[entryKey].ts | 17 + .../catalog/src/pages/api/v1/catalog/index.ts | 11 + .../pages/api/v1/catalog/install-signals.ts | 36 + .../src/pages/caplets/[entryKey].astro | 59 ++ apps/catalog/src/pages/index.astro | 62 ++ apps/catalog/src/scripts/copy.ts | 29 + apps/catalog/src/scripts/search.ts | 5 + apps/catalog/src/scripts/theme-init.ts | 21 + apps/catalog/src/scripts/theme.ts | 135 +++ apps/catalog/src/scripts/virtual-results.ts | 244 +++++ apps/catalog/src/styles/catalog.css | 426 +++++++++ apps/catalog/src/styles/starwind.css | 178 ++++ apps/catalog/starwind.config.json | 28 + apps/catalog/test/catalog-api.test.ts | 61 ++ .../test/fixtures/catalog-search-rows.ts | 42 + .../test/fixtures/cloudflare-workers.ts | 1 + apps/catalog/test/ingest.test.ts | 288 ++++++ apps/catalog/test/markdown.test.ts | 46 + apps/catalog/test/search-filter.test.ts | 119 +++ apps/catalog/test/search-row.test.ts | 120 +++ apps/catalog/test/theme.test.ts | 29 + apps/catalog/test/virtual-results.test.ts | 160 ++++ apps/catalog/tsconfig.json | 11 + apps/catalog/vitest.config.ts | 13 + apps/docs/astro.config.mjs | 2 + apps/docs/src/content/docs/catalog.mdx | 46 + apps/docs/src/content/docs/install.mdx | 13 + .../src/content/docs/privacy/indexing.mdx | 51 + apps/docs/src/content/docs/vault.mdx | 16 + caplets/ast-grep/CAPLET.md | 24 +- caplets/browser-use/CAPLET.md | 4 +- caplets/context7/CAPLET.md | 23 +- caplets/deepwiki/CAPLET.md | 8 - caplets/github/CAPLET.md | 19 - caplets/gmail/CAPLET.md | 8 - caplets/google-drive/CAPLET.md | 7 - caplets/google-tasks/CAPLET.md | 6 - caplets/linear/CAPLET.md | 12 - caplets/lsp/CAPLET.md | 2 +- caplets/npm/CAPLET.md | 5 - caplets/playwright/CAPLET.md | 4 +- caplets/posthog/CAPLET.md | 1 - caplets/sentry/CAPLET.md | 1 - caplets/sourcegraph/CAPLET.md | 1 - caplets/stealth-browser-use/CAPLET.md | 38 - ...aplets-catalog-search-site-requirements.md | 224 +++++ ...search-virtualized-results-requirements.md | 194 ++++ ...2-feat-caplets-catalog-search-site-plan.md | 343 +++++++ ...efactor-catalog-starwind-migration-plan.md | 170 ++++ ...2-feat-catalog-virtualized-results-plan.md | 269 ++++++ infra/alchemy-domains.ts | 6 + infra/alchemy-runner.test.ts | 29 +- package.json | 2 + packages/core/package.json | 4 + packages/core/rolldown.config.ts | 1 + .../core/src/catalog-indexing/eligibility.ts | 79 ++ packages/core/src/catalog-indexing/payload.ts | 26 + packages/core/src/catalog/caplet-markdown.ts | 117 +++ packages/core/src/catalog/entry.ts | 74 ++ packages/core/src/catalog/index.ts | 41 + packages/core/src/catalog/install-command.ts | 38 + packages/core/src/catalog/source.ts | 154 +++ packages/core/src/catalog/types.ts | 121 +++ packages/core/src/catalog/warnings.ts | 73 ++ packages/core/src/cli.ts | 154 ++- packages/core/src/cli/install.ts | 175 ++++ packages/core/src/remote-control/dispatch.ts | 37 +- packages/core/test/catalog-indexing.test.ts | 224 +++++ packages/core/test/catalog-model.test.ts | 144 +++ .../core/test/catalog-official-index.test.ts | 42 + packages/core/test/config.test.ts | 144 --- packages/core/test/package-boundaries.test.ts | 1 + .../core/test/remote-control-dispatch.test.ts | 54 ++ pnpm-lock.yaml | 852 ++++++++++++++++- scripts/generate-catalog-index.ts | 99 ++ turbo.json | 3 + 141 files changed, 8952 insertions(+), 327 deletions(-) create mode 100644 .changeset/fuzzy-catalogs-smile.md create mode 100644 apps/catalog/astro.config.mjs create mode 100644 apps/catalog/migrations/0001_catalog.sql create mode 100644 apps/catalog/package.json create mode 100644 apps/catalog/public/icon-header-dark.png create mode 100644 apps/catalog/public/icon.png create mode 100644 apps/catalog/src/cloudflare-dev-middleware.ts create mode 100644 apps/catalog/src/cloudflare-workers-dev.ts create mode 100644 apps/catalog/src/components/CapletDetail.astro create mode 100644 apps/catalog/src/components/CapletResult.astro create mode 100644 apps/catalog/src/components/CatalogHeader.astro create mode 100644 apps/catalog/src/components/FilterBar.astro create mode 100644 apps/catalog/src/components/HugeIcon.astro create mode 100644 apps/catalog/src/components/InstallCommand.astro create mode 100644 apps/catalog/src/components/ResultList.astro create mode 100644 apps/catalog/src/components/SafetyNotice.astro create mode 100644 apps/catalog/src/components/SearchShell.astro create mode 100644 apps/catalog/src/components/ThemeInit.astro create mode 100644 apps/catalog/src/components/ThemeToggle.astro create mode 100644 apps/catalog/src/components/starwind/badge/Badge.astro create mode 100644 apps/catalog/src/components/starwind/badge/index.ts create mode 100644 apps/catalog/src/components/starwind/badge/variants.ts create mode 100644 apps/catalog/src/components/starwind/button/Button.astro create mode 100644 apps/catalog/src/components/starwind/button/index.ts create mode 100644 apps/catalog/src/components/starwind/button/variants.ts create mode 100644 apps/catalog/src/components/starwind/card/Card.astro create mode 100644 apps/catalog/src/components/starwind/card/CardAction.astro create mode 100644 apps/catalog/src/components/starwind/card/CardContent.astro create mode 100644 apps/catalog/src/components/starwind/card/CardDescription.astro create mode 100644 apps/catalog/src/components/starwind/card/CardFooter.astro create mode 100644 apps/catalog/src/components/starwind/card/CardHeader.astro create mode 100644 apps/catalog/src/components/starwind/card/CardTitle.astro create mode 100644 apps/catalog/src/components/starwind/card/index.ts create mode 100644 apps/catalog/src/components/starwind/card/variants.ts create mode 100644 apps/catalog/src/components/starwind/table/Table.astro create mode 100644 apps/catalog/src/components/starwind/table/TableBody.astro create mode 100644 apps/catalog/src/components/starwind/table/TableCaption.astro create mode 100644 apps/catalog/src/components/starwind/table/TableCell.astro create mode 100644 apps/catalog/src/components/starwind/table/TableFoot.astro create mode 100644 apps/catalog/src/components/starwind/table/TableHead.astro create mode 100644 apps/catalog/src/components/starwind/table/TableHeader.astro create mode 100644 apps/catalog/src/components/starwind/table/TableRow.astro create mode 100644 apps/catalog/src/components/starwind/table/index.ts create mode 100644 apps/catalog/src/components/starwind/table/variants.ts create mode 100644 apps/catalog/src/data/official-catalog.json create mode 100644 apps/catalog/src/env.d.ts create mode 100644 apps/catalog/src/lib/catalog-env.ts create mode 100644 apps/catalog/src/lib/catalog-response.ts create mode 100644 apps/catalog/src/lib/catalog-store.ts create mode 100644 apps/catalog/src/lib/counts.ts create mode 100644 apps/catalog/src/lib/entry-route.ts create mode 100644 apps/catalog/src/lib/ingest.ts create mode 100644 apps/catalog/src/lib/markdown.ts create mode 100644 apps/catalog/src/lib/public-source.ts create mode 100644 apps/catalog/src/lib/rate-limit.ts create mode 100644 apps/catalog/src/lib/search-filter.ts create mode 100644 apps/catalog/src/lib/search-row.ts create mode 100644 apps/catalog/src/lib/suppression.ts create mode 100644 apps/catalog/src/lib/theme.ts create mode 100644 apps/catalog/src/middleware.ts create mode 100644 apps/catalog/src/pages/api/v1/catalog/entries/[entryKey].ts create mode 100644 apps/catalog/src/pages/api/v1/catalog/index.ts create mode 100644 apps/catalog/src/pages/api/v1/catalog/install-signals.ts create mode 100644 apps/catalog/src/pages/caplets/[entryKey].astro create mode 100644 apps/catalog/src/pages/index.astro create mode 100644 apps/catalog/src/scripts/copy.ts create mode 100644 apps/catalog/src/scripts/search.ts create mode 100644 apps/catalog/src/scripts/theme-init.ts create mode 100644 apps/catalog/src/scripts/theme.ts create mode 100644 apps/catalog/src/scripts/virtual-results.ts create mode 100644 apps/catalog/src/styles/catalog.css create mode 100644 apps/catalog/src/styles/starwind.css create mode 100644 apps/catalog/starwind.config.json create mode 100644 apps/catalog/test/catalog-api.test.ts create mode 100644 apps/catalog/test/fixtures/catalog-search-rows.ts create mode 100644 apps/catalog/test/fixtures/cloudflare-workers.ts create mode 100644 apps/catalog/test/ingest.test.ts create mode 100644 apps/catalog/test/markdown.test.ts create mode 100644 apps/catalog/test/search-filter.test.ts create mode 100644 apps/catalog/test/search-row.test.ts create mode 100644 apps/catalog/test/theme.test.ts create mode 100644 apps/catalog/test/virtual-results.test.ts create mode 100644 apps/catalog/tsconfig.json create mode 100644 apps/catalog/vitest.config.ts create mode 100644 apps/docs/src/content/docs/catalog.mdx create mode 100644 apps/docs/src/content/docs/privacy/indexing.mdx delete mode 100644 caplets/stealth-browser-use/CAPLET.md create mode 100644 docs/brainstorms/2026-06-26-caplets-catalog-search-site-requirements.md create mode 100644 docs/brainstorms/2026-06-27-catalog-search-virtualized-results-requirements.md create mode 100644 docs/plans/2026-06-26-002-feat-caplets-catalog-search-site-plan.md create mode 100644 docs/plans/2026-06-27-001-refactor-catalog-starwind-migration-plan.md create mode 100644 docs/plans/2026-06-27-002-feat-catalog-virtualized-results-plan.md create mode 100644 packages/core/src/catalog-indexing/eligibility.ts create mode 100644 packages/core/src/catalog-indexing/payload.ts create mode 100644 packages/core/src/catalog/caplet-markdown.ts create mode 100644 packages/core/src/catalog/entry.ts create mode 100644 packages/core/src/catalog/index.ts create mode 100644 packages/core/src/catalog/install-command.ts create mode 100644 packages/core/src/catalog/source.ts create mode 100644 packages/core/src/catalog/types.ts create mode 100644 packages/core/src/catalog/warnings.ts create mode 100644 packages/core/test/catalog-indexing.test.ts create mode 100644 packages/core/test/catalog-model.test.ts create mode 100644 packages/core/test/catalog-official-index.test.ts create mode 100644 scripts/generate-catalog-index.ts diff --git a/.changeset/config.json b/.changeset/config.json index d94dbced..2cca2dcb 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -7,5 +7,5 @@ "access": "public", "baseBranch": "main", "updateInternalDependencies": "patch", - "ignore": ["@caplets/landing", "@caplets/docs"] + "ignore": ["@caplets/landing", "@caplets/docs", "@caplets/catalog"] } diff --git a/.changeset/fuzzy-catalogs-smile.md b/.changeset/fuzzy-catalogs-smile.md new file mode 100644 index 00000000..f35895c9 --- /dev/null +++ b/.changeset/fuzzy-catalogs-smile.md @@ -0,0 +1,7 @@ +--- +"@caplets/core": minor +"caplets": minor +--- + +Add shared catalog primitives, official catalog generation, public catalog indexing statuses, +and install-time Vault setup recovery metadata for Caplets install/update flows. diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 587bd9a6..c9f177c7 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -47,7 +47,7 @@ jobs: - name: Run quality gates run: pnpm verify - - name: Deploy landing page + - name: Deploy sites run: pnpm run alchemy:deploy env: ALCHEMY_STAGE: prod diff --git a/.oxfmtrc.json b/.oxfmtrc.json index 17a8580d..af2a8ae5 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -1,6 +1,6 @@ { "$schema": "./node_modules/oxfmt/configuration_schema.json", - "ignorePatterns": ["**/.brv/**"], + "ignorePatterns": ["**/.brv/**", "apps/catalog/src/data/official-catalog.json"], "plugins": ["prettier-plugin-astro"], "overrides": [ { diff --git a/CONCEPTS.md b/CONCEPTS.md index 1bf3f2e6..ff6a1709 100644 --- a/CONCEPTS.md +++ b/CONCEPTS.md @@ -16,6 +16,18 @@ The Prebuilt Caplets Catalog is curated as a Code Mode-first capability catalog, Install-ready catalog entries have an explicit verification status, a reproducible validation path, and a named primary Code Mode workflow. Unverified entries may exist as drafts or recipes, but they do not count as install-ready catalog coverage. +### Catalog Search Site + +The public search surface for installable Caplets at `catalog.caplets.dev`. + +The Catalog Search Site is separate from the landing page and docs site. It indexes official Caplets from this repo and community Caplets discovered through public external installs, with search, readable Caplet content, source labels, install-count popularity signals, warnings, and inspection-first copyable install commands as the primary user flow. + +### Public Catalog Indexing Signal + +The public-source install signal that lets `catalog.caplets.dev` discover and rank community Caplets. + +Public Catalog Indexing is not ordinary anonymous telemetry. It may publish public Caplet content, normalized install command, source identity needed to reproduce install, and aggregate install count; it must not publish installer identity, private source URLs, local paths, config, credentials, prompts, tool arguments, tool outputs, or hostnames. Catalog install counts are popularity and ranking signals, not safety signals, and public entries can be suppressed when automatic indexing creates stale, abusive, leaked, or high-risk catalog records. + ### Catalog-Grade Caplet A Caplet that is ready to live in the Prebuilt Caplets Catalog. diff --git a/alchemy.run.ts b/alchemy.run.ts index e1d5a405..9362c2ef 100644 --- a/alchemy.run.ts +++ b/alchemy.run.ts @@ -1,5 +1,5 @@ import alchemy from "alchemy"; -import { Astro } from "alchemy/cloudflare"; +import { Astro, D1Database } from "alchemy/cloudflare"; import { GitHubComment } from "alchemy/github"; import { CloudflareStateStore } from "alchemy/state"; @@ -10,28 +10,50 @@ const app = await alchemy("caplets", { password: process.env.ALCHEMY_PASSWORD!, }); -const { docsPageDomain, docsPageUrl, landingPageDomain, landingPageUrl } = buildAlchemyDomains( - app.stage, - { local: app.local }, -); +const { + catalogPageDomain, + catalogPageUrl, + docsPageDomain, + docsPageUrl, + landingPageDomain, + landingPageUrl, +} = buildAlchemyDomains(app.stage, { local: app.local }); +const hostSuffix = process.env.SSH_CONNECTION ? " --host 0.0.0.0" : ""; export const landingPage = await Astro("landing-page", { cwd: "apps/landing", dev: { - command: "pnpm run dev" + (process.env.SSH_CONNECTION ? " --host 0.0.0.0" : ""), + command: "pnpm run dev --port 4321" + hostSuffix, }, domains: [landingPageDomain, `www.${landingPageDomain}`], }); export const docsPage = await Astro("docs-page", { cwd: "apps/docs", dev: { - command: "pnpm run dev -- --port 4322" + (process.env.SSH_CONNECTION ? " --host 0.0.0.0" : ""), + command: "pnpm run dev --port 4322" + hostSuffix, }, domains: [docsPageDomain], }); +export const catalogDatabase = await D1Database("catalog-database", { + name: `caplets-${app.stage}-catalog`, + migrationsDir: "apps/catalog/migrations", + adopt: true, + delete: false, +}); +export const catalogPage = await Astro("catalog-page", { + cwd: "apps/catalog", + dev: { + command: "pnpm run dev --port 4323" + hostSuffix, + }, + bindings: { + CATALOG_DB: catalogDatabase, + }, + domains: [catalogPageDomain], +}); console.log({ "Landing Page URL": landingPageUrl, "Docs Page URL": docsPageUrl, + "Catalog Page URL": catalogPageUrl, }); const [repositoryOwnerFromSlug, repositoryNameFromSlug] = @@ -55,6 +77,7 @@ if (pullRequestNumber) { Landing: ${landingPageUrl} Docs: ${docsPageUrl} +Catalog: ${catalogPageUrl} Built from commit ${shortSha}`, }); diff --git a/apps/catalog/astro.config.mjs b/apps/catalog/astro.config.mjs new file mode 100644 index 00000000..b8a170b5 --- /dev/null +++ b/apps/catalog/astro.config.mjs @@ -0,0 +1,50 @@ +// @ts-check +import cloudflare from "@astrojs/cloudflare"; +import tailwindcss from "@tailwindcss/vite"; +import { defineConfig } from "astro/config"; +import { fileURLToPath } from "node:url"; + +const cloudflareDevMiddleware = fileURLToPath( + new URL("./src/cloudflare-dev-middleware.ts", import.meta.url), +); +const cloudflareDevWorkers = fileURLToPath( + new URL("./src/cloudflare-workers-dev.ts", import.meta.url), +); +const isProductionBuild = process.env.NODE_ENV === "production"; +const optimizeExclude = [ + "tailwind-variants", + "unified", + "remark-parse", + "remark-rehype", + "rehype-sanitize", + "rehype-stringify", +]; + +export default defineConfig({ + site: "https://catalog.caplets.dev", + output: "server", + adapter: cloudflare(), + devToolbar: { + enabled: false, + }, + vite: { + build: { + assetsInlineLimit: 0, + }, + plugins: [tailwindcss()], + resolve: { + alias: { + "@astrojs/cloudflare/entrypoints/middleware.js": cloudflareDevMiddleware, + ...(isProductionBuild ? {} : { "cloudflare:workers": cloudflareDevWorkers }), + }, + }, + ssr: { + optimizeDeps: { + exclude: optimizeExclude, + }, + }, + optimizeDeps: { + exclude: optimizeExclude, + }, + }, +}); diff --git a/apps/catalog/migrations/0001_catalog.sql b/apps/catalog/migrations/0001_catalog.sql new file mode 100644 index 00000000..28c6b64f --- /dev/null +++ b/apps/catalog/migrations/0001_catalog.sql @@ -0,0 +1,42 @@ +create table if not exists catalog_counts ( + entry_key text primary key, + install_count integer not null default 0, + updated_at_ms integer not null +); + +create table if not exists catalog_entries ( + entry_key text primary key, + provider text not null, + repository text not null, + source_path text not null, + caplet_id text not null, + resolved_revision text, + content_hash text, + entry_json text not null, + updated_at_ms integer not null +); + +create table if not exists catalog_signal_dedupe ( + entry_key text primary key, + provider text not null, + repository text not null, + accepted_at_ms integer not null +); + +create table if not exists catalog_signal_repository_windows ( + provider text not null, + repository text not null, + window_start_ms integer not null, + accepted_count integer not null default 0, + primary key (provider, repository, window_start_ms) +); + +create table if not exists catalog_suppressions ( + entry_key text primary key, + reason text not null, + suppressed_at text not null +); + +create index if not exists catalog_counts_rank_idx on catalog_counts (install_count desc); +create index if not exists catalog_entries_repository_idx on catalog_entries (provider, repository); +create index if not exists catalog_signal_repository_windows_time_idx on catalog_signal_repository_windows (window_start_ms); diff --git a/apps/catalog/package.json b/apps/catalog/package.json new file mode 100644 index 00000000..166c2b7c --- /dev/null +++ b/apps/catalog/package.json @@ -0,0 +1,40 @@ +{ + "name": "@caplets/catalog", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "astro": "astro", + "build": "astro build", + "dev": "astro dev", + "preview": "astro preview", + "test": "vitest run test", + "typecheck": "astro check" + }, + "dependencies": { + "@astrojs/check": "^0.9.9", + "@astrojs/cloudflare": "^13.7.0", + "@caplets/core": "workspace:*", + "@hugeicons/core-free-icons": "^4.2.2", + "@tailwindcss/forms": "^0.5.11", + "@tailwindcss/vite": "^4.3.1", + "@tanstack/virtual-core": "^3.17.2", + "astro": "^6.4.6", + "rehype-sanitize": "^6.0.0", + "rehype-stringify": "^10.0.1", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.1.2", + "tailwind-variants": "^3.2.2", + "tailwindcss": "^4.3.1", + "tw-animate-css": "^1.4.0", + "typescript": "^6.0.3", + "unified": "^11.0.5" + }, + "devDependencies": { + "happy-dom": "^20.10.6", + "vite": "^7.3.5", + "vitest": "^4.1.8", + "wrangler": "^4.105.0" + }, + "packageManager": "pnpm@11.7.0" +} diff --git a/apps/catalog/public/icon-header-dark.png b/apps/catalog/public/icon-header-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..7f54aa666a340b32a706753201b2fe6788b7aa18 GIT binary patch literal 2115 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

c;->vwF={ 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 literal 0 HcmV?d00001 diff --git a/apps/catalog/src/cloudflare-dev-middleware.ts b/apps/catalog/src/cloudflare-dev-middleware.ts new file mode 100644 index 00000000..07e111b0 --- /dev/null +++ b/apps/catalog/src/cloudflare-dev-middleware.ts @@ -0,0 +1,3 @@ +import type { MiddlewareHandler } from "astro"; + +export const onRequest: MiddlewareHandler = (_context, next) => next(); diff --git a/apps/catalog/src/cloudflare-workers-dev.ts b/apps/catalog/src/cloudflare-workers-dev.ts new file mode 100644 index 00000000..1c60631f --- /dev/null +++ b/apps/catalog/src/cloudflare-workers-dev.ts @@ -0,0 +1 @@ +export const env = {}; diff --git a/apps/catalog/src/components/CapletDetail.astro b/apps/catalog/src/components/CapletDetail.astro new file mode 100644 index 00000000..fe2f8085 --- /dev/null +++ b/apps/catalog/src/components/CapletDetail.astro @@ -0,0 +1,116 @@ +--- +import { ChevronRightIcon } from "@hugeicons/core-free-icons"; +import type { CatalogEntryRecord } from "../lib/catalog-store"; +import { renderCatalogMarkdown, splitCatalogMarkdown } from "../lib/markdown"; +import HugeIcon from "./HugeIcon.astro"; +import InstallCommand from "./InstallCommand.astro"; +import SafetyNotice from "./SafetyNotice.astro"; +import { Badge } from "./starwind/badge"; +import { Card, CardContent, CardHeader, CardTitle } from "./starwind/card"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "./starwind/table"; + +type Props = { + entry: CatalogEntryRecord; +}; + +const { entry } = Astro.props; +const markdownParts = entry.contentMarkdown ? splitCatalogMarkdown(entry.contentMarkdown) : undefined; +const renderedContent = markdownParts?.bodyMarkdown + ? await renderCatalogMarkdown(markdownParts.bodyMarkdown) + : undefined; +const frontmatterRows = markdownParts?.frontmatterRows ?? []; +const hasReadableContent = Boolean(renderedContent) || frontmatterRows.length > 0; +const detailCommand = hasReadableContent + ? entry.installCommand + : { ...entry.installCommand, copyable: false }; +--- + +

+ +
+
+
+ {entry.trustLevel} + {entry.source.repository} +
+

{entry.name}

+

{entry.description}

+
+
+ +
+
+ +
+
+ + + + CAPLET.md + + + { + hasReadableContent ? ( + <> + {frontmatterRows.length > 0 && ( +
+

Frontmatter

+
+ + + + Field + Value + + + + {frontmatterRows.map((row) => ( + + + {row.value} + + ))} + +
+ {row.key} +
+
+
+ )} + {renderedContent &&
} + + ) : ( +
+

Content unavailable

+

This entry cannot be installed from the catalog until readable Caplet content is available for inspection.

+
+ ) + } + + +
+ +
+
diff --git a/apps/catalog/src/components/CapletResult.astro b/apps/catalog/src/components/CapletResult.astro new file mode 100644 index 00000000..99f2eec2 --- /dev/null +++ b/apps/catalog/src/components/CapletResult.astro @@ -0,0 +1,50 @@ +--- +import type { CatalogEntryRecord } from "../lib/catalog-store"; +import InstallCommand from "./InstallCommand.astro"; +import SafetyNotice from "./SafetyNotice.astro"; +import { Badge } from "./starwind/badge"; +import { Button } from "./starwind/button"; + +type Props = { + entry: CatalogEntryRecord; +}; + +const { entry } = Astro.props; +const detailHref = `/caplets/${encodeURIComponent(entry.entryKey)}/`; +--- + +
+
+
+ {entry.name} + {entry.trustLevel} +
+

{entry.description}

+
+ {entry.source.repository} + {entry.workflow.label} + {entry.installCountDisplay} installs + {entry.authReadiness === "required" ? "auth required" : "auth ready"} +
+ {entry.tags.length > 0 && ( +
+ {entry.tags.map((tag) => {tag})} +
+ )} +
+
+ + + +
+
diff --git a/apps/catalog/src/components/CatalogHeader.astro b/apps/catalog/src/components/CatalogHeader.astro new file mode 100644 index 00000000..01273001 --- /dev/null +++ b/apps/catalog/src/components/CatalogHeader.astro @@ -0,0 +1,65 @@ +--- +import ThemeToggle from "./ThemeToggle.astro"; + +type NavLink = { + href: string; + label: string; +}; + +type Props = { + links: NavLink[]; +}; + +const { links } = Astro.props; +--- + +
+ + + Caplets Catalog + +
+ + +
+
+ + diff --git a/apps/catalog/src/components/FilterBar.astro b/apps/catalog/src/components/FilterBar.astro new file mode 100644 index 00000000..37581f20 --- /dev/null +++ b/apps/catalog/src/components/FilterBar.astro @@ -0,0 +1,37 @@ +--- +const tags = Astro.props.tags as string[]; +--- + +
+ + + + +
diff --git a/apps/catalog/src/components/HugeIcon.astro b/apps/catalog/src/components/HugeIcon.astro new file mode 100644 index 00000000..ed76d281 --- /dev/null +++ b/apps/catalog/src/components/HugeIcon.astro @@ -0,0 +1,39 @@ +--- +type IconNode = readonly [string, Readonly>]; + +type Props = { + icon: readonly IconNode[]; + label?: string; + class?: string; +}; + +const { icon, label, class: className } = Astro.props; + +function svgAttrs(attrs: IconNode[1]): Record { + const normalized: Record = {}; + for (const [key, value] of Object.entries(attrs)) { + if (key === "key") continue; + normalized[key.replace(/[A-Z]/g, (match) => `-${match.toLowerCase()}`)] = value; + } + return normalized; +} +--- + + + { + icon.map(([, attrs]) => { + const pathAttrs = svgAttrs(attrs); + return ; + }) + } + diff --git a/apps/catalog/src/components/InstallCommand.astro b/apps/catalog/src/components/InstallCommand.astro new file mode 100644 index 00000000..e658c6c6 --- /dev/null +++ b/apps/catalog/src/components/InstallCommand.astro @@ -0,0 +1,34 @@ +--- +import type { CatalogInstallCommand } from "@caplets/core/catalog"; +import { Copy01Icon } from "@hugeicons/core-free-icons"; +import HugeIcon from "./HugeIcon.astro"; +import { Button } from "./starwind/button"; + +type Props = { + command: CatalogInstallCommand; + detail?: boolean; +}; + +const { command, detail = false } = Astro.props; +--- + +
+ {command.text || "Install command unavailable"} + { + detail && command.copyable ? ( + + ) : detail ? ( + Copy unavailable + ) : ( + Preview + ) + } +
diff --git a/apps/catalog/src/components/ResultList.astro b/apps/catalog/src/components/ResultList.astro new file mode 100644 index 00000000..f6734584 --- /dev/null +++ b/apps/catalog/src/components/ResultList.astro @@ -0,0 +1,33 @@ +--- +import type { CatalogSearchRow } from "../lib/search-row"; + +type Props = { + rows: CatalogSearchRow[]; +}; + +const { rows } = Astro.props; +--- + +
+
+
+ Caplet + Description + Installs + Install + Status +
+
+
+
+
+ +
diff --git a/apps/catalog/src/components/SafetyNotice.astro b/apps/catalog/src/components/SafetyNotice.astro new file mode 100644 index 00000000..1e3bfa5b --- /dev/null +++ b/apps/catalog/src/components/SafetyNotice.astro @@ -0,0 +1,33 @@ +--- +import type { CatalogWarning } from "@caplets/core/catalog"; +import { AlertCircleIcon } from "@hugeicons/core-free-icons"; +import HugeIcon from "./HugeIcon.astro"; + +type Props = { + warnings: CatalogWarning[]; + compact?: boolean; +}; + +const { compact = false, warnings } = Astro.props; +--- + +{ + warnings.length > 0 && ( +
+
+ +

{compact ? "Warnings" : "Inspect before installing"}

+
+
    + {warnings.map((warning) => ( +
  • + {warning.label} + {!compact && {warning.message}} +
  • + ))} +
+
+ ) +} diff --git a/apps/catalog/src/components/SearchShell.astro b/apps/catalog/src/components/SearchShell.astro new file mode 100644 index 00000000..8d7a6023 --- /dev/null +++ b/apps/catalog/src/components/SearchShell.astro @@ -0,0 +1,68 @@ +--- +import type { CatalogEntryRecord } from "../lib/catalog-store"; +import { catalogSearchRowsFromEntries } from "../lib/search-row"; +import FilterBar from "./FilterBar.astro"; +import ResultList from "./ResultList.astro"; +import HugeIcon from "./HugeIcon.astro"; +import { + AlertCircleIcon, + ComputerUserIcon, + DatabaseSyncIcon, + Key01Icon, + Link01Icon, + Settings02Icon, + Shield01Icon, +} from "@hugeicons/core-free-icons"; + +type Props = { + entries: CatalogEntryRecord[]; +}; + +const { entries } = Astro.props; +const rows = catalogSearchRowsFromEntries(entries); +const tags = [...new Set(rows.flatMap((entry) => entry.tags))].sort((left, right) => + left.localeCompare(right), +); +const indexJson = JSON.stringify(rows).replace(/ +
+
+ +
+ {rows.length} Caplets +
+
+ +
+ {legend.map((item) => ( + + + {item.label} + + ))} +
+
+ diff --git a/apps/catalog/src/components/ThemeToggle.astro b/apps/catalog/src/components/ThemeToggle.astro new file mode 100644 index 00000000..cac2b893 --- /dev/null +++ b/apps/catalog/src/components/ThemeToggle.astro @@ -0,0 +1,77 @@ +--- +import { + ArrowDown01Icon, + ComputerIcon, + Moon02Icon, + Sun01Icon, + Tick02Icon, +} from "@hugeicons/core-free-icons"; +import HugeIcon from "./HugeIcon.astro"; + +const options = [ + { value: "light", label: "Light", icon: Sun01Icon }, + { value: "dark", label: "Dark", icon: Moon02Icon }, + { value: "system", label: "System", icon: ComputerIcon }, +]; +--- + +
+ + + +
+ + + + diff --git a/apps/catalog/src/components/starwind/badge/Badge.astro b/apps/catalog/src/components/starwind/badge/Badge.astro new file mode 100644 index 00000000..dcdf7a2e --- /dev/null +++ b/apps/catalog/src/components/starwind/badge/Badge.astro @@ -0,0 +1,17 @@ +--- +import type { HTMLAttributes } from "astro/types"; +import type { VariantProps } from "tailwind-variants"; + +import { badge } from "./variants"; + +interface Props + extends HTMLAttributes<"div">, Omit, "type">, VariantProps {} + +const { variant, size, class: className, ...rest } = Astro.props; +const isLink = Astro.props.href ? true : false; +const Tag = Astro.props.href ? "a" : "div"; +--- + + + + diff --git a/apps/catalog/src/components/starwind/badge/index.ts b/apps/catalog/src/components/starwind/badge/index.ts new file mode 100644 index 00000000..53fcc369 --- /dev/null +++ b/apps/catalog/src/components/starwind/badge/index.ts @@ -0,0 +1,7 @@ +import Badge from "./Badge.astro"; +import { badge } from "./variants"; +const BadgeVariants = { badge }; + +export { Badge, BadgeVariants }; + +export default Badge; diff --git a/apps/catalog/src/components/starwind/badge/variants.ts b/apps/catalog/src/components/starwind/badge/variants.ts new file mode 100644 index 00000000..3a9358ce --- /dev/null +++ b/apps/catalog/src/components/starwind/badge/variants.ts @@ -0,0 +1,41 @@ +import { tv } from "tailwind-variants"; + +export const badge = tv({ + base: [ + "starwind-badge inline-flex items-center gap-1.5 rounded-full font-medium whitespace-nowrap", + "[&_svg]:pointer-events-none [&_svg]:shrink-0", + "transition-all outline-none focus-visible:ring-3", + "aria-invalid:border-error aria-invalid:focus-visible:ring-error/40", + ], + variants: { + variant: { + default: "bg-foreground text-background focus-visible:ring-outline/50", + primary: "bg-primary text-primary-foreground focus-visible:ring-primary/50", + secondary: "bg-secondary text-secondary-foreground focus-visible:ring-secondary/50", + outline: "border-border focus-visible:border-outline focus-visible:ring-outline/50 border", + ghost: "bg-foreground/10 text-foreground focus-visible:ring-outline/50", + info: "bg-info text-info-foreground focus-visible:ring-info/50", + success: "bg-success text-success-foreground focus-visible:ring-success/50", + warning: "bg-warning text-warning-foreground focus-visible:ring-warning/50", + error: "bg-error text-error-foreground focus-visible:ring-error/50", + }, + size: { + sm: "px-2.5 py-0.5 text-xs [&_svg:not([class*='size-'])]:size-3", + md: "px-3 py-0.5 text-sm [&_svg:not([class*='size-'])]:size-4", + lg: "px-4 py-1 text-base [&_svg:not([class*='size-'])]:size-4.5", + }, + isLink: { true: "cursor-pointer", false: "" }, + }, + compoundVariants: [ + { isLink: true, variant: "default", className: "hover:bg-foreground/80" }, + { isLink: true, variant: "primary", className: "hover:bg-primary/80" }, + { isLink: true, variant: "secondary", className: "hover:bg-secondary/80" }, + { isLink: true, variant: "outline", className: "hover:border-border/80" }, + { isLink: true, variant: "ghost", className: "hover:bg-foreground/7" }, + { isLink: true, variant: "info", className: "hover:bg-info/80" }, + { isLink: true, variant: "success", className: "hover:bg-success/80" }, + { isLink: true, variant: "warning", className: "hover:bg-warning/80" }, + { isLink: true, variant: "error", className: "hover:bg-error/80" }, + ], + defaultVariants: { variant: "default", size: "md", isLink: false }, +}); diff --git a/apps/catalog/src/components/starwind/button/Button.astro b/apps/catalog/src/components/starwind/button/Button.astro new file mode 100644 index 00000000..b1e00cd0 --- /dev/null +++ b/apps/catalog/src/components/starwind/button/Button.astro @@ -0,0 +1,26 @@ +--- +import type { HTMLAttributes } from "astro/types"; +import type { VariantProps } from "tailwind-variants"; + +import { button } from "./variants"; + +interface Props + extends + HTMLAttributes<"button">, + Omit, "type">, + VariantProps {} + +const { variant, size, class: className, type, ...rest } = Astro.props; + +const isLink = Boolean(Astro.props.href); +const Tag = isLink ? "a" : "button"; +--- + + + + diff --git a/apps/catalog/src/components/starwind/button/index.ts b/apps/catalog/src/components/starwind/button/index.ts new file mode 100644 index 00000000..16de9b64 --- /dev/null +++ b/apps/catalog/src/components/starwind/button/index.ts @@ -0,0 +1,7 @@ +import Button from "./Button.astro"; +import { button } from "./variants"; +const ButtonVariants = { button }; + +export { Button, ButtonVariants }; + +export default Button; diff --git a/apps/catalog/src/components/starwind/button/variants.ts b/apps/catalog/src/components/starwind/button/variants.ts new file mode 100644 index 00000000..23e8b831 --- /dev/null +++ b/apps/catalog/src/components/starwind/button/variants.ts @@ -0,0 +1,38 @@ +import { tv } from "tailwind-variants"; + +export const button = tv({ + base: [ + "inline-flex items-center justify-center gap-1.5 rounded-md font-medium whitespace-nowrap", + "[&_svg]:pointer-events-none [&_svg]:shrink-0", + "transition-all outline-none focus-visible:ring-3", + "disabled:pointer-events-none disabled:opacity-50", + "aria-invalid:border-error aria-invalid:focus-visible:ring-error/40", + ], + variants: { + variant: { + default: "bg-foreground text-background hover:bg-foreground/90 focus-visible:ring-outline/50", + primary: + "bg-primary text-primary-foreground hover:bg-primary/90 focus-visible:ring-primary/50", + secondary: + "bg-secondary text-secondary-foreground hover:bg-secondary/90 focus-visible:ring-secondary/50", + outline: + "dark:border-input focus-visible:ring-outline/50 bg-background dark:bg-input/30 focus-visible:border-outline hover:bg-muted dark:hover:bg-input/50 hover:text-foreground border shadow-xs", + ghost: "hover:bg-muted hover:text-foreground focus-visible:ring-outline/50", + info: "bg-info text-info-foreground hover:bg-info/90 focus-visible:ring-info/50", + success: + "bg-success text-success-foreground hover:bg-success/90 focus-visible:ring-success/50", + warning: + "bg-warning text-warning-foreground hover:bg-warning/90 focus-visible:ring-warning/50", + error: "bg-error text-error-foreground hover:bg-error/90 focus-visible:ring-error/50", + }, + size: { + sm: "h-9 px-4 text-sm has-[>svg]:px-3 [&_svg:not([class*='size-'])]:size-3.5", + md: "h-11 px-5 text-base has-[>svg]:px-4 [&_svg:not([class*='size-'])]:size-4.5", + lg: "h-12 px-8 text-lg has-[>svg]:px-6 [&_svg:not([class*='size-'])]:size-5", + "icon-sm": "size-9 [&_svg:not([class*='size-'])]:size-3.5", + icon: "size-11 [&_svg:not([class*='size-'])]:size-4.5", + "icon-lg": "size-12 [&_svg:not([class*='size-'])]:size-5", + }, + }, + defaultVariants: { variant: "default", size: "md" }, +}); diff --git a/apps/catalog/src/components/starwind/card/Card.astro b/apps/catalog/src/components/starwind/card/Card.astro new file mode 100644 index 00000000..8af25541 --- /dev/null +++ b/apps/catalog/src/components/starwind/card/Card.astro @@ -0,0 +1,19 @@ +--- +import type { HTMLAttributes } from "astro/types"; +import type { VariantProps } from "tailwind-variants"; + +import { card } from "./variants"; + +type Props = HTMLAttributes<"div"> & VariantProps; + +const { class: className, size, ...rest } = Astro.props; +--- + +
+ +
diff --git a/apps/catalog/src/components/starwind/card/CardAction.astro b/apps/catalog/src/components/starwind/card/CardAction.astro new file mode 100644 index 00000000..17965904 --- /dev/null +++ b/apps/catalog/src/components/starwind/card/CardAction.astro @@ -0,0 +1,13 @@ +--- +import type { HTMLAttributes } from "astro/types"; + +import { cardAction } from "./variants"; + +type Props = HTMLAttributes<"div">; + +const { class: className, ...rest } = Astro.props; +--- + +
+ +
diff --git a/apps/catalog/src/components/starwind/card/CardContent.astro b/apps/catalog/src/components/starwind/card/CardContent.astro new file mode 100644 index 00000000..365e809c --- /dev/null +++ b/apps/catalog/src/components/starwind/card/CardContent.astro @@ -0,0 +1,13 @@ +--- +import type { HTMLAttributes } from "astro/types"; + +import { cardContent } from "./variants"; + +type Props = HTMLAttributes<"div">; + +const { class: className, ...rest } = Astro.props; +--- + +
+ +
diff --git a/apps/catalog/src/components/starwind/card/CardDescription.astro b/apps/catalog/src/components/starwind/card/CardDescription.astro new file mode 100644 index 00000000..8b5079b3 --- /dev/null +++ b/apps/catalog/src/components/starwind/card/CardDescription.astro @@ -0,0 +1,13 @@ +--- +import type { HTMLAttributes } from "astro/types"; + +import { cardDescription } from "./variants"; + +type Props = HTMLAttributes<"div">; + +const { class: className, ...rest } = Astro.props; +--- + +
+ +
diff --git a/apps/catalog/src/components/starwind/card/CardFooter.astro b/apps/catalog/src/components/starwind/card/CardFooter.astro new file mode 100644 index 00000000..1ba4323e --- /dev/null +++ b/apps/catalog/src/components/starwind/card/CardFooter.astro @@ -0,0 +1,13 @@ +--- +import type { HTMLAttributes } from "astro/types"; + +import { cardFooter } from "./variants"; + +type Props = HTMLAttributes<"div">; + +const { class: className, ...rest } = Astro.props; +--- + +
+ +
diff --git a/apps/catalog/src/components/starwind/card/CardHeader.astro b/apps/catalog/src/components/starwind/card/CardHeader.astro new file mode 100644 index 00000000..011353be --- /dev/null +++ b/apps/catalog/src/components/starwind/card/CardHeader.astro @@ -0,0 +1,13 @@ +--- +import type { HTMLAttributes } from "astro/types"; + +import { cardHeader } from "./variants"; + +type Props = HTMLAttributes<"div">; + +const { class: className, ...rest } = Astro.props; +--- + +
+ +
diff --git a/apps/catalog/src/components/starwind/card/CardTitle.astro b/apps/catalog/src/components/starwind/card/CardTitle.astro new file mode 100644 index 00000000..25f74e97 --- /dev/null +++ b/apps/catalog/src/components/starwind/card/CardTitle.astro @@ -0,0 +1,13 @@ +--- +import type { HTMLAttributes } from "astro/types"; + +import { cardTitle } from "./variants"; + +type Props = HTMLAttributes<"div">; + +const { class: className, ...rest } = Astro.props; +--- + +
+ +
diff --git a/apps/catalog/src/components/starwind/card/index.ts b/apps/catalog/src/components/starwind/card/index.ts new file mode 100644 index 00000000..747f5313 --- /dev/null +++ b/apps/catalog/src/components/starwind/card/index.ts @@ -0,0 +1,46 @@ +import Card from "./Card.astro"; +import CardAction from "./CardAction.astro"; +import CardContent from "./CardContent.astro"; +import CardDescription from "./CardDescription.astro"; +import CardFooter from "./CardFooter.astro"; +import CardHeader from "./CardHeader.astro"; +import CardTitle from "./CardTitle.astro"; +import { + card, + cardAction, + cardContent, + cardDescription, + cardFooter, + cardHeader, + cardTitle, +} from "./variants"; +const CardVariants = { + card, + cardAction, + cardContent, + cardDescription, + cardFooter, + cardHeader, + cardTitle, +}; + +export { + Card, + CardAction, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, + CardVariants, +}; + +export default { + Root: Card, + Header: CardHeader, + Footer: CardFooter, + Title: CardTitle, + Description: CardDescription, + Content: CardContent, + Action: CardAction, +}; diff --git a/apps/catalog/src/components/starwind/card/variants.ts b/apps/catalog/src/components/starwind/card/variants.ts new file mode 100644 index 00000000..5c803154 --- /dev/null +++ b/apps/catalog/src/components/starwind/card/variants.ts @@ -0,0 +1,45 @@ +import { tv } from "tailwind-variants"; + +export const card = tv({ + base: [ + "bg-card text-card-foreground group/card ring-border flex flex-col rounded-xl ring-1", + "has-[[data-slot=card-footer]]:pb-0 has-[>img:first-child]:pt-0", + "*:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl", + ], + variants: { + size: { + default: "gap-6 py-6", + sm: "gap-4 py-4 text-sm", + }, + }, + defaultVariants: { + size: "default", + }, +}); + +export const cardAction = tv({ + base: "col-start-2 row-span-2 row-start-1 self-start justify-self-end", +}); + +export const cardContent = tv({ + base: "px-6 group-data-[size=sm]/card:px-4", +}); + +export const cardDescription = tv({ + base: "text-muted-foreground text-base group-data-[size=sm]/card:text-sm", +}); + +export const cardFooter = tv({ + base: "bg-muted/50 flex items-center rounded-b-xl border-t p-6 group-data-[size=sm]/card:p-4", +}); + +export const cardHeader = tv({ + base: [ + "@container/card-header grid auto-rows-min items-start gap-1 px-6 group-data-[size=sm]/card:px-4", + "has-[[data-slot=card-action]]:grid-cols-[1fr_auto] has-[[data-slot=card-description]]:grid-rows-[auto_auto]", + ], +}); + +export const cardTitle = tv({ + base: "font-heading text-xl leading-snug font-medium group-data-[size=sm]/card:text-base", +}); diff --git a/apps/catalog/src/components/starwind/table/Table.astro b/apps/catalog/src/components/starwind/table/Table.astro new file mode 100644 index 00000000..3afe2506 --- /dev/null +++ b/apps/catalog/src/components/starwind/table/Table.astro @@ -0,0 +1,16 @@ +--- +import type { HTMLAttributes } from "astro/types"; +import type { VariantProps } from "tailwind-variants"; + +import { table } from "./variants"; + +type Props = HTMLAttributes<"table"> & VariantProps; + +const { class: className, ...rest } = Astro.props; +--- + +
+ + +
+
diff --git a/apps/catalog/src/components/starwind/table/TableBody.astro b/apps/catalog/src/components/starwind/table/TableBody.astro new file mode 100644 index 00000000..8638ba56 --- /dev/null +++ b/apps/catalog/src/components/starwind/table/TableBody.astro @@ -0,0 +1,14 @@ +--- +import type { HTMLAttributes } from "astro/types"; +import type { VariantProps } from "tailwind-variants"; + +import { tableBody } from "./variants"; + +type Props = HTMLAttributes<"tbody"> & VariantProps; + +const { class: className, ...rest } = Astro.props; +--- + + + + diff --git a/apps/catalog/src/components/starwind/table/TableCaption.astro b/apps/catalog/src/components/starwind/table/TableCaption.astro new file mode 100644 index 00000000..6a49a3bc --- /dev/null +++ b/apps/catalog/src/components/starwind/table/TableCaption.astro @@ -0,0 +1,14 @@ +--- +import type { HTMLAttributes } from "astro/types"; +import type { VariantProps } from "tailwind-variants"; + +import { tableCaption } from "./variants"; + +type Props = HTMLAttributes<"caption"> & VariantProps; + +const { class: className, ...rest } = Astro.props; +--- + + + + diff --git a/apps/catalog/src/components/starwind/table/TableCell.astro b/apps/catalog/src/components/starwind/table/TableCell.astro new file mode 100644 index 00000000..e244f175 --- /dev/null +++ b/apps/catalog/src/components/starwind/table/TableCell.astro @@ -0,0 +1,14 @@ +--- +import type { HTMLAttributes } from "astro/types"; +import type { VariantProps } from "tailwind-variants"; + +import { tableCell } from "./variants"; + +type Props = HTMLAttributes<"td"> & VariantProps; + +const { class: className, ...rest } = Astro.props; +--- + + + + diff --git a/apps/catalog/src/components/starwind/table/TableFoot.astro b/apps/catalog/src/components/starwind/table/TableFoot.astro new file mode 100644 index 00000000..70e7ab10 --- /dev/null +++ b/apps/catalog/src/components/starwind/table/TableFoot.astro @@ -0,0 +1,14 @@ +--- +import type { HTMLAttributes } from "astro/types"; +import type { VariantProps } from "tailwind-variants"; + +import { tableFoot } from "./variants"; + +type Props = HTMLAttributes<"tfoot"> & VariantProps; + +const { class: className, ...rest } = Astro.props; +--- + + + + diff --git a/apps/catalog/src/components/starwind/table/TableHead.astro b/apps/catalog/src/components/starwind/table/TableHead.astro new file mode 100644 index 00000000..12a69f29 --- /dev/null +++ b/apps/catalog/src/components/starwind/table/TableHead.astro @@ -0,0 +1,14 @@ +--- +import type { HTMLAttributes } from "astro/types"; +import type { VariantProps } from "tailwind-variants"; + +import { tableHead } from "./variants"; + +type Props = HTMLAttributes<"th"> & VariantProps; + +const { class: className, ...rest } = Astro.props; +--- + + + + diff --git a/apps/catalog/src/components/starwind/table/TableHeader.astro b/apps/catalog/src/components/starwind/table/TableHeader.astro new file mode 100644 index 00000000..59bff533 --- /dev/null +++ b/apps/catalog/src/components/starwind/table/TableHeader.astro @@ -0,0 +1,14 @@ +--- +import type { HTMLAttributes } from "astro/types"; +import type { VariantProps } from "tailwind-variants"; + +import { tableHeader } from "./variants"; + +type Props = HTMLAttributes<"thead"> & VariantProps; + +const { class: className, ...rest } = Astro.props; +--- + + + + diff --git a/apps/catalog/src/components/starwind/table/TableRow.astro b/apps/catalog/src/components/starwind/table/TableRow.astro new file mode 100644 index 00000000..f98dd1ac --- /dev/null +++ b/apps/catalog/src/components/starwind/table/TableRow.astro @@ -0,0 +1,14 @@ +--- +import type { HTMLAttributes } from "astro/types"; +import type { VariantProps } from "tailwind-variants"; + +import { tableRow } from "./variants"; + +type Props = HTMLAttributes<"tr"> & VariantProps; + +const { class: className, ...rest } = Astro.props; +--- + + + + diff --git a/apps/catalog/src/components/starwind/table/index.ts b/apps/catalog/src/components/starwind/table/index.ts new file mode 100644 index 00000000..8c021208 --- /dev/null +++ b/apps/catalog/src/components/starwind/table/index.ts @@ -0,0 +1,51 @@ +import Table from "./Table.astro"; +import TableBody from "./TableBody.astro"; +import TableCaption from "./TableCaption.astro"; +import TableCell from "./TableCell.astro"; +import TableFoot from "./TableFoot.astro"; +import TableHead from "./TableHead.astro"; +import TableHeader from "./TableHeader.astro"; +import TableRow from "./TableRow.astro"; +import { + table, + tableBody, + tableCaption, + tableCell, + tableFoot, + tableHead, + tableHeader, + tableRow, +} from "./variants"; +const TableVariants = { + table, + tableBody, + tableCaption, + tableCell, + tableFoot, + tableHead, + tableHeader, + tableRow, +}; + +export { + Table, + TableBody, + TableCaption, + TableCell, + TableFoot, + TableHead, + TableHeader, + TableRow, + TableVariants, +}; + +export default { + Root: Table, + Body: TableBody, + Caption: TableCaption, + Cell: TableCell, + Foot: TableFoot, + Head: TableHead, + Header: TableHeader, + Row: TableRow, +}; diff --git a/apps/catalog/src/components/starwind/table/variants.ts b/apps/catalog/src/components/starwind/table/variants.ts new file mode 100644 index 00000000..9e80ed12 --- /dev/null +++ b/apps/catalog/src/components/starwind/table/variants.ts @@ -0,0 +1,33 @@ +import { tv } from "tailwind-variants"; + +export const table = tv({ + base: "w-full caption-bottom text-sm", +}); + +export const tableBody = tv({ + base: "[&_tr:last-child]:border-0", +}); + +export const tableCaption = tv({ + base: "text-muted-foreground mt-4 text-sm", +}); + +export const tableCell = tv({ + base: "p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]", +}); + +export const tableFoot = tv({ + base: "bg-muted/50 border-t font-medium [&>tr]:last:border-b-0", +}); + +export const tableHead = tv({ + base: "text-muted-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]", +}); + +export const tableHeader = tv({ + base: "[&_tr]:border-b", +}); + +export const tableRow = tv({ + base: "hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors", +}); diff --git a/apps/catalog/src/data/official-catalog.json b/apps/catalog/src/data/official-catalog.json new file mode 100644 index 00000000..6db7fd4d --- /dev/null +++ b/apps/catalog/src/data/official-catalog.json @@ -0,0 +1,878 @@ +[ + { + "entryKey": "github:spiritledsoftware:caplets:ast-grep%2Fcaplet.md:ast-grep", + "id": "ast-grep", + "name": "ast-grep", + "description": "Search, scan, test, rewrite, and scaffold ast-grep rules through curated MCP tools.", + "source": { + "provider": "github", + "owner": "spiritledsoftware", + "repo": "caplets", + "repository": "spiritledsoftware/caplets", + "canonicalUrl": "https://github.com/spiritledsoftware/caplets" + }, + "sourcePath": "ast-grep/CAPLET.md", + "trustLevel": "official", + "contentMarkdown": "---\n# yaml-language-server: $schema=https://caplets.dev/caplet.schema.json\nname: ast-grep\ndescription: Search, scan, test, rewrite, and scaffold ast-grep rules through curated MCP tools.\ntags:\n - mcp\n - code\n - search\nprojectBinding:\n required: true\nmcpServer:\n command: npx\n args: [-y, ast-grep-mcp@latest]\n---\n\n# AST Grep\n\nUse this Caplet to expose ast-grep's structural search, scan, rule testing, rewrite, and scaffold workflows without giving an agent unrestricted shell access.\n\nThe manifest uses the full `ast-grep-mcp` MCP server.\n\nProject Binding is required because ast-grep reads and may rewrite files in the attached repository. The bound root defines the workspace that search and rewrite operations are allowed to target.\n\n## Safety\n\nRead-only search, scan, and normal test actions set `readOnlyHint: true`. Apply-all rewrite, snapshot-update, and scaffolding actions set `destructiveHint: true` because they can modify files.\n", + "tags": [ + "code", + "mcp", + "search" + ], + "intendedTask": "unknown", + "setupReadiness": "ready", + "authReadiness": "ready", + "projectBindingReadiness": "required", + "workflow": { + "kind": "mcp", + "label": "MCP server" + }, + "installCommand": { + "text": "caplets install spiritledsoftware/caplets ast-grep", + "copyable": true, + "revisionBound": false + }, + "warnings": [ + { + "code": "local_control", + "severity": "danger", + "label": "Local control", + "message": "This Caplet can operate against local project or machine state." + }, + { + "code": "project_binding_required", + "severity": "caution", + "label": "Project Binding required", + "message": "This Caplet needs a Project Binding before it can run in the intended environment." + } + ] + }, + { + "entryKey": "github:spiritledsoftware:caplets:browser-use%2Fcaplet.md:browser-use", + "id": "browser-use", + "name": "Browser Use", + "description": "Drive the user's real browser through Playwright MCP for local control.", + "source": { + "provider": "github", + "owner": "spiritledsoftware", + "repo": "caplets", + "repository": "spiritledsoftware/caplets", + "canonicalUrl": "https://github.com/spiritledsoftware/caplets" + }, + "sourcePath": "browser-use/CAPLET.md", + "trustLevel": "official", + "contentMarkdown": "---\n# yaml-language-server: $schema=https://caplets.dev/caplet.schema.json\nname: Browser Use\ndescription: Drive the user's real browser through Playwright MCP for local control.\ntags:\n - browser\n - playwright\n - mcp\nmcpServer:\n command: npx\n args:\n - -y\n - \"@playwright/mcp@latest\"\n - --extension\n---\n\n# Browser Use\n\nUse this Caplet when an agent needs a local browser to inspect pages, gather current web context, or exercise browser-based workflows.\n\n## Setup\n\nInstall Playwright browser dependencies for the runtime where this Caplet runs. If you need a specific browser executable or profile, create a private variant that uses environment variables such as `DEFAULT_BROWSER_EXECUTABLE_PATH` and `DEFAULT_BROWSER_USER_DATA_DIR`.\n\n## Safety\n\nThis is a local-control Caplet. Browser actions can sign in, submit forms, trigger purchases, or change account data. Prefer navigation, reading, and screenshots first; review mutating interactions before execution.\n", + "tags": [ + "browser", + "mcp", + "playwright" + ], + "intendedTask": "unknown", + "setupReadiness": "ready", + "authReadiness": "ready", + "projectBindingReadiness": "ready", + "workflow": { + "kind": "mcp", + "label": "MCP server" + }, + "installCommand": { + "text": "caplets install spiritledsoftware/caplets browser-use", + "copyable": true, + "revisionBound": false + }, + "warnings": [] + }, + { + "entryKey": "github:spiritledsoftware:caplets:coding-agent-toolkit%2Fcaplet.md:coding-agent-toolkit", + "id": "coding-agent-toolkit", + "name": "Coding Agent Toolkit", + "description": "self-contained nested toolkit of high-value Caplets for coding agents.", + "source": { + "provider": "github", + "owner": "spiritledsoftware", + "repo": "caplets", + "repository": "spiritledsoftware/caplets", + "canonicalUrl": "https://github.com/spiritledsoftware/caplets" + }, + "sourcePath": "coding-agent-toolkit/CAPLET.md", + "trustLevel": "official", + "contentMarkdown": "---\n# yaml-language-server: $schema=https://caplets.dev/caplet.schema.json\nname: Coding Agent Toolkit\ndescription: self-contained nested toolkit of high-value Caplets for coding agents.\ntags:\n - coding-agent\n - toolkit\n - caplets\ncapletSet:\n capletsRoot: ./caplets\n---\n\n# Coding Agent Toolkit\n\nUse this CapletSet to give coding agents a focused bundle of high-value Caplets for repository inspection, code search, package metadata, vulnerability lookup, hosted documentation, and browser automation.\n\nThe source repository keeps the child Caplets in this toolkit as symlinks to the canonical top-level examples. That avoids duplicate maintenance while keeping one curated toolkit entry point.\n\nWhen a directory Caplet is installed, Caplets materializes those symlinked children as real files and directories. Installed copies are self-contained and do not depend on the source repository symlink layout.\n", + "tags": [ + "caplets", + "coding-agent", + "toolkit" + ], + "intendedTask": "unknown", + "setupReadiness": "ready", + "authReadiness": "ready", + "projectBindingReadiness": "ready", + "workflow": { + "kind": "set", + "label": "Caplet set" + }, + "installCommand": { + "text": "caplets install spiritledsoftware/caplets coding-agent-toolkit", + "copyable": true, + "revisionBound": false + }, + "warnings": [] + }, + { + "entryKey": "github:spiritledsoftware:caplets:computer-use%2Fcaplet.md:computer-use", + "id": "computer-use", + "name": "Computer Use", + "description": "Control local desktop applications and windows through open-computer-use for explicit desktop automation workflows.", + "source": { + "provider": "github", + "owner": "spiritledsoftware", + "repo": "caplets", + "repository": "spiritledsoftware/caplets", + "canonicalUrl": "https://github.com/spiritledsoftware/caplets" + }, + "sourcePath": "computer-use/CAPLET.md", + "trustLevel": "official", + "contentMarkdown": "---\n# yaml-language-server: $schema=https://caplets.dev/caplet.schema.json\nname: Computer Use\ndescription: Control local desktop applications and windows through open-computer-use for explicit desktop automation workflows.\ntags:\n - computer-use\n - desktop\n - local-control\nmcpServer:\n command: npx\n args:\n - -y\n - open-computer-use@latest\n - mcp\n---\n\n# Computer Use\n\nUse this Caplet only when an agent needs explicit access to the local desktop, application windows, or GUI workflows that cannot be completed through APIs or CLI tools.\n\n## Safety\n\nThis is a high-risk local-control Caplet. It can operate real applications and may expose private screen content. Keep tasks narrow, identify the target application before acting, and do not use it for credential entry, payment flows, or irreversible actions without direct user instruction.\n", + "tags": [ + "computer-use", + "desktop", + "local-control" + ], + "intendedTask": "unknown", + "setupReadiness": "ready", + "authReadiness": "ready", + "projectBindingReadiness": "ready", + "workflow": { + "kind": "mcp", + "label": "MCP server" + }, + "installCommand": { + "text": "caplets install spiritledsoftware/caplets computer-use", + "copyable": true, + "revisionBound": false + }, + "warnings": [] + }, + { + "entryKey": "github:spiritledsoftware:caplets:context7%2Fcaplet.md:context7", + "id": "context7", + "name": "Context7 Documentation", + "description": "Fetch current library and framework documentation through Context7 before using version-sensitive APIs.", + "source": { + "provider": "github", + "owner": "spiritledsoftware", + "repo": "caplets", + "repository": "spiritledsoftware/caplets", + "canonicalUrl": "https://github.com/spiritledsoftware/caplets" + }, + "sourcePath": "context7/CAPLET.md", + "trustLevel": "official", + "contentMarkdown": "---\n# yaml-language-server: $schema=https://caplets.dev/caplet.schema.json\nname: Context7 Documentation\ndescription: Fetch current library and framework documentation through Context7 before using version-sensitive APIs.\ntags:\n - docs\n - libraries\n - frameworks\n - api-reference\nmcpServer:\n url: https://mcp.context7.com/mcp/oauth\n auth:\n type: oauth2\n---\n\n# Context7 Documentation\n\nUse this Caplet when the agent needs up-to-date library, SDK, framework, CLI, or cloud-service\ndocumentation before writing code or giving technical instructions.\n\n## Good Fits\n\n- Check current API signatures for fast-moving JavaScript, TypeScript, Python, or cloud libraries.\n- Look up migration notes before changing framework configuration.\n- Retrieve official examples for a specific package version.\n- Resolve uncertainty about CLI flags, config files, or SDK initialization.\n\n## Use Carefully\n\n- Prefer primary documentation over snippets when implementation risk is high.\n- Record the library or package name clearly before searching.\n- Do not use this as a substitute for project-local types and tests.\n", + "tags": [ + "api-reference", + "docs", + "frameworks", + "libraries" + ], + "intendedTask": "unknown", + "setupReadiness": "ready", + "authReadiness": "required", + "projectBindingReadiness": "ready", + "workflow": { + "kind": "mcp", + "label": "MCP server" + }, + "installCommand": { + "text": "caplets install spiritledsoftware/caplets context7", + "copyable": true, + "revisionBound": false + }, + "warnings": [ + { + "code": "mutating_saas", + "severity": "caution", + "label": "Can change external services", + "message": "This Caplet may perform mutating operations against an external service." + }, + { + "code": "auth_required", + "severity": "caution", + "label": "Authentication required", + "message": "This Caplet needs credentials or an auth flow before use." + } + ] + }, + { + "entryKey": "github:spiritledsoftware:caplets:deepwiki%2Fcaplet.md:deepwiki", + "id": "deepwiki", + "name": "DeepWiki", + "description": "Query repository-focused documentation and codebase explanations through DeepWiki's MCP service.", + "source": { + "provider": "github", + "owner": "spiritledsoftware", + "repo": "caplets", + "repository": "spiritledsoftware/caplets", + "canonicalUrl": "https://github.com/spiritledsoftware/caplets" + }, + "sourcePath": "deepwiki/CAPLET.md", + "trustLevel": "official", + "contentMarkdown": "---\n# yaml-language-server: $schema=https://caplets.dev/caplet.schema.json\nname: DeepWiki\ndescription: Query repository-focused documentation and codebase explanations through DeepWiki's MCP service.\ntags:\n - docs\n - code\n - mcp\nmcpServer:\n url: https://mcp.deepwiki.com/mcp\n---\n\n# DeepWiki\n\nUse this Caplet when the agent needs repository documentation, architecture explanations, or codebase\ncontext from DeepWiki before making implementation decisions.\n\n## Good Fits\n\n- Research how an unfamiliar open source repository is structured.\n- Find documentation-backed explanations for framework, package, or service behavior.\n- Cross-check implementation details before modifying code that depends on another project.\n- Summarize repository concepts for planning, code review, or onboarding notes.\n", + "tags": [ + "code", + "docs", + "mcp" + ], + "intendedTask": "unknown", + "setupReadiness": "ready", + "authReadiness": "ready", + "projectBindingReadiness": "ready", + "workflow": { + "kind": "mcp", + "label": "MCP server" + }, + "installCommand": { + "text": "caplets install spiritledsoftware/caplets deepwiki", + "copyable": true, + "revisionBound": false + }, + "warnings": [] + }, + { + "entryKey": "github:spiritledsoftware:caplets:github%2Fcaplet.md:github", + "id": "github", + "name": "GitHub", + "description": "Inspect and manage GitHub repositories, issues, pull requests, branches, commits, and code review workflows.", + "source": { + "provider": "github", + "owner": "spiritledsoftware", + "repo": "caplets", + "repository": "spiritledsoftware/caplets", + "canonicalUrl": "https://github.com/spiritledsoftware/caplets" + }, + "sourcePath": "github/CAPLET.md", + "trustLevel": "official", + "contentMarkdown": "---\n# yaml-language-server: $schema=https://caplets.dev/caplet.schema.json\nname: GitHub\ndescription: Inspect and manage GitHub repositories, issues, pull requests, branches, commits, and code review workflows.\ntags:\n - code\n - github\n - pull-requests\n - issues\n - reviews\nmcpServer:\n url: https://api.githubcopilot.com/mcp\n auth:\n type: bearer\n token: $vault:GH_TOKEN\n---\n\n# GitHub\n\nUse this Caplet when the agent needs live GitHub repository context or needs to act on\nissues, pull requests, branches, commits, or review feedback.\n\n## Good Fits\n\n- Summarize recent pull request activity before a code review.\n- Inspect open issues and identify implementation work.\n- Create or update issues from an implementation plan.\n- Compare branches, inspect commits, or review pull request files.\n- Leave review comments after the agent has inspected the relevant diff.\n\n## Use Carefully\n\n- Mutating operations can affect real repositories. Prefer read operations first.\n- Store a least-privilege `GH_TOKEN` in the Caplets Vault for the runtime where GitHub runs.\n- Do not ask the agent to expose token values, repository secrets, or private issue contents outside\n the intended conversation.\n", + "tags": [ + "code", + "github", + "issues", + "pull-requests", + "reviews" + ], + "intendedTask": "unknown", + "setupReadiness": "ready", + "authReadiness": "required", + "projectBindingReadiness": "ready", + "workflow": { + "kind": "mcp", + "label": "MCP server" + }, + "installCommand": { + "text": "caplets install spiritledsoftware/caplets github", + "copyable": true, + "revisionBound": false + }, + "warnings": [ + { + "code": "mutating_saas", + "severity": "caution", + "label": "Can change external services", + "message": "This Caplet may perform mutating operations against an external service." + }, + { + "code": "auth_required", + "severity": "caution", + "label": "Authentication required", + "message": "This Caplet needs credentials or an auth flow before use." + } + ] + }, + { + "entryKey": "github:spiritledsoftware:caplets:gmail%2Fcaplet.md:gmail", + "id": "gmail", + "name": "Gmail", + "description": "Search, read, label, draft, and send Gmail messages through the Gmail API Discovery document.", + "source": { + "provider": "github", + "owner": "spiritledsoftware", + "repo": "caplets", + "repository": "spiritledsoftware/caplets", + "canonicalUrl": "https://github.com/spiritledsoftware/caplets" + }, + "sourcePath": "gmail/CAPLET.md", + "trustLevel": "official", + "contentMarkdown": "---\n# yaml-language-server: $schema=https://caplets.dev/caplet.schema.json\nname: Gmail\ndescription: Search, read, label, draft, and send Gmail messages through the Gmail API Discovery document.\ntags:\n - google\n - gmail\n - email\ngoogleDiscoveryApi:\n discoveryUrl: https://gmail.googleapis.com/$discovery/rest?version=v1\n auth:\n type: oauth2\n issuer: https://accounts.google.com\n clientId: $vault:GOOGLE_CLIENT_ID\n clientSecret: $vault:GOOGLE_CLIENT_SECRET\n---\n\n# Gmail\n\nUse this Caplet when an agent needs Gmail context for support, scheduling, customer communication, or inbox triage.\n\n## Use Carefully\n\nEmail often contains private or regulated content. Keep queries narrow, summarize minimally, and require explicit user intent before sending, modifying, trashing, or deleting messages.\n", + "tags": [ + "email", + "gmail", + "google" + ], + "intendedTask": "unknown", + "setupReadiness": "ready", + "authReadiness": "required", + "projectBindingReadiness": "ready", + "workflow": { + "kind": "google_discovery", + "label": "Google Discovery API" + }, + "installCommand": { + "text": "caplets install spiritledsoftware/caplets gmail", + "copyable": true, + "revisionBound": false + }, + "warnings": [ + { + "code": "mutating_saas", + "severity": "caution", + "label": "Can change external services", + "message": "This Caplet may perform mutating operations against an external service." + }, + { + "code": "auth_required", + "severity": "caution", + "label": "Authentication required", + "message": "This Caplet needs credentials or an auth flow before use." + } + ] + }, + { + "entryKey": "github:spiritledsoftware:caplets:google-drive%2Fcaplet.md:google-drive", + "id": "google-drive", + "name": "Google Drive", + "description": "Search, read, download, upload, and manage Google Drive files through the Drive API Discovery document.", + "source": { + "provider": "github", + "owner": "spiritledsoftware", + "repo": "caplets", + "repository": "spiritledsoftware/caplets", + "canonicalUrl": "https://github.com/spiritledsoftware/caplets" + }, + "sourcePath": "google-drive/CAPLET.md", + "trustLevel": "official", + "contentMarkdown": "---\n# yaml-language-server: $schema=https://caplets.dev/caplet.schema.json\nname: Google Drive\ndescription: Search, read, download, upload, and manage Google Drive files through the Drive API Discovery document.\ntags:\n - google\n - drive\n - files\ngoogleDiscoveryApi:\n discoveryUrl: https://www.googleapis.com/discovery/v1/apis/drive/v3/rest\n auth:\n type: oauth2\n issuer: https://accounts.google.com\n clientId: $vault:GOOGLE_CLIENT_ID\n clientSecret: $vault:GOOGLE_CLIENT_SECRET\n---\n\n# Google Drive\n\nUse this Caplet when an agent needs Drive files as context or needs to create/update files with explicit user direction.\n\n## Use Carefully\n\nSearch metadata before reading content. Confirm file IDs and names before upload, update, trash, or delete operations, especially on shared drives.\n", + "tags": [ + "drive", + "files", + "google" + ], + "intendedTask": "unknown", + "setupReadiness": "ready", + "authReadiness": "required", + "projectBindingReadiness": "ready", + "workflow": { + "kind": "google_discovery", + "label": "Google Discovery API" + }, + "installCommand": { + "text": "caplets install spiritledsoftware/caplets google-drive", + "copyable": true, + "revisionBound": false + }, + "warnings": [ + { + "code": "mutating_saas", + "severity": "caution", + "label": "Can change external services", + "message": "This Caplet may perform mutating operations against an external service." + }, + { + "code": "auth_required", + "severity": "caution", + "label": "Authentication required", + "message": "This Caplet needs credentials or an auth flow before use." + } + ] + }, + { + "entryKey": "github:spiritledsoftware:caplets:google-tasks%2Fcaplet.md:google-tasks", + "id": "google-tasks", + "name": "Google Tasks", + "description": "Read, create, update, organize, and complete Google Tasks through the Google Tasks API Discovery document.", + "source": { + "provider": "github", + "owner": "spiritledsoftware", + "repo": "caplets", + "repository": "spiritledsoftware/caplets", + "canonicalUrl": "https://github.com/spiritledsoftware/caplets" + }, + "sourcePath": "google-tasks/CAPLET.md", + "trustLevel": "official", + "contentMarkdown": "---\n# yaml-language-server: $schema=https://caplets.dev/caplet.schema.json\nname: Google Tasks\ndescription: Read, create, update, organize, and complete Google Tasks through the Google Tasks API Discovery document.\ntags:\n - google\n - tasks\n - productivity\ngoogleDiscoveryApi:\n discoveryUrl: https://www.googleapis.com/discovery/v1/apis/tasks/v1/rest\n auth:\n type: oauth2\n issuer: https://accounts.google.com\n clientId: $vault:GOOGLE_CLIENT_ID\n clientSecret: $vault:GOOGLE_CLIENT_SECRET\n---\n\n# Google Tasks\n\nUse this Caplet when an agent needs to inspect or manage Google Tasks during planning, follow-up, or personal workflow coordination.\n\n## Use Carefully\n\nList existing tasklists and tasks before mutating. Confirm task names, due dates, and tasklist IDs before creating, completing, moving, or deleting tasks.\n", + "tags": [ + "google", + "productivity", + "tasks" + ], + "intendedTask": "unknown", + "setupReadiness": "ready", + "authReadiness": "required", + "projectBindingReadiness": "ready", + "workflow": { + "kind": "google_discovery", + "label": "Google Discovery API" + }, + "installCommand": { + "text": "caplets install spiritledsoftware/caplets google-tasks", + "copyable": true, + "revisionBound": false + }, + "warnings": [ + { + "code": "mutating_saas", + "severity": "caution", + "label": "Can change external services", + "message": "This Caplet may perform mutating operations against an external service." + }, + { + "code": "auth_required", + "severity": "caution", + "label": "Authentication required", + "message": "This Caplet needs credentials or an auth flow before use." + } + ] + }, + { + "entryKey": "github:spiritledsoftware:caplets:linear%2Fcaplet.md:linear", + "id": "linear", + "name": "Linear", + "description": "Plan and track product work in Linear by reading teams, projects, cycles, issues, comments, and workflow state.", + "source": { + "provider": "github", + "owner": "spiritledsoftware", + "repo": "caplets", + "repository": "spiritledsoftware/caplets", + "canonicalUrl": "https://github.com/spiritledsoftware/caplets" + }, + "sourcePath": "linear/CAPLET.md", + "trustLevel": "official", + "contentMarkdown": "---\n# yaml-language-server: $schema=https://caplets.dev/caplet.schema.json\nname: Linear\ndescription: Plan and track product work in Linear by reading teams, projects, cycles, issues, comments, and workflow state.\ntags:\n - planning\n - linear\n - issues\n - projects\n - triage\nmcpServer:\n url: https://mcp.linear.app/mcp\n auth:\n type: oauth2\n---\n\n# Linear\n\nUse this Caplet when the agent needs live product planning context from Linear or needs to keep\nimplementation work synchronized with issues, projects, and team workflows.\n\n## Good Fits\n\n- Find the current issue or project that matches a requested feature.\n- Summarize open work by team, project, cycle, label, or assignee.\n- Draft issue breakdowns from a technical plan.\n- Add implementation notes or status comments after code changes.\n- Check whether a bug or feature already has active work before creating a new issue.\n\n## Reference Files\n\n- [Workflows](./workflows.md): recommended lookup, planning, status update, and triage flows.\n\n## Use Carefully\n\n- Linear issue updates are visible to teammates. Read first, then write deliberately.\n- Keep issue titles and comments concise; use links to detailed implementation artifacts when useful.\n- Avoid broad, noisy searches when a team key, issue ID, project, or label is available.\n", + "tags": [ + "issues", + "linear", + "planning", + "projects", + "triage" + ], + "intendedTask": "unknown", + "setupReadiness": "ready", + "authReadiness": "required", + "projectBindingReadiness": "ready", + "workflow": { + "kind": "mcp", + "label": "MCP server" + }, + "installCommand": { + "text": "caplets install spiritledsoftware/caplets linear", + "copyable": true, + "revisionBound": false + }, + "warnings": [ + { + "code": "mutating_saas", + "severity": "caution", + "label": "Can change external services", + "message": "This Caplet may perform mutating operations against an external service." + }, + { + "code": "auth_required", + "severity": "caution", + "label": "Authentication required", + "message": "This Caplet needs credentials or an auth flow before use." + } + ] + }, + { + "entryKey": "github:spiritledsoftware:caplets:lsp%2Fcaplet.md:lsp", + "id": "lsp", + "name": "LSP", + "description": "Language Server Protocol tools for project-aware code intelligence through language-server-mcp.", + "source": { + "provider": "github", + "owner": "spiritledsoftware", + "repo": "caplets", + "repository": "spiritledsoftware/caplets", + "canonicalUrl": "https://github.com/spiritledsoftware/caplets" + }, + "sourcePath": "lsp/CAPLET.md", + "trustLevel": "official", + "contentMarkdown": "---\n# yaml-language-server: $schema=https://caplets.dev/caplet.schema.json\nname: LSP\ndescription: Language Server Protocol tools for project-aware code intelligence through language-server-mcp.\ntags:\n - mcp\n - code\n - lsp\n - language-server\n - diagnostics\nprojectBinding:\n required: true\nmcpServer:\n command: npx\n args: [-y, language-server-mcp@latest]\n---\n\n# LSP\n\nUse this Caplet to expose Language Server Protocol capabilities through `language-server-mcp` without handing every language-server operation to an agent up front.\n\nThe server runs over stdio, starts local language servers lazily, and gives agents project-aware code intelligence for repositories that have LSP configuration or supported built-in language servers.\n\nProject Binding is required because all useful LSP operations need a trustworthy bound project root for workspace-relative files, language-server startup, diagnostics, and edit containment.\n\n## Good Fits\n\n- Inspect hover/type information before editing unfamiliar code.\n- Jump to definitions, declarations, implementations, and type definitions.\n- Find references and document/workspace symbols during refactors.\n- Read diagnostics from configured language servers before proposing fixes.\n- Request formatting, code actions, rename edits, and workspace edits.\n- Cross-check code review or planning assumptions with language-server-backed facts.\n\n## Setup\n\nCreate a project-level `.lsp-mcp.jsonc` when the defaults are not enough:\n\n```jsonc\n{\n \"lsp\": {\n \"servers\": {\n \"typescript\": {\n \"registry\": \"typescript\",\n },\n },\n },\n}\n```\n\nFor file-targeted tools, pass absolute or workspace-relative paths that resolve inside the workspace. A typical request includes `workspaceRoot`, `filePath`, `line`, and `character`:\n\n```json\n{\n \"workspaceRoot\": \"/absolute/path/to/project\",\n \"filePath\": \"/absolute/path/to/project/src/index.ts\",\n \"line\": 3,\n \"character\": 12\n}\n```\n\n`serverId` is optional for file-targeted tools. When omitted, `language-server-mcp` runs against configured or built-in servers whose language IDs or file extensions match the file. Add `\"serverId\": \"typescript\"` to force a specific server.\n\nBuilt-in registry IDs include common servers such as `typescript`, `deno`, `eslint`, `json`, `pyright`, `gopls`, `rust`, `svelte`, `vue`, `yaml-ls`, `bash`, `clangd`, `lua-ls`, and `terraform`. Mason and nvim-lspconfig aliases such as `ts_ls`, `denols`, `yamlls`, `bashls`, `rust_analyzer`, and compatibility aliases such as `python`, `go`, and `yaml` are also accepted by the downstream server.\n\n## Safety\n\n`language-server-mcp` defaults to conservative behavior for file modification and process execution:\n\n- Edit-producing tools return edits by default and do not write files unless `apply: true` is passed.\n- `apply: true` requires `serverId` when more than one matching LSP server would produce edits.\n- Applied edits are restricted to the workspace root unless the downstream server is configured with `security.allowExternalFiles: true`.\n- `workspace/executeCommand` is enabled by default, but can be disabled globally or restricted with per-server command allowlists.\n- LSP servers start lazily on first use and stop after an idle timeout by default.\n- Managed downloads are available only for supported built-ins and can be disabled with `downloads.enabled: false`.\n\n## Examples\n\nAsk for hover information at a symbol:\n\n```json\n{\n \"workspaceRoot\": \"/absolute/path/to/project\",\n \"filePath\": \"/absolute/path/to/project/src/index.ts\",\n \"line\": 10,\n \"character\": 18,\n \"serverId\": \"typescript\"\n}\n```\n\nRead diagnostics for a source file:\n\n```json\n{\n \"workspaceRoot\": \"/absolute/path/to/project\",\n \"filePath\": \"/absolute/path/to/project/src/index.ts\"\n}\n```\n\nRequest code actions without applying edits immediately:\n\n```json\n{\n \"workspaceRoot\": \"/absolute/path/to/project\",\n \"filePath\": \"/absolute/path/to/project/src/index.ts\",\n \"range\": {\n \"start\": { \"line\": 12, \"character\": 0 },\n \"end\": { \"line\": 12, \"character\": 24 }\n },\n \"apply\": false,\n \"serverId\": \"typescript\"\n}\n```\n", + "tags": [ + "code", + "diagnostics", + "language-server", + "lsp", + "mcp" + ], + "intendedTask": "unknown", + "setupReadiness": "ready", + "authReadiness": "ready", + "projectBindingReadiness": "required", + "workflow": { + "kind": "mcp", + "label": "MCP server" + }, + "installCommand": { + "text": "caplets install spiritledsoftware/caplets lsp", + "copyable": true, + "revisionBound": false + }, + "warnings": [ + { + "code": "local_control", + "severity": "danger", + "label": "Local control", + "message": "This Caplet can operate against local project or machine state." + }, + { + "code": "project_binding_required", + "severity": "caution", + "label": "Project Binding required", + "message": "This Caplet needs a Project Binding before it can run in the intended environment." + } + ] + }, + { + "entryKey": "github:spiritledsoftware:caplets:npm%2Fcaplet.md:npm", + "id": "npm", + "name": "npm Registry", + "description": "Query package metadata, versions, dist-tags, and search results from the public npm registry.", + "source": { + "provider": "github", + "owner": "spiritledsoftware", + "repo": "caplets", + "repository": "spiritledsoftware/caplets", + "canonicalUrl": "https://github.com/spiritledsoftware/caplets" + }, + "sourcePath": "npm/CAPLET.md", + "trustLevel": "official", + "contentMarkdown": "---\n# yaml-language-server: $schema=https://caplets.dev/caplet.schema.json\nname: npm Registry\ndescription: Query package metadata, versions, dist-tags, and search results from the public npm registry.\ntags:\n - openapi\n - npm\n - packages\n - code\nopenapiEndpoint:\n specUrl: https://raw.githubusercontent.com/npm/api-documentation/main/api/base.yaml\n auth:\n type: none\n---\n\n# npm Registry\n\nUse this Caplet to inspect npm registry operations through npm's published\nOpenAPI description of the public registry API.\n\n## Usage Notes\n\n- Use `get_package` to fetch packument metadata for a package, including versions and dist-tags.\n- Use `get_package_version` when you need metadata for one exact published version.\n- Use `get_dist_tags` to read the package's current dist-tags without fetching the full packument.\n- Use `search_packages` to find packages by npm registry search text and ranking weights.\n\n## Examples\n\n- Fetch React packument metadata: `packageName: react`.\n- Fetch TypeScript version metadata: `packageName: typescript`, `version: 5.8.3`.\n- Search packages: `text: keywords:react hooks`, `size: 10`.\n", + "tags": [ + "code", + "npm", + "openapi", + "packages" + ], + "intendedTask": "unknown", + "setupReadiness": "ready", + "authReadiness": "ready", + "projectBindingReadiness": "ready", + "workflow": { + "kind": "openapi", + "label": "OpenAPI" + }, + "installCommand": { + "text": "caplets install spiritledsoftware/caplets npm", + "copyable": true, + "revisionBound": false + }, + "warnings": [] + }, + { + "entryKey": "github:spiritledsoftware:caplets:osv%2Fcaplet.md:osv", + "id": "osv", + "name": "OSV Vulnerabilities", + "description": "Query OSV.dev vulnerability data through explicit HTTP actions.", + "source": { + "provider": "github", + "owner": "spiritledsoftware", + "repo": "caplets", + "repository": "spiritledsoftware/caplets", + "canonicalUrl": "https://github.com/spiritledsoftware/caplets" + }, + "sourcePath": "osv/CAPLET.md", + "trustLevel": "official", + "contentMarkdown": "---\n# yaml-language-server: $schema=https://caplets.dev/caplet.schema.json\nname: OSV Vulnerabilities\ndescription: Query OSV.dev vulnerability data through explicit HTTP actions.\ntags:\n - security\n - vulnerabilities\n - http\n - code\nhttpApi:\n baseUrl: https://api.osv.dev\n auth:\n type: none\n actions:\n query_package_version:\n description: Read-only OSV query for vulnerabilities affecting one package ecosystem/name/version tuple.\n method: POST\n path: /v1/query\n inputSchema:\n type: object\n properties:\n name:\n type: string\n description: Package name, such as lodash, requests, or openssl.\n ecosystem:\n type: string\n description: OSV ecosystem, such as npm, PyPI, Maven, Go, crates.io, Packagist, RubyGems, NuGet, Debian, or Alpine.\n version:\n type: string\n description: Package version to query.\n page_token:\n type: string\n description: Optional pagination token returned by OSV.\n required:\n - name\n - ecosystem\n - version\n jsonBody:\n package:\n name: $input.name\n ecosystem: $input.ecosystem\n version: $input.version\n page_token: $input.page_token\n query_purl:\n description: Read-only OSV query for vulnerabilities affecting one package URL (purl).\n method: POST\n path: /v1/query\n inputSchema:\n type: object\n properties:\n purl:\n type: string\n description: Package URL, such as pkg:npm/lodash@4.17.20 or pkg:pypi/requests@2.19.0.\n page_token:\n type: string\n description: Optional pagination token returned by OSV.\n required:\n - purl\n jsonBody:\n package:\n purl: $input.purl\n page_token: $input.page_token\n query_commit:\n description: Read-only OSV query for vulnerabilities associated with one source commit hash.\n method: POST\n path: /v1/query\n inputSchema:\n type: object\n properties:\n commit:\n type: string\n description: Source commit hash to query.\n page_token:\n type: string\n description: Optional pagination token returned by OSV.\n required:\n - commit\n jsonBody:\n commit: $input.commit\n page_token: $input.page_token\n query_batch:\n description: Read-only OSV batch query for multiple package, purl, commit, or version requests.\n method: POST\n path: /v1/querybatch\n inputSchema:\n type: object\n properties:\n queries:\n type: array\n description: OSV query objects accepted by /v1/querybatch.\n items:\n type: object\n additionalProperties: true\n required:\n - queries\n jsonBody:\n queries: $input.queries\n get_vulnerability:\n description: Read-only OSV lookup for one vulnerability record by OSV, CVE, or GHSA identifier.\n method: GET\n path: /v1/vulns/{id}\n inputSchema:\n type: object\n properties:\n id:\n type: string\n description: Vulnerability identifier, such as OSV-2020-744, CVE-2021-44228, or GHSA-jfh8-c2jp-5v3q.\n required:\n - id\n---\n\n# OSV Vulnerabilities\n\nUse this Caplet to query OSV.dev for known vulnerabilities affecting package\nversions, package URLs, source commits, or known vulnerability IDs.\n\n## Usage Notes\n\n- All actions are read-only HTTP requests against the public OSV API.\n- Use `query_package_version` when you know the package ecosystem, name, and exact version.\n- Use `query_purl` when tooling already produced a package URL such as `pkg:npm/lodash@4.17.20`.\n- Use `query_commit` for source-level checks against a commit hash.\n- Use `query_batch` to check multiple packages or commits in one request.\n- Use `get_vulnerability` when you already have an OSV, CVE, or GHSA identifier.\n\n## Ecosystems\n\nCommon OSV ecosystems include `npm`, `PyPI`, `Maven`, `Go`, `crates.io`,\n`Packagist`, `RubyGems`, `NuGet`, `Debian`, `Alpine`, and `OSS-Fuzz`.\n\n## Examples\n\n- Query npm package version: `name: lodash`, `ecosystem: npm`, `version: 4.17.20`.\n- Query Python package version: `name: requests`, `ecosystem: PyPI`, `version: 2.19.0`.\n- Query a purl: `purl: pkg:npm/lodash@4.17.20`.\n- Fetch a vulnerability: `id: CVE-2021-44228`.\n", + "tags": [ + "code", + "http", + "security", + "vulnerabilities" + ], + "intendedTask": "unknown", + "setupReadiness": "ready", + "authReadiness": "ready", + "projectBindingReadiness": "ready", + "workflow": { + "kind": "http", + "label": "HTTP API" + }, + "installCommand": { + "text": "caplets install spiritledsoftware/caplets osv", + "copyable": true, + "revisionBound": false + }, + "warnings": [] + }, + { + "entryKey": "github:spiritledsoftware:caplets:playwright%2Fcaplet.md:playwright", + "id": "playwright", + "name": "Playwright", + "description": "Drive a browser through Playwright MCP for frontend testing, inspection, and automation workflows.", + "source": { + "provider": "github", + "owner": "spiritledsoftware", + "repo": "caplets", + "repository": "spiritledsoftware/caplets", + "canonicalUrl": "https://github.com/spiritledsoftware/caplets" + }, + "sourcePath": "playwright/CAPLET.md", + "trustLevel": "official", + "contentMarkdown": "---\n# yaml-language-server: $schema=https://caplets.dev/caplet.schema.json\nname: Playwright\ndescription: Drive a browser through Playwright MCP for frontend testing, inspection, and automation workflows.\ntags:\n - browser\n - testing\n - mcp\n - frontend\nsetup:\n commands:\n - label: Install Playwright MCP\n command: npm\n args: [\"install\", \"-g\", \"@playwright/mcp@latest\"]\n timeoutMs: 120000\n maxOutputBytes: 200000\n - label: Install Chromium browser\n command: npx\n args: [\"-y\", \"playwright@latest\", \"install\", \"chromium\"]\n timeoutMs: 180000\n maxOutputBytes: 200000\n verify:\n - label: Check Playwright MCP\n command: playwright-mcp\n args: [\"--help\"]\n timeoutMs: 10000\n maxOutputBytes: 20000\nmcpServer:\n command: playwright-mcp\n args:\n - --headless\n---\n\n# Playwright\n\nUse this Caplet when the agent needs browser automation for frontend debugging, accessibility checks,\nvisual inspection, or end-to-end testing workflows.\n\n## Good Fits\n\n- Reproduce frontend bugs in a real browser context.\n- Inspect pages, forms, navigation, and interactive states.\n- Validate user flows before or after UI changes.\n- Gather browser evidence for debugging layout, hydration, or client-side behavior.\n\n## Setup\n\nThis Caplet installs `@playwright/mcp@0.0.75` globally with npm, installs the Chromium browser\nruntime with `npx playwright install chromium`, then verifies `playwright-mcp --help`. Setup is\nexplicit because browser automation needs both a stable MCP binary and a browser runtime before the\nhosted or local stdio server starts.\n\nRemove `--headless`, or set `PLAYWRIGHT_MCP_HEADLESS=false` in a custom MCP\nenvironment, to use a visible browser. For advanced settings, create a\nPlaywright MCP JSON config file in your project (for example,\n`.caplets/playwright-mcp.json`) and add `--config .caplets/playwright-mcp.json`\nto the args.\n", + "tags": [ + "browser", + "frontend", + "mcp", + "testing" + ], + "intendedTask": "unknown", + "setupReadiness": "required", + "authReadiness": "ready", + "projectBindingReadiness": "ready", + "workflow": { + "kind": "mcp", + "label": "MCP server" + }, + "installCommand": { + "text": "caplets install spiritledsoftware/caplets playwright", + "copyable": true, + "revisionBound": false + }, + "warnings": [ + { + "code": "setup_required", + "severity": "info", + "label": "Setup required", + "message": "This Caplet includes setup steps that should be completed before use." + } + ] + }, + { + "entryKey": "github:spiritledsoftware:caplets:posthog%2Fcaplet.md:posthog", + "id": "posthog", + "name": "PostHog", + "description": "Inspect PostHog analytics, feature flags, experiments, session replay, and product telemetry through PostHog's hosted MCP server.", + "source": { + "provider": "github", + "owner": "spiritledsoftware", + "repo": "caplets", + "repository": "spiritledsoftware/caplets", + "canonicalUrl": "https://github.com/spiritledsoftware/caplets" + }, + "sourcePath": "posthog/CAPLET.md", + "trustLevel": "official", + "contentMarkdown": "---\n# yaml-language-server: $schema=https://caplets.dev/caplet.schema.json\nname: PostHog\ndescription: Inspect PostHog analytics, feature flags, experiments, session replay, and product telemetry through PostHog's hosted MCP server.\ntags:\n - analytics\n - posthog\n - product\n - feature-flags\nmcpServer:\n url: https://mcp.posthog.com/mcp\n auth:\n type: oauth2\n---\n\n# PostHog\n\nUse this Caplet when an agent needs product analytics or feature-flag context from PostHog before planning, debugging, or validating a change.\n\n## Good Fits\n\n- Query trends, funnels, retention, or HogQL for a product question.\n- Inspect feature flags, experiments, or rollout state before changing code.\n- Search session replays and event data while investigating user-reported behavior.\n\n## Use Carefully\n\nPostHog MCP includes mutating tools for flags, insights, dashboards, and other project state. Prefer read-only inspection first, review planned mutations, and keep OAuth access scoped to the PostHog organization and project you intend to expose.\n", + "tags": [ + "analytics", + "feature-flags", + "posthog", + "product" + ], + "intendedTask": "unknown", + "setupReadiness": "ready", + "authReadiness": "required", + "projectBindingReadiness": "ready", + "workflow": { + "kind": "mcp", + "label": "MCP server" + }, + "installCommand": { + "text": "caplets install spiritledsoftware/caplets posthog", + "copyable": true, + "revisionBound": false + }, + "warnings": [ + { + "code": "mutating_saas", + "severity": "caution", + "label": "Can change external services", + "message": "This Caplet may perform mutating operations against an external service." + }, + { + "code": "auth_required", + "severity": "caution", + "label": "Authentication required", + "message": "This Caplet needs credentials or an auth flow before use." + } + ] + }, + { + "entryKey": "github:spiritledsoftware:caplets:pypi%2Fcaplet.md:pypi", + "id": "pypi", + "name": "PyPI", + "description": "Query Python package metadata, releases, files, vulnerabilities, and Simple API project details from PyPI.", + "source": { + "provider": "github", + "owner": "spiritledsoftware", + "repo": "caplets", + "repository": "spiritledsoftware/caplets", + "canonicalUrl": "https://github.com/spiritledsoftware/caplets" + }, + "sourcePath": "pypi/CAPLET.md", + "trustLevel": "official", + "contentMarkdown": "---\n# yaml-language-server: $schema=https://caplets.dev/caplet.schema.json\nname: PyPI\ndescription: Query Python package metadata, releases, files, vulnerabilities, and Simple API project details from PyPI.\ntags:\n - openapi\n - pypi\n - python\n - packages\n - code\nopenapiEndpoint:\n specPath: ./pypi.openapi.yaml\n auth:\n type: none\n---\n\n# PyPI\n\nUse this Caplet to inspect Python package metadata through a compact, read-only\nOpenAPI description of public PyPI endpoints.\n\n## Spec\n\nThe local OpenAPI spec is [pypi.openapi.yaml](./pypi.openapi.yaml).\n\n## API Coverage\n\n- The PyPI JSON API endpoints return project and release metadata, including package info, release files, download URLs, vulnerability records, and maintainer or author ownership fields when PyPI includes them.\n- The Simple Repository API endpoint uses PyPI's JSON representation for project file listings. The curated OpenAPI spec supplies `Accept: application/vnd.pypi.simple.v1+json` automatically for `get_simple_project`, so callers only provide `project`.\n- Deprecated XML-RPC APIs are intentionally excluded; use these JSON endpoints for agent workflows.\n\n## Usage Notes\n\n- Use `get_project` to fetch current project metadata, releases, URLs, and vulnerability records.\n- Use `get_release` when you need metadata for one exact published version.\n- Use `get_simple_project` when dependency tooling needs Simple API file links and hashes.\n\n## Examples\n\n- Fetch Requests project metadata: `project: requests`.\n- Fetch Django release metadata: `project: django`, `version: 5.0.6`.\n- Fetch Simple API JSON: `project: pytest`.\n", + "tags": [ + "code", + "openapi", + "packages", + "pypi", + "python" + ], + "intendedTask": "unknown", + "setupReadiness": "ready", + "authReadiness": "ready", + "projectBindingReadiness": "ready", + "workflow": { + "kind": "openapi", + "label": "OpenAPI" + }, + "installCommand": { + "text": "caplets install spiritledsoftware/caplets pypi", + "copyable": true, + "revisionBound": false + }, + "warnings": [] + }, + { + "entryKey": "github:spiritledsoftware:caplets:repo-cli%2Fcaplet.md:repo-cli", + "id": "repo-cli", + "name": "Repository CLI", + "description": "Inspect and run common local repository workflows through curated CLI tools.", + "source": { + "provider": "github", + "owner": "spiritledsoftware", + "repo": "caplets", + "repository": "spiritledsoftware/caplets", + "canonicalUrl": "https://github.com/spiritledsoftware/caplets" + }, + "sourcePath": "repo-cli/CAPLET.md", + "trustLevel": "official", + "contentMarkdown": "---\n# yaml-language-server: $schema=https://caplets.dev/caplet.schema.json\nname: Repository CLI\ndescription: Inspect and run common local repository workflows through curated CLI tools.\ntags:\n - cli\n - code\nprojectBinding:\n required: true\ncliTools:\n actions:\n git_status:\n description: Show concise Git working tree status.\n command: git\n args:\n - status\n - --short\n annotations:\n readOnlyHint: true\n git_current_branch:\n description: Print the current Git branch name.\n command: git\n args:\n - branch\n - --show-current\n annotations:\n readOnlyHint: true\n package_test:\n description: Run the repository test script with pnpm.\n command: pnpm\n args:\n - run\n - test\n timeoutMs: 120000\n---\n\n# Repository CLI\n\nUse this Caplet to expose a small, typed set of local repository commands without giving an agent arbitrary shell access.\n\nProject Binding is required because every command is meant to run against the attached repository. The bound root prevents the agent from accidentally treating an unrelated working directory as the target project.\n", + "tags": [ + "cli", + "code" + ], + "intendedTask": "unknown", + "setupReadiness": "ready", + "authReadiness": "ready", + "projectBindingReadiness": "required", + "workflow": { + "kind": "cli", + "label": "CLI tools" + }, + "installCommand": { + "text": "caplets install spiritledsoftware/caplets repo-cli", + "copyable": true, + "revisionBound": false + }, + "warnings": [ + { + "code": "local_control", + "severity": "danger", + "label": "Local control", + "message": "This Caplet can operate against local project or machine state." + }, + { + "code": "project_binding_required", + "severity": "caution", + "label": "Project Binding required", + "message": "This Caplet needs a Project Binding before it can run in the intended environment." + } + ] + }, + { + "entryKey": "github:spiritledsoftware:caplets:sentry%2Fcaplet.md:sentry", + "id": "sentry", + "name": "Sentry", + "description": "Inspect Sentry issues, events, traces, releases, and AI debugging context through Sentry's hosted MCP server.", + "source": { + "provider": "github", + "owner": "spiritledsoftware", + "repo": "caplets", + "repository": "spiritledsoftware/caplets", + "canonicalUrl": "https://github.com/spiritledsoftware/caplets" + }, + "sourcePath": "sentry/CAPLET.md", + "trustLevel": "official", + "contentMarkdown": "---\n# yaml-language-server: $schema=https://caplets.dev/caplet.schema.json\nname: Sentry\ndescription: Inspect Sentry issues, events, traces, releases, and AI debugging context through Sentry's hosted MCP server.\ntags:\n - observability\n - sentry\n - errors\n - tracing\nmcpServer:\n url: https://mcp.sentry.dev/mcp\n auth:\n type: oauth2\n---\n\n# Sentry\n\nUse this Caplet when an agent needs live Sentry context while debugging production errors, investigating traces, or checking release health.\n\n## Good Fits\n\n- Find the highest-impact issues for a project and time range.\n- Inspect event details, stack traces, tags, breadcrumbs, and suspect commits.\n- Correlate deploys or releases with new errors before changing code.\n\n## Use Carefully\n\nSentry data can contain user, request, and environment details. Ask for narrow projects and time windows, summarize only the needed debugging context, and review any mutating tool calls before applying changes to Sentry state.\n", + "tags": [ + "errors", + "observability", + "sentry", + "tracing" + ], + "intendedTask": "unknown", + "setupReadiness": "ready", + "authReadiness": "required", + "projectBindingReadiness": "ready", + "workflow": { + "kind": "mcp", + "label": "MCP server" + }, + "installCommand": { + "text": "caplets install spiritledsoftware/caplets sentry", + "copyable": true, + "revisionBound": false + }, + "warnings": [ + { + "code": "mutating_saas", + "severity": "caution", + "label": "Can change external services", + "message": "This Caplet may perform mutating operations against an external service." + }, + { + "code": "auth_required", + "severity": "caution", + "label": "Authentication required", + "message": "This Caplet needs credentials or an auth flow before use." + } + ] + }, + { + "entryKey": "github:spiritledsoftware:caplets:sourcegraph%2Fcaplet.md:sourcegraph", + "id": "sourcegraph", + "name": "Sourcegraph", + "description": "Search and inspect code across Sourcegraph using its MCP endpoint for repository-aware coding workflows.", + "source": { + "provider": "github", + "owner": "spiritledsoftware", + "repo": "caplets", + "repository": "spiritledsoftware/caplets", + "canonicalUrl": "https://github.com/spiritledsoftware/caplets" + }, + "sourcePath": "sourcegraph/CAPLET.md", + "trustLevel": "official", + "contentMarkdown": "---\n# yaml-language-server: $schema=https://caplets.dev/caplet.schema.json\nname: Sourcegraph\ndescription: Search and inspect code across Sourcegraph using its MCP endpoint for repository-aware coding workflows.\ntags:\n - sourcegraph\n - code-search\n - mcp\nmcpServer:\n url: https://sourcegraph.com/.api/mcp\n auth:\n type: oauth2\n---\n\n# Sourcegraph\n\nUse this Caplet when the agent needs broad code search, repository navigation, or cross-repository\ncontext from Sourcegraph.\n\n## Good Fits\n\n- Find examples of an API, class, or migration pattern across indexed repositories.\n- Trace references before changing shared interfaces.\n- Compare implementations across services or packages.\n- Gather code-search evidence for debugging, review, or planning.\n\n## Setup\n\nThis Caplet targets Sourcegraph Cloud at `https://sourcegraph.com/.api/mcp` and uses OAuth.\n\nSelf-managed Sourcegraph users should change the URL to\n`https:///.api/mcp`. OAuth/DCR support or access-token headers depend on the\ninstance setup, so configure authentication to match your deployment.\n", + "tags": [ + "code-search", + "mcp", + "sourcegraph" + ], + "intendedTask": "unknown", + "setupReadiness": "ready", + "authReadiness": "required", + "projectBindingReadiness": "ready", + "workflow": { + "kind": "mcp", + "label": "MCP server" + }, + "installCommand": { + "text": "caplets install spiritledsoftware/caplets sourcegraph", + "copyable": true, + "revisionBound": false + }, + "warnings": [ + { + "code": "mutating_saas", + "severity": "caution", + "label": "Can change external services", + "message": "This Caplet may perform mutating operations against an external service." + }, + { + "code": "auth_required", + "severity": "caution", + "label": "Authentication required", + "message": "This Caplet needs credentials or an auth flow before use." + } + ] + } +] diff --git a/apps/catalog/src/env.d.ts b/apps/catalog/src/env.d.ts new file mode 100644 index 00000000..2ddc5b31 --- /dev/null +++ b/apps/catalog/src/env.d.ts @@ -0,0 +1,14 @@ +import "../.astro/types.d.ts"; + +import type { Runtime } from "@astrojs/cloudflare"; +import type { D1Database } from "@cloudflare/workers-types"; + +declare global { + namespace App { + interface Locals extends Runtime {} + } +} + +interface Env { + CATALOG_DB?: D1Database; +} diff --git a/apps/catalog/src/lib/catalog-env.ts b/apps/catalog/src/lib/catalog-env.ts new file mode 100644 index 00000000..82cb22d1 --- /dev/null +++ b/apps/catalog/src/lib/catalog-env.ts @@ -0,0 +1,11 @@ +import type { D1Database } from "@cloudflare/workers-types"; +// @ts-expect-error @astrojs/cloudflare provides this virtual module at runtime. +import { env } from "cloudflare:workers"; + +export type CatalogEnv = { + CATALOG_DB?: D1Database; +}; + +export function getCatalogEnv(): CatalogEnv { + return env as CatalogEnv; +} diff --git a/apps/catalog/src/lib/catalog-response.ts b/apps/catalog/src/lib/catalog-response.ts new file mode 100644 index 00000000..7681ec86 --- /dev/null +++ b/apps/catalog/src/lib/catalog-response.ts @@ -0,0 +1,31 @@ +export function jsonResponse(data: unknown, init: ResponseInit = {}): Response { + const headers = new Headers(init.headers); + headers.set("content-type", "application/json; charset=utf-8"); + headers.set("cache-control", headers.get("cache-control") ?? "public, max-age=60"); + applySecurityHeaders(headers); + return new Response(JSON.stringify(data, null, 2), { ...init, headers }); +} + +export function notFound(message = "Catalog entry not found."): Response { + return jsonResponse({ ok: false, error: { code: "not_found", message } }, { status: 404 }); +} + +export function applySecurityHeaders(headers: Headers): Headers { + headers.set("x-content-type-options", "nosniff"); + headers.set("referrer-policy", "strict-origin-when-cross-origin"); + headers.set("permissions-policy", "interest-cohort=()"); + headers.set( + "content-security-policy", + [ + "default-src 'self'", + "base-uri 'none'", + "object-src 'none'", + "frame-ancestors 'none'", + "img-src 'self' data: https:", + "style-src 'self' 'unsafe-inline'", + "script-src 'self'", + "connect-src 'self'", + ].join("; "), + ); + return headers; +} diff --git a/apps/catalog/src/lib/catalog-store.ts b/apps/catalog/src/lib/catalog-store.ts new file mode 100644 index 00000000..2b890adf --- /dev/null +++ b/apps/catalog/src/lib/catalog-store.ts @@ -0,0 +1,78 @@ +import { formatCatalogInstallCount, type CatalogEntry } from "@caplets/core/catalog"; +import type { D1Database } from "@cloudflare/workers-types"; +import officialEntries from "../data/official-catalog.json"; +import { rankInstallCount } from "./counts"; + +export type CatalogEntryRecord = CatalogEntry & { + installCount: number; + installCountDisplay: string; + rankScore: number; +}; + +export type CatalogStoreEnv = { + CATALOG_DB?: D1Database | undefined; +}; + +export async function listCatalogEntries(env: CatalogStoreEnv = {}): Promise { + const db = env.CATALOG_DB; + const [overlays, suppressed, communityEntries] = db + ? await Promise.all([ + readCountOverlays(db), + readSuppressedEntryKeys(db), + readCommunityEntries(db), + ]) + : [new Map(), new Set(), []]; + return [...(officialEntries as CatalogEntry[]), ...communityEntries] + .filter((entry) => !suppressed.has(entry.entryKey)) + .map((entry) => withCount(entry, overlays.get(entry.entryKey) ?? 0)) + .sort((left, right) => { + const rank = right.rankScore - left.rankScore; + return rank === 0 ? left.name.localeCompare(right.name) : rank; + }); +} + +export async function getCatalogEntry( + entryKey: string, + env: CatalogStoreEnv = {}, +): Promise { + return (await listCatalogEntries(env)).find((entry) => entry.entryKey === entryKey); +} + +function withCount(entry: CatalogEntry, installCount: number): CatalogEntryRecord { + return { + ...entry, + installCount, + installCountDisplay: formatCatalogInstallCount(installCount), + rankScore: rankInstallCount(installCount), + }; +} + +async function readCountOverlays(db: D1Database): Promise> { + const result = await db + .prepare("select entry_key as entryKey, install_count as installCount from catalog_counts") + .all<{ entryKey: string; installCount: number }>(); + return new Map( + (result.results ?? []).map((row) => [row.entryKey, Number(row.installCount) || 0]), + ); +} + +async function readSuppressedEntryKeys(db: D1Database): Promise> { + const result = await db.prepare("select entry_key as entryKey from catalog_suppressions").all<{ + entryKey: string; + }>(); + return new Set((result.results ?? []).map((row) => row.entryKey)); +} + +async function readCommunityEntries(db: D1Database): Promise { + const result = await db.prepare("select entry_json as entryJson from catalog_entries").all<{ + entryJson: string; + }>(); + return (result.results ?? []).flatMap((row) => { + try { + const parsed = JSON.parse(row.entryJson) as CatalogEntry; + return parsed.trustLevel === "community" ? [parsed] : []; + } catch { + return []; + } + }); +} diff --git a/apps/catalog/src/lib/counts.ts b/apps/catalog/src/lib/counts.ts new file mode 100644 index 00000000..643cf20b --- /dev/null +++ b/apps/catalog/src/lib/counts.ts @@ -0,0 +1,6 @@ +export function rankInstallCount(count: number): number { + if (!Number.isFinite(count) || count < 10) { + return 0; + } + return Math.floor(count); +} diff --git a/apps/catalog/src/lib/entry-route.ts b/apps/catalog/src/lib/entry-route.ts new file mode 100644 index 00000000..ff508efe --- /dev/null +++ b/apps/catalog/src/lib/entry-route.ts @@ -0,0 +1,3 @@ +export function decodeEntryRouteKey(value: string): string { + return value.replace(/%3A/giu, ":").replace(/%25/giu, "%"); +} diff --git a/apps/catalog/src/lib/ingest.ts b/apps/catalog/src/lib/ingest.ts new file mode 100644 index 00000000..1f005bfe --- /dev/null +++ b/apps/catalog/src/lib/ingest.ts @@ -0,0 +1,353 @@ +import { + catalogAuthRequiredFromFrontmatter, + type CatalogEntry, + catalogEntryKey, + catalogMutatesExternalStateFromFrontmatter, + catalogProjectBindingRequiredFromFrontmatter, + catalogSetupRequiredFromFrontmatter, + catalogStringArrayFromFrontmatter, + catalogStringFromFrontmatter, + catalogUsesLocalControlFromFrontmatter, + catalogWorkflowSummaryFromFrontmatter, + createCatalogEntry, + readCatalogCapletFrontmatterFromMarkdown, + type CatalogIndexingStatus, + type CatalogSourceIdentity, +} from "@caplets/core/catalog"; +import type { D1Database } from "@cloudflare/workers-types"; +import { publicCatalogSourceEligibility } from "./public-source"; +import { refractoryWindowAllows } from "./rate-limit"; +import { isSuppressed } from "./suppression"; + +const maxBodyBytes = 16 * 1024; +const maxFetchedCapletBytes = 128 * 1024; +const refractoryWindowMs = 60 * 60 * 1000; +const maxRepositorySignalsPerWindow = 250; +const officialRepository = "spiritledsoftware/caplets"; + +export type CatalogInstallSignal = { + source: string; + capletId: string; + sourcePath: string; + resolvedRevision?: string | undefined; + contentHash?: string | undefined; + entry?: CatalogEntry | undefined; +}; + +export type CatalogInstallSignalResult = { + status: CatalogIndexingStatus; + entryKey?: string | undefined; +}; + +export async function parseInstallSignalRequest(request: Request): Promise { + const contentLengthHeader = request.headers.get("content-length"); + const contentLength = contentLengthHeader ? Number(contentLengthHeader) : undefined; + if ( + contentLength !== undefined && + (!Number.isSafeInteger(contentLength) || contentLength > maxBodyBytes) + ) { + throw new Error("request_body_too_large"); + } + const body = JSON.parse(await readLimitedRequestText(request)); + if (!isInstallSignal(body)) { + throw new Error("invalid_install_signal"); + } + return body; +} + +export async function acceptInstallSignal(input: { + signal: CatalogInstallSignal; + db?: D1Database | undefined; + fetch?: typeof fetch | undefined; + now?: Date | undefined; +}): Promise { + const eligibility = publicCatalogSourceEligibility(input.signal.source); + if (!eligibility.eligible) { + return { status: "ineligible" }; + } + if (!input.signal.resolvedRevision && !input.signal.contentHash) { + return { status: "revision_unavailable" }; + } + + const entryKey = catalogEntryKey({ + source: eligibility.source, + sourcePath: input.signal.sourcePath, + capletId: input.signal.capletId, + }); + if (eligibility.source.repository === officialRepository) { + return { status: "already_current", entryKey }; + } + if (!input.db) { + return { status: "unavailable", entryKey }; + } + if (await isSuppressed(input.db, entryKey)) { + return { status: "ineligible" }; + } + + const previous = await input.db + .prepare("select accepted_at_ms from catalog_signal_dedupe where entry_key = ? limit 1") + .bind(entryKey) + .first<{ accepted_at_ms: number }>(); + const nowMs = input.now?.getTime() ?? Date.now(); + const decision = refractoryWindowAllows({ + nowMs, + previousAcceptedAtMs: previous?.accepted_at_ms, + windowMs: refractoryWindowMs, + }); + if (!decision.allowed) { + return { status: "rate_limited", entryKey }; + } + if (!(await repositoryWindowAllows(input.db, eligibility.source, nowMs))) { + return { status: "rate_limited", entryKey }; + } + + await recordAcceptedSignal(input.db, { + entry: await canonicalEntryForAcceptedSignal( + input.signal, + eligibility.source, + entryKey, + input.fetch ?? globalThis.fetch, + ), + entryKey, + source: eligibility.source, + nowMs, + signal: input.signal, + }); + return { status: previous ? "counted" : "accepted", entryKey }; +} + +async function readLimitedRequestText(request: Request): Promise { + if (!request.body) { + throw new Error("invalid_install_signal"); + } + const reader = request.body.getReader(); + const chunks: Uint8Array[] = []; + let totalBytes = 0; + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + totalBytes += value.byteLength; + if (totalBytes > maxBodyBytes) { + throw new Error("request_body_too_large"); + } + chunks.push(value); + } + } finally { + reader.releaseLock(); + } + return new TextDecoder().decode(concatChunks(chunks, totalBytes)); +} + +function concatChunks(chunks: Uint8Array[], totalBytes: number): Uint8Array { + const output = new Uint8Array(totalBytes); + let offset = 0; + for (const chunk of chunks) { + output.set(chunk, offset); + offset += chunk.byteLength; + } + return output; +} + +async function repositoryWindowAllows( + db: D1Database, + source: CatalogSourceIdentity, + nowMs: number, +): Promise { + const windowStartMs = Math.floor(nowMs / refractoryWindowMs) * refractoryWindowMs; + const existing = await db + .prepare( + `select accepted_count as acceptedCount + from catalog_signal_repository_windows + where provider = ? and repository = ? and window_start_ms = ? + limit 1`, + ) + .bind(source.provider, source.repository, windowStartMs) + .first<{ acceptedCount: number }>(); + return (existing?.acceptedCount ?? 0) < maxRepositorySignalsPerWindow; +} + +function isInstallSignal(value: unknown): value is CatalogInstallSignal { + if (!value || typeof value !== "object") return false; + const candidate = value as Record; + return ( + typeof candidate.source === "string" && + typeof candidate.capletId === "string" && + typeof candidate.sourcePath === "string" && + (candidate.resolvedRevision === undefined || typeof candidate.resolvedRevision === "string") && + (candidate.contentHash === undefined || typeof candidate.contentHash === "string") && + (candidate.entry === undefined || isCatalogEntry(candidate.entry)) + ); +} + +function isCatalogEntry(value: unknown): value is CatalogEntry { + if (!value || typeof value !== "object") return false; + const candidate = value as Record; + return ( + typeof candidate.entryKey === "string" && + typeof candidate.id === "string" && + typeof candidate.name === "string" && + typeof candidate.description === "string" && + candidate.trustLevel === "community" && + typeof candidate.sourcePath === "string" && + typeof candidate.installCommand === "object" + ); +} + +async function recordAcceptedSignal( + db: D1Database, + input: { + entry?: CatalogEntry | undefined; + entryKey: string; + source: CatalogSourceIdentity; + nowMs: number; + signal: CatalogInstallSignal; + }, +): Promise { + const statements = [ + db + .prepare( + `insert into catalog_counts (entry_key, install_count, updated_at_ms) + values (?, 1, ?) + on conflict(entry_key) do update set + install_count = install_count + 1, + updated_at_ms = excluded.updated_at_ms`, + ) + .bind(input.entryKey, input.nowMs), + db + .prepare( + `insert into catalog_signal_dedupe (entry_key, provider, repository, accepted_at_ms) + values (?, ?, ?, ?) + on conflict(entry_key) do update set accepted_at_ms = excluded.accepted_at_ms`, + ) + .bind(input.entryKey, input.source.provider, input.source.repository, input.nowMs), + db + .prepare( + `insert into catalog_signal_repository_windows (provider, repository, window_start_ms, accepted_count) + values (?, ?, ?, 1) + on conflict(provider, repository, window_start_ms) do update set + accepted_count = accepted_count + 1`, + ) + .bind( + input.source.provider, + input.source.repository, + Math.floor(input.nowMs / refractoryWindowMs) * refractoryWindowMs, + ), + ]; + if (input.entry) { + statements.push( + db + .prepare( + `insert into catalog_entries ( + entry_key, provider, repository, source_path, caplet_id, + resolved_revision, content_hash, entry_json, updated_at_ms + ) + values (?, ?, ?, ?, ?, ?, ?, ?, ?) + on conflict(entry_key) do update set + resolved_revision = excluded.resolved_revision, + content_hash = excluded.content_hash, + entry_json = excluded.entry_json, + updated_at_ms = excluded.updated_at_ms`, + ) + .bind( + input.entryKey, + input.source.provider, + input.source.repository, + input.signal.sourcePath, + input.signal.capletId, + input.signal.resolvedRevision, + input.signal.contentHash, + JSON.stringify(input.entry), + input.nowMs, + ), + ); + } + await db.batch(statements); +} + +async function canonicalEntryForAcceptedSignal( + signal: CatalogInstallSignal, + source: CatalogSourceIdentity, + entryKey: string, + fetchImpl: typeof fetch | undefined, +): Promise { + const entry = signal.entry; + if (!entry || entry.entryKey !== entryKey) return undefined; + const contentMarkdown = await fetchPublicCapletMarkdown(signal, source, fetchImpl); + if (!contentMarkdown) return undefined; + const frontmatter = readCatalogCapletFrontmatterFromMarkdown(contentMarkdown); + return createCatalogEntry({ + id: signal.capletId, + name: catalogStringFromFrontmatter(frontmatter.name) ?? signal.capletId, + description: + catalogStringFromFrontmatter(frontmatter.description) ?? + `Community Caplet ${signal.capletId}.`, + source, + sourcePath: signal.sourcePath, + trustLevel: "community", + resolvedRevision: signal.resolvedRevision, + indexedContentHash: signal.contentHash, + contentMarkdown, + tags: catalogStringArrayFromFrontmatter(frontmatter.tags), + useWhen: catalogStringFromFrontmatter(frontmatter.useWhen), + avoidWhen: catalogStringFromFrontmatter(frontmatter.avoidWhen), + setupRequired: catalogSetupRequiredFromFrontmatter(frontmatter), + authRequired: catalogAuthRequiredFromFrontmatter(frontmatter), + projectBindingRequired: catalogProjectBindingRequiredFromFrontmatter(frontmatter), + workflow: catalogWorkflowSummaryFromFrontmatter(frontmatter, { + kind: "set", + label: "Caplet", + }), + mutatesExternalState: catalogMutatesExternalStateFromFrontmatter(frontmatter), + localControl: catalogUsesLocalControlFromFrontmatter(frontmatter), + }); +} + +async function fetchPublicCapletMarkdown( + signal: CatalogInstallSignal, + source: CatalogSourceIdentity, + fetchImpl: typeof fetch | undefined, +): Promise { + if (!fetchImpl || !signal.resolvedRevision) return undefined; + for (const path of candidateRawCapletPaths(signal.sourcePath)) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 3_000); + try { + const response = await fetchImpl(rawGithubUrl(source, signal.resolvedRevision, path), { + headers: { accept: "text/plain" }, + signal: controller.signal, + }); + if (!response.ok) continue; + const text = await response.text(); + if (new TextEncoder().encode(text).byteLength <= maxFetchedCapletBytes) { + return text; + } + } catch { + continue; + } finally { + clearTimeout(timeout); + } + } + return undefined; +} + +function candidateRawCapletPaths(sourcePath: string): string[] { + const cleaned = sourcePath.trim().replace(/\\/g, "/").replace(/^\.\//u, ""); + const segments = cleaned.split("/").filter(Boolean); + if (segments.some((segment) => segment === "." || segment === "..")) return []; + const normalized = segments.join("/"); + if (!normalized) return []; + if (/\.md$/iu.test(normalized)) return [normalized]; + return [`${normalized}/CAPLET.md`]; +} + +function rawGithubUrl( + source: CatalogSourceIdentity, + resolvedRevision: string, + sourcePath: string, +): string { + const path = sourcePath.split("/").map(encodeURIComponent).join("/"); + return `https://raw.githubusercontent.com/${source.owner}/${source.repo}/${encodeURIComponent( + resolvedRevision, + )}/${path}`; +} diff --git a/apps/catalog/src/lib/markdown.ts b/apps/catalog/src/lib/markdown.ts new file mode 100644 index 00000000..c526625a --- /dev/null +++ b/apps/catalog/src/lib/markdown.ts @@ -0,0 +1,92 @@ +import rehypeSanitize, { defaultSchema } from "rehype-sanitize"; +import rehypeStringify from "rehype-stringify"; +import remarkParse from "remark-parse"; +import remarkRehype from "remark-rehype"; +import { unified } from "unified"; +import { readCatalogCapletFrontmatterFromMarkdown } from "@caplets/core/catalog"; + +const allowedProtocols = ["http", "https"]; +const frontmatterPattern = /^---\r?\n[\s\S]*?\r?\n---\r?\n?/u; + +export type CatalogFrontmatterRow = { + key: string; + value: string; +}; + +export type CatalogMarkdownParts = { + bodyMarkdown: string; + frontmatterRows: CatalogFrontmatterRow[]; +}; + +export function splitCatalogMarkdown(markdown: string): CatalogMarkdownParts { + const frontmatterMatch = frontmatterPattern.exec(markdown); + if (!frontmatterMatch) { + return { bodyMarkdown: markdown, frontmatterRows: [] }; + } + const frontmatter = readCatalogCapletFrontmatterFromMarkdown(frontmatterMatch[0]); + return { + bodyMarkdown: markdown.slice(frontmatterMatch[0].length).trimStart(), + frontmatterRows: flattenFrontmatterRows(frontmatter), + }; +} + +export async function renderCatalogMarkdown(markdown: string): Promise { + return String( + await unified() + .use(remarkParse) + .use(remarkRehype, { allowDangerousHtml: false }) + .use(rehypeSanitize, { + ...defaultSchema, + attributes: { + ...defaultSchema.attributes, + a: [["href"], ["title"]], + code: [["className"]], + }, + protocols: { + ...defaultSchema.protocols, + href: allowedProtocols, + }, + }) + .use(rehypeStringify) + .process(markdown), + ); +} + +function flattenFrontmatterRows(value: unknown, prefix: string[] = []): CatalogFrontmatterRow[] { + if (Array.isArray(value)) { + if (value.every(isScalar)) { + return [{ key: formatKey(prefix), value: value.map(formatScalar).join(", ") }]; + } + return value.flatMap((item, index) => flattenFrontmatterRows(item, [...prefix, `[${index}]`])); + } + if (isRecord(value)) { + return Object.entries(value).flatMap(([key, nested]) => + flattenFrontmatterRows(nested, [...prefix, key]), + ); + } + if (prefix.length === 0) return []; + return [{ key: formatKey(prefix), value: formatScalar(value) }]; +} + +function formatKey(parts: string[]): string { + return parts.reduce((key, part) => { + if (!key) return part; + return part.startsWith("[") ? `${key}${part}` : `${key}.${part}`; + }, ""); +} + +function formatScalar(value: unknown): string { + if (value === null) return "null"; + if (value === undefined) return ""; + if (typeof value === "string") return value; + if (typeof value === "number" || typeof value === "boolean") return String(value); + return JSON.stringify(value); +} + +function isScalar(value: unknown): boolean { + return value === null || ["string", "number", "boolean", "undefined"].includes(typeof value); +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} diff --git a/apps/catalog/src/lib/public-source.ts b/apps/catalog/src/lib/public-source.ts new file mode 100644 index 00000000..26e25a4d --- /dev/null +++ b/apps/catalog/src/lib/public-source.ts @@ -0,0 +1,11 @@ +import { + normalizeCatalogSourceIdentity, + type CatalogIndexingEligibility, +} from "@caplets/core/catalog"; + +export function publicCatalogSourceEligibility(source: unknown): CatalogIndexingEligibility { + if (typeof source !== "string") { + return { eligible: false, reason: "unsupported_source", redactedSource: "[redacted]" }; + } + return normalizeCatalogSourceIdentity(source); +} diff --git a/apps/catalog/src/lib/rate-limit.ts b/apps/catalog/src/lib/rate-limit.ts new file mode 100644 index 00000000..8297ec39 --- /dev/null +++ b/apps/catalog/src/lib/rate-limit.ts @@ -0,0 +1,22 @@ +export type RateLimitDecision = { + allowed: boolean; + retryAfterSeconds?: number | undefined; +}; + +export function refractoryWindowAllows(input: { + nowMs: number; + previousAcceptedAtMs?: number | undefined; + windowMs: number; +}): RateLimitDecision { + if (input.previousAcceptedAtMs === undefined) { + return { allowed: true }; + } + const nextAllowedAt = input.previousAcceptedAtMs + input.windowMs; + if (input.nowMs >= nextAllowedAt) { + return { allowed: true }; + } + return { + allowed: false, + retryAfterSeconds: Math.ceil((nextAllowedAt - input.nowMs) / 1000), + }; +} diff --git a/apps/catalog/src/lib/search-filter.ts b/apps/catalog/src/lib/search-filter.ts new file mode 100644 index 00000000..dae130a5 --- /dev/null +++ b/apps/catalog/src/lib/search-filter.ts @@ -0,0 +1,50 @@ +export type CatalogSearchRecord = { + id: string; + name: string; + description: string; + tags: string[]; + trust: string; + setup: string; + count: number; + searchText?: string | undefined; +}; + +export type CatalogSearchFilters = { + query: string; + trust: string; + setup: string; + tag: string; + sort: "rank" | "name"; +}; + +export function filterCatalogSearchRecords( + records: CatalogSearchRecord[], + filters: CatalogSearchFilters, +): CatalogSearchRecord[] { + const query = filters.query.trim().toLowerCase(); + const normalizedQuery = normalizeSearchText(query); + return records + .filter((record) => { + const searchable = + record.searchText ?? [record.name, record.description, record.tags.join(" ")].join(" "); + const lowerSearchable = searchable.toLowerCase(); + const normalizedSearchable = normalizeSearchText(lowerSearchable); + return ( + (!query || + lowerSearchable.includes(query) || + normalizedSearchable.includes(normalizedQuery)) && + (filters.trust === "all" || record.trust === filters.trust) && + (filters.setup === "all" || record.setup === filters.setup) && + (filters.tag === "all" || record.tags.includes(filters.tag)) + ); + }) + .sort((left, right) => { + if (filters.sort === "name") return left.name.localeCompare(right.name); + const rank = right.count - left.count; + return rank === 0 ? left.name.localeCompare(right.name) : rank; + }); +} + +function normalizeSearchText(value: string): string { + return value.replace(/[^a-z0-9]+/g, " ").trim(); +} diff --git a/apps/catalog/src/lib/search-row.ts b/apps/catalog/src/lib/search-row.ts new file mode 100644 index 00000000..bb1d8f58 --- /dev/null +++ b/apps/catalog/src/lib/search-row.ts @@ -0,0 +1,74 @@ +import type { CatalogWarning, CatalogWarningSeverity } from "@caplets/core/catalog"; +import type { CatalogEntryRecord } from "./catalog-store"; + +export type CatalogSearchStatusCode = CatalogWarning["code"] | "vault_required"; + +export type CatalogSearchStatus = { + code: CatalogSearchStatusCode; + label: string; + severity: CatalogWarningSeverity; +}; + +export type CatalogSearchRow = { + id: string; + name: string; + description: string; + tags: string[]; + trust: string; + setup: string; + count: number; + installCountDisplay: string; + sourceRepository: string; + workflowLabel: string; + authReadiness: string; + projectBindingReadiness: string; + detailHref: string; + installCommandText: string; + installCommandCopyable: boolean; + statuses: CatalogSearchStatus[]; + searchText: string; +}; + +export function catalogSearchRowFromEntry(entry: CatalogEntryRecord): CatalogSearchRow { + const statuses = entry.warnings.map((warning) => ({ + code: warning.code, + label: warning.label, + severity: warning.severity, + })); + const installCommandText = entry.installCommand.text || "Install command unavailable"; + const row: Omit = { + id: entry.entryKey, + name: entry.name, + description: entry.description, + tags: entry.tags, + trust: entry.trustLevel, + setup: entry.setupReadiness, + count: entry.rankScore, + installCountDisplay: entry.installCountDisplay, + sourceRepository: entry.source.repository, + workflowLabel: entry.workflow.label, + authReadiness: entry.authReadiness, + projectBindingReadiness: entry.projectBindingReadiness, + detailHref: `/caplets/${encodeURIComponent(entry.entryKey)}/`, + installCommandText, + installCommandCopyable: entry.installCommand.copyable, + statuses, + }; + return { + ...row, + searchText: [ + row.name, + row.description, + row.tags.join(" "), + row.sourceRepository, + row.workflowLabel, + row.installCommandText, + ] + .join(" ") + .toLowerCase(), + }; +} + +export function catalogSearchRowsFromEntries(entries: CatalogEntryRecord[]): CatalogSearchRow[] { + return entries.map(catalogSearchRowFromEntry); +} diff --git a/apps/catalog/src/lib/suppression.ts b/apps/catalog/src/lib/suppression.ts new file mode 100644 index 00000000..fee4263c --- /dev/null +++ b/apps/catalog/src/lib/suppression.ts @@ -0,0 +1,16 @@ +import type { D1Database } from "@cloudflare/workers-types"; + +export type SuppressionRecord = { + entryKey: string; + reason: string; + suppressedAt: string; +}; + +export async function isSuppressed(db: D1Database | undefined, entryKey: string): Promise { + if (!db) return false; + const record = await db + .prepare("select entry_key from catalog_suppressions where entry_key = ? limit 1") + .bind(entryKey) + .first<{ entry_key: string }>(); + return Boolean(record); +} diff --git a/apps/catalog/src/lib/theme.ts b/apps/catalog/src/lib/theme.ts new file mode 100644 index 00000000..57959048 --- /dev/null +++ b/apps/catalog/src/lib/theme.ts @@ -0,0 +1,31 @@ +export const catalogThemeStorageKey = "caplets.catalog.theme"; +export const catalogThemeSystemQuery = "(prefers-color-scheme: dark)"; + +export const catalogThemePreferences = ["light", "dark", "system"] as const; + +export type CatalogThemePreference = (typeof catalogThemePreferences)[number]; +export type CatalogResolvedTheme = Exclude; + +export function isCatalogThemePreference( + value: string | null | undefined, +): value is CatalogThemePreference { + return value === "light" || value === "dark" || value === "system"; +} + +export function normalizeCatalogThemePreference( + value: string | null | undefined, +): CatalogThemePreference { + return isCatalogThemePreference(value) ? value : "system"; +} + +export function resolveCatalogTheme( + preference: CatalogThemePreference, + systemPrefersDark: boolean, +): CatalogResolvedTheme { + if (preference !== "system") return preference; + return systemPrefersDark ? "dark" : "light"; +} + +export function catalogThemeLabel(preference: CatalogThemePreference): string { + return preference === "light" ? "Light" : preference === "dark" ? "Dark" : "System"; +} diff --git a/apps/catalog/src/middleware.ts b/apps/catalog/src/middleware.ts new file mode 100644 index 00000000..80a9bdec --- /dev/null +++ b/apps/catalog/src/middleware.ts @@ -0,0 +1,8 @@ +import { defineMiddleware } from "astro/middleware"; +import { applySecurityHeaders } from "./lib/catalog-response"; + +export const onRequest = defineMiddleware(async (_context, next) => { + const response = await next(); + applySecurityHeaders(response.headers); + return response; +}); diff --git a/apps/catalog/src/pages/api/v1/catalog/entries/[entryKey].ts b/apps/catalog/src/pages/api/v1/catalog/entries/[entryKey].ts new file mode 100644 index 00000000..936e2044 --- /dev/null +++ b/apps/catalog/src/pages/api/v1/catalog/entries/[entryKey].ts @@ -0,0 +1,17 @@ +import type { APIContext } from "astro"; +import { getCatalogEnv } from "../../../../../lib/catalog-env"; +import { getCatalogEntry } from "../../../../../lib/catalog-store"; +import { jsonResponse, notFound } from "../../../../../lib/catalog-response"; +import { decodeEntryRouteKey } from "../../../../../lib/entry-route"; + +export async function GET(context: APIContext): Promise { + const entryKey = context.params.entryKey + ? decodeEntryRouteKey(context.params.entryKey) + : undefined; + if (!entryKey) { + return notFound(); + } + + const entry = await getCatalogEntry(entryKey, getCatalogEnv()); + return entry ? jsonResponse({ version: 1, entry }) : notFound(); +} diff --git a/apps/catalog/src/pages/api/v1/catalog/index.ts b/apps/catalog/src/pages/api/v1/catalog/index.ts new file mode 100644 index 00000000..ab16685c --- /dev/null +++ b/apps/catalog/src/pages/api/v1/catalog/index.ts @@ -0,0 +1,11 @@ +import type { APIContext } from "astro"; +import { getCatalogEnv } from "../../../../lib/catalog-env"; +import { listCatalogEntries } from "../../../../lib/catalog-store"; +import { jsonResponse } from "../../../../lib/catalog-response"; + +export async function GET(_context: APIContext): Promise { + return jsonResponse({ + version: 1, + entries: await listCatalogEntries(getCatalogEnv()), + }); +} diff --git a/apps/catalog/src/pages/api/v1/catalog/install-signals.ts b/apps/catalog/src/pages/api/v1/catalog/install-signals.ts new file mode 100644 index 00000000..68f10732 --- /dev/null +++ b/apps/catalog/src/pages/api/v1/catalog/install-signals.ts @@ -0,0 +1,36 @@ +import type { APIContext } from "astro"; +import { getCatalogEnv } from "../../../../lib/catalog-env"; +import { acceptInstallSignal, parseInstallSignalRequest } from "../../../../lib/ingest"; +import { jsonResponse } from "../../../../lib/catalog-response"; + +export async function POST(context: APIContext): Promise { + try { + const signal = await parseInstallSignalRequest(context.request); + const result = await acceptInstallSignal({ + signal, + db: getCatalogEnv().CATALOG_DB, + }); + if (result.status === "unavailable") { + return jsonResponse( + { + ok: false, + result, + error: { + code: "indexer_unavailable", + message: "Catalog indexer is unavailable.", + }, + }, + { status: 503, headers: { "cache-control": "no-store" } }, + ); + } + return jsonResponse( + { ok: true, result }, + { status: result.status === "ineligible" ? 202 : 200 }, + ); + } catch { + return jsonResponse( + { ok: false, error: { code: "invalid_request", message: "Invalid catalog signal." } }, + { status: 400 }, + ); + } +} diff --git a/apps/catalog/src/pages/caplets/[entryKey].astro b/apps/catalog/src/pages/caplets/[entryKey].astro new file mode 100644 index 00000000..f0cb5dc7 --- /dev/null +++ b/apps/catalog/src/pages/caplets/[entryKey].astro @@ -0,0 +1,59 @@ +--- +import CatalogHeader from "../../components/CatalogHeader.astro"; +import CapletDetail from "../../components/CapletDetail.astro"; +import ThemeInit from "../../components/ThemeInit.astro"; +import { getCatalogEnv } from "../../lib/catalog-env"; +import { decodeEntryRouteKey } from "../../lib/entry-route"; +import { getCatalogEntry } from "../../lib/catalog-store"; +import "../../styles/starwind.css"; +import "../../styles/catalog.css"; + +export const prerender = false; + +const entryKey = Astro.params.entryKey ? decodeEntryRouteKey(Astro.params.entryKey) : undefined; +const entry = entryKey ? await getCatalogEntry(entryKey, getCatalogEnv()) : undefined; +--- + + + + + + + {entry ? `${entry.name} - Caplets Catalog` : "Caplet unavailable"} + + + + + + + +
+ { + entry ? ( + + ) : ( +
+

Caplet unavailable

+

This catalog entry is unavailable, suppressed, or no longer indexed.

+ Back to search +
+ ) + } +
+
+ + + diff --git a/apps/catalog/src/pages/index.astro b/apps/catalog/src/pages/index.astro new file mode 100644 index 00000000..a62b8ca4 --- /dev/null +++ b/apps/catalog/src/pages/index.astro @@ -0,0 +1,62 @@ +--- +import CatalogHeader from "../components/CatalogHeader.astro"; +import SearchShell from "../components/SearchShell.astro"; +import ThemeInit from "../components/ThemeInit.astro"; +import { getCatalogEnv } from "../lib/catalog-env"; +import { listCatalogEntries } from "../lib/catalog-store"; +import "../styles/starwind.css"; +import "../styles/catalog.css"; + +export const prerender = false; + +const entries = await listCatalogEntries(getCatalogEnv()); +--- + + + + + + + Caplets Catalog + + + + + + + +
+
+
+

+ Find a capability before you install it. +

+

+ Search indexed Caplets, inspect source content, check setup readiness, and treat install counts as popularity only. +

+
+
+ Public catalog entries are not security reviews. + Examine the Caplet and source before installing, especially for community entries. +
+
+
+ +
+
+
+ + + diff --git a/apps/catalog/src/scripts/copy.ts b/apps/catalog/src/scripts/copy.ts new file mode 100644 index 00000000..b749b651 --- /dev/null +++ b/apps/catalog/src/scripts/copy.ts @@ -0,0 +1,29 @@ +const copyStatus = document.querySelector("[data-copy-status]") as HTMLElement | null; + +function announceCopyStatus(message: string): void { + if (!copyStatus) return; + copyStatus.textContent = ""; + window.setTimeout(() => { + copyStatus.textContent = message; + }, 0); +} + +async function copyCommand(button: HTMLButtonElement): Promise { + const command = button.dataset.copyCommand ?? ""; + try { + await navigator.clipboard.writeText(command); + announceCopyStatus("Install command copied."); + } catch { + announceCopyStatus("Copy failed. Select the command text manually."); + } +} + +document.addEventListener("click", (event) => { + const button = (event.target as Element | null)?.closest( + "[data-copy-command]", + ); + if (!button) return; + void copyCommand(button); +}); + +export {}; diff --git a/apps/catalog/src/scripts/search.ts b/apps/catalog/src/scripts/search.ts new file mode 100644 index 00000000..a30b73db --- /dev/null +++ b/apps/catalog/src/scripts/search.ts @@ -0,0 +1,5 @@ +import { initVirtualCatalogSearch } from "./virtual-results"; + +initVirtualCatalogSearch(); + +export {}; diff --git a/apps/catalog/src/scripts/theme-init.ts b/apps/catalog/src/scripts/theme-init.ts new file mode 100644 index 00000000..484fbab6 --- /dev/null +++ b/apps/catalog/src/scripts/theme-init.ts @@ -0,0 +1,21 @@ +import { + catalogThemeStorageKey, + catalogThemeSystemQuery, + normalizeCatalogThemePreference, + resolveCatalogTheme, +} from "../lib/theme"; + +try { + const preference = normalizeCatalogThemePreference(localStorage.getItem(catalogThemeStorageKey)); + const theme = resolveCatalogTheme(preference, window.matchMedia(catalogThemeSystemQuery).matches); + document.documentElement.classList.toggle("dark", theme === "dark"); + document.documentElement.dataset.themePreference = preference; + document.documentElement.style.colorScheme = theme; +} catch { + const theme = window.matchMedia(catalogThemeSystemQuery).matches ? "dark" : "light"; + document.documentElement.classList.toggle("dark", theme === "dark"); + document.documentElement.dataset.themePreference = "system"; + document.documentElement.style.colorScheme = theme; +} + +export {}; diff --git a/apps/catalog/src/scripts/theme.ts b/apps/catalog/src/scripts/theme.ts new file mode 100644 index 00000000..6e331035 --- /dev/null +++ b/apps/catalog/src/scripts/theme.ts @@ -0,0 +1,135 @@ +import { + catalogThemeLabel, + catalogThemeStorageKey, + catalogThemeSystemQuery, + isCatalogThemePreference, + normalizeCatalogThemePreference, + resolveCatalogTheme, + type CatalogThemePreference, +} from "../lib/theme"; + +const query = window.matchMedia(catalogThemeSystemQuery); +const root = document.querySelector("[data-theme-menu-root]") as HTMLElement | null; +const trigger = document.querySelector("[data-theme-trigger]") as HTMLButtonElement | null; +const menu = document.querySelector("[data-theme-menu]") as HTMLElement | null; +const label = document.querySelector("[data-theme-label]") as HTMLElement | null; +const controls = Array.from( + document.querySelectorAll("[data-theme-option]"), +) as HTMLButtonElement[]; +const currentIcons = Array.from( + document.querySelectorAll("[data-theme-current-icon]"), +) as HTMLElement[]; + +function getStoredPreference(): CatalogThemePreference { + try { + return normalizeCatalogThemePreference(localStorage.getItem(catalogThemeStorageKey)); + } catch { + return "system"; + } +} + +function storePreference(preference: CatalogThemePreference): void { + try { + localStorage.setItem(catalogThemeStorageKey, preference); + } catch { + // Storage can be unavailable in hardened browser contexts; keep the in-page selection usable. + } +} + +function applyTheme(preference: CatalogThemePreference): void { + const theme = resolveCatalogTheme(preference, query.matches); + document.documentElement.classList.toggle("dark", theme === "dark"); + document.documentElement.dataset.themePreference = preference; + document.documentElement.style.colorScheme = theme; + + for (const control of controls) { + const selected = control.dataset.themeOption === preference; + control.setAttribute("aria-checked", String(selected)); + if (selected && label) + label.textContent = control.dataset.themeLabelValue ?? catalogThemeLabel(preference); + } + + for (const icon of currentIcons) { + icon.hidden = icon.dataset.themeCurrentIcon !== preference; + } +} + +function setMenuOpen(open: boolean): void { + if (!trigger || !menu) return; + trigger.setAttribute("aria-expanded", String(open)); + menu.hidden = !open; +} + +function isMenuOpen(): boolean { + return trigger?.getAttribute("aria-expanded") === "true"; +} + +function focusOption(index: number): void { + controls.at(index)?.focus(); +} + +function focusedOptionIndex(): number { + return controls.findIndex((control) => control === document.activeElement); +} + +trigger?.addEventListener("click", () => { + setMenuOpen(!isMenuOpen()); +}); + +trigger?.addEventListener("keydown", (event) => { + if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return; + event.preventDefault(); + setMenuOpen(true); + focusOption(event.key === "ArrowDown" ? 0 : controls.length - 1); +}); + +for (const control of controls) { + control.addEventListener("click", () => { + const preference = control.dataset.themeOption ?? null; + if (!isCatalogThemePreference(preference)) return; + storePreference(preference); + applyTheme(preference); + setMenuOpen(false); + trigger?.focus(); + }); + + control.addEventListener("keydown", (event) => { + const currentIndex = focusedOptionIndex(); + if (event.key === "ArrowDown") { + event.preventDefault(); + focusOption((currentIndex + 1) % controls.length); + } + if (event.key === "ArrowUp") { + event.preventDefault(); + focusOption((currentIndex - 1 + controls.length) % controls.length); + } + if (event.key === "Home") { + event.preventDefault(); + focusOption(0); + } + if (event.key === "End") { + event.preventDefault(); + focusOption(controls.length - 1); + } + }); +} + +document.addEventListener("click", (event) => { + if (!isMenuOpen() || root?.contains(event.target as Node)) return; + setMenuOpen(false); +}); + +document.addEventListener("keydown", (event) => { + if (event.key !== "Escape" || !isMenuOpen()) return; + setMenuOpen(false); + trigger?.focus(); +}); + +query.addEventListener("change", () => { + const preference = getStoredPreference(); + if (preference === "system") applyTheme(preference); +}); + +applyTheme(getStoredPreference()); + +export {}; diff --git a/apps/catalog/src/scripts/virtual-results.ts b/apps/catalog/src/scripts/virtual-results.ts new file mode 100644 index 00000000..22588f24 --- /dev/null +++ b/apps/catalog/src/scripts/virtual-results.ts @@ -0,0 +1,244 @@ +import { + AlertCircleIcon, + ComputerUserIcon, + Copy01Icon, + DatabaseSyncIcon, + Key01Icon, + Link01Icon, + Settings02Icon, + Shield01Icon, +} from "@hugeicons/core-free-icons"; +import { + observeWindowOffset, + observeWindowRect, + Virtualizer, + windowScroll, + type VirtualItem, +} from "@tanstack/virtual-core"; +import { filterCatalogSearchRecords, type CatalogSearchFilters } from "../lib/search-filter"; +import type { CatalogSearchRow, CatalogSearchStatusCode } from "../lib/search-row"; + +type IconNode = readonly [string, Readonly>]; +type IconSvgObject = readonly IconNode[]; + +const rowHeight = 72; +const mobileRowHeight = 148; +const overscan = 8; +const statusIcons: Record = { + unverified_community: Shield01Icon, + local_control: ComputerUserIcon, + mutating_saas: DatabaseSyncIcon, + auth_required: Key01Icon, + setup_required: Settings02Icon, + project_binding_required: Link01Icon, + readiness_unknown: AlertCircleIcon, + vault_required: Key01Icon, +}; + +export type VirtualCatalogSearch = { + applySearch(): void; + destroy(): void; + renderedRowCount(): number; +}; + +export function initVirtualCatalogSearch( + root: Document | HTMLElement = document, +): VirtualCatalogSearch | undefined { + const shell = root.querySelector("[data-search-shell]") as HTMLElement | null; + const input = root.querySelector("[data-search-input]") as HTMLInputElement | null; + const resultList = root.querySelector("[data-result-list]") as HTMLElement | null; + const resultSpacer = root.querySelector("[data-result-spacer]") as HTMLElement | null; + const resultStatus = root.querySelector("[data-result-status]") as HTMLElement | null; + const emptyState = root.querySelector("[data-empty-state]") as HTMLElement | null; + const reset = root.querySelector("[data-reset-search]") as HTMLButtonElement | null; + const trust = root.querySelector('[data-filter="trust"]') as HTMLSelectElement | null; + const setup = root.querySelector('[data-filter="setup"]') as HTMLSelectElement | null; + const tag = root.querySelector('[data-filter="tag"]') as HTMLSelectElement | null; + const sort = root.querySelector("[data-sort]") as HTMLSelectElement | null; + const index = root.querySelector("[data-search-index]") as HTMLScriptElement | null; + if (!shell || !input || !resultList || !resultSpacer || !resultStatus || !emptyState || !index) { + return undefined; + } + + const inputEl = input; + const resultListEl = resultList; + const resultSpacerEl = resultSpacer; + const resultStatusEl = resultStatus; + const emptyStateEl = emptyState; + const rows = parseRows(index.textContent ?? "[]"); + let visibleRows = [...rows]; + let lastFocusedControl: HTMLElement | null = null; + const virtualizer = new Virtualizer({ + count: visibleRows.length, + getScrollElement: () => window, + estimateSize: () => + window.matchMedia("(max-width: 680px)").matches ? mobileRowHeight : rowHeight, + overscan, + scrollToFn: windowScroll, + observeElementRect: observeWindowRect, + observeElementOffset: observeWindowOffset, + getItemKey: (index) => visibleRows[index]?.id ?? index, + onChange: () => renderVirtualRows(), + }); + const cleanupVirtualizer = virtualizer._didMount(); + virtualizer._willUpdate(); + + function controls(): Array { + return [inputEl, trust, setup, tag, sort]; + } + + function filters(): CatalogSearchFilters { + return { + query: inputEl.value, + trust: trust?.value ?? "all", + setup: setup?.value ?? "all", + tag: tag?.value ?? "all", + sort: sort?.value === "name" ? "name" : "rank", + }; + } + + function applyUrlState(): void { + const params = new URLSearchParams(window.location.search); + inputEl.value = params.get("q") ?? ""; + if (trust && params.has("scope")) trust.value = params.get("scope") ?? "all"; + if (setup && params.has("setup")) setup.value = params.get("setup") ?? "all"; + if (tag && params.has("tag")) tag.value = params.get("tag") ?? "all"; + if (sort && params.has("sort")) sort.value = params.get("sort") === "name" ? "name" : "rank"; + } + + function writeUrlState(): void { + const params = new URLSearchParams(); + if (inputEl.value.trim()) params.set("q", inputEl.value.trim()); + if (trust && trust.value !== "all") params.set("scope", trust.value); + if (setup && setup.value !== "all") params.set("setup", setup.value); + if (tag && tag.value !== "all") params.set("tag", tag.value); + if (sort && sort.value !== "rank") params.set("sort", sort.value); + const query = params.toString(); + const next = `${window.location.pathname}${query ? `?${query}` : ""}${window.location.hash}`; + window.history.replaceState(null, "", next); + } + + function applySearch(options: { writeUrl?: boolean; resetScroll?: boolean } = {}): void { + lastFocusedControl = + document.activeElement instanceof HTMLElement ? document.activeElement : null; + visibleRows = filterCatalogSearchRecords(rows, filters()) as CatalogSearchRow[]; + virtualizer.setOptions({ ...virtualizer.options, count: visibleRows.length }); + resultStatusEl.textContent = `${visibleRows.length} ${visibleRows.length === 1 ? "Caplet" : "Caplets"}`; + emptyStateEl.hidden = visibleRows.length > 0; + resultSpacerEl.style.height = `${Math.max(virtualizer.getTotalSize(), visibleRows.length ? rowHeight : 1)}px`; + if (options.writeUrl !== false) writeUrlState(); + if (options.resetScroll !== false) virtualizer.scrollToIndex(0, { align: "start" }); + renderVirtualRows(); + if ( + lastFocusedControl && + controls().includes(lastFocusedControl as HTMLInputElement | HTMLSelectElement) + ) { + lastFocusedControl.focus(); + } + } + + function renderVirtualRows(): void { + const items = virtualizer.getVirtualItems(); + resultSpacerEl.style.height = `${Math.max(virtualizer.getTotalSize(), visibleRows.length ? rowHeight : 1)}px`; + resultListEl.replaceChildren(...items.map((item) => renderRow(item, visibleRows[item.index]))); + } + + for (const control of controls()) { + control?.addEventListener("input", () => applySearch()); + control?.addEventListener("change", () => applySearch()); + } + + reset?.addEventListener("click", () => { + inputEl.value = ""; + for (const select of [trust, setup, tag]) { + if (select) select.value = "all"; + } + if (sort) sort.value = "rank"; + applySearch(); + inputEl.focus(); + }); + + window.addEventListener("popstate", () => { + applyUrlState(); + applySearch({ writeUrl: false }); + }); + + window.addEventListener("resize", () => { + virtualizer.measure(); + renderVirtualRows(); + }); + + applyUrlState(); + applySearch({ writeUrl: false, resetScroll: false }); + + return { + applySearch, + destroy() { + cleanupVirtualizer(); + }, + renderedRowCount() { + return resultListEl.querySelectorAll("[data-result-row]").length; + }, + }; +} + +function parseRows(value: string): CatalogSearchRow[] { + const parsed = JSON.parse(value) as CatalogSearchRow[]; + return Array.isArray(parsed) ? parsed : []; +} + +function renderRow(item: VirtualItem, row: CatalogSearchRow | undefined): HTMLElement { + const element = document.createElement("article"); + element.className = "catalog-result-row"; + element.role = "row"; + element.dataset.resultRow = ""; + element.dataset.index = String(item.index); + element.style.transform = `translateY(${item.start}px)`; + if (!row) return element; + element.innerHTML = ` +
+
+ ${escapeHtml(row.name)} + ${escapeHtml(row.trust)} +
+
+

${escapeHtml(row.description)}

+
${escapeHtml(row.installCountDisplay)}
+
+ ${escapeHtml(row.installCommandText)} + ${row.installCommandCopyable ? `` : `Copy unavailable`} +
+
+ ${row.statuses.map((status) => `${renderIcon(statusIcons[status.code] ?? AlertCircleIcon, status.label)}`).join("")} +
+ `; + return element; +} + +function renderIcon(icon: IconSvgObject, label: string): string { + return `${icon + .map( + ([, attrs]) => + ` key !== "key") + .map(([key, value]) => `${dashAttr(key)}="${escapeAttribute(String(value))}"`) + .join(" ")} />`, + ) + .join("")}`; +} + +function dashAttr(value: string): string { + return value.replace(/[A-Z]/g, (match) => `-${match.toLowerCase()}`); +} + +function escapeHtml(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +function escapeAttribute(value: string): string { + return escapeHtml(value).replace(/'/g, "'"); +} diff --git a/apps/catalog/src/styles/catalog.css b/apps/catalog/src/styles/catalog.css new file mode 100644 index 00000000..0385d7d3 --- /dev/null +++ b/apps/catalog/src/styles/catalog.css @@ -0,0 +1,426 @@ +.catalog-search { + --catalog-header-sticky-offset: 4.25rem; +} + +.skip-link { + position: fixed; + z-index: 60; + top: 1rem; + left: 1rem; + transform: translateY(-150%); + border-radius: 0.5rem; + background: var(--foreground); + color: var(--background); + padding: 0.65rem 0.85rem; + font-weight: 700; + transition: transform 160ms ease; +} + +.skip-link:focus { + transform: translateY(0); +} + +.copy-status { + position: fixed; + z-index: 50; + right: 1rem; + bottom: 1rem; + pointer-events: none; + max-width: min(24rem, calc(100vw - 2rem)); + border: 1px solid var(--border); + border-radius: 0.625rem; + background: var(--popover); + color: var(--popover-foreground); + padding: 0.75rem 1rem; + font-size: 0.875rem; + opacity: 0; + transform: translateY(0.5rem); + transition: + opacity 160ms ease, + transform 160ms ease; +} + +.copy-status:not(:empty) { + opacity: 1; + transform: translateY(0); +} + +.markdown-body { + max-width: 74ch; +} + +.markdown-body h1, +.markdown-body h2, +.markdown-body h3 { + margin: 1.2em 0 0.4em; + line-height: 1.18; + text-wrap: balance; +} + +.markdown-body h1:first-child { + margin-top: 0; +} + +.markdown-body p { + text-wrap: pretty; +} + +.markdown-body pre, +.markdown-body code, +.record-panel code { + font-family: var(--font-mono); +} + +.markdown-body pre { + overflow-x: auto; + border-radius: 0.625rem; + background: var(--code-surface); + color: var(--code-ink); + padding: 0.875rem; +} + +.catalog-search { + overflow: clip; + border: 1px solid var(--border); + border-radius: 0.625rem; + background: var(--card); + color: var(--card-foreground); +} + +.catalog-search__toolbar { + position: sticky; + z-index: 20; + top: var(--catalog-header-sticky-offset); + display: grid; + gap: 0.65rem; + border-bottom: 1px solid var(--border); + background: color-mix(in oklch, var(--card) 94%, var(--background)); + padding: 0.75rem; +} + +.catalog-search__topline { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 0.75rem; + align-items: end; +} + +.catalog-search__field { + display: grid; + gap: 0.4rem; + min-width: 0; +} + +.catalog-search__input { + min-height: 2.65rem; + width: 100%; + border-color: var(--border); + border-radius: 0.5rem; + background: var(--card); + color: var(--card-foreground); + padding: 0 0.85rem; + font-size: 1rem; +} + +.catalog-search__input::placeholder { + color: var(--muted-foreground); +} + +.catalog-search__input:focus { + border-color: var(--outline); + box-shadow: 0 0 0 3px color-mix(in oklch, var(--outline), transparent 70%); +} + +.catalog-search__status { + min-width: max-content; + color: var(--muted-foreground); + font-family: var(--font-mono); + font-size: 0.75rem; + font-weight: 700; +} + +.catalog-filterbar { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 0.55rem; +} + +.catalog-filterbar label { + display: grid; + gap: 0.3rem; +} + +.catalog-filterbar select { + min-height: 2.35rem; + width: 100%; + border-color: var(--border); + border-radius: 0.5rem; + background: var(--card); + color: var(--card-foreground); + font-size: 0.9rem; +} + +.catalog-filterbar select:focus { + border-color: var(--outline); + box-shadow: 0 0 0 3px color-mix(in oklch, var(--outline), transparent 70%); +} + +.catalog-search__legend { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; +} + +.catalog-search__legend-item { + display: inline-flex; + min-height: 1.75rem; + align-items: center; + gap: 0.3rem; + border: 1px solid var(--border); + border-radius: 999px; + background: var(--background); + color: var(--muted-foreground); + padding: 0.25rem 0.45rem; + font-family: var(--font-mono); + font-size: 0.7rem; + font-weight: 700; +} + +.catalog-result-grid { + overflow: visible; +} + +.catalog-result-grid__header, +.catalog-result-row { + display: grid; + grid-template-columns: minmax(10rem, 1.2fr) minmax(14rem, 1.7fr) 5rem minmax(12rem, 1.1fr) 7.5rem; + gap: 0.75rem; + align-items: center; +} + +.catalog-result-grid__header { + border-bottom: 1px solid var(--border); + background: var(--muted); + color: var(--muted-foreground); + padding: 0.45rem 0.9rem; + font-family: var(--font-mono); + font-size: 0.72rem; + font-weight: 700; +} + +.catalog-result-grid__spacer { + position: relative; + min-height: 1px; +} + +.catalog-result-grid__rows { + position: absolute; + inset: 0 0 auto; +} + +.catalog-result-row { + position: absolute; + left: 0; + right: 0; + min-height: 72px; + border-bottom: 1px solid var(--border); + padding: 0.55rem 0.9rem; + transition: background-color 160ms ease; +} + +.catalog-result-row:hover { + background: var(--muted); +} + +.catalog-result-row__name, +.catalog-result-row__command { + min-width: 0; +} + +.catalog-result-row__heading { + display: flex; + min-width: 0; + align-items: center; + gap: 0.35rem; +} + +.catalog-result-row__title { + display: block; + min-width: 0; + overflow: hidden; + color: var(--foreground); + font-weight: 700; + text-overflow: ellipsis; + white-space: nowrap; + text-decoration: none; +} + +.catalog-result-row__title:hover { + color: var(--primary-accent); +} + +.catalog-result-row__title:focus-visible, +.catalog-result-row__copy:focus-visible { + outline: 2px solid var(--outline); + outline-offset: 2px; +} + +.catalog-result-row__trust, +.catalog-result-row__installs, +.catalog-result-row__copy-unavailable { + color: var(--muted-foreground); + font-family: var(--font-mono); + font-size: 0.72rem; + font-weight: 700; +} + +.catalog-result-row__trust { + flex: 0 0 auto; + border-radius: 999px; + background: var(--success); + color: var(--success-foreground); + padding: 0.12rem 0.4rem; +} + +.catalog-result-row__description { + display: -webkit-box; + overflow: hidden; + margin: 0; + color: var(--muted-foreground); + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} + +.catalog-result-row__command { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 0.35rem; + align-items: center; +} + +.catalog-result-row__command code { + overflow: hidden; + border: 1px solid color-mix(in oklch, var(--code-ink), var(--code-surface) 70%); + border-radius: 0.45rem; + background: var(--code-surface); + color: var(--code-ink); + padding: 0.45rem 0.55rem; + font-family: var(--font-mono); + font-size: 0.78rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +.catalog-result-row__copy { + display: inline-grid; + width: 2rem; + height: 2rem; + place-items: center; + border: 1px solid var(--border); + border-radius: 0.45rem; + background: var(--card); + color: var(--foreground); +} + +.catalog-result-row__copy:hover { + background: var(--muted); +} + +.catalog-result-row__statuses { + display: flex; + flex-wrap: wrap; + gap: 0.25rem; + justify-content: flex-end; +} + +.catalog-result-row__status { + display: inline-grid; + width: 1.75rem; + height: 1.75rem; + place-items: center; + border: 1px solid var(--border); + border-radius: 999px; + background: var(--background); + color: var(--muted-foreground); +} + +.catalog-result-row__status--danger { + background: var(--error); + color: var(--error-foreground); +} + +.catalog-result-row__status--caution { + background: var(--warning); + color: var(--warning-foreground); +} + +.catalog-result-row__status--info { + background: var(--info); + color: var(--info-foreground); +} + +@media (max-width: 900px) { + .catalog-filterbar { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .catalog-result-grid__header { + display: none; + } + + .catalog-result-row { + grid-template-columns: minmax(0, 1fr) auto; + grid-template-areas: + "name installs" + "description statuses" + "command command"; + min-height: 148px; + align-items: center; + } + + .catalog-result-row__name { + grid-area: name; + } + + .catalog-result-row__description { + grid-area: description; + } + + .catalog-result-row__command { + grid-area: command; + } + + .catalog-result-row__installs { + grid-area: installs; + } + + .catalog-result-row__statuses { + grid-area: statuses; + justify-self: end; + } +} + +@media (max-width: 640px) { + .catalog-search { + --catalog-header-sticky-offset: 7.75rem; + } + + .catalog-search__topline, + .catalog-filterbar { + grid-template-columns: 1fr; + } + + .catalog-search__legend-item span { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; + } + + .catalog-result-row { + gap: 0.45rem; + padding-inline: 0.7rem; + } +} diff --git a/apps/catalog/src/styles/starwind.css b/apps/catalog/src/styles/starwind.css new file mode 100644 index 00000000..8245da0f --- /dev/null +++ b/apps/catalog/src/styles/starwind.css @@ -0,0 +1,178 @@ +@import "tailwindcss"; +@import "tw-animate-css"; + +@plugin "@tailwindcss/forms"; + +@custom-variant dark (&:where(.dark, .dark *)); + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-primary-accent: var(--primary-accent); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-secondary-accent: var(--secondary-accent); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-info: var(--info); + --color-info-foreground: var(--info-foreground); + --color-success: var(--success); + --color-success-foreground: var(--success-foreground); + --color-warning: var(--warning); + --color-warning-foreground: var(--warning-foreground); + --color-error: var(--error); + --color-error-foreground: var(--error-foreground); + --color-border: var(--border); + --color-input: var(--input); + --color-outline: var(--outline); + --radius-xs: calc(var(--radius) - 0.375rem); + --radius-sm: calc(var(--radius) - 0.25rem); + --radius-md: calc(var(--radius) - 0.125rem); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 0.25rem); + --radius-2xl: calc(var(--radius) + 0.5rem); + --font-sans: var(--font-sans); + --font-mono: var(--font-mono); +} + +:root { + color-scheme: light; + --ember: oklch(62% 0.17 37); + --ember-quiet: oklch(86% 0.07 45); + --linen: oklch(97% 0.018 86); + --paper: oklch(99% 0.014 88); + --parchment: oklch(93% 0.045 82); + --ash: oklch(84% 0.032 80); + --ink: oklch(23% 0.025 98); + --muted-olive: oklch(46% 0.035 110); + --danger: oklch(48% 0.16 31); + --success-tone: oklch(45% 0.11 145); + --radius: 0.625rem; + --font-sans: + Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; + + --background: var(--linen); + --foreground: var(--ink); + --card: var(--paper); + --card-foreground: var(--ink); + --popover: var(--paper); + --popover-foreground: var(--ink); + --primary: var(--ink); + --primary-foreground: var(--paper); + --primary-accent: var(--ember); + --secondary: var(--parchment); + --secondary-foreground: var(--ink); + --secondary-accent: var(--ember-quiet); + --muted: color-mix(in oklch, var(--paper) 70%, var(--parchment)); + --muted-foreground: var(--muted-olive); + --accent: var(--parchment); + --accent-foreground: var(--ink); + --info: color-mix(in oklch, var(--paper) 76%, var(--ember-quiet)); + --info-foreground: var(--ink); + --success: color-mix(in oklch, var(--success-tone), var(--paper) 82%); + --success-foreground: oklch(29% 0.08 145); + --warning: color-mix(in oklch, var(--parchment), var(--ember-quiet) 35%); + --warning-foreground: var(--ink); + --error: color-mix(in oklch, var(--danger), var(--paper) 82%); + --error-foreground: oklch(36% 0.14 31); + --border: var(--ash); + --input: var(--ash); + --outline: oklch(62% 0.17 37 / 0.38); + --code-surface: oklch(21% 0.025 98); + --code-ink: var(--paper); +} + +.dark { + color-scheme: dark; + --ember: oklch(73% 0.16 42); + --ember-quiet: oklch(33% 0.08 45); + --linen: oklch(17% 0.022 92); + --paper: oklch(21% 0.02 92); + --parchment: oklch(28% 0.035 82); + --ash: oklch(39% 0.035 82); + --ink: oklch(92% 0.018 86); + --muted-olive: oklch(75% 0.035 88); + --danger: oklch(76% 0.16 31); + --success-tone: oklch(75% 0.12 145); + + --background: var(--linen); + --foreground: var(--ink); + --card: var(--paper); + --card-foreground: var(--ink); + --popover: color-mix(in oklch, var(--paper) 90%, black); + --popover-foreground: var(--ink); + --primary: var(--ink); + --primary-foreground: var(--linen); + --secondary: var(--parchment); + --secondary-foreground: var(--ink); + --muted: color-mix(in oklch, var(--paper) 76%, var(--ash)); + --muted-foreground: var(--muted-olive); + --accent: var(--parchment); + --accent-foreground: var(--ink); + --info: color-mix(in oklch, var(--paper) 80%, var(--ember)); + --info-foreground: var(--ink); + --success: oklch(31% 0.065 145); + --success-foreground: oklch(88% 0.085 145); + --warning: color-mix(in oklch, var(--parchment), var(--ember) 18%); + --warning-foreground: var(--ink); + --error: oklch(31% 0.08 31); + --error-foreground: oklch(90% 0.08 31); + --border: var(--ash); + --input: var(--ash); + --outline: oklch(73% 0.16 42 / 0.42); + --code-surface: oklch(12% 0.02 92); + --code-ink: oklch(93% 0.014 88); +} + +@layer base { + * { + @apply border-border outline-outline/50; + } + + html { + @apply scroll-smooth bg-background text-foreground; + } + + body { + @apply min-h-screen min-w-80 bg-background font-sans text-[15px] leading-relaxed text-foreground antialiased; + margin: 0; + } + + a { + @apply text-inherit; + } + + button, + select { + @apply cursor-pointer; + } + + button, + input, + select { + font: inherit; + } + + [hidden] { + display: none !important; + } + + @media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + scroll-behavior: auto !important; + transition-duration: 1ms !important; + animation-duration: 1ms !important; + } + } +} diff --git a/apps/catalog/starwind.config.json b/apps/catalog/starwind.config.json new file mode 100644 index 00000000..870880e8 --- /dev/null +++ b/apps/catalog/starwind.config.json @@ -0,0 +1,28 @@ +{ + "$schema": "https://starwind.dev/config-schema.json", + "tailwind": { + "css": "src/styles/starwind.css", + "baseColor": "neutral", + "cssVariables": true + }, + "componentDir": "src/components", + "utilsDir": "src/lib/utils", + "components": [ + { + "name": "button", + "version": "2.3.3" + }, + { + "name": "badge", + "version": "1.4.3" + }, + { + "name": "card", + "version": "2.0.2" + }, + { + "name": "table", + "version": "1.1.2" + } + ] +} diff --git a/apps/catalog/test/catalog-api.test.ts b/apps/catalog/test/catalog-api.test.ts new file mode 100644 index 00000000..5065a578 --- /dev/null +++ b/apps/catalog/test/catalog-api.test.ts @@ -0,0 +1,61 @@ +import type { D1Database } from "@cloudflare/workers-types"; +import { describe, expect, it } from "vitest"; +import { getCatalogEntry, listCatalogEntries } from "../src/lib/catalog-store"; + +describe("catalog read model", () => { + it("serves official entries with low-count display and generated install commands", async () => { + const entries = await listCatalogEntries(); + const github = entries.find((entry) => entry.id === "github"); + + expect(github).toMatchObject({ + installCount: 0, + installCountDisplay: "<10", + installCommand: { + text: "caplets install spiritledsoftware/caplets github", + copyable: true, + }, + trustLevel: "official", + }); + expect(await getCatalogEntry(github?.entryKey ?? "")).toMatchObject({ id: "github" }); + }); + + it("hides suppressed entries from list and detail reads", async () => { + const githubEntryKey = "github:spiritledsoftware:caplets:github%2Fcaplet.md:github"; + const entries = await listCatalogEntries({ + CATALOG_DB: fakeD1([githubEntryKey]), + }); + expect(entries.some((entry) => entry.id === "github")).toBe(false); + await expect( + getCatalogEntry(githubEntryKey, { + CATALOG_DB: fakeD1([githubEntryKey]), + }), + ).resolves.toBeUndefined(); + }); +}); + +function fakeD1(suppressedEntryKeys: string[]) { + return { + prepare(sql: string) { + return { + bind(...values: unknown[]) { + return { + first: async () => { + if (sql.includes("catalog_suppressions")) { + return suppressedEntryKeys.includes(String(values[0])) + ? { entry_key: values[0] } + : null; + } + return null; + }, + }; + }, + all: async () => { + if (sql.includes("catalog_suppressions")) { + return { results: suppressedEntryKeys.map((entryKey) => ({ entryKey })) }; + } + return { results: [] }; + }, + }; + }, + } as unknown as D1Database; +} diff --git a/apps/catalog/test/fixtures/catalog-search-rows.ts b/apps/catalog/test/fixtures/catalog-search-rows.ts new file mode 100644 index 00000000..d1b281bd --- /dev/null +++ b/apps/catalog/test/fixtures/catalog-search-rows.ts @@ -0,0 +1,42 @@ +import type { CatalogSearchRow } from "../../src/lib/search-row"; + +type CatalogSearchRowFixture = Partial & Pick; + +export function catalogSearchRowFixture(input: CatalogSearchRowFixture): CatalogSearchRow { + const description = input.description ?? `${input.name} test description`; + const tags = input.tags ?? []; + const installCommandText = + input.installCommandText ?? `caplets install spiritledsoftware/caplets ${input.id}`; + return { + description, + tags, + trust: "official", + setup: "ready", + count: 0, + installCountDisplay: "<10", + sourceRepository: "spiritledsoftware/caplets", + workflowLabel: "MCP server", + authReadiness: "ready", + projectBindingReadiness: "ready", + detailHref: `/caplets/${encodeURIComponent(input.id)}/`, + installCommandText, + installCommandCopyable: true, + statuses: [], + searchText: [input.name, description, tags.join(" "), installCommandText] + .join(" ") + .toLowerCase(), + ...input, + }; +} + +export function manyCatalogSearchRows(count: number): CatalogSearchRow[] { + return Array.from({ length: count }, (_, index) => + catalogSearchRowFixture({ + id: `caplet-${index}`, + name: `Caplet ${index}`, + description: `Compact row ${index} for virtual rendering checks.`, + tags: index % 2 === 0 ? ["even", "mcp"] : ["odd", "cli"], + count: count - index, + }), + ); +} diff --git a/apps/catalog/test/fixtures/cloudflare-workers.ts b/apps/catalog/test/fixtures/cloudflare-workers.ts new file mode 100644 index 00000000..1c60631f --- /dev/null +++ b/apps/catalog/test/fixtures/cloudflare-workers.ts @@ -0,0 +1 @@ +export const env = {}; diff --git a/apps/catalog/test/ingest.test.ts b/apps/catalog/test/ingest.test.ts new file mode 100644 index 00000000..1cf8f5ce --- /dev/null +++ b/apps/catalog/test/ingest.test.ts @@ -0,0 +1,288 @@ +import type { D1Database } from "@cloudflare/workers-types"; +import type { APIContext } from "astro"; +import { describe, expect, it } from "vitest"; +import { acceptInstallSignal, parseInstallSignalRequest } from "../src/lib/ingest"; +import { POST as postInstallSignal } from "../src/pages/api/v1/catalog/install-signals"; + +describe("catalog install signal ingestion", () => { + it("accepts revision-bound public GitHub signals without echoing raw private values", async () => { + const db = fakeD1(); + await expect( + acceptInstallSignal({ + db, + signal: { + source: "https://github.com/Community/Tools.git", + capletId: "deploy", + sourcePath: "caplets/deploy/CAPLET.md", + resolvedRevision: "abc123", + }, + }), + ).resolves.toEqual({ + status: "accepted", + entryKey: "github:community:tools:caplets%2Fdeploy%2Fcaplet.md:deploy", + }); + expect(db.executedWrites.length).toBeGreaterThan(0); + }); + + it("skips private or unpinned signals with categorical statuses only", async () => { + await expect( + acceptInstallSignal({ + signal: { + source: "../private", + capletId: "secret", + sourcePath: "secret/CAPLET.md", + resolvedRevision: "abc123", + }, + }), + ).resolves.toEqual({ status: "ineligible" }); + + await expect( + acceptInstallSignal({ + signal: { + source: "community/tools", + capletId: "deploy", + sourcePath: "caplets/deploy/CAPLET.md", + }, + }), + ).resolves.toEqual({ status: "revision_unavailable" }); + }); + + it("enforces body size without trusting content-length", async () => { + const body = JSON.stringify({ + source: "community/tools", + capletId: "deploy", + sourcePath: "caplets/deploy/CAPLET.md", + resolvedRevision: "abc123", + padding: "x".repeat(16 * 1024), + }); + + await expect( + parseInstallSignalRequest( + new Request("https://catalog.caplets.dev/api/v1/catalog/install-signals", { + method: "POST", + body, + }), + ), + ).rejects.toThrow("request_body_too_large"); + }); + + it("does not count suppressed entries", async () => { + const db = fakeD1({ + suppressedEntryKeys: new Set(["github:community:tools:caplets%2Fdeploy%2Fcaplet.md:deploy"]), + }); + + await expect( + acceptInstallSignal({ + db, + signal: { + source: "community/tools", + capletId: "deploy", + sourcePath: "caplets/deploy/CAPLET.md", + resolvedRevision: "abc123", + }, + }), + ).resolves.toEqual({ status: "ineligible" }); + expect(db.executedWrites).toHaveLength(0); + }); + + it("does not let direct signals create community records for official Caplets", async () => { + const db = fakeD1(); + + await expect( + acceptInstallSignal({ + db, + signal: { + source: "spiritledsoftware/caplets", + capletId: "github", + sourcePath: "caplets/github/CAPLET.md", + resolvedRevision: "abc123", + }, + }), + ).resolves.toEqual({ + status: "already_current", + entryKey: "github:spiritledsoftware:caplets:caplets%2Fgithub%2Fcaplet.md:github", + }); + expect(db.executedWrites).toHaveLength(0); + }); + + it("persists submitted community entries when D1 is available", async () => { + const db = fakeD1(); + await acceptInstallSignal({ + db, + fetch: rawCapletFetch(), + signal: { + source: "community/tools", + capletId: "deploy", + sourcePath: "caplets/deploy/CAPLET.md", + resolvedRevision: "abc123", + contentHash: "sha256:abc", + entry: { + entryKey: "github:community:tools:caplets%2Fdeploy%2Fcaplet.md:deploy", + id: "deploy", + name: "Deploy", + description: "Deploy projects.", + source: { + provider: "github", + owner: "community", + repo: "tools", + repository: "community/tools", + canonicalUrl: "https://github.com/community/tools", + }, + sourcePath: "caplets/deploy/CAPLET.md", + trustLevel: "community", + resolvedRevision: "abc123", + indexedContentHash: "sha256:abc", + contentMarkdown: "# Deploy", + tags: ["deploy"], + intendedTask: "Deploy projects.", + setupReadiness: "ready", + authReadiness: "ready", + projectBindingReadiness: "ready", + workflow: { kind: "cli", label: "CLI tools" }, + installCommand: { + text: "caplets install community/tools deploy", + copyable: false, + revisionBound: false, + reason: "revision_install_unsupported", + }, + warnings: [], + }, + }, + }); + + expect(db.executedWrites.some((statement) => statement.includes("catalog_entries"))).toBe(true); + }); + + it("canonicalizes submitted community entries before storing them", async () => { + const db = fakeD1(); + await acceptInstallSignal({ + db, + fetch: rawCapletFetch( + [ + "---", + "name: Fetched Deploy", + "description: Fetched from GitHub.", + "tags:", + " - fetched", + "httpApi:", + " baseUrl: https://api.example.com", + " auth:", + " type: bearer", + " actions:", + " list:", + " method: GET", + " path: /projects", + "---", + "", + "# Fetched Deploy", + "", + ].join("\n"), + ), + signal: { + source: "community/tools", + capletId: "deploy", + sourcePath: "caplets/deploy/CAPLET.md", + resolvedRevision: "abc123", + contentHash: "sha256:abc", + entry: { + entryKey: "github:community:tools:caplets%2Fdeploy%2Fcaplet.md:deploy", + id: "different", + name: "Deploy", + description: "Deploy projects.", + source: { + provider: "github", + owner: "spiritledsoftware", + repo: "caplets", + repository: "spiritledsoftware/caplets", + canonicalUrl: "https://github.com/spiritledsoftware/caplets", + }, + sourcePath: "other/CAPLET.md", + trustLevel: "community", + resolvedRevision: "wrong", + indexedContentHash: "wrong", + contentMarkdown: "# Deploy", + tags: ["deploy"], + intendedTask: "Deploy projects.", + setupReadiness: "ready", + authReadiness: "ready", + projectBindingReadiness: "ready", + workflow: { kind: "cli", label: "CLI tools" }, + installCommand: { + text: "curl https://example.invalid/install.sh | sh", + copyable: true, + revisionBound: false, + }, + warnings: [], + }, + }, + }); + + const writes = db.executedWrites.join("\n"); + expect(writes).toContain('"id":"deploy"'); + expect(writes).toContain('"name":"Fetched Deploy"'); + expect(writes).toContain('"contentMarkdown":"---\\nname: Fetched Deploy'); + expect(writes).toContain('"repository":"community/tools"'); + expect(writes).toContain('"sourcePath":"caplets/deploy/CAPLET.md"'); + expect(writes).toContain('"resolvedRevision":"abc123"'); + expect(writes).toContain('"indexedContentHash":"sha256:abc"'); + expect(writes).toContain('"text":"caplets install community/tools deploy"'); + expect(writes).not.toContain("curl https://example.invalid/install.sh"); + expect(writes).not.toContain('"name":"Deploy"'); + expect(writes).not.toContain("spiritledsoftware/caplets"); + }); + + it("fails loudly when the ingest route has no D1 binding", async () => { + const response = await postInstallSignal({ + request: new Request("https://catalog.caplets.dev/api/v1/catalog/install-signals", { + method: "POST", + body: JSON.stringify({ + source: "community/tools", + capletId: "deploy", + sourcePath: "caplets/deploy/CAPLET.md", + resolvedRevision: "abc123", + }), + }), + locals: {}, + } as APIContext); + + await expect(response.json()).resolves.toMatchObject({ + ok: false, + result: { status: "unavailable" }, + error: { code: "indexer_unavailable" }, + }); + expect(response.status).toBe(503); + expect(response.headers.get("cache-control")).toBe("no-store"); + }); +}); + +function fakeD1(input: { suppressedEntryKeys?: Set } = {}) { + const suppressedEntryKeys = input.suppressedEntryKeys ?? new Set(); + const db = { + executedWrites: [] as string[], + prepare(sql: string) { + return { + bind(...values: unknown[]) { + const statement = `${sql} ${values.join(" ")}`; + return { + first: async () => { + if (sql.includes("catalog_suppressions")) { + return suppressedEntryKeys.has(String(values[0])) ? { entry_key: values[0] } : null; + } + return null; + }, + toString: () => statement, + }; + }, + }; + }, + batch: async (statements: unknown[]) => { + db.executedWrites.push(...statements.map(String)); + return []; + }, + }; + return db as unknown as D1Database & { executedWrites: string[] }; +} + +function rawCapletFetch(markdown = "# Deploy\n"): typeof fetch { + return (async () => new Response(markdown)) as typeof fetch; +} diff --git a/apps/catalog/test/markdown.test.ts b/apps/catalog/test/markdown.test.ts new file mode 100644 index 00000000..65c8cc5e --- /dev/null +++ b/apps/catalog/test/markdown.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { renderCatalogMarkdown, splitCatalogMarkdown } from "../src/lib/markdown"; + +describe("catalog Markdown rendering", () => { + it("strips raw HTML and dangerous URLs from indexed content", async () => { + const html = await renderCatalogMarkdown( + "# Caplet\n\n\n\n[bad](javascript:alert(1)) [ok](https://example.com)", + ); + + expect(html).toContain("

Caplet

"); + expect(html).not.toContain(" +
+
+
+ + +
+ `; +} + +function input(): HTMLInputElement { + return document.querySelector("[data-search-input]") as HTMLInputElement; +} + +function resultSpacer(): HTMLElement { + return document.querySelector("[data-result-spacer]") as HTMLElement; +} + +function resultRows(): HTMLElement[] { + return Array.from(document.querySelectorAll("[data-result-row]")); +} diff --git a/apps/catalog/tsconfig.json b/apps/catalog/tsconfig.json new file mode 100644 index 00000000..990abae7 --- /dev/null +++ b/apps/catalog/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "astro/tsconfigs/strict", + "include": [".astro/types.d.ts", "**/*"], + "exclude": ["dist"], + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + } + } +} diff --git a/apps/catalog/vitest.config.ts b/apps/catalog/vitest.config.ts new file mode 100644 index 00000000..5a7e6bd3 --- /dev/null +++ b/apps/catalog/vitest.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + resolve: { + alias: { + "cloudflare:workers": new URL("./test/fixtures/cloudflare-workers.ts", import.meta.url) + .pathname, + }, + }, + test: { + include: ["test/**/*.test.ts"], + }, +}); diff --git a/apps/docs/astro.config.mjs b/apps/docs/astro.config.mjs index eda7b857..18f8b1ba 100644 --- a/apps/docs/astro.config.mjs +++ b/apps/docs/astro.config.mjs @@ -42,6 +42,7 @@ export default defineConfig({ items: [ { label: "Code Mode", link: "/code-mode/" }, { label: "Add capabilities", link: "/capabilities/" }, + { label: "Catalog", link: "/catalog/" }, { label: "Caplets Vault", link: "/vault/" }, { label: "Agent integrations", link: "/agent-integrations/" }, { label: "Remote attach", link: "/remote-attach/" }, @@ -54,6 +55,7 @@ export default defineConfig({ { label: "Configuration schema", link: "/reference/config/" }, { label: "Code Mode API", link: "/reference/code-mode-api/" }, { label: "Caplet files", link: "/reference/caplet-files/" }, + { label: "Catalog indexing privacy", link: "/privacy/indexing/" }, { label: "GitHub releases", link: "https://github.com/spiritledsoftware/caplets/releases", diff --git a/apps/docs/src/content/docs/catalog.mdx b/apps/docs/src/content/docs/catalog.mdx new file mode 100644 index 00000000..8a58e9db --- /dev/null +++ b/apps/docs/src/content/docs/catalog.mdx @@ -0,0 +1,46 @@ +--- +title: Catalog +description: Search public Caplets and inspect installable catalog entries. +--- + +`catalog.caplets.dev` is the public search surface for Caplets. It lists official Caplets +from this repository and public community Caplets discovered from successful public +external installs. + +Catalog entries show the Caplet name, description, tags, source repository, setup and auth +readiness, warning labels, aggregate install counts, readable `CAPLET.md` content, and a +generated install command when the catalog can tie that command to inspected content. + +## Safety model + +The catalog is not a scanner, certification program, endorsement list, or moderation +queue. V1 performs basic public-source eligibility checks, bounded ingestion, sanitized +Markdown rendering, count bucketing, and operator suppression. It does not claim semantic +malware review, dependency scanning, vulnerability scanning, secret scanning, or human +approval. + +Install counts are popularity and ranking signals only. They are not safety, quality, or +trust signals. + +## Install commands + +Install commands are generated by Caplets from normalized source metadata and Caplet IDs. +Community Markdown cannot author the copyable command shown by the catalog. + +Result rows may show a command preview. Copy actions live on detail pages after the source +content and warnings are visible. If content cannot be inspected or tied to a revision or +content hash, the catalog hides copy actions and shows recovery guidance instead. + +## Public API + +Agents and tools should use the structured read API rather than scraping HTML: + +```text +https://catalog.caplets.dev/api/v1/catalog +https://catalog.caplets.dev/api/v1/catalog/entries/ +``` + +The API returns the same shared catalog entry model used by the site and CLI indexing +statuses. The npm subpath `@caplets/core/catalog` exposes worker-safe implementation +helpers for this v1 model, but the stable external discovery contract is the versioned +HTTP API. diff --git a/apps/docs/src/content/docs/install.mdx b/apps/docs/src/content/docs/install.mdx index 8f2ab715..aec52a37 100644 --- a/apps/docs/src/content/docs/install.mdx +++ b/apps/docs/src/content/docs/install.mdx @@ -55,6 +55,19 @@ caplets update sentry github Install, restore, and update refuse to overwrite local edits unless you pass `--force`. Use `--json` when a script needs per-entry statuses and machine-readable errors. +For public external sources, install, restore, and update also attempt best-effort catalog +indexing after the local mutation succeeds. Human output prints a short notice when a +public Caplet source may be indexed. JSON output includes per-entry `catalogIndexing` +status values. Indexing failures never block install/update/restore, and private or local +sources are skipped with redacted categorical statuses. + +Set `CAPLETS_DISABLE_CATALOG_INDEXING=1` to skip catalog indexing network requests +entirely. + +If an installed Caplet references unresolved Vault values, output includes immediate +recovery guidance. JSON rows include `vaultSetup` with recovery commands; human output +prints the relevant `caplets vault set` or `caplets vault access grant` command. + For explicit remote catalog lifecycle operations, `--remote` targets the remote machine's global Caplets root and global lockfile: diff --git a/apps/docs/src/content/docs/privacy/indexing.mdx b/apps/docs/src/content/docs/privacy/indexing.mdx new file mode 100644 index 00000000..09532aa0 --- /dev/null +++ b/apps/docs/src/content/docs/privacy/indexing.mdx @@ -0,0 +1,51 @@ +--- +title: Catalog indexing privacy +description: What public catalog indexing can and cannot publish. +--- + +Catalog indexing is separate from anonymous telemetry. + +Set `CAPLETS_DISABLE_CATALOG_INDEXING=1` to disable catalog indexing submissions. + +Anonymous telemetry intentionally excludes source URLs, Caplet IDs, hostnames, local paths, +tool arguments, tool outputs, and raw config. Public catalog indexing needs different data: +it publishes public-source metadata so other users can search for public Caplets. + +## What can become public + +After a successful install, restore, or update of an eligible public external Caplet, +Caplets may submit: + +- Normalized public provider and repository identity, such as `github.com/owner/repo`. +- Caplet ID and source path inside the public repository. +- Resolved revision or content hash when available. +- Installed content hash. +- Generated install command metadata. +- Safe derived catalog metadata, warnings, and aggregate install counts. + +## What must not be sent + +Catalog indexing must not send installer identity, local paths, private config, credentials, +raw agent prompts, tool arguments, tool outputs, hostnames for private sources, private +source URLs, Vault secret values, raw environment values, or individual install-event +identity. + +Ineligible sources return categorical statuses such as `ineligible`, +`revision_unavailable`, or `unavailable` without echoing raw private source values. + +## Nonblocking behavior + +Indexing is best effort. If the catalog indexer is unavailable, rate-limited, or rejects a +source, install/update/restore still succeeds or fails based on the local lifecycle result, +not on catalog reporting. + +## Vault values + +Vault references are local or remote runtime setup. Vault values are never written to the +catalog, lockfiles, install-count events, logs, or JSON output. When install/update finds +unresolved Vault setup, CLI output provides recovery commands such as: + +```sh +caplets vault set GH_TOKEN +caplets vault access grant GH_TOKEN github +``` diff --git a/apps/docs/src/content/docs/vault.mdx b/apps/docs/src/content/docs/vault.mdx index 710ecaf4..2215b9fd 100644 --- a/apps/docs/src/content/docs/vault.mdx +++ b/apps/docs/src/content/docs/vault.mdx @@ -214,3 +214,19 @@ caplets vault access grant GH_TOKEN github If the Caplet starts through an agent harness, restart the agent after setting or granting Vault values so the Caplets runtime reloads clean config. + +## Install-time setup guidance + +After `caplets install`, lockfile restore, or `caplets update`, Caplets checks the runtime +config for unresolved Vault references on affected Caplets. The install lifecycle remains +successful, but output includes recovery guidance when a key is missing or ungranted. + +Human output names only the command to run: + +```sh +caplets vault set GH_TOKEN +caplets vault access grant GH_TOKEN github +``` + +`--json` includes a per-entry `vaultSetup` object with `status`, `messages`, and +`recoveryCommands`. It does not include raw secret values. diff --git a/caplets/ast-grep/CAPLET.md b/caplets/ast-grep/CAPLET.md index 0114c0e0..432b4927 100644 --- a/caplets/ast-grep/CAPLET.md +++ b/caplets/ast-grep/CAPLET.md @@ -8,24 +8,12 @@ tags: - search projectBinding: required: true -setup: - commands: - - label: Install ast-grep MCP - command: npm - args: ["install", "-g", "ast-grep-mcp"] - timeoutMs: 120000 - maxOutputBytes: 200000 - verify: - - label: Check ast-grep MCP - command: ast-grep-mcp - args: ["--help"] - timeoutMs: 10000 - maxOutputBytes: 20000 mcpServer: - command: ast-grep-mcp + command: npx + args: [-y, ast-grep-mcp@latest] --- -# ast-grep MCP +# AST Grep Use this Caplet to expose ast-grep's structural search, scan, rule testing, rewrite, and scaffold workflows without giving an agent unrestricted shell access. @@ -33,12 +21,6 @@ The manifest uses the full `ast-grep-mcp` MCP server. Project Binding is required because ast-grep reads and may rewrite files in the attached repository. The bound root defines the workspace that search and rewrite operations are allowed to target. -## Setup - -This Caplet installs `ast-grep-mcp` globally with npm, then verifies the installed binary with -`ast-grep-mcp --help`. Setup is explicit because hosted and local stdio runtimes need a stable -binary instead of running package-manager downloads during each MCP startup. - ## Safety Read-only search, scan, and normal test actions set `readOnlyHint: true`. Apply-all rewrite, snapshot-update, and scaffolding actions set `destructiveHint: true` because they can modify files. diff --git a/caplets/browser-use/CAPLET.md b/caplets/browser-use/CAPLET.md index 3baa4867..bbf34925 100644 --- a/caplets/browser-use/CAPLET.md +++ b/caplets/browser-use/CAPLET.md @@ -1,7 +1,7 @@ --- # yaml-language-server: $schema=https://caplets.dev/caplet.schema.json name: Browser Use -description: Drive a local Playwright browser through the Playwright MCP server for web inspection and browser workflows. +description: Drive the user's real browser through Playwright MCP for local control. tags: - browser - playwright @@ -11,7 +11,7 @@ mcpServer: args: - -y - "@playwright/mcp@latest" - - --browser=chromium + - --extension --- # Browser Use diff --git a/caplets/context7/CAPLET.md b/caplets/context7/CAPLET.md index 5113631f..3775feff 100644 --- a/caplets/context7/CAPLET.md +++ b/caplets/context7/CAPLET.md @@ -7,21 +7,10 @@ tags: - libraries - frameworks - api-reference -setup: - commands: - - label: Install Context7 MCP - command: npm - args: ["install", "-g", "@upstash/context7-mcp"] - timeoutMs: 120000 - maxOutputBytes: 200000 - verify: - - label: Check Context7 MCP - command: context7-mcp - args: ["--help"] - timeoutMs: 10000 - maxOutputBytes: 20000 mcpServer: - command: context7-mcp + url: https://mcp.context7.com/mcp/oauth + auth: + type: oauth2 --- # Context7 Documentation @@ -41,9 +30,3 @@ documentation before writing code or giving technical instructions. - Prefer primary documentation over snippets when implementation risk is high. - Record the library or package name clearly before searching. - Do not use this as a substitute for project-local types and tests. - -## Setup - -This Caplet installs `@upstash/context7-mcp` globally with npm, then verifies the installed -`context7-mcp` binary with `--help`. Setup is explicit so hosted and local stdio runtimes start a -known binary without running `npx` package downloads on every agent session. diff --git a/caplets/deepwiki/CAPLET.md b/caplets/deepwiki/CAPLET.md index 486c429c..88ce9f28 100644 --- a/caplets/deepwiki/CAPLET.md +++ b/caplets/deepwiki/CAPLET.md @@ -7,10 +7,7 @@ tags: - code - mcp mcpServer: - transport: http url: https://mcp.deepwiki.com/mcp - auth: - type: none --- # DeepWiki @@ -24,8 +21,3 @@ context from DeepWiki before making implementation decisions. - Find documentation-backed explanations for framework, package, or service behavior. - Cross-check implementation details before modifying code that depends on another project. - Summarize repository concepts for planning, code review, or onboarding notes. - -## Setup - -This Caplet uses the hosted DeepWiki MCP endpoint at `https://mcp.deepwiki.com/mcp` with no -configured authentication. Hosted endpoint availability may depend on DeepWiki's current MCP service. diff --git a/caplets/github/CAPLET.md b/caplets/github/CAPLET.md index ae76912c..60d5d70f 100644 --- a/caplets/github/CAPLET.md +++ b/caplets/github/CAPLET.md @@ -9,7 +9,6 @@ tags: - issues - reviews mcpServer: - transport: http url: https://api.githubcopilot.com/mcp auth: type: bearer @@ -35,21 +34,3 @@ issues, pull requests, branches, commits, or review feedback. - Store a least-privilege `GH_TOKEN` in the Caplets Vault for the runtime where GitHub runs. - Do not ask the agent to expose token values, repository secrets, or private issue contents outside the intended conversation. - -## Setup - -Create a GitHub token with the minimum repository scopes needed for your workflow, then store it in -the local/global Vault and grant this Caplet access: - -```sh -caplets vault set GH_TOKEN --grant github -caplets serve -``` - -For a self-hosted remote or hosted Cloud-backed runtime, write the value to that runtime instead: - -```sh -caplets vault set GH_TOKEN --remote --grant github -``` - -This Caplet uses GitHub's hosted MCP endpoint at `https://api.githubcopilot.com/mcp`. diff --git a/caplets/gmail/CAPLET.md b/caplets/gmail/CAPLET.md index 9beaf02e..019345ed 100644 --- a/caplets/gmail/CAPLET.md +++ b/caplets/gmail/CAPLET.md @@ -13,20 +13,12 @@ googleDiscoveryApi: issuer: https://accounts.google.com clientId: $vault:GOOGLE_CLIENT_ID clientSecret: $vault:GOOGLE_CLIENT_SECRET - scopes: - - https://www.googleapis.com/auth/gmail.metadata - - https://www.googleapis.com/auth/gmail.readonly - - https://www.googleapis.com/auth/gmail.modify --- # Gmail Use this Caplet when an agent needs Gmail context for support, scheduling, customer communication, or inbox triage. -## Scope Guidance - -Start with metadata or readonly access when possible. Add `gmail.modify` only when the workflow needs labels, archive, trash, drafts, or sending. Avoid the broad `https://mail.google.com/` scope unless a separate reviewed local Caplet genuinely needs permanent delete access. - ## Use Carefully Email often contains private or regulated content. Keep queries narrow, summarize minimally, and require explicit user intent before sending, modifying, trashing, or deleting messages. diff --git a/caplets/google-drive/CAPLET.md b/caplets/google-drive/CAPLET.md index 61b1e34d..cc670bd4 100644 --- a/caplets/google-drive/CAPLET.md +++ b/caplets/google-drive/CAPLET.md @@ -13,19 +13,12 @@ googleDiscoveryApi: issuer: https://accounts.google.com clientId: $vault:GOOGLE_CLIENT_ID clientSecret: $vault:GOOGLE_CLIENT_SECRET - scopes: - - https://www.googleapis.com/auth/drive.file - - https://www.googleapis.com/auth/drive.metadata.readonly --- # Google Drive Use this Caplet when an agent needs Drive files as context or needs to create/update files with explicit user direction. -## Scope Guidance - -Prefer `drive.file` and `drive.metadata.readonly` for public-safe use. Google recommends narrow scopes where possible; broad Drive scopes such as `drive` and `drive.readonly` are restricted and should be added only in a reviewed private Caplet. - ## Use Carefully Search metadata before reading content. Confirm file IDs and names before upload, update, trash, or delete operations, especially on shared drives. diff --git a/caplets/google-tasks/CAPLET.md b/caplets/google-tasks/CAPLET.md index c85da86f..371fa986 100644 --- a/caplets/google-tasks/CAPLET.md +++ b/caplets/google-tasks/CAPLET.md @@ -13,18 +13,12 @@ googleDiscoveryApi: issuer: https://accounts.google.com clientId: $vault:GOOGLE_CLIENT_ID clientSecret: $vault:GOOGLE_CLIENT_SECRET - scopes: - - https://www.googleapis.com/auth/tasks --- # Google Tasks Use this Caplet when an agent needs to inspect or manage Google Tasks during planning, follow-up, or personal workflow coordination. -## Scope Guidance - -Google Tasks offers `tasks.readonly` for read-only workflows and `tasks` for creating, editing, organizing, and deleting tasks. This install-ready Caplet includes the mutating `tasks` scope because normal task management requires it; use a private read-only variant if the agent should never mutate task state. - ## Use Carefully List existing tasklists and tasks before mutating. Confirm task names, due dates, and tasklist IDs before creating, completing, moving, or deleting tasks. diff --git a/caplets/linear/CAPLET.md b/caplets/linear/CAPLET.md index ce05afa3..cda27e0d 100644 --- a/caplets/linear/CAPLET.md +++ b/caplets/linear/CAPLET.md @@ -9,7 +9,6 @@ tags: - projects - triage mcpServer: - transport: http url: https://mcp.linear.app/mcp auth: type: oauth2 @@ -37,14 +36,3 @@ implementation work synchronized with issues, projects, and team workflows. - Linear issue updates are visible to teammates. Read first, then write deliberately. - Keep issue titles and comments concise; use links to detailed implementation artifacts when useful. - Avoid broad, noisy searches when a team key, issue ID, project, or label is available. - -## Setup - -Authenticate once through Caplets: - -```sh -caplets auth login linear -``` - -The Linear MCP endpoint supports OAuth. Caplets stores the resulting token bundle in your local -Caplets auth store. diff --git a/caplets/lsp/CAPLET.md b/caplets/lsp/CAPLET.md index 76879c3d..f85d6b60 100644 --- a/caplets/lsp/CAPLET.md +++ b/caplets/lsp/CAPLET.md @@ -12,7 +12,7 @@ projectBinding: required: true mcpServer: command: npx - args: [-y, language-server-mcp] + args: [-y, language-server-mcp@latest] --- # LSP diff --git a/caplets/npm/CAPLET.md b/caplets/npm/CAPLET.md index c7dbb434..86e2ceb9 100644 --- a/caplets/npm/CAPLET.md +++ b/caplets/npm/CAPLET.md @@ -18,11 +18,6 @@ openapiEndpoint: Use this Caplet to inspect npm registry operations through npm's published OpenAPI description of the public registry API. -## Spec - -The OpenAPI spec is loaded from npm's public API documentation repository: -. - ## Usage Notes - Use `get_package` to fetch packument metadata for a package, including versions and dist-tags. diff --git a/caplets/playwright/CAPLET.md b/caplets/playwright/CAPLET.md index 83f6c6ad..858255fa 100644 --- a/caplets/playwright/CAPLET.md +++ b/caplets/playwright/CAPLET.md @@ -11,12 +11,12 @@ setup: commands: - label: Install Playwright MCP command: npm - args: ["install", "-g", "@playwright/mcp@0.0.75"] + args: ["install", "-g", "@playwright/mcp@latest"] timeoutMs: 120000 maxOutputBytes: 200000 - label: Install Chromium browser command: npx - args: ["playwright", "install", "chromium"] + args: ["-y", "playwright@latest", "install", "chromium"] timeoutMs: 180000 maxOutputBytes: 200000 verify: diff --git a/caplets/posthog/CAPLET.md b/caplets/posthog/CAPLET.md index 8200ca3d..61e9dda6 100644 --- a/caplets/posthog/CAPLET.md +++ b/caplets/posthog/CAPLET.md @@ -8,7 +8,6 @@ tags: - product - feature-flags mcpServer: - transport: http url: https://mcp.posthog.com/mcp auth: type: oauth2 diff --git a/caplets/sentry/CAPLET.md b/caplets/sentry/CAPLET.md index b9d78301..374abe5a 100644 --- a/caplets/sentry/CAPLET.md +++ b/caplets/sentry/CAPLET.md @@ -8,7 +8,6 @@ tags: - errors - tracing mcpServer: - transport: http url: https://mcp.sentry.dev/mcp auth: type: oauth2 diff --git a/caplets/sourcegraph/CAPLET.md b/caplets/sourcegraph/CAPLET.md index b6c04c70..5f2d0b08 100644 --- a/caplets/sourcegraph/CAPLET.md +++ b/caplets/sourcegraph/CAPLET.md @@ -7,7 +7,6 @@ tags: - code-search - mcp mcpServer: - transport: http url: https://sourcegraph.com/.api/mcp auth: type: oauth2 diff --git a/caplets/stealth-browser-use/CAPLET.md b/caplets/stealth-browser-use/CAPLET.md deleted file mode 100644 index ac0f3872..00000000 --- a/caplets/stealth-browser-use/CAPLET.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -# yaml-language-server: $schema=https://caplets.dev/caplet.schema.json -name: Stealth Browser Use -description: Drive a stealth-configured local Playwright browser for web workflows that need a non-default browser profile. -tags: - - browser - - playwright - - stealth - - local-control -mcpServer: - command: npx - args: - - -y - - "@playwright/mcp@latest" - - "--config=$env:CAPLETS_STEALTH_PLAYWRIGHT_CONFIG" - - --browser=firefox - - "--executable-path=$env:CAPLETS_STEALTH_BROWSER_EXECUTABLE" - - "--user-data-dir=$env:CAPLETS_STEALTH_BROWSER_USER_DATA_DIR" - - --headless ---- - -# Stealth Browser Use - -Use this Caplet when a workflow needs a dedicated local browser profile and the ordinary Browser Use Caplet is not suitable. - -## Setup - -Create a local Playwright MCP config file and set: - -- `CAPLETS_STEALTH_PLAYWRIGHT_CONFIG` -- `CAPLETS_STEALTH_BROWSER_EXECUTABLE` -- `CAPLETS_STEALTH_BROWSER_USER_DATA_DIR` - -Do not check browser profiles, cookies, or machine-specific paths into a public Caplet. - -## Safety - -This is a local-control Caplet. Follow site terms, avoid credential entry, and review any mutating browser interaction before it runs. diff --git a/docs/brainstorms/2026-06-26-caplets-catalog-search-site-requirements.md b/docs/brainstorms/2026-06-26-caplets-catalog-search-site-requirements.md new file mode 100644 index 00000000..452e1dc7 --- /dev/null +++ b/docs/brainstorms/2026-06-26-caplets-catalog-search-site-requirements.md @@ -0,0 +1,224 @@ +--- +date: 2026-06-26 +topic: caplets-catalog-search-site +--- + +# Caplets Catalog Search Site Requirements + +## Summary + +`catalog.caplets.dev` should be a separate search-first catalog site for finding installable Caplets, with inspection-first detail pages and copyable install commands as the conversion action. It should index official Caplets from this repo and community Caplets discovered through public external installs. + +--- + +## Problem Frame + +The repo now has a growing Prebuilt Caplets Catalog and lockfile-backed installs, but discovery still depends on knowing a repo and Caplet ID ahead of time. That works for a curated seed set, but it does not help users find useful Caplets across the wider ecosystem. + +Skills.sh provides the relevant pattern: installation becomes the discovery signal, search and ranking make useful entries findable, and the page exposes enough source content for users to decide whether to install. Caplets needs the same discovery loop, adapted to Caplets' higher-risk capability surface and existing privacy promises. + +--- + +## Key Decisions + +- **Search is the v1 product.** The site should optimize for finding, inspecting, and installing Caplets rather than becoming a full marketplace or security trust hub. +- **Install is the submission mechanism.** Installing a Caplet from a public external source should make that Caplet eligible for indexing without a separate publisher submission flow, subject to public-source fetch boundaries and removal or suppression rules. +- **Community Caplets are unverified by default.** V1 should warn users to inspect community Caplets before install rather than imply that automatic indexing means endorsement. +- **Security scanning is deferred.** V1 should keep only basic ingestion hygiene and avoid claiming semantic, dependency, or supply-chain scanning. +- **Official Caplets get distinct labeling.** Repo-owned catalog entries should be visually and semantically distinct from community entries. +- **Install counts are popularity signals only.** Ranking can use install counts, but copy and UI must not imply that install count proves safety, quality, or endorsement. +- **Public indexing is separate from anonymous telemetry.** The catalog indexing signal publishes source identity and Caplet identity, so it must be modeled and documented separately from ordinary anonymous telemetry. +- **Vault setup belongs in install-time CLI onboarding.** When an installed Caplet requests Vault-backed config, the CLI should help the user grant or set the needed secret for the target runtime without sending secrets to the public catalog or adding a separate site-side consent step. +- **Frontend design must use Impeccable.** The catalog site should be shaped as a precise product-register developer tool through `$impeccable`, using the existing Caplets design system and avoiding generic marketplace UI. + +--- + +## Actors + +- A1. **Agent power-user.** Searches the catalog to find a Caplet that solves a concrete integration or agent-workflow need. +- A2. **Installing user.** Installs a Caplet from a public external repository and thereby contributes a public indexing signal. +- A3. **Community Caplet author.** Publishes Caplets in a public repository and gains discovery through installs rather than a manual submission form. +- A4. **Official catalog maintainer.** Maintains repo-owned Caplets under `caplets/` and expects them to be labeled as official. +- A5. **Catalog site visitor.** Reads `CAPLET.md`, source metadata, warnings, and install counts before deciding whether to trust an entry. +- A6. **Catalog indexer.** Receives public-source install signals, fetches public Caplet content, and updates search data and aggregate counts. +- A7. **Implementation agent.** Builds and polishes the catalog frontend using the Caplets design system and `$impeccable`. +- A8. **Vault-using Caplet.** References Caplets Vault values in runtime-resolved config and needs the installing user to set or grant those values before it can execute. + +--- + +## Requirements + +**Catalog Site** + +- R1. The catalog must live as a separate public site from the landing page and docs site, with `catalog.caplets.dev` as the production domain. +- R2. The catalog must be deployed through the same Cloudflare-backed deployment family as the existing public sites. +- R3. The catalog home surface must make search the primary interaction and make install-copy a conversion action only after the user can inspect the Caplet content and warnings. +- R4. Search results must expose the Caplet name, description, tags, source, official or community status, install count, primary Code Mode workflow, intended agent task, setup or auth readiness, and normalized install command preview. +- R5. A Caplet detail view must expose the indexed `CAPLET.md` content or a readable equivalent before showing the copyable install command as the next action. +- R6. The site must support ranking by aggregate installs while preserving clear official and unverified-community labels. +- R7. Install counts must be presented as popularity and ranking signals only, not as trust, quality, safety, or endorsement signals. +- R8. The site must support v1 filters and sort controls for official/community scope, tags, source owner or repository, setup/auth readiness, relevance, aggregate installs, and reset-to-default. +- R9. The v1 information architecture must include search/results, Caplet detail, official/community scoped views, documentation links, privacy/indexing links, and safety copy near install actions. +- R10. The catalog must keep Caplets framed as a Code Mode-first capability layer for coding agents, not as a generic plugin marketplace. + +**Indexing Sources** + +- R11. The index must seed official entries from the repo-owned catalog under `caplets/`. +- R12. The index must add community entries discovered through successful installs from public external sources. +- R13. Public external indexing must publish only the Caplet content, normalized install command, source identity needed to reproduce install, aggregate install count, verification status, and safe catalog metadata. +- R14. Public external indexing must not transmit, persist, log, or publish installer identity, local paths, private config, credentials, raw agent prompts, tool arguments, tool outputs, hostnames, or non-public source URLs. +- R15. The indexer must only fetch community content from normalized supported public providers, and must reject local files, private or auth-only hosts, private IP ranges, and redirects to non-public destinations. +- R16. The indexer must skip indexing when the source is not public, the install command cannot be normalized, or the selected Caplet file cannot be fetched from the public source. +- R17. The indexer must count installs in aggregate form that supports ranking without exposing individual install events, with deduplication, rate limiting, and low-count bucketing or suppression where needed. +- R18. Repeated installs of the same public source and Caplet should update ranking signals without creating duplicate catalog entries for the same source identity. +- R19. V1 must include a lightweight source-owner or operator suppression path for stale sources, abuse, takedown requests, or cases where automatic indexing creates unacceptable risk. + +**Trust, Warnings, And Safety** + +- R20. Community entries must be labeled as unverified by default. +- R21. Official entries must be labeled distinctly from community entries without implying that all official Caplets are risk-free. +- R22. The site must display a prominent warning that Caplets can grant agents access to APIs, local tools, files, browsers, desktop control, or project state. +- R23. The site must tell users to read the Caplet before installing and to install only from sources they trust. +- R24. V1 must not claim semantic scanning, vulnerability scanning, dependency scanning, secret scanning, moderation review, or safety certification for community entries. +- R25. Basic ingestion hygiene must reject invalid Caplet files, unparseable `CAPLET.md` content, non-normalizable install commands, and non-public sources. +- R26. Indexed Caplet content must render through a sanitized, non-executable Markdown path that strips or escapes unsafe HTML and scripts. +- R27. Install commands shown by the site must be generated from normalized source metadata, not copied out of community-authored Markdown. +- R28. Safety warnings must be visible near install actions, not buried only on secondary pages. +- R29. V1 must have an explicit stop rule: community indexing or affected entries can be suppressed when abuse, private-source leakage, or high-risk behavior appears before scanning or moderation exists. + +**Frontend Design** + +- R30. The catalog frontend must use `$impeccable` during implementation for product-register shaping, critique, and polish. +- R31. The catalog UI must follow the existing Caplets design system: precise, calm, capability-focused, and state-rich without generic marketplace decoration. +- R32. Search, filters, result cards, detail views, copy actions, empty states, and warnings must be keyboard-accessible, responsive, and readable at mobile and desktop widths. +- R33. Official, community, unverified, warning, loading, empty, and error states must be conveyed with text or icons in addition to color. +- R34. The frontend must define outcomes for loading, empty, no-results, error, unavailable-content, copy-success, copy-failure, filter-reset, and recovery states. +- R35. The frontend must include visible focus states, accessible names for controls, screen-reader announcements for result counts/loading/errors/copy actions, sufficient contrast, reduced-motion handling, and mobile touch targets. +- R36. The frontend must avoid generic SaaS cream, neon developer-tool dark mode, identical decorative card grids, hero metrics, gradient text, and vague AI productivity copy. + +**Documentation And CLI Relationship** + +- R37. Public docs must explain that catalog indexing is a public-source discovery signal, not ordinary anonymous telemetry. +- R38. Public docs must explain which install metadata can become public when installing from public external sources. +- R39. CLI-facing notices must make public external indexing understandable without adding an interactive confirmation step. +- R40. CLI-facing warnings or notices must not block successful install just because the catalog site or indexer is unavailable. +- R41. Install commands shown by the site must match the supported `caplets install` shape for official and community entries. +- R42. The catalog must not require a hosted Caplets account for search, detail inspection, or copying install commands. +- R43. Public docs and UI copy must state that install counts are ranking signals, not safety signals. +- R44. After install, restore, or update materializes Caplets that reference Caplets Vault values in runtime-resolved config fields, the CLI must surface the missing Vault setup before the user discovers the Caplet is quarantined at runtime. +- R45. If the target Vault already contains a referenced key but the installed Caplet lacks a matching access grant, the CLI must prompt the user to grant that key to the Caplet. +- R46. If the target Vault does not contain a referenced key, the CLI must prompt the user to set the key and grant it to the Caplet in the same target runtime. +- R47. Vault setup prompts must target the runtime that owns the install scope, including local project, local global, and remote global installs, without copying secrets between runtime Vault stores. +- R48. Vault setup must never write raw secret values to lockfiles, public catalog indexing signals, install-count events, logs, JSON install output, or the catalog site. +- R49. Noninteractive, JSON, or otherwise unpromptable installs must not hang or fail solely because Vault setup is unresolved; they must return actionable `caplets vault set` and `caplets vault access grant` recovery guidance while existing Vault quarantine behavior protects runtime execution. + +--- + +## Key Flows + +- F1. Search for a Caplet + - **Trigger:** A user lands on `catalog.caplets.dev` with an integration or workflow in mind. + - **Actors:** A1, A5 + - **Steps:** The user searches, scans results with labels, counts, workflow metadata, and install command previews, opens a promising entry, reads the Caplet content and warnings, then copies the install command. + - **Covered by:** R3, R4, R5, R22, R23, R28, R41 + +- F2. Index an official Caplet + - **Trigger:** The repo-owned catalog contains an installable Caplet. + - **Actors:** A4, A6 + - **Steps:** The index includes the official Caplet, marks it as official, exposes its source content, and produces the supported install command. + - **Covered by:** R5, R11, R21, R41 + +- F3. Index a community Caplet from install + - **Trigger:** A user successfully installs a Caplet from a public external source. + - **Actors:** A2, A3, A6 + - **Steps:** The install reports public source metadata to the catalog indexer, the indexer fetches the public Caplet file from a supported public provider, validates ingestion hygiene, updates aggregate count, and makes the entry searchable as unverified community content. + - **Covered by:** R12, R13, R15, R17, R18, R20, R25 + +- F4. Skip a private or invalid source + - **Trigger:** An install source is private, not fetchable, invalid, or non-normalizable. + - **Actors:** A2, A6 + - **Steps:** The install can continue locally, but the public indexer does not publish the entry and does not leak source details. + - **Covered by:** R14, R15, R16, R25, R40 + +- F5. Use catalog during implementation + - **Trigger:** A future agent builds the catalog frontend. + - **Actors:** A7 + - **Steps:** The agent uses `$impeccable`, follows the Caplets product design system, and verifies the search and install surfaces across accessibility and responsive states. + - **Covered by:** R30, R31, R32, R33, R34, R35, R36 + +- F6. Install a Caplet that needs Vault setup + - **Trigger:** A user installs, restores, or updates a Caplet whose runtime config references Caplets Vault. + - **Actors:** A2, A3, A8 + - **Steps:** The CLI completes the catalog lifecycle operation, detects target-runtime Vault references, prompts to grant an existing key or set a missing key, creates the needed access grant, and keeps secret values out of catalog indexing and install output. + - **Covered by:** R44, R45, R46, R47, R48, R49 + +--- + +## Acceptance Examples + +- AE1. **Covers R3, R4, R5, R23.** Given a user searches for "sentry", when results appear, then each matching result shows source, label, install count, workflow metadata, and install command preview, and the detail view lets the user read the Caplet before copying the install command. +- AE2. **Covers R5, R11, R21, R41.** Given `sentry` is in the repo-owned catalog, when it appears on the site, then it is marked official, exposes readable source content, and shows a supported `caplets install spiritledsoftware/caplets sentry` command. +- AE3. **Covers R12, R13, R17, R20.** Given a user installs `owner/repo my-caplet` from a public repo, when indexing succeeds, then the site can show the public `CAPLET.md` content, normalized install command, aggregate installs, and unverified community label. +- AE4. **Covers R14, R15, R16.** Given a user installs from a private repo, local path, private IP-backed source, or unsupported redirect, when the install completes locally, then the public catalog does not publish that source or Caplet identity. +- AE5. **Covers R22, R24, R28.** Given a community Caplet exposes local browser control, when the detail page shows the install command, then a visible warning tells users to inspect and trust the source, without claiming scanner approval. +- AE6. **Covers R26, R27.** Given a community `CAPLET.md` contains unsafe HTML or a hand-written install snippet, when the detail page renders, then unsafe content is stripped or escaped and the copy command comes from normalized source metadata. +- AE7. **Covers R30, R32, R33, R34, R35.** Given the catalog frontend is implemented, when it is reviewed with Impeccable, then search, filters, result cards, warnings, empty states, and copy actions are responsive, state-complete, and accessible without relying only on color. +- AE8. **Covers R37, R38, R39, R43.** Given a user reads catalog documentation, when it describes public indexing, then it distinguishes public-source catalog indexing from ordinary anonymous telemetry, names the metadata that may become public, and explains that install counts are not safety signals. +- AE9. **Covers R44, R45, R46, R47, R48, R49.** Given a user installs a Caplet that references `$vault:GH_TOKEN`, when the target Vault already has `GH_TOKEN`, then install prompts to grant it to the Caplet; when the key is missing, install prompts to set and grant it; and when the command is noninteractive, install returns recovery commands without leaking the secret or blocking on a prompt. + +--- + +## Success Criteria + +- Users can search official and community Caplets from a dedicated `catalog.caplets.dev` site. +- A user can read the indexed Caplet content and copy a supported install command without leaving the catalog detail page. +- Official and community entries are visually and semantically distinguishable. +- Community entries are labeled unverified and warn users to inspect Caplets before install. +- Public external installs can create or update catalog entries without a manual submission workflow. +- Install counts support ranking without exposing individual installation details or implying safety. +- The catalog does not claim scanner coverage in v1. +- Installs of Caplets that request Vault-backed config guide users to grant existing Vault keys or set missing keys for the correct runtime without exposing secret values to catalog systems. +- The indexer has a clear suppression path for stale, abusive, leaked, or high-risk community entries. +- The frontend design pass uses Impeccable and lands as a precise developer-tool surface rather than a generic marketplace. + +--- + +## Scope Boundaries + +- Semantic security scanning, dependency scanning, secret scanning, OpenSSF Scorecard, Socket-style partner audits, and LLM malicious-intent review are deferred. +- Ratings, reviews, comments, report-abuse flows, manual moderation queues, publisher profiles, and verified publisher programs are deferred. +- Lightweight entry suppression, source-owner removal handling, and an operator stop rule remain in scope as ingestion safety controls, not as a full moderation system. +- Hosted account features, personalized recommendations, saved Caplets, and user dashboards are deferred. +- The catalog should not become the primary docs site or replace `docs.caplets.dev`. +- The catalog should not imply that installing a community Caplet is safe merely because it is indexed. + +--- + +## Dependencies / Assumptions + +- The current repo-owned catalog under `caplets/` remains the source of official entries. +- Lockfile-backed install and update behavior remains the install lifecycle foundation. +- Public external indexing needs an explicit public-source signal because ordinary anonymous telemetry forbids publishing source and Caplet identity. +- External community indexing is limited to sources that can be publicly fetched and normalized. +- The first release can use basic ingestion hygiene without blocking future scanner integration. +- Caplets Vault remains runtime-owned state, so install-time setup must create grants in the same runtime that will execute the installed Caplet. +- Public-source install notices can be non-blocking, but the user must be able to learn that public external installs may contribute to the public catalog. +- The existing Caplets product design context is the right design foundation for the catalog site. + +--- + +## Sources / Research + +- `STRATEGY.md` for the Code Mode-first product frame. +- `CONCEPTS.md` for Prebuilt Caplets Catalog, Catalog-Grade Caplet, Caplets Lockfile, and Anonymous Telemetry vocabulary. +- `alchemy.run.ts` and `infra/alchemy-domains.ts` for the existing landing/docs Cloudflare deployment shape. +- `apps/landing/PRODUCT.md`, `apps/landing/DESIGN.md`, and `apps/landing/src/styles/starwind.css` for Caplets frontend design context. +- `caplets/` for official catalog source files. +- `apps/docs/src/content/docs/install.mdx` for current lockfile-aware install and update docs. +- `packages/core/src/cli.ts` and `packages/core/src/cli/install.ts` for current install, restore, update, and lockfile behavior. +- `docs/product/caplets-vault.md` for Vault references, runtime-owned stores, and access grants. +- `docs/product/anonymous-telemetry.md` and `packages/core/src/telemetry/privacy.ts` for telemetry privacy boundaries. +- `skills/writing-caplets/SKILL.md` for catalog-grade Caplet authoring expectations. +- `https://www.skills.sh/` for the search and install-count discovery pattern. +- `https://github.com/vercel-labs/skills` for the public skills CLI install telemetry and audit-display shape. +- `https://socket.dev/blog/socket-brings-supply-chain-security-to-skills` for the security-scanning model intentionally deferred from v1. diff --git a/docs/brainstorms/2026-06-27-catalog-search-virtualized-results-requirements.md b/docs/brainstorms/2026-06-27-catalog-search-virtualized-results-requirements.md new file mode 100644 index 00000000..95069aaa --- /dev/null +++ b/docs/brainstorms/2026-06-27-catalog-search-virtualized-results-requirements.md @@ -0,0 +1,194 @@ +--- +date: 2026-06-27 +topic: catalog-search-virtualized-results +--- + +# Catalog Search Virtualized Results Requirements + +## Summary + +The catalog search page should move from full result cards to a dense, virtualized results table that remains fast as the catalog grows. Search, filters, sort, counts, URL state, copy actions, and warning signals should still apply to the complete local result set while the browser renders only a bounded visible window. + +--- + +## Problem Frame + +The current catalog search page renders full result cards for every entry and then filters, hides, sorts, and reorders those DOM nodes client-side. That is acceptable for the seed catalog, but it will become a browser performance problem as the catalog grows toward thousands of entries. + +The search page is also doing too much inspection work. Search results should help users scan and compare Caplets quickly, while the detail page remains the place to inspect full warnings, source metadata, and `CAPLET.md` content before installation. + +--- + +## Key Decisions + +- **Virtualize a complete local result set.** The page should keep instant client-side search over a compact index while only rendering the visible row window. +- **Use dense rows instead of full cards.** The search page becomes a scanning surface; the detail page remains the inspection surface. +- **Use page scrolling.** The browser scrollbar should remain the main scroll model, with a compact sticky toolbar under the site header. +- **Use a proven virtualization library.** Scroll measurement, overscan, resize behavior, focus behavior, and browser edge cases should not be hand-rolled. +- **Use `$impeccable` during implementation.** The dense table, sticky controls, responsive behavior, accessibility, status icon affordances, and browser polish must be shaped and reviewed with Impeccable. + +--- + +## Actors + +- A1. **Catalog search user.** Searches, filters, sorts, scans rows, copies install commands, and opens detail pages. +- A2. **Keyboard and assistive technology user.** Navigates the sticky toolbar, virtualized rows, copy controls, tooltips, and detail links without relying on pointer hover or color alone. +- A3. **Implementation agent.** Builds the search page hardening and uses `$impeccable` as part of frontend design and polish. +- A4. **Future catalog operator.** Needs the search page to remain usable as the index grows to thousands of official and community entries. + +--- + +## Requirements + +**Result Shape** + +- R1. The search results surface must use dense table-like rows rather than full result cards. +- R2. Each row must show the Caplet title, official or community status, install count, truncated description, truncated install command, copy action, and compact status indicators. +- R3. Row descriptions must truncate after a fixed length or fixed visual space so rows remain scan-friendly. +- R4. Install commands must truncate visually while remaining copyable through an icon button. +- R5. Rows must link or otherwise route users to the detail page for full inspection. +- R6. Full warning explanations, complete source metadata, and full `CAPLET.md` inspection must remain on the detail page rather than expanding every search result row. + +**Status Signals** + +- R7. Local control, setup required, project binding required, vault secrets required, and similar risk or readiness signals must appear as icons in the row. +- R8. Status icons must have hover and keyboard-accessible tooltips or equivalent labels. +- R9. A compact legend must explain status icons near the results controls. +- R10. Status meaning must not rely on color alone. +- R11. Iconography must use the catalog's existing icon system rather than custom hand-rolled glyphs. + +**Virtualization And Search Behavior** + +- R12. The page must use a proven virtualization library for the result rows. +- R13. The virtualized list must render only the visible row window plus reasonable overscan. +- R14. The page must support a catalog size of 10,000 entries without rendering thousands of result rows into the DOM. +- R15. Search, filters, and sort must operate against the complete local compact index, not only the currently rendered rows. +- R16. Search result count must reflect the complete filtered result set. +- R17. Changing search, filters, or sort must reset the virtualized list to the top while preserving focus in the active control. +- R18. Search, filter, sort, and pagination-like rendering behavior must keep shareable URL state for query, scope, setup, tag, and sort. +- R19. The result list must preserve expected browser page scrolling instead of introducing a nested internal scroll panel. + +**Toolbar And Layout** + +- R20. The search input, filters, sort control, result count, and status legend must live in a compact sticky toolbar. +- R21. The sticky toolbar must remain visible while users scroll through results. +- R22. The toolbar must stay compact enough that it does not dominate the viewport on desktop or mobile. +- R23. Desktop and tablet rows should use a fixed-height layout to simplify virtualization and avoid scroll jumps. +- R24. Mobile rows may stack fields, but their height and truncation rules must remain predictable enough for stable virtualization. +- R25. The dense results surface must fit the existing Caplets product design system and Starwind-based catalog UI. + +**Accessibility And Interaction** + +- R26. Copy buttons, row detail links, filters, sort controls, tooltips, and sticky toolbar controls must be keyboard accessible. +- R27. Result count updates, no-results state, and copy success or failure must be announced accessibly. +- R28. Focus states must remain visible in light, dark, and system color schemes. +- R29. Virtualization must not trap focus, lose focus during rerenders, or make keyboard navigation skip unpredictably. +- R30. Touch targets on mobile must remain usable even though rows are denser than the current cards. +- R31. Reduced-motion preferences must be respected for any row, toolbar, tooltip, or copy-feedback transitions. + +**Implementation Process** + +- R32. Implementation must use `$impeccable` for product-register shaping and polish of the dense search results UI. +- R33. The Impeccable pass must specifically cover visual hierarchy, density, toolbar compactness, status-icon comprehension, responsive behavior, accessibility, and browser rendering polish. +- R34. The implementation must keep the detail page as the inspection surface and avoid moving dense-row performance work into unrelated detail-page redesign. + +--- + +## Key Flows + +- F1. Search and scan dense results + - **Trigger:** A user searches or filters the catalog. + - **Actors:** A1 + - **Steps:** The sticky toolbar updates URL-backed search state, the result count updates for the full filtered set, the list resets to the top, and the user scans dense rows with title, status, installs, description, install command, and icons. + - **Covered by:** R1, R2, R15, R16, R17, R18, R20, R21 + +- F2. Scroll a large result set + - **Trigger:** A filtered or unfiltered result set contains thousands of entries. + - **Actors:** A1, A4 + - **Steps:** The user scrolls with the browser scrollbar, the virtualizer updates the visible row window, and the DOM row count remains bounded. + - **Covered by:** R12, R13, R14, R19, R23, R24 + +- F3. Copy an install command from a row + - **Trigger:** A user finds a promising Caplet in the dense list. + - **Actors:** A1, A2 + - **Steps:** The row shows a truncated command, the user activates the copy icon button, the full command is copied, and copy feedback is announced. + - **Covered by:** R4, R26, R27, R28, R30 + +- F4. Interpret status icons + - **Trigger:** A row has local-control, setup, project-binding, Vault, or similar status signals. + - **Actors:** A1, A2 + - **Steps:** The row shows compact icons, the toolbar legend explains them, and hover or keyboard interaction reveals an accessible label. + - **Covered by:** R7, R8, R9, R10, R11, R26 + +- F5. Open full inspection + - **Trigger:** A user needs to inspect a Caplet before installing. + - **Actors:** A1 + - **Steps:** The user opens the detail page from a dense row and reviews complete warnings, metadata, and `CAPLET.md` content there. + - **Covered by:** R5, R6, R34 + +- F6. Implement and polish the UI + - **Trigger:** A future agent implements the virtualized search results work. + - **Actors:** A3 + - **Steps:** The agent uses the existing product design context, applies `$impeccable`, verifies density and accessibility, and polishes the browser behavior. + - **Covered by:** R25, R32, R33 + +--- + +## Acceptance Examples + +- AE1. **Covers R1, R2, R6.** Given the catalog search page loads, when results are displayed, then they appear as dense rows with title, status, install count, truncated description, truncated command, copy action, and icons rather than full warning cards. +- AE2. **Covers R12, R13, R14.** Given a test index contains 10,000 entries, when the search page renders and the user scrolls, then the DOM contains only a bounded row window plus overscan instead of thousands of rendered result rows. +- AE3. **Covers R15, R16, R17, R18.** Given a user changes the search query, filter, or sort, when the result set updates, then the count reflects all matching entries, URL state updates, focus remains in the active control, and the list scrolls back to the first row. +- AE4. **Covers R19, R20, R21, R22.** Given a user scrolls through many results, when the page moves, then the browser scrollbar is the main scroll mechanism and the compact toolbar remains visible without consuming excessive viewport height. +- AE5. **Covers R4, R26, R27.** Given a row's install command is longer than the available column, when it renders, then the command truncates visually, the copy icon has an accessible name, and activating it copies the full command. +- AE6. **Covers R7, R8, R9, R10.** Given rows include local control, setup, project binding, or Vault status, when a user sees the list, then the icons are explained by the legend and by accessible tooltip or label behavior. +- AE7. **Covers R23, R24, R29.** Given the list is virtualized, when rows enter and leave the visible window, then row height remains predictable and keyboard focus is not lost or skipped unexpectedly. +- AE8. **Covers R28, R31.** Given a user has dark mode or reduced motion enabled, when they use the toolbar, rows, tooltips, and copy feedback, then focus remains visible and motion is reduced or removed. +- AE9. **Covers R32, R33.** Given implementation is complete, when the frontend is reviewed with `$impeccable`, then the dense results surface passes product-register scrutiny for hierarchy, density, responsive layout, accessibility, and polish. + +--- + +## Success Criteria + +- The search page remains usable with a 10,000-entry mocked catalog. +- Initial render does not create thousands of result row DOM nodes. +- Scrolling keeps the rendered row count bounded. +- Search, filters, and sort remain responsive and reset the result window to the top. +- The sticky toolbar remains compact and visible during page scroll. +- Rows are dense enough to scan like a table while still exposing copy and detail actions. +- Status icons are understandable through a legend and accessible labels. +- Copy buttons, row links, tooltips, theme modes, and mobile layout continue to work. +- The implementation includes an Impeccable design and polish pass. + +--- + +## Scope Boundaries + +- Server-side search and remote result paging are out of scope for v1. +- Loading more results from an API as the user scrolls is out of scope for v1. +- Redesigning the detail page is out of scope except for preserving it as the inspection destination. +- Full warning explanations in every search result row are out of scope. +- Explicit timing targets such as a fixed millisecond filter budget are out of scope; behavioral performance acceptance is sufficient for this hardening pass. + +--- + +## Dependencies / Assumptions + +- The catalog can ship a compact client-side search index containing row-level fields without embedding full `CAPLET.md` bodies in the search page. +- The existing catalog detail route remains the canonical inspection surface. +- The catalog continues to use the Caplets product design system and Starwind component direction. +- The implementation environment can add or use a virtualization library compatible with the Astro catalog app. +- Status icon meanings are stable enough to document in a compact legend. +- The existing light, dark, and system theme controls remain part of the catalog UI. + +--- + +## Sources / Research + +- `docs/brainstorms/2026-06-26-caplets-catalog-search-site-requirements.md` for the broader catalog search-site scope. +- `apps/catalog/src/components/ResultList.astro` for the current full-list rendering behavior. +- `apps/catalog/src/components/CapletResult.astro` for the current full result-card shape. +- `apps/catalog/src/scripts/search.ts` for current client-side filtering, sorting, hiding, and DOM reordering behavior. +- `apps/catalog/src/lib/search-filter.ts` for current local search semantics. +- `PRODUCT.md` and `DESIGN.md` for the Caplets product design register and visual system. +- `$impeccable` product-register guidance for implementation-time UI shaping and polish. diff --git a/docs/plans/2026-06-26-002-feat-caplets-catalog-search-site-plan.md b/docs/plans/2026-06-26-002-feat-caplets-catalog-search-site-plan.md new file mode 100644 index 00000000..77975238 --- /dev/null +++ b/docs/plans/2026-06-26-002-feat-caplets-catalog-search-site-plan.md @@ -0,0 +1,343 @@ +--- +title: "feat: Build Caplets catalog search site" +type: feat +date: 2026-06-26 +origin: docs/brainstorms/2026-06-26-caplets-catalog-search-site-requirements.md +--- + +# feat: Build Caplets catalog search site + +## Summary + +Build `catalog.caplets.dev` as a separate search-first public catalog for Caplets. The catalog indexes official Caplets from this repo and community Caplets discovered from successful public external installs. It exposes readable Caplet content, normalized install commands, aggregate install counts, source labels, and loud safety warnings without claiming scanner coverage in v1. + +This release also adds install-time Vault onboarding for Caplets that reference Vault values, so users get grant or set-and-grant guidance immediately after install, restore, or update instead of only discovering runtime quarantine later. + +The implementation should keep Caplets Code Mode-first and agent-readable. The website, public JSON/API responses, and CLI JSON output should share catalog primitives rather than drifting into separate browser-only and agent-only representations. + +--- + +## Problem Frame + +Caplets now has a growing prebuilt catalog and lockfile-backed install/update behavior, but discovery still depends on knowing a repo and Caplet ID ahead of time. That is enough for a curated seed catalog, but not enough for a wider ecosystem where useful Caplets live outside this repo. + +The requirements call for the same discovery loop that makes skills.sh useful: installs from public sources create a discovery signal; search makes useful entries findable; detail pages expose enough source content for users to decide whether to install. Caplets has a higher-risk capability surface than ordinary prompt or skill catalogs, so the catalog must foreground inspection, source trust, unverified community status, and the fact that install counts are popularity signals, not safety signals. + +The public indexing signal cannot be ordinary anonymous telemetry. Anonymous telemetry intentionally rejects source URLs, Caplet IDs, hostnames, local paths, and other values that public catalog indexing needs to publish. Catalog indexing needs a separate, optically explicit, public-source-only contract with stricter eligibility checks and nonblocking failure behavior. + +Vault setup is part of the same install experience. A public catalog install can materialize a Caplet that is structurally valid but unusable until a Vault key exists and is granted to that Caplet. The CLI should detect that state immediately for the target runtime and guide the user without sending secrets to the catalog, lockfile, logs, JSON output, or install-count events. + +--- + +## Requirements + +Plan requirement IDs use `P-R*` so they do not collide with the origin brainstorm's `R1`-`R49` IDs. + +**Catalog Site And Design** + +- P-R1. Add a separate catalog site at `apps/catalog` and deploy it through the existing Alchemy/Cloudflare deployment family with production domain `catalog.caplets.dev`. +- P-R2. Make search the primary first interaction; install-copy is a conversion action only after users can inspect Caplet content, source, labels, warnings, and setup readiness. +- P-R3. Results and detail views expose name, description, tags, source owner/repo, official/community status, unverified status, aggregate install count, setup/auth readiness, primary Code Mode workflow or best available workflow summary, intended agent task, warning summary, and a normalized install command. +- P-R4. Follow the Caplets product register style: precise, calm, state-rich, and capability-focused. Use `$impeccable` during frontend implementation and avoid generic marketplace or SaaS landing-page patterns. +- P-R5. The frontend covers loading, empty, no-results, unavailable-content, error, copy-success, copy-failure, filter-reset, reduced-motion, mobile, and keyboard states with accessible names, visible focus, screen-reader status updates, and warnings that do not rely only on color. + +**Shared Catalog Model** + +- P-R6. Add shared catalog primitives for entries, source identities, install commands, warning summaries, install-count display, indexing eligibility, and indexing result status. The site, CLI JSON, indexing service, and public catalog read API consume these primitives. +- P-R7. Do not add a new `catalog` property to Caplet files or global config in v1. Derive catalog entries from existing Caplet content, parsed backend metadata, tags, `useWhen`, `avoidWhen`, setup/auth/project-binding/runtime data, lockfile/risk metadata, and source provenance. Missing optional fields become `unknown`, warnings, or lower-ranked facets rather than schema churn. +- P-R8. Generate install commands only from normalized source metadata and the indexed resolved revision when the source supports revision pinning. Do not copy install commands from community-authored Markdown. +- P-R9. Render indexed `CAPLET.md` content through a sanitized, non-executable Markdown path that strips or escapes unsafe HTML, scripts, event handlers, and dangerous URLs. +- P-R10. Expose the same catalog entry shape through a versioned public read API or static JSON endpoint so agents and tooling are not forced to scrape the website. + +**Indexing Sources And Storage** + +- P-R11. Seed official catalog entries from repo-owned Caplets under `caplets/`, using existing Caplet parsing rather than a parallel parser. +- P-R12. Add community entries from successful installs of public external sources only. +- P-R13. Public indexing publishes only public Caplet content, normalized install command, source identity needed to reproduce install, indexed resolved revision or content hash when available, aggregate install count, verification/official/community status, warning summaries, and safe derived metadata. +- P-R14. Public indexing never transmits, persists, logs, or publishes installer identity, local paths, private config, credentials, raw agent prompts, tool arguments, tool outputs, installer or runtime hostnames, private-source URLs, private or non-public source hostnames, non-public source URLs, or Vault secret values. The normalized public provider/source identity allowed by P-R13 is the only hostname-bearing data allowed to reach catalog storage or output. +- P-R15. Indexing eligibility rejects local paths, private or auth-only hosts, private IP ranges, unsupported providers, non-normalizable install commands, invalid Caplet files, unfetchable public Caplet files, and redirects to non-public destinations. +- P-R16. Store canonical entries, aggregate counts, dedupe/rate-limit state, and suppression records in Cloudflare-owned state. Low-count bucketing, source-key quotas, bounded fetch work, and suppression must prevent individual install events from becoming visible or easy to inflate. +- P-R17. Repeated installs of the same public source and Caplet update aggregate ranking signals without creating duplicate entries. +- P-R18. Add an internal operator suppression path for stale sources, takedown requests, private-source leakage, abuse, or high-risk behavior discovered before scanning/moderation exists. V1 must not add a public admin endpoint for suppression. + +**CLI, Remote, And Agent Surfaces** + +- P-R19. `caplets install`, no-argument lockfile restore, and `caplets update` emit public catalog indexing signals as best-effort side effects only after successful materialization of eligible public external Caplets. +- P-R20. Catalog indexing must not block install/update/restore when the indexer is unavailable, rate-limited, or rejects a source. +- P-R21. CLI human output includes a clear non-blocking notice for public external installs explaining that public Caplet content/source identity may be indexed. No extra confirmation prompt is added. +- P-R22. CLI JSON output includes stable indexing eligibility/status data without leaking rejected private source values or secrets. +- P-R23. Remote installs and updates emit or return indexing status from the runtime that performed the mutation. `--remote` continues to target remote global install state for this milestone. +- P-R24. Agent and tooling access in v1 is the versioned public catalog read API/static JSON plus CLI JSON indexing statuses. Installed-Caplet native and Code Mode inspection summaries are follow-up unless they fall out cheaply from shared data without expanding the launch gate. + +**Vault Install Onboarding** + +- P-R25. After install, restore, or update materializes Caplets that reference Vault values in runtime-resolved config fields, the CLI detects target-runtime Vault requirements for affected Caplets. +- P-R26. If the target Vault already contains a referenced key but the Caplet lacks a matching grant, prompt the user to grant that key to the Caplet. +- P-R27. If the target Vault does not contain a referenced key, prompt the user to set the key and grant it to the Caplet in the same target runtime. +- P-R28. Vault setup targets the runtime that owns the install scope: local project, local global, or remote global. Never copy secrets between runtime Vault stores. +- P-R29. Vault setup never writes raw secret values to lockfiles, catalog indexing signals, install-count events, logs, JSON output, or the catalog site. +- P-R30. Noninteractive, JSON, CI, and otherwise unpromptable installs do not hang or fail solely because Vault setup is unresolved; they return actionable `caplets vault set` and `caplets vault access grant` guidance while existing runtime quarantine remains the enforcement path. + +**Docs And Release** + +- P-R31. Public docs explain that catalog indexing is public-source indexing, not anonymous telemetry, and name the metadata that can become public after public external installs. +- P-R32. Docs and UI copy state that install counts are ranking/popularity signals, not safety, quality, endorsement, or verification signals. +- P-R33. Docs cover catalog search, public indexing, source suppression, community warnings, install-copy, lockfile restore/update interaction, and Vault install setup behavior. +- P-R34. Add a changeset for public CLI behavior, public indexing behavior, Vault install onboarding, and any package exports added for shared catalog primitives. +- P-R35. Add focused automated coverage for shared catalog normalization, stable entry keys, official indexing, community ingestion, source revision binding, privacy/log boundaries, ingest abuse controls, CLI indexing statuses, Vault onboarding, frontend rendering, deployment domains, and docs checks. + +--- + +## Key Technical Decisions + +- **KTD1. Build a new Astro app, not a docs page.** The catalog is a search product with its own domain, interaction model, data API, and deployment surface. It belongs in `apps/catalog`, deployed beside `apps/landing` and `apps/docs`. +- **KTD2. Use Cloudflare D1 for canonical catalog state and a bounded read model for v1 search.** Official entries can be generated from the repo, but community installs and ranking counts are dynamic. D1 is the smallest Cloudflare-native store for canonical entries, aggregate counts, suppression records, and dedupe/rate-limit state. V1 search should use a denormalized JSON/read API loaded by the site and filtered client-side, targeting a corpus under roughly 5,000 entries or a 2 MiB compressed index. Move to D1 FTS or dedicated search before exceeding those bounds or missing a sub-500 ms p95 index fetch target. +- **KTD3. Keep public catalog indexing separate from anonymous telemetry.** Do not widen `packages/core/src/telemetry/events.ts` or `packages/core/src/telemetry/privacy.ts` to allow public source identity. Add a separate catalog indexing client and payload schema with its own explicit eligibility rules, notices, docs, and tests. +- **KTD4. Add worker-safe shared catalog primitives with a clear support contract.** Put pure types and functions under `packages/core/src/catalog/` and export them through a dedicated package subpath. Keep the export free of Node-only filesystem/process APIs so the Cloudflare catalog app and core CLI can share normalization logic without violating package-boundary tests. The versioned `/api/v1/catalog` read API is the stable external discovery contract; mark the package export's v1 data-model helpers as experimental in docs/types so npm consumers do not mistake it for the long-term catalog API boundary. +- **KTD5. Do not add a Caplet `catalog` metadata property in v1.** The user explicitly does not want a skills.sh-incompatible metadata layer. Derive v1 catalog fields from existing Caplet data and mark missing optional facets as `unknown`. If later usage proves a field is essential, add it from evidence, not ahead of need. +- **KTD6. Treat install commands as generated, revision-bound artifacts.** The only install command shown by the site or returned in catalog JSON is generated from normalized source identity, Caplet ID, and indexed resolved revision when available. Community Markdown can explain usage, but it cannot author the copyable command. If the source cannot be pinned to the content the user inspected, hide the copy action and show a freshness warning rather than offering an unbound command. +- **KTD7. Use basic ingestion hygiene, not scanner claims.** V1 rejects invalid/non-public/unsafe-to-render inputs and supports suppression. It does not claim semantic scanning, dependency scanning, secret scanning, vulnerability scanning, moderation review, or certification. +- **KTD8. Keep install authoritative and indexing best-effort.** Install, restore, update, lockfile writes, local modification checks, and Vault setup remain the authoritative local/remote lifecycle. Catalog reporting is a nonblocking side effect with structured status output. +- **KTD9. Run Vault setup after materialization, before the user moves on.** Install/update/restore should complete the file/lock mutation first, then detect affected Vault references and guide setup. If setup is skipped or impossible, runtime quarantine still protects execution. +- **KTD10. Prompt only when the CLI can safely prompt.** Interactive TTY installs can prompt for grants and secret values. `--json`, non-TTY, CI, and remote contexts that cannot safely prompt return recovery commands and statuses without hanging. +- **KTD11. Remote Vault setup uses remote Vault commands.** For `--remote`, the local CLI may prompt the human, but set/grant operations must go through remote control so the secret lands in the remote global Vault store. The local machine must not persist or reuse the remote secret. +- **KTD12. Use Impeccable as a required implementation step.** The plan should reserve explicit implementation time for `$impeccable` shaping, critique, browser inspection, and polish rather than treating frontend design as CSS afterthought. +- **KTD13. Use an internal suppression script in v1.** Suppression writes should be performed by an operator-only script that mutates catalog D1 state with deployment credentials. Do not add a public suppression/admin endpoint until there is an authenticated admin product surface and audit model. +- **KTD14. Make count integrity privacy-preserving.** The install-signal endpoint is unauthenticated public ingestion by design. It must enforce body-size limits, global quotas, per-normalized-source refractory windows, idempotency keys based on normalized public source plus resolved revision/content hash, bounded fetch work, redacted `rate_limited`/`suppressed` statuses, and low-count display as `<10` until an entry has at least 10 accepted signals. Do not persist installer identity or raw client identifiers in app state. +- **KTD15. Choose a worker-compatible Markdown pipeline up front.** Use a `unified` pipeline with `remark-parse`, `remark-rehype` without raw HTML, `rehype-sanitize`, and `rehype-stringify`; allow only the minimal tags/attributes needed for readable Caplet docs and only `https:`, `http:`, and relative links after validation. Pair sanitizer fixtures with catalog CSP/security-header tests. + +--- + +## High-Level Technical Design + +```mermaid +flowchart TB + Repo["repo caplets/"] --> Official["official seed generator"] + Install["caplets install/update"] --> Signal["catalog indexing client"] + Signal --> API["catalog ingest API"] + API --> Fetch["public source fetch + parse"] + Official --> Normalize["shared catalog normalizer"] + Fetch --> Normalize + Normalize --> D1["Cloudflare D1 catalog state"] + D1 --> Read["catalog read API / JSON index"] + Read --> Site["apps/catalog search UI"] + Read --> Agents["agent/tooling consumers"] +``` + +```mermaid +flowchart LR + Source["normalized public source"] --> Eligibility["eligibility checks"] + Eligibility -->|public + supported| Payload["safe indexing payload"] + Eligibility -->|local/private/invalid| Skip["skip with redacted reason"] + Payload --> Ingest["best-effort POST"] + Ingest -->|accepted| Status["indexed or counted"] + Ingest -->|unavailable/rejected| Nonblock["install still succeeds"] +``` + +```mermaid +sequenceDiagram + participant User + participant CLI + participant Install as Install lifecycle + participant Vault as Target Vault + participant Remote as Remote control + + User->>CLI: caplets install repo github + CLI->>Install: materialize Caplet and update lock + Install-->>CLI: installed entries + config source origins + CLI->>Vault: inspect key/grant status for affected entries + alt key exists, grant missing + CLI-->>User: prompt to grant GH_TOKEN + User-->>CLI: yes + alt target is remote + CLI->>Remote: vault_access_grant + else target is local + CLI->>Vault: grant access + end + else key missing + CLI-->>User: prompt to set GH_TOKEN + User-->>CLI: secret value + alt target is remote + CLI->>Remote: vault_set / vault_access_grant + else target is local + CLI->>Vault: set and grant + end + end + CLI-->>User: install result + vault setup status +``` + +--- + +## Implementation Units + +### U1. Add shared catalog primitives and command normalization + +- **Goal:** Create the stable model consumed by the site, CLI, indexer, tests, and public catalog API. +- **Requirements:** P-R3, P-R6, P-R7, P-R8, P-R10, P-R22, P-R24 +- **Dependencies:** None +- **Files:** `packages/core/src/catalog/types.ts`, `packages/core/src/catalog/source.ts`, `packages/core/src/catalog/install-command.ts`, `packages/core/src/catalog/warnings.ts`, `packages/core/src/catalog/entry.ts`, `packages/core/src/catalog/index.ts`, `packages/core/package.json`, `packages/core/rolldown.config.ts`, `packages/core/test/catalog-model.test.ts`, `packages/core/test/package-boundaries.test.ts` +- **Approach:** Define `CatalogEntry`, `CatalogEntryKey`, `CatalogSourceIdentity`, `CatalogInstallCommand`, `CatalogWarning`, `CatalogIndexingEligibility`, `CatalogIndexingStatus`, and display-count helpers. Implement public-source normalization for public GitHub repositories and owner/repo shorthand without credentials; additional providers remain out of v1 until they meet the same public-source checks. Define stable `entryKey` derivation from provider, normalized owner/repo, normalized Caplet path, and Caplet ID, with official aliases and suppression aliases mapping to the same canonical key while revision fields stay separate. Implement install-command generation from normalized source identity, Caplet ID, and resolved revision when available. Implement derived workflow/readiness/warning summaries from existing Caplet fields and parsed runtime/risk metadata, with explicit `unknown` states for missing optional data. Export the module as `@caplets/core/catalog`, keep it worker-safe, and document `/api/v1/catalog` as the stable external discovery contract. +- **Test scenarios:** Owner/repo plus Caplet ID and resolved revision produces a pinned install command; official repo entries produce the official install command; credential-bearing URLs are rejected; local paths and private hosts return redacted ineligible statuses; `entryKey` handles case normalization, paths, official aliases, revision separation, and suppression aliases consistently; warnings include unverified community, local-control, mutating SaaS, auth/setup required, Project Binding required, and unknown readiness; generated commands never use Markdown content; the package-boundary test verifies the export is dedicated and worker-safe. +- **Verification:** Site and CLI code can import one pure model without pulling in Node-only core runtime code. + +### U2. Generate official catalog seed data from repo Caplets + +- **Goal:** Seed official catalog entries from `caplets/` with no duplicate parser. +- **Requirements:** P-R7, P-R11, P-R13, P-R35 +- **Dependencies:** U1 +- **Files:** `scripts/generate-catalog-index.ts`, `apps/catalog/src/data/official-catalog.json`, `packages/core/src/caplet-source/parse.ts`, `packages/core/src/caplet-source/filesystem.ts`, `packages/core/test/catalog-official-index.test.ts`, `package.json`, `turbo.json` +- **Approach:** Add a generation script that reads `caplets/` through the existing Caplet source parser, converts parsed Caplets into shared `CatalogEntry` records, marks them official, and writes a deterministic seed JSON file for `apps/catalog`. Keep the generated file deterministic through stable sorting and stable JSON. Official install counts can start at zero or merge with D1 aggregate counts at read time. +- **Test scenarios:** Every checked-in official Caplet parses; generated official entries include source, install command, readable content reference/body, tags, setup/auth readiness, workflow summary or `unknown`, and warning labels; malformed official Caplets fail generation; output ordering is stable; generated data does not include local absolute paths, credentials, or shadowing policy rules. +- **Verification:** The official catalog can be regenerated from source without hand-maintained catalog metadata. + +### U3. Add Cloudflare catalog infrastructure and data store + +- **Goal:** Deploy `catalog.caplets.dev` with Cloudflare-backed state for dynamic community indexing and ranking. +- **Requirements:** P-R1, P-R10, P-R12, P-R16, P-R17, P-R18, P-R35 +- **Dependencies:** U1, U2 +- **Files:** `apps/catalog/package.json`, `apps/catalog/astro.config.mjs`, `apps/catalog/migrations/0001_catalog.sql`, `apps/catalog/src/env.d.ts`, `apps/catalog/src/lib/catalog-store.ts`, `apps/catalog/src/lib/counts.ts`, `apps/catalog/src/pages/api/v1/catalog/index.ts`, `apps/catalog/src/pages/api/v1/catalog/entries/[entryKey].ts`, `apps/catalog/src/pages/api/v1/catalog/install-signals.ts`, `scripts/catalog-suppress.ts`, `alchemy.run.ts`, `infra/alchemy-domains.ts`, `infra/alchemy-runner.test.ts`, `.github/workflows/deploy.yml`, `.github/workflows/pr-preview-deploy.yml` +- **Approach:** Add `catalogPageDomain` and `catalogPageUrl` to domain construction, deploy `apps/catalog` beside landing/docs, and include catalog preview URLs in PR comments. Configure the catalog Astro app for Cloudflare/server output so API routes can bind to D1. Provision D1 through Alchemy for catalog state. Create tables for canonical entries, aggregate counts, source aliases/dedupe keys, suppression records, ingestion attempts with bounded retention, and idempotency keys that do not identify installers. The read API should merge official seed data with D1 community records and count overlays. The write API should accept only the strict public indexing signal envelope and return structured accepted/rejected/status responses. Add an internal `scripts/catalog-suppress.ts` operator path for suppression writes; do not expose a public admin endpoint in v1. +- **Test scenarios:** Production domain resolves to `catalog.caplets.dev`; preview domains use `catalog..preview.caplets.dev`; local dev uses a distinct port; PR preview comments include catalog URL; D1 migrations create expected tables; read APIs return official seed entries with counts; suppressed entries do not appear in search/detail responses; the suppression script can hide an entry without adding a public route; write API rejects malformed payloads and never echoes unsafe source values; body-size limits, global quotas, per-source refractory windows, idempotency keys, low-count `<10` display, and bounded fetch work prevent repeated public signals from arbitrarily inflating visible counts. +- **Verification:** The catalog can deploy independently from landing/docs while still using the existing Cloudflare deployment family. + +### U4. Implement community ingestion, fetch, sanitization, and suppression + +- **Goal:** Turn public install signals into safe, searchable community entries. +- **Requirements:** P-R9, P-R12, P-R13, P-R14, P-R15, P-R16, P-R17, P-R18, P-R35 +- **Dependencies:** U1, U3 +- **Files:** `apps/catalog/package.json`, `apps/catalog/src/lib/public-source.ts`, `apps/catalog/src/lib/ingest.ts`, `apps/catalog/src/lib/markdown.ts`, `apps/catalog/src/lib/rate-limit.ts`, `apps/catalog/src/lib/suppression.ts`, `apps/catalog/src/pages/api/v1/catalog/install-signals.ts`, `apps/catalog/test/ingest.test.ts`, `apps/catalog/test/markdown.test.ts`, `apps/catalog/test/suppression.test.ts` +- **Approach:** The indexer validates source eligibility, fetches only supported public provider raw content at the resolved revision when possible, follows redirects only while preserving public-source guarantees, parses selected Caplet files, sanitizes Markdown, computes the shared catalog entry, updates aggregate counts, and stores or refreshes the canonical entry. Use the Markdown pipeline from KTD15 and store both rendered sanitized content and the indexed revision/content hash used to generate the detail page. Dedupe by stable `entryKey`, not by ad hoc source strings. Suppression rules override both new ingestion and read responses. Store aggregate counts and display buckets; do not expose individual install events. Persist only categorical attempt status plus allowed public source metadata; do not log request bodies, raw rejected source values, credentials, client identifiers, Vault values, or unsafe exception payloads. +- **Test scenarios:** Public GitHub source ingests at the resolved revision; a branch changing between install signal and ingestion does not cause the catalog to publish a different inspected artifact; local path, private IP, localhost, unsupported host, credential URL, auth-required source, unsupported redirect, missing Caplet file, invalid frontmatter, oversized file, and unsafe Markdown are rejected or sanitized; repeated signals increment counts without duplicate entries; low counts display as `<10` and do not dominate ranking; suppression hides an entry and prevents re-ingestion; generated install command is independent of Markdown snippets; rejection and error paths do not persist or log unsafe values. +- **Verification:** Community indexing can publish only safe public content and aggregate ranking signals. + +### U5. Emit best-effort catalog indexing signals from install/update/restore + +- **Goal:** Make successful public external installs the v1 submission mechanism. +- **Requirements:** P-R12, P-R13, P-R14, P-R15, P-R19, P-R20, P-R21, P-R22, P-R23, P-R35 +- **Dependencies:** U1, U3, U4 +- **Files:** `packages/core/src/catalog-indexing/client.ts`, `packages/core/src/catalog-indexing/eligibility.ts`, `packages/core/src/catalog-indexing/payload.ts`, `packages/core/src/cli/install.ts`, `packages/core/src/cli.ts`, `packages/core/src/remote-control/types.ts`, `packages/core/src/remote-control/dispatch.ts`, `packages/core/test/catalog-indexing.test.ts`, `packages/core/test/cli.test.ts`, `packages/core/test/cli-remote.test.ts`, `packages/core/test/remote-control-dispatch.test.ts` +- **Approach:** After a lifecycle operation succeeds and lock entries are durable, evaluate installed entries for catalog-index eligibility. Eligible public external entries send a bounded payload to the catalog ingest endpoint containing normalized public source identity, Caplet path, Caplet ID, tracked ref, resolved revision when available, installed content hash, and stable `entryKey`; omit any payload that cannot be made public and revision-bound. Ineligible entries return categorical redacted reasons such as `not_public`, `unsupported_source`, `revision_unavailable`, or `suppressed`. Human output prints a concise notice for public external sources; JSON output includes indexing status. Remote install/update emits the signal from the remote process where practical, or returns enough nonsecret status for the local CLI to report. Any network/indexer failure is reported as a nonblocking status and never fails the local install. +- **Test scenarios:** Official repo install is already represented by official seed data and does not create duplicate community entries; public community install sends normalized source identity, Caplet ID, resolved revision, and installed hash; private/local/non-public installs skip without echoing raw private source; failed installs do not signal; an unresolved public revision returns `revision_unavailable`; indexer unavailable produces nonblocking status; `--json` includes stable status values; remote global install/update reports status without mutating local project lockfiles or leaking remote host details. +- **Verification:** Public external installs become discovery signals without weakening install correctness or privacy. + +### U6. Build the catalog search and detail frontend with Impeccable + +- **Goal:** Deliver the public search product and detail inspection experience. +- **Requirements:** P-R1, P-R2, P-R3, P-R4, P-R5, P-R8, P-R9, P-R10, P-R32, P-R35 +- **Dependencies:** U1, U2, U3 for an official-only preview; U4 before public community launch +- **Files:** `apps/catalog/src/pages/index.astro`, `apps/catalog/src/pages/caplets/[entryKey].astro`, `apps/catalog/src/components/SearchShell.astro`, `apps/catalog/src/components/FilterBar.astro`, `apps/catalog/src/components/ResultList.astro`, `apps/catalog/src/components/CapletResult.astro`, `apps/catalog/src/components/CapletDetail.astro`, `apps/catalog/src/components/InstallCommand.astro`, `apps/catalog/src/components/SafetyNotice.astro`, `apps/catalog/src/lib/security-headers.ts`, `apps/catalog/src/scripts/search.ts`, `apps/catalog/src/scripts/copy.ts`, `apps/catalog/src/styles/catalog.css`, `apps/catalog/test/catalog-ui.test.ts`, `apps/catalog/test/accessibility.test.ts` +- **Approach:** Use the root `PRODUCT.md` and `DESIGN.md` plus the existing landing implementation patterns as design context. Build a dense, search-first developer tool: search input, filters, sort, result count, official/community scopes, tags, setup/auth readiness, source owner/repo, warning badges, and result/detail states. Result cards may show a non-copy command preview and must route users to the detail page for copy. Copy actions live on detail pages only after readable sanitized content, indexed revision/hash, and warning context are visible. If content is unavailable, suppressed, or no longer fetchable, show safe metadata and warnings, link to the public source when allowed, hide the copy action, and explain that install is blocked until the content can be inspected again. Add restrictive catalog security headers including CSP, `base-uri 'none'`, `object-src 'none'`, and `frame-ancestors 'none'`. Run `$impeccable` during implementation for shaping and polish, then verify desktop/mobile screenshots and keyboard behavior. +- **Test scenarios:** Search for `sentry` shows official result with non-copy command preview and detail link; community result shows unverified warning; filtering by official/community/tags/setup works; no-results and reset states are accessible; detail renders sanitized content with indexed revision/hash before the copy action; unavailable-content detail hides copy and exposes recovery/source inspection guidance; unsafe HTML does not execute; security headers are present on community Markdown detail pages; copy success/failure updates an aria-live region; focus order is predictable; mobile controls do not overlap; reduced-motion users are not forced through animation; install-count copy states popularity only. +- **Verification:** Users can find, inspect, and copy install commands from a precise, accessible catalog surface. + +### U7. Stabilize public agent and tooling access + +- **Goal:** Prevent the catalog from becoming browser-only without expanding the launch gate into installed-Caplet native surface changes. +- **Requirements:** P-R6, P-R10, P-R24, P-R35 +- **Dependencies:** U1, U2, U3, U5 +- **Files:** `apps/catalog/src/pages/api/v1/catalog/index.ts`, `apps/catalog/src/pages/api/v1/catalog/entries/[entryKey].ts`, `packages/core/src/catalog/index.ts`, `apps/catalog/test/catalog-api.test.ts`, `packages/core/test/catalog-model.test.ts`, `apps/docs/src/content/docs/catalog.mdx` +- **Approach:** Treat `/api/v1/catalog` and `/api/v1/catalog/entries/[entryKey]` as the v1 agent/tooling surface. The response should use the shared catalog entry model, include generated install commands and warnings, and avoid requiring HTML scraping. CLI JSON indexing statuses should use the same status enums. Do not modify native tool descriptions, Code Mode declarations, or installed-Caplet `inspect()` as part of the launch gate; add those later only if the public API proves insufficient or the data threads through without extra behavior surface. +- **Test scenarios:** Public API list and detail responses include the shared catalog entry shape, warning labels, install command, indexed revision/hash, and setup/auth readiness; private/local skipped installs do not appear or expose raw source values; CLI JSON indexing status enum matches the public model; docs tell agents to use the public API/static JSON rather than scrape HTML; no native/Code Mode declaration snapshots churn in v1 solely for catalog summaries. +- **Verification:** Agents can search and inspect catalog entries through structured public data while the launch stays focused on the catalog site and indexing loop. + +### U8. Add install-time Vault setup detection and prompting + +- **Goal:** Guide users through missing Vault keys or grants immediately after install/restore/update. +- **Requirements:** P-R25, P-R26, P-R27, P-R28, P-R29, P-R30, P-R35 +- **Dependencies:** U3 for remote status reporting shape is helpful but not strictly required; U5 for shared install lifecycle result integration +- **Files:** `packages/core/src/cli/vault-onboarding.ts`, `packages/core/src/config.ts`, `packages/core/src/vault/index.ts`, `packages/core/src/cli/vault.ts`, `packages/core/src/cli/install.ts`, `packages/core/src/cli.ts`, `packages/core/src/remote-control/types.ts`, `packages/core/src/remote-control/dispatch.ts`, `packages/core/test/catalog-vault.test.ts`, `packages/core/test/cli.test.ts`, `packages/core/test/cli-remote.test.ts`, `packages/core/test/remote-control-dispatch.test.ts` +- **Approach:** Extract the existing unresolved-Vault detection logic from config parsing into a reusable helper rather than regex-scanning Markdown. After install/restore/update, re-load the target runtime config with the Vault bootstrap resolver, resolve the single active config source origin for each affected Caplet in the target runtime, filter Vault requirements to those active origins, and inspect the target Vault store for key/grant status. If an affected Caplet has zero active origins or multiple possible active origins after shadowing/disabled-set rules, do not write a grant; return recovery guidance naming the ambiguity. Interactive local installs prompt to grant an existing key or set-and-grant a missing key. Interactive remote installs prompt locally but execute set/grant through remote Vault control commands. Noninteractive/JSON installs return `vaultSetup` statuses and recovery commands. Set-and-grant should be rollback-safe enough that a failed grant does not leave an ambiguous success message. +- **Test scenarios:** Installing a `$vault:GH_TOKEN` Caplet with existing key and missing grant prompts grant; missing key prompts set-and-grant; declined prompt leaves install successful with recovery guidance; `--json` and non-TTY never prompt; raw secret never appears in output, logs, lockfile, catalog signal, or JSON; project/global origins produce correct grant scope; shadowed project/global Caplets, disabled sets, zero-origin cases, and multiple-origin ambiguity return recovery guidance instead of writing the wrong grant; remote global uses remote Vault operations; remote failures return redacted actionable errors; runtime quarantine behavior remains unchanged when setup is unresolved. +- **Verification:** Vault-backed Caplets become usable sooner without weakening secret boundaries. + +### U9. Update docs, privacy copy, and release metadata + +- **Goal:** Make the new public discovery loop understandable and release-ready. +- **Requirements:** P-R21, P-R31, P-R32, P-R33, P-R34, P-R35 +- **Dependencies:** U1-U8 +- **Files:** `apps/docs/src/content/docs/install.mdx`, `apps/docs/src/content/docs/vault.mdx`, `apps/docs/src/content/docs/capabilities.mdx`, `apps/docs/src/content/docs/catalog.mdx`, `apps/docs/src/content/docs/privacy/indexing.mdx`, `apps/docs/astro.config.mjs`, `README.md`, `.changeset/*.md` +- **Approach:** Document catalog search, public indexing, install-count meaning, community warnings, internal suppression, revision-bound install commands, and the exact public/private metadata boundary. Update install docs to mention nonblocking public indexing notices for public external installs and how lockfile restore/update interact with catalog signals. Update Vault docs with install-time prompt behavior and noninteractive recovery commands. Add docs navigation for catalog/indexing privacy. Document `/api/v1/catalog` as the supported public discovery API and `@caplets/core/catalog` as an experimental shared implementation export in v1. Add a changeset covering CLI output/behavior, public indexing behavior, public package export, and Vault onboarding. +- **Test scenarios:** Docs say `caplets update`, not `upgrade`; docs distinguish public catalog indexing from anonymous telemetry; docs list what may become public and what must never become public; docs state install counts are not safety signals; docs do not claim scanner coverage; docs show revision-bound install-copy behavior; docs show `caplets vault set` and `caplets vault access grant` recovery commands for noninteractive installs; changeset exists unless the PR is explicitly labeled `no changeset`. +- **Verification:** Users can understand the discovery loop and privacy boundary before installing public community Caplets. + +--- + +## System-Wide Impact + +- **Public app surface:** Adds a third public Astro app and a new production domain. This touches Alchemy deployment, preview comments, local dev ports, and Cloudflare state. +- **Core package exports:** Adds a worker-safe catalog export and tests to keep it separate from Node-only runtime code. +- **Install lifecycle:** Adds best-effort public indexing status and Vault setup results after install/restore/update. This must not destabilize existing lockfile behavior. +- **Remote control:** Remote install/update and remote Vault setup need nonsecret status propagation and must keep remote global state on the remote machine. +- **Privacy model:** Public catalog indexing becomes a separate data path from anonymous telemetry. Tests should prove telemetry privacy remains restrictive. +- **Agent surfaces:** The versioned public catalog read API and CLI JSON statuses are the v1 agent-readable surfaces, so agent discovery stays aligned with the website without forcing installed native/Code Mode changes into launch. +- **Docs and release:** Public docs, changesets, and likely deployment workflow changes are part of the implementation, not optional follow-up. + +--- + +## Risks And Mitigations + +- **Private source or secret leakage.** Mitigation: separate indexing client, strict eligibility schema, redacted skip reasons, no telemetry allowlist expansion, source fetch allowlist, private IP/redirect rejection, and tests for local/private/token/hostname/path cases. +- **Install counts being mistaken for trust.** Mitigation: UI and docs copy near ranking and install actions; count label should read as popularity/ranking only; no score language. +- **Website-only catalog.** Mitigation: shared catalog model, stable public read API/JSON, and CLI JSON statuses. +- **Markdown XSS or command injection.** Mitigation: sanitized Markdown rendering and generated install commands from normalized source metadata only. +- **Inspecting one revision but installing another.** Mitigation: catalog entries store indexed revision/content hash, detail pages show that revision, and copyable install commands are pinned to the inspected revision when supported; otherwise copy is hidden. +- **Remote Vault secret mishandling.** Mitigation: prompt locally only for human entry, execute set/grant through remote Vault commands, never persist locally, and test JSON/log redaction. +- **Indexer outage affecting installs.** Mitigation: indexing happens only after successful install and failures return nonblocking statuses. +- **D1/search complexity creep.** Mitigation: use D1 for canonical state and a simple denormalized read model for v1; defer advanced search ranking infrastructure until actual scale requires it. +- **Install-count abuse.** Mitigation: source-key quotas, idempotency keys, refractory windows, low-count bucketing, bounded fetch work, suppression, and no installer identity in app state. +- **Schema churn through a new Caplet `catalog` property.** Mitigation: explicitly avoid the property in v1; derive from existing fields and tolerate `unknown` optional facets. +- **Scanner scope creep.** Mitigation: v1 stop rule is suppression plus hygiene. Do not imply semantic or dependency scanning until a separate requirements pass exists. + +--- + +## Scope Boundaries + +- Semantic malicious-intent scanning, dependency scanning, vulnerability scanning, secret scanning, OpenSSF Scorecard, Socket-style audit integration, and LLM review are out of scope for v1. +- Ratings, reviews, comments, publisher profiles, verified publisher programs, manual moderation queues, and hosted accounts are out of scope. +- Installed-Caplet native/Code Mode catalog summaries and a dedicated `caplets catalog search` or `caplets catalog get` CLI command are deferred unless implementation proves the public JSON endpoint is insufficient for agent/tool access. +- Site-side Vault secret entry is out of scope. Vault setup belongs to the CLI and target runtime. +- Remote project install/update/Vault semantics remain out of scope; `--remote` targets remote global state for this milestone. +- The catalog does not replace `docs.caplets.dev` and should link to docs rather than becoming the docs site. + +--- + +## Open Questions + +- **Public read API response shape:** Decide final payload fields, pagination shape, and search parameters during implementation; keep endpoint names versioned under `/api/v1/catalog/`. +- **Dedicated CLI catalog search:** Keep deferred for v1 unless agent testing shows the public JSON endpoint is not enough. + +--- + +## Testing Strategy + +- Run focused core tests for catalog primitives, official seed generation, indexing eligibility, CLI install/update/restore status, Vault onboarding, remote dispatch, and telemetry privacy: + - `pnpm --filter @caplets/core test -- test/catalog-model.test.ts test/catalog-official-index.test.ts test/catalog-indexing.test.ts test/catalog-vault.test.ts test/cli.test.ts test/cli-remote.test.ts test/remote-control-dispatch.test.ts test/telemetry-redaction.test.ts test/telemetry-events.test.ts test/package-boundaries.test.ts` +- Run catalog app tests: + - `pnpm --filter @caplets/catalog test` + - `pnpm --filter @caplets/catalog typecheck` + - `pnpm --filter @caplets/catalog build` +- Run generated artifact checks when adding exports or generated official data: + - `pnpm schema:check` + - `pnpm docs:check` + - `pnpm code-mode:check-api` +- Run deployment/domain tests: + - `pnpm test -- infra/alchemy-runner.test.ts` +- Run browser and accessibility checks during frontend implementation, including desktop/mobile screenshots, keyboard-only search/filter/detail/copy flows, reduced-motion checks, and sanitized Markdown fixture pages. +- Finish with the repo gate before PR: + - `pnpm verify` + +--- + +## Sources / Research + +- `docs/brainstorms/2026-06-26-caplets-catalog-search-site-requirements.md` for source requirements. +- `STRATEGY.md`, `CONCEPTS.md`, `PRODUCT.md`, and `DESIGN.md` for product, vocabulary, and design direction. +- `alchemy.run.ts`, `infra/alchemy-domains.ts`, `.github/workflows/deploy.yml`, and `.github/workflows/pr-preview-deploy.yml` for existing public-site deployment. +- `packages/core/src/caplet-source/parse.ts`, `packages/core/src/caplet-files.ts`, and `caplets/` for official Caplet parsing. +- `packages/core/src/cli.ts`, `packages/core/src/cli/install.ts`, and `packages/core/src/cli/lockfile.ts` for install/update/restore and lockfile behavior. +- `packages/core/src/remote-control/types.ts` and `packages/core/src/remote-control/dispatch.ts` for remote install/update and Vault command routing. +- `packages/core/src/config.ts`, `packages/core/src/vault/index.ts`, `docs/product/caplets-vault.md`, and `apps/docs/src/content/docs/vault.mdx` for Vault reference and grant behavior. +- `docs/product/anonymous-telemetry.md`, `packages/core/src/telemetry/privacy.ts`, and telemetry tests for privacy boundaries. +- `packages/core/src/cli/inspection.ts`, `packages/core/src/registry.ts`, `packages/core/src/capability-description.ts`, `packages/core/src/native/tools.ts`, and Code Mode tests for current installed-Caplet inspection surfaces considered and kept out of the v1 launch gate. +- `docs/solutions/integration-issues/vault-cli-runtime-integration-fixes.md`, `docs/solutions/integration-issues/stale-remote-profile-credentials-refresh.md`, `docs/solutions/architecture-patterns/code-mode-repl-sessions.md`, and `docs/solutions/architecture-patterns/native-daemon-service-management.md` for prior repo learnings relevant to Vault, remote credentials, agent-facing runtime state, and lifecycle boundaries. +- `https://www.skills.sh/` and `https://github.com/vercel-labs/skills` for the public discovery pattern the requirements reference. diff --git a/docs/plans/2026-06-27-001-refactor-catalog-starwind-migration-plan.md b/docs/plans/2026-06-27-001-refactor-catalog-starwind-migration-plan.md new file mode 100644 index 00000000..47a23f1c --- /dev/null +++ b/docs/plans/2026-06-27-001-refactor-catalog-starwind-migration-plan.md @@ -0,0 +1,170 @@ +--- +title: "refactor: Migrate catalog site to Starwind" +type: refactor +date: 2026-06-27 +origin: docs/brainstorms/2026-06-26-caplets-catalog-search-site-requirements.md +--- + +# refactor: Migrate catalog site to Starwind + +## Summary + +Migrate the catalog search site from bespoke `catalog.css` component styling to the same Starwind + Tailwind foundation used by the landing site, while preserving the existing catalog product behavior: search, filters, result cards, detail pages, warnings, frontmatter table, install command copy, and light/dark/system theme selection. + +The migration should be treated as a presentation-system refactor, not a catalog product redesign. Data ingestion, indexing, API shape, install-count behavior, and CLI catalog behavior stay out of scope unless the UI migration exposes a small bug in the existing browser-facing surface. + +--- + +## Problem Frame + +The catalog site currently matches the intended catalog flows, but it carries its own hand-written styling system in `apps/catalog/src/styles/catalog.css`. That makes it drift from the Starwind-based public site stack and increases the cost of future UI polish. Recent browser feedback has also been fixing low-level UI rendering details one by one: brand icon rendering, frontmatter formatting, hidden search results, detail-page metadata, and theme controls. Starwind gives the catalog a more durable component and token foundation so those fixes live in a shared UI vocabulary instead of accumulating as isolated CSS rules. + +The origin requirements still govern the product: the catalog must be a precise, search-first, inspection-first developer tool, not a generic marketplace (see origin: `docs/brainstorms/2026-06-26-caplets-catalog-search-site-requirements.md`). This plan migrates the frontend implementation that serves those requirements; it does not expand v1 scanning, moderation, indexing, or install lifecycle scope. + +--- + +## Requirements + +- R1. The catalog app must use Starwind/Tailwind as its primary presentation layer, following the existing `apps/landing` Starwind setup and token conventions. +- R2. The migration must preserve the current catalog search, filter, sort, result count, result visibility, detail routing, copy command, warning, and no-result behaviors. +- R3. The catalog must keep the existing light/dark/system theme dropdown behavior, but implement theme state through Starwind's `.dark` convention rather than catalog-only `data-theme` styling. +- R4. Shared surfaces such as buttons, badges, cards or panels, tables, separators, and disclosure/menu controls should use Starwind primitives or app-local Starwind-compatible wrappers instead of bespoke class families. +- R5. The Caplets brand icon must render consistently in the header on the search and detail pages, with the dark icon background filling the visible icon container. +- R6. The detail-page `CAPLET.md` frontmatter table must remain readable and compact, with table semantics preserved through Starwind table styling. +- R7. Public-facing detail metadata must omit internal-only fields such as `entryKey`, while keeping useful source path, workflow, install count, revision, content hash, auth, setup, and Project Binding readiness. +- R8. The migration must not reintroduce inline-script CSP problems; theme bootstrap, search, and copy behavior stay in external script modules. +- R9. The catalog must remain responsive and keyboard-accessible across desktop and mobile, including visible focus states, usable touch targets, and non-overlapping text. +- R10. The app should retire most of `apps/catalog/src/styles/catalog.css`; any remaining CSS must be limited to Starwind token setup, hard-to-express app-specific behavior, or small compatibility rules such as `[hidden]`. + +--- + +## Scope Boundaries + +- **In scope:** `apps/catalog` Starwind setup, app-local Starwind components or wrappers, catalog page/layout components, theme/search/copy script compatibility, focused UI characterization tests, and browser verification scenarios. +- **Out of scope:** Catalog ingestion semantics, D1 schema, install-signal ranking, public catalog API contracts, CLI install/update behavior, Vault onboarding, scanning, moderation, and new catalog content. +- **Deferred:** Extracting a cross-app `packages/ui` design-system package. If duplication between `apps/landing` and `apps/catalog` becomes a sustained maintenance problem, extract it later from two working app-local Starwind implementations. + +--- + +## Key Technical Decisions + +- KTD1. **Use app-local Starwind components for the catalog.** Follow `apps/landing/src/components/starwind`, but do not import from `apps/landing`; app-to-app imports blur deploy boundaries and make aliases brittle. Copy or generate the needed Starwind primitives under `apps/catalog/src/components/starwind`. +- KTD2. **Keep the migration behavior-preserving.** This is a refactor of the UI system. The search page and detail page should look cleaner and more consistent, but result inclusion, command text, warnings, public metadata, and Markdown rendering behavior should not change except where the user has already requested corrections. +- KTD3. **Use Starwind's `.dark` theme convention with the existing three-way user choice.** Keep `Light`, `Dark`, and `System` as user-facing modes, but have `ThemeInit.astro`, `theme-init.ts`, and `theme.ts` apply or remove the `dark` class on `` so Tailwind dark variants work. +- KTD4. **Prefer Starwind primitives over a new catalog design system.** Use Starwind `Button`, `Badge`, `Card`, `Table`, `Separator`, and menu/sheet/dialog primitives where they map naturally. Catalog-specific wrappers are acceptable for domain surfaces such as `CapletResult`, `SafetyNotice`, and `InstallCommand`. +- KTD5. **Do not add a shared UI package during this pass.** The implementation can reuse landing patterns by inspection, but moving Starwind components to a package would create dependency and bundling decisions unrelated to making this catalog page match the other sites. +- KTD6. **Keep iconography consistent with current catalog feedback.** Continue using Huge Icons for catalog-specific icon controls unless a Starwind primitive requires its default icon path. Do not reintroduce hand-rolled glyphs. +- KTD7. **Test the risky behavior seams directly.** CSS migration risk is highest around hidden filtered results, theme class application, copy status, and detail content rendering. Add focused tests around the pure filtering and theme logic where possible, then rely on browser inspection for layout and interaction polish. + +--- + +## High-Level Technical Design + +```mermaid +flowchart TB + Landing["apps/landing Starwind patterns"] --> CatalogStarwind["apps/catalog/src/components/starwind"] + CatalogStarwind --> Layout["Catalog layout/header/theme"] + CatalogStarwind --> Search["Search/filter/results"] + CatalogStarwind --> Detail["Detail/frontmatter/record"] + Tokens["apps/catalog/src/styles/starwind.css"] --> Layout + Tokens --> Search + Tokens --> Detail + Scripts["external scripts: theme/search/copy"] --> Layout + Scripts --> Search + Scripts --> Detail +``` + +The catalog should end with a small Starwind token file, app-local Starwind primitives, Tailwind utility classes in Astro components, and only narrow custom CSS for behavior Starwind does not cover cleanly. + +--- + +## Implementation Units + +### U1. Establish catalog Starwind baseline + +- **Goal:** Give `apps/catalog` the same Starwind/Tailwind foundation as the landing site without coupling the two apps. +- **Requirements:** R1, R4, R10 +- **Dependencies:** None +- **Files:** `apps/catalog/package.json`, `apps/catalog/astro.config.mjs`, `apps/catalog/tsconfig.json`, `apps/catalog/starwind.config.json`, `apps/catalog/src/styles/starwind.css`, `apps/catalog/src/styles/catalog.css`, `apps/catalog/src/components/starwind/button/*`, `apps/catalog/src/components/starwind/badge/*`, `apps/catalog/src/components/starwind/card/*`, `apps/catalog/src/components/starwind/table/*`, `apps/catalog/src/components/starwind/separator/*` +- **Patterns:** Follow `apps/landing/package.json`, `apps/landing/astro.config.mjs`, `apps/landing/tsconfig.json`, `apps/landing/src/styles/starwind.css`, and `apps/landing/src/components/starwind/*`. +- **Approach:** Add the Starwind dependencies already used by the landing site where missing, keep the existing Tailwind Vite plugin, add the `@/*` alias for catalog components, and create a catalog `starwind.css` with Caplets tokens that support both light and dark mode. Move base styles, focus rings, fonts, and color tokens into Starwind/Tailwind. Keep `catalog.css` temporarily during migration, then shrink it as units move. +- **Test scenarios:** `apps/catalog` typecheck and build still resolve Astro aliases and Starwind component imports; Starwind token colors compile in light and dark mode; no `apps/catalog` code imports Starwind components from `apps/landing`; `catalog.css` no longer defines a parallel full-page token system after migration. +- **Verification:** `pnpm --filter @caplets/catalog typecheck` and `pnpm --filter @caplets/catalog build`. + +### U2. Migrate shell, header, brand, and theme controls + +- **Goal:** Replace the custom header/theme styling with Starwind-compatible layout and theme behavior. +- **Requirements:** R1, R3, R4, R5, R8, R9 +- **Dependencies:** U1 +- **Files:** `apps/catalog/src/components/CatalogHeader.astro`, `apps/catalog/src/components/ThemeToggle.astro`, `apps/catalog/src/components/ThemeInit.astro`, `apps/catalog/src/components/HugeIcon.astro`, `apps/catalog/src/scripts/theme.ts`, `apps/catalog/src/scripts/theme-init.ts`, `apps/catalog/src/lib/theme.ts`, `apps/catalog/test/theme.test.ts`, `apps/catalog/src/pages/index.astro`, `apps/catalog/src/pages/caplets/[entryKey].astro` +- **Patterns:** Follow the header structure in `apps/landing/src/components/landing/Header.astro` and the `.dark` setup in `apps/landing/src/styles/starwind.css`, while preserving the catalog's light/dark/system dropdown request. +- **Approach:** Move page shell concerns into Starwind utility classes, keep the header sticky and compact, render the real Caplets icon with a full dark icon container, and migrate theme state to a small shared theme helper used by both early bootstrap and interactive dropdown code. The dropdown should remain a real menu-like control with icon, label, selected state, and outside-click/escape handling. +- **Test scenarios:** `system` mode follows `matchMedia("(prefers-color-scheme: dark)")`; `light` removes the `dark` class; `dark` adds the `dark` class; invalid stored values fall back to `system`; the selected menu item exposes the right checked state; header branding renders on both index and detail pages; no inline script is required for theme bootstrap. +- **Verification:** `apps/catalog/test/theme.test.ts`, typecheck, build, and browser inspection of theme switching on `/` and `/caplets/.../`. + +### U3. Migrate search, filters, and result list + +- **Goal:** Move the search surface onto Starwind primitives while preserving the current client-side behavior and fixing result visibility regressions. +- **Requirements:** R1, R2, R4, R8, R9, R10 +- **Dependencies:** U1 +- **Files:** `apps/catalog/src/components/SearchShell.astro`, `apps/catalog/src/components/FilterBar.astro`, `apps/catalog/src/components/ResultList.astro`, `apps/catalog/src/components/CapletResult.astro`, `apps/catalog/src/components/InstallCommand.astro`, `apps/catalog/src/components/SafetyNotice.astro`, `apps/catalog/src/scripts/search.ts`, `apps/catalog/src/lib/search-filter.ts`, `apps/catalog/test/search-filter.test.ts`, `apps/catalog/test/catalog-api.test.ts` +- **Patterns:** Use Starwind `Card`, `Badge`, `Button`, and form-compatible Tailwind classes rather than `.search-shell__*`, `.caplet-result__*`, `.chip`, and `.tag-row` class families. +- **Approach:** Extract the filter predicate and sort behavior from `search.ts` into a pure helper so the exact matching logic is protected before CSS classes change. Convert the search panel, filter bar, result count, warning badges, tag badges, command preview, and inspect action to Starwind/Tailwind classes. Keep `[hidden] { display: none !important; }` in the remaining compatibility CSS or equivalent global layer so filtered-out results cannot render due to utility-class changes. +- **Test scenarios:** Searching `ast-grep` shows the `ast-grep` result and hides nonmatching entries; searching a term that only matches tags still works; scope, setup, tag, and sort controls compose with text search; result count singular/plural text matches visible results; reset returns to default result set; command previews remain non-copy detail affordances; warning badges include text and icon, not color only. +- **Verification:** `apps/catalog/test/search-filter.test.ts`, typecheck, build, and browser inspection of `/` at desktop and mobile widths. + +### U4. Migrate detail page, Markdown content, and record metadata + +- **Goal:** Move the detail view to Starwind table/panel primitives while preserving inspection-first content and public metadata boundaries. +- **Requirements:** R1, R2, R4, R6, R7, R8, R9, R10 +- **Dependencies:** U1, U2 +- **Files:** `apps/catalog/src/components/CapletDetail.astro`, `apps/catalog/src/components/InstallCommand.astro`, `apps/catalog/src/components/SafetyNotice.astro`, `apps/catalog/src/lib/markdown.ts`, `apps/catalog/test/markdown.test.ts`, `apps/catalog/src/pages/caplets/[entryKey].astro` +- **Patterns:** Use Starwind `Table` components for frontmatter rows, Starwind `Card` or panel classes for record/warning surfaces, and Starwind `Button` for copy/back actions. +- **Approach:** Preserve the frontmatter extraction path and sanitized Markdown renderer. Replace the current frontmatter table styling with Starwind table components and Tailwind classes that handle long values without overflow. Keep the copyable install command near the top but maintain the inspection-first warning and content context. Keep `entryKey` out of public detail metadata while preserving source path, workflow, install count, revision, content hash, auth, setup, and Project Binding readiness. +- **Test scenarios:** Frontmatter rows flatten and render as table rows; long schema/frontmatter values wrap or scroll without breaking the layout; unsafe Markdown stays stripped or escaped; detail pages do not render `Entry key`; unavailable entries show a Starwind-styled recovery state; copy button status updates remain accessible; record panel remains readable in light and dark modes. +- **Verification:** `apps/catalog/test/markdown.test.ts`, typecheck, build, and browser inspection of at least one official detail page in light and dark mode. + +### U5. Retire bespoke CSS and verify visual parity + +- **Goal:** Finish the migration by deleting or shrinking old CSS and checking the browser experience end to end. +- **Requirements:** R1, R8, R9, R10 +- **Dependencies:** U2, U3, U4 +- **Files:** `apps/catalog/src/styles/catalog.css`, `apps/catalog/src/styles/starwind.css`, `apps/catalog/src/pages/index.astro`, `apps/catalog/src/pages/caplets/[entryKey].astro`, `apps/catalog/src/components/*.astro` +- **Patterns:** Follow the landing site's split between `apps/landing/src/styles/starwind.css` for tokens/base and small component-local utility classes in Astro files. +- **Approach:** Remove obsolete BEM-style class rules after each component migrates. Keep only narrow app-global rules that are still justified: skip-link behavior, hidden compatibility, copy status positioning if not expressible cleanly in utilities, and Markdown content defaults if component-scoped utilities would be worse. Run a final pass for one-note palette drift, text overflow, focus visibility, reduced-motion handling, and mobile layout density. +- **Test scenarios:** No remaining large class families such as `.catalog-header__*`, `.search-shell__*`, `.caplet-result__*`, `.detail-grid__*`, or `.theme-menu__*` carry primary layout styling; search and detail pages render with Starwind tokens in light, dark, and system modes; browser console has no CSP or module import errors; mobile header/actions do not overlap; selected controls and buttons have visible focus states. +- **Verification:** `pnpm --filter @caplets/catalog typecheck`, `pnpm --filter @caplets/catalog build`, and browser-use or manual browser inspection of `/` and one `/caplets/.../` route. + +--- + +## Acceptance Examples + +- AE1. Given the catalog home loads, when a user searches for `ast-grep`, then only matching result rows are visible and the result count matches the visible rows. +- AE2. Given the user opens the theme dropdown, when they select `Dark`, `Light`, or `System`, then the `` class state and visible selected menu state match the choice without inline-script CSP errors. +- AE3. Given an official detail page is opened, when the user inspects `CAPLET.md`, then frontmatter appears as a readable table and Markdown content remains sanitized. +- AE4. Given a detail page renders the record panel, when the user scans public metadata, then internal `entryKey` is absent and source path, workflow, installs, revision, content hash, auth, setup, and Project Binding readiness remain present. +- AE5. Given the catalog header appears on the search and detail pages, when the page is viewed in light or dark mode, then the real Caplets brand icon fills the icon container with its dark mark background. +- AE6. Given the site is viewed on a narrow mobile viewport, when the user tabs through search, filters, result cards, theme controls, and copy actions, then focus remains visible and controls do not overlap or truncate critical text. + +--- + +## Risks & Dependencies + +- **Starwind component duplication:** App-local components duplicate landing primitives. This is intentional for now; extracting too early would mix a focused refactor with package design work. +- **Theme flash risk:** Moving from `data-theme` to `.dark` can cause a light/dark flash if bootstrap timing regresses. Keep the external bootstrap small and loaded before first paint-sensitive rendering. +- **Hidden-result regression:** Tailwind utility changes can accidentally override result hiding. Protect the pure filtering logic with tests and keep a global hidden rule. +- **Icon dependency mismatch:** Landing uses Starwind's default icon path while the catalog recently moved to Huge Icons. The migration should avoid hand-rolled glyphs and keep catalog icons consistent unless implementation discovers a Starwind primitive with a hard icon dependency. +- **Visual churn:** Starwind spacing and border defaults can make the page feel generic if applied mechanically. Use the Caplets token palette and existing product-register density as constraints. + +--- + +## Sources / Research + +- `docs/brainstorms/2026-06-26-caplets-catalog-search-site-requirements.md` for catalog product requirements and design constraints. +- `docs/plans/2026-06-26-002-feat-caplets-catalog-search-site-plan.md` for the broader catalog implementation plan this refactor builds on. +- `CONCEPTS.md` for the Catalog Search Site and public indexing vocabulary. +- `apps/catalog/src/pages/index.astro` and `apps/catalog/src/pages/caplets/[entryKey].astro` for current route structure. +- `apps/catalog/src/components/` for current catalog UI component boundaries. +- `apps/catalog/src/styles/catalog.css` for the bespoke styling to retire. +- `apps/landing/src/styles/starwind.css` and `apps/landing/src/components/starwind/` for the repo's established Starwind pattern. +- `apps/landing/src/components/landing/Header.astro` for Starwind-based header and brand treatment. diff --git a/docs/plans/2026-06-27-002-feat-catalog-virtualized-results-plan.md b/docs/plans/2026-06-27-002-feat-catalog-virtualized-results-plan.md new file mode 100644 index 00000000..915b36a9 --- /dev/null +++ b/docs/plans/2026-06-27-002-feat-catalog-virtualized-results-plan.md @@ -0,0 +1,269 @@ +--- +title: "feat: Virtualize catalog search results" +type: feat +date: 2026-06-27 +origin: docs/brainstorms/2026-06-27-catalog-search-virtualized-results-requirements.md +--- + +# feat: Virtualize catalog search results + +## Summary + +Replace the catalog search page's full-card result list with a dense, virtualized, table-like results surface. The page will keep local full-index search semantics and URL state while rendering only a bounded row window for large result sets. + +--- + +## Problem Frame + +The catalog search page currently renders every result as a full card and then hides, sorts, and reorders those DOM nodes in the browser. That creates too much DOM and too much per-filter mutation for a catalog that is expected to scale toward thousands of official and community entries. + +The search page is also carrying detail-page work. Dense rows should help users scan, copy, and open detail pages quickly, while full warnings, source metadata, and `CAPLET.md` inspection stay on the detail route. + +--- + +## Requirements + +**Dense Results** + +- R1. The search page renders dense table-like rows with title, official or community status, install count, truncated description, truncated install command, copy action, and compact status icons. +- R2. Full warning explanations and complete `CAPLET.md` inspection remain on the detail page. +- R3. Rows expose a detail route and a copyable install command without recreating the old full-card layout. + +**Virtualized Search Behavior** + +- R4. Search, filters, sort, counts, and URL state operate against the complete compact local index. +- R5. The visible DOM row count remains bounded when the catalog contains 10,000 entries. +- R6. Search, filter, and sort changes reset the virtualized window to the top while preserving focus in the active control. +- R7. Page scrolling remains the primary scroll model; the implementation must not introduce a nested result scroll panel. + +**Toolbar, Status, And Accessibility** + +- R8. Search input, filters, sort, result count, and status legend live in a compact sticky toolbar. +- R9. Local control, setup, Project Binding, Vault, readiness, and community status are represented by Huge Icons with a compact legend and accessible labels or tooltips. +- R10. Copy buttons, row links, filters, sort controls, status explanations, no-results state, and result-count updates are keyboard and screen-reader accessible. +- R11. The dense surface remains usable in light, dark, system theme, reduced-motion, desktop, tablet, and mobile contexts. + +**Implementation Process** + +- R12. Use a proven headless virtualizer rather than hand-rolling scroll measurement and overscan. +- R13. Use `$impeccable` during implementation for product-register shaping, density, responsive behavior, accessibility, status-icon comprehension, and browser polish. + +--- + +## Key Technical Decisions + +- KTD1. **Use a compact row model as the search data source.** The current DOM-derived search data must be replaced with serialized row records so filtering never depends on pre-rendering every result card. +- KTD2. **Use `@tanstack/virtual-core` for virtualization.** The package is headless TS/JS virtualization, so the Astro page can stay framework-light while the client script owns the visible result window. +- KTD3. **Use window/page virtualization.** The virtualizer should track the browser scroll position rather than a nested result container, preserving normal page scrolling and sticky-toolbar behavior. +- KTD4. **Render rows from client-side templates and event delegation.** Virtual rows are transient, so copy buttons, detail links, and status affordances need delegated handlers or rerender-safe binding rather than one-time listeners on initial DOM nodes. +- KTD5. **Build a table-like grid, not a literal table dependency.** Virtualization should render predictable row grids while borrowing Starwind table/button/badge styling vocabulary where it fits. +- KTD6. **Keep Impeccable as a required quality gate.** The requirements make `$impeccable` part of implementation, so the plan must reserve a polish unit for density, accessibility, responsive behavior, and browser inspection. +- KTD7. **Model Vault as a status vocabulary entry without inventing data.** The row status map should reserve a Vault-required status for catalog records that expose it later, but the UI must not show Vault status for entries that lack source data. + +--- + +## High-Level Technical Design + +The implementation should split the search page into a server-rendered shell, a serialized compact row index, and a client-side virtual row renderer. + +```mermaid +flowchart TB + Store["Catalog store entries"] --> Model["Compact row model"] + Model --> Shell["Astro search shell"] + Shell --> Data["Serialized row index"] + Data --> Filter["Local filter and sort"] + Filter --> Virtualizer["Window virtualizer"] + Virtualizer --> Rows["Visible row DOM window"] + Rows --> Detail["Detail page links"] + Rows --> Copy["Delegated copy action"] + Rows --> Status["Status icon labels"] +``` + +Search-state changes should flow through one state owner so URL state, result count, empty state, focus, and virtualizer scroll position stay synchronized. + +```mermaid +stateDiagram-v2 + [*] --> Loaded + Loaded --> Filtered: query/filter/sort changes + Filtered --> Synced: update URL and result count + Synced --> Top: reset virtualizer to first row + Top --> Scrolling: user scrolls page + Scrolling --> Synced: visible window changes + Synced --> Empty: zero matches + Empty --> Filtered: reset or new search +``` + +--- + +## Implementation Units + +### U1. Add the compact row model + +- **Goal:** Create a row-level data shape that contains only the fields the search page needs for filtering, rendering, status icons, copy, and detail routing. +- **Requirements:** R1, R2, R3, R4, R9 +- **Dependencies:** None +- **Files:** + - `apps/catalog/src/lib/search-filter.ts` + - `apps/catalog/src/lib/search-row.ts` + - `apps/catalog/test/search-filter.test.ts` + - `apps/catalog/test/search-row.test.ts` +- **Approach:** Extend the search record shape from name/description/tags/trust/setup/count into a compact catalog row model. Include detail href, install command text and copyability, install-count display, warning/status codes, readiness fields, a reserved optional Vault-required status, and enough normalized search text to keep filtering independent from DOM attributes. +- **Patterns to follow:** `apps/catalog/src/lib/catalog-store.ts` already adapts core catalog entries into UI records; `apps/catalog/src/lib/search-filter.ts` already owns local filtering and sorting semantics. +- **Test scenarios:** + - Covers AE1. Given a catalog entry with warnings, install command, and readiness metadata, building the row model returns the title, trust label, count display, truncated-render source fields, status codes, command text, and detail href. + - Covers AE3. Given row records with punctuation variants such as `ast-grep` and `ast_grep`, filtering still matches normalized search terms across title, description, tags, and install command where intended. + - Given a non-copyable install command, the row model marks copy unavailable while preserving the command preview text or unavailable reason for rendering. + - Given a record with no Vault status source, the row model does not fabricate a Vault-required icon. + - Given rows with equal rank scores, sorting remains deterministic by name. +- **Verification:** The row model can feed search/filter/sort tests without any rendered result DOM. + +### U2. Reshape the search shell and sticky toolbar + +- **Goal:** Replace the current card-list shell with a compact sticky toolbar and virtual-results host while preserving search controls and empty-state behavior. +- **Requirements:** R1, R3, R7, R8, R10, R11 +- **Dependencies:** U1 +- **Files:** + - `apps/catalog/src/components/SearchShell.astro` + - `apps/catalog/src/components/FilterBar.astro` + - `apps/catalog/src/components/ResultList.astro` + - `apps/catalog/src/components/CapletResult.astro` + - `apps/catalog/src/styles/catalog.css` + - `apps/catalog/test/virtual-results.test.ts` +- **Approach:** Make `SearchShell.astro` output the compact toolbar, a table-like row grid structure, a virtual row mount, serialized row data, status live region, and empty state. Retire or reduce `CapletResult.astro` from the search route so full warning cards no longer render for every result. +- **Patterns to follow:** Existing Starwind table styling vocabulary in `apps/catalog/src/components/starwind/table/` and the current filter control classes in `FilterBar.astro`. +- **Test scenarios:** + - Covers AE1. Given rendered search shell markup, the result host contains row/table affordances and does not contain one full result article per entry. + - Covers AE4. Given the shell markup, the toolbar controls and status legend are grouped in the sticky toolbar region. + - Given the no-results state is present, it remains hidden by default and keeps the reset action available for the client script. + - Given the search shell is rendered for mobile width constraints, controls have accessible names and usable touch-target class structure. +- **Verification:** The search page source no longer pre-renders full result cards as the primary results surface. + +### U3. Implement window-based virtual rendering + +- **Goal:** Add a client-side renderer that filters the compact index, drives a headless virtualizer, and renders only the visible result rows plus overscan. +- **Requirements:** R4, R5, R6, R7, R10, R12 +- **Dependencies:** U1, U2 +- **Files:** + - `apps/catalog/package.json` + - `pnpm-lock.yaml` + - `apps/catalog/src/scripts/search.ts` + - `apps/catalog/src/scripts/virtual-results.ts` + - `apps/catalog/test/virtual-results.test.ts` + - `apps/catalog/vitest.config.ts` +- **Approach:** Add `@tanstack/virtual-core` and use its headless virtualizer against the window scroll model. The renderer should maintain filtered row IDs, result count, empty state, virtual padding or transform positioning, and scroll-to-top behavior when query state changes. +- **Execution note:** Add characterization coverage around current search-filter semantics before replacing DOM hiding/reordering. +- **Patterns to follow:** Keep the existing `search.ts` as the entry point for search-page behavior, but move virtual-row-specific code into a testable module. If DOM testing needs a browser-like environment, add a small Vitest DOM environment dependency rather than a full end-to-end framework. +- **Test scenarios:** + - Covers AE2. Given 10,000 generated row records, initial render creates only a bounded visible row count plus overscan. + - Covers AE2. Given a simulated scroll near the middle of a 10,000-row set, the rendered row count remains bounded and the visible IDs update. + - Covers AE3. Given a query change from many matches to one match, the result count updates, the virtualizer resets to the first row, and focus remains on the active input. + - Covers AE3. Given initial URL parameters for query, filters, tag, or sort, the search state hydrates from the URL before the first virtual render. + - Covers AE3. Given browser back or forward navigation changes query state, the controls, count, and rendered window update together. + - Covers AE4. Given the virtualized list initializes, it uses window/page scroll configuration rather than an internal scroll panel. + - Given zero matches, the empty state is shown and no stale virtual rows remain mounted. +- **Verification:** A mocked 10,000-entry dataset proves bounded DOM row rendering under initial render, scroll, filter, and sort updates. + +### U4. Add dense row interactions and status icon semantics + +- **Goal:** Render dense rows with copyable truncated install commands, accessible detail links, status icons, legend, and tooltip or label behavior. +- **Requirements:** R1, R2, R3, R9, R10, R11 +- **Dependencies:** U1, U2, U3 +- **Files:** + - `apps/catalog/src/components/HugeIcon.astro` + - `apps/catalog/src/scripts/copy.ts` + - `apps/catalog/src/scripts/virtual-results.ts` + - `apps/catalog/src/lib/search-row.ts` + - `apps/catalog/src/styles/catalog.css` + - `apps/catalog/test/search-row.test.ts` + - `apps/catalog/test/virtual-results.test.ts` +- **Approach:** Define a stable status-code-to-icon map using Huge Icons and render icon-only row indicators with accessible names. Use delegated copy handling because row nodes are created and destroyed by virtualization, and keep the full command in data for copy while visually truncating it in the row. +- **Patterns to follow:** `InstallCommand.astro` and `SafetyNotice.astro` already define command and warning semantics; `HugeIcon.astro` is the existing icon wrapper; `copy.ts` already owns user-facing copy announcements. +- **Test scenarios:** + - Covers AE5. Given a long install command, the rendered row truncates the visible command while the copy button writes the full command and announces success. + - Covers AE5. Given a non-copyable command, the copy control is disabled or absent with an accessible unavailable state. + - Covers AE6. Given rows with local-control, setup, Project Binding, Vault, and readiness status codes, each icon has an accessible label and appears in the legend. + - Covers AE7. Given a focused copy button or detail link, a virtual row refresh does not leave focus trapped on a removed node without a recovery target. + - Given a clipboard failure, the existing copy-status region announces a failure message. +- **Verification:** Row interactions keep working after virtual rows are recycled. + +### U5. Polish responsive layout, themes, and Impeccable review path + +- **Goal:** Bring the dense result surface up to the Caplets product-register quality bar across viewport sizes, themes, reduced motion, and browser inspection. +- **Requirements:** R8, R9, R10, R11, R13 +- **Dependencies:** U2, U3, U4 +- **Files:** + - `apps/catalog/src/styles/catalog.css` + - `apps/catalog/src/styles/starwind.css` + - `apps/catalog/src/components/SearchShell.astro` + - `apps/catalog/src/components/ResultList.astro` + - `apps/catalog/src/scripts/virtual-results.ts` +- **Approach:** Use `$impeccable` during implementation with the catalog product context. Tune density, sticky-toolbar height, row grid columns, status legend placement, mobile stacking, visible focus, contrast, and reduced-motion behavior without introducing decorative cards or custom scrollbars. +- **Patterns to follow:** `PRODUCT.md`, `DESIGN.md`, `apps/catalog/src/styles/starwind.css`, and the product-register guidance from `$impeccable`. +- **Test scenarios:** + - Covers AE4. In browser inspection, the sticky toolbar remains visible during page scroll and does not consume excessive viewport height. + - Covers AE6. In browser inspection, status icons are understandable through legend and hover or keyboard labels. + - Covers AE8. In browser inspection, light, dark, and system theme states keep row text, focus rings, icons, and command previews readable. + - Covers AE8. With reduced motion enabled, row and tooltip transitions do not rely on motion to communicate state. + - On a mobile viewport, rows stack predictably and copy/detail controls remain usable. +- **Verification:** Impeccable review and browser inspection confirm the result surface feels like a precise developer-tool table rather than a card grid. + +### U6. Add performance-oriented catalog fixtures and verification hooks + +- **Goal:** Make the 10,000-entry acceptance bar repeatable without requiring production catalog data at that scale. +- **Requirements:** R5, R6, R10, R12 +- **Dependencies:** U1, U3, U4 +- **Files:** + - `apps/catalog/test/fixtures/catalog-search-rows.ts` + - `apps/catalog/test/virtual-results.test.ts` + - `apps/catalog/test/search-row.test.ts` + - `apps/catalog/src/scripts/virtual-results.ts` +- **Approach:** Add deterministic row factories for large catalog sets and use them in virtual-renderer tests. Keep fixtures compact so they validate row count, filtering, scroll-window changes, copyability, and status variance without embedding full `CAPLET.md` content. +- **Patterns to follow:** Existing catalog tests use small, explicit fixtures in `apps/catalog/test/search-filter.test.ts`; extend that style with deterministic generator helpers for scale tests. +- **Test scenarios:** + - Covers AE2. A generated 10,000-row fixture keeps rendered row count bounded before and after scroll. + - Covers AE3. Filtering the generated fixture to one match updates count and resets the visible row window. + - Covers AE6. Generated status variants cover every icon represented in the legend. + - Given repeated renders against a generated fixture, the renderer does not duplicate row nodes or leave stale rows after sort changes. +- **Verification:** The catalog app has a repeatable local test path for the performance acceptance criteria before browser smoke testing. + +--- + +## Scope Boundaries + +- Server-side search, remote result paging, and API-backed infinite loading remain out of scope. +- Detail-page redesign remains out of scope beyond preserving it as the inspection destination. +- Full warning explanations in search rows remain out of scope; rows show status icons and accessible labels only. +- Exact timing budgets remain out of scope; acceptance is behavioral: bounded DOM, responsive controls, stable scrolling, and working interactions with 10,000 mocked entries. +- A full end-to-end browser test framework is not required for this hardening pass unless implementation reveals Vitest DOM tests cannot cover the virtualizer contract. + +--- + +## Risks & Dependencies + +- **Virtualizer and Astro integration:** `@tanstack/virtual-core` is headless and should fit the current framework-light catalog app, but implementation must verify window scroll measurement under Astro's script bundling and Cloudflare build path. +- **Accessibility under virtualization:** Virtual rows can be removed while interactive descendants are focused. The renderer must preserve focus intentionally when filters, sorts, or scroll windows change. +- **Sticky toolbar height:** A sticky toolbar that includes search, filters, result count, and legend can crowd mobile screens. Impeccable polish should treat compactness as a blocking quality concern. +- **Event binding drift:** Existing copy behavior attaches to initial DOM buttons. Virtual rows require delegated or rerender-safe binding so copied commands keep working as rows recycle. +- **Test environment fidelity:** Vitest DOM tests can prove bounded rendering and state transitions, but final visual behavior still needs browser inspection because sticky positioning, tooltips, and theme contrast are layout-dependent. + +--- + +## Documentation / Operational Notes + +- No public docs change is required for the search-page hardening itself. +- If a new dependency is added, keep it scoped to `apps/catalog/package.json` and let the app's build/typecheck path validate bundling. +- The implementation should not create a changeset unless package behavior or published package dependencies change outside the private catalog app. + +--- + +## Sources / Research + +- `docs/brainstorms/2026-06-27-catalog-search-virtualized-results-requirements.md` for origin requirements. +- `docs/brainstorms/2026-06-26-caplets-catalog-search-site-requirements.md` for broader catalog-site boundaries. +- `apps/catalog/src/components/SearchShell.astro` for the current search shell and filter/result composition. +- `apps/catalog/src/components/ResultList.astro` and `apps/catalog/src/components/CapletResult.astro` for the current all-results full-card rendering path. +- `apps/catalog/src/scripts/search.ts` for the current DOM-derived filtering and reordering behavior. +- `apps/catalog/src/lib/search-filter.ts` and `apps/catalog/test/search-filter.test.ts` for existing local search semantics. +- `apps/catalog/src/components/starwind/table/` for Starwind table styling vocabulary already available in the catalog app. +- `PRODUCT.md` and `DESIGN.md` for Caplets product-register design context. +- `@tanstack/virtual-core` npm metadata: version `3.17.2`, described as headless TS/JS virtualization with homepage `https://tanstack.com/virtual`. diff --git a/infra/alchemy-domains.ts b/infra/alchemy-domains.ts index 07277e8a..fb42d403 100644 --- a/infra/alchemy-domains.ts +++ b/infra/alchemy-domains.ts @@ -6,6 +6,8 @@ export interface AlchemyDomains { landingPageUrl: string; docsPageDomain: string; docsPageUrl: string; + catalogPageDomain: string; + catalogPageUrl: string; } export function buildAlchemyDomains( @@ -15,8 +17,10 @@ export function buildAlchemyDomains( const baseDomain = stage === "prod" ? globalBaseDomain : `${stage}.preview.${globalBaseDomain}`; const landingPageDomain = baseDomain; const docsPageDomain = `docs.${baseDomain}`; + const catalogPageDomain = `catalog.${baseDomain}`; const landingPageUrl = local ? `http://localhost:4321` : `https://${landingPageDomain}`; const docsPageUrl = local ? `http://localhost:4322` : `https://${docsPageDomain}`; + const catalogPageUrl = local ? `http://localhost:4323` : `https://${catalogPageDomain}`; return { baseDomain, @@ -24,5 +28,7 @@ export function buildAlchemyDomains( landingPageUrl, docsPageDomain, docsPageUrl, + catalogPageDomain, + catalogPageUrl, }; } diff --git a/infra/alchemy-runner.test.ts b/infra/alchemy-runner.test.ts index bbdd3faa..0bfcd0e4 100644 --- a/infra/alchemy-runner.test.ts +++ b/infra/alchemy-runner.test.ts @@ -17,19 +17,40 @@ test("Alchemy runner preserves existing NODE_OPTIONS after the fetch shim", () = test.each([ { landingPageDomain: "caplets.dev", + docsPageDomain: "docs.caplets.dev", + catalogPageDomain: "catalog.caplets.dev", stage: "prod", }, { landingPageDomain: "branch.preview.caplets.dev", + docsPageDomain: "docs.branch.preview.caplets.dev", + catalogPageDomain: "catalog.branch.preview.caplets.dev", stage: "branch", }, { landingPageDomain: "dev.preview.caplets.dev", + docsPageDomain: "docs.dev.preview.caplets.dev", + catalogPageDomain: "catalog.dev.preview.caplets.dev", stage: "dev", }, -])("derives matching domains for $stage", ({ landingPageDomain, stage }) => { - expect(buildAlchemyDomains(stage)).toMatchObject({ - landingPageDomain, - landingPageUrl: `https://${landingPageDomain}`, +])( + "derives matching domains for $stage", + ({ catalogPageDomain, docsPageDomain, landingPageDomain, stage }) => { + expect(buildAlchemyDomains(stage)).toMatchObject({ + landingPageDomain, + landingPageUrl: `https://${landingPageDomain}`, + docsPageDomain, + docsPageUrl: `https://${docsPageDomain}`, + catalogPageDomain, + catalogPageUrl: `https://${catalogPageDomain}`, + }); + }, +); + +test("derives local dev ports for each site", () => { + expect(buildAlchemyDomains("dev", { local: true })).toMatchObject({ + landingPageUrl: "http://localhost:4321", + docsPageUrl: "http://localhost:4322", + catalogPageUrl: "http://localhost:4323", }); }); diff --git a/package.json b/package.json index b187d7b0..5814f3b1 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,8 @@ "benchmark:live:pi-eval:competitors": "pnpm --filter @caplets/benchmarks benchmark:live:pi-eval:competitors", "build": "turbo build", "build:watch": "turbo build:watch", + "catalog:check": "tsx ./scripts/generate-catalog-index.ts --check", + "catalog:generate": "tsx ./scripts/generate-catalog-index.ts", "changeset": "changeset", "clean": "turbo clean", "clean-install": "rm -rf **/node_modules pnpm-lock.yaml && pnpm install", diff --git a/packages/core/package.json b/packages/core/package.json index ca3108ed..5c601d50 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -47,6 +47,10 @@ "types": "./dist/caplet-source/filesystem.d.ts", "default": "./dist/caplet-source/filesystem.js" }, + "./catalog": { + "types": "./dist/catalog/index.d.ts", + "default": "./dist/catalog.js" + }, "./code-mode": { "types": "./dist/code-mode/index.d.ts", "default": "./dist/code-mode.js" diff --git a/packages/core/rolldown.config.ts b/packages/core/rolldown.config.ts index 90e4de46..93fe663e 100644 --- a/packages/core/rolldown.config.ts +++ b/packages/core/rolldown.config.ts @@ -21,6 +21,7 @@ export default defineConfig([ input: { "caplet-source": "src/caplet-source/index.ts", "code-mode": "src/code-mode/index.ts", + catalog: "src/catalog/index.ts", "observed-output-shapes/pure": "src/observed-output-shapes/pure.ts", "project-binding": "src/project-binding/index.ts", redaction: "src/redaction.ts", diff --git a/packages/core/src/catalog-indexing/eligibility.ts b/packages/core/src/catalog-indexing/eligibility.ts new file mode 100644 index 00000000..24e5e454 --- /dev/null +++ b/packages/core/src/catalog-indexing/eligibility.ts @@ -0,0 +1,79 @@ +import { + catalogEntryKey, + normalizeCatalogSourceIdentity, + type CatalogIndexingStatus, +} from "../catalog"; +import type { + CatalogIndexingCandidate, + CatalogIndexingPayload, + CatalogIndexingResult, +} from "./payload"; + +export type CatalogIndexingLockEntry = { + id: string; + source: + | { + type: "git"; + repository: string; + path: string; + resolvedRevision?: string | undefined; + } + | { + type: "local"; + path?: string | undefined; + portability?: string | undefined; + }; + installedHash: string; +}; + +const officialRepository = "spiritledsoftware/caplets"; + +export function catalogIndexingPayloadForLockEntry( + entry: CatalogIndexingLockEntry, +): CatalogIndexingPayload | CatalogIndexingResult { + const candidate = catalogIndexingCandidateForLockEntry(entry); + if ("status" in candidate) return candidate; + return { + source: candidate.source.repository, + capletId: entry.id, + sourcePath: candidate.sourcePath, + resolvedRevision: candidate.resolvedRevision, + contentHash: candidate.installedHash, + entryKey: candidate.entryKey, + }; +} + +export function catalogIndexingCandidateForLockEntry( + entry: CatalogIndexingLockEntry, +): CatalogIndexingCandidate | CatalogIndexingResult { + if (entry.source.type !== "git") { + return skip("ineligible", "not_public"); + } + const source = normalizeCatalogSourceIdentity(entry.source.repository); + if (!source.eligible) { + return skip("ineligible", source.reason); + } + if (source.source.repository === officialRepository) { + return skip("already_current", "official_seed"); + } + if (!entry.source.resolvedRevision) { + return skip("revision_unavailable", "revision_unavailable"); + } + const entryKey = catalogEntryKey({ + source: source.source, + sourcePath: entry.source.path, + capletId: entry.id, + }); + return { + id: entry.id, + source: source.source, + sourcePath: entry.source.path, + resolvedRevision: entry.source.resolvedRevision, + installedHash: entry.installedHash, + entryKey, + }; +} + +function skip(status: CatalogIndexingStatus, reason: string): CatalogIndexingResult { + return { status, reason }; +} diff --git a/packages/core/src/catalog-indexing/payload.ts b/packages/core/src/catalog-indexing/payload.ts new file mode 100644 index 00000000..6a79a45f --- /dev/null +++ b/packages/core/src/catalog-indexing/payload.ts @@ -0,0 +1,26 @@ +import type { CatalogEntry, CatalogIndexingStatus, CatalogSourceIdentity } from "../catalog"; + +export type CatalogIndexingResult = { + status: CatalogIndexingStatus; + entryKey?: string | undefined; + reason?: string | undefined; +}; + +export type CatalogIndexingPayload = { + source: string; + capletId: string; + sourcePath: string; + resolvedRevision?: string | undefined; + contentHash?: string | undefined; + entryKey: string; + entry?: CatalogEntry | undefined; +}; + +export type CatalogIndexingCandidate = { + id: string; + source: CatalogSourceIdentity; + sourcePath: string; + resolvedRevision: string; + installedHash: string; + entryKey: string; +}; diff --git a/packages/core/src/catalog/caplet-markdown.ts b/packages/core/src/catalog/caplet-markdown.ts new file mode 100644 index 00000000..67696ae1 --- /dev/null +++ b/packages/core/src/catalog/caplet-markdown.ts @@ -0,0 +1,117 @@ +import { parse as parseYaml } from "yaml"; +import { catalogWorkflowSummaryForBackendFamily } from "./entry"; +import type { CatalogWorkflowSummary } from "./types"; + +export function readCatalogCapletFrontmatterFromMarkdown( + markdown: string, +): Record { + const match = /^---\r?\n([\s\S]*?)\r?\n---/u.exec(markdown); + if (!match?.[1]) return {}; + const parsed = parseYaml(match[1]); + return isRecord(parsed) ? parsed : {}; +} + +export function catalogStringFromFrontmatter(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +export function catalogStringArrayFromFrontmatter(value: unknown): string[] | undefined { + if (!Array.isArray(value)) return undefined; + return value.filter((entry): entry is string => typeof entry === "string"); +} + +export function catalogSetupRequiredFromFrontmatter(frontmatter: Record): boolean { + return frontmatter.setup !== undefined; +} + +export function catalogAuthRequiredFromFrontmatter(frontmatter: Record): boolean { + const auth = catalogCapletAuth(frontmatter); + return auth !== undefined && auth.type !== "none"; +} + +export function catalogProjectBindingRequiredFromFrontmatter( + frontmatter: Record, +): boolean { + return isRecord(frontmatter.projectBinding) && frontmatter.projectBinding.required === true; +} + +export function catalogWorkflowSummaryFromFrontmatter( + frontmatter: Record, + fallback: CatalogWorkflowSummary, +): CatalogWorkflowSummary { + return catalogWorkflowSummaryForBackendFamily(catalogBackendFamilies(frontmatter)[0]) ?? fallback; +} + +export function catalogMutatesExternalStateFromFrontmatter( + frontmatter: Record, +): boolean { + if (frontmatter.graphqlEndpoint !== undefined) return true; + if (frontmatter.openapiEndpoint !== undefined || frontmatter.googleDiscoveryApi !== undefined) { + return true; + } + const httpApi = frontmatter.httpApi; + if (isRecord(httpApi) && isRecord(httpApi.actions)) { + return Object.values(httpApi.actions).some((action) => { + if (!isRecord(action)) return false; + return typeof action.method === "string" && action.method.toUpperCase() !== "GET"; + }); + } + if (isRecord(frontmatter.cliTools) && isRecord(frontmatter.cliTools.actions)) { + return Object.values(frontmatter.cliTools.actions).some((action) => { + if (!isRecord(action) || !isRecord(action.annotations)) return true; + return action.annotations.readOnlyHint !== true; + }); + } + return false; +} + +export function catalogUsesLocalControlFromFrontmatter( + frontmatter: Record, +): boolean { + const runtime = isRecord(frontmatter.runtime) ? frontmatter.runtime : undefined; + const runtimeFeatures = Array.isArray(runtime?.features) ? runtime.features : []; + return ( + catalogProjectBindingRequiredFromFrontmatter(frontmatter) || + runtimeFeatures.length > 0 || + frontmatter.cliTools !== undefined || + isLocalMcpServer(frontmatter) + ); +} + +function catalogBackendFamilies(frontmatter: Record): string[] { + const families: Array = [ + ["mcp", "mcpServer"], + ["openapi", "openapiEndpoint"], + ["googleDiscovery", "googleDiscoveryApi"], + ["graphql", "graphqlEndpoint"], + ["http", "httpApi"], + ["cli", "cliTools"], + ["caplets", "capletSet"], + ]; + return families.flatMap(([family, key]) => (frontmatter[key] === undefined ? [] : [family])); +} + +function catalogCapletAuth( + frontmatter: Record, +): Record | undefined { + for (const key of [ + "mcpServer", + "openapiEndpoint", + "googleDiscoveryApi", + "graphqlEndpoint", + "httpApi", + ]) { + const backend = frontmatter[key]; + if (isRecord(backend) && isRecord(backend.auth)) return backend.auth; + } + return undefined; +} + +function isLocalMcpServer(frontmatter: Record): boolean { + const mcpServer = frontmatter.mcpServer; + return isRecord(mcpServer) && typeof mcpServer.command === "string"; +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} diff --git a/packages/core/src/catalog/entry.ts b/packages/core/src/catalog/entry.ts new file mode 100644 index 00000000..50d1698b --- /dev/null +++ b/packages/core/src/catalog/entry.ts @@ -0,0 +1,74 @@ +import { generateCatalogInstallCommand } from "./install-command"; +import { catalogEntryKey } from "./source"; +import type { + CatalogEntry, + CatalogEntryInput, + CatalogReadiness, + CatalogWorkflowSummary, +} from "./types"; +import { catalogWarningsForEntry } from "./warnings"; + +export function createCatalogEntry(input: CatalogEntryInput): CatalogEntry { + return { + entryKey: catalogEntryKey({ + source: input.source, + sourcePath: input.sourcePath, + capletId: input.id, + }), + id: input.id, + name: input.name, + description: input.description, + source: input.source, + sourcePath: input.sourcePath, + trustLevel: input.trustLevel, + ...(input.resolvedRevision ? { resolvedRevision: input.resolvedRevision } : {}), + ...(input.indexedContentHash ? { indexedContentHash: input.indexedContentHash } : {}), + ...(input.contentMarkdown ? { contentMarkdown: input.contentMarkdown } : {}), + tags: stableTags(input.tags ?? []), + intendedTask: input.useWhen?.trim() || "unknown", + ...(input.avoidWhen?.trim() ? { avoidWhen: input.avoidWhen.trim() } : {}), + setupReadiness: readiness(input.setupRequired), + authReadiness: readiness(input.authRequired), + projectBindingReadiness: readiness(input.projectBindingRequired), + workflow: input.workflow ?? { kind: "unknown", label: "Unknown" }, + installCommand: generateCatalogInstallCommand({ + source: input.source, + capletId: input.id, + resolvedRevision: input.resolvedRevision, + requireRevisionBound: input.trustLevel === "community", + }), + warnings: catalogWarningsForEntry(input), + }; +} + +export function formatCatalogInstallCount(count: number): string { + if (!Number.isFinite(count) || count <= 0) return "<10"; + if (count < 10) return "<10"; + return Math.floor(count).toLocaleString("en-US"); +} + +export function catalogWorkflowSummaryForBackendFamily( + family: string | undefined, +): CatalogWorkflowSummary | undefined { + if (family === "mcp") return { kind: "mcp", label: "MCP server" }; + if (family === "openapi") return { kind: "openapi", label: "OpenAPI" }; + if (family === "googleDiscovery") { + return { kind: "google_discovery", label: "Google Discovery API" }; + } + if (family === "graphql") return { kind: "graphql", label: "GraphQL" }; + if (family === "http") return { kind: "http", label: "HTTP API" }; + if (family === "cli") return { kind: "cli", label: "CLI tools" }; + if (family === "caplets") return { kind: "set", label: "Caplet set" }; + return undefined; +} + +function readiness(value: boolean | undefined): CatalogReadiness { + if (value === undefined) return "unknown"; + return value ? "required" : "ready"; +} + +function stableTags(tags: string[]): string[] { + return [...new Set(tags.map((tag) => tag.trim().toLowerCase()).filter(Boolean))].sort( + (left, right) => left.localeCompare(right), + ); +} diff --git a/packages/core/src/catalog/index.ts b/packages/core/src/catalog/index.ts new file mode 100644 index 00000000..859c79aa --- /dev/null +++ b/packages/core/src/catalog/index.ts @@ -0,0 +1,41 @@ +export { + catalogWorkflowSummaryForBackendFamily, + createCatalogEntry, + formatCatalogInstallCount, +} from "./entry"; +export { + catalogAuthRequiredFromFrontmatter, + catalogMutatesExternalStateFromFrontmatter, + catalogProjectBindingRequiredFromFrontmatter, + catalogSetupRequiredFromFrontmatter, + catalogStringArrayFromFrontmatter, + catalogStringFromFrontmatter, + catalogUsesLocalControlFromFrontmatter, + catalogWorkflowSummaryFromFrontmatter, + readCatalogCapletFrontmatterFromMarkdown, +} from "./caplet-markdown"; +export { generateCatalogInstallCommand } from "./install-command"; +export { + catalogEntryKey, + normalizeCatalogId, + normalizeCatalogPath, + normalizeCatalogSourceIdentity, +} from "./source"; +export { catalogWarningsForEntry } from "./warnings"; +export type { + CatalogEntry, + CatalogEntryInput, + CatalogEntryKey, + CatalogIndexingEligibility, + CatalogIndexingIneligibleReason, + CatalogIndexingStatus, + CatalogInstallCommand, + CatalogReadiness, + CatalogSourceIdentity, + CatalogSourceProvider, + CatalogTrustLevel, + CatalogWarning, + CatalogWarningCode, + CatalogWarningSeverity, + CatalogWorkflowSummary, +} from "./types"; diff --git a/packages/core/src/catalog/install-command.ts b/packages/core/src/catalog/install-command.ts new file mode 100644 index 00000000..f3410ff3 --- /dev/null +++ b/packages/core/src/catalog/install-command.ts @@ -0,0 +1,38 @@ +import type { CatalogInstallCommand, CatalogSourceIdentity } from "./types"; + +export function generateCatalogInstallCommand(input: { + source: CatalogSourceIdentity; + capletId: string; + resolvedRevision?: string | undefined; + requireRevisionBound?: boolean | undefined; +}): CatalogInstallCommand { + if (input.source.provider !== "github") { + return { + text: "", + copyable: false, + revisionBound: false, + reason: "unsupported_source", + }; + } + + const base = `caplets install ${input.source.repository} ${shellWord(input.capletId)}`; + if (input.resolvedRevision) { + return { + text: base, + copyable: false, + revisionBound: false, + reason: "revision_install_unsupported", + }; + } + + return { + text: base, + copyable: !input.requireRevisionBound, + revisionBound: false, + ...(input.requireRevisionBound ? { reason: "revision_unavailable" as const } : {}), + }; +} + +function shellWord(value: string): string { + return /^[A-Za-z0-9._/-]+$/u.test(value) ? value : JSON.stringify(value); +} diff --git a/packages/core/src/catalog/source.ts b/packages/core/src/catalog/source.ts new file mode 100644 index 00000000..d307c2d1 --- /dev/null +++ b/packages/core/src/catalog/source.ts @@ -0,0 +1,154 @@ +import type { + CatalogEntryKey, + CatalogIndexingEligibility, + CatalogIndexingIneligibleReason, + CatalogSourceIdentity, +} from "./types"; + +const githubOwnerRepoPattern = /^[a-z0-9][a-z0-9-]{0,38}\/[a-z0-9._-]+$/iu; +const githubOwnerPattern = /^[a-z0-9][a-z0-9-]{0,38}$/iu; +const githubRepoPattern = /^[a-z0-9._-]+$/iu; + +export function normalizeCatalogSourceIdentity(source: string): CatalogIndexingEligibility { + const trimmed = source.trim(); + if (!trimmed) { + return ineligible("empty_source"); + } + if (looksLikeLocalPath(trimmed)) { + return ineligible("local_path"); + } + + if (githubOwnerRepoPattern.test(trimmed)) { + const [owner, repo] = trimmed.split("/"); + return eligibleGithub(owner, repo); + } + + let url: URL; + try { + url = new URL(trimmed); + } catch { + return ineligible("unsupported_source"); + } + + if (url.username || url.password) { + return ineligible("credential_url"); + } + const hostname = url.hostname.toLowerCase(); + if (isPrivateHost(hostname)) { + return ineligible("private_host"); + } + if (url.protocol !== "https:") { + return ineligible("unsupported_source"); + } + if (hostname !== "github.com") { + return ineligible("unsupported_source"); + } + + if (url.search || url.hash) { + return ineligible("unsupported_source"); + } + + const pathSegments = url.pathname.split("/").filter(Boolean); + if (pathSegments.length !== 2) { + return ineligible("unsupported_source"); + } + const owner = pathSegments[0]; + const repoSegment = pathSegments[1]; + if (!owner || !repoSegment) { + return ineligible("unsupported_source"); + } + + return eligibleGithub(owner, repoSegment.replace(/\.git$/iu, "")); +} + +export function catalogEntryKey(input: { + source: CatalogSourceIdentity; + sourcePath: string; + capletId: string; +}): CatalogEntryKey { + return [ + input.source.provider, + input.source.owner, + input.source.repo, + normalizeCatalogPath(input.sourcePath), + normalizeCatalogId(input.capletId), + ] + .map(encodeURIComponent) + .join(":"); +} + +export function normalizeCatalogPath(path: string): string { + const normalized = path.trim().replace(/\\/g, "/").replace(/^\.\//u, "").toLowerCase(); + const segments: string[] = []; + for (const segment of normalized.split("/")) { + if (!segment || segment === ".") continue; + if (segment === "..") { + segments.pop(); + continue; + } + segments.push(segment); + } + return segments.join("/"); +} + +export function normalizeCatalogId(id: string): string { + return id.trim().toLowerCase(); +} + +function eligibleGithub( + owner: string | undefined, + repo: string | undefined, +): CatalogIndexingEligibility { + const normalizedOwner = owner?.trim().toLowerCase(); + const normalizedRepo = repo + ?.trim() + .replace(/\.git$/iu, "") + .toLowerCase(); + if ( + !normalizedOwner || + !normalizedRepo || + !githubOwnerPattern.test(normalizedOwner) || + !githubRepoPattern.test(normalizedRepo) + ) { + return ineligible("unsupported_source"); + } + return { + eligible: true, + source: { + provider: "github", + owner: normalizedOwner, + repo: normalizedRepo, + repository: `${normalizedOwner}/${normalizedRepo}`, + canonicalUrl: `https://github.com/${normalizedOwner}/${normalizedRepo}`, + }, + }; +} + +function ineligible(reason: CatalogIndexingIneligibleReason): CatalogIndexingEligibility { + return { eligible: false, reason, redactedSource: "[redacted]" }; +} + +function looksLikeLocalPath(value: string): boolean { + return ( + value.startsWith("/") || + value.startsWith("./") || + value.startsWith("../") || + /^[A-Za-z]:[\\/]/u.test(value) || + value.includes("\\") + ); +} + +function isPrivateHost(hostname: string): boolean { + if (hostname === "localhost" || hostname.endsWith(".localhost")) { + return true; + } + if ( + hostname.startsWith("127.") || + hostname.startsWith("10.") || + hostname.startsWith("192.168.") + ) { + return true; + } + const match172 = /^172\.(\d{1,3})\./u.exec(hostname); + return Boolean(match172?.[1] && Number(match172[1]) >= 16 && Number(match172[1]) <= 31); +} diff --git a/packages/core/src/catalog/types.ts b/packages/core/src/catalog/types.ts new file mode 100644 index 00000000..50f8fd51 --- /dev/null +++ b/packages/core/src/catalog/types.ts @@ -0,0 +1,121 @@ +export type CatalogSourceProvider = "github"; + +export type CatalogTrustLevel = "official" | "community"; + +export type CatalogReadiness = "ready" | "required" | "unknown"; + +export type CatalogEntryKey = string; + +export type CatalogSourceIdentity = { + provider: CatalogSourceProvider; + owner: string; + repo: string; + repository: string; + canonicalUrl: string; +}; + +export type CatalogInstallCommand = { + text: string; + copyable: boolean; + revisionBound: boolean; + reason?: + | "revision_unavailable" + | "revision_install_unsupported" + | "unsupported_source" + | undefined; +}; + +export type CatalogWarningCode = + | "unverified_community" + | "local_control" + | "mutating_saas" + | "auth_required" + | "setup_required" + | "project_binding_required" + | "readiness_unknown"; + +export type CatalogWarningSeverity = "info" | "caution" | "danger"; + +export type CatalogWarning = { + code: CatalogWarningCode; + severity: CatalogWarningSeverity; + label: string; + message: string; +}; + +export type CatalogIndexingIneligibleReason = + | "credential_url" + | "empty_source" + | "local_path" + | "private_host" + | "unsupported_source"; + +export type CatalogIndexingEligibility = + | { + eligible: true; + source: CatalogSourceIdentity; + } + | { + eligible: false; + reason: CatalogIndexingIneligibleReason; + redactedSource: "[redacted]"; + }; + +export type CatalogIndexingStatus = + | "accepted" + | "already_current" + | "counted" + | "ineligible" + | "rate_limited" + | "rejected" + | "revision_unavailable" + | "suppressed" + | "unavailable"; + +export type CatalogWorkflowSummary = { + kind: "code_mode" | "mcp" | "openapi" | "google_discovery" | "graphql" | "http" | "cli" | "set"; + label: string; +}; + +export type CatalogEntryInput = { + id: string; + name: string; + description: string; + source: CatalogSourceIdentity; + sourcePath: string; + trustLevel: CatalogTrustLevel; + resolvedRevision?: string | undefined; + indexedContentHash?: string | undefined; + contentMarkdown?: string | undefined; + tags?: string[] | undefined; + useWhen?: string | undefined; + avoidWhen?: string | undefined; + setupRequired?: boolean | undefined; + authRequired?: boolean | undefined; + projectBindingRequired?: boolean | undefined; + workflow?: CatalogWorkflowSummary | undefined; + mutatesExternalState?: boolean | undefined; + localControl?: boolean | undefined; +}; + +export type CatalogEntry = { + entryKey: CatalogEntryKey; + id: string; + name: string; + description: string; + source: CatalogSourceIdentity; + sourcePath: string; + trustLevel: CatalogTrustLevel; + resolvedRevision?: string | undefined; + indexedContentHash?: string | undefined; + contentMarkdown?: string | undefined; + tags: string[]; + intendedTask: string | "unknown"; + avoidWhen?: string | undefined; + setupReadiness: CatalogReadiness; + authReadiness: CatalogReadiness; + projectBindingReadiness: CatalogReadiness; + workflow: CatalogWorkflowSummary | { kind: "unknown"; label: "Unknown" }; + installCommand: CatalogInstallCommand; + warnings: CatalogWarning[]; +}; diff --git a/packages/core/src/catalog/warnings.ts b/packages/core/src/catalog/warnings.ts new file mode 100644 index 00000000..50e31dc5 --- /dev/null +++ b/packages/core/src/catalog/warnings.ts @@ -0,0 +1,73 @@ +import type { CatalogEntryInput, CatalogWarning } from "./types"; + +export function catalogWarningsForEntry(input: CatalogEntryInput): CatalogWarning[] { + return [ + input.trustLevel === "community" + ? warning( + "unverified_community", + "caution", + "Unverified community Caplet", + "This Caplet is indexed from a public community source and has not been reviewed by Caplets.", + ) + : undefined, + input.localControl + ? warning( + "local_control", + "danger", + "Local control", + "This Caplet can operate against local project or machine state.", + ) + : undefined, + input.mutatesExternalState + ? warning( + "mutating_saas", + "caution", + "Can change external services", + "This Caplet may perform mutating operations against an external service.", + ) + : undefined, + input.authRequired + ? warning( + "auth_required", + "caution", + "Authentication required", + "This Caplet needs credentials or an auth flow before use.", + ) + : undefined, + input.setupRequired + ? warning( + "setup_required", + "info", + "Setup required", + "This Caplet includes setup steps that should be completed before use.", + ) + : undefined, + input.projectBindingRequired + ? warning( + "project_binding_required", + "caution", + "Project Binding required", + "This Caplet needs a Project Binding before it can run in the intended environment.", + ) + : undefined, + input.setupRequired === undefined || + input.authRequired === undefined || + input.projectBindingRequired === undefined + ? warning( + "readiness_unknown", + "info", + "Readiness unknown", + "Some setup, auth, or runtime readiness metadata is not available for this entry.", + ) + : undefined, + ].filter((item): item is CatalogWarning => Boolean(item)); +} + +function warning( + code: CatalogWarning["code"], + severity: CatalogWarning["severity"], + label: string, + message: string, +): CatalogWarning { + return { code, severity, label, message }; +} diff --git a/packages/core/src/cli.ts b/packages/core/src/cli.ts index 8c0b5e76..292876df 100644 --- a/packages/core/src/cli.ts +++ b/packages/core/src/cli.ts @@ -56,9 +56,11 @@ import { } from "./cli/vault"; import { installCaplets, + indexInstalledCapletsFromLockfile, restoreCapletsFromLockfile, updateCapletsFromLockfile, } from "./cli/install"; +import type { CatalogIndexingResult } from "./catalog-indexing/payload"; import { readCapletsLockfile } from "./cli/lockfile"; import { formatSetupMenu, @@ -2942,13 +2944,28 @@ export function createProgram(io: CliIO = {}): Command { ...(installSource ? { repo: installSource } : {}), capletIds: selectedCapletIds, force: Boolean(options.force), - })) as { installed: Array<{ id: string; destination: string }> }; + ...(catalogIndexingDisabled(env) ? { disableCatalogIndexing: true } : {}), + })) as { + installed: Array<{ + id: string; + destination: string; + status?: "installed" | "restored" | "updated" | "noop" | undefined; + catalogIndexing?: CatalogIndexingResult | undefined; + }>; + }; if (options.json) { writeOut(`${JSON.stringify(installJsonResult(result.installed), null, 2)}\n`); return; } for (const caplet of result.installed) { - writeOut(`Installed ${caplet.id} to remote ${caplet.destination}\n`); + const action = + caplet.status === "noop" + ? "Already installed" + : caplet.status === "restored" + ? "Restored" + : "Installed"; + writeOut(`${action} ${caplet.id} to remote ${caplet.destination}\n`); + writeCatalogIndexingNotice(caplet.catalogIndexing, writeOut); } return; } @@ -2964,6 +2981,8 @@ export function createProgram(io: CliIO = {}): Command { destinationRoot, lockfilePath, }); + await attachCatalogIndexingResults(result.installed, env); + attachVaultSetupResults(result.installed, target, io); if (options.json) { writeOut(`${JSON.stringify(installJsonResult(result.installed), null, 2)}\n`); return; @@ -2972,6 +2991,8 @@ export function createProgram(io: CliIO = {}): Command { writeOut( `${caplet.status === "noop" ? "Already installed" : "Restored"} ${caplet.id} to ${localMutationTargetLabel(target, io)}${caplet.destination}\n`, ); + writeCatalogIndexingNotice(caplet.catalogIndexing, writeOut); + writeVaultSetupNotice(caplet.vaultSetup, writeOut); } return; } @@ -2981,6 +3002,8 @@ export function createProgram(io: CliIO = {}): Command { destinationRoot, lockfilePath, }); + await attachCatalogIndexingResults(result.installed, env); + attachVaultSetupResults(result.installed, target, io); if (options.json) { writeOut(`${JSON.stringify(installJsonResult(result.installed), null, 2)}\n`); return; @@ -2989,6 +3012,8 @@ export function createProgram(io: CliIO = {}): Command { writeOut( `Installed ${caplet.id} to ${localMutationTargetLabel(target, io)}${caplet.destination}\n`, ); + writeCatalogIndexingNotice(caplet.catalogIndexing, writeOut); + writeVaultSetupNotice(caplet.vaultSetup, writeOut); } }, ); @@ -3014,7 +3039,15 @@ export function createProgram(io: CliIO = {}): Command { const result = (await remote.request("update", { capletIds, force: Boolean(options.force), - })) as { installed: Array<{ id: string; destination: string; status?: string }> }; + ...(catalogIndexingDisabled(env) ? { disableCatalogIndexing: true } : {}), + })) as { + installed: Array<{ + id: string; + destination: string; + status?: string; + catalogIndexing?: CatalogIndexingResult | undefined; + }>; + }; if (options.json) { writeOut(`${JSON.stringify(installJsonResult(result.installed), null, 2)}\n`); return; @@ -3023,6 +3056,7 @@ export function createProgram(io: CliIO = {}): Command { writeOut( `${caplet.status === "noop" ? "Already current" : "Updated"} ${caplet.id} at remote ${caplet.destination}\n`, ); + writeCatalogIndexingNotice(caplet.catalogIndexing, writeOut); } return; } @@ -3040,6 +3074,8 @@ export function createProgram(io: CliIO = {}): Command { destinationRoot, lockfilePath, }); + await attachCatalogIndexingResults(result.installed, env); + attachVaultSetupResults(result.installed, target, io); if (options.json) { writeOut(`${JSON.stringify(installJsonResult(result.installed), null, 2)}\n`); return; @@ -3048,6 +3084,8 @@ export function createProgram(io: CliIO = {}): Command { writeOut( `${caplet.status === "noop" ? "Already current" : "Updated"} ${caplet.id} at ${localMutationTargetLabel(target, io)}${caplet.destination}\n`, ); + writeCatalogIndexingNotice(caplet.catalogIndexing, writeOut); + writeVaultSetupNotice(caplet.vaultSetup, writeOut); } }, ); @@ -4134,6 +4172,8 @@ function installJsonResult( hash?: string | undefined; lockfile?: string | undefined; source?: string | undefined; + catalogIndexing?: CatalogIndexingResult | undefined; + vaultSetup?: unknown; }>, ) { return { @@ -4144,10 +4184,118 @@ function installJsonResult( ...(entry.lockfile ? { lockfile: entry.lockfile } : {}), ...(entry.hash ? { hash: entry.hash } : {}), ...(entry.source ? { source: entry.source } : {}), + ...(entry.catalogIndexing ? { catalogIndexing: entry.catalogIndexing } : {}), + ...(entry.vaultSetup ? { vaultSetup: entry.vaultSetup } : {}), })), }; } +type VaultSetupStatus = { + status: "ready" | "unresolved" | "unknown"; + recoveryCommands: string[]; + messages: string[]; +}; + +async function attachCatalogIndexingResults( + installed: Array<{ + id: string; + lockfile?: string | undefined; + catalogIndexing?: CatalogIndexingResult | undefined; + }>, + env: NodeJS.ProcessEnv | Record, +): Promise { + const results = await indexInstalledCapletsFromLockfile(installed, { + disableCatalogIndexing: catalogIndexingDisabled(env), + }); + for (const entry of installed) { + entry.catalogIndexing = results.get(entry.id); + } +} + +function catalogIndexingDisabled(env: NodeJS.ProcessEnv | Record) { + return env.CAPLETS_DISABLE_CATALOG_INDEXING === "1"; +} + +function writeCatalogIndexingNotice( + result: CatalogIndexingResult | undefined, + writeOut: (value: string) => void, +): void { + if (!result) return; + if (result.status === "accepted" || result.status === "counted") { + writeOut( + "Catalog indexing: public Caplet source and content metadata may appear on catalog.caplets.dev.\n", + ); + } else if (result.status === "unavailable") { + writeOut("Catalog indexing: skipped because the catalog indexer was unavailable.\n"); + } +} + +function attachVaultSetupResults( + installed: Array<{ id: string; vaultSetup?: unknown }>, + target: Exclude, + io: CliIO, +): void { + const statuses = vaultSetupStatusesForInstalled( + installed.map((entry) => entry.id), + target, + io, + ); + for (const entry of installed) { + entry.vaultSetup = statuses.get(entry.id); + } +} + +function vaultSetupStatusesForInstalled( + ids: string[], + target: Exclude, + io: CliIO, +): Map { + const statuses = new Map( + ids.map((id) => [id, { status: "ready", recoveryCommands: [], messages: [] }]), + ); + try { + const env = io.env ?? process.env; + const configPath = resolveConfigPath(envConfigPath(env)); + const projectConfigPath = envProjectConfigPath(env); + const result = loadLocalOverlayConfigWithSources(configPath, projectConfigPath); + for (const warning of result.warnings) { + if (!warning.recoverable || !warning.message.includes("Vault key")) continue; + const id = /^Caplet ([^ ]+) references /u.exec(warning.message)?.[1]; + if (!id || !statuses.has(id)) continue; + const status = statuses.get(id); + if (!status) continue; + status.status = "unresolved"; + status.messages.push(warning.message); + const command = /run `([^`]+)`/u.exec(warning.message)?.[1]; + if (command && !status.recoveryCommands.includes(command)) { + status.recoveryCommands.push(command); + } + } + } catch { + for (const status of statuses.values()) { + status.status = "unknown"; + } + } + return statuses; +} + +function writeVaultSetupNotice(result: unknown, writeOut: (value: string) => void): void { + if (!isVaultSetupStatus(result) || result.status !== "unresolved") return; + for (const command of result.recoveryCommands) { + writeOut(`Vault setup: run \`${command}\`.\n`); + } +} + +function isVaultSetupStatus(value: unknown): value is VaultSetupStatus { + return ( + Boolean(value) && + typeof value === "object" && + value !== null && + "status" in value && + Array.isArray((value as { recoveryCommands?: unknown }).recoveryCommands) + ); +} + function parseAuthFlagTarget(options: AuthTargetOptions): AuthTarget | undefined { const selected = [ options.project ? "--project" : undefined, diff --git a/packages/core/src/cli/install.ts b/packages/core/src/cli/install.ts index c78301ca..eccd91e3 100644 --- a/packages/core/src/cli/install.ts +++ b/packages/core/src/cli/install.ts @@ -25,6 +25,15 @@ import { discoverCapletFiles, validateCapletFile } from "../caplet-files"; import { resolveProjectCapletsRoot } from "../config"; import { SERVER_ID_PATTERN } from "../config/validation"; import { CapletsError, toSafeError } from "../errors"; +import type { CatalogIndexingResult } from "../catalog-indexing/payload"; +import { catalogIndexingPayloadForLockEntry } from "../catalog-indexing/eligibility"; +import { + catalogWorkflowSummaryForBackendFamily, + createCatalogEntry, + normalizeCatalogSourceIdentity, + type CatalogEntry, + type CatalogWorkflowSummary, +} from "../catalog"; import { readCapletsLockfile, validateLockfileDestination, @@ -41,6 +50,8 @@ type InstallableCaplet = { hash?: string | undefined; status?: "installed" | "restored" | "updated" | "noop" | undefined; lockfile?: string | undefined; + catalogIndexing?: CatalogIndexingResult | undefined; + vaultSetup?: unknown; }; type InstallPlan = InstallableCaplet & { @@ -384,6 +395,161 @@ export function updateCapletsFromLockfile(options: { return { installed: results }; } +export async function indexInstalledCapletsFromLockfile( + installed: Array<{ id: string; destination?: string | undefined; lockfile?: string | undefined }>, + options: { + disableCatalogIndexing?: boolean | undefined; + endpoint?: string | undefined; + fetch?: typeof fetch | undefined; + } = {}, +): Promise> { + const byLockfile = new Map>(); + if (options.disableCatalogIndexing || process.env.CAPLETS_DISABLE_CATALOG_INDEXING === "1") { + return new Map( + installed.map((entry) => [ + entry.id, + { status: "ineligible", reason: "catalog_indexing_disabled" }, + ]), + ); + } + for (const entry of installed) { + if (!entry.lockfile) continue; + byLockfile.set(entry.lockfile, (byLockfile.get(entry.lockfile) ?? new Set()).add(entry.id)); + } + + const results = new Map(); + for (const [lockfilePath, ids] of byLockfile) { + const lockfile = readCapletsLockfile(lockfilePath); + const destinations = new Map( + installed + .filter((candidate) => candidate.lockfile === lockfilePath && candidate.destination) + .map((candidate) => [candidate.id, candidate.destination!]), + ); + const indexed = await Promise.all( + lockfile.entries + .filter((candidate) => ids.has(candidate.id)) + .map(async (entry) => { + const payload = catalogIndexingPayloadForLockEntry(entry); + if ("status" in payload) { + return [entry.id, payload] as const; + } + payload.entry = catalogEntryForInstalledLockEntry(entry, destinations.get(entry.id)); + return [entry.id, await submitCatalogIndexingPayload(payload, options)] as const; + }), + ); + for (const [id, result] of indexed) { + results.set(id, result); + } + } + return results; +} + +async function submitCatalogIndexingPayload( + payload: { + source: string; + capletId: string; + sourcePath: string; + resolvedRevision?: string | undefined; + contentHash?: string | undefined; + entryKey: string; + entry?: CatalogEntry | undefined; + }, + options: { + endpoint?: string | undefined; + fetch?: typeof fetch | undefined; + }, +): Promise { + const fetchImpl = options.fetch ?? globalThis.fetch; + if (!fetchImpl) { + return { status: "unavailable", entryKey: payload.entryKey, reason: "fetch_unavailable" }; + } + const endpoint = + options.endpoint ?? + process.env.CAPLETS_CATALOG_INDEX_URL ?? + "https://catalog.caplets.dev/api/v1/catalog/install-signals"; + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 3_000); + try { + const response = await fetchImpl(endpoint, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(payload), + signal: controller.signal, + }); + if (!response.ok) { + return { status: "unavailable", entryKey: payload.entryKey, reason: "indexer_unavailable" }; + } + const parsed = (await response.json().catch(() => undefined)) as + | { result?: CatalogIndexingResult } + | undefined; + return parsed?.result?.status + ? { ...parsed.result, entryKey: parsed.result.entryKey ?? payload.entryKey } + : { status: "accepted", entryKey: payload.entryKey }; + } catch { + return { status: "unavailable", entryKey: payload.entryKey, reason: "indexer_unavailable" }; + } finally { + clearTimeout(timeout); + } +} + +function catalogEntryForInstalledLockEntry( + entry: CapletsLockEntry, + destination: string | undefined, +): CatalogEntry | undefined { + if (entry.source.type !== "git" || !destination) return undefined; + const source = normalizeCatalogSourceIdentity(entry.source.repository); + if (!source.eligible) return undefined; + try { + const capletFile = lstatSync(destination).isDirectory() + ? join(destination, "CAPLET.md") + : destination; + const contentMarkdown = readFileSync(capletFile, "utf8"); + const frontmatter = readCapletFrontmatterFromText(contentMarkdown); + return createCatalogEntry({ + id: entry.id, + name: stringFromFrontmatter(frontmatter.name) ?? entry.id, + description: + stringFromFrontmatter(frontmatter.description) ?? `Community Caplet ${entry.id}.`, + source: source.source, + sourcePath: entry.source.path, + trustLevel: "community", + resolvedRevision: entry.source.resolvedRevision, + indexedContentHash: entry.installedHash, + contentMarkdown, + tags: stringArrayFromFrontmatter(frontmatter.tags), + useWhen: stringFromFrontmatter(frontmatter.useWhen), + avoidWhen: stringFromFrontmatter(frontmatter.avoidWhen), + setupRequired: frontmatter.setup !== undefined, + authRequired: capletAuthRequired(frontmatter), + projectBindingRequired: entry.risk.projectBindingRequired, + workflow: workflowSummaryFromRisk(entry.risk), + mutatesExternalState: entry.risk.mutating || entry.risk.destructive, + localControl: entry.risk.safety === "local_control", + }); + } catch { + return undefined; + } +} + +function stringFromFrontmatter(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function stringArrayFromFrontmatter(value: unknown): string[] | undefined { + if (!Array.isArray(value)) return undefined; + return value.filter((entry): entry is string => typeof entry === "string"); +} + +function workflowSummaryFromRisk(risk: CapletsLockEntry["risk"]): CatalogWorkflowSummary { + return ( + catalogWorkflowSummaryForBackendFamily(risk.backendFamilies[0]) ?? { + kind: "set", + label: "Caplet", + } + ); +} + function refreshedLockSource( source: CapletsLockSource, lockedSource: LockedSourceResolution, @@ -647,6 +813,10 @@ function readCapletFrontmatter(sourcePath: string): Record { ? join(sourcePath, "CAPLET.md") : sourcePath; const text = readFileSync(capletFile, "utf8"); + return readCapletFrontmatterFromText(text); +} + +function readCapletFrontmatterFromText(text: string): Record { const match = /^---\r?\n([\s\S]*?)\r?\n---/u.exec(text); if (!match) return {}; const yaml = match[1]; @@ -682,6 +852,11 @@ function capletAuth(frontmatter: Record): Record): boolean { + const auth = capletAuth(frontmatter); + return auth !== undefined && auth.type !== "none"; +} + function derivedSafety(input: { backendFamilies: string[]; auth: Record | undefined; diff --git a/packages/core/src/remote-control/dispatch.ts b/packages/core/src/remote-control/dispatch.ts index aaa8a48e..71b4fd8e 100644 --- a/packages/core/src/remote-control/dispatch.ts +++ b/packages/core/src/remote-control/dispatch.ts @@ -18,9 +18,11 @@ import { completionShells, type CompletionShell } from "./../cli/completion"; import { initConfig } from "./../cli/init"; import { installCaplets, + indexInstalledCapletsFromLockfile, restoreCapletsFromLockfile, updateCapletsFromLockfile, } from "./../cli/install"; +import type { CatalogIndexingResult } from "../catalog-indexing/payload"; import { listCaplets } from "./../cli/inspection"; import { loadConfigWithSources, @@ -139,7 +141,7 @@ async function dispatch(request: RemoteCliRequest, context: RemoteControlDispatc if (request.command === "install") { const repo = optionalString(request.arguments, "repo"); if (!repo) { - return { + const result = { remote: true, ...restoreCapletsFromLockfile({ ...optionalProp("capletIds", optionalStringArray(request.arguments, "capletIds")), @@ -147,8 +149,13 @@ async function dispatch(request: RemoteCliRequest, context: RemoteControlDispatc ...optionalProp("force", optionalBoolean(request.arguments, "force")), }), }; + await attachRemoteCatalogIndexingResults( + result.installed, + optionalBoolean(request.arguments, "disableCatalogIndexing") ?? false, + ); + return result; } - return { + const result = { remote: true, ...installCaplets(repo, { ...optionalProp("capletIds", optionalStringArray(request.arguments, "capletIds")), @@ -156,10 +163,15 @@ async function dispatch(request: RemoteCliRequest, context: RemoteControlDispatc ...optionalProp("force", optionalBoolean(request.arguments, "force")), }), }; + await attachRemoteCatalogIndexingResults( + result.installed, + optionalBoolean(request.arguments, "disableCatalogIndexing") ?? false, + ); + return result; } if (request.command === "update") { - return { + const result = { remote: true, ...updateCapletsFromLockfile({ ...optionalProp("capletIds", optionalStringArray(request.arguments, "capletIds")), @@ -167,6 +179,11 @@ async function dispatch(request: RemoteCliRequest, context: RemoteControlDispatc ...optionalProp("force", optionalBoolean(request.arguments, "force")), }), }; + await attachRemoteCatalogIndexingResults( + result.installed, + optionalBoolean(request.arguments, "disableCatalogIndexing") ?? false, + ); + return result; } if (request.command === "complete_cli") { @@ -223,6 +240,20 @@ async function dispatch(request: RemoteCliRequest, context: RemoteControlDispatc ); } +async function attachRemoteCatalogIndexingResults( + installed: Array<{ + id: string; + lockfile?: string | undefined; + catalogIndexing?: CatalogIndexingResult | undefined; + }>, + disableCatalogIndexing: boolean, +): Promise { + const results = await indexInstalledCapletsFromLockfile(installed, { disableCatalogIndexing }); + for (const entry of installed) { + entry.catalogIndexing = results.get(entry.id); + } +} + function dispatchVault(request: RemoteCliRequest, context: RemoteControlDispatchContext) { const store = remoteVaultStore(context); switch (request.command) { diff --git a/packages/core/test/catalog-indexing.test.ts b/packages/core/test/catalog-indexing.test.ts new file mode 100644 index 00000000..4dd72aad --- /dev/null +++ b/packages/core/test/catalog-indexing.test.ts @@ -0,0 +1,224 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { catalogIndexingPayloadForLockEntry } from "../src/catalog-indexing/eligibility"; +import { indexInstalledCapletsFromLockfile } from "../src/cli/install"; +import { writeCapletsLockfile, type CapletsLockEntry } from "../src/cli/lockfile"; + +const tempDirs: string[] = []; + +afterEach(() => { + delete process.env.CAPLETS_DISABLE_CATALOG_INDEXING; + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("catalog indexing", () => { + it("builds public revision-bound payloads from lockfile entries", () => { + expect(catalogIndexingPayloadForLockEntry(lockEntry())).toEqual({ + source: "community/tools", + capletId: "deploy", + sourcePath: "caplets/deploy/CAPLET.md", + resolvedRevision: "abc123", + contentHash: "sha256-installed", + entryKey: "github:community:tools:caplets%2Fdeploy%2Fcaplet.md:deploy", + }); + }); + + it("skips official, local, and unpinned sources with categorical results", () => { + expect( + catalogIndexingPayloadForLockEntry( + lockEntry({ repository: "spiritledsoftware/caplets", resolvedRevision: "abc123" }), + ), + ).toEqual({ status: "already_current", reason: "official_seed" }); + expect( + catalogIndexingPayloadForLockEntry({ + ...lockEntry(), + source: { type: "local", path: "/private/caplets", portability: "non_portable" }, + }), + ).toEqual({ status: "ineligible", reason: "not_public" }); + expect( + catalogIndexingPayloadForLockEntry({ + ...lockEntry(), + source: { + type: "git", + repository: "https://token@github.com/private/tools", + path: "caplets/secret/CAPLET.md", + resolvedRevision: "abc123", + }, + }), + ).toEqual({ status: "ineligible", reason: "credential_url" }); + expect(catalogIndexingPayloadForLockEntry(lockEntry({ resolvedRevision: undefined }))).toEqual({ + status: "revision_unavailable", + reason: "revision_unavailable", + }); + }); + + it("attaches nonblocking statuses without leaking rejected source values", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-catalog-indexing-")); + tempDirs.push(dir); + const lockfilePath = join(dir, ".caplets.lock.json"); + writeCapletsLockfile(lockfilePath, { + version: 1, + entries: [ + lockEntry(), + { + ...lockEntry({ id: "local" }), + source: { type: "local", path: dir, portability: "non_portable" }, + }, + ], + }); + const fetchImpl = vi.fn(async () => Response.json({ result: { status: "counted" } })); + + const results = await indexInstalledCapletsFromLockfile( + [ + { id: "deploy", lockfile: lockfilePath }, + { id: "local", lockfile: lockfilePath }, + ], + { endpoint: "https://catalog.example.test/install-signals", fetch: fetchImpl }, + ); + + expect(results.get("deploy")).toMatchObject({ status: "counted" }); + expect(results.get("local")).toEqual({ status: "ineligible", reason: "not_public" }); + expect(JSON.stringify([...results.values()])).not.toContain("token"); + expect(fetchImpl).toHaveBeenCalledTimes(1); + }); + + it("derives submitted community readiness from CAPLET frontmatter", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-catalog-readiness-")); + tempDirs.push(dir); + const lockfilePath = join(dir, ".caplets.lock.json"); + const capletPath = join(dir, "deploy.md"); + writeFileSync( + capletPath, + [ + "---", + "name: Deploy", + "description: Deploy projects.", + "setup:", + " steps:", + " - run: npm install", + "httpApi:", + " baseUrl: https://api.example.com", + " auth:", + " type: bearer", + " actions:", + " list:", + " method: GET", + " path: /projects", + "---", + "", + "# Deploy", + "", + ].join("\n"), + ); + writeCapletsLockfile(lockfilePath, { + version: 1, + entries: [ + { + ...lockEntry(), + destination: "deploy.md", + risk: { + ...lockEntry().risk, + authScopes: undefined, + runtimeFeatures: undefined, + }, + }, + ], + }); + let submitted: unknown; + const fetchImpl = vi.fn( + async (_request: Parameters[0], init?: Parameters[1]) => { + submitted = JSON.parse(String(init?.body)); + return Response.json({ result: { status: "accepted" } }); + }, + ); + + await indexInstalledCapletsFromLockfile( + [{ id: "deploy", destination: capletPath, lockfile: lockfilePath }], + { endpoint: "https://catalog.example.test/install-signals", fetch: fetchImpl }, + ); + + expect(submitted).toMatchObject({ + entry: { + setupReadiness: "required", + authReadiness: "required", + }, + }); + }); + + it("honors the catalog indexing environment opt-out", async () => { + process.env.CAPLETS_DISABLE_CATALOG_INDEXING = "1"; + const fetchImpl = vi.fn(); + + const results = await indexInstalledCapletsFromLockfile( + [{ id: "deploy", lockfile: "unused" }], + { + fetch: fetchImpl, + }, + ); + + expect(results.get("deploy")).toEqual({ + status: "ineligible", + reason: "catalog_indexing_disabled", + }); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it("honors an explicit catalog indexing opt-out", async () => { + const fetchImpl = vi.fn(); + + const results = await indexInstalledCapletsFromLockfile( + [{ id: "deploy", lockfile: "unused" }], + { + disableCatalogIndexing: true, + fetch: fetchImpl, + }, + ); + + expect(results.get("deploy")).toEqual({ + status: "ineligible", + reason: "catalog_indexing_disabled", + }); + expect(fetchImpl).not.toHaveBeenCalled(); + }); +}); + +function lockEntry( + overrides: { + id?: string; + repository?: string; + resolvedRevision?: string | undefined; + } = {}, +): CapletsLockEntry { + const hasRevisionOverride = Object.prototype.hasOwnProperty.call(overrides, "resolvedRevision"); + return { + id: overrides.id ?? "deploy", + destination: `${overrides.id ?? "deploy"}.md`, + kind: "file", + source: { + type: "git", + repository: overrides.repository ?? "community/tools", + path: `caplets/${overrides.id ?? "deploy"}/CAPLET.md`, + trackedRef: "HEAD", + ...(hasRevisionOverride + ? overrides.resolvedRevision + ? { resolvedRevision: overrides.resolvedRevision } + : {} + : { resolvedRevision: "abc123" }), + portability: "portable", + }, + installedHash: "sha256-installed", + installedAt: "2026-06-26T00:00:00.000Z", + updatedAt: "2026-06-26T00:00:00.000Z", + risk: { + backendFamilies: ["mcp"], + safety: "standard", + projectBindingRequired: false, + mutating: false, + destructive: false, + }, + }; +} diff --git a/packages/core/test/catalog-model.test.ts b/packages/core/test/catalog-model.test.ts new file mode 100644 index 00000000..e2a04110 --- /dev/null +++ b/packages/core/test/catalog-model.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, it } from "vitest"; +import { + catalogEntryKey, + createCatalogEntry, + formatCatalogInstallCount, + generateCatalogInstallCommand, + normalizeCatalogSourceIdentity, +} from "../src/catalog"; + +describe("catalog model", () => { + it("normalizes public GitHub sources and generates revision-bound install commands", () => { + const normalized = normalizeCatalogSourceIdentity( + "https://github.com/SpiritLedSoftware/Caplets.git", + ); + + expect(normalized).toMatchObject({ + eligible: true, + source: { + provider: "github", + owner: "spiritledsoftware", + repo: "caplets", + repository: "spiritledsoftware/caplets", + canonicalUrl: "https://github.com/spiritledsoftware/caplets", + }, + }); + if (!normalized.eligible) throw new Error("expected GitHub source to be eligible"); + + expect( + generateCatalogInstallCommand({ + source: normalized.source, + capletId: "sentry", + resolvedRevision: "abc123def456", + }), + ).toEqual({ + text: "caplets install spiritledsoftware/caplets sentry", + copyable: false, + revisionBound: false, + reason: "revision_install_unsupported", + }); + }); + + it("keeps official install commands in the supported shorthand shape when no revision is required", () => { + const normalized = normalizeCatalogSourceIdentity("spiritledsoftware/caplets"); + if (!normalized.eligible) throw new Error("expected shorthand source to be eligible"); + + expect( + generateCatalogInstallCommand({ + source: normalized.source, + capletId: "github", + }), + ).toEqual({ + text: "caplets install spiritledsoftware/caplets github", + copyable: true, + revisionBound: false, + }); + }); + + it("rejects credential, local, private, and unsupported source values without echoing them", () => { + const cases = [ + ["https://token@github.com/spiritledsoftware/caplets", "credential_url"], + ["../private-caplets", "local_path"], + ["http://127.0.0.1/caplets.git", "private_host"], + ["https://github.com/spiritledsoftware/caplets", "unsupported_source"], + ["https://github.com/spiritledsoftware/caplets/issues/1", "unsupported_source"], + ["https://github.com/spiritledsoftware/caplets?tab=readme", "unsupported_source"], + ["https://example.com/spiritledsoftware/caplets", "unsupported_source"], + ] as const; + + for (const [source, reason] of cases) { + expect(normalizeCatalogSourceIdentity(source)).toEqual({ + eligible: false, + reason, + redactedSource: "[redacted]", + }); + } + }); + + it("derives stable entry keys from source, path, and Caplet ID without including revisions", () => { + const source = normalizeCatalogSourceIdentity("SpiritLedSoftware/Caplets"); + if (!source.eligible) throw new Error("expected source to be eligible"); + + const first = catalogEntryKey({ + source: source.source, + sourcePath: "./Caplets/Sentry/CAPLET.md", + capletId: "Sentry", + }); + const second = catalogEntryKey({ + source: source.source, + sourcePath: "caplets/sentry/caplet.md", + capletId: "sentry", + }); + + expect(first).toBe(second); + }); + + it("derives readiness, workflow, task, warnings, and low-count display from existing data", () => { + const source = normalizeCatalogSourceIdentity("community/tools"); + if (!source.eligible) throw new Error("expected source to be eligible"); + + const entry = createCatalogEntry({ + id: "deploy", + name: "Deploy", + description: "Deploys a project.", + source: source.source, + sourcePath: "caplets/deploy/CAPLET.md", + trustLevel: "community", + contentMarkdown: "# Deploy\n\nUse this to deploy.", + tags: ["Deploy", "deploy", "ci"], + useWhen: "Deploy the current project.", + setupRequired: true, + authRequired: true, + projectBindingRequired: true, + localControl: true, + mutatesExternalState: true, + workflow: { kind: "code_mode", label: "Code Mode" }, + }); + + expect(entry.entryKey).toBe("github:community:tools:caplets%2Fdeploy%2Fcaplet.md:deploy"); + expect(entry.contentMarkdown).toBe("# Deploy\n\nUse this to deploy."); + expect(entry.tags).toEqual(["ci", "deploy"]); + expect(entry.setupReadiness).toBe("required"); + expect(entry.authReadiness).toBe("required"); + expect(entry.projectBindingReadiness).toBe("required"); + expect(entry.workflow).toEqual({ kind: "code_mode", label: "Code Mode" }); + expect(entry.intendedTask).toBe("Deploy the current project."); + expect(entry.installCommand).toEqual({ + text: "caplets install community/tools deploy", + copyable: false, + revisionBound: false, + reason: "revision_unavailable", + }); + expect(entry.warnings.map((warning) => warning.code)).toEqual([ + "unverified_community", + "local_control", + "mutating_saas", + "auth_required", + "setup_required", + "project_binding_required", + ]); + expect(formatCatalogInstallCount(0)).toBe("<10"); + expect(formatCatalogInstallCount(9)).toBe("<10"); + expect(formatCatalogInstallCount(1234)).toBe("1,234"); + }); +}); diff --git a/packages/core/test/catalog-official-index.test.ts b/packages/core/test/catalog-official-index.test.ts new file mode 100644 index 00000000..bffd2ca9 --- /dev/null +++ b/packages/core/test/catalog-official-index.test.ts @@ -0,0 +1,42 @@ +import { existsSync, readFileSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { describe, expect, it } from "vitest"; +import { generateOfficialCatalogEntries } from "../../../scripts/generate-catalog-index"; + +const repoRoot = resolve(import.meta.dirname, "../../.."); + +describe("official catalog index generation", () => { + it("derives official entries from checked-in Caplet files without catalog metadata", async () => { + const entries = await generateOfficialCatalogEntries(repoRoot); + const github = entries.find((entry) => entry.id === "github"); + + expect(entries.length).toBeGreaterThan(10); + expect(github).toMatchObject({ + id: "github", + name: "GitHub", + sourcePath: "github/CAPLET.md", + trustLevel: "official", + source: { + provider: "github", + repository: "spiritledsoftware/caplets", + }, + installCommand: { + text: "caplets install spiritledsoftware/caplets github", + copyable: true, + }, + authReadiness: "required", + }); + expect(github?.contentMarkdown).toContain("# GitHub"); + expect(JSON.stringify(entries)).not.toContain('"shadowing"'); + expect(JSON.stringify(entries)).not.toContain(repoRoot); + }); + + it("matches the checked-in deterministic seed file", async () => { + const outputPath = join(repoRoot, "apps/catalog/src/data/official-catalog.json"); + + expect(existsSync(outputPath)).toBe(true); + expect(readFileSync(outputPath, "utf8")).toBe( + `${JSON.stringify(await generateOfficialCatalogEntries(repoRoot), null, 2)}\n`, + ); + }); +}); diff --git a/packages/core/test/config.test.ts b/packages/core/test/config.test.ts index 4379657e..14973652 100644 --- a/packages/core/test/config.test.ts +++ b/packages/core/test/config.test.ts @@ -1885,150 +1885,6 @@ describe("config", () => { } }); - it("keeps repository example Caplets loadable", () => { - const examples = loadCapletFiles(join(import.meta.dirname, "../../..", "caplets")); - - const config = parseConfig(examples, { - sources: { - github: { - kind: "global-file", - path: join(import.meta.dirname, "../../..", "caplets", "github", "CAPLET.md"), - }, - }, - vaultResolver: (reference) => { - if (reference.referenceName !== "GH_TOKEN") { - throw new Error( - `Unexpected Vault reference in repository fixture: ${reference.referenceName}`, - ); - } - return { storedKey: reference.referenceName, value: "test-github-token" }; - }, - }); - - expect(config.mcpServers.context7).toMatchObject({ - server: "context7", - name: "Context7 Documentation", - command: "context7-mcp", - setup: { - commands: [ - { - label: "Install Context7 MCP", - command: "npm", - args: ["install", "-g", "@upstash/context7-mcp"], - }, - ], - }, - }); - expect(config.mcpServers.github).toMatchObject({ - server: "github", - name: "GitHub", - transport: "http", - url: "https://api.githubcopilot.com/mcp", - auth: { type: "bearer", token: "test-github-token" }, - }); - expect(config.mcpServers.linear).toMatchObject({ - server: "linear", - name: "Linear", - transport: "http", - url: "https://mcp.linear.app/mcp", - auth: { type: "oauth2" }, - }); - expect(config.mcpServers["ast-grep"]).toMatchObject({ - server: "ast-grep", - name: "ast-grep", - command: "ast-grep-mcp", - setup: { - verify: [ - { - label: "Check ast-grep MCP", - command: "ast-grep-mcp", - args: ["--help"], - }, - ], - }, - }); - expect(config.httpApis.osv).toMatchObject({ - server: "osv", - name: "OSV Vulnerabilities", - baseUrl: "https://api.osv.dev", - auth: { type: "none" }, - actions: { - query_package_version: { - method: "POST", - path: "/v1/query", - jsonBody: { - package: { - name: "$input.name", - ecosystem: "$input.ecosystem", - }, - version: "$input.version", - }, - }, - get_vulnerability: { - method: "GET", - path: "/v1/vulns/{id}", - }, - }, - }); - expect(config.openapiEndpoints.npm).toMatchObject({ - server: "npm", - name: "npm Registry", - specUrl: "https://raw.githubusercontent.com/npm/api-documentation/main/api/base.yaml", - auth: { type: "none" }, - }); - expect(config.openapiEndpoints.pypi).toMatchObject({ - server: "pypi", - name: "PyPI", - specPath: expect.stringMatching(/caplets[/\\]pypi[/\\]pypi\.openapi\.yaml$/), - auth: { type: "none" }, - }); - expect(config.mcpServers.deepwiki).toMatchObject({ - server: "deepwiki", - name: "DeepWiki", - transport: "http", - url: "https://mcp.deepwiki.com/mcp", - auth: { type: "none" }, - }); - expect(config.mcpServers.sourcegraph).toMatchObject({ - server: "sourcegraph", - name: "Sourcegraph", - transport: "http", - url: "https://sourcegraph.com/.api/mcp", - auth: { type: "oauth2" }, - }); - expect(config.mcpServers.playwright).toMatchObject({ - server: "playwright", - name: "Playwright", - command: "playwright-mcp", - args: ["--headless"], - setup: { - commands: [ - { - label: "Install Playwright MCP", - command: "npm", - args: ["install", "-g", "@playwright/mcp@0.0.75"], - }, - { - label: "Install Chromium browser", - command: "npx", - args: ["playwright", "install", "chromium"], - }, - ], - }, - }); - expect(config.mcpServers.lsp).toMatchObject({ - server: "lsp", - name: "LSP", - command: "npx", - args: ["-y", "language-server-mcp"], - }); - expect(config.capletSets["coding-agent-toolkit"]).toMatchObject({ - server: "coding-agent-toolkit", - name: "Coding Agent Toolkit", - capletsRoot: expect.stringMatching(/caplets[/\\]coding-agent-toolkit[/\\]caplets$/), - }); - }); - it("keeps repository Caplet reference files linked from CAPLET.md", () => { const examplesRoot = join(import.meta.dirname, "../../..", "caplets"); const capletDirs = readdirSync(examplesRoot, { withFileTypes: true }).filter((entry) => diff --git a/packages/core/test/package-boundaries.test.ts b/packages/core/test/package-boundaries.test.ts index e9ba501a..41536aea 100644 --- a/packages/core/test/package-boundaries.test.ts +++ b/packages/core/test/package-boundaries.test.ts @@ -98,6 +98,7 @@ describe("package boundaries", () => { it("keeps Worker-safe core exports on dedicated bundles", () => { const dedicatedExports = [ "./caplet-source", + "./catalog", "./observed-output-shapes/pure", "./project-binding", "./redaction", diff --git a/packages/core/test/remote-control-dispatch.test.ts b/packages/core/test/remote-control-dispatch.test.ts index 59321b6b..f5241b3e 100644 --- a/packages/core/test/remote-control-dispatch.test.ts +++ b/packages/core/test/remote-control-dispatch.test.ts @@ -507,6 +507,60 @@ describe("dispatchRemoteCliRequest", () => { }); }); + it("honors remote catalog indexing opt-out from the client request", async () => { + const context = testContext(); + const sourceRepo = join(context.tempRoot, "source-disabled-indexing"); + const sourceCaplets = join(sourceRepo, "caplets"); + mkdirSync(sourceCaplets, { recursive: true }); + writeFileSync( + join(sourceCaplets, "sample.md"), + [ + "---", + "name: Sample", + "description: Sample Caplet.", + "httpApi:", + " baseUrl: http://127.0.0.1:1", + " auth:", + " type: none", + " actions:", + " check:", + " method: GET", + " path: /check", + "---", + "", + "# Sample", + "", + ].join("\n"), + ); + + const response = await dispatchRemoteCliRequest( + { + command: "install", + arguments: { + repo: sourceRepo, + capletIds: ["sample"], + disableCatalogIndexing: true, + }, + }, + context, + ); + + expect(response).toMatchObject({ + ok: true, + result: { + installed: [ + expect.objectContaining({ + id: "sample", + catalogIndexing: { + status: "ineligible", + reason: "catalog_indexing_disabled", + }, + }), + ], + }, + }); + }); + it("dispatches complete_cli using server-owned config", async () => { const context = testContext(); writeFileSync( diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 343ff9b6..f7a56ba0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -22,7 +22,7 @@ importers: version: 7.0.0-dev.20260613.1 alchemy: specifier: 0.93.12 - version: 0.93.12(@opentelemetry/api@1.9.1)(vite@7.3.5(@types/node@25.9.4)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0))(workerd@1.20260617.1) + version: 0.93.12(@astrojs/cloudflare@12.6.13(@types/node@25.9.4)(astro@6.4.8(@types/node@25.9.4)(aws4fetch@1.0.20)(jiti@2.7.0)(lightningcss@1.32.0)(rollup@4.62.2)(tsx@4.22.4)(yaml@2.9.0))(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0))(@cloudflare/vite-plugin@1.42.3(vite@7.3.5(@types/node@25.9.4)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0))(workerd@1.20260617.1)(wrangler@4.105.0(@cloudflare/workers-types@4.20260621.1)))(@opentelemetry/api@1.9.1)(astro@6.4.8(@types/node@25.9.4)(aws4fetch@1.0.20)(jiti@2.7.0)(lightningcss@1.32.0)(rollup@4.62.2)(tsx@4.22.4)(yaml@2.9.0))(vite@7.3.5(@types/node@25.9.4)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0))(workerd@1.20260617.1) husky: specifier: ^9.1.7 version: 9.1.7 @@ -52,7 +52,74 @@ importers: version: 6.0.3 vitest: specifier: ^4.1.8 - version: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@25.9.4)(vite@7.3.5(@types/node@25.9.4)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0)) + version: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@25.9.4)(happy-dom@20.10.6)(vite@7.3.5(@types/node@25.9.4)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0)) + + apps/catalog: + dependencies: + '@astrojs/check': + specifier: ^0.9.9 + version: 0.9.9(prettier-plugin-astro@0.14.1)(prettier@3.8.4)(typescript@6.0.3) + '@astrojs/cloudflare': + specifier: ^13.7.0 + version: 13.7.0(@types/node@25.9.4)(astro@6.4.8(@types/node@25.9.4)(aws4fetch@1.0.20)(jiti@2.7.0)(lightningcss@1.32.0)(rollup@4.62.2)(tsx@4.22.4)(yaml@2.9.0))(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(workerd@1.20260625.1)(wrangler@4.105.0(@cloudflare/workers-types@4.20260621.1))(yaml@2.9.0) + '@caplets/core': + specifier: workspace:* + version: link:../../packages/core + '@hugeicons/core-free-icons': + specifier: ^4.2.2 + version: 4.2.2 + '@tailwindcss/forms': + specifier: ^0.5.11 + version: 0.5.11(tailwindcss@4.3.1) + '@tailwindcss/vite': + specifier: ^4.3.1 + version: 4.3.1(vite@7.3.5(@types/node@25.9.4)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0)) + '@tanstack/virtual-core': + specifier: ^3.17.2 + version: 3.17.2 + astro: + specifier: ^6.4.6 + version: 6.4.8(@types/node@25.9.4)(aws4fetch@1.0.20)(jiti@2.7.0)(lightningcss@1.32.0)(rollup@4.62.2)(tsx@4.22.4)(yaml@2.9.0) + rehype-sanitize: + specifier: ^6.0.0 + version: 6.0.0 + rehype-stringify: + specifier: ^10.0.1 + version: 10.0.1 + remark-parse: + specifier: ^11.0.0 + version: 11.0.0 + remark-rehype: + specifier: ^11.1.2 + version: 11.1.2 + tailwind-variants: + specifier: ^3.2.2 + version: 3.2.2(tailwind-merge@3.6.0)(tailwindcss@4.3.1) + tailwindcss: + specifier: ^4.3.1 + version: 4.3.1 + tw-animate-css: + specifier: ^1.4.0 + version: 1.4.0 + typescript: + specifier: ^6.0.3 + version: 6.0.3 + unified: + specifier: ^11.0.5 + version: 11.0.5 + devDependencies: + happy-dom: + specifier: ^20.10.6 + version: 20.10.6 + vite: + specifier: ^7.3.5 + version: 7.3.5(@types/node@25.9.4)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0) + vitest: + specifier: ^4.1.8 + version: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@25.9.4)(happy-dom@20.10.6)(vite@7.3.5(@types/node@25.9.4)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0)) + wrangler: + specifier: ^4.105.0 + version: 4.105.0(@cloudflare/workers-types@4.20260621.1) apps/docs: dependencies: @@ -151,7 +218,7 @@ importers: version: 6.0.3 vitest: specifier: ^4.1.8 - version: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@25.9.4)(vite@7.3.5(@types/node@25.9.4)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0)) + version: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@25.9.4)(happy-dom@20.10.6)(vite@7.3.5(@types/node@25.9.4)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0)) packages/cli: dependencies: @@ -176,7 +243,7 @@ importers: version: 6.0.3 vitest: specifier: ^4.1.8 - version: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@25.9.4)(vite@7.3.5(@types/node@25.9.4)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0)) + version: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@25.9.4)(happy-dom@20.10.6)(vite@7.3.5(@types/node@25.9.4)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0)) packages/core: dependencies: @@ -273,7 +340,7 @@ importers: version: 1.1.2 vitest: specifier: ^4.1.8 - version: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@25.9.4)(vite@7.3.5(@types/node@25.9.4)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0)) + version: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@25.9.4)(happy-dom@20.10.6)(vite@7.3.5(@types/node@25.9.4)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0)) packages/opencode: dependencies: @@ -301,7 +368,7 @@ importers: version: 6.0.3 vitest: specifier: ^4.1.8 - version: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@25.9.4)(vite@7.3.5(@types/node@25.9.4)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0)) + version: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@25.9.4)(happy-dom@20.10.6)(vite@7.3.5(@types/node@25.9.4)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0)) packages/pi: dependencies: @@ -332,7 +399,7 @@ importers: version: 6.0.3 vitest: specifier: ^4.1.8 - version: 4.1.9(@opentelemetry/api@1.9.0)(@types/node@25.9.4)(vite@7.3.5(@types/node@25.9.4)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0)) + version: 4.1.9(@opentelemetry/api@1.9.0)(@types/node@25.9.4)(happy-dom@20.10.6)(vite@7.3.5(@types/node@25.9.4)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0)) packages: @@ -378,6 +445,17 @@ packages: peerDependencies: typescript: ^5.0.0 || ^6.0.0 + '@astrojs/cloudflare@12.6.13': + resolution: {integrity: sha512-oKaCyiovyQr183r9U93787Ju1zwk+rRMgPnLTwCLckHmOUK7sltA1Gp4LSGt8oNMgqQS6jR7uRdfQ/NPul37QA==} + peerDependencies: + astro: ^5.7.0 + + '@astrojs/cloudflare@13.7.0': + resolution: {integrity: sha512-nXTB70NlhG5JcQjo2a8psart4xG4POLwYZJV498A4JFM3jauMZ51IetkVR784Dy9rjn1qMH5vUO44ng6YqtwQg==} + peerDependencies: + astro: ^6.3.0 + wrangler: ^4.83.0 + '@astrojs/compiler@2.13.1': resolution: {integrity: sha512-f3FN83d2G/v32ipNClRKgYv30onQlMZX1vCeZMjPsMMPl1mDpmbl0+N5BYo4S/ofzqJyS5hvwacEo0CCVDn/Qg==} @@ -387,6 +465,9 @@ packages: '@astrojs/internal-helpers@0.10.0': resolution: {integrity: sha512-Ry2R3VPeIN4uPCSA4xQc+e+vsJXkalKpEbDc07hV+a/o5Bs2N/s/uDcPJH/05L19DKh9tAy7e6JM3YZ6Cxfezw==} + '@astrojs/internal-helpers@0.7.6': + resolution: {integrity: sha512-GOle7smBWKfMSP8osUIGOlB5kaHdQLV3foCsf+5Q9Wsuu+C6Fs3Ez/ttXmhjZ1HkSgsogcM1RXSjjOVieHq16Q==} + '@astrojs/language-server@2.16.10': resolution: {integrity: sha512-87VQ/5GSdHlRnUA+hGuerYyIGAj+9RbZmATyuKLEUePinUXhQ5YkRnRrHhOD9sSi5JOErLjrLkHnfZFEvGrV8w==} hasBin: true @@ -432,6 +513,12 @@ packages: resolution: {integrity: sha512-j8DNruA8ors99Al39RYZPJK4DC1bKkoNm93mAMuBhY9TCNC4R8n1q7ovFnJ5qhGh5Lsh7pa1gpQVpYpsJPeTHQ==} engines: {node: 18.20.8 || ^20.3.0 || >=22.0.0} + '@astrojs/underscore-redirects@1.0.0': + resolution: {integrity: sha512-qZxHwVnmb5FXuvRsaIGaqWgnftjCuMY+GSbaVZdBmE4j8AfgPqKPxYp8SUERyJcjpKCEmO4wD6ybuGH8A2kVRQ==} + + '@astrojs/underscore-redirects@1.0.3': + resolution: {integrity: sha512-cxnGSw+sJigBLdX4TMSZKkzV6C3gMLJMucDk2W+n281Xhie68T2/9f1+1NMNDCZsc5i0FED7Qt5I10g2O9wtZg==} + '@astrojs/yaml2ts@0.2.4': resolution: {integrity: sha512-8oddpOae35pJsXPQXhTkM0ypfKPskVsh2bCxRtbf7e+/Epw2nReakFYpLKjZMEr75CsoF203PMnCocpfz0s69A==} @@ -636,10 +723,23 @@ packages: resolution: {integrity: sha512-EYlRokl8szrP9Z25qT5aepMdBjzBvHF9ZEhzIiUBc9guz/T31EqRgvD0QSgZcpE93xiwrr+OkB4nz0BZyF6fSA==} engines: {node: '>= 20.12.0'} + '@cloudflare/kv-asset-handler@0.4.2': + resolution: {integrity: sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ==} + engines: {node: '>=18.0.0'} + '@cloudflare/kv-asset-handler@0.5.0': resolution: {integrity: sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==} engines: {node: '>=22.0.0'} + '@cloudflare/unenv-preset@2.10.0': + resolution: {integrity: sha512-/uII4vLQXhzCAZzEVeYAjFLBNg2nqTJ1JGzd2lRF6ItYe6U2zVoYGfeKpGx/EkBF6euiU+cyBXgMdtJih+nQ6g==} + peerDependencies: + unenv: 2.0.0-rc.24 + workerd: ^1.20251221.0 + peerDependenciesMeta: + workerd: + optional: true + '@cloudflare/unenv-preset@2.16.1': resolution: {integrity: sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==} peerDependencies: @@ -658,6 +758,19 @@ packages: workerd: optional: true + '@cloudflare/vite-plugin@1.42.3': + resolution: {integrity: sha512-finkD78XE2kpWmWcOo3YjNShcdC2KDS2ZPZTgwuuWBa2FsOCHIWm2c1H6IUXZfK9BqZpHoz4SJI0ZPsjjBYq4A==} + hasBin: true + peerDependencies: + vite: ^6.1.0 || ^7.0.0 || ^8.0.0 + wrangler: ^4.105.0 + + '@cloudflare/workerd-darwin-64@1.20260114.0': + resolution: {integrity: sha512-HNlsRkfNgardCig2P/5bp/dqDECsZ4+NU5XewqArWxMseqt3C5daSuptI620s4pn7Wr0ZKg7jVLH0PDEBkA+aA==} + engines: {node: '>=16'} + cpu: [x64] + os: [darwin] + '@cloudflare/workerd-darwin-64@1.20260424.1': resolution: {integrity: sha512-yFR1XaJbSDLg/qbwtrYaU2xwFXatIPKR5nrMQCN1q/m6+Qe/j6r+kCnFEvOJjMZOm9iCKsE6Qly5clgl4u32qw==} engines: {node: '>=16'} @@ -670,6 +783,18 @@ packages: cpu: [x64] os: [darwin] + '@cloudflare/workerd-darwin-64@1.20260625.1': + resolution: {integrity: sha512-naCfBv0WnnTQIQPTniqMoUlklOIFjrAcSn1X+IAOhY8aFLF/xGYtFjs1eEE8sFib3ZuChGGpU23FFORVczqr0A==} + engines: {node: '>=16'} + cpu: [x64] + os: [darwin] + + '@cloudflare/workerd-darwin-arm64@1.20260114.0': + resolution: {integrity: sha512-qyE1UdFnAlxzb+uCfN/d9c8icch7XRiH49/DjoqEa+bCDihTuRS7GL1RmhVIqHJhb3pX3DzxmKgQZBDBL83Inw==} + engines: {node: '>=16'} + cpu: [arm64] + os: [darwin] + '@cloudflare/workerd-darwin-arm64@1.20260424.1': resolution: {integrity: sha512-LqWKcE7x/9KyC2iQvKPeb20hKST3dYXDZlYTvFymgR1DfLS0OFOCzVGTloVNd7WqvK4SkdzBYfxo7QMIAeBK0w==} engines: {node: '>=16'} @@ -682,6 +807,18 @@ packages: cpu: [arm64] os: [darwin] + '@cloudflare/workerd-darwin-arm64@1.20260625.1': + resolution: {integrity: sha512-jmH6zjp6Wrux46+qtFwDwrj+vd7s5bdwEqeGvdnwE0a4IEeAhKs0L42HQOyID+g5lkrHq9m55+AbhtmRAm63Pw==} + engines: {node: '>=16'} + cpu: [arm64] + os: [darwin] + + '@cloudflare/workerd-linux-64@1.20260114.0': + resolution: {integrity: sha512-Z0BLvAj/JPOabzads2ddDEfgExWTlD22pnwsuNbPwZAGTSZeQa3Y47eGUWyHk+rSGngknk++S7zHTGbKuG7RRg==} + engines: {node: '>=16'} + cpu: [x64] + os: [linux] + '@cloudflare/workerd-linux-64@1.20260424.1': resolution: {integrity: sha512-YlEBFbAYZHe/ylzl8WEYQEU/jr+0XMqXaST2oBk5oVjksdb1NGuJaggluCdZAzuJJ8UqdTmyhY5u/qrasbiFWA==} engines: {node: '>=16'} @@ -694,6 +831,18 @@ packages: cpu: [x64] os: [linux] + '@cloudflare/workerd-linux-64@1.20260625.1': + resolution: {integrity: sha512-MiQkpA/dX8d83Zp64pzHUKfd6ca4cvwxnNobSP6CnXvfESvnNI9pfa+nfwnParla36sPmnYntNkjR7NjRuDeKQ==} + engines: {node: '>=16'} + cpu: [x64] + os: [linux] + + '@cloudflare/workerd-linux-arm64@1.20260114.0': + resolution: {integrity: sha512-kPUmEtUxUWlr9PQ64kuhdK0qyo8idPe5IIXUgi7xCD7mDd6EOe5J7ugDpbfvfbYKEjx4DpLvN2t45izyI/Sodw==} + engines: {node: '>=16'} + cpu: [arm64] + os: [linux] + '@cloudflare/workerd-linux-arm64@1.20260424.1': resolution: {integrity: sha512-qJ0X0m6cL8fWDUPDg8K4IxYZXNJI6XbeOihqjnqKbAClrjdPDn8VUSd+z2XiCQ5NylMtMrpa/skC9UfaR6mh8g==} engines: {node: '>=16'} @@ -706,6 +855,18 @@ packages: cpu: [arm64] os: [linux] + '@cloudflare/workerd-linux-arm64@1.20260625.1': + resolution: {integrity: sha512-LxxW7Qv60Xvv37+w6gUSDpYZziyqMy+cZWd9IvSA5ehVgKAxmzEaYPMiSZlxk32nbIWL9u/tfjXYCOKJ4Lo+XQ==} + engines: {node: '>=16'} + cpu: [arm64] + os: [linux] + + '@cloudflare/workerd-windows-64@1.20260114.0': + resolution: {integrity: sha512-MJnKgm6i1jZGyt2ZHQYCnRlpFTEZcK2rv9y7asS3KdVEXaDgGF8kOns5u6YL6/+eMogfZuHRjfDS+UqRTUYIFA==} + engines: {node: '>=16'} + cpu: [x64] + os: [win32] + '@cloudflare/workerd-windows-64@1.20260424.1': resolution: {integrity: sha512-tZ7Z9qmYNAP6z1/+8r/zKbk8F8DZmpmwNzMeN+zkde2Wnhfr3FBqOkJXT/5zmli8HPoWrIXxSiyqcNDMy8V2Zg==} engines: {node: '>=16'} @@ -718,6 +879,12 @@ packages: cpu: [x64] os: [win32] + '@cloudflare/workerd-windows-64@1.20260625.1': + resolution: {integrity: sha512-LH6iIX1HHaTwVKV5VokDxxUErXJzQoNZFRwVm7Vx/3fB/ApcTcRCUaMqcxI4as94jEUqg+pmX5czOndiveohow==} + engines: {node: '>=16'} + cpu: [x64] + os: [win32] + '@cloudflare/workers-types@4.20260621.1': resolution: {integrity: sha512-c4xrf4shZdDOK1ihh1UKzlS/3MDYiGThT/Oqr4Y3qR9NLCSNzHB7rt+Vk/LOp0ZSNjA+7WNJEQsOhpiQtpT2GA==} @@ -786,6 +953,12 @@ packages: cpu: [ppc64] os: [aix] + '@esbuild/aix-ppc64@0.27.0': + resolution: {integrity: sha512-KuZrd2hRjz01y5JK9mEBSD3Vj3mbCvemhT466rSuJYeE/hjuBrHfjjcjMdTm/sz7au+++sdbJZJmuBwQLuw68A==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + '@esbuild/aix-ppc64@0.27.7': resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} engines: {node: '>=18'} @@ -804,6 +977,12 @@ packages: cpu: [arm64] os: [android] + '@esbuild/android-arm64@0.27.0': + resolution: {integrity: sha512-CC3vt4+1xZrs97/PKDkl0yN7w8edvU2vZvAFGD16n9F0Cvniy5qvzRXjfO1l94efczkkQE6g1x0i73Qf5uthOQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm64@0.27.7': resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} engines: {node: '>=18'} @@ -822,6 +1001,12 @@ packages: cpu: [arm] os: [android] + '@esbuild/android-arm@0.27.0': + resolution: {integrity: sha512-j67aezrPNYWJEOHUNLPj9maeJte7uSMM6gMoxfPC9hOg8N02JuQi/T7ewumf4tNvJadFkvLZMlAq73b9uwdMyQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-arm@0.27.7': resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} engines: {node: '>=18'} @@ -840,6 +1025,12 @@ packages: cpu: [x64] os: [android] + '@esbuild/android-x64@0.27.0': + resolution: {integrity: sha512-wurMkF1nmQajBO1+0CJmcN17U4BP6GqNSROP8t0X/Jiw2ltYGLHpEksp9MpoBqkrFR3kv2/te6Sha26k3+yZ9Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/android-x64@0.27.7': resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} engines: {node: '>=18'} @@ -858,6 +1049,12 @@ packages: cpu: [arm64] os: [darwin] + '@esbuild/darwin-arm64@0.27.0': + resolution: {integrity: sha512-uJOQKYCcHhg07DL7i8MzjvS2LaP7W7Pn/7uA0B5S1EnqAirJtbyw4yC5jQ5qcFjHK9l6o/MX9QisBg12kNkdHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-arm64@0.27.7': resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} engines: {node: '>=18'} @@ -876,6 +1073,12 @@ packages: cpu: [x64] os: [darwin] + '@esbuild/darwin-x64@0.27.0': + resolution: {integrity: sha512-8mG6arH3yB/4ZXiEnXof5MK72dE6zM9cDvUcPtxhUZsDjESl9JipZYW60C3JGreKCEP+p8P/72r69m4AZGJd5g==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + '@esbuild/darwin-x64@0.27.7': resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} engines: {node: '>=18'} @@ -894,6 +1097,12 @@ packages: cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-arm64@0.27.0': + resolution: {integrity: sha512-9FHtyO988CwNMMOE3YIeci+UV+x5Zy8fI2qHNpsEtSF83YPBmE8UWmfYAQg6Ux7Gsmd4FejZqnEUZCMGaNQHQw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-arm64@0.27.7': resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} engines: {node: '>=18'} @@ -912,6 +1121,12 @@ packages: cpu: [x64] os: [freebsd] + '@esbuild/freebsd-x64@0.27.0': + resolution: {integrity: sha512-zCMeMXI4HS/tXvJz8vWGexpZj2YVtRAihHLk1imZj4efx1BQzN76YFeKqlDr3bUWI26wHwLWPd3rwh6pe4EV7g==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + '@esbuild/freebsd-x64@0.27.7': resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} engines: {node: '>=18'} @@ -930,6 +1145,12 @@ packages: cpu: [arm64] os: [linux] + '@esbuild/linux-arm64@0.27.0': + resolution: {integrity: sha512-AS18v0V+vZiLJyi/4LphvBE+OIX682Pu7ZYNsdUHyUKSoRwdnOsMf6FDekwoAFKej14WAkOef3zAORJgAtXnlQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm64@0.27.7': resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} engines: {node: '>=18'} @@ -948,6 +1169,12 @@ packages: cpu: [arm] os: [linux] + '@esbuild/linux-arm@0.27.0': + resolution: {integrity: sha512-t76XLQDpxgmq2cNXKTVEB7O7YMb42atj2Re2Haf45HkaUpjM2J0UuJZDuaGbPbamzZ7bawyGFUkodL+zcE+jvQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-arm@0.27.7': resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} engines: {node: '>=18'} @@ -966,6 +1193,12 @@ packages: cpu: [ia32] os: [linux] + '@esbuild/linux-ia32@0.27.0': + resolution: {integrity: sha512-Mz1jxqm/kfgKkc/KLHC5qIujMvnnarD9ra1cEcrs7qshTUSksPihGrWHVG5+osAIQ68577Zpww7SGapmzSt4Nw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-ia32@0.27.7': resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} engines: {node: '>=18'} @@ -984,6 +1217,12 @@ packages: cpu: [loong64] os: [linux] + '@esbuild/linux-loong64@0.27.0': + resolution: {integrity: sha512-QbEREjdJeIreIAbdG2hLU1yXm1uu+LTdzoq1KCo4G4pFOLlvIspBm36QrQOar9LFduavoWX2msNFAAAY9j4BDg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-loong64@0.27.7': resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} engines: {node: '>=18'} @@ -1002,6 +1241,12 @@ packages: cpu: [mips64el] os: [linux] + '@esbuild/linux-mips64el@0.27.0': + resolution: {integrity: sha512-sJz3zRNe4tO2wxvDpH/HYJilb6+2YJxo/ZNbVdtFiKDufzWq4JmKAiHy9iGoLjAV7r/W32VgaHGkk35cUXlNOg==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-mips64el@0.27.7': resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} engines: {node: '>=18'} @@ -1020,6 +1265,12 @@ packages: cpu: [ppc64] os: [linux] + '@esbuild/linux-ppc64@0.27.0': + resolution: {integrity: sha512-z9N10FBD0DCS2dmSABDBb5TLAyF1/ydVb+N4pi88T45efQ/w4ohr/F/QYCkxDPnkhkp6AIpIcQKQ8F0ANoA2JA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-ppc64@0.27.7': resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} engines: {node: '>=18'} @@ -1038,6 +1289,12 @@ packages: cpu: [riscv64] os: [linux] + '@esbuild/linux-riscv64@0.27.0': + resolution: {integrity: sha512-pQdyAIZ0BWIC5GyvVFn5awDiO14TkT/19FTmFcPdDec94KJ1uZcmFs21Fo8auMXzD4Tt+diXu1LW1gHus9fhFQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-riscv64@0.27.7': resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} engines: {node: '>=18'} @@ -1056,6 +1313,12 @@ packages: cpu: [s390x] os: [linux] + '@esbuild/linux-s390x@0.27.0': + resolution: {integrity: sha512-hPlRWR4eIDDEci953RI1BLZitgi5uqcsjKMxwYfmi4LcwyWo2IcRP+lThVnKjNtk90pLS8nKdroXYOqW+QQH+w==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-s390x@0.27.7': resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} engines: {node: '>=18'} @@ -1074,6 +1337,12 @@ packages: cpu: [x64] os: [linux] + '@esbuild/linux-x64@0.27.0': + resolution: {integrity: sha512-1hBWx4OUJE2cab++aVZ7pObD6s+DK4mPGpemtnAORBvb5l/g5xFGk0vc0PjSkrDs0XaXj9yyob3d14XqvnQ4gw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + '@esbuild/linux-x64@0.27.7': resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} engines: {node: '>=18'} @@ -1092,6 +1361,12 @@ packages: cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-arm64@0.27.0': + resolution: {integrity: sha512-6m0sfQfxfQfy1qRuecMkJlf1cIzTOgyaeXaiVaaki8/v+WB+U4hc6ik15ZW6TAllRlg/WuQXxWj1jx6C+dfy3w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + '@esbuild/netbsd-arm64@0.27.7': resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} engines: {node: '>=18'} @@ -1110,6 +1385,12 @@ packages: cpu: [x64] os: [netbsd] + '@esbuild/netbsd-x64@0.27.0': + resolution: {integrity: sha512-xbbOdfn06FtcJ9d0ShxxvSn2iUsGd/lgPIO2V3VZIPDbEaIj1/3nBBe1AwuEZKXVXkMmpr6LUAgMkLD/4D2PPA==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + '@esbuild/netbsd-x64@0.27.7': resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} engines: {node: '>=18'} @@ -1128,6 +1409,12 @@ packages: cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-arm64@0.27.0': + resolution: {integrity: sha512-fWgqR8uNbCQ/GGv0yhzttj6sU/9Z5/Sv/VGU3F5OuXK6J6SlriONKrQ7tNlwBrJZXRYk5jUhuWvF7GYzGguBZQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-arm64@0.27.7': resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} engines: {node: '>=18'} @@ -1146,6 +1433,12 @@ packages: cpu: [x64] os: [openbsd] + '@esbuild/openbsd-x64@0.27.0': + resolution: {integrity: sha512-aCwlRdSNMNxkGGqQajMUza6uXzR/U0dIl1QmLjPtRbLOx3Gy3otfFu/VjATy4yQzo9yFDGTxYDo1FfAD9oRD2A==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + '@esbuild/openbsd-x64@0.27.7': resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} engines: {node: '>=18'} @@ -1164,6 +1457,12 @@ packages: cpu: [arm64] os: [openharmony] + '@esbuild/openharmony-arm64@0.27.0': + resolution: {integrity: sha512-nyvsBccxNAsNYz2jVFYwEGuRRomqZ149A39SHWk4hV0jWxKM0hjBPm3AmdxcbHiFLbBSwG6SbpIcUbXjgyECfA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + '@esbuild/openharmony-arm64@0.27.7': resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} engines: {node: '>=18'} @@ -1182,6 +1481,12 @@ packages: cpu: [x64] os: [sunos] + '@esbuild/sunos-x64@0.27.0': + resolution: {integrity: sha512-Q1KY1iJafM+UX6CFEL+F4HRTgygmEW568YMqDA5UV97AuZSm21b7SXIrRJDwXWPzr8MGr75fUZPV67FdtMHlHA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/sunos-x64@0.27.7': resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} engines: {node: '>=18'} @@ -1200,6 +1505,12 @@ packages: cpu: [arm64] os: [win32] + '@esbuild/win32-arm64@0.27.0': + resolution: {integrity: sha512-W1eyGNi6d+8kOmZIwi/EDjrL9nxQIQ0MiGqe/AWc6+IaHloxHSGoeRgDRKHFISThLmsewZ5nHFvGFWdBYlgKPg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-arm64@0.27.7': resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} engines: {node: '>=18'} @@ -1218,6 +1529,12 @@ packages: cpu: [ia32] os: [win32] + '@esbuild/win32-ia32@0.27.0': + resolution: {integrity: sha512-30z1aKL9h22kQhilnYkORFYt+3wp7yZsHWus+wSKAJR8JtdfI76LJ4SBdMsCopTR3z/ORqVu5L1vtnHZWVj4cQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-ia32@0.27.7': resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} engines: {node: '>=18'} @@ -1236,6 +1553,12 @@ packages: cpu: [x64] os: [win32] + '@esbuild/win32-x64@0.27.0': + resolution: {integrity: sha512-aIitBcjQeyOhMTImhLZmtxfdOcuNRpwlPNmlFKPcHQYPhEssw75Cl1TSXJXpMkzaua9FUetx/4OQKq7eJul5Cg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@esbuild/win32-x64@0.27.7': resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} engines: {node: '>=18'} @@ -1298,6 +1621,9 @@ packages: peerDependencies: hono: ^4 + '@hugeicons/core-free-icons@4.2.2': + resolution: {integrity: sha512-fjQW3p6cwoJmwK3oCnV8Z3PC5sHihDvr2jkAHax8cxqp62GaV/D7B/Rnao+JwicW8fmLZUQ6QrzWfoyMFOEQlQ==} + '@iarna/toml@2.2.5': resolution: {integrity: sha512-trnsAYxU3xnS1gPHPyU961coFyLkh4gAD/0zQ5mymY4yOZ+CYvsPqUbOFSw0aDM4y0tV7tiFxL/1XfXPNC6IPg==} @@ -2577,6 +2903,9 @@ packages: peerDependencies: vite: ^5.2.0 || ^6 || ^7 || ^8 + '@tanstack/virtual-core@3.17.2': + resolution: {integrity: sha512-w43MvWvmShpb6kIC9MOoLyUkLmRTLPjt61bHWs+X29hACSpX+n8DvgZ3qM7cUfflKlRRcHR9KVJE6TmcqnQvcA==} + '@turbo/darwin-64@2.9.18': resolution: {integrity: sha512-9f27peFu16ur8c0v9nUFUEyBnbKuuFsUTjHFWfmwGfzySBXbHwzU44QhZon6Mznz0cHsIr3984NQj/bVrnGSRw==} cpu: [x64] @@ -2685,6 +3014,9 @@ packages: '@types/webxr@0.5.24': resolution: {integrity: sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==} + '@types/whatwg-mimetype@3.0.2': + resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==} + '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} @@ -3034,6 +3366,10 @@ packages: buffer-equal-constant-time@1.0.1: resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + buffer-image-size@0.6.4: + resolution: {integrity: sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==} + engines: {node: '>=4.0'} + bundle-name@4.1.0: resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} engines: {node: '>=18'} @@ -3447,6 +3783,10 @@ packages: resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} engines: {node: '>=0.12'} + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + env-paths@3.0.0: resolution: {integrity: sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -3488,6 +3828,11 @@ packages: engines: {node: '>=18'} hasBin: true + esbuild@0.27.0: + resolution: {integrity: sha512-jd0f4NHbD6cALCyGElNpGAOtWxSq46l9X/sWB0Nzd5er4Kz2YTm+Vl0qKFT9KUJvD8+fiO8AvoHhFvEatfVixA==} + engines: {node: '>=18'} + hasBin: true + esbuild@0.27.7: resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} engines: {node: '>=18'} @@ -3807,6 +4152,10 @@ packages: h3@1.15.11: resolution: {integrity: sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg==} + happy-dom@20.10.6: + resolution: {integrity: sha512-6QD0ilzDDt93tX44y8tbmZdAcdTRYDhUP+Asgi6pC8Pp5IA3cvaZGyoVN/EGtlq9ziT65iPuBBn3ASLr6hCgVw==} + engines: {node: '>=20.0.0'} + has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} @@ -3856,6 +4205,9 @@ packages: hast-util-raw@9.1.0: resolution: {integrity: sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==} + hast-util-sanitize@5.0.2: + resolution: {integrity: sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg==} + hast-util-select@6.0.4: resolution: {integrity: sha512-RqGS1ZgI0MwxLaKLDxjprynNzINEkRHY2i8ln4DDjgv9ZhcYVIHN9rlpiYsqtFwrgpYU361SyWDQcGNIBVu3lw==} @@ -4498,6 +4850,11 @@ packages: resolution: {integrity: sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==} hasBin: true + miniflare@4.20260114.0: + resolution: {integrity: sha512-QwHT7S6XqGdQxIvql1uirH/7/i3zDEt0B/YBXTYzMfJtVCR4+ue3KPkU+Bl0zMxvpgkvjh9+eCHhJbKEqya70A==} + engines: {node: '>=18.0.0'} + hasBin: true + miniflare@4.20260424.0: resolution: {integrity: sha512-B6MKBBd5TJ19daUc3Ae9rWctn1nDA/VCXykXfCsp9fTxyfGxnZY27tJs1caxgE9MWEMMKGbGHouqVtgKbKGxmw==} engines: {node: '>=18.0.0'} @@ -4508,6 +4865,11 @@ packages: engines: {node: '>=22.0.0'} hasBin: true + miniflare@4.20260625.0: + resolution: {integrity: sha512-3kKXwRUObJsnBYPBgR0NiNZYKF/yv8GFyha1cx2EeAEraxNODgRVcyeRo+F1ok1tg5Mg7iUpOWSkknQTHuFhwA==} + engines: {node: '>=22.0.0'} + hasBin: true + minimatch@10.2.5: resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} engines: {node: 18 || 20 || >=22} @@ -4963,6 +5325,9 @@ packages: rehype-recma@1.0.0: resolution: {integrity: sha512-lqA4rGUf1JmacCNWWZx0Wv1dHqMwxzsDWYMTowuplHF3xH0N/MmrZ/G3BDZnzAkRmxDadujCjaKM2hqYdCBOGw==} + rehype-sanitize@6.0.0: + resolution: {integrity: sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg==} + rehype-stringify@10.0.1: resolution: {integrity: sha512-k9ecfXHmIPuFVI61B9DeLPN0qFHfawM6RsuX48hoqlaKSF61RskNjSm1lI8PhBEM0MRdLxVVm4WmTqJQccH9mA==} @@ -5410,6 +5775,10 @@ packages: undici-types@7.24.6: resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} + undici@7.14.0: + resolution: {integrity: sha512-Vqs8HTzjpQXZeXdpsfChQTlafcMQaaIwnGwLam1wudSSjlJeQ3bw1j+TLPePgrCnCpUXx7Ba5Pdpf5OBih62NQ==} + engines: {node: '>=20.18.1'} + undici@7.24.8: resolution: {integrity: sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ==} engines: {node: '>=20.18.1'} @@ -5564,6 +5933,46 @@ packages: vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + vite@6.4.3: + resolution: {integrity: sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + jiti: '>=1.21.0' + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + vite@7.3.5: resolution: {integrity: sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww==} engines: {node: ^20.19.0 || >=22.12.0} @@ -5770,6 +6179,10 @@ packages: resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} engines: {node: '>=20'} + whatwg-mimetype@3.0.0: + resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} + engines: {node: '>=12'} + whatwg-url@16.0.1: resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} @@ -5788,6 +6201,11 @@ packages: engines: {node: '>=8'} hasBin: true + workerd@1.20260114.0: + resolution: {integrity: sha512-kTJ+jNdIllOzWuVA3NRQRvywP0T135zdCjAE2dAUY1BFbxM6fmMZV8BbskEoQ4hAODVQUfZQmyGctcwvVCKxFA==} + engines: {node: '>=16'} + hasBin: true + workerd@1.20260424.1: resolution: {integrity: sha512-oKsB0Xo/mfkYMdSACoS06XZg09VUK4rXwHfF/1t3P++sMbwzf4UHQvMO57+zxpEB2nVrY/ZkW0bYFGq4GdAFSQ==} engines: {node: '>=16'} @@ -5798,12 +6216,37 @@ packages: engines: {node: '>=16'} hasBin: true + workerd@1.20260625.1: + resolution: {integrity: sha512-GApQvFX52SDM6L4u0+RRnUDB1wJOnEwoXjinkmOPtIyofWBxrlZckdegJSYc1leg++lLZ3+DQ4zMVmBqYVtzfA==} + engines: {node: '>=16'} + hasBin: true + wrangler@4.103.0: resolution: {integrity: sha512-3Lv1P5t2xcSEkSTKtG+Lz+3JFryuU7YPLkaCUj7gNe+CJsjZJLtUwqsh1x595QBxkIbCE0GAvDx2DCJUU4+oqw==} engines: {node: '>=22.0.0'} hasBin: true peerDependencies: - '@cloudflare/workers-types': ^4.20260617.1 + '@cloudflare/workers-types': ^4.20260617.1 + peerDependenciesMeta: + '@cloudflare/workers-types': + optional: true + + wrangler@4.105.0: + resolution: {integrity: sha512-7dXFH6OLj1Fv0y6ZeRPUxFTkp+duWD7/xxVi/1c0vfOeEYwIFKWB7cdqnY05DvY1Ta3BnqAwRkXfLs8PDj538g==} + engines: {node: '>=22.0.0'} + hasBin: true + peerDependencies: + '@cloudflare/workers-types': ^4.20260625.1 + peerDependenciesMeta: + '@cloudflare/workers-types': + optional: true + + wrangler@4.59.2: + resolution: {integrity: sha512-Z4xn6jFZTaugcOKz42xvRAYKgkVUERHVbuCJ5+f+gK+R6k12L02unakPGOA0L0ejhUl16dqDjKe4tmL9sedHcw==} + engines: {node: '>=20.0.0'} + hasBin: true + peerDependencies: + '@cloudflare/workers-types': ^4.20260114.0 peerDependenciesMeta: '@cloudflare/workers-types': optional: true @@ -5911,6 +6354,9 @@ packages: peerDependencies: zod: ^3.25.28 || ^4 + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + zod@4.1.8: resolution: {integrity: sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ==} @@ -5982,6 +6428,57 @@ snapshots: - prettier - prettier-plugin-astro + '@astrojs/cloudflare@12.6.13(@types/node@25.9.4)(astro@6.4.8(@types/node@25.9.4)(aws4fetch@1.0.20)(jiti@2.7.0)(lightningcss@1.32.0)(rollup@4.62.2)(tsx@4.22.4)(yaml@2.9.0))(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0)': + dependencies: + '@astrojs/internal-helpers': 0.7.6 + '@astrojs/underscore-redirects': 1.0.0 + '@cloudflare/workers-types': 4.20260621.1 + astro: 6.4.8(@types/node@25.9.4)(aws4fetch@1.0.20)(jiti@2.7.0)(lightningcss@1.32.0)(rollup@4.62.2)(tsx@4.22.4)(yaml@2.9.0) + tinyglobby: 0.2.17 + vite: 6.4.3(@types/node@25.9.4)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0) + wrangler: 4.59.2(@cloudflare/workers-types@4.20260621.1) + transitivePeerDependencies: + - '@types/node' + - bufferutil + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - terser + - tsx + - utf-8-validate + - yaml + optional: true + + '@astrojs/cloudflare@13.7.0(@types/node@25.9.4)(astro@6.4.8(@types/node@25.9.4)(aws4fetch@1.0.20)(jiti@2.7.0)(lightningcss@1.32.0)(rollup@4.62.2)(tsx@4.22.4)(yaml@2.9.0))(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(workerd@1.20260625.1)(wrangler@4.105.0(@cloudflare/workers-types@4.20260621.1))(yaml@2.9.0)': + dependencies: + '@astrojs/internal-helpers': 0.10.0 + '@astrojs/underscore-redirects': 1.0.3 + '@cloudflare/vite-plugin': 1.42.3(vite@7.3.5(@types/node@25.9.4)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0))(workerd@1.20260625.1)(wrangler@4.105.0(@cloudflare/workers-types@4.20260621.1)) + astro: 6.4.8(@types/node@25.9.4)(aws4fetch@1.0.20)(jiti@2.7.0)(lightningcss@1.32.0)(rollup@4.62.2)(tsx@4.22.4)(yaml@2.9.0) + piccolore: 0.1.3 + tinyglobby: 0.2.17 + vite: 7.3.5(@types/node@25.9.4)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0) + wrangler: 4.105.0(@cloudflare/workers-types@4.20260621.1) + transitivePeerDependencies: + - '@types/node' + - bufferutil + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - terser + - tsx + - utf-8-validate + - workerd + - yaml + '@astrojs/compiler@2.13.1': {} '@astrojs/compiler@4.0.0': {} @@ -5997,6 +6494,9 @@ snapshots: smol-toml: 1.6.1 unified: 11.0.5 + '@astrojs/internal-helpers@0.7.6': + optional: true + '@astrojs/language-server@2.16.10(prettier-plugin-astro@0.14.1)(prettier@3.8.4)(typescript@6.0.3)': dependencies: '@astrojs/compiler': 2.13.1 @@ -6118,6 +6618,11 @@ snapshots: is-wsl: 3.1.1 which-pm-runs: 1.1.0 + '@astrojs/underscore-redirects@1.0.0': + optional: true + + '@astrojs/underscore-redirects@1.0.3': {} + '@astrojs/yaml2ts@0.2.4': dependencies: yaml: 2.9.0 @@ -6560,50 +7065,123 @@ snapshots: fast-wrap-ansi: 0.2.2 sisteransi: 1.0.5 + '@cloudflare/kv-asset-handler@0.4.2': + optional: true + '@cloudflare/kv-asset-handler@0.5.0': {} + '@cloudflare/unenv-preset@2.10.0(unenv@2.0.0-rc.24)(workerd@1.20260114.0)': + dependencies: + unenv: 2.0.0-rc.24 + optionalDependencies: + workerd: 1.20260114.0 + optional: true + '@cloudflare/unenv-preset@2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260617.1)': dependencies: unenv: 2.0.0-rc.24 optionalDependencies: workerd: 1.20260617.1 + '@cloudflare/unenv-preset@2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260625.1)': + dependencies: + unenv: 2.0.0-rc.24 + optionalDependencies: + workerd: 1.20260625.1 + '@cloudflare/unenv-preset@2.7.7(unenv@2.0.0-rc.21)(workerd@1.20260617.1)': dependencies: unenv: 2.0.0-rc.21 optionalDependencies: workerd: 1.20260617.1 + '@cloudflare/vite-plugin@1.42.3(vite@7.3.5(@types/node@25.9.4)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0))(workerd@1.20260617.1)(wrangler@4.105.0(@cloudflare/workers-types@4.20260621.1))': + dependencies: + '@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260617.1) + miniflare: 4.20260625.0 + unenv: 2.0.0-rc.24 + vite: 7.3.5(@types/node@25.9.4)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0) + wrangler: 4.105.0(@cloudflare/workers-types@4.20260621.1) + ws: 8.21.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + - workerd + optional: true + + '@cloudflare/vite-plugin@1.42.3(vite@7.3.5(@types/node@25.9.4)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0))(workerd@1.20260625.1)(wrangler@4.105.0(@cloudflare/workers-types@4.20260621.1))': + dependencies: + '@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260625.1) + miniflare: 4.20260625.0 + unenv: 2.0.0-rc.24 + vite: 7.3.5(@types/node@25.9.4)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0) + wrangler: 4.105.0(@cloudflare/workers-types@4.20260621.1) + ws: 8.21.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + - workerd + + '@cloudflare/workerd-darwin-64@1.20260114.0': + optional: true + '@cloudflare/workerd-darwin-64@1.20260424.1': optional: true '@cloudflare/workerd-darwin-64@1.20260617.1': optional: true + '@cloudflare/workerd-darwin-64@1.20260625.1': + optional: true + + '@cloudflare/workerd-darwin-arm64@1.20260114.0': + optional: true + '@cloudflare/workerd-darwin-arm64@1.20260424.1': optional: true '@cloudflare/workerd-darwin-arm64@1.20260617.1': optional: true + '@cloudflare/workerd-darwin-arm64@1.20260625.1': + optional: true + + '@cloudflare/workerd-linux-64@1.20260114.0': + optional: true + '@cloudflare/workerd-linux-64@1.20260424.1': optional: true '@cloudflare/workerd-linux-64@1.20260617.1': optional: true + '@cloudflare/workerd-linux-64@1.20260625.1': + optional: true + + '@cloudflare/workerd-linux-arm64@1.20260114.0': + optional: true + '@cloudflare/workerd-linux-arm64@1.20260424.1': optional: true '@cloudflare/workerd-linux-arm64@1.20260617.1': optional: true + '@cloudflare/workerd-linux-arm64@1.20260625.1': + optional: true + + '@cloudflare/workerd-windows-64@1.20260114.0': + optional: true + '@cloudflare/workerd-windows-64@1.20260424.1': optional: true '@cloudflare/workerd-windows-64@1.20260617.1': optional: true + '@cloudflare/workerd-windows-64@1.20260625.1': + optional: true + '@cloudflare/workers-types@4.20260621.1': {} '@cspotcode/source-map-support@0.8.1': @@ -6726,6 +7304,9 @@ snapshots: '@esbuild/aix-ppc64@0.25.12': optional: true + '@esbuild/aix-ppc64@0.27.0': + optional: true + '@esbuild/aix-ppc64@0.27.7': optional: true @@ -6735,6 +7316,9 @@ snapshots: '@esbuild/android-arm64@0.25.12': optional: true + '@esbuild/android-arm64@0.27.0': + optional: true + '@esbuild/android-arm64@0.27.7': optional: true @@ -6744,6 +7328,9 @@ snapshots: '@esbuild/android-arm@0.25.12': optional: true + '@esbuild/android-arm@0.27.0': + optional: true + '@esbuild/android-arm@0.27.7': optional: true @@ -6753,6 +7340,9 @@ snapshots: '@esbuild/android-x64@0.25.12': optional: true + '@esbuild/android-x64@0.27.0': + optional: true + '@esbuild/android-x64@0.27.7': optional: true @@ -6762,6 +7352,9 @@ snapshots: '@esbuild/darwin-arm64@0.25.12': optional: true + '@esbuild/darwin-arm64@0.27.0': + optional: true + '@esbuild/darwin-arm64@0.27.7': optional: true @@ -6771,6 +7364,9 @@ snapshots: '@esbuild/darwin-x64@0.25.12': optional: true + '@esbuild/darwin-x64@0.27.0': + optional: true + '@esbuild/darwin-x64@0.27.7': optional: true @@ -6780,6 +7376,9 @@ snapshots: '@esbuild/freebsd-arm64@0.25.12': optional: true + '@esbuild/freebsd-arm64@0.27.0': + optional: true + '@esbuild/freebsd-arm64@0.27.7': optional: true @@ -6789,6 +7388,9 @@ snapshots: '@esbuild/freebsd-x64@0.25.12': optional: true + '@esbuild/freebsd-x64@0.27.0': + optional: true + '@esbuild/freebsd-x64@0.27.7': optional: true @@ -6798,6 +7400,9 @@ snapshots: '@esbuild/linux-arm64@0.25.12': optional: true + '@esbuild/linux-arm64@0.27.0': + optional: true + '@esbuild/linux-arm64@0.27.7': optional: true @@ -6807,6 +7412,9 @@ snapshots: '@esbuild/linux-arm@0.25.12': optional: true + '@esbuild/linux-arm@0.27.0': + optional: true + '@esbuild/linux-arm@0.27.7': optional: true @@ -6816,6 +7424,9 @@ snapshots: '@esbuild/linux-ia32@0.25.12': optional: true + '@esbuild/linux-ia32@0.27.0': + optional: true + '@esbuild/linux-ia32@0.27.7': optional: true @@ -6825,6 +7436,9 @@ snapshots: '@esbuild/linux-loong64@0.25.12': optional: true + '@esbuild/linux-loong64@0.27.0': + optional: true + '@esbuild/linux-loong64@0.27.7': optional: true @@ -6834,6 +7448,9 @@ snapshots: '@esbuild/linux-mips64el@0.25.12': optional: true + '@esbuild/linux-mips64el@0.27.0': + optional: true + '@esbuild/linux-mips64el@0.27.7': optional: true @@ -6843,6 +7460,9 @@ snapshots: '@esbuild/linux-ppc64@0.25.12': optional: true + '@esbuild/linux-ppc64@0.27.0': + optional: true + '@esbuild/linux-ppc64@0.27.7': optional: true @@ -6852,6 +7472,9 @@ snapshots: '@esbuild/linux-riscv64@0.25.12': optional: true + '@esbuild/linux-riscv64@0.27.0': + optional: true + '@esbuild/linux-riscv64@0.27.7': optional: true @@ -6861,6 +7484,9 @@ snapshots: '@esbuild/linux-s390x@0.25.12': optional: true + '@esbuild/linux-s390x@0.27.0': + optional: true + '@esbuild/linux-s390x@0.27.7': optional: true @@ -6870,6 +7496,9 @@ snapshots: '@esbuild/linux-x64@0.25.12': optional: true + '@esbuild/linux-x64@0.27.0': + optional: true + '@esbuild/linux-x64@0.27.7': optional: true @@ -6879,6 +7508,9 @@ snapshots: '@esbuild/netbsd-arm64@0.25.12': optional: true + '@esbuild/netbsd-arm64@0.27.0': + optional: true + '@esbuild/netbsd-arm64@0.27.7': optional: true @@ -6888,6 +7520,9 @@ snapshots: '@esbuild/netbsd-x64@0.25.12': optional: true + '@esbuild/netbsd-x64@0.27.0': + optional: true + '@esbuild/netbsd-x64@0.27.7': optional: true @@ -6897,6 +7532,9 @@ snapshots: '@esbuild/openbsd-arm64@0.25.12': optional: true + '@esbuild/openbsd-arm64@0.27.0': + optional: true + '@esbuild/openbsd-arm64@0.27.7': optional: true @@ -6906,6 +7544,9 @@ snapshots: '@esbuild/openbsd-x64@0.25.12': optional: true + '@esbuild/openbsd-x64@0.27.0': + optional: true + '@esbuild/openbsd-x64@0.27.7': optional: true @@ -6915,6 +7556,9 @@ snapshots: '@esbuild/openharmony-arm64@0.25.12': optional: true + '@esbuild/openharmony-arm64@0.27.0': + optional: true + '@esbuild/openharmony-arm64@0.27.7': optional: true @@ -6924,6 +7568,9 @@ snapshots: '@esbuild/sunos-x64@0.25.12': optional: true + '@esbuild/sunos-x64@0.27.0': + optional: true + '@esbuild/sunos-x64@0.27.7': optional: true @@ -6933,6 +7580,9 @@ snapshots: '@esbuild/win32-arm64@0.25.12': optional: true + '@esbuild/win32-arm64@0.27.0': + optional: true + '@esbuild/win32-arm64@0.27.7': optional: true @@ -6942,6 +7592,9 @@ snapshots: '@esbuild/win32-ia32@0.25.12': optional: true + '@esbuild/win32-ia32@0.27.0': + optional: true + '@esbuild/win32-ia32@0.27.7': optional: true @@ -6951,6 +7604,9 @@ snapshots: '@esbuild/win32-x64@0.25.12': optional: true + '@esbuild/win32-x64@0.27.0': + optional: true + '@esbuild/win32-x64@0.27.7': optional: true @@ -7013,6 +7669,8 @@ snapshots: dependencies: hono: 4.12.26 + '@hugeicons/core-free-icons@4.2.2': {} + '@iarna/toml@2.2.5': {} '@img/colour@1.1.0': {} @@ -7994,6 +8652,8 @@ snapshots: tailwindcss: 4.3.1 vite: 7.3.5(@types/node@25.9.4)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0) + '@tanstack/virtual-core@3.17.2': {} + '@turbo/darwin-64@2.9.18': optional: true @@ -8095,6 +8755,8 @@ snapshots: '@types/webxr@0.5.24': {} + '@types/whatwg-mimetype@3.0.2': {} + '@types/ws@8.18.1': dependencies: '@types/node': 25.9.4 @@ -8261,7 +8923,7 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 - alchemy@0.93.12(@opentelemetry/api@1.9.1)(vite@7.3.5(@types/node@25.9.4)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0))(workerd@1.20260617.1): + alchemy@0.93.12(@astrojs/cloudflare@12.6.13(@types/node@25.9.4)(astro@6.4.8(@types/node@25.9.4)(aws4fetch@1.0.20)(jiti@2.7.0)(lightningcss@1.32.0)(rollup@4.62.2)(tsx@4.22.4)(yaml@2.9.0))(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0))(@cloudflare/vite-plugin@1.42.3(vite@7.3.5(@types/node@25.9.4)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0))(workerd@1.20260617.1)(wrangler@4.105.0(@cloudflare/workers-types@4.20260621.1)))(@opentelemetry/api@1.9.1)(astro@6.4.8(@types/node@25.9.4)(aws4fetch@1.0.20)(jiti@2.7.0)(lightningcss@1.32.0)(rollup@4.62.2)(tsx@4.22.4)(yaml@2.9.0))(vite@7.3.5(@types/node@25.9.4)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0))(workerd@1.20260617.1): dependencies: '@aws-sdk/credential-providers': 3.1073.0 '@cloudflare/unenv-preset': 2.7.7(unenv@2.0.0-rc.21)(workerd@1.20260617.1) @@ -8294,6 +8956,9 @@ snapshots: ws: 8.21.0 yaml: 2.9.0 optionalDependencies: + '@astrojs/cloudflare': 12.6.13(@types/node@25.9.4)(astro@6.4.8(@types/node@25.9.4)(aws4fetch@1.0.20)(jiti@2.7.0)(lightningcss@1.32.0)(rollup@4.62.2)(tsx@4.22.4)(yaml@2.9.0))(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0) + '@cloudflare/vite-plugin': 1.42.3(vite@7.3.5(@types/node@25.9.4)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0))(workerd@1.20260617.1)(wrangler@4.105.0(@cloudflare/workers-types@4.20260621.1)) + astro: 6.4.8(@types/node@25.9.4)(aws4fetch@1.0.20)(jiti@2.7.0)(lightningcss@1.32.0)(rollup@4.62.2)(tsx@4.22.4)(yaml@2.9.0) vite: 7.3.5(@types/node@25.9.4)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0) transitivePeerDependencies: - '@aws-sdk/client-rds-data' @@ -8540,6 +9205,10 @@ snapshots: buffer-equal-constant-time@1.0.1: {} + buffer-image-size@0.6.4: + dependencies: + '@types/node': 25.9.4 + bundle-name@4.1.0: dependencies: run-applescript: 7.1.0 @@ -8817,6 +9486,8 @@ snapshots: entities@6.0.1: {} + entities@7.0.1: {} + env-paths@3.0.0: {} environment@1.1.0: {} @@ -8883,6 +9554,36 @@ snapshots: '@esbuild/win32-ia32': 0.25.12 '@esbuild/win32-x64': 0.25.12 + esbuild@0.27.0: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.0 + '@esbuild/android-arm': 0.27.0 + '@esbuild/android-arm64': 0.27.0 + '@esbuild/android-x64': 0.27.0 + '@esbuild/darwin-arm64': 0.27.0 + '@esbuild/darwin-x64': 0.27.0 + '@esbuild/freebsd-arm64': 0.27.0 + '@esbuild/freebsd-x64': 0.27.0 + '@esbuild/linux-arm': 0.27.0 + '@esbuild/linux-arm64': 0.27.0 + '@esbuild/linux-ia32': 0.27.0 + '@esbuild/linux-loong64': 0.27.0 + '@esbuild/linux-mips64el': 0.27.0 + '@esbuild/linux-ppc64': 0.27.0 + '@esbuild/linux-riscv64': 0.27.0 + '@esbuild/linux-s390x': 0.27.0 + '@esbuild/linux-x64': 0.27.0 + '@esbuild/netbsd-arm64': 0.27.0 + '@esbuild/netbsd-x64': 0.27.0 + '@esbuild/openbsd-arm64': 0.27.0 + '@esbuild/openbsd-x64': 0.27.0 + '@esbuild/openharmony-arm64': 0.27.0 + '@esbuild/sunos-x64': 0.27.0 + '@esbuild/win32-arm64': 0.27.0 + '@esbuild/win32-ia32': 0.27.0 + '@esbuild/win32-x64': 0.27.0 + optional: true + esbuild@0.27.7: optionalDependencies: '@esbuild/aix-ppc64': 0.27.7 @@ -9326,6 +10027,19 @@ snapshots: ufo: 1.6.4 uncrypto: 0.1.3 + happy-dom@20.10.6: + dependencies: + '@types/node': 25.9.4 + '@types/whatwg-mimetype': 3.0.2 + '@types/ws': 8.18.1 + buffer-image-size: 0.6.4 + entities: 7.0.1 + whatwg-mimetype: 3.0.0 + ws: 8.21.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + has-flag@4.0.0: {} has-symbols@1.1.0: {} @@ -9421,6 +10135,12 @@ snapshots: web-namespaces: 2.0.1 zwitch: 2.0.4 + hast-util-sanitize@5.0.2: + dependencies: + '@types/hast': 3.0.4 + '@ungap/structured-clone': 1.3.1 + unist-util-position: 5.0.0 + hast-util-select@6.0.4: dependencies: '@types/hast': 3.0.4 @@ -10357,6 +11077,20 @@ snapshots: mini-svg-data-uri@1.4.4: {} + miniflare@4.20260114.0: + dependencies: + '@cspotcode/source-map-support': 0.8.1 + sharp: 0.34.5 + undici: 7.14.0 + workerd: 1.20260114.0 + ws: 8.18.0 + youch: 4.1.0-beta.10 + zod: 3.25.76 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + optional: true + miniflare@4.20260424.0: dependencies: '@cspotcode/source-map-support': 0.8.1 @@ -10381,6 +11115,18 @@ snapshots: - bufferutil - utf-8-validate + miniflare@4.20260625.0: + dependencies: + '@cspotcode/source-map-support': 0.8.1 + sharp: 0.34.5 + undici: 7.28.0 + workerd: 1.20260625.1 + ws: 8.21.0 + youch: 4.1.0-beta.10 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + minimatch@10.2.5: dependencies: brace-expansion: 5.0.6 @@ -10872,6 +11618,11 @@ snapshots: transitivePeerDependencies: - supports-color + rehype-sanitize@6.0.0: + dependencies: + '@types/hast': 3.0.4 + hast-util-sanitize: 5.0.2 + rehype-stringify@10.0.1: dependencies: '@types/hast': 3.0.4 @@ -11420,6 +12171,9 @@ snapshots: undici-types@7.24.6: {} + undici@7.14.0: + optional: true + undici@7.24.8: {} undici@7.28.0: {} @@ -11547,6 +12301,23 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 + vite@6.4.3(@types/node@25.9.4)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0): + dependencies: + esbuild: 0.25.12 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + postcss: 8.5.15 + rollup: 4.62.2 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 25.9.4 + fsevents: 2.3.3 + jiti: 2.7.0 + lightningcss: 1.32.0 + tsx: 4.22.4 + yaml: 2.9.0 + optional: true + vite@7.3.5(@types/node@25.9.4)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0): dependencies: esbuild: 0.27.7 @@ -11567,7 +12338,7 @@ snapshots: optionalDependencies: vite: 7.3.5(@types/node@25.9.4)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0) - vitest@4.1.9(@opentelemetry/api@1.9.0)(@types/node@25.9.4)(vite@7.3.5(@types/node@25.9.4)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0)): + vitest@4.1.9(@opentelemetry/api@1.9.0)(@types/node@25.9.4)(happy-dom@20.10.6)(vite@7.3.5(@types/node@25.9.4)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.9 '@vitest/mocker': 4.1.9(vite@7.3.5(@types/node@25.9.4)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0)) @@ -11592,10 +12363,11 @@ snapshots: optionalDependencies: '@opentelemetry/api': 1.9.0 '@types/node': 25.9.4 + happy-dom: 20.10.6 transitivePeerDependencies: - msw - vitest@4.1.9(@opentelemetry/api@1.9.1)(@types/node@25.9.4)(vite@7.3.5(@types/node@25.9.4)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0)): + vitest@4.1.9(@opentelemetry/api@1.9.1)(@types/node@25.9.4)(happy-dom@20.10.6)(vite@7.3.5(@types/node@25.9.4)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.9 '@vitest/mocker': 4.1.9(vite@7.3.5(@types/node@25.9.4)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0)) @@ -11620,6 +12392,7 @@ snapshots: optionalDependencies: '@opentelemetry/api': 1.9.1 '@types/node': 25.9.4 + happy-dom: 20.10.6 transitivePeerDependencies: - msw @@ -11737,6 +12510,8 @@ snapshots: webidl-conversions@8.0.1: {} + whatwg-mimetype@3.0.0: {} + whatwg-url@16.0.1: dependencies: '@exodus/bytes': 1.15.1 @@ -11756,6 +12531,15 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 + workerd@1.20260114.0: + optionalDependencies: + '@cloudflare/workerd-darwin-64': 1.20260114.0 + '@cloudflare/workerd-darwin-arm64': 1.20260114.0 + '@cloudflare/workerd-linux-64': 1.20260114.0 + '@cloudflare/workerd-linux-arm64': 1.20260114.0 + '@cloudflare/workerd-windows-64': 1.20260114.0 + optional: true + workerd@1.20260424.1: optionalDependencies: '@cloudflare/workerd-darwin-64': 1.20260424.1 @@ -11772,6 +12556,14 @@ snapshots: '@cloudflare/workerd-linux-arm64': 1.20260617.1 '@cloudflare/workerd-windows-64': 1.20260617.1 + workerd@1.20260625.1: + optionalDependencies: + '@cloudflare/workerd-darwin-64': 1.20260625.1 + '@cloudflare/workerd-darwin-arm64': 1.20260625.1 + '@cloudflare/workerd-linux-64': 1.20260625.1 + '@cloudflare/workerd-linux-arm64': 1.20260625.1 + '@cloudflare/workerd-windows-64': 1.20260625.1 + wrangler@4.103.0(@cloudflare/workers-types@4.20260621.1): dependencies: '@cloudflare/kv-asset-handler': 0.5.0 @@ -11789,6 +12581,41 @@ snapshots: - bufferutil - utf-8-validate + wrangler@4.105.0(@cloudflare/workers-types@4.20260621.1): + dependencies: + '@cloudflare/kv-asset-handler': 0.5.0 + '@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260625.1) + blake3-wasm: 2.1.5 + esbuild: 0.28.1 + miniflare: 4.20260625.0 + path-to-regexp: 6.3.0 + unenv: 2.0.0-rc.24 + workerd: 1.20260625.1 + optionalDependencies: + '@cloudflare/workers-types': 4.20260621.1 + fsevents: 2.3.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + wrangler@4.59.2(@cloudflare/workers-types@4.20260621.1): + dependencies: + '@cloudflare/kv-asset-handler': 0.4.2 + '@cloudflare/unenv-preset': 2.10.0(unenv@2.0.0-rc.24)(workerd@1.20260114.0) + blake3-wasm: 2.1.5 + esbuild: 0.27.0 + miniflare: 4.20260114.0 + path-to-regexp: 6.3.0 + unenv: 2.0.0-rc.24 + workerd: 1.20260114.0 + optionalDependencies: + '@cloudflare/workers-types': 4.20260621.1 + fsevents: 2.3.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + optional: true + wrap-ansi@10.0.0: dependencies: ansi-styles: 6.2.3 @@ -11882,6 +12709,9 @@ snapshots: dependencies: zod: 4.4.3 + zod@3.25.76: + optional: true + zod@4.1.8: {} zod@4.4.3: {} diff --git a/scripts/generate-catalog-index.ts b/scripts/generate-catalog-index.ts new file mode 100644 index 00000000..daff828f --- /dev/null +++ b/scripts/generate-catalog-index.ts @@ -0,0 +1,99 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, join, relative, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import { FilesystemCapletSource } from "../packages/core/src/caplet-source/filesystem"; +import { + parseCapletSource, + type ParsedCapletSourceCaplet, +} from "../packages/core/src/caplet-source/parse"; +import { + catalogWorkflowSummaryForBackendFamily, + createCatalogEntry, + normalizeCatalogSourceIdentity, + type CatalogEntry, + type CatalogWorkflowSummary, +} from "../packages/core/src/catalog"; + +if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { + const repoRoot = findRepoRoot(process.cwd()); + const outputPath = join(repoRoot, "apps/catalog/src/data/official-catalog.json"); + + if (process.argv.includes("--check")) { + const expected = `${JSON.stringify(await generateOfficialCatalogEntries(repoRoot), null, 2)}\n`; + const actual = existsSync(outputPath) ? readFileSync(outputPath, "utf8") : ""; + if (actual !== expected) { + console.error("Official catalog index is out of date. Run pnpm catalog:generate."); + console.error(`- ${relative(repoRoot, outputPath)}`); + process.exit(1); + } + } else { + const entries = await generateOfficialCatalogEntries(repoRoot); + mkdirSync(dirname(outputPath), { recursive: true }); + writeFileSync(outputPath, `${JSON.stringify(entries, null, 2)}\n`); + console.log(`Generated ${relative(repoRoot, outputPath)}`); + } +} + +export async function generateOfficialCatalogEntries(root: string): Promise { + const source = new FilesystemCapletSource(join(root, "caplets")); + const parsed = await parseCapletSource(source); + if (!parsed.ok) { + throw new Error( + parsed.errors + .map((error) => [error.path, error.message].filter(Boolean).join(": ")) + .join("\n"), + ); + } + + const officialSource = normalizeCatalogSourceIdentity("spiritledsoftware/caplets"); + if (!officialSource.eligible) { + throw new Error("Official catalog source identity is invalid."); + } + + const entries = await Promise.all( + parsed.resolvedCaplets.map(async (caplet) => { + const file = await source.readFile(caplet.sourcePath); + return createCatalogEntry({ + id: caplet.id, + name: caplet.name, + description: caplet.description, + source: officialSource.source, + sourcePath: caplet.sourcePath, + trustLevel: "official", + contentMarkdown: file?.content, + tags: caplet.config.tags, + useWhen: caplet.config.useWhen, + avoidWhen: caplet.config.avoidWhen, + setupRequired: caplet.setupRequired, + authRequired: caplet.authRequired, + projectBindingRequired: caplet.projectBindingRequired, + workflow: workflowSummary(caplet), + mutatesExternalState: caplet.authRequired, + localControl: caplet.projectBindingRequired || caplet.backend === "cli", + }); + }), + ); + + return entries.sort((left, right) => left.entryKey.localeCompare(right.entryKey)); +} + +function workflowSummary(caplet: ParsedCapletSourceCaplet): CatalogWorkflowSummary { + return ( + catalogWorkflowSummaryForBackendFamily(caplet.backend) ?? { + kind: "set", + label: "Caplet set", + } + ); +} + +function findRepoRoot(start: string): string { + let current = resolve(start); + while (!existsSync(join(current, "pnpm-workspace.yaml"))) { + const parent = dirname(current); + if (parent === current) { + throw new Error(`Could not find repo root from ${start}`); + } + current = parent; + } + return current; +} diff --git a/turbo.json b/turbo.json index 87202542..4d353ee9 100644 --- a/turbo.json +++ b/turbo.json @@ -11,6 +11,9 @@ "persistent": true, "cache": false }, + "catalog:check": { + "outputs": [] + }, "typecheck": { "dependsOn": ["^build", "^typecheck"] } From cc71d29c4c37e059ef4130ec5342dc81753b9d53 Mon Sep 17 00:00:00 2001 From: Caplets Test Date: Sat, 27 Jun 2026 12:36:34 -0400 Subject: [PATCH 02/16] fix(catalog): deploy current cloudflare build output --- alchemy.run.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/alchemy.run.ts b/alchemy.run.ts index 9362c2ef..cebb192a 100644 --- a/alchemy.run.ts +++ b/alchemy.run.ts @@ -41,6 +41,8 @@ export const catalogDatabase = await D1Database("catalog-database", { }); export const catalogPage = await Astro("catalog-page", { cwd: "apps/catalog", + entrypoint: "dist/server/entry.mjs", + assets: "dist/client", dev: { command: "pnpm run dev --port 4323" + hostSuffix, }, From 3f6137caa931d2e25e773baf7f05e3da81f410d6 Mon Sep 17 00:00:00 2001 From: Caplets Test Date: Sat, 27 Jun 2026 13:42:09 -0400 Subject: [PATCH 03/16] fix(catalog): address review hardening feedback --- apps/catalog/migrations/0001_catalog.sql | 16 +- apps/catalog/src/components/ResultList.astro | 54 +++- apps/catalog/src/data/official-catalog.json | 93 ++++--- apps/catalog/src/lib/catalog-store.ts | 17 +- apps/catalog/src/lib/ingest.ts | 261 +++++++++++------- apps/catalog/src/lib/status-icons.ts | 26 ++ apps/catalog/src/lib/suppression.ts | 4 +- .../pages/api/v1/catalog/install-signals.ts | 18 +- .../src/pages/caplets/[entryKey].astro | 3 + apps/catalog/src/scripts/virtual-results.ts | 33 +-- apps/catalog/test/ingest.test.ts | 113 +++++++- apps/catalog/vitest.config.ts | 6 +- .../core/src/catalog-indexing/eligibility.ts | 12 +- packages/core/src/catalog/caplet-markdown.ts | 12 +- packages/core/src/catalog/install-command.ts | 12 +- packages/core/src/cli.ts | 2 +- packages/core/src/cli/install.ts | 150 +++++++--- packages/core/test/catalog-indexing.test.ts | 22 +- packages/core/test/catalog-model.test.ts | 19 +- scripts/generate-catalog-index.ts | 17 +- 20 files changed, 636 insertions(+), 254 deletions(-) create mode 100644 apps/catalog/src/lib/status-icons.ts diff --git a/apps/catalog/migrations/0001_catalog.sql b/apps/catalog/migrations/0001_catalog.sql index 28c6b64f..9c93b2cf 100644 --- a/apps/catalog/migrations/0001_catalog.sql +++ b/apps/catalog/migrations/0001_catalog.sql @@ -17,18 +17,13 @@ create table if not exists catalog_entries ( ); create table if not exists catalog_signal_dedupe ( - entry_key text primary key, - provider text not null, - repository text not null, - accepted_at_ms integer not null -); - -create table if not exists catalog_signal_repository_windows ( + entry_key text not null, + signal_fingerprint text not null, provider text not null, repository text not null, window_start_ms integer not null, - accepted_count integer not null default 0, - primary key (provider, repository, window_start_ms) + accepted_at_ms integer not null, + primary key (entry_key, signal_fingerprint, window_start_ms) ); create table if not exists catalog_suppressions ( @@ -39,4 +34,5 @@ create table if not exists catalog_suppressions ( create index if not exists catalog_counts_rank_idx on catalog_counts (install_count desc); create index if not exists catalog_entries_repository_idx on catalog_entries (provider, repository); -create index if not exists catalog_signal_repository_windows_time_idx on catalog_signal_repository_windows (window_start_ms); +create index if not exists catalog_signal_dedupe_entry_time_idx on catalog_signal_dedupe (entry_key, accepted_at_ms); +create index if not exists catalog_signal_dedupe_repository_window_idx on catalog_signal_dedupe (provider, repository, window_start_ms); diff --git a/apps/catalog/src/components/ResultList.astro b/apps/catalog/src/components/ResultList.astro index f6734584..ef2efdaa 100644 --- a/apps/catalog/src/components/ResultList.astro +++ b/apps/catalog/src/components/ResultList.astro @@ -1,11 +1,14 @@ --- +import HugeIcon from "./HugeIcon.astro"; import type { CatalogSearchRow } from "../lib/search-row"; +import { AlertCircleIcon, catalogStatusIcons, Copy01Icon } from "../lib/status-icons"; type Props = { rows: CatalogSearchRow[]; }; const { rows } = Astro.props; +const initialRows = rows.slice(0, 24); ---
@@ -22,7 +25,56 @@ const { rows } = Astro.props; data-result-spacer style={`height:${Math.max(rows.length, 1) * 72}px`} > -
+
+ {initialRows.map((row, index) => ( +
+
+
+ {row.name} + {row.trust} +
+
+

{row.description}

+
{row.installCountDisplay}
+
+ {row.installCommandText} + {row.installCommandCopyable ? ( + + ) : ( + Copy unavailable + )} +
+
+ {row.statuses.map((status) => ( + + + + ))} +
+
+ ))} +
- ${row.statuses.map((status) => `${renderIcon(statusIcons[status.code] ?? AlertCircleIcon, status.label)}`).join("")} + ${row.statuses.map((status) => `${renderIcon(catalogStatusIcons[status.code] ?? AlertCircleIcon, status.label)}`).join("")}
`; return element; diff --git a/apps/catalog/test/ingest.test.ts b/apps/catalog/test/ingest.test.ts index 1cf8f5ce..2d5c7275 100644 --- a/apps/catalog/test/ingest.test.ts +++ b/apps/catalog/test/ingest.test.ts @@ -1,4 +1,5 @@ import type { D1Database } from "@cloudflare/workers-types"; +import type { CatalogEntry } from "@caplets/core/catalog"; import type { APIContext } from "astro"; import { describe, expect, it } from "vitest"; import { acceptInstallSignal, parseInstallSignalRequest } from "../src/lib/ingest"; @@ -10,11 +11,13 @@ describe("catalog install signal ingestion", () => { await expect( acceptInstallSignal({ db, + fetch: rawCapletFetch(), signal: { source: "https://github.com/Community/Tools.git", capletId: "deploy", sourcePath: "caplets/deploy/CAPLET.md", resolvedRevision: "abc123", + entry: submittedEntry(), }, }), ).resolves.toEqual({ @@ -24,6 +27,26 @@ describe("catalog install signal ingestion", () => { expect(db.executedWrites.length).toBeGreaterThan(0); }); + it("rejects install signals that cannot be canonicalized before counting", async () => { + const db = fakeD1(); + + await expect( + acceptInstallSignal({ + db, + signal: { + source: "community/tools", + capletId: "deploy", + sourcePath: "caplets/deploy/CAPLET.md", + resolvedRevision: "abc123", + }, + }), + ).resolves.toEqual({ + status: "rejected", + entryKey: "github:community:tools:caplets%2Fdeploy%2Fcaplet.md:deploy", + }); + expect(db.executedWrites.join("\n")).not.toContain("catalog_counts"); + }); + it("skips private or unpinned signals with categorical statuses only", async () => { await expect( acceptInstallSignal({ @@ -82,7 +105,7 @@ describe("catalog install signal ingestion", () => { }, }), ).resolves.toEqual({ status: "ineligible" }); - expect(db.executedWrites).toHaveLength(0); + expect(db.executedWrites.join("\n")).not.toContain("catalog_counts"); }); it("does not let direct signals create community records for official Caplets", async () => { @@ -102,7 +125,7 @@ describe("catalog install signal ingestion", () => { status: "already_current", entryKey: "github:spiritledsoftware:caplets:caplets%2Fgithub%2Fcaplet.md:github", }); - expect(db.executedWrites).toHaveLength(0); + expect(db.executedWrites.join("\n")).not.toContain("catalog_counts"); }); it("persists submitted community entries when D1 is available", async () => { @@ -140,10 +163,9 @@ describe("catalog install signal ingestion", () => { projectBindingReadiness: "ready", workflow: { kind: "cli", label: "CLI tools" }, installCommand: { - text: "caplets install community/tools deploy", - copyable: false, - revisionBound: false, - reason: "revision_install_unsupported", + text: "caplets install community/tools#abc123 deploy", + copyable: true, + revisionBound: true, }, warnings: [], }, @@ -153,6 +175,29 @@ describe("catalog install signal ingestion", () => { expect(db.executedWrites.some((statement) => statement.includes("catalog_entries"))).toBe(true); }); + it("does not buffer oversized fetched CAPLET markdown before rejecting", async () => { + const db = fakeD1(); + + await expect( + acceptInstallSignal({ + db, + fetch: rawCapletFetch("x".repeat(128 * 1024 + 1)), + signal: { + source: "community/tools", + capletId: "deploy", + sourcePath: "caplets/deploy/CAPLET.md", + resolvedRevision: "abc123", + contentHash: "sha256:abc", + entry: submittedEntry(), + }, + }), + ).resolves.toEqual({ + status: "rejected", + entryKey: "github:community:tools:caplets%2Fdeploy%2Fcaplet.md:deploy", + }); + expect(db.executedWrites.join("\n")).not.toContain("catalog_counts"); + }); + it("canonicalizes submitted community entries before storing them", async () => { const db = fakeD1(); await acceptInstallSignal({ @@ -225,7 +270,7 @@ describe("catalog install signal ingestion", () => { expect(writes).toContain('"sourcePath":"caplets/deploy/CAPLET.md"'); expect(writes).toContain('"resolvedRevision":"abc123"'); expect(writes).toContain('"indexedContentHash":"sha256:abc"'); - expect(writes).toContain('"text":"caplets install community/tools deploy"'); + expect(writes).toContain('"text":"caplets install community/tools#abc123 deploy"'); expect(writes).not.toContain("curl https://example.invalid/install.sh"); expect(writes).not.toContain('"name":"Deploy"'); expect(writes).not.toContain("spiritledsoftware/caplets"); @@ -255,7 +300,14 @@ describe("catalog install signal ingestion", () => { }); }); -function fakeD1(input: { suppressedEntryKeys?: Set } = {}) { +function fakeD1( + input: { + suppressedEntryKeys?: Set; + reservationChanges?: number; + existingInstallCount?: number; + previousAcceptedAtMs?: number; + } = {}, +) { const suppressedEntryKeys = input.suppressedEntryKeys ?? new Set(); const db = { executedWrites: [] as string[], @@ -268,8 +320,22 @@ function fakeD1(input: { suppressedEntryKeys?: Set } = {}) { if (sql.includes("catalog_suppressions")) { return suppressedEntryKeys.has(String(values[0])) ? { entry_key: values[0] } : null; } + if (sql.includes("catalog_counts")) { + return input.existingInstallCount === undefined + ? null + : { installCount: input.existingInstallCount }; + } + if (sql.includes("catalog_signal_dedupe")) { + return input.previousAcceptedAtMs === undefined + ? null + : { acceptedAtMs: input.previousAcceptedAtMs }; + } return null; }, + run: async () => { + db.executedWrites.push(statement); + return { meta: { changes: input.reservationChanges ?? 1 } }; + }, toString: () => statement, }; }, @@ -286,3 +352,34 @@ function fakeD1(input: { suppressedEntryKeys?: Set } = {}) { function rawCapletFetch(markdown = "# Deploy\n"): typeof fetch { return (async () => new Response(markdown)) as typeof fetch; } + +function submittedEntry(): CatalogEntry { + return { + entryKey: "github:community:tools:caplets%2Fdeploy%2Fcaplet.md:deploy", + id: "deploy", + name: "Deploy", + description: "Deploy projects.", + source: { + provider: "github", + owner: "community", + repo: "tools", + repository: "community/tools", + canonicalUrl: "https://github.com/community/tools", + }, + sourcePath: "caplets/deploy/CAPLET.md", + trustLevel: "community", + contentMarkdown: "# Deploy", + tags: ["deploy"], + intendedTask: "Deploy projects.", + setupReadiness: "ready", + authReadiness: "ready", + projectBindingReadiness: "ready", + workflow: { kind: "cli", label: "CLI tools" }, + installCommand: { + text: "caplets install community/tools#abc123 deploy", + copyable: true, + revisionBound: true, + }, + warnings: [], + }; +} diff --git a/apps/catalog/vitest.config.ts b/apps/catalog/vitest.config.ts index 5a7e6bd3..9b94b85a 100644 --- a/apps/catalog/vitest.config.ts +++ b/apps/catalog/vitest.config.ts @@ -1,10 +1,12 @@ import { defineConfig } from "vitest/config"; +import { fileURLToPath } from "node:url"; export default defineConfig({ resolve: { alias: { - "cloudflare:workers": new URL("./test/fixtures/cloudflare-workers.ts", import.meta.url) - .pathname, + "cloudflare:workers": fileURLToPath( + new URL("./test/fixtures/cloudflare-workers.ts", import.meta.url), + ), }, }, test: { diff --git a/packages/core/src/catalog-indexing/eligibility.ts b/packages/core/src/catalog-indexing/eligibility.ts index 24e5e454..26c8c8ad 100644 --- a/packages/core/src/catalog-indexing/eligibility.ts +++ b/packages/core/src/catalog-indexing/eligibility.ts @@ -59,21 +59,29 @@ export function catalogIndexingCandidateForLockEntry( if (!entry.source.resolvedRevision) { return skip("revision_unavailable", "revision_unavailable"); } + const sourcePath = catalogSourcePathForLockPath(entry.source.path); const entryKey = catalogEntryKey({ source: source.source, - sourcePath: entry.source.path, + sourcePath, capletId: entry.id, }); return { id: entry.id, source: source.source, - sourcePath: entry.source.path, + sourcePath, resolvedRevision: entry.source.resolvedRevision, installedHash: entry.installedHash, entryKey, }; } +function catalogSourcePathForLockPath(path: string): string { + return path + .replace(/\\/g, "/") + .replace(/^\.\//u, "") + .replace(/^caplets\//iu, ""); +} + function skip(status: CatalogIndexingStatus, reason: string): CatalogIndexingResult { return { status, reason }; } diff --git a/packages/core/src/catalog/caplet-markdown.ts b/packages/core/src/catalog/caplet-markdown.ts index 67696ae1..ea5ac0f8 100644 --- a/packages/core/src/catalog/caplet-markdown.ts +++ b/packages/core/src/catalog/caplet-markdown.ts @@ -25,8 +25,7 @@ export function catalogSetupRequiredFromFrontmatter(frontmatter: Record): boolean { - const auth = catalogCapletAuth(frontmatter); - return auth !== undefined && auth.type !== "none"; + return catalogCapletAuthBlocks(frontmatter).some((auth) => auth.type !== "none"); } export function catalogProjectBindingRequiredFromFrontmatter( @@ -91,9 +90,10 @@ function catalogBackendFamilies(frontmatter: Record): string[] return families.flatMap(([family, key]) => (frontmatter[key] === undefined ? [] : [family])); } -function catalogCapletAuth( +function catalogCapletAuthBlocks( frontmatter: Record, -): Record | undefined { +): Array> { + const blocks: Array> = []; for (const key of [ "mcpServer", "openapiEndpoint", @@ -102,9 +102,9 @@ function catalogCapletAuth( "httpApi", ]) { const backend = frontmatter[key]; - if (isRecord(backend) && isRecord(backend.auth)) return backend.auth; + if (isRecord(backend) && isRecord(backend.auth)) blocks.push(backend.auth); } - return undefined; + return blocks; } function isLocalMcpServer(frontmatter: Record): boolean { diff --git a/packages/core/src/catalog/install-command.ts b/packages/core/src/catalog/install-command.ts index f3410ff3..1fa4c679 100644 --- a/packages/core/src/catalog/install-command.ts +++ b/packages/core/src/catalog/install-command.ts @@ -15,13 +15,15 @@ export function generateCatalogInstallCommand(input: { }; } - const base = `caplets install ${input.source.repository} ${shellWord(input.capletId)}`; + const repo = input.resolvedRevision + ? `${input.source.repository}#${input.resolvedRevision}` + : input.source.repository; + const base = `caplets install ${shellWord(repo)} ${shellWord(input.capletId)}`; if (input.resolvedRevision) { return { text: base, - copyable: false, - revisionBound: false, - reason: "revision_install_unsupported", + copyable: true, + revisionBound: true, }; } @@ -34,5 +36,5 @@ export function generateCatalogInstallCommand(input: { } function shellWord(value: string): string { - return /^[A-Za-z0-9._/-]+$/u.test(value) ? value : JSON.stringify(value); + return /^[A-Za-z0-9._/#-]+$/u.test(value) ? value : `'${value.replace(/'/g, `'"'"'`)}'`; } diff --git a/packages/core/src/cli.ts b/packages/core/src/cli.ts index 292876df..dbbf55b0 100644 --- a/packages/core/src/cli.ts +++ b/packages/core/src/cli.ts @@ -3948,7 +3948,7 @@ function isInstallSourceArgument( if (isExplicitLocalPath(value)) return true; if (/^[a-z][a-z0-9+.-]*:\/\//i.test(value)) return true; if (/^[^@\s]+@[^:\s]+:.+/.test(value)) return true; - if (/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+(?:\.git)?$/.test(value)) return true; + if (/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+(?:\.git)?(?:#[^\s#]+)?$/.test(value)) return true; return ( options.allowImplicitLocalPath !== false && existsSync(value) && diff --git a/packages/core/src/cli/install.ts b/packages/core/src/cli/install.ts index eccd91e3..b7538146 100644 --- a/packages/core/src/cli/install.ts +++ b/packages/core/src/cli/install.ts @@ -28,6 +28,14 @@ import { CapletsError, toSafeError } from "../errors"; import type { CatalogIndexingResult } from "../catalog-indexing/payload"; import { catalogIndexingPayloadForLockEntry } from "../catalog-indexing/eligibility"; import { + catalogAuthRequiredFromFrontmatter, + catalogMutatesExternalStateFromFrontmatter, + catalogProjectBindingRequiredFromFrontmatter, + catalogSetupRequiredFromFrontmatter, + catalogStringArrayFromFrontmatter, + catalogStringFromFrontmatter, + catalogUsesLocalControlFromFrontmatter, + catalogWorkflowSummaryFromFrontmatter, catalogWorkflowSummaryForBackendFamily, createCatalogEntry, normalizeCatalogSourceIdentity, @@ -419,7 +427,15 @@ export async function indexInstalledCapletsFromLockfile( const results = new Map(); for (const [lockfilePath, ids] of byLockfile) { - const lockfile = readCapletsLockfile(lockfilePath); + let lockfile: ReturnType; + try { + lockfile = readCapletsLockfile(lockfilePath); + } catch { + for (const id of ids) { + results.set(id, { status: "unavailable", reason: "lockfile_unavailable" }); + } + continue; + } const destinations = new Map( installed .filter((candidate) => candidate.lockfile === lockfilePath && candidate.destination) @@ -433,7 +449,11 @@ export async function indexInstalledCapletsFromLockfile( if ("status" in payload) { return [entry.id, payload] as const; } - payload.entry = catalogEntryForInstalledLockEntry(entry, destinations.get(entry.id)); + payload.entry = catalogEntryForInstalledLockEntry( + entry, + destinations.get(entry.id), + payload.sourcePath, + ); return [entry.id, await submitCatalogIndexingPayload(payload, options)] as const; }), ); @@ -496,6 +516,7 @@ async function submitCatalogIndexingPayload( function catalogEntryForInstalledLockEntry( entry: CapletsLockEntry, destination: string | undefined, + sourcePath: string, ): CatalogEntry | undefined { if (entry.source.type !== "git" || !destination) return undefined; const source = normalizeCatalogSourceIdentity(entry.source.repository); @@ -508,39 +529,33 @@ function catalogEntryForInstalledLockEntry( const frontmatter = readCapletFrontmatterFromText(contentMarkdown); return createCatalogEntry({ id: entry.id, - name: stringFromFrontmatter(frontmatter.name) ?? entry.id, + name: catalogStringFromFrontmatter(frontmatter.name) ?? entry.id, description: - stringFromFrontmatter(frontmatter.description) ?? `Community Caplet ${entry.id}.`, + catalogStringFromFrontmatter(frontmatter.description) ?? `Community Caplet ${entry.id}.`, source: source.source, - sourcePath: entry.source.path, + sourcePath, trustLevel: "community", resolvedRevision: entry.source.resolvedRevision, indexedContentHash: entry.installedHash, contentMarkdown, - tags: stringArrayFromFrontmatter(frontmatter.tags), - useWhen: stringFromFrontmatter(frontmatter.useWhen), - avoidWhen: stringFromFrontmatter(frontmatter.avoidWhen), - setupRequired: frontmatter.setup !== undefined, - authRequired: capletAuthRequired(frontmatter), - projectBindingRequired: entry.risk.projectBindingRequired, - workflow: workflowSummaryFromRisk(entry.risk), - mutatesExternalState: entry.risk.mutating || entry.risk.destructive, - localControl: entry.risk.safety === "local_control", + tags: catalogStringArrayFromFrontmatter(frontmatter.tags), + useWhen: catalogStringFromFrontmatter(frontmatter.useWhen), + avoidWhen: catalogStringFromFrontmatter(frontmatter.avoidWhen), + setupRequired: catalogSetupRequiredFromFrontmatter(frontmatter), + authRequired: catalogAuthRequiredFromFrontmatter(frontmatter), + projectBindingRequired: catalogProjectBindingRequiredFromFrontmatter(frontmatter), + workflow: catalogWorkflowSummaryFromFrontmatter( + frontmatter, + workflowSummaryFromRisk(entry.risk), + ), + mutatesExternalState: catalogMutatesExternalStateFromFrontmatter(frontmatter), + localControl: catalogUsesLocalControlFromFrontmatter(frontmatter), }); } catch { return undefined; } } -function stringFromFrontmatter(value: unknown): string | undefined { - return typeof value === "string" && value.trim() ? value.trim() : undefined; -} - -function stringArrayFromFrontmatter(value: unknown): string[] | undefined { - if (!Array.isArray(value)) return undefined; - return value.filter((entry): entry is string => typeof entry === "string"); -} - function workflowSummaryFromRisk(risk: CapletsLockEntry["risk"]): CatalogWorkflowSummary { return ( catalogWorkflowSummaryForBackendFamily(risk.backendFamilies[0]) ?? { @@ -610,19 +625,16 @@ function resolveInstallSource(repo: string): { } const normalizedRepo = normalizeGitRepo(repo); + const installSource = splitInstallSourceRef(normalizedRepo); const repoRoot = mkdtempSync(join(tmpdir(), "caplets-install-")); try { - execFileSync("git", ["clone", "--depth", "1", "--", normalizedRepo, repoRoot], { - env: externalGitEnv(), - stdio: "ignore", - timeout: 60_000, - }); + cloneInstallSource(installSource, repoRoot); const resolvedRevision = gitRevision(repoRoot); return { - id: normalizedRepo, + id: installSource.repository, repoRoot, sourceKind: "git", - repository: normalizedRepo, + repository: installSource.repository, resolvedRevision, cleanup: () => removeInstallPath(repoRoot, `temporary install source ${repoRoot}`, true), }; @@ -632,6 +644,60 @@ function resolveInstallSource(repo: string): { } } +function cloneInstallSource( + source: { repository: string; ref?: string | undefined }, + repoRoot: string, +): void { + if (!source.ref) { + execFileSync("git", ["clone", "--depth", "1", "--", source.repository, repoRoot], { + env: externalGitEnv(), + stdio: "ignore", + timeout: 60_000, + }); + return; + } + + try { + execFileSync("git", ["init", repoRoot], { + env: externalGitEnv(), + stdio: "ignore", + timeout: 60_000, + }); + execFileSync("git", ["remote", "add", "origin", source.repository], { + cwd: repoRoot, + env: externalGitEnv(), + stdio: "ignore", + timeout: 60_000, + }); + execFileSync("git", ["fetch", "--depth", "1", "origin", source.ref], { + cwd: repoRoot, + env: externalGitEnv(), + stdio: "ignore", + timeout: 60_000, + }); + execFileSync("git", ["checkout", "--detach", "FETCH_HEAD"], { + cwd: repoRoot, + env: externalGitEnv(), + stdio: "ignore", + timeout: 60_000, + }); + } catch { + rmSync(repoRoot, { recursive: true, force: true }); + mkdirSync(repoRoot, { recursive: true }); + execFileSync("git", ["clone", "--", source.repository, repoRoot], { + env: externalGitEnv(), + stdio: "ignore", + timeout: 60_000, + }); + execFileSync("git", ["checkout", "--detach", source.ref], { + cwd: repoRoot, + env: externalGitEnv(), + stdio: "ignore", + timeout: 60_000, + }); + } +} + function updateLockfileAfterInstall( lockfilePath: string, plans: InstallPlan[], @@ -852,11 +918,6 @@ function capletAuth(frontmatter: Record): Record): boolean { - const auth = capletAuth(frontmatter); - return auth !== undefined && auth.type !== "none"; -} - function derivedSafety(input: { backendFamilies: string[]; auth: Record | undefined; @@ -1100,13 +1161,26 @@ function externalGitEnv(): NodeJS.ProcessEnv { } export function normalizeGitRepo(repo: string): string { - if (/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo)) { - const normalized = repo.endsWith(".git") ? repo.slice(0, -4) : repo; - return `https://github.com/${normalized}.git`; + const source = splitInstallSourceRef(repo); + if (/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+(?:\.git)?$/.test(source.repository)) { + const normalized = source.repository.endsWith(".git") + ? source.repository.slice(0, -4) + : source.repository; + return withInstallSourceRef(`https://github.com/${normalized}.git`, source.ref); } return repo; } +function splitInstallSourceRef(repo: string): { repository: string; ref?: string | undefined } { + const index = repo.lastIndexOf("#"); + if (index <= 0 || index === repo.length - 1) return { repository: repo }; + return { repository: repo.slice(0, index), ref: repo.slice(index + 1) }; +} + +function withInstallSourceRef(repository: string, ref: string | undefined): string { + return ref ? `${repository}#${ref}` : repository; +} + function preflightInstallCaplets( caplets: Array<{ id: string; path: string }>, options: { destinationRoot: string; force: boolean; repoRoot: string; sourceId: string }, diff --git a/packages/core/test/catalog-indexing.test.ts b/packages/core/test/catalog-indexing.test.ts index 4dd72aad..6a2de31b 100644 --- a/packages/core/test/catalog-indexing.test.ts +++ b/packages/core/test/catalog-indexing.test.ts @@ -20,10 +20,10 @@ describe("catalog indexing", () => { expect(catalogIndexingPayloadForLockEntry(lockEntry())).toEqual({ source: "community/tools", capletId: "deploy", - sourcePath: "caplets/deploy/CAPLET.md", + sourcePath: "deploy/CAPLET.md", resolvedRevision: "abc123", contentHash: "sha256-installed", - entryKey: "github:community:tools:caplets%2Fdeploy%2Fcaplet.md:deploy", + entryKey: "github:community:tools:deploy%2Fcaplet.md:deploy", }); }); @@ -145,10 +145,28 @@ describe("catalog indexing", () => { entry: { setupReadiness: "required", authReadiness: "required", + sourcePath: "deploy/CAPLET.md", + installCommand: { + text: "caplets install community/tools#abc123 deploy", + copyable: true, + revisionBound: true, + }, }, }); }); + it("keeps catalog indexing best-effort when a lockfile cannot be read", async () => { + const results = await indexInstalledCapletsFromLockfile( + [{ id: "deploy", lockfile: "/missing/.caplets.lock.json" }], + { endpoint: "https://catalog.example.test/install-signals", fetch: vi.fn() }, + ); + + expect(results.get("deploy")).toEqual({ + status: "unavailable", + reason: "lockfile_unavailable", + }); + }); + it("honors the catalog indexing environment opt-out", async () => { process.env.CAPLETS_DISABLE_CATALOG_INDEXING = "1"; const fetchImpl = vi.fn(); diff --git a/packages/core/test/catalog-model.test.ts b/packages/core/test/catalog-model.test.ts index e2a04110..91d490bb 100644 --- a/packages/core/test/catalog-model.test.ts +++ b/packages/core/test/catalog-model.test.ts @@ -32,13 +32,24 @@ describe("catalog model", () => { resolvedRevision: "abc123def456", }), ).toEqual({ - text: "caplets install spiritledsoftware/caplets sentry", - copyable: false, - revisionBound: false, - reason: "revision_install_unsupported", + text: "caplets install spiritledsoftware/caplets#abc123def456 sentry", + copyable: true, + revisionBound: true, }); }); + it("quotes generated install command words for POSIX shells", () => { + const normalized = normalizeCatalogSourceIdentity("community/tools"); + if (!normalized.eligible) throw new Error("expected shorthand source to be eligible"); + + expect( + generateCatalogInstallCommand({ + source: normalized.source, + capletId: "$(touch /tmp/pwn)'", + }).text, + ).toBe(`caplets install community/tools '$(touch /tmp/pwn)'"'"''`); + }); + it("keeps official install commands in the supported shorthand shape when no revision is required", () => { const normalized = normalizeCatalogSourceIdentity("spiritledsoftware/caplets"); if (!normalized.eligible) throw new Error("expected shorthand source to be eligible"); diff --git a/scripts/generate-catalog-index.ts b/scripts/generate-catalog-index.ts index daff828f..1434d06d 100644 --- a/scripts/generate-catalog-index.ts +++ b/scripts/generate-catalog-index.ts @@ -7,9 +7,15 @@ import { type ParsedCapletSourceCaplet, } from "../packages/core/src/caplet-source/parse"; import { + catalogAuthRequiredFromFrontmatter, + catalogMutatesExternalStateFromFrontmatter, + catalogProjectBindingRequiredFromFrontmatter, + catalogSetupRequiredFromFrontmatter, + catalogUsesLocalControlFromFrontmatter, catalogWorkflowSummaryForBackendFamily, createCatalogEntry, normalizeCatalogSourceIdentity, + readCatalogCapletFrontmatterFromMarkdown, type CatalogEntry, type CatalogWorkflowSummary, } from "../packages/core/src/catalog"; @@ -53,6 +59,7 @@ export async function generateOfficialCatalogEntries(root: string): Promise { const file = await source.readFile(caplet.sourcePath); + const frontmatter = readCatalogCapletFrontmatterFromMarkdown(file?.content ?? ""); return createCatalogEntry({ id: caplet.id, name: caplet.name, @@ -64,12 +71,12 @@ export async function generateOfficialCatalogEntries(root: string): Promise Date: Sat, 27 Jun 2026 14:28:49 -0400 Subject: [PATCH 04/16] fix(catalog): compact mobile sticky search --- apps/catalog/src/components/SearchShell.astro | 2 + apps/catalog/src/styles/catalog.css | 55 ++++++++++++++++++- 2 files changed, 54 insertions(+), 3 deletions(-) diff --git a/apps/catalog/src/components/SearchShell.astro b/apps/catalog/src/components/SearchShell.astro index 8d7a6023..4bd8552a 100644 --- a/apps/catalog/src/components/SearchShell.astro +++ b/apps/catalog/src/components/SearchShell.astro @@ -53,6 +53,8 @@ const legend = [ {rows.length} Caplets + +
{legend.map((item) => ( diff --git a/apps/catalog/src/styles/catalog.css b/apps/catalog/src/styles/catalog.css index 0385d7d3..008470dc 100644 --- a/apps/catalog/src/styles/catalog.css +++ b/apps/catalog/src/styles/catalog.css @@ -92,12 +92,19 @@ z-index: 20; top: var(--catalog-header-sticky-offset); display: grid; - gap: 0.65rem; border-bottom: 1px solid var(--border); background: color-mix(in oklch, var(--card) 94%, var(--background)); padding: 0.75rem; } +.catalog-search__controls { + display: grid; + gap: 0.65rem; + border-bottom: 1px solid var(--border); + background: color-mix(in oklch, var(--card) 94%, var(--background)); + padding: 0 0.75rem 0.75rem; +} + .catalog-search__topline { display: grid; grid-template-columns: minmax(0, 1fr) auto; @@ -405,9 +412,51 @@ --catalog-header-sticky-offset: 7.75rem; } - .catalog-search__topline, + .catalog-search__toolbar { + padding: 0.5rem; + } + + .catalog-search__topline { + grid-template-columns: minmax(0, 1fr) auto; + gap: 0.5rem; + align-items: center; + } + + .catalog-search__field > span { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; + } + + .catalog-search__input { + min-height: 2.35rem; + padding-inline: 0.7rem; + } + + .catalog-search__status { + font-size: 0.68rem; + } + + .catalog-search__controls { + gap: 0.5rem; + padding: 0.5rem; + } + .catalog-filterbar { - grid-template-columns: 1fr; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.4rem; + } + + .catalog-filterbar label { + gap: 0.2rem; + } + + .catalog-filterbar select { + min-height: 2.15rem; + font-size: 0.84rem; } .catalog-search__legend-item span { From 4c4bf5f969f9b909bf02644f62acf14bb5f8db4c Mon Sep 17 00:00:00 2001 From: Caplets Test Date: Sat, 27 Jun 2026 15:16:03 -0400 Subject: [PATCH 05/16] fix(catalog): simplify catalog header --- .../src/components/CatalogHeader.astro | 156 +++++++++++++++--- apps/catalog/src/components/SearchShell.astro | 18 -- apps/catalog/src/components/ThemeToggle.astro | 21 ++- .../src/pages/caplets/[entryKey].astro | 8 +- apps/catalog/src/pages/index.astro | 26 +-- apps/catalog/src/scripts/virtual-results.ts | 2 +- apps/catalog/src/styles/catalog.css | 113 +++---------- apps/catalog/test/virtual-results.test.ts | 2 +- 8 files changed, 183 insertions(+), 163 deletions(-) diff --git a/apps/catalog/src/components/CatalogHeader.astro b/apps/catalog/src/components/CatalogHeader.astro index 01273001..f46592e9 100644 --- a/apps/catalog/src/components/CatalogHeader.astro +++ b/apps/catalog/src/components/CatalogHeader.astro @@ -1,42 +1,55 @@ --- import ThemeToggle from "./ThemeToggle.astro"; -type NavLink = { - href: string; - label: string; +type SearchProps = { + count?: number; + value?: string; }; type Props = { - links: NavLink[]; + search?: SearchProps; }; -const { links } = Astro.props; +const { search } = Astro.props; ---
- Caplets Catalog + Caplets Catalog -
- + { + search ? ( + + ) : null + } +
@@ -46,20 +59,111 @@ const { links } = Astro.props; position: sticky; z-index: 40; top: 0; - display: flex; + display: grid; + grid-template-columns: auto minmax(13rem, 38rem) auto; align-items: center; - justify-content: space-between; - gap: 1rem; + gap: 0.75rem; border-bottom: 1px solid var(--border); background: color-mix(in oklch, var(--background), transparent 8%); - padding: 0.75rem max(1rem, calc((100vw - 1180px) / 2)); + padding: 0.6rem max(1rem, calc((100vw - 1180px) / 2)); backdrop-filter: blur(12px); } + .catalog-header__brand { + display: inline-flex; + min-height: 2.75rem; + min-width: 0; + align-items: center; + gap: 0.65rem; + color: var(--foreground); + font-weight: 700; + text-decoration: none; + } + + .catalog-header__brand:focus-visible, + .catalog-header__input:focus-visible { + outline: 2px solid var(--outline); + outline-offset: 2px; + } + + .catalog-header__brand-label { + white-space: nowrap; + } + + .catalog-header__search { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 0.5rem; + min-width: 0; + } + + .catalog-header__search-label, + .catalog-header__status { + color: var(--muted-foreground); + font-family: var(--font-mono); + font-size: 0.72rem; + font-weight: 700; + } + + .catalog-header__input { + min-height: 2.45rem; + min-width: 0; + width: 100%; + border: 1px solid var(--border); + border-radius: 0.5rem; + background: var(--card); + color: var(--card-foreground); + padding: 0 0.75rem; + } + + .catalog-header__input::placeholder { + color: var(--muted-foreground); + } + + .catalog-header__input:focus { + border-color: var(--outline); + box-shadow: 0 0 0 3px color-mix(in oklch, var(--outline), transparent 70%); + } + + .catalog-header__status { + min-width: max-content; + } + + .catalog-header__actions { + display: flex; + justify-content: flex-end; + } + @media (max-width: 560px) { .catalog-header { - align-items: flex-start; - flex-direction: column; + grid-template-columns: auto minmax(0, 1fr) auto; + gap: 0.5rem; + padding-block: 0.5rem; + } + + .catalog-header__brand { + min-height: 2.5rem; + } + + .catalog-header__brand-label, + .catalog-header__search-label, + .catalog-header__status { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; + } + + .catalog-header__search { + grid-template-columns: minmax(0, 1fr); + } + + .catalog-header__input { + min-height: 2.35rem; + padding-inline: 0.65rem; } } diff --git a/apps/catalog/src/components/SearchShell.astro b/apps/catalog/src/components/SearchShell.astro index 4bd8552a..69ecdbe3 100644 --- a/apps/catalog/src/components/SearchShell.astro +++ b/apps/catalog/src/components/SearchShell.astro @@ -36,24 +36,6 @@ const legend = [ ---