# Using the kit in another project without publishing it

`pnpm kit:local` builds the kit and drops the **same file set `npm publish` would send** into another project's `node_modules/`. No registry involved, and the package name, the licence holder and the Nexus account can all still be undecided.

Azerbaijani: [`kit-local.az.md`](./kit-local.az.md) · Short version: [README §8b](../../README.md#8b-using-the-kit-without-publishing-it)

---

## 1. Five steps

Say the kit is at `.../create-reactivite/template5` and the app at `.../my-app`.

```bash
# 1) The target must already be installed (node_modules has to exist)
cd ../my-app && npm install

# 2) Sync from the kit
cd ../create-reactivite/template5
npm run kit:local -- --to ../../my-app

# 3) The script prints what to change — apply it (full code below)

# 4) If the target is TypeScript
cd ../../my-app && npm install --save-dev radix-ui

# 5) Run it
npm run dev
```

All three invocations are equivalent — pick one:

```bash
pnpm kit:local -- --to ../my-app           # if you have pnpm
npm  run kit:local -- --to ../my-app       # with npm
node scripts/link-local.mjs --to ../my-app # no package manager at all
```

> The `--` matters: without it, `npm run` / `pnpm` keeps the flags for itself instead of passing them to the script.

---

## 2. What changes in the target project

The script prints all of this, but here it is in full.

### 2.1 The stylesheet, once, in the app entry

```tsx
// Vite: src/main.tsx   ·   Next: app/layout.tsx
import '@cbar/uikit/styles.css';
```

That file is **self-contained** (~112 kB, compiled Tailwind plus both token layers). The app needs **no Tailwind setup of its own**.

If the app already runs Tailwind v4, take the tokens instead:

```css
@import 'tailwindcss';
@import '@cbar/uikit/tokens.css';
@import '@cbar/uikit/theme.css';
@source '../node_modules/@cbar/uikit/dist';
```

### 2.2 The two root providers

```tsx
import { TooltipProvider } from '@cbar/uikit/tooltip';
import { Toaster } from '@cbar/uikit/toast';

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <TooltipProvider>
      <App />
      <Toaster position="bottom-right" />
    </TooltipProvider>
  </StrictMode>,
);
```

`TooltipProvider` has to sit above every `Tooltip` (the open/close delay is shared), and `Toaster` is the portal every `toast()` call renders into.

### 2.3 Components, by subpath

```tsx
import { Button } from '@cbar/uikit/button';
import { HomeIcon } from '@cbar/uikit/icons';

<Button colorPalette="primary" size="md">
  <HomeIcon />
  Salam
</Button>
```

The barrel (`from '@cbar/uikit'`) works too, but subpath imports keep cold builds and type-checking noticeably faster. `form` and `icons` are deliberately **not** in the barrel — each has its own subpath.

### 2.4 Dark mode

```html
<html class="dark">
```

### 2.5 Vite configuration

```ts
export default defineConfig({
  plugins: [react()],
  optimizeDeps: { exclude: ['@cbar/uikit'] },
});
```

Without it Vite pre-bundles the kit once and every re-sync then needs `vite --force`.

**Next.js**: add **nothing** to `next.config`. In particular do not reach for `transpilePackages` — the kit ships compiled ESM + CJS and its `'use client'` directives survive into `dist`, so Next consumes it as a normal package.

### 2.6 TypeScript — `radix-ui` as a dev dependency

```bash
npm install --save-dev radix-ui      # pnpm add -D radix-ui
```

The kit ships no **runtime** dependencies, but the emitted `.d.ts` files still write `import * as DialogPrimitive from 'radix-ui/dialog'` to describe their prop types. With the package absent those types resolve to nothing, and Dialog, Tooltip, Select and fifteen others type-check as **taking no props at all**:

```
TS2559: Type '{ children: Element[]; }' has no properties in common with
        type 'IntrinsicAttributes & DialogContentProps'.
```

Runtime cost is **zero** — the JavaScript already has Radix compiled in, and the app's built bundle is byte-identical with or without it (measured: same chunk hash).

---

## 3. Every flag

| Flag | What it does |
|---|---|
| `--to <path>` | Target project. **Repeatable.** Resolved against the current directory. |
| `--watch` | Rebuild and re-sync whenever `src/` changes. Copy mode only. |
| `--no-build` | Skip the build, use the existing `dist/` as it stands. |
| `--mode copy` | Default. Copies straight into `node_modules`. |
| `--mode pack` | Builds a real tarball and installs it (see §5). |
| `--pm npm\|pnpm\|yarn` | Which manager runs the kit's build. Default `npm`. |
| `--target-pm npm\|pnpm\|yarn` | The install command in `--mode pack`. Default: read from the target's lock file. |
| `--force` | Overwrite a directory this script did not write (see §6). |
| `-h`, `--help` | The usage text. |

---

## 4. `.kit-local.json` for several projects

At the kit root (`template5/.kit-local.json`):

```json
{ "targets": ["../my-app", "../another-app", "../../work/dashboard"] }
```

Then simply:

```bash
npm run kit:local
```

The file is **gitignored** — it is machine-local, so everyone keeps their own list. Passing `--to` ignores the file entirely.

> One difference: `--to` paths resolve against the **current directory**, while paths in `.kit-local.json` resolve against the **kit root**, because that is where the file lives.

---

## 5. `copy` or `pack`

| | `--mode copy` (default) | `--mode pack` |
|---|---|---|
| What it does | copies files straight into `node_modules` | `npm pack` → tarball → install in the target |
| Speed | seconds | you wait for an install |
| Target `package.json` | **untouched** | a dependency is written |
| After an `install` | **can be pruned away** | survives |
| Yarn PnP | does not work | works |
| Use it for | day-to-day work, `--watch` | handing the kit to a teammate, PnP, a stable setup |

In `pack` mode the tarball is written **beside the kit**, not into a temp directory — npm records the path it installed from in the target's `package.json`, and a temp directory disappears on the next sweep, breaking that dependency. `*.tgz` is already in the kit's `_gitignore`.

---

## 5b. Why `node_modules` and not some other folder in the app

A fair question, since a folder like `vendor/ui-kit/` would be visible in the repo and would survive an install. Three things make `node_modules` the right destination anyway.

**The `exports` map only works there.** The whole consumption model is 48 subpaths — `import { Button } from '@cbar/uikit/button'` — and subpath resolution runs through a *package name* looked up in `node_modules`. `./button` is declared in `package.json#exports` as `./dist/components/button/index.js`. Alias the name at a folder instead and `<pkg>/button` becomes the literal path `vendor/ui-kit/button`, which does not exist. Either the subpaths are lost, or every one of the 48 needs an alias entry — and in four separate places: `tsconfig#paths`, Vite, Next's webpack config and Vitest.

**Fidelity is the point of the script.** It copies the exact file set `package.json#files` names (`link-local.mjs`, `shippedEntries()`), so a bug that only shows up locally is a bug that would have shipped. Outside `node_modules` you would be exercising a resolution path no consumer ever uses: conditional exports (`import` / `require` / `types`), `typesVersions`, the way Next treats a `'use client'` directive in a dependency, Vite's `optimizeDeps`. A local success would stop meaning anything about the published package.

**The app's import lines never change.** What the app writes today is character-for-character what it writes after a real `npm install <pkg>`. Switching from the local drop to the published package is deleting the drop — no code edit. With a vendor folder and relative paths, publishing day means rewriting every import in the app.

### Approaches that were considered and rejected

| Approach | Why not |
|---|---|
| `npm link` / `pnpm link` / `file:` pointed at the kit repo | All three create a symlink to the kit's own directory, which has its own `node_modules` — Node resolves `react` from there before the app's, and every hook throws `Invalid hook call`. |
| `vendor/ui-kit/` + a bundler alias | `exports` no longer applies, so the 48 subpaths break; four alias surfaces to keep in sync; every import in the app has to be rewritten at publish time. |
| **yalc** | The de-facto tool for this problem, and it is worth knowing that it implements exactly the folder-in-the-project idea — it stages the packed files at `<app>/.yalc/<pkg>` — **and still creates a `node_modules/<pkg>` entry pointing at them.** The staging folder is not the import path; its job is to be recorded in `package.json` so the drop survives an install. So yalc confirms this decision rather than contradicting it. |
| Verdaccio or another local registry | The highest fidelity of all, but it needs a service running and a registry configured per machine. `--mode pack` reaches the same place with neither. |
| A tarball installed as a dependency | Not rejected — that *is* `--mode pack`, §5. |

One clarification, because the yalc row otherwise reads as a contradiction: the "copy, never link" rule is about **what the link points at**, not about symlinks as such. A link to the kit repo duplicates React because the kit repo has its own `node_modules`. A link to a folder *inside the app* does not — the resolved real path is still under the app, so `react` is found in the app's own tree. That is the whole reason yalc works.

The one genuine cost of copying into `node_modules` is that the drop is in no lock file and a later `install` in the target can prune it away. That is a real limitation, not a detail — and its answer is `--mode pack` in §5, which writes a dependency that survives.

---

## 6. The guards

The script checks all of this **before** writing anything:

| Check | Result |
|---|---|
| No `package.json` in the target | stops |
| No `node_modules` in the target | stops — "install the target first" |
| Target is Yarn PnP (`.pnp.cjs`) | stops, suggests `--mode pack` |
| Target's React major is outside the peer range | **warns**, does not stop |
| Destination exists but has no `.kit-local` marker | stops — that may be a real install of the same name. `--force` overrides |
| `dist/index.js` or `dist/styles.css` missing | stops — a partial `dist/` is worse than none |

Every successful sync writes a `.kit-local` marker into the destination recording the source path, version and timestamp, so it is obvious the directory is a local drop.

Each sync **removes the destination first**. That is what stops a renamed or deleted component from leaving an old `dist/components/<old>/index.js` behind that still resolves.

---

## 7. Day-to-day recipes

### I changed a component and want to see it in the app immediately

```bash
npm run kit:local -- --to ../my-app --watch
```

It watches `.ts`, `.tsx` and `.css` under `src/`, with a 300 ms debounce. Save five files in a row and you get **one** sync, not five. A change arriving mid-build is queued rather than starting a second build. A failing build does **not** kill the watcher — it prints the error and waits for the next save.

Run the app's dev server in its own terminal; HMR picks the change up once the sync finishes.

### I only want the copy, the build is already current

```bash
npm run kit:local -- --to ../my-app --no-build
```

### I added a new component

```bash
npm run exports:gen        # so the new subpath lands in package.json#exports
npm run kit:local -- --to ../my-app
```

(`kit:local` calls `build`, which starts with `exports:gen`, so this is automatic. Run it separately only when using `--no-build`.)

### I ran `npm install` in the app and the kit vanished

Expected: a copy is in no lock file, so a manager that prunes removes it. Two ways out:

```bash
npm run kit:local -- --to ../my-app --no-build   # re-sync
# or, once and for all:
npm run kit:local -- --to ../my-app --mode pack
```

### I want to hand it to a teammate

```bash
npm run kit:local -- --to ../my-app --mode pack
```

Then move the resulting `.tgz` into the target repo and install from there, so someone else's clone can install it too.

---

## 8. When something breaks

| Symptom | Cause and fix |
|---|---|
| `TS2559: … has no properties in common with … DialogContentProps` | `radix-ui` missing in the app. `npm i -D radix-ui`. See §2.6. |
| I changed something and the app does not show it | Vite is serving its pre-bundle. Add `optimizeDeps.exclude` (§2.5); the script clears `node_modules/.vite` anyway, but the dev server may need a restart. |
| `Invalid hook call` | Two React copies. This script copies rather than links, so it should not happen — check you are not also using `npm link` or a `file:` symlink. |
| `… has no node_modules` | Run `npm install` in the target first. |
| `dist/ is incomplete — missing dist\styles.css` | A partial build (e.g. `run-tsup.mjs` on its own, whose `clean: true` wipes `dist`). Run without `--no-build`. |
| `… already exists and was not written by this script` | A real install of that name is there. Remove it, or pass `--force`. |
| `uses Yarn PnP` | Use `--mode pack`. |
| No styling at all | `styles.css` is not imported, or the `.dark` class is not where expected. §2.1. |
| Tooltip never opens / `toast()` does nothing | The root providers are missing. §2.2. |

---

## 9. When to stop doing this and publish properly

`kit:local` is for development and trying things out. If any of these is true, it is time to publish:

- Several people use the kit and each has to sync on their own machine.
- CI has to build the target project (this script cannot run there — the kit repo is not present).
- You need to roll back by version number.

The publishing path: [`instructions.md` §6](../instructions.md#6-publishing-the-kit) and the `/release-kit` skill.
