chore: add registry install flow

This commit is contained in:
2026-03-20 10:38:05 +08:00
parent 5045756525
commit e8aea4b88a
13 changed files with 1788 additions and 20 deletions
+4 -4
View File
@@ -14,9 +14,9 @@ The repo is still an internal/private monorepo, so Changesets is used conservati
This repo now has two release-adjacent workflows:
- `Changeset Status` runs on pull requests and checks whether changes to releasable packages
include a `.changeset/*.md` entry.
include a `.changeset/*.md` entry, while also verifying that `registry/index.json` is current.
- `Release Version PR` runs on `main` and opens or updates a version PR by running
`pnpm changeset version`.
`pnpm release:version`.
The PR check intentionally focuses on the releasable packages in scope today:
@@ -69,7 +69,7 @@ flow is enabled, treat Changesets as the source of truth for:
- which changes deserve release notes
- which internal dependency bumps should be coordinated
The current automation stops at version intent and version PR creation. It does not publish to a
registry, create tags, or assume any package registry credentials exist yet.
The current automation stops at version intent, registry metadata refresh, and version PR creation.
It does not publish to npm, create tags, or assume any package registry credentials exist yet.
See `docs/releasing.md` for the expected workflow around creating and consuming changesets.
+3
View File
@@ -99,3 +99,6 @@ jobs:
- name: Validate pending changesets
run: pnpm changeset:status --since "origin/${BASE_REF}"
- name: Validate registry metadata
run: pnpm registry:check
+1 -1
View File
@@ -40,7 +40,7 @@ jobs:
- name: Open or update version PR
uses: changesets/action@v1
with:
version: pnpm changeset version
version: pnpm release:version
commit: "chore: version packages"
title: "chore: version packages"
env:
+25
View File
@@ -9,6 +9,7 @@ default styling with its own tokens, motion recipes, and component contract.
- `packages/tokens`: theme tokens, motion tokens, and theme helpers
- `packages/ui`: component source, variants, contracts, and tests
- `apps/docs`: Storybook docs and usage reference
- `registry`: generated registry metadata plus the source-copy install contract
- `tests/e2e`: Playwright smoke coverage for high-value Storybook flows
## System principles
@@ -50,6 +51,12 @@ Build Storybook:
pnpm build:docs
```
Build the registry metadata:
```bash
pnpm registry:build
```
Run tests:
```bash
@@ -64,6 +71,12 @@ pnpm lint
pnpm typecheck
```
Install source-owned components into another project:
```bash
pnpm registry:install --project ../acme-app button dialog
```
## Workspace structure
```txt
@@ -74,6 +87,7 @@ packages/
ui/ Component source, variants, tests, and contracts
tests/
e2e/ Playwright smoke specs
registry/ Generated item metadata for copy-in installs
```
## How the component system is organized
@@ -88,6 +102,17 @@ The system is layered:
The current public component layer lives in `packages/ui/src/components`, with shared
helpers in `packages/ui/src/lib`.
## Registry install flow
Cadence UI now ships a minimal internal registry flow for source-owned adoption.
Consumers pin this repo to a reviewed commit or tag, then run the local installer to
copy selected items into their own codebase.
- Registry metadata lives in `registry/index.json` and is generated by `pnpm registry:build`.
- The installer copies components into `src/cadence-ui`, adds missing package dependencies,
and writes `src/cadence-ui/.install-manifest.json` so upgrades can reuse the same item set.
- Install and upgrade instructions live in [docs/registry.md](/Users/xd/project/cadence-ui/docs/registry.md).
## Docs and QA
Storybook is the main usage reference and review surface. Component stories are expected
+118
View File
@@ -0,0 +1,118 @@
# Registry Install and Upgrade
Cadence UI now supports a minimal internal registry flow for source-owned consumers.
The goal is not package publishing yet. The goal is to make one reviewed repo state
directly consumable by another app.
## What the registry contains
- `registry/config.json`: the authored registry format
- `registry/index.json`: generated install metadata
- `scripts/build-registry.mjs`: rebuilds and validates the generated index
- `scripts/registry-install.mjs`: copies selected source files into a consumer project
`registry/index.json` is intentionally machine-readable. It records:
- installable item names
- entrypoints and copied files
- transitive source files required by each item
- external package dependencies
- the package versions that produced the registry snapshot
## Maintainer workflow
Whenever `packages/ui` or `packages/tokens` changes in a way that affects installable
source, refresh the registry metadata:
```bash
pnpm registry:build
```
To verify that the generated file is current:
```bash
pnpm registry:check
```
The pull request workflow now runs `pnpm registry:check`, and the release version PR
workflow refreshes the registry automatically via `pnpm release:version`.
## Consumer install flow
### 1. Pin the Cadence UI source
Consumers should install from a reviewed Cadence UI repo state, usually:
- the merge commit of a version PR, or
- a maintainer-created tag that points at that versioned commit
The consumer does not need this repo as a runtime dependency. It only needs access to
the checked-out source when running the installer.
### 2. Run the installer from the Cadence UI repo
From this repository checkout:
```bash
pnpm registry:install --project ../acme-app button dialog
```
The installer will:
- include `tokens` automatically when a component requires it
- copy files into `src/cadence-ui`
- preserve shared internal imports under `src/cadence-ui/components`, `src/cadence-ui/lib`,
and `src/cadence-ui/tokens`
- add any missing runtime dependencies to the consumer's `package.json`
- write `src/cadence-ui/.install-manifest.json`
Useful flags:
- `--target-dir src/shared/cadence-ui`: customize the destination root
- `--skip-package-json`: copy files without editing the consumer package manifest
- `--dry-run`: preview copied files and package changes
## Consumer upgrade flow
Upgrades stay source-owned. There is no generated wrapper or hidden runtime.
1. Update the Cadence UI checkout to the new reviewed commit or tag.
2. Re-run the installer against the same consumer project.
```bash
pnpm registry:install --project ../acme-app
```
If no item names are provided, the installer reuses the list stored in
`src/cadence-ui/.install-manifest.json`.
After that:
- review the consumer diff
- run the consumer package manager install if `package.json` changed
- verify imports still point at `src/cadence-ui/tokens/styles.css`
## Expected consumer import points
At minimum, the consumer app should import:
- `src/cadence-ui/tokens/styles.css` from its global app stylesheet or entry module
If the consumer wants the theme helpers, they can also import from:
- `src/cadence-ui/tokens/index.ts`
## Current scope
This registry flow is intentionally minimal:
- it copies source files
- it writes dependency intent into the consumer app
- it supports reinstalling the same item set for upgrades
It does not yet:
- publish to npm
- host a remote registry API
- auto-tag or auto-publish releases
- resolve consumer-specific codemods during upgrade
+28 -14
View File
@@ -9,6 +9,7 @@ The current goal is modest:
- keep release notes attached to the changes that caused them
- avoid inventing ad hoc version bumps when the component system evolves
- make release intent enforceable in CI before package work merges
- keep the generated registry metadata aligned with versioned source
## Current assumptions
@@ -68,6 +69,7 @@ At minimum, run:
```bash
pnpm lint
pnpm registry:check
pnpm typecheck
pnpm test
```
@@ -104,10 +106,10 @@ keep the dependency graph coherent without requiring manual package edits.
### 4. Version the packages
When it is time to cut a release manually, run the Changesets version step:
When it is time to cut a release manually, run the repo version step:
```bash
pnpm changeset version
pnpm release:version
```
That step is expected to:
@@ -115,6 +117,7 @@ That step is expected to:
- update package versions
- update internal dependency ranges where needed
- consume the pending changeset files
- rebuild `registry/index.json` so the copy-in install metadata stays version-aligned
Review the resulting package diffs carefully before merging.
@@ -122,17 +125,19 @@ On `main`, the repository also runs a `Release Version PR` workflow that opens o
version PR with the same command. That workflow is intentionally limited to version-file updates;
it does not publish packages.
### 5. Publish or tag
### 5. Distribute the versioned source
Publishing is not fully wired in this repo yet, so treat this step as pending infrastructure.
Publishing is not fully wired in this repo yet, so the consumable artifact is the versioned
repository state itself.
The intended future flow is:
After the version PR merges:
```bash
pnpm changeset publish
```
- keep the merge commit as a stable install point, or create a maintainer tag for it
- treat the committed `registry/index.json` as the install contract for that repo state
- point consumers at that commit or tag when they run `pnpm registry:install`
But until registry, auth, and CI behavior are explicit, do not assume publish is automated.
See [docs/registry.md](/Users/xd/project/cadence-ui/docs/registry.md) for the consumer
install and upgrade flow.
## CI workflows
@@ -153,16 +158,24 @@ pnpm changeset:status --since origin/main
This validates that pending changesets resolve cleanly against the base branch.
The workflow also runs:
```bash
pnpm registry:check
```
This prevents registry metadata drift from merging unnoticed.
### Version PR workflow
The `Release Version PR` workflow runs on pushes to `main` and on manual dispatch. It:
- installs dependencies
- runs `pnpm changeset version`
- runs `pnpm release:version`
- opens or updates a version PR using `changesets/action`
This is a versioning skeleton, not a publish pipeline. It is safe to enable before registry
credentials or publish commands exist.
This is still a versioning skeleton, not a publish pipeline, but the version PR now refreshes
the committed registry metadata that consumers install from.
## Future publish prerequisites
@@ -183,5 +196,6 @@ Before turning the version workflow into a publish workflow, the repo still need
## Main-thread follow-up still needed
This baseline now covers changeset intent checks in PRs and version PR creation on `main`.
What remains is the publish decision and any registry-specific automation.
This baseline now covers changeset intent checks in PRs, registry metadata validation, and
version PR creation on `main`. What remains is the publish decision, tagging policy, and any
registry-specific automation beyond source-copy installs.
+29 -1
View File
@@ -7,14 +7,42 @@ import tseslint from "typescript-eslint";
export default tseslint.config(
{
ignores: [
"**/.artifacts/**",
"**/.tmp-home/**",
"**/dist/**",
"**/node_modules/**",
"**/output/**",
"**/playwright-report/**",
"**/storybook-static/**",
"**/coverage/**"
"**/coverage/**",
"**/test-results/**"
]
},
js.configs.recommended,
...tseslint.configs.recommended,
{
files: ["**/*.{js,mjs}"],
languageOptions: {
ecmaVersion: "latest",
sourceType: "module",
globals: {
...globals.node
}
}
},
{
files: ["**/*.cjs"],
languageOptions: {
ecmaVersion: "latest",
sourceType: "commonjs",
globals: {
...globals.node
}
},
rules: {
"@typescript-eslint/no-require-imports": "off"
}
},
{
files: ["**/*.{ts,tsx,mts,cts}"],
languageOptions: {
+4
View File
@@ -13,6 +13,10 @@
"changeset:status": "changeset status --verbose",
"dev:docs": "pnpm --dir apps/docs run storybook",
"lint": "eslint .",
"registry:build": "node ./scripts/build-registry.mjs",
"registry:check": "node ./scripts/build-registry.mjs --check",
"registry:install": "node ./scripts/registry-install.mjs",
"release:version": "pnpm changeset version && pnpm registry:build",
"test": "pnpm --filter @ai-ui/ui test",
"test:e2e": "playwright test",
"test:e2e:smoke": "playwright test tests/e2e/storybook-smoke.spec.ts",
+19
View File
@@ -0,0 +1,19 @@
# Registry
This directory holds the internal Cadence UI registry contract.
- `config.json` is the maintained source of registry intent.
- `index.json` is generated by `pnpm registry:build`.
The generated index is what the installer consumes. It records:
- installable item names
- copied source files
- required package dependencies
- package versions for the registry snapshot
Consumers should not edit `index.json` by hand. Maintainers should regenerate it after
source changes that affect installable components or tokens.
See [docs/registry.md](/Users/xd/project/cadence-ui/docs/registry.md) for the install
and upgrade flow.
+20
View File
@@ -0,0 +1,20 @@
{
"libraryName": "cadence-ui",
"defaultTargetDir": "src/cadence-ui",
"tokens": {
"name": "tokens",
"description": "Base CSS variables, motion tokens, and theme helpers.",
"entrypoints": [
"packages/tokens/src/styles.css",
"packages/tokens/src/index.ts"
],
"targetSubdir": "tokens"
},
"ui": {
"componentsDir": "packages/ui/src/components",
"requires": [
"tokens"
],
"targetSubdir": "."
}
}
+913
View File
@@ -0,0 +1,913 @@
{
"generatedBy": "scripts/build-registry.mjs",
"install": {
"defaultTargetDir": "src/cadence-ui",
"manifestFile": ".install-manifest.json"
},
"items": [
{
"description": "Base CSS variables, motion tokens, and theme helpers.",
"displayName": "Tokens",
"entrypoints": [
"packages/tokens/src/styles.css",
"packages/tokens/src/index.ts"
],
"files": [
"packages/tokens/src/base.css",
"packages/tokens/src/index.ts",
"packages/tokens/src/motion.css",
"packages/tokens/src/styles.css",
"packages/tokens/src/tokens.css"
],
"kind": "tokens",
"name": "tokens",
"packageDependencies": {},
"requires": [],
"sourcePackage": "@ai-ui/tokens",
"sourceVersion": "0.0.0",
"targetDirectory": "src/cadence-ui/tokens"
},
{
"description": "Source-owned Alert component.",
"displayName": "Alert",
"entrypoints": [
"packages/ui/src/components/alert.tsx"
],
"files": [
"packages/ui/src/components/alert.tsx",
"packages/ui/src/components/alert.variants.ts",
"packages/ui/src/lib/cn.ts",
"packages/ui/src/lib/contracts.ts",
"packages/ui/src/lib/cva.ts",
"packages/ui/src/lib/motion.ts"
],
"kind": "component",
"name": "alert",
"packageDependencies": {
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"react": "^18.3.1 || ^19.0.0",
"tailwind-merge": "^3.5.0"
},
"requires": [
"tokens"
],
"sourcePackage": "@ai-ui/ui",
"sourceVersion": "0.0.0",
"targetDirectory": "src/cadence-ui"
},
{
"description": "Source-owned Avatar component.",
"displayName": "Avatar",
"entrypoints": [
"packages/ui/src/components/avatar.tsx"
],
"files": [
"packages/ui/src/components/avatar.tsx",
"packages/ui/src/components/avatar.variants.ts",
"packages/ui/src/lib/cn.ts",
"packages/ui/src/lib/contracts.ts",
"packages/ui/src/lib/cva.ts",
"packages/ui/src/lib/motion.ts"
],
"kind": "component",
"name": "avatar",
"packageDependencies": {
"@radix-ui/react-avatar": "^1.1.11",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"react": "^18.3.1 || ^19.0.0",
"tailwind-merge": "^3.5.0"
},
"requires": [
"tokens"
],
"sourcePackage": "@ai-ui/ui",
"sourceVersion": "0.0.0",
"targetDirectory": "src/cadence-ui"
},
{
"description": "Source-owned Badge component.",
"displayName": "Badge",
"entrypoints": [
"packages/ui/src/components/badge.tsx"
],
"files": [
"packages/ui/src/components/badge.tsx",
"packages/ui/src/components/badge.variants.ts",
"packages/ui/src/lib/cn.ts",
"packages/ui/src/lib/contracts.ts",
"packages/ui/src/lib/cva.ts",
"packages/ui/src/lib/motion.ts"
],
"kind": "component",
"name": "badge",
"packageDependencies": {
"@radix-ui/react-slot": "^1.2.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"react": "^18.3.1 || ^19.0.0",
"tailwind-merge": "^3.5.0"
},
"requires": [
"tokens"
],
"sourcePackage": "@ai-ui/ui",
"sourceVersion": "0.0.0",
"targetDirectory": "src/cadence-ui"
},
{
"description": "Source-owned Button component.",
"displayName": "Button",
"entrypoints": [
"packages/ui/src/components/button.tsx"
],
"files": [
"packages/ui/src/components/button.tsx",
"packages/ui/src/components/button.variants.ts",
"packages/ui/src/lib/cn.ts",
"packages/ui/src/lib/contracts.ts",
"packages/ui/src/lib/cva.ts",
"packages/ui/src/lib/motion.ts"
],
"kind": "component",
"name": "button",
"packageDependencies": {
"@radix-ui/react-slot": "^1.2.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"motion": "^12.38.0",
"react": "^18.3.1 || ^19.0.0",
"tailwind-merge": "^3.5.0"
},
"requires": [
"tokens"
],
"sourcePackage": "@ai-ui/ui",
"sourceVersion": "0.0.0",
"targetDirectory": "src/cadence-ui"
},
{
"description": "Source-owned Card component.",
"displayName": "Card",
"entrypoints": [
"packages/ui/src/components/card.tsx"
],
"files": [
"packages/ui/src/components/card.tsx",
"packages/ui/src/components/card.variants.ts",
"packages/ui/src/lib/cn.ts",
"packages/ui/src/lib/contracts.ts",
"packages/ui/src/lib/cva.ts",
"packages/ui/src/lib/motion.ts"
],
"kind": "component",
"name": "card",
"packageDependencies": {
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"react": "^18.3.1 || ^19.0.0",
"tailwind-merge": "^3.5.0"
},
"requires": [
"tokens"
],
"sourcePackage": "@ai-ui/ui",
"sourceVersion": "0.0.0",
"targetDirectory": "src/cadence-ui"
},
{
"description": "Source-owned Checkbox component.",
"displayName": "Checkbox",
"entrypoints": [
"packages/ui/src/components/checkbox.tsx"
],
"files": [
"packages/ui/src/components/checkbox.tsx",
"packages/ui/src/components/checkbox.variants.ts",
"packages/ui/src/lib/cn.ts",
"packages/ui/src/lib/contracts.ts",
"packages/ui/src/lib/cva.ts",
"packages/ui/src/lib/motion.ts"
],
"kind": "component",
"name": "checkbox",
"packageDependencies": {
"@radix-ui/react-checkbox": "^1.3.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"react": "^18.3.1 || ^19.0.0",
"tailwind-merge": "^3.5.0"
},
"requires": [
"tokens"
],
"sourcePackage": "@ai-ui/ui",
"sourceVersion": "0.0.0",
"targetDirectory": "src/cadence-ui"
},
{
"description": "Source-owned Combobox component.",
"displayName": "Combobox",
"entrypoints": [
"packages/ui/src/components/combobox.tsx"
],
"files": [
"packages/ui/src/components/combobox.tsx",
"packages/ui/src/components/combobox.variants.ts",
"packages/ui/src/components/field.tsx",
"packages/ui/src/components/label.tsx",
"packages/ui/src/lib/cn.ts",
"packages/ui/src/lib/contracts.ts",
"packages/ui/src/lib/cva.ts",
"packages/ui/src/lib/motion.ts"
],
"kind": "component",
"name": "combobox",
"packageDependencies": {
"@radix-ui/react-popover": "^1.1.15",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"react": "^18.3.1 || ^19.0.0",
"tailwind-merge": "^3.5.0"
},
"requires": [
"tokens"
],
"sourcePackage": "@ai-ui/ui",
"sourceVersion": "0.0.0",
"targetDirectory": "src/cadence-ui"
},
{
"description": "Source-owned Command component.",
"displayName": "Command",
"entrypoints": [
"packages/ui/src/components/command.tsx"
],
"files": [
"packages/ui/src/components/command.tsx",
"packages/ui/src/components/command.variants.ts",
"packages/ui/src/components/dialog.tsx",
"packages/ui/src/components/dialog.variants.ts",
"packages/ui/src/lib/cn.ts",
"packages/ui/src/lib/contracts.ts",
"packages/ui/src/lib/cva.ts",
"packages/ui/src/lib/motion.ts"
],
"kind": "component",
"name": "command",
"packageDependencies": {
"@radix-ui/react-dialog": "^1.1.15",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"react": "^18.3.1 || ^19.0.0",
"tailwind-merge": "^3.5.0"
},
"requires": [
"tokens"
],
"sourcePackage": "@ai-ui/ui",
"sourceVersion": "0.0.0",
"targetDirectory": "src/cadence-ui"
},
{
"description": "Source-owned Data Table component.",
"displayName": "Data Table",
"entrypoints": [
"packages/ui/src/components/data-table.tsx"
],
"files": [
"packages/ui/src/components/button.tsx",
"packages/ui/src/components/button.variants.ts",
"packages/ui/src/components/checkbox.tsx",
"packages/ui/src/components/checkbox.variants.ts",
"packages/ui/src/components/data-table.tsx",
"packages/ui/src/components/data-table.variants.ts",
"packages/ui/src/components/empty-state.tsx",
"packages/ui/src/components/empty-state.variants.ts",
"packages/ui/src/components/field.tsx",
"packages/ui/src/components/input.tsx",
"packages/ui/src/components/input.variants.ts",
"packages/ui/src/components/label.tsx",
"packages/ui/src/components/select.tsx",
"packages/ui/src/components/select.variants.ts",
"packages/ui/src/components/skeleton.tsx",
"packages/ui/src/components/spinner.tsx",
"packages/ui/src/lib/cn.ts",
"packages/ui/src/lib/contracts.ts",
"packages/ui/src/lib/cva.ts",
"packages/ui/src/lib/motion.ts"
],
"kind": "component",
"name": "data-table",
"packageDependencies": {
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.4",
"@tanstack/react-table": "^8.21.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"motion": "^12.38.0",
"react": "^18.3.1 || ^19.0.0",
"tailwind-merge": "^3.5.0"
},
"requires": [
"tokens"
],
"sourcePackage": "@ai-ui/ui",
"sourceVersion": "0.0.0",
"targetDirectory": "src/cadence-ui"
},
{
"description": "Source-owned Dialog component.",
"displayName": "Dialog",
"entrypoints": [
"packages/ui/src/components/dialog.tsx"
],
"files": [
"packages/ui/src/components/dialog.tsx",
"packages/ui/src/components/dialog.variants.ts",
"packages/ui/src/lib/cn.ts",
"packages/ui/src/lib/contracts.ts",
"packages/ui/src/lib/cva.ts"
],
"kind": "component",
"name": "dialog",
"packageDependencies": {
"@radix-ui/react-dialog": "^1.1.15",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"react": "^18.3.1 || ^19.0.0",
"tailwind-merge": "^3.5.0"
},
"requires": [
"tokens"
],
"sourcePackage": "@ai-ui/ui",
"sourceVersion": "0.0.0",
"targetDirectory": "src/cadence-ui"
},
{
"description": "Source-owned Dropdown Menu component.",
"displayName": "Dropdown Menu",
"entrypoints": [
"packages/ui/src/components/dropdown-menu.tsx"
],
"files": [
"packages/ui/src/components/dropdown-menu.tsx",
"packages/ui/src/components/dropdown-menu.variants.ts",
"packages/ui/src/lib/cn.ts",
"packages/ui/src/lib/contracts.ts",
"packages/ui/src/lib/cva.ts"
],
"kind": "component",
"name": "dropdown-menu",
"packageDependencies": {
"@radix-ui/react-dropdown-menu": "^2.1.16",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"react": "^18.3.1 || ^19.0.0",
"tailwind-merge": "^3.5.0"
},
"requires": [
"tokens"
],
"sourcePackage": "@ai-ui/ui",
"sourceVersion": "0.0.0",
"targetDirectory": "src/cadence-ui"
},
{
"description": "Source-owned Empty State component.",
"displayName": "Empty State",
"entrypoints": [
"packages/ui/src/components/empty-state.tsx"
],
"files": [
"packages/ui/src/components/empty-state.tsx",
"packages/ui/src/components/empty-state.variants.ts",
"packages/ui/src/lib/cn.ts",
"packages/ui/src/lib/contracts.ts",
"packages/ui/src/lib/cva.ts",
"packages/ui/src/lib/motion.ts"
],
"kind": "component",
"name": "empty-state",
"packageDependencies": {
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"react": "^18.3.1 || ^19.0.0",
"tailwind-merge": "^3.5.0"
},
"requires": [
"tokens"
],
"sourcePackage": "@ai-ui/ui",
"sourceVersion": "0.0.0",
"targetDirectory": "src/cadence-ui"
},
{
"description": "Source-owned Field component.",
"displayName": "Field",
"entrypoints": [
"packages/ui/src/components/field.tsx"
],
"files": [
"packages/ui/src/components/field.tsx",
"packages/ui/src/components/label.tsx",
"packages/ui/src/lib/cn.ts",
"packages/ui/src/lib/contracts.ts"
],
"kind": "component",
"name": "field",
"packageDependencies": {
"clsx": "^2.1.1",
"react": "^18.3.1 || ^19.0.0",
"tailwind-merge": "^3.5.0"
},
"requires": [
"tokens"
],
"sourcePackage": "@ai-ui/ui",
"sourceVersion": "0.0.0",
"targetDirectory": "src/cadence-ui"
},
{
"description": "Source-owned Form component.",
"displayName": "Form",
"entrypoints": [
"packages/ui/src/components/form.tsx"
],
"files": [
"packages/ui/src/components/field.tsx",
"packages/ui/src/components/form.tsx",
"packages/ui/src/components/label.tsx",
"packages/ui/src/lib/cn.ts",
"packages/ui/src/lib/contracts.ts"
],
"kind": "component",
"name": "form",
"packageDependencies": {
"@radix-ui/react-slot": "^1.2.4",
"clsx": "^2.1.1",
"react": "^18.3.1 || ^19.0.0",
"react-hook-form": "^7.71.2",
"tailwind-merge": "^3.5.0"
},
"requires": [
"tokens"
],
"sourcePackage": "@ai-ui/ui",
"sourceVersion": "0.0.0",
"targetDirectory": "src/cadence-ui"
},
{
"description": "Source-owned Input component.",
"displayName": "Input",
"entrypoints": [
"packages/ui/src/components/input.tsx"
],
"files": [
"packages/ui/src/components/field.tsx",
"packages/ui/src/components/input.tsx",
"packages/ui/src/components/input.variants.ts",
"packages/ui/src/components/label.tsx",
"packages/ui/src/lib/cn.ts",
"packages/ui/src/lib/contracts.ts",
"packages/ui/src/lib/cva.ts",
"packages/ui/src/lib/motion.ts"
],
"kind": "component",
"name": "input",
"packageDependencies": {
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"react": "^18.3.1 || ^19.0.0",
"tailwind-merge": "^3.5.0"
},
"requires": [
"tokens"
],
"sourcePackage": "@ai-ui/ui",
"sourceVersion": "0.0.0",
"targetDirectory": "src/cadence-ui"
},
{
"description": "Source-owned Label component.",
"displayName": "Label",
"entrypoints": [
"packages/ui/src/components/label.tsx"
],
"files": [
"packages/ui/src/components/field.tsx",
"packages/ui/src/components/label.tsx",
"packages/ui/src/lib/cn.ts",
"packages/ui/src/lib/contracts.ts"
],
"kind": "component",
"name": "label",
"packageDependencies": {
"clsx": "^2.1.1",
"react": "^18.3.1 || ^19.0.0",
"tailwind-merge": "^3.5.0"
},
"requires": [
"tokens"
],
"sourcePackage": "@ai-ui/ui",
"sourceVersion": "0.0.0",
"targetDirectory": "src/cadence-ui"
},
{
"description": "Source-owned Popover component.",
"displayName": "Popover",
"entrypoints": [
"packages/ui/src/components/popover.tsx"
],
"files": [
"packages/ui/src/components/popover.tsx",
"packages/ui/src/components/popover.variants.ts",
"packages/ui/src/lib/cn.ts",
"packages/ui/src/lib/contracts.ts",
"packages/ui/src/lib/cva.ts"
],
"kind": "component",
"name": "popover",
"packageDependencies": {
"@radix-ui/react-popover": "^1.1.15",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"react": "^18.3.1 || ^19.0.0",
"tailwind-merge": "^3.5.0"
},
"requires": [
"tokens"
],
"sourcePackage": "@ai-ui/ui",
"sourceVersion": "0.0.0",
"targetDirectory": "src/cadence-ui"
},
{
"description": "Source-owned Progress component.",
"displayName": "Progress",
"entrypoints": [
"packages/ui/src/components/progress.tsx"
],
"files": [
"packages/ui/src/components/progress.tsx",
"packages/ui/src/components/progress.variants.ts",
"packages/ui/src/lib/cn.ts",
"packages/ui/src/lib/contracts.ts",
"packages/ui/src/lib/cva.ts",
"packages/ui/src/lib/motion.ts"
],
"kind": "component",
"name": "progress",
"packageDependencies": {
"@radix-ui/react-progress": "^1.1.8",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"react": "^18.3.1 || ^19.0.0",
"tailwind-merge": "^3.5.0"
},
"requires": [
"tokens"
],
"sourcePackage": "@ai-ui/ui",
"sourceVersion": "0.0.0",
"targetDirectory": "src/cadence-ui"
},
{
"description": "Source-owned Radio Group component.",
"displayName": "Radio Group",
"entrypoints": [
"packages/ui/src/components/radio-group.tsx"
],
"files": [
"packages/ui/src/components/radio-group.tsx",
"packages/ui/src/components/radio-group.variants.ts",
"packages/ui/src/lib/cn.ts",
"packages/ui/src/lib/contracts.ts",
"packages/ui/src/lib/cva.ts",
"packages/ui/src/lib/motion.ts"
],
"kind": "component",
"name": "radio-group",
"packageDependencies": {
"@radix-ui/react-radio-group": "^1.3.8",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"react": "^18.3.1 || ^19.0.0",
"tailwind-merge": "^3.5.0"
},
"requires": [
"tokens"
],
"sourcePackage": "@ai-ui/ui",
"sourceVersion": "0.0.0",
"targetDirectory": "src/cadence-ui"
},
{
"description": "Source-owned Select component.",
"displayName": "Select",
"entrypoints": [
"packages/ui/src/components/select.tsx"
],
"files": [
"packages/ui/src/components/field.tsx",
"packages/ui/src/components/label.tsx",
"packages/ui/src/components/select.tsx",
"packages/ui/src/components/select.variants.ts",
"packages/ui/src/lib/cn.ts",
"packages/ui/src/lib/contracts.ts",
"packages/ui/src/lib/cva.ts",
"packages/ui/src/lib/motion.ts"
],
"kind": "component",
"name": "select",
"packageDependencies": {
"@radix-ui/react-select": "^2.2.6",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"react": "^18.3.1 || ^19.0.0",
"tailwind-merge": "^3.5.0"
},
"requires": [
"tokens"
],
"sourcePackage": "@ai-ui/ui",
"sourceVersion": "0.0.0",
"targetDirectory": "src/cadence-ui"
},
{
"description": "Source-owned Separator component.",
"displayName": "Separator",
"entrypoints": [
"packages/ui/src/components/separator.tsx"
],
"files": [
"packages/ui/src/components/separator.tsx",
"packages/ui/src/components/separator.variants.ts",
"packages/ui/src/lib/cn.ts",
"packages/ui/src/lib/contracts.ts",
"packages/ui/src/lib/cva.ts"
],
"kind": "component",
"name": "separator",
"packageDependencies": {
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"react": "^18.3.1 || ^19.0.0",
"tailwind-merge": "^3.5.0"
},
"requires": [
"tokens"
],
"sourcePackage": "@ai-ui/ui",
"sourceVersion": "0.0.0",
"targetDirectory": "src/cadence-ui"
},
{
"description": "Source-owned Sheet component.",
"displayName": "Sheet",
"entrypoints": [
"packages/ui/src/components/sheet.tsx"
],
"files": [
"packages/ui/src/components/dialog.variants.ts",
"packages/ui/src/components/sheet.tsx",
"packages/ui/src/components/sheet.variants.ts",
"packages/ui/src/lib/cn.ts",
"packages/ui/src/lib/contracts.ts",
"packages/ui/src/lib/cva.ts"
],
"kind": "component",
"name": "sheet",
"packageDependencies": {
"@radix-ui/react-dialog": "^1.1.15",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"react": "^18.3.1 || ^19.0.0",
"tailwind-merge": "^3.5.0"
},
"requires": [
"tokens"
],
"sourcePackage": "@ai-ui/ui",
"sourceVersion": "0.0.0",
"targetDirectory": "src/cadence-ui"
},
{
"description": "Source-owned Skeleton component.",
"displayName": "Skeleton",
"entrypoints": [
"packages/ui/src/components/skeleton.tsx"
],
"files": [
"packages/ui/src/components/skeleton.tsx",
"packages/ui/src/lib/cn.ts",
"packages/ui/src/lib/contracts.ts",
"packages/ui/src/lib/cva.ts"
],
"kind": "component",
"name": "skeleton",
"packageDependencies": {
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"react": "^18.3.1 || ^19.0.0",
"tailwind-merge": "^3.5.0"
},
"requires": [
"tokens"
],
"sourcePackage": "@ai-ui/ui",
"sourceVersion": "0.0.0",
"targetDirectory": "src/cadence-ui"
},
{
"description": "Source-owned Spinner component.",
"displayName": "Spinner",
"entrypoints": [
"packages/ui/src/components/spinner.tsx"
],
"files": [
"packages/ui/src/components/spinner.tsx",
"packages/ui/src/lib/cn.ts",
"packages/ui/src/lib/contracts.ts",
"packages/ui/src/lib/cva.ts"
],
"kind": "component",
"name": "spinner",
"packageDependencies": {
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"react": "^18.3.1 || ^19.0.0",
"tailwind-merge": "^3.5.0"
},
"requires": [
"tokens"
],
"sourcePackage": "@ai-ui/ui",
"sourceVersion": "0.0.0",
"targetDirectory": "src/cadence-ui"
},
{
"description": "Source-owned Switch component.",
"displayName": "Switch",
"entrypoints": [
"packages/ui/src/components/switch.tsx"
],
"files": [
"packages/ui/src/components/switch.tsx",
"packages/ui/src/components/switch.variants.ts",
"packages/ui/src/lib/cn.ts",
"packages/ui/src/lib/contracts.ts",
"packages/ui/src/lib/cva.ts",
"packages/ui/src/lib/motion.ts"
],
"kind": "component",
"name": "switch",
"packageDependencies": {
"@radix-ui/react-switch": "^1.2.6",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"react": "^18.3.1 || ^19.0.0",
"tailwind-merge": "^3.5.0"
},
"requires": [
"tokens"
],
"sourcePackage": "@ai-ui/ui",
"sourceVersion": "0.0.0",
"targetDirectory": "src/cadence-ui"
},
{
"description": "Source-owned Tabs component.",
"displayName": "Tabs",
"entrypoints": [
"packages/ui/src/components/tabs.tsx"
],
"files": [
"packages/ui/src/components/tabs.tsx",
"packages/ui/src/components/tabs.variants.ts",
"packages/ui/src/lib/cn.ts",
"packages/ui/src/lib/contracts.ts",
"packages/ui/src/lib/cva.ts",
"packages/ui/src/lib/motion.ts"
],
"kind": "component",
"name": "tabs",
"packageDependencies": {
"@radix-ui/react-tabs": "^1.1.13",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"react": "^18.3.1 || ^19.0.0",
"tailwind-merge": "^3.5.0"
},
"requires": [
"tokens"
],
"sourcePackage": "@ai-ui/ui",
"sourceVersion": "0.0.0",
"targetDirectory": "src/cadence-ui"
},
{
"description": "Source-owned Textarea component.",
"displayName": "Textarea",
"entrypoints": [
"packages/ui/src/components/textarea.tsx"
],
"files": [
"packages/ui/src/components/field.tsx",
"packages/ui/src/components/label.tsx",
"packages/ui/src/components/textarea.tsx",
"packages/ui/src/components/textarea.variants.ts",
"packages/ui/src/lib/cn.ts",
"packages/ui/src/lib/contracts.ts",
"packages/ui/src/lib/cva.ts",
"packages/ui/src/lib/motion.ts"
],
"kind": "component",
"name": "textarea",
"packageDependencies": {
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"react": "^18.3.1 || ^19.0.0",
"tailwind-merge": "^3.5.0"
},
"requires": [
"tokens"
],
"sourcePackage": "@ai-ui/ui",
"sourceVersion": "0.0.0",
"targetDirectory": "src/cadence-ui"
},
{
"description": "Source-owned Toast component.",
"displayName": "Toast",
"entrypoints": [
"packages/ui/src/components/toast.tsx"
],
"files": [
"packages/ui/src/components/toast.tsx",
"packages/ui/src/components/toast.variants.ts",
"packages/ui/src/lib/cn.ts",
"packages/ui/src/lib/contracts.ts",
"packages/ui/src/lib/cva.ts",
"packages/ui/src/lib/motion.ts"
],
"kind": "component",
"name": "toast",
"packageDependencies": {
"@radix-ui/react-toast": "^1.2.15",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"react": "^18.3.1 || ^19.0.0",
"tailwind-merge": "^3.5.0"
},
"requires": [
"tokens"
],
"sourcePackage": "@ai-ui/ui",
"sourceVersion": "0.0.0",
"targetDirectory": "src/cadence-ui"
},
{
"description": "Source-owned Tooltip component.",
"displayName": "Tooltip",
"entrypoints": [
"packages/ui/src/components/tooltip.tsx"
],
"files": [
"packages/ui/src/components/tooltip.tsx",
"packages/ui/src/components/tooltip.variants.ts",
"packages/ui/src/lib/cn.ts",
"packages/ui/src/lib/contracts.ts",
"packages/ui/src/lib/cva.ts"
],
"kind": "component",
"name": "tooltip",
"packageDependencies": {
"@radix-ui/react-tooltip": "^1.2.8",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"react": "^18.3.1 || ^19.0.0",
"tailwind-merge": "^3.5.0"
},
"requires": [
"tokens"
],
"sourcePackage": "@ai-ui/ui",
"sourceVersion": "0.0.0",
"targetDirectory": "src/cadence-ui"
}
],
"library": {
"name": "cadence-ui",
"packageManager": "pnpm@10.25.0",
"packages": {
"@ai-ui/tokens": "0.0.0",
"@ai-ui/ui": "0.0.0"
}
}
}
+297
View File
@@ -0,0 +1,297 @@
import fs from "node:fs/promises";
import path from "node:path";
import { builtinModules } from "node:module";
import { fileURLToPath } from "node:url";
import ts from "typescript";
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(scriptDir, "..");
const registryDir = path.join(repoRoot, "registry");
const registryConfigPath = path.join(registryDir, "config.json");
const registryIndexPath = path.join(registryDir, "index.json");
const args = new Set(process.argv.slice(2));
const checkMode = args.has("--check");
const uiPackagePath = path.join(repoRoot, "packages", "ui", "package.json");
const tokensPackagePath = path.join(repoRoot, "packages", "tokens", "package.json");
const rootPackagePath = path.join(repoRoot, "package.json");
function toPosixPath(value) {
return value.split(path.sep).join(path.posix.sep);
}
async function readJson(filePath) {
const source = await fs.readFile(filePath, "utf8");
return JSON.parse(source);
}
function isBuiltinSpecifier(specifier) {
return specifier.startsWith("node:") || builtinModules.includes(specifier);
}
function normalizePackageName(specifier) {
if (specifier.startsWith("@")) {
const [scope, name] = specifier.split("/");
return `${scope}/${name}`;
}
return specifier.split("/")[0];
}
function getDisplayName(name) {
return name
.split("-")
.filter(Boolean)
.map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1))
.join(" ");
}
function getScriptSpecifiers(sourceText) {
const { importedFiles } = ts.preProcessFile(sourceText, true, true);
return importedFiles.map((entry) => entry.fileName);
}
function getCssSpecifiers(sourceText) {
const specifiers = [];
const pattern = /@import\s+["']([^"']+)["']/g;
for (const match of sourceText.matchAll(pattern)) {
specifiers.push(match[1]);
}
return specifiers;
}
async function resolveLocalImport(fromFile, specifier) {
const basePath = path.resolve(path.dirname(fromFile), specifier);
const candidates = [
basePath,
`${basePath}.ts`,
`${basePath}.tsx`,
`${basePath}.js`,
`${basePath}.mjs`,
`${basePath}.cjs`,
`${basePath}.css`,
path.join(basePath, "index.ts"),
path.join(basePath, "index.tsx"),
path.join(basePath, "index.js")
];
for (const candidate of candidates) {
try {
const stats = await fs.stat(candidate);
if (stats.isFile()) {
return candidate;
}
} catch {
continue;
}
}
throw new Error(
`Unable to resolve "${specifier}" from ${toPosixPath(path.relative(repoRoot, fromFile))}.`
);
}
async function collectSourceGraph(entrypoints) {
const filesToVisit = [...entrypoints];
const visited = new Set();
const externalPackages = new Set();
while (filesToVisit.length > 0) {
const currentFile = filesToVisit.pop();
if (!currentFile || visited.has(currentFile)) {
continue;
}
visited.add(currentFile);
const sourceText = await fs.readFile(currentFile, "utf8");
const localSpecifiers = currentFile.endsWith(".css")
? getCssSpecifiers(sourceText)
: getScriptSpecifiers(sourceText);
for (const specifier of localSpecifiers) {
if (specifier.startsWith(".") || specifier.startsWith("..")) {
filesToVisit.push(await resolveLocalImport(currentFile, specifier));
continue;
}
if (isBuiltinSpecifier(specifier)) {
continue;
}
externalPackages.add(normalizePackageName(specifier));
}
}
return {
externalPackages: [...externalPackages].sort(),
files: [...visited]
.map((filePath) => toPosixPath(path.relative(repoRoot, filePath)))
.sort()
};
}
async function discoverComponentEntrypoints(componentsDir) {
const absoluteDir = path.join(repoRoot, componentsDir);
const entries = await fs.readdir(absoluteDir, { withFileTypes: true });
return entries
.filter((entry) => entry.isFile())
.map((entry) => entry.name)
.filter((fileName) => fileName.endsWith(".tsx"))
.filter((fileName) => !fileName.endsWith(".test.tsx"))
.sort()
.map((fileName) => ({
description: `Source-owned ${getDisplayName(fileName.replace(/\.tsx$/, ""))} component.`,
entrypoints: [toPosixPath(path.join(componentsDir, fileName))],
kind: "component",
name: fileName.replace(/\.tsx$/, "")
}));
}
function createDependencyLookup(...packageJsonFiles) {
const lookup = new Map();
for (const packageJson of packageJsonFiles) {
for (const section of [
packageJson.peerDependencies ?? {},
packageJson.dependencies ?? {},
packageJson.devDependencies ?? {}
]) {
for (const [name, range] of Object.entries(section)) {
if (!lookup.has(name)) {
lookup.set(name, range);
}
}
}
}
return lookup;
}
async function buildRegistryIndex() {
const [registryConfig, rootPackage, uiPackage, tokensPackage] = await Promise.all([
readJson(registryConfigPath),
readJson(rootPackagePath),
readJson(uiPackagePath),
readJson(tokensPackagePath)
]);
const dependencyLookup = createDependencyLookup(uiPackage, tokensPackage, rootPackage);
const itemDefinitions = [
{
...registryConfig.tokens,
kind: "tokens",
requires: [],
sourcePackage: tokensPackage.name
},
...(await discoverComponentEntrypoints(registryConfig.ui.componentsDir)).map((item) => ({
...item,
requires: [...registryConfig.ui.requires],
sourcePackage: uiPackage.name
}))
];
const items = [];
for (const definition of itemDefinitions) {
const entrypoints = definition.entrypoints.map((entrypoint) =>
path.join(repoRoot, ...entrypoint.split("/"))
);
const sourceGraph = await collectSourceGraph(entrypoints);
const packageDependencies = {};
for (const dependencyName of sourceGraph.externalPackages) {
const range = dependencyLookup.get(dependencyName);
if (!range) {
throw new Error(
`Missing dependency range for "${dependencyName}" while building registry item "${definition.name}".`
);
}
packageDependencies[dependencyName] = range;
}
items.push({
description: definition.description,
displayName: getDisplayName(definition.name),
entrypoints: definition.entrypoints,
files: sourceGraph.files,
kind: definition.kind,
name: definition.name,
packageDependencies,
requires: definition.requires,
sourcePackage: definition.sourcePackage,
sourceVersion:
definition.sourcePackage === uiPackage.name ? uiPackage.version : tokensPackage.version,
targetDirectory:
definition.kind === "tokens"
? toPosixPath(
path.posix.join(registryConfig.defaultTargetDir, registryConfig.tokens.targetSubdir)
)
: toPosixPath(
path.posix.join(
registryConfig.defaultTargetDir,
registryConfig.ui.targetSubdir === "."
? ""
: registryConfig.ui.targetSubdir
)
)
});
}
return {
generatedBy: "scripts/build-registry.mjs",
install: {
defaultTargetDir: registryConfig.defaultTargetDir,
manifestFile: ".install-manifest.json"
},
items,
library: {
name: registryConfig.libraryName,
packageManager: rootPackage.packageManager,
packages: {
[tokensPackage.name]: tokensPackage.version,
[uiPackage.name]: uiPackage.version
}
}
};
}
async function main() {
const nextIndex = await buildRegistryIndex();
const nextContent = `${JSON.stringify(nextIndex, null, 2)}\n`;
if (checkMode) {
let currentContent = "";
try {
currentContent = await fs.readFile(registryIndexPath, "utf8");
} catch {
currentContent = "";
}
if (currentContent !== nextContent) {
console.error("registry/index.json is out of date. Run `pnpm registry:build`.");
process.exitCode = 1;
return;
}
console.log("registry/index.json is up to date.");
return;
}
await fs.mkdir(registryDir, { recursive: true });
await fs.writeFile(registryIndexPath, nextContent);
console.log(`Wrote ${toPosixPath(path.relative(repoRoot, registryIndexPath))}.`);
}
await main();
+327
View File
@@ -0,0 +1,327 @@
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(scriptDir, "..");
const registryIndexPath = path.join(repoRoot, "registry", "index.json");
function toPosixPath(value) {
return value.split(path.sep).join(path.posix.sep);
}
async function readJson(filePath) {
const source = await fs.readFile(filePath, "utf8");
return JSON.parse(source);
}
function sortObject(input) {
return Object.fromEntries(Object.entries(input).sort(([left], [right]) => left.localeCompare(right)));
}
function parseArgs(argv) {
const options = {
dryRun: false,
project: process.cwd(),
skipPackageJson: false,
targetDir: null
};
const items = [];
for (let index = 0; index < argv.length; index += 1) {
const current = argv[index];
if (current === "--dry-run") {
options.dryRun = true;
continue;
}
if (current === "--skip-package-json") {
options.skipPackageJson = true;
continue;
}
if (current === "--project" || current === "--target-dir") {
const next = argv[index + 1];
if (!next) {
throw new Error(`Expected a value after ${current}.`);
}
if (current === "--project") {
options.project = next;
} else {
options.targetDir = next;
}
index += 1;
continue;
}
if (current.startsWith("--")) {
throw new Error(`Unknown option: ${current}`);
}
items.push(current);
}
return {
items,
options
};
}
function mapSourceToTarget(sourcePath, targetDir) {
if (sourcePath.startsWith("packages/ui/src/")) {
return toPosixPath(path.posix.join(targetDir, sourcePath.replace("packages/ui/src/", "")));
}
if (sourcePath.startsWith("packages/tokens/src/")) {
return toPosixPath(
path.posix.join(targetDir, "tokens", sourcePath.replace("packages/tokens/src/", ""))
);
}
throw new Error(`Unsupported registry source path: ${sourcePath}`);
}
function resolveItems(registryIndex, requestedItems) {
const itemLookup = new Map(registryIndex.items.map((item) => [item.name, item]));
const resolved = new Map();
const stack = [...requestedItems];
while (stack.length > 0) {
const name = stack.pop();
const item = itemLookup.get(name);
if (!item) {
const available = [...itemLookup.keys()].sort().join(", ");
throw new Error(`Unknown registry item "${name}". Available items: ${available}.`);
}
if (resolved.has(name)) {
continue;
}
resolved.set(name, item);
for (const dependency of item.requires) {
stack.push(dependency);
}
}
return [...resolved.values()].sort((left, right) => left.name.localeCompare(right.name));
}
async function readInstallManifest(manifestPath) {
try {
return await readJson(manifestPath);
} catch {
return null;
}
}
function collectPackageDependencies(items) {
const dependencies = {};
for (const item of items) {
for (const [name, range] of Object.entries(item.packageDependencies)) {
dependencies[name] = range;
}
}
return sortObject(dependencies);
}
function upsertDependencySections(packageJson, dependencies) {
const sections = ["dependencies", "devDependencies", "peerDependencies", "optionalDependencies"];
const added = {};
const conflicts = [];
const nextPackageJson = JSON.parse(JSON.stringify(packageJson));
nextPackageJson.dependencies = nextPackageJson.dependencies ?? {};
for (const [name, range] of Object.entries(dependencies)) {
let existingSection = null;
let existingRange = null;
for (const section of sections) {
const value = nextPackageJson[section]?.[name];
if (typeof value === "string") {
existingSection = section;
existingRange = value;
break;
}
}
if (!existingSection) {
nextPackageJson.dependencies[name] = range;
added[name] = range;
continue;
}
if (existingRange !== range) {
conflicts.push({
existingRange,
name,
requiredRange: range,
section: existingSection
});
}
}
nextPackageJson.dependencies = sortObject(nextPackageJson.dependencies);
return {
added,
conflicts,
packageJson: nextPackageJson
};
}
async function copyRegistryFiles({ dryRun, items, projectRoot, targetDir }) {
const writtenFiles = [];
const updatedFiles = [];
const unchangedFiles = [];
for (const item of items) {
for (const sourcePath of item.files) {
const sourceFile = path.join(repoRoot, ...sourcePath.split("/"));
const targetRelativePath = mapSourceToTarget(sourcePath, targetDir);
const targetFile = path.join(projectRoot, ...targetRelativePath.split("/"));
const content = await fs.readFile(sourceFile, "utf8");
let existingContent = null;
try {
existingContent = await fs.readFile(targetFile, "utf8");
} catch {
existingContent = null;
}
if (existingContent === content) {
unchangedFiles.push(targetRelativePath);
continue;
}
if (!dryRun) {
await fs.mkdir(path.dirname(targetFile), { recursive: true });
await fs.writeFile(targetFile, content);
}
if (existingContent === null) {
writtenFiles.push(targetRelativePath);
} else {
updatedFiles.push(targetRelativePath);
}
}
}
return {
unchangedFiles,
updatedFiles,
writtenFiles
};
}
async function main() {
const { items: requestedItems, options } = parseArgs(process.argv.slice(2));
const registryIndex = await readJson(registryIndexPath);
const projectRoot = path.resolve(options.project);
const targetDir = toPosixPath(options.targetDir ?? registryIndex.install.defaultTargetDir);
const targetRoot = path.join(projectRoot, ...targetDir.split("/"));
const manifestPath = path.join(targetRoot, registryIndex.install.manifestFile);
let itemNames = requestedItems;
if (itemNames.length === 0) {
const manifest = await readInstallManifest(manifestPath);
if (!manifest?.items?.length) {
throw new Error(
`No registry items were provided and ${toPosixPath(
path.relative(projectRoot, manifestPath)
)} does not exist yet.`
);
}
itemNames = manifest.items;
}
const resolvedItems = resolveItems(registryIndex, itemNames);
const packageDependencies = collectPackageDependencies(resolvedItems);
const copyResult = await copyRegistryFiles({
dryRun: options.dryRun,
items: resolvedItems,
projectRoot,
targetDir
});
let dependencyResult = {
added: {},
conflicts: []
};
if (!options.skipPackageJson) {
const packageJsonPath = path.join(projectRoot, "package.json");
const packageJson = await readJson(packageJsonPath);
dependencyResult = upsertDependencySections(packageJson, packageDependencies);
if (!options.dryRun) {
await fs.writeFile(
packageJsonPath,
`${JSON.stringify(dependencyResult.packageJson, null, 2)}\n`
);
}
}
const installManifest = {
items: resolvedItems.map((item) => item.name),
packageDependencies,
registry: registryIndex.library.name,
sourcePackages: registryIndex.library.packages,
targetDir
};
if (!options.dryRun) {
await fs.mkdir(targetRoot, { recursive: true });
await fs.writeFile(manifestPath, `${JSON.stringify(installManifest, null, 2)}\n`);
}
console.log(
`${options.dryRun ? "Planned" : "Installed"} registry items: ${resolvedItems
.map((item) => item.name)
.join(", ")}`
);
console.log(
`Files written: ${copyResult.writtenFiles.length}, updated: ${copyResult.updatedFiles.length}, unchanged: ${copyResult.unchangedFiles.length}`
);
if (!options.skipPackageJson) {
const added = Object.keys(dependencyResult.added);
console.log(
added.length > 0
? `Added package dependencies: ${added.join(", ")}`
: "Added package dependencies: none"
);
}
if (dependencyResult.conflicts.length > 0) {
console.log("Dependency version conflicts:");
for (const conflict of dependencyResult.conflicts) {
console.log(
`- ${conflict.name}: keeping ${conflict.section}=${conflict.existingRange}, registry expects ${conflict.requiredRange}`
);
}
}
console.log(`Target directory: ${targetDir}`);
console.log(`Tokens import path: ${targetDir}/tokens/styles.css`);
console.log(
options.dryRun
? "Dry run complete."
: "Review the diff, then run your package manager install if package.json changed."
);
}
await main();