uipkge-ng 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Uday Adaka
4
+ uipkge (https://uipkge.dev) - uipkge-ng, the uipkge CLI for Angular
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ of this software and associated documentation files (the "Software"), to deal
8
+ in the Software without restriction, including without limitation the rights
9
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ copies of the Software, and to permit persons to whom the Software is
11
+ furnished to do so, subject to the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be included in all
14
+ copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,371 @@
1
+ # uipkge-ng
2
+
3
+ **Add [uipkge](https://uipkge.dev) components to your Angular app — as source you own.**
4
+
5
+ uipkge-ng is the Angular CLI for the uipkge registry, in the spirit of shadcn/ui: it copies each component's source into your project, installs what it needs and wires up Tailwind. There's no runtime package to depend on and no version to upgrade — the code is yours to read and change.
6
+
7
+ ```bash
8
+ npx uipkge-ng init
9
+ npx uipkge-ng add button dialog date-picker
10
+ ```
11
+
12
+ ```ts
13
+ import { UiButtonComponent } from '@/ui/button';
14
+ ```
15
+
16
+ ```html
17
+ <button ui-button variant="outline">Save</button>
18
+ ```
19
+
20
+ - **One command to set up** — Tailwind CSS v4, the `cn()` helper, design tokens (light and dark) and tsconfig paths.
21
+ - **Dependencies resolved** — adding `date-picker` brings `calendar`, `popover` and their npm packages; `@angular/*` packages are pinned to your Angular version.
22
+ - **Safe in real projects** — it never overwrites your files without asking, works in multi-project workspaces and monorepos, and follows your folder layout.
23
+ - **Your own registries too** — install from private registries with auth, or publish your own with `uipkge-ng build`.
24
+ - **AI-ready** — `uipkge-ng mcp` lets assistants browse components and their source.
25
+
26
+ ## Contents
27
+
28
+ - [Requirements](#requirements)
29
+ - [Getting started](#getting-started)
30
+ - [Commands](#commands)
31
+ - [Existing projects](#existing-projects)
32
+ - [Workspaces and monorepos](#workspaces-and-monorepos)
33
+ - [components.json](#componentsjson)
34
+ - [Custom folders](#custom-folders)
35
+ - [Registries](#registries)
36
+ - [Publishing your own registry](#publishing-your-own-registry)
37
+ - [AI assistants (MCP)](#ai-assistants-mcp)
38
+ - [CI and scripting](#ci-and-scripting)
39
+ - [Troubleshooting](#troubleshooting)
40
+
41
+ ## Requirements
42
+
43
+ - Node.js **20.12** or newer
44
+ - An Angular CLI application with standalone components (Analog/Vite supported; Nx workspaces not yet)
45
+ - npm, pnpm, yarn or bun — detected from your lockfile
46
+
47
+ ## Getting started
48
+
49
+ Run it without installing:
50
+
51
+ ```bash
52
+ npx uipkge-ng init # or: pnpm dlx uipkge-ng init / bunx uipkge-ng init
53
+ ```
54
+
55
+ or add it to the project and use it from there:
56
+
57
+ ```bash
58
+ npm i -D uipkge-ng
59
+ npx uipkge-ng add button
60
+ ```
61
+
62
+ `init` shows what it did:
63
+
64
+ ```text
65
+ ✔ uipkge is ready.
66
+ ✔ Tailwind CSS v4 set up
67
+ ✔ tsconfig paths `@/*` and `@/ui/*` added (tsconfig.json)
68
+ ✔ cn() helper written (src/lib/utils.ts)
69
+ ✔ design tokens written (src/uipkge.css)
70
+ ✔ src/styles.css imports ./uipkge.css
71
+ ✔ components.json written
72
+ ```
73
+
74
+ Then add components — by name, or run `uipkge-ng add` on its own to pick from a searchable list:
75
+
76
+ ```bash
77
+ npx uipkge-ng add card dialog
78
+ npx uipkge-ng list # everything available, ✔ on what you have
79
+ ```
80
+
81
+ Dark mode follows a `dark` class on `<html>`.
82
+
83
+ `@uipkge/ng` and `uipkge` are aliases of the same CLI — `npx @uipkge/ng add button` and `npx uipkge add button` work too.
84
+
85
+ ## Commands
86
+
87
+ | Command | |
88
+ | --- | --- |
89
+ | [`init`](#init) | set up the project |
90
+ | [`add [items...]`](#add) | add components and their dependencies |
91
+ | [`list [@registry] [query]`](#list) | browse components (alias `ls`) |
92
+ | [`view <items...>`](#view) | show a component's details and source |
93
+ | [`diff [item]`](#diff) | compare your copies with the registry |
94
+ | [`info`](#info) | project, config and registry details |
95
+ | [`build [registry.json]`](#publishing-your-own-registry) | build your own registry |
96
+ | [`mcp`](#ai-assistants-mcp) | MCP server for AI assistants |
97
+
98
+ Items can be names (`button`), named-registry items (`@acme/card`), item URLs (`https://…/card.json`) or local item files (`./card.json`).
99
+
100
+ ### init
101
+
102
+ Sets the project up once:
103
+
104
+ - installs **Tailwind CSS v4** if it's missing (`tailwindcss`, `@tailwindcss/postcss`, `postcss`) and creates or extends your PostCSS config
105
+ - adds the `@/*` and `@/ui/*` paths to your tsconfig — comments, formatting and existing paths are kept
106
+ - writes the `cn()` helper to `src/lib/utils.ts`
107
+ - writes the design tokens to `src/uipkge.css` and imports them from your global stylesheet (found in `angular.json`). With a Sass/Less stylesheet, `src/uipkge.css` is added to the app's `styles` in `angular.json` instead
108
+ - writes `components.json`
109
+
110
+ Nothing on the network can fail halfway: everything is fetched before the project is touched. Re-running is safe; `--overwrite` rewrites the foundation files.
111
+
112
+ ### add
113
+
114
+ ```bash
115
+ uipkge-ng add button dialog
116
+ uipkge-ng add --all
117
+ uipkge-ng add @acme/card
118
+ ```
119
+
120
+ Adds the components and everything they depend on — other components first, then npm packages. Components you already have are left as they are.
121
+
122
+ | Flag | |
123
+ | --- | --- |
124
+ | `--dry-run` | list every file that would be created, overwritten or kept, the npm packages and extras — nothing is written |
125
+ | `--view[=<path>]` | print the files that would be written (optionally only those matching `<path>`) |
126
+ | `--diff[=<path>]` | show how they'd differ from your files |
127
+ | `-p, --path <dir>` | put the requested components in `<dir>` (their dependencies stay in the usual folder) |
128
+ | `-o, --overwrite` | replace existing files |
129
+ | `-a, --all` | add every component |
130
+ | `-y, --yes` | don't ask for confirmation |
131
+
132
+ Unknown names stop the command before anything changes. Items can also carry install-time extras, which `add` applies:
133
+
134
+ - `cssVars` / `css` → a marked block in `src/uipkge.css`, replaced (not duplicated) on reinstall; colour variables are registered with Tailwind, so `bg-<name>` works
135
+ - `envVars` → appended to `.env`; existing keys are never touched
136
+ - `docs` → printed after install
137
+
138
+ ### list
139
+
140
+ ```bash
141
+ uipkge-ng list # grouped by category
142
+ uipkge-ng list date # search names and descriptions
143
+ uipkge-ng list --category form
144
+ uipkge-ng list --installed
145
+ uipkge-ng list @acme # a named registry
146
+ ```
147
+
148
+ Works outside a project too. `--json` for scripts.
149
+
150
+ ### view
151
+
152
+ ```bash
153
+ uipkge-ng view date-picker
154
+ ```
155
+
156
+ Everything an install would bring — description, dependencies, notes and the full source of every file — without installing. `--json` prints the raw item.
157
+
158
+ ### diff
159
+
160
+ ```bash
161
+ uipkge-ng diff # which installed components differ from the registry
162
+ uipkge-ng diff button # the unified diff for one
163
+ ```
164
+
165
+ Checks what you installed from the default registry and from every named one. Takes any item form: `button`, `@acme/card`, a URL or a local file. Line-ending differences are ignored. To take the registry version, `uipkge-ng add button --overwrite`.
166
+
167
+ ### info
168
+
169
+ Prints the CLI and Node versions, the project (app, Angular, package manager, Tailwind, PostCSS), `components.json`, the folders your aliases resolve to, whether the registry is reachable and what's installed. Paste it into bug reports; `--json` for machines.
170
+
171
+ ## Existing projects
172
+
173
+ uipkge-ng is built to be run on apps that already have code in them:
174
+
175
+ - **Your files are never overwritten silently.** If a file `add` would write already exists with different content — say your own `button.component.ts` — it stops before writing anything. Interactively it asks; otherwise re-run with `--overwrite`, or install elsewhere with `--path src/app/uipkge`.
176
+ - **Your styles stay yours.** Tokens live in their own file (`src/uipkge.css`); your stylesheet only gains one import line.
177
+ - **Your tsconfig stays yours.** Existing paths are kept. If `@/*` already points somewhere else, uipkge's path is added as a fallback and the helper goes where your mapping resolves.
178
+ - **Your `utils.ts` stays yours.** If `src/lib/utils.ts` exists, `init` keeps it and warns if it doesn't export `cn()`.
179
+ - **Try first.** `add --dry-run` shows exactly what would change, `add --diff` shows how.
180
+
181
+ ## Workspaces and monorepos
182
+
183
+ **Angular multi-project workspaces** (`ng new --create-application=false`, apps under `projects/`): run uipkge-ng inside the app's folder, or pass it with `-c`:
184
+
185
+ ```bash
186
+ npx uipkge-ng init -c projects/admin
187
+ npx uipkge-ng add button -c projects/admin
188
+ ```
189
+
190
+ Each app gets its own `components.json`, tokens and paths — written to its `tsconfig.app.json` and `tsconfig.spec.json`, keeping the paths it inherits. Packages are installed once, for the workspace. At the workspace root, uipkge-ng asks you to choose rather than guess.
191
+
192
+ **npm, pnpm, yarn and bun workspaces**: run it in the app (`apps/web`). The package manager comes from the workspace's lockfile.
193
+
194
+ **Nx** isn't supported yet.
195
+
196
+ ## components.json
197
+
198
+ `init` writes it; you can edit it by hand.
199
+
200
+ ```json
201
+ {
202
+ "framework": "angular",
203
+ "registryUrl": "https://uipkge.dev/r/angular",
204
+ "styles": "src/styles.css",
205
+ "tokens": "src/uipkge.css",
206
+ "aliases": {
207
+ "ui": "@/ui",
208
+ "lib": "@/lib",
209
+ "blocks": "@/app/components/blocks"
210
+ },
211
+ "registries": {}
212
+ }
213
+ ```
214
+
215
+ | Field | |
216
+ | --- | --- |
217
+ | `registryUrl` | the default registry (the `UIPKGE_REGISTRY_URL` environment variable overrides it) |
218
+ | `styles` | your global stylesheet, which imports the tokens |
219
+ | `tokens` | the file uipkge owns for design tokens and item CSS |
220
+ | `aliases` | where components, helpers and blocks go — see [Custom folders](#custom-folders) |
221
+ | `registries` | named and private registries — see [Registries](#registries) |
222
+
223
+ ## Custom folders
224
+
225
+ By default components go to `src/app/components/ui`, helpers to `src/lib` and blocks to `src/app/components/blocks`. For your own layout, point `aliases` at import aliases your tsconfig `paths` map:
226
+
227
+ ```json
228
+ { "aliases": { "ui": "@shared/ui", "lib": "@core/lib", "blocks": "@shared/blocks" } }
229
+ ```
230
+
231
+ ```jsonc
232
+ // tsconfig.json
233
+ "paths": { "@shared/*": ["./src/app/shared/*"], "@core/*": ["./src/app/core/*"] }
234
+ ```
235
+
236
+ Every command then uses those folders, and the `@/ui/…` and `@/lib/…` imports inside component sources are rewritten to your aliases. An alias that `paths` doesn't map is an error, so files never land somewhere unexpected. `uipkge-ng info` shows where each alias resolves.
237
+
238
+ ## Registries
239
+
240
+ Components come from `https://uipkge.dev/r/angular` unless you configure otherwise. Any shadcn-style registry works — a public one, your company's private one, or one you [build yourself](#publishing-your-own-registry).
241
+
242
+ ### Changing the default registry
243
+
244
+ Set `registryUrl` in `components.json`, or `UIPKGE_REGISTRY_URL` in the environment (it wins, which is handy for mirrors and CI):
245
+
246
+ ```json
247
+ { "registryUrl": "https://registry.acme.dev/r" }
248
+ ```
249
+
250
+ Bare names (`uipkge-ng add card`) then come from that registry. It doesn't need to ship the foundation: if it has no `utils` or `tailwind` item, `init` takes uipkge's and says so.
251
+
252
+ ### Named and private registries
253
+
254
+ To use several registries side by side, add them under `registries`; their items are then `@name/item`:
255
+
256
+ ```json
257
+ {
258
+ "registries": {
259
+ "@acme": "https://acme.dev/r/{name}.json",
260
+ "@internal": {
261
+ "url": "https://registry.internal.dev/{name}.json",
262
+ "headers": { "Authorization": "Bearer ${INTERNAL_TOKEN}" },
263
+ "params": { "v": "2" }
264
+ }
265
+ }
266
+ }
267
+ ```
268
+
269
+ ```bash
270
+ uipkge-ng add @acme/card
271
+ uipkge-ng list @internal
272
+ uipkge-ng view @internal/data-table
273
+ ```
274
+
275
+ - `{name}` becomes the item name; `list` reads the index at `{name}` = `registry`.
276
+ - `${VAR}` in headers comes from the environment, then `.env.local`, then `.env`. A missing variable is an error before any request is made.
277
+ - Headers are only ever sent to that registry's own URLs.
278
+ - `list @name`, `info` and `diff` show which of its items you have. Items added from a URL or a file can be checked with `uipkge-ng diff <url-or-file>`.
279
+
280
+ ## Publishing your own registry
281
+
282
+ Describe your items in a `registry.json`, pointing at your source files:
283
+
284
+ ```json
285
+ {
286
+ "name": "acme",
287
+ "homepage": "https://acme.dev",
288
+ "items": [
289
+ {
290
+ "name": "stat-card",
291
+ "type": "registry:ui",
292
+ "description": "A labelled number.",
293
+ "registryDependencies": ["badge"],
294
+ "dependencies": [],
295
+ "files": [
296
+ {
297
+ "path": "src/stat-card/stat-card.ts",
298
+ "type": "registry:ui",
299
+ "target": "~/src/app/components/ui/stat-card/stat-card.ts"
300
+ }
301
+ ]
302
+ }
303
+ ]
304
+ }
305
+ ```
306
+
307
+ ```bash
308
+ npx uipkge-ng build # → public/r/stat-card.json, public/r/registry.json
309
+ npx uipkge-ng build path/to/registry.json --output dist/r
310
+ ```
311
+
312
+ `build` inlines each file's content into `<item>.json` and writes a `registry.json` index. It checks everything first — names, duplicates, missing files, paths or targets outside the project, dependency formats — reports every problem at once and writes nothing until they all pass.
313
+
314
+ In `registryDependencies`, a bare name (`badge`) is a uipkge component — `build` writes it as its full uipkge URL, so it resolves the same in every project, whatever that project's default registry is. For your own items use `@acme/name` or a full URL; a bare name that matches one of your items is reported as an error. Import `cn` from `@/lib/utils` and other components from `@/ui/…` in your sources — installs rewrite those to each project's aliases. Serve the output folder anywhere static and add it to `registries`.
315
+
316
+ ## AI assistants (MCP)
317
+
318
+ `uipkge-ng mcp` runs a read-only [Model Context Protocol](https://modelcontextprotocol.io) server on stdio, so assistants can find components and read their real source instead of guessing APIs:
319
+
320
+ | Tool | |
321
+ | --- | --- |
322
+ | `list_components` | search by query, category or named registry |
323
+ | `view_components` | details, dependencies, notes and full source |
324
+ | `get_add_command` | the `npx uipkge-ng add …` command, checked against the registry |
325
+ | `list_registries` | the default and named registries this project can use |
326
+
327
+ It never writes files — installing stays a command you run. It reads the `components.json` of the directory it starts in, named registries included.
328
+
329
+ ```json
330
+ // .mcp.json — Claude Code, Cursor and others use the same shape
331
+ { "mcpServers": { "uipkge": { "command": "npx", "args": ["-y", "uipkge-ng", "mcp"] } } }
332
+ ```
333
+
334
+ ## CI and scripting
335
+
336
+ - Confirmations are skipped automatically when there's no TTY or `CI` is set; `-y` skips them anywhere.
337
+ - Without a TTY, `add` with no names is an error — pass names or `--all`.
338
+ - A file conflict fails with exit code 1 and nothing written; pass `--overwrite` if replacing is intended.
339
+ - `-s, --silent` prints only errors (package-manager output appears only if it fails).
340
+ - `list`, `view` and `info` take `--json`.
341
+ - An option a command doesn't use is an error, not ignored — `init --dry-run` fails rather than initializing.
342
+ - `NO_COLOR` turns colours off.
343
+
344
+ | Option | |
345
+ | --- | --- |
346
+ | `-c, --cwd <dir>` | run in another directory |
347
+ | `-y, --yes` | don't ask for confirmation |
348
+ | `-s, --silent` | only print errors |
349
+ | `--debug` | stack traces on errors |
350
+ | `-h, --help` / `-v, --version` | |
351
+
352
+ ## Troubleshooting
353
+
354
+ | Message | Fix |
355
+ | --- | --- |
356
+ | `No components.json found` | run `uipkge-ng init` first |
357
+ | `This workspace has 2 apps: …` | run inside the app's folder, or pass `-c projects/<app>` |
358
+ | `N files already exist with different content` | keep yours and use `--path`, or replace with `--overwrite` |
359
+ | `The "ui" alias "…" is not mapped in tsconfig.json "paths"` | add the mapping, or reset `aliases` to the defaults |
360
+ | `Registry "@acme" needs the environment variable ACME_TOKEN` | set it in your shell, `.env.local` or `.env` |
361
+ | `Nx workspaces are not supported yet` | use a standalone Angular CLI app for now |
362
+
363
+ Still stuck? Include the output of `npx uipkge-ng info` when you [open an issue](https://github.com/uday-a/uipkge-cli/issues).
364
+
365
+ ## Credits
366
+
367
+ Created by **Uday Adaka** as part of **[uipkge](https://uipkge.dev)**. It uses the [shadcn/ui](https://ui.shadcn.com) registry format, so any registry in that format can serve Angular items to it.
368
+
369
+ ## License
370
+
371
+ [MIT](./LICENSE) © 2026 Uday Adaka — uipkge. You can use, change and redistribute it freely; keep the copyright and license notice in copies of the CLI.
package/dist/args.js ADDED
@@ -0,0 +1,7 @@
1
+ /**
2
+ * `--view` / `--diff` take an optional value (`--view` or `--view=src/x.ts`),
3
+ * which node's parseArgs can't express; a bare flag becomes `--flag=` (all files).
4
+ */
5
+ export function normalizeOptionalValues(argv) {
6
+ return argv.map(arg => (arg === '--view' || arg === '--diff' ? `${arg}=` : arg));
7
+ }
@@ -0,0 +1,216 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import * as path from 'node:path';
3
+ import { registryFor, requireConfig } from '../config.js';
4
+ import { UipkgeError } from '../errors.js';
5
+ import { applyCss, applyEnv, envKeys, hasCss } from '../extras.js';
6
+ import { conflictingFiles, isItemInstalled, targetedFiles, writeItemFiles } from '../files.js';
7
+ import { adaptItem, resolveLayout } from '../layout.js';
8
+ import { color, log, plural } from '../output.js';
9
+ import { installPackages, missingPackages, pinAngular } from '../packages.js';
10
+ import { declaredDependencies, loadProject, resolveTarget } from '../project.js';
11
+ import { confirm, isInteractive, pickComponents } from '../prompts.js';
12
+ import { planInstall } from '../resolve.js';
13
+ import { colorPatch, compareItem } from './diff.js';
14
+ import { firstSentence, isListable } from './list.js';
15
+ export const DEFAULT_UI_DIR = 'src/app/components/ui';
16
+ /**
17
+ * `--path shared/ui` moves an item's files from the UI folder to `shared/ui`,
18
+ * keeping the per-component subfolder. Files outside the UI folder (e.g.
19
+ * `src/lib/…`) stay put. Only requested items move: their dependencies stay in
20
+ * the UI folder, where the `@/ui/*` imports expect them.
21
+ */
22
+ export function relocate(item, dir, uiDir = DEFAULT_UI_DIR) {
23
+ const base = dir.replace(/\\/g, '/').replace(/^\.?\/+/, '').replace(/\/+$/, '');
24
+ const from = `~/${uiDir}/`;
25
+ return {
26
+ ...item,
27
+ files: item.files.map(f => (f.target?.startsWith(from) ? { ...f, target: `~/${base}/${f.target.slice(from.length)}` } : f)),
28
+ };
29
+ }
30
+ export async function runAdd(options) {
31
+ const project = await loadProject(options.cwd);
32
+ const { root } = project;
33
+ const config = await requireConfig(root);
34
+ const registry = registryFor(root, config, path.resolve(options.cwd));
35
+ const preview = options.dryRun || options.view !== undefined || options.diff !== undefined;
36
+ const layout = resolveLayout(root, config.aliases);
37
+ let names = [...new Set(options.names)];
38
+ if (options.all || !names.length) {
39
+ const listable = (await registry.index()).items.filter(isListable);
40
+ if (options.all)
41
+ names = listable.map(i => i.name);
42
+ else if (!isInteractive()) {
43
+ throw new UipkgeError('No components given.', 'Pass names (`uipkge-ng add button dialog`) or --all. See `uipkge-ng list`.');
44
+ }
45
+ else {
46
+ names = await pickComponents(listable.map(i => ({
47
+ value: i.name,
48
+ label: i.name,
49
+ hint: isItemInstalled(root, adaptItem(i, layout)) ? 'installed' : firstSentence(i.description ?? '').slice(0, 60),
50
+ })));
51
+ }
52
+ }
53
+ log.step('Resolving components');
54
+ const plan = await planInstall(registry, names, item => isItemInstalled(root, item), options.overwrite || preview, (item, requested) => {
55
+ const adapted = adaptItem(item, layout);
56
+ return requested && options.path ? relocate(adapted, options.path, layout.dirs.ui) : adapted;
57
+ });
58
+ const declared = declaredDependencies(project.packageJson);
59
+ const packages = missingPackages(pinAngular(plan.dependencies, project.angularCore), declared);
60
+ const devPackages = missingPackages(pinAngular(plan.devDependencies, project.angularCore), declared);
61
+ // Previews cover what you asked for, plus dependencies that would actually be new.
62
+ const previewPlan = { ...plan, install: plan.install.filter(i => plan.requested.includes(i.name) || !isItemInstalled(root, i)) };
63
+ if (options.view !== undefined)
64
+ return printView(previewPlan, options.view);
65
+ if (options.diff !== undefined)
66
+ return printDiff(root, previewPlan, options.diff);
67
+ if (options.dryRun)
68
+ return printDryRun(root, config.tokens, plan, packages, devPackages, options.overwrite);
69
+ if (!plan.install.length) {
70
+ log.info(`Already installed: ${plan.skipped.join(', ')}. ${color.dim('Use --overwrite to reinstall.')}`);
71
+ return;
72
+ }
73
+ // Existing files that differ: never write the rest of an item around them.
74
+ let overwrite = options.overwrite;
75
+ const conflicts = overwrite ? [] : plan.install.flatMap(item => conflictingFiles(root, item).map(file => `${file} (${item.name})`));
76
+ if (conflicts.length) {
77
+ const list = conflicts.map(c => ` • ${c}`).join('\n');
78
+ if (options.yes || !isInteractive()) {
79
+ throw new UipkgeError(`${plural(conflicts.length, 'file')} already exist${conflicts.length === 1 ? 's' : ''} with different content:\n${list}`, 'Nothing was changed. Re-run with --overwrite to replace them, or --path <dir> to add the component somewhere else.');
80
+ }
81
+ log.warn(`These files already exist with different content:\n${list}`);
82
+ if (!(await confirm(`Overwrite ${conflicts.length === 1 ? 'it' : 'them'}?`, false))) {
83
+ log.info('Nothing was changed.');
84
+ return;
85
+ }
86
+ overwrite = true;
87
+ }
88
+ if (!options.yes && isInteractive()) {
89
+ log.info(`${color.bold('Will add')} ${plan.install.map(i => i.name).join(', ')}`);
90
+ if (packages.length || devPackages.length)
91
+ log.info(`${color.bold('npm')} ${[...packages, ...devPackages].join(', ')}`);
92
+ if (!(await confirm('Continue?'))) {
93
+ log.info('Nothing was changed.');
94
+ return;
95
+ }
96
+ }
97
+ if (packages.length || devPackages.length) {
98
+ log.step(`Installing ${[...packages, ...devPackages].join(', ')}`);
99
+ await installPackages(project.packageManager, project.packageRoot, packages);
100
+ await installPackages(project.packageManager, project.packageRoot, devPackages, true);
101
+ }
102
+ const kept = [];
103
+ const docs = [];
104
+ const tokensFile = path.join(root, config.tokens);
105
+ for (const item of plan.install) {
106
+ const result = await writeItemFiles(root, item, overwrite);
107
+ kept.push(...result.kept);
108
+ const extras = [];
109
+ if (await applyCss(tokensFile, item))
110
+ extras.push(`css → ${config.tokens}`);
111
+ const envAdded = await applyEnv(path.join(root, '.env'), item);
112
+ if (envAdded.length)
113
+ extras.push(`.env: ${envAdded.join(', ')}`);
114
+ if (item.tailwind)
115
+ log.warn(`${item.name} ships a Tailwind v3 config; Tailwind v4 reads config from CSS, so it was not applied.`);
116
+ if (item.docs)
117
+ docs.push([item.name, item.docs]);
118
+ log.success(`${item.name} ${color.dim(`(${[plural(result.written.length, 'file'), ...extras].join(', ')})`)}`);
119
+ }
120
+ log.blank();
121
+ log.info(`${color.green('✔')} Added ${plural(plan.install.length, 'component')}.`);
122
+ if (plan.skipped.length)
123
+ log.info(color.dim(` Already installed, left as is: ${plan.skipped.join(', ')}`));
124
+ if (kept.length)
125
+ log.info(color.dim(` Existing files kept (use --overwrite to replace): ${kept.join(', ')}`));
126
+ for (const [name, text] of docs) {
127
+ log.blank();
128
+ log.info(`${color.bold(`Notes from ${name}:`)}\n${text.trim()}`);
129
+ }
130
+ }
131
+ function fileStates(root, item, overwrite) {
132
+ return targetedFiles(item).map(f => {
133
+ const abs = resolveTarget(root, f.target);
134
+ const differs = conflictingFiles(root, { ...item, files: [f] }).length > 0;
135
+ return { file: path.relative(root, abs), state: !existsSync(abs) ? 'new' : overwrite ? 'overwrite' : differs ? 'conflict' : 'keep' };
136
+ });
137
+ }
138
+ function printDryRun(root, tokens, plan, packages, devPackages, overwrite) {
139
+ const toWrite = plan.install.filter(item => overwrite || !isItemInstalled(root, item));
140
+ const already = plan.install.filter(item => !overwrite && isItemInstalled(root, item)).map(i => i.name);
141
+ log.info(color.bold('Dry run — nothing will be written.'));
142
+ if (!toWrite.length) {
143
+ log.info(`All of it is already installed: ${[...already, ...plan.skipped].join(', ')}.`);
144
+ return;
145
+ }
146
+ const label = {
147
+ new: color.green('new '),
148
+ overwrite: color.yellow('overwrite'),
149
+ keep: color.dim('keep '),
150
+ conflict: color.red('conflict '),
151
+ };
152
+ for (const item of toWrite) {
153
+ log.blank();
154
+ log.info(color.bold(item.name));
155
+ for (const { file, state } of fileStates(root, item, overwrite))
156
+ log.info(` ${label[state]} ${file}`);
157
+ if (hasCss(item))
158
+ log.info(` ${color.cyan('css ')} block in ${tokens}`);
159
+ if (item.envVars) {
160
+ const envFile = path.join(root, '.env');
161
+ const existing = existsSync(envFile) ? envKeys(readFileSync(envFile, 'utf8')) : new Set();
162
+ const add = Object.keys(item.envVars).filter(k => !existing.has(k));
163
+ if (add.length)
164
+ log.info(` ${color.cyan('env ')} .env: ${add.join(', ')}`);
165
+ }
166
+ if (item.docs)
167
+ log.info(` ${color.cyan('notes ')} shown after install`);
168
+ }
169
+ if (already.length)
170
+ log.info(color.dim(`\nAlready installed (skipped): ${already.join(', ')}`));
171
+ if (packages.length)
172
+ log.info(`\n${color.bold('npm install')} ${packages.join(' ')}`);
173
+ if (devPackages.length)
174
+ log.info(`${color.bold('npm install -D')} ${devPackages.join(' ')}`);
175
+ const conflicts = overwrite ? 0 : toWrite.reduce((n, item) => n + conflictingFiles(root, item).length, 0);
176
+ if (conflicts)
177
+ log.warn(`${plural(conflicts, 'file')} differ${conflicts === 1 ? 's' : ''} from yours; add will stop unless you pass --overwrite.`);
178
+ log.info(color.dim('\nRun again without --dry-run to apply.'));
179
+ }
180
+ function matches(file, filter) {
181
+ return !filter || file.replace(/\\/g, '/').includes(filter.replace(/\\/g, '/').replace(/^\.?\//, ''));
182
+ }
183
+ function printView(plan, filter) {
184
+ let shown = 0;
185
+ for (const item of plan.install) {
186
+ for (const f of targetedFiles(item)) {
187
+ const file = f.target.replace(/^~\//, '');
188
+ if (!matches(file, filter))
189
+ continue;
190
+ shown++;
191
+ process.stdout.write(`${color.bold(`── ${file}`)} ${color.dim(`(${item.name})`)}\n${f.content ?? ''}\n`);
192
+ }
193
+ }
194
+ if (!shown)
195
+ log.info(filter ? `No file matches "${filter}".` : 'No files.');
196
+ }
197
+ async function printDiff(root, plan, filter) {
198
+ let changed = 0;
199
+ let fresh = 0;
200
+ for (const item of plan.install) {
201
+ for (const status of await compareItem(root, item)) {
202
+ if (!matches(status.file, filter))
203
+ continue;
204
+ if (status.status === 'changed') {
205
+ changed++;
206
+ process.stdout.write(`${colorPatch(status.patch)}\n`);
207
+ }
208
+ else if (status.status === 'missing') {
209
+ fresh++;
210
+ log.info(`${color.green('new file')} ${status.file} ${color.dim(`(${item.name})`)}`);
211
+ }
212
+ }
213
+ }
214
+ if (!changed && !fresh)
215
+ log.success(filter ? `No differences in files matching "${filter}".` : 'No differences — your files match the registry.');
216
+ }