uipkge-ng 0.1.0 → 0.1.2

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/CHANGELOG.md ADDED
@@ -0,0 +1,44 @@
1
+ # Changelog
2
+
3
+ All notable changes to `uipkge-ng` (and its aliases `@uipkge/ng` and `uipkge`). This project follows [Semantic Versioning](https://semver.org); while it's 0.x, minor versions may change behaviour.
4
+
5
+ ## 0.1.2 — 2026-09-26
6
+
7
+ ### Added
8
+
9
+ - Nx workspaces: Angular apps are found from their `project.json` (Node apps and libraries are skipped), `@/*` and `@/ui/*` go into `tsconfig.base.json` beside your library paths, and a Sass app gets the tokens in its `project.json`. Works from the workspace root or inside an app.
10
+ - Components added from a URL or a local file are recorded in `components.json` under `sources`, so `list`, `diff`, `info` and `doctor` include them like registry components.
11
+ - `doctor` and `init` warn about a global `* { margin: 0; padding: 0 }` outside a CSS layer — it overrides Tailwind v4's layered utilities and strips every component's spacing — and tell you to move it into `@layer base`.
12
+
13
+ ### Fixed
14
+
15
+ - A project with `@analogjs/vite-plugin-angular` (used for Vitest in Nx and some Angular CLI setups) is no longer mistaken for an Analog app, which pointed Tailwind setup at Vite instead of PostCSS.
16
+
17
+ ## 0.1.1 — 2026-09-26
18
+
19
+ ### Added
20
+
21
+ - `doctor`: checks the setup (Node, Angular app, `components.json`, registries and their tokens, aliases, Tailwind v4 and its build step, design tokens actually loaded, `cn()`, incomplete components, undeclared component packages) and prints the fix for each problem. Exits with 1 on failure; `--json`.
22
+
23
+ ### Fixed
24
+
25
+ - `add` no longer stops half-way on a bundle item — an item with no files of its own, only dependencies, such as `charts`. Bundles now install their dependencies.
26
+ - `add` checks every item before writing anything, so a broken item can't leave a project half-installed.
27
+ - `init` works when the default registry (`registryUrl`) ships only its own components: `utils` and `tailwind` come from uipkge instead, and it says so.
28
+
29
+ ### Changed
30
+
31
+ - Resolving dependencies fetches in parallel: adding all 200 uipkge components resolves in about 3 seconds instead of about 70.
32
+ - `build` writes bare names in `registryDependencies` as uipkge URLs, so they resolve the same in every project whatever its default registry is. A bare name that matches one of the registry's own items is now an error — use `@your-registry/name` or a URL for those.
33
+
34
+ ## 0.1.0 — 2026-09-25
35
+
36
+ First release.
37
+
38
+ - `init`: Tailwind CSS v4, the `cn()` helper, design tokens and tsconfig paths; Sass/Less apps get the tokens registered in `angular.json`.
39
+ - `add`: components with their dependencies, `@angular/*` pinned to the app's version; `--dry-run`, `--view`, `--diff`, `--path`, `--overwrite`; stops before writing when a file you have differs.
40
+ - `list`, `view`, `diff`, `info`.
41
+ - Custom folders through `aliases`, with import rewriting.
42
+ - Named and private registries (`@name/item`, `{name}` URLs, `${ENV}` headers from the environment, `.env.local` and `.env`).
43
+ - `build` for publishing your own registry; `mcp` server for AI assistants.
44
+ - Multi-project Angular workspaces and npm/pnpm/yarn/bun monorepos.
package/README.md CHANGED
@@ -41,7 +41,7 @@ import { UiButtonComponent } from '@/ui/button';
41
41
  ## Requirements
42
42
 
43
43
  - Node.js **20.12** or newer
44
- - An Angular CLI application with standalone components (Analog/Vite supported; Nx workspaces not yet)
44
+ - An Angular CLI application with standalone components — including multi-project workspaces and Nx (Analog/Vite supported)
45
45
  - npm, pnpm, yarn or bun — detected from your lockfile
46
46
 
47
47
  ## Getting started
@@ -92,6 +92,7 @@ Dark mode follows a `dark` class on `<html>`.
92
92
  | [`view <items...>`](#view) | show a component's details and source |
93
93
  | [`diff [item]`](#diff) | compare your copies with the registry |
94
94
  | [`info`](#info) | project, config and registry details |
95
+ | [`doctor`](#doctor) | check the setup and explain how to fix problems |
95
96
  | [`build [registry.json]`](#publishing-your-own-registry) | build your own registry |
96
97
  | [`mcp`](#ai-assistants-mcp) | MCP server for AI assistants |
97
98
 
@@ -168,12 +169,37 @@ Checks what you installed from the default registry and from every named one. Ta
168
169
 
169
170
  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
 
172
+ ### doctor
173
+
174
+ ```bash
175
+ npx uipkge-ng doctor
176
+ ```
177
+
178
+ Checks the whole setup — like `react-native doctor` — and says how to fix anything wrong:
179
+
180
+ ```text
181
+ Styling
182
+ ✔ Tailwind CSS ^4.3.3
183
+ ✔ Tailwind build .postcssrc.json uses @tailwindcss/postcss
184
+ ✖ Design tokens src/uipkge.css isn't loaded
185
+ → Add `@import './uipkge.css';` to src/styles.css, or list src/uipkge.css under "styles" in angular.json.
186
+
187
+ Components
188
+ ! Incomplete components files missing from 1: card
189
+ → Reinstall with `uipkge-ng add card --overwrite`.
190
+ ✖ Component packages not in package.json: class-variance-authority
191
+ → Run `npm install class-variance-authority`.
192
+ ```
193
+
194
+ It checks Node, the Angular app (and workspace), `components.json`, that the registry and every named registry answer (and their tokens are set), that your aliases resolve, Tailwind v4 and its build step, that the design tokens are actually loaded, the `cn()` helper, global CSS resets that would override Tailwind (`* { padding: 0 }` outside a layer), components with missing files, and npm packages your components need but `package.json` doesn't list. It changes nothing. It exits with 1 when something fails, so it can run in CI; `--json` for scripts.
195
+
171
196
  ## Existing projects
172
197
 
173
198
  uipkge-ng is built to be run on apps that already have code in them:
174
199
 
175
200
  - **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
201
  - **Your styles stay yours.** Tokens live in their own file (`src/uipkge.css`); your stylesheet only gains one import line.
202
+ - **Your global resets are checked.** Tailwind v4 keeps its utilities in a CSS layer, and CSS outside a layer always wins — so a `* { margin: 0; padding: 0 }` reset (common in older apps and in some starters) strips every component's spacing. `init` and `doctor` point it out; wrap it in `@layer base { … }` and everything spaces correctly again.
177
203
  - **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
204
  - **Your `utils.ts` stays yours.** If `src/lib/utils.ts` exists, `init` keeps it and warns if it doesn't export `cn()`.
179
205
  - **Try first.** `add --dry-run` shows exactly what would change, `add --diff` shows how.
@@ -191,7 +217,7 @@ Each app gets its own `components.json`, tokens and paths — written to its `ts
191
217
 
192
218
  **npm, pnpm, yarn and bun workspaces**: run it in the app (`apps/web`). The package manager comes from the workspace's lockfile.
193
219
 
194
- **Nx** isn't supported yet.
220
+ **Nx** workspaces work from the workspace root or inside an app (`apps/shop`). Angular apps are found from their `project.json` — Node apps and libraries are skipped — and `@/*` / `@/ui/*` go into `tsconfig.base.json` next to your library paths, where Nx keeps them. A Sass app gets the tokens in its `project.json` `styles`.
195
221
 
196
222
  ## components.json
197
223
 
@@ -219,6 +245,7 @@ Each app gets its own `components.json`, tokens and paths — written to its `ts
219
245
  | `tokens` | the file uipkge owns for design tokens and item CSS |
220
246
  | `aliases` | where components, helpers and blocks go — see [Custom folders](#custom-folders) |
221
247
  | `registries` | named and private registries — see [Registries](#registries) |
248
+ | `sources` | written by `add`: where components added from a URL or a local file came from, so `list`, `diff`, `info` and `doctor` include them |
222
249
 
223
250
  ## Custom folders
224
251
 
@@ -237,7 +264,7 @@ Every command then uses those folders, and the `@/ui/…` and `@/lib/…` import
237
264
 
238
265
  ## Registries
239
266
 
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).
267
+ Out of the box everything comes from the uipkge registry, `https://uipkge.dev/r/angular` — no setup needed. Any shadcn-style registry works — a public one, your company's private one, or one you [build yourself](#publishing-your-own-registry).
241
268
 
242
269
  ### Changing the default registry
243
270
 
@@ -275,7 +302,7 @@ uipkge-ng view @internal/data-table
275
302
  - `{name}` becomes the item name; `list` reads the index at `{name}` = `registry`.
276
303
  - `${VAR}` in headers comes from the environment, then `.env.local`, then `.env`. A missing variable is an error before any request is made.
277
304
  - 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>`.
305
+ - `list @name`, `info` and `diff` show which of its items you have. Components added from a URL or a local file are recorded under `sources`, so `list` (in their own group), `diff`, `info` and `doctor` include them too.
279
306
 
280
307
  ## Publishing your own registry
281
308
 
@@ -349,6 +376,12 @@ It never writes files — installing stays a command you run. It reads the `comp
349
376
  | `--debug` | stack traces on errors |
350
377
  | `-h, --help` / `-v, --version` | |
351
378
 
379
+ ## Known limits
380
+
381
+ - **Tested by hand on macOS.** CI runs Linux, macOS and Windows on every push (Windows doesn't block yet).
382
+ - **Analog** apps build with Vite: `init` installs `@tailwindcss/vite`, but you add `tailwindcss()` to `vite.config.ts` yourself.
383
+ - `--path` moves only the components you name; their dependencies stay in the default folder so imports keep resolving.
384
+
352
385
  ## Troubleshooting
353
386
 
354
387
  | Message | Fix |
@@ -358,9 +391,8 @@ It never writes files — installing stays a command you run. It reads the `comp
358
391
  | `N files already exist with different content` | keep yours and use `--path`, or replace with `--overwrite` |
359
392
  | `The "ui" alias "…" is not mapped in tsconfig.json "paths"` | add the mapping, or reset `aliases` to the defaults |
360
393
  | `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
394
 
363
- Still stuck? Include the output of `npx uipkge-ng info` when you [open an issue](https://github.com/uday-a/uipkge-cli/issues).
395
+ Start with `npx uipkge-ng doctor` — it finds most setup problems and tells you the fix. Still stuck? Include the output of `npx uipkge-ng info` when you [open an issue](https://github.com/uday-a/uipkge-cli/issues).
364
396
 
365
397
  ## Credits
366
398
 
@@ -368,4 +400,6 @@ Created by **Uday Adaka** as part of **[uipkge](https://uipkge.dev)**. It uses t
368
400
 
369
401
  ## License
370
402
 
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.
403
+ [MIT](./LICENSE) © 2026 Uday Adaka — uipkge.
404
+
405
+ In plain words: you can use it for anything — personal or commercial — fork it, change it, and sell or redistribute it, including your modified version. The one condition: keep the copyright line (Uday Adaka, uipkge) and the license text in every copy.
@@ -1,14 +1,15 @@
1
1
  import { existsSync, readFileSync } from 'node:fs';
2
2
  import * as path from 'node:path';
3
- import { registryFor, requireConfig } from '../config.js';
3
+ import { registryFor, requireConfig, toSource, writeConfig } from '../config.js';
4
4
  import { UipkgeError } from '../errors.js';
5
5
  import { applyCss, applyEnv, envKeys, hasCss } from '../extras.js';
6
- import { conflictingFiles, isItemInstalled, targetedFiles, writeItemFiles } from '../files.js';
6
+ import { assertWritable, conflictingFiles, isItemInstalled, targetedFiles, writeItemFiles } from '../files.js';
7
7
  import { adaptItem, resolveLayout } from '../layout.js';
8
8
  import { color, log, plural } from '../output.js';
9
9
  import { installPackages, missingPackages, pinAngular } from '../packages.js';
10
10
  import { declaredDependencies, loadProject, resolveTarget } from '../project.js';
11
11
  import { confirm, isInteractive, pickComponents } from '../prompts.js';
12
+ import { refKind } from '../registry.js';
12
13
  import { planInstall } from '../resolve.js';
13
14
  import { colorPatch, compareItem } from './diff.js';
14
15
  import { firstSentence, isListable } from './list.js';
@@ -66,10 +67,29 @@ export async function runAdd(options) {
66
67
  return printDiff(root, previewPlan, options.diff);
67
68
  if (options.dryRun)
68
69
  return printDryRun(root, config.tokens, plan, packages, devPackages, options.overwrite);
70
+ /** Remember where URL/file components came from, so diff/info/doctor can find them later. */
71
+ const recordSources = async () => {
72
+ const cwd = path.resolve(options.cwd);
73
+ let changed = false;
74
+ for (const ref of names.filter(n => refKind(n) === 'url' || refKind(n) === 'file')) {
75
+ const { name } = await registry.item(ref);
76
+ const source = toSource(root, cwd, ref);
77
+ if (config.sources?.[name] !== source) {
78
+ config.sources = { ...config.sources, [name]: source };
79
+ changed = true;
80
+ }
81
+ }
82
+ if (changed)
83
+ await writeConfig(root, config);
84
+ };
69
85
  if (!plan.install.length) {
86
+ await recordSources();
70
87
  log.info(`Already installed: ${plan.skipped.join(', ')}. ${color.dim('Use --overwrite to reinstall.')}`);
71
88
  return;
72
89
  }
90
+ // Every item must be writable before anything is written or installed.
91
+ for (const item of plan.install)
92
+ assertWritable(root, item);
73
93
  // Existing files that differ: never write the rest of an item around them.
74
94
  let overwrite = options.overwrite;
75
95
  const conflicts = overwrite ? [] : plan.install.flatMap(item => conflictingFiles(root, item).map(file => `${file} (${item.name})`));
@@ -115,8 +135,10 @@ export async function runAdd(options) {
115
135
  log.warn(`${item.name} ships a Tailwind v3 config; Tailwind v4 reads config from CSS, so it was not applied.`);
116
136
  if (item.docs)
117
137
  docs.push([item.name, item.docs]);
118
- log.success(`${item.name} ${color.dim(`(${[plural(result.written.length, 'file'), ...extras].join(', ')})`)}`);
138
+ const summary = targetedFiles(item).length ? plural(result.written.length, 'file') : 'bundle';
139
+ log.success(`${item.name} ${color.dim(`(${[summary, ...extras].join(', ')})`)}`);
119
140
  }
141
+ await recordSources();
120
142
  log.blank();
121
143
  log.info(`${color.green('✔')} Added ${plural(plan.install.length, 'component')}.`);
122
144
  if (plan.skipped.length)
@@ -2,7 +2,7 @@ import { existsSync } from 'node:fs';
2
2
  import { readFile } from 'node:fs/promises';
3
3
  import * as path from 'node:path';
4
4
  import { createTwoFilesPatch } from 'diff';
5
- import { registryFor, requireConfig } from '../config.js';
5
+ import { fromSource, registryFor, requireConfig } from '../config.js';
6
6
  import { isItemInstalled, targetedFiles, normalizeEol } from '../files.js';
7
7
  import { adaptItem, resolveLayout } from '../layout.js';
8
8
  import { color, log, plural } from '../output.js';
@@ -59,7 +59,7 @@ export async function runDiff(options) {
59
59
  await diffOne(root, adaptItem(await registry.item(options.name), layout), options.name);
60
60
  return;
61
61
  }
62
- await diffAll(root, registry, Object.keys(config.registries ?? {}), layout);
62
+ await diffAll(root, registry, Object.keys(config.registries ?? {}), layout, config.sources ?? {});
63
63
  }
64
64
  async function diffOne(root, item, ref) {
65
65
  const files = await compareItem(root, item);
@@ -110,8 +110,29 @@ export async function installedRefs(root, registry, namespaces, layout) {
110
110
  }));
111
111
  return refs.flat();
112
112
  }
113
- async function diffAll(root, registry, namespaces, layout) {
114
- const installed = await installedRefs(root, registry, namespaces, layout);
113
+ /**
114
+ * Components added from a URL or file (components.json `sources`) that are still installed,
115
+ * as loadable refs mapped to how they're shown (the stored source). A source that can't be
116
+ * loaded any more is reported and skipped.
117
+ */
118
+ export async function installedSources(root, registry, sources, layout) {
119
+ const found = new Map();
120
+ for (const source of Object.values(sources)) {
121
+ const ref = fromSource(root, source);
122
+ try {
123
+ if (isItemInstalled(root, adaptItem(await registry.item(ref), layout)))
124
+ found.set(ref, source);
125
+ }
126
+ catch (error) {
127
+ log.warn(`Skipped ${source}: ${error instanceof Error ? error.message : String(error)}`);
128
+ }
129
+ }
130
+ return found;
131
+ }
132
+ async function diffAll(root, registry, namespaces, layout, sources) {
133
+ const external = await installedSources(root, registry, sources, layout);
134
+ const installed = [...(await installedRefs(root, registry, namespaces, layout)), ...external.keys()];
135
+ const label = (ref) => external.get(ref) ?? ref;
115
136
  if (!installed.length) {
116
137
  log.info('No uipkge components are installed yet. Add one with `uipkge-ng add <name>`.');
117
138
  return;
@@ -133,8 +154,8 @@ async function diffAll(root, registry, namespaces, layout) {
133
154
  }
134
155
  outdated.sort();
135
156
  log.info(`${plural(outdated.length, 'component')} ${outdated.length === 1 ? 'differs' : 'differ'} from the registry:`);
136
- for (const name of outdated)
137
- log.info(` ${color.yellow('•')} ${name}`);
157
+ for (const ref of outdated)
158
+ log.info(` ${color.yellow('•')} ${label(ref)}`);
138
159
  log.blank();
139
160
  log.info(color.dim('See the changes with `uipkge-ng diff <name>`.'));
140
161
  }
@@ -0,0 +1,279 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import * as path from 'node:path';
3
+ import { fromSource, readConfig, registryFor } from '../config.js';
4
+ import { targetedFiles } from '../files.js';
5
+ import { adaptItem, resolveLayout } from '../layout.js';
6
+ import { color } from '../output.js';
7
+ import { installArgs, missingPackages } from '../packages.js';
8
+ import { angularApps, declaredDependencies, detectPackageManager, findApp, readPackageJson, resolveTarget } from '../project.js';
9
+ import { FOUNDATION_ITEMS, namedRegistryFrom } from '../registry.js';
10
+ import { projectEnv } from '../env.js';
11
+ import { TAILWIND_POSTCSS, readPostcssConfig, relativeImport, unlayeredResets } from '../setup.js';
12
+ const MIN_NODE = [20, 12];
13
+ /** `3: a, b, c` or `202: a, b, c, d, e, f … and 196 more`. */
14
+ const summarize = (names, shown = 6) => `${names.length}: ${names.slice(0, shown).join(', ')}${names.length > shown ? ` … and ${names.length - shown} more` : ''}`;
15
+ /**
16
+ * Checks a project's uipkge setup, like `react-native doctor`. Never throws:
17
+ * every problem becomes a failed check with a fix, and later checks that
18
+ * depend on it are skipped rather than failing with noise.
19
+ */
20
+ export async function runChecks(cwd) {
21
+ const checks = [];
22
+ const push = (c) => (checks.push(c), c);
23
+ // ── Environment ──
24
+ const [major = 0, minor = 0] = process.versions.node.split('.').map(Number);
25
+ const nodeOk = major > MIN_NODE[0] || (major === MIN_NODE[0] && minor >= MIN_NODE[1]);
26
+ push({
27
+ section: 'Environment',
28
+ title: 'Node.js',
29
+ status: nodeOk ? 'pass' : 'fail',
30
+ detail: process.version,
31
+ fix: nodeOk ? undefined : `uipkge-ng needs Node ${MIN_NODE.join('.')} or newer.`,
32
+ });
33
+ // ── Project ──
34
+ const location = findApp(path.resolve(cwd));
35
+ if (!location) {
36
+ push({ section: 'Project', title: 'package.json', status: 'fail', detail: 'none found', fix: 'Run this inside your Angular project.' });
37
+ return checks;
38
+ }
39
+ if (location.ambiguous) {
40
+ push({
41
+ section: 'Project',
42
+ title: 'Angular app',
43
+ status: 'fail',
44
+ detail: `this workspace has ${location.ambiguous.length} apps: ${location.ambiguous.map(a => a.name).join(', ')}`,
45
+ fix: `Run it inside one app's folder, or pass it with \`-c ${path.relative(cwd, location.ambiguous[0].root) || '.'}\`.`,
46
+ });
47
+ return checks;
48
+ }
49
+ const { root, packageRoot } = location;
50
+ let pkg;
51
+ try {
52
+ pkg = await readPackageJson(packageRoot);
53
+ }
54
+ catch (error) {
55
+ push({ section: 'Project', title: 'package.json', status: 'fail', detail: error.message });
56
+ return checks;
57
+ }
58
+ const deps = declaredDependencies(pkg);
59
+ const angular = deps['@angular/core'];
60
+ push({
61
+ section: 'Project',
62
+ title: 'Angular',
63
+ status: angular ? 'pass' : 'fail',
64
+ detail: angular ? `${angular}${location.app ? ` (app: ${location.app.name})` : ''}` : 'no @angular/core in package.json',
65
+ fix: angular ? undefined : 'uipkge-ng works with Angular CLI apps.',
66
+ });
67
+ if (!angular)
68
+ return checks;
69
+ if (existsSync(path.join(packageRoot, 'nx.json')))
70
+ push({ section: 'Project', title: 'Workspace', status: 'pass', detail: 'Nx' });
71
+ const pm = detectPackageManager(packageRoot, pkg);
72
+ push({ section: 'Project', title: 'Package manager', status: 'pass', detail: pm });
73
+ // ── Configuration ──
74
+ let config = null;
75
+ try {
76
+ config = await readConfig(root);
77
+ }
78
+ catch (error) {
79
+ push({ section: 'Configuration', title: 'components.json', status: 'fail', detail: error.message, fix: 'Fix the JSON, or run `uipkge-ng init --overwrite`.' });
80
+ return checks;
81
+ }
82
+ if (!config) {
83
+ push({ section: 'Configuration', title: 'components.json', status: 'fail', detail: `not found in ${root}`, fix: 'Run `uipkge-ng init`.' });
84
+ return checks;
85
+ }
86
+ push({ section: 'Configuration', title: 'components.json', status: 'pass', detail: path.relative(cwd, path.join(root, 'components.json')) || 'components.json' });
87
+ const registry = registryFor(root, config, cwd);
88
+ let index = null;
89
+ try {
90
+ index = (await registry.index()).items;
91
+ push({ section: 'Configuration', title: 'Registry', status: 'pass', detail: `${registry.baseUrl} (${index.length} items)` });
92
+ }
93
+ catch (error) {
94
+ push({
95
+ section: 'Configuration',
96
+ title: 'Registry',
97
+ status: 'fail',
98
+ detail: `${registry.baseUrl}: ${error.message}`,
99
+ fix: 'Check your connection, "registryUrl" in components.json, or UIPKGE_REGISTRY_URL.',
100
+ });
101
+ }
102
+ const env = projectEnv(root);
103
+ for (const [ns, raw] of Object.entries(config.registries ?? {})) {
104
+ try {
105
+ namedRegistryFrom(ns, raw, env);
106
+ await registry.index(ns);
107
+ push({ section: 'Configuration', title: `Registry ${ns}`, status: 'pass' });
108
+ }
109
+ catch (error) {
110
+ push({
111
+ section: 'Configuration',
112
+ title: `Registry ${ns}`,
113
+ status: 'fail',
114
+ detail: error.message,
115
+ fix: `Check "${ns}" under "registries" in components.json; tokens come from the environment, .env.local or .env.`,
116
+ });
117
+ }
118
+ }
119
+ let layout = null;
120
+ try {
121
+ layout = resolveLayout(root, config.aliases);
122
+ push({ section: 'Configuration', title: 'Folders (aliases)', status: 'pass', detail: `ui → ${layout.dirs.ui}, lib → ${layout.dirs.lib}` });
123
+ }
124
+ catch (error) {
125
+ push({ section: 'Configuration', title: 'Folders (aliases)', status: 'fail', detail: error.message, fix: 'Map the aliases in tsconfig "paths", or run `uipkge-ng init --overwrite`.' });
126
+ }
127
+ // ── Styling ──
128
+ const tailwind = deps['tailwindcss'];
129
+ const tailwindMajor = Number(tailwind?.match(/\d+/)?.[0] ?? 0);
130
+ push({
131
+ section: 'Styling',
132
+ title: 'Tailwind CSS',
133
+ status: tailwindMajor >= 4 ? 'pass' : 'fail',
134
+ detail: tailwind ?? 'not installed',
135
+ fix: tailwindMajor >= 4 ? undefined : tailwind ? 'uipkge components need Tailwind CSS v4.' : 'Run `uipkge-ng init`.',
136
+ });
137
+ const analog = Boolean(deps['@analogjs/platform']);
138
+ if (analog) {
139
+ push({ section: 'Styling', title: 'Tailwind build', status: 'warn', detail: 'Analog builds with Vite', fix: "Check vite.config.ts has `tailwindcss()` from '@tailwindcss/vite' in plugins." });
140
+ }
141
+ else {
142
+ const postcss = await readPostcssConfig(packageRoot);
143
+ const wired = postcss?.content.includes(TAILWIND_POSTCSS);
144
+ push({
145
+ section: 'Styling',
146
+ title: 'Tailwind build',
147
+ status: wired ? 'pass' : 'fail',
148
+ detail: wired ? `${postcss.file} uses ${TAILWIND_POSTCSS}` : postcss ? `${postcss.file} doesn't use ${TAILWIND_POSTCSS}` : 'no PostCSS config',
149
+ fix: wired ? undefined : `Add "${TAILWIND_POSTCSS}" to your PostCSS plugins, or run \`uipkge-ng init --overwrite\`.`,
150
+ });
151
+ }
152
+ const tokensFile = path.join(root, config.tokens);
153
+ if (!existsSync(tokensFile)) {
154
+ push({ section: 'Styling', title: 'Design tokens', status: 'fail', detail: `${config.tokens} is missing`, fix: 'Run `uipkge-ng init --overwrite`.' });
155
+ }
156
+ else {
157
+ // Loaded either by an @import in the global stylesheet or as its own entry in angular.json "styles".
158
+ const importPath = relativeImport(config.styles, config.tokens);
159
+ const stylesFile = path.join(root, config.styles);
160
+ const imported = existsSync(stylesFile) && new RegExp(`@import\\s+(url\\()?['"]${importPath.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&')}['"]`).test(readFileSync(stylesFile, 'utf8'));
161
+ const app = location.app ?? angularApps(packageRoot).find(a => a.root === root);
162
+ const registered = Boolean(app?.styles.some(s => path.resolve(root, s) === tokensFile));
163
+ push({
164
+ section: 'Styling',
165
+ title: 'Design tokens',
166
+ status: imported || registered ? 'pass' : 'fail',
167
+ detail: imported ? `${config.styles} imports ${importPath}` : registered ? `${config.tokens} is in angular.json "styles"` : `${config.tokens} isn't loaded`,
168
+ fix: imported || registered ? undefined : `Add \`@import '${importPath}';\` to ${config.styles}, or list ${config.tokens} under "styles" in angular.json.`,
169
+ });
170
+ }
171
+ const globalStyles = path.join(root, config.styles);
172
+ if (existsSync(globalStyles)) {
173
+ const resets = unlayeredResets(readFileSync(globalStyles, 'utf8'));
174
+ push({
175
+ section: 'Styling',
176
+ title: 'Global resets',
177
+ status: resets.length ? 'warn' : 'pass',
178
+ detail: resets.length
179
+ ? `${config.styles} zeroes margin/padding on \`${resets[0]}\` outside a CSS layer, which overrides Tailwind's utilities (components lose their spacing)`
180
+ : 'none that override Tailwind',
181
+ fix: resets.length ? `Move that rule into \`@layer base { … }\` in ${config.styles}.` : undefined,
182
+ });
183
+ }
184
+ if (layout) {
185
+ const utils = path.join(root, layout.dirs.lib, 'utils.ts');
186
+ const hasCn = existsSync(utils) && /export\s+(?:function|const|let)\s+cn\b|export\s*\{[^}]*\bcn\b[^}]*\}/.test(readFileSync(utils, 'utf8'));
187
+ push({
188
+ section: 'Styling',
189
+ title: 'cn() helper',
190
+ status: hasCn ? 'pass' : 'fail',
191
+ detail: hasCn ? path.relative(root, utils) : existsSync(utils) ? `${path.relative(root, utils)} doesn't export cn()` : `${path.relative(root, utils)} is missing`,
192
+ fix: hasCn ? undefined : 'Every component imports it. Run `uipkge-ng init --overwrite`, or add it (`uipkge-ng view utils`).',
193
+ });
194
+ }
195
+ // ── Components ──
196
+ if (!index || !layout) {
197
+ push({ section: 'Components', title: 'Installed components', status: 'skip', detail: 'needs the registry and folders above' });
198
+ return checks;
199
+ }
200
+ // Registry components, plus those added from a URL or file (recorded in `sources`).
201
+ const candidates = index.filter(i => !FOUNDATION_ITEMS.has(i.name));
202
+ for (const [name, source] of Object.entries(config.sources ?? {})) {
203
+ try {
204
+ candidates.push(await registry.item(fromSource(root, source)));
205
+ }
206
+ catch (error) {
207
+ push({
208
+ section: 'Components',
209
+ title: `Source of ${name}`,
210
+ status: 'warn',
211
+ detail: `${source}: ${error.message}`,
212
+ fix: `Fix or remove "${name}" under "sources" in components.json.`,
213
+ });
214
+ }
215
+ }
216
+ const installed = [];
217
+ const partial = [];
218
+ for (const item of candidates) {
219
+ const files = targetedFiles(adaptItem(item, layout));
220
+ if (!files.length)
221
+ continue;
222
+ const present = files.filter(f => existsSync(resolveTarget(root, f.target))).length;
223
+ if (present === files.length)
224
+ installed.push(item);
225
+ else if (present > 0)
226
+ partial.push(item.name);
227
+ }
228
+ push({ section: 'Components', title: 'Installed components', status: 'pass', detail: installed.length ? summarize(installed.map(i => i.name)) : 'none yet' });
229
+ if (partial.length) {
230
+ push({
231
+ section: 'Components',
232
+ title: 'Incomplete components',
233
+ status: 'warn',
234
+ detail: `files missing from ${summarize(partial)}`,
235
+ fix: `Reinstall with \`uipkge-ng add ${partial.join(' ')} --overwrite\`.`,
236
+ });
237
+ }
238
+ const missing = missingPackages(installed.flatMap(i => i.dependencies ?? []), deps);
239
+ push({
240
+ section: 'Components',
241
+ title: 'Component packages',
242
+ status: missing.length ? 'fail' : 'pass',
243
+ detail: missing.length ? `not in package.json: ${missing.join(', ')}` : 'all declared',
244
+ fix: missing.length ? `Run \`${pm} ${installArgs(pm, missing, false).join(' ')}\`.` : undefined,
245
+ });
246
+ return checks;
247
+ }
248
+ const MARK = {
249
+ pass: color.green('✔'),
250
+ warn: color.yellow('!'),
251
+ fail: color.red('✖'),
252
+ skip: color.dim('-'),
253
+ };
254
+ export async function runDoctor(options) {
255
+ const checks = await runChecks(options.cwd);
256
+ const failed = checks.filter(c => c.status === 'fail').length;
257
+ const warned = checks.filter(c => c.status === 'warn').length;
258
+ if (failed)
259
+ process.exitCode = 1;
260
+ if (options.json) {
261
+ process.stdout.write(`${JSON.stringify({ ok: failed === 0, checks }, null, 2)}\n`);
262
+ return;
263
+ }
264
+ const width = Math.max(...checks.map(c => c.title.length)) + 2;
265
+ const out = [color.bold('uipkge-ng doctor')];
266
+ let section = '';
267
+ for (const c of checks) {
268
+ if (c.section !== section)
269
+ out.push('', color.bold(c.section)), (section = c.section);
270
+ out.push(` ${MARK[c.status]} ${c.title.padEnd(width)}${c.detail ? color.dim(c.detail) : ''}`);
271
+ if (c.fix && (c.status === 'fail' || c.status === 'warn'))
272
+ out.push(` ${' '.repeat(width)}${color.cyan('→')} ${c.fix}`);
273
+ }
274
+ const passed = checks.filter(c => c.status === 'pass').length;
275
+ out.push('', failed
276
+ ? color.red(`${failed} problem${failed === 1 ? '' : 's'} found`) + color.dim(` · ${passed} passed${warned ? ` · ${warned} warning${warned === 1 ? '' : 's'}` : ''}`)
277
+ : color.green('Everything looks good.') + color.dim(` ${passed} checks passed${warned ? ` · ${warned} warning${warned === 1 ? '' : 's'}` : ''}`), '');
278
+ process.stdout.write(out.join('\n'));
279
+ }
@@ -2,6 +2,7 @@ import { existsSync } from 'node:fs';
2
2
  import * as path from 'node:path';
3
3
  import { readConfig, registryFor } from '../config.js';
4
4
  import { isItemInstalled } from '../files.js';
5
+ import { installedSources } from './diff.js';
5
6
  import { adaptItem, resolveLayout } from '../layout.js';
6
7
  import { color } from '../output.js';
7
8
  import { declaredDependencies, detectPackageManager, findApp, readPackageJson } from '../project.js';
@@ -26,7 +27,7 @@ export async function collectInfo(cwd, version) {
26
27
  app: location?.app?.name ?? null,
27
28
  apps: location?.ambiguous?.map(a => a.name) ?? null,
28
29
  angular: deps['@angular/core'] ?? null,
29
- analog: Boolean(deps['@analogjs/platform'] || deps['@analogjs/vite-plugin-angular']),
30
+ analog: Boolean(deps['@analogjs/platform']),
30
31
  nx: Boolean(deps['nx'] || (packageRoot && existsSync(path.join(packageRoot, 'nx.json')))),
31
32
  packageManager: packageRoot && pkg ? detectPackageManager(packageRoot, pkg) : null,
32
33
  tailwind: deps['tailwindcss'] ?? null,
@@ -61,6 +62,13 @@ export async function collectInfo(cwd, version) {
61
62
  catch (error) {
62
63
  info.registry.error = error instanceof Error ? error.message : String(error);
63
64
  }
65
+ // Components added from a URL or file, recorded by `add`.
66
+ if (root && config?.sources && layout) {
67
+ const found = new Set((await installedSources(root, registryFor(root, config, cwd), config.sources, layout)).values());
68
+ for (const [name, source] of Object.entries(config.sources))
69
+ if (found.has(source))
70
+ info.installed.push(`${name} (${source})`);
71
+ }
64
72
  // Named registries: best effort, an unreachable one just adds nothing.
65
73
  if (root && config?.registries) {
66
74
  const registry = registryFor(root, config, cwd);
@@ -98,7 +106,7 @@ export async function runInfo(options) {
98
106
  row('package manager', info.project.packageManager ?? '—'),
99
107
  row('tailwind', yes(info.project.tailwind)),
100
108
  row('postcss config', yes(info.project.postcssConfig)),
101
- row('analog / nx', `${info.project.analog ? 'analog' : 'no'} / ${info.project.nx ? color.yellow('nx (not supported yet)') : 'no'}`),
109
+ row('analog / nx', `${info.project.analog ? 'analog' : 'no'} / ${info.project.nx ? 'nx' : 'no'}`),
102
110
  '',
103
111
  color.bold('components.json'),
104
112
  info.config ? JSON.stringify(info.config, null, 2).split('\n').map(l => ` ${l}`).join('\n') : row('status', color.yellow('not initialized — run `uipkge-ng init`')),
@@ -7,10 +7,10 @@ import { writeItemFiles } from '../files.js';
7
7
  import { adaptItem, resolveLayout } from '../layout.js';
8
8
  import { color, log } from '../output.js';
9
9
  import { installPackages, missingPackages } from '../packages.js';
10
- import { declaredDependencies, loadProject } from '../project.js';
10
+ import { declaredDependencies, isNxWorkspace, loadProject } from '../project.js';
11
11
  import { confirm, isInteractive } from '../prompts.js';
12
12
  import { createRegistry, DEFAULT_REGISTRY_URL, NotFoundError, resolveRegistryUrl } from '../registry.js';
13
- import { addGlobalStyle, appTsconfigEdit, planTailwind, readPostcssConfig, relativeImport, withTailwindPlugin, tsconfigsToEdit, withTokensImport, writeIfChanged, } from '../setup.js';
13
+ import { addGlobalStyle, appTsconfigEdit, planTailwind, readPostcssConfig, relativeImport, withTailwindPlugin, tsconfigsToEdit, unlayeredResets, withTokensImport, writeIfChanged, } from '../setup.js';
14
14
  export async function runInit(options) {
15
15
  const project = await loadProject(options.cwd);
16
16
  const { root, packageRoot } = project;
@@ -74,14 +74,17 @@ export async function runInit(options) {
74
74
  done.push('Tailwind CSS v4 installed (add the Vite plugin)');
75
75
  }
76
76
  // 3. tsconfig aliases the component sources import.
77
- const tsconfigs = tsconfigsToEdit(root);
77
+ // Nx keeps every path in the shared tsconfig.base.json: an app tsconfig with its own `paths`
78
+ // would stop seeing libraries added there later.
79
+ const nxBase = path.join(packageRoot, 'tsconfig.base.json');
80
+ const tsconfigs = isNxWorkspace(packageRoot) && existsSync(nxBase) ? [path.relative(root, nxBase)] : tsconfigsToEdit(root);
78
81
  if (!tsconfigs.length)
79
82
  throw new UipkgeError(`No tsconfig.json in ${root}.`);
80
83
  const edited = [];
81
84
  for (const file of tsconfigs) {
82
85
  const abs = path.join(root, file);
83
86
  if (await writeIfChanged(abs, appTsconfigEdit(root, file, await readFile(abs, 'utf8'))))
84
- edited.push(file);
87
+ edited.push(path.relative(packageRoot, abs).replace(/\\/g, '/'));
85
88
  }
86
89
  if (edited.length)
87
90
  done.push(`tsconfig paths \`@/*\` and \`@/ui/*\` added (${edited.join(', ')})`);
@@ -106,6 +109,12 @@ export async function runInit(options) {
106
109
  await linkTokens(project, config.styles, config.tokens, done);
107
110
  await writeConfig(root, config);
108
111
  done.push('components.json written');
112
+ // An unlayered `* { padding: 0 }` beats Tailwind v4's layered utilities and strips every component's spacing.
113
+ const stylesPath = path.join(root, config.styles);
114
+ const resets = existsSync(stylesPath) ? unlayeredResets(await readFile(stylesPath, 'utf8')) : [];
115
+ if (resets.length) {
116
+ log.warn(`${config.styles} resets margin/padding on \`${resets[0]}\` outside a CSS layer; it will override Tailwind's utilities. Move it into \`@layer base { … }\`.`);
117
+ }
109
118
  log.blank();
110
119
  log.success(color.bold('uipkge is ready.'));
111
120
  for (const line of done)
@@ -134,8 +143,9 @@ async function linkTokens(project, styles, tokens, done) {
134
143
  // Sass/Less: a CSS import there doesn't carry Tailwind v4's syntax reliably, so the tokens become their own global stylesheet.
135
144
  if (!styles.endsWith('.css') && project.app) {
136
145
  const entry = path.relative(project.packageRoot, path.join(root, tokens)).replace(/\\/g, '/');
137
- if (await addGlobalStyle(project.packageRoot, project.app.name, entry))
138
- done.push(`angular.json styles include ${entry}`);
146
+ const where = path.relative(project.packageRoot, project.app.config.file).replace(/\\/g, '/');
147
+ if (await addGlobalStyle(project.app.config, entry))
148
+ done.push(`${where} styles include ${entry}`);
139
149
  return;
140
150
  }
141
151
  if (!existsSync(stylesFile)) {
@@ -1,5 +1,5 @@
1
1
  import * as path from 'node:path';
2
- import { readConfig, registryFor } from '../config.js';
2
+ import { fromSource, readConfig, registryFor } from '../config.js';
3
3
  import { UipkgeError } from '../errors.js';
4
4
  import { isItemInstalled } from '../files.js';
5
5
  import { adaptItem, resolveLayout } from '../layout.js';
@@ -17,6 +17,8 @@ export function firstSentence(text) {
17
17
  return end === -1 ? flat : flat.slice(0, end);
18
18
  }
19
19
  const OTHER = 'other';
20
+ /** Group for components added from a URL or local file (components.json `sources`). */
21
+ export const SOURCES_GROUP = 'added from a url or file';
20
22
  /** Filters, then groups by first category (alphabetical, `other` last), rows sorted by name. */
21
23
  export function groupRows(items, filter) {
22
24
  const query = filter.query?.trim().toLowerCase();
@@ -43,9 +45,11 @@ export function groupRows(items, filter) {
43
45
  for (const row of rows)
44
46
  byCategory.set(row.category, [...(byCategory.get(row.category) ?? []), row]);
45
47
  return [...byCategory]
46
- .sort(([a], [b]) => (a === OTHER ? 1 : b === OTHER ? -1 : a.localeCompare(b)))
48
+ // Registry categories alphabetically, then `other`, then components from URLs/files.
49
+ .sort(([a], [b]) => rank(a) - rank(b) || a.localeCompare(b))
47
50
  .map(([cat, list]) => ({ category: cat, rows: list.sort((x, y) => x.name.localeCompare(y.name)) }));
48
51
  }
52
+ const rank = (category) => (category === SOURCES_GROUP ? 2 : category === OTHER ? 1 : 0);
49
53
  export const ellipsize = (text, width) => width < 2 ? '' : text.length <= width ? text : `${text.slice(0, width - 1).trimEnd()}…`;
50
54
  export async function runList(options) {
51
55
  const cwd = path.resolve(options.cwd);
@@ -66,7 +70,21 @@ export async function runList(options) {
66
70
  layout = undefined;
67
71
  }
68
72
  }
69
- const groups = groupRows(options.namespace ? index.items.map(i => ({ ...i, name: `${options.namespace}/${i.name}` })) : index.items, {
73
+ // Components added from a URL or file (recorded by `add`) aren't in any index; list them in their own group.
74
+ const fromSources = [];
75
+ if (!options.namespace && root && config?.sources) {
76
+ for (const source of Object.values(config.sources)) {
77
+ try {
78
+ const item = await registry.item(fromSource(root, source));
79
+ fromSources.push({ ...item, categories: [SOURCES_GROUP], description: `${source}${item.description ? ` — ${item.description}` : ''}` });
80
+ }
81
+ catch {
82
+ // An unreachable source is reported by `doctor`; browsing shouldn't fail on it.
83
+ }
84
+ }
85
+ }
86
+ const items = options.namespace ? index.items.map(i => ({ ...i, name: `${options.namespace}/${i.name}` })) : [...index.items, ...fromSources];
87
+ const groups = groupRows(items, {
70
88
  query: options.query,
71
89
  category: options.category,
72
90
  installedOnly: options.installedOnly,
package/dist/config.js CHANGED
@@ -3,9 +3,17 @@ import { readFile, writeFile } from 'node:fs/promises';
3
3
  import * as path from 'node:path';
4
4
  import { projectEnv } from './env.js';
5
5
  import { UipkgeError } from './errors.js';
6
- import { createRegistry, resolveRegistryUrl } from './registry.js';
6
+ import { createRegistry, refKind, resolveRegistryUrl } from './registry.js';
7
7
  export const CONFIG_FILE = 'components.json';
8
8
  export const DEFAULT_ALIASES = { ui: '@/ui', lib: '@/lib', blocks: '@/app/components/blocks' };
9
+ /** How `sources` stores a ref: URLs as they are, local files relative to the app so the entry stays portable. */
10
+ export function toSource(root, cwd, ref) {
11
+ return refKind(ref) === 'file' ? path.relative(root, path.resolve(cwd, ref)).replace(/\\/g, '/') : ref;
12
+ }
13
+ /** A `sources` entry as a ref the registry client can load from anywhere. */
14
+ export function fromSource(root, source) {
15
+ return refKind(source) === 'url' ? source : path.resolve(root, source);
16
+ }
9
17
  export function defaultConfig(registryUrl) {
10
18
  return {
11
19
  framework: 'angular',
package/dist/files.js CHANGED
@@ -26,15 +26,24 @@ export function conflictingFiles(root, item) {
26
26
  return normalizeEol(readFileSync(abs, 'utf8')) === normalizeEol(f.content) ? [] : [path.relative(root, abs)];
27
27
  });
28
28
  }
29
- export async function writeItemFiles(root, item, overwrite) {
30
- const result = { written: [], kept: [] };
31
- const files = targetedFiles(item);
32
- if (!files.length)
33
- throw new UipkgeError(`Registry item \`${item.name}\` has no files to install.`);
34
- for (const file of files) {
29
+ /**
30
+ * Throws if the item can't be written as-is. `add` checks every item before
31
+ * writing any, so a bad item never leaves a project half-installed. An item
32
+ * without files (a bundle such as `charts`) is fine: it only brings its
33
+ * dependencies.
34
+ */
35
+ export function assertWritable(root, item) {
36
+ for (const file of targetedFiles(item)) {
35
37
  if (typeof file.content !== 'string') {
36
- throw new UipkgeError(`Registry item \`${item.name}\` is missing the content of ${file.target}.`);
38
+ throw new UipkgeError(`Registry item \`${item.name}\` is missing the content of ${file.target}.`, 'Nothing was installed.');
37
39
  }
40
+ resolveTarget(root, file.target);
41
+ }
42
+ }
43
+ export async function writeItemFiles(root, item, overwrite) {
44
+ const result = { written: [], kept: [] };
45
+ assertWritable(root, item);
46
+ for (const file of targetedFiles(item)) {
38
47
  const abs = resolveTarget(root, file.target);
39
48
  const rel = path.relative(root, abs);
40
49
  if (existsSync(abs) && !overwrite) {
package/dist/index.js CHANGED
@@ -6,6 +6,7 @@ import { normalizeOptionalValues } from './args.js';
6
6
  import { runAdd } from './commands/add.js';
7
7
  import { runBuild } from './commands/build.js';
8
8
  import { runDiff } from './commands/diff.js';
9
+ import { runDoctor } from './commands/doctor.js';
9
10
  import { runInfo } from './commands/info.js';
10
11
  import { runInit } from './commands/init.js';
11
12
  import { runList } from './commands/list.js';
@@ -21,6 +22,7 @@ const COMMAND_HELP = [
21
22
  ['view <items...>', "show a component's details and files"],
22
23
  ['diff [item]', 'compare installed components with the registry'],
23
24
  ['info', 'show project, config and registry details'],
25
+ ['doctor', 'check the setup and explain how to fix problems'],
24
26
  ['build [file]', 'build your own registry (registry.json → public/r)'],
25
27
  ['mcp', 'run an MCP server so AI assistants can browse components'],
26
28
  ];
@@ -47,7 +49,7 @@ Options
47
49
  -s, --silent only print errors
48
50
  --category <c> only one category (list)
49
51
  -i, --installed only installed components (list)
50
- --json machine-readable output (list, view, info)
52
+ --json machine-readable output (list, view, info, doctor)
51
53
  --output <dir> where build writes the registry (default public/r)
52
54
  --debug show stack traces on errors
53
55
  -h, --help show this help
@@ -55,7 +57,7 @@ Options
55
57
 
56
58
  Registry: https://uipkge.dev/r/angular (override with components.json "registryUrl" or UIPKGE_REGISTRY_URL)
57
59
  `;
58
- const COMMANDS = new Set(['init', 'add', 'list', 'ls', 'view', 'diff', 'info', 'build', 'mcp']);
60
+ const COMMANDS = new Set(['init', 'add', 'list', 'ls', 'view', 'diff', 'info', 'doctor', 'build', 'mcp']);
59
61
  const OPTION_COMMANDS = {
60
62
  overwrite: ['init', 'add'],
61
63
  all: ['add'],
@@ -65,7 +67,7 @@ const OPTION_COMMANDS = {
65
67
  diff: ['add'],
66
68
  category: ['list', 'ls'],
67
69
  installed: ['list', 'ls'],
68
- json: ['list', 'ls', 'view', 'info'],
70
+ json: ['list', 'ls', 'view', 'info', 'doctor'],
69
71
  output: ['build'],
70
72
  };
71
73
  async function main(argv) {
@@ -135,6 +137,8 @@ async function main(argv) {
135
137
  return runDiff({ cwd, name: rest[0] });
136
138
  case 'info':
137
139
  return runInfo({ cwd, json: values.json, version: VERSION });
140
+ case 'doctor':
141
+ return runDoctor({ cwd, json: values.json });
138
142
  case 'build':
139
143
  return runBuild({ cwd, registry: rest[0] ?? 'registry.json', output: values.output ?? 'public/r' });
140
144
  case 'mcp':
package/dist/project.js CHANGED
@@ -1,4 +1,4 @@
1
- import { existsSync, readFileSync } from 'node:fs';
1
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
2
2
  import { readFile } from 'node:fs/promises';
3
3
  import * as path from 'node:path';
4
4
  import { parse } from 'jsonc-parser';
@@ -52,23 +52,53 @@ const isInside = (child, parent) => {
52
52
  const rel = path.relative(parent, child);
53
53
  return !rel.startsWith('..') && !path.isAbsolute(rel);
54
54
  };
55
- /** Application projects in angular.json (libraries have no global styles to theme). */
55
+ function toApp(packageRoot, name, p, config, rootDir) {
56
+ const root = rootDir ?? path.resolve(packageRoot, p.root ?? '');
57
+ const styles = ((p.architect ?? p.targets)?.build?.options?.styles ?? [])
58
+ .map(s => (typeof s === 'string' ? s : s.input))
59
+ .filter((s) => Boolean(s))
60
+ .map(s => path.relative(root, path.resolve(packageRoot, s)).replace(/\\/g, '/'));
61
+ return { name, root, styles, config };
62
+ }
63
+ /** project.json files of an Nx workspace (a few levels deep, skipping build output and dependencies). */
64
+ function nxProjectFiles(dir, depth = 0, out = []) {
65
+ if (depth > 4)
66
+ return out;
67
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
68
+ if (entry.isFile() && entry.name === 'project.json')
69
+ out.push(path.join(dir, entry.name));
70
+ else if (entry.isDirectory() && !/^(node_modules|dist|tmp|coverage|\..*)$/.test(entry.name))
71
+ nxProjectFiles(path.join(dir, entry.name), depth + 1, out);
72
+ }
73
+ return out;
74
+ }
75
+ /**
76
+ * Angular application projects (libraries have no global styles to theme): from
77
+ * angular.json, or in an Nx workspace from each app's project.json, keeping only
78
+ * apps whose build target is Angular's.
79
+ */
56
80
  export function angularApps(packageRoot) {
57
81
  const file = path.join(packageRoot, 'angular.json');
58
- if (!existsSync(file))
82
+ if (existsSync(file)) {
83
+ const doc = (parse(readFileSync(file, 'utf8')) ?? {});
84
+ return Object.entries(doc.projects ?? {})
85
+ .filter(([, p]) => (p.projectType ?? 'application') === 'application')
86
+ .map(([name, p]) => toApp(packageRoot, name, p, { file, path: ['projects', name] }));
87
+ }
88
+ if (!existsSync(path.join(packageRoot, 'nx.json')))
59
89
  return [];
60
- const doc = (parse(readFileSync(file, 'utf8')) ?? {});
61
- return Object.entries(doc.projects ?? {})
62
- .filter(([, p]) => (p.projectType ?? 'application') === 'application')
63
- .map(([name, p]) => {
64
- const root = path.resolve(packageRoot, p.root ?? '');
65
- const styles = ((p.architect ?? p.targets)?.build?.options?.styles ?? [])
66
- .map(s => (typeof s === 'string' ? s : s.input))
67
- .filter((s) => Boolean(s))
68
- .map(s => path.relative(root, path.resolve(packageRoot, s)).replace(/\\/g, '/'));
69
- return { name, root, styles };
90
+ return nxProjectFiles(packageRoot).flatMap(projectFile => {
91
+ const p = (parse(readFileSync(projectFile, 'utf8')) ?? {});
92
+ const build = p.targets?.build;
93
+ const angularBuild = /angular/.test(build?.executor ?? build?.builder ?? '');
94
+ if (p.projectType !== 'application' || !angularBuild)
95
+ return [];
96
+ const root = path.dirname(projectFile);
97
+ return [toApp(packageRoot, p.name ?? path.basename(root), p, { file: projectFile, path: [] }, root)];
70
98
  });
71
99
  }
100
+ /** An Nx workspace: its tsconfig paths live in the shared tsconfig.base.json. */
101
+ export const isNxWorkspace = (packageRoot) => existsSync(path.join(packageRoot, 'nx.json'));
72
102
  /**
73
103
  * Which Angular app `cwd` belongs to. An existing components.json between cwd
74
104
  * and the package root wins; otherwise the angular.json project containing
@@ -112,16 +142,15 @@ export async function loadProject(cwd) {
112
142
  if (!angularCore) {
113
143
  throw new UipkgeError(`${root} is not an Angular project (no @angular/core in package.json).`);
114
144
  }
115
- if (deps['nx'] || existsSync(path.join(packageRoot, 'nx.json'))) {
116
- throw new UipkgeError('Nx workspaces are not supported yet.', 'Run uipkge-ng inside a standalone Angular CLI app.');
117
- }
118
145
  return {
119
146
  root,
120
147
  packageRoot,
121
148
  app: location.app,
122
149
  packageJson,
123
150
  angularCore,
124
- isAnalog: Boolean(deps['@analogjs/platform'] || deps['@analogjs/vite-plugin-angular']),
151
+ // The Analog framework itself. @analogjs/vite-plugin-angular alone is common in Angular CLI and
152
+ // Nx workspaces (Vitest), and those still build with the Angular CLI, not Vite.
153
+ isAnalog: Boolean(deps['@analogjs/platform']),
125
154
  hasTailwind: Boolean(deps['tailwindcss']),
126
155
  packageManager: detectPackageManager(packageRoot, packageJson),
127
156
  };
package/dist/resolve.js CHANGED
@@ -28,17 +28,28 @@ transform = item => item) {
28
28
  const skipped = [];
29
29
  const dependencies = new Set();
30
30
  const devDependencies = new Set();
31
- async function visit(ref, direct) {
32
- // The default registry's foundation is init's job; a component depending on
33
- // `utils` must not reinstall it. `@acme/utils` is someone else's item.
31
+ // The default registry's foundation is init's job; a component depending on
32
+ // `utils` must not reinstall it. `@acme/utils` is someone else's item.
33
+ const isFoundation = (ref) => {
34
34
  const kind = refKind(ref);
35
35
  const ownRegistry = kind === 'name' || (kind === 'url' && ref.startsWith(`${registry.baseUrl}/`));
36
- if (ownRegistry && FOUNDATION_ITEMS.has(itemNameFromRef(ref)))
36
+ return ownRegistry && FOUNDATION_ITEMS.has(itemNameFromRef(ref));
37
+ };
38
+ // Start fetches early and in parallel; the registry caches them, so the ordered
39
+ // walk below just awaits ones already in flight. Errors surface there, not here.
40
+ const prefetch = (refs) => {
41
+ for (const ref of refs)
42
+ if (!isFoundation(ref))
43
+ registry.item(ref).catch(() => undefined);
44
+ };
45
+ async function visit(ref, direct) {
46
+ if (isFoundation(ref))
37
47
  return undefined;
38
48
  const item = transform(await registry.item(ref), direct);
39
49
  if (visited.has(item.name))
40
50
  return item.name;
41
51
  visited.add(item.name);
52
+ prefetch(item.registryDependencies ?? []);
42
53
  for (const dep of item.registryDependencies ?? [])
43
54
  await visit(dep, false);
44
55
  if (isInstalled(item) && !overwrite) {
@@ -51,6 +62,7 @@ transform = item => item) {
51
62
  return item.name;
52
63
  }
53
64
  const requested = [];
65
+ prefetch(names);
54
66
  for (const name of names) {
55
67
  const resolved = await visit(name, true);
56
68
  if (resolved && !requested.includes(resolved))
package/dist/setup.js CHANGED
@@ -142,21 +142,23 @@ export async function writeIfChanged(file, next) {
142
142
  }
143
143
  /**
144
144
  * Puts `entry` (relative to the workspace root) first in the app's global
145
- * `styles` for every target that has them (build, test), keeping angular.json's
145
+ * `styles` for every target that has them (build, test), in whichever file
146
+ * configures the app — angular.json or an Nx project.json — keeping its
146
147
  * formatting. Returns whether anything changed.
147
148
  */
148
- export async function addGlobalStyle(packageRoot, project, entry) {
149
- const file = path.join(packageRoot, 'angular.json');
150
- let text = await readFile(file, 'utf8');
151
- const doc = parse(text);
152
- const entryOf = doc.projects?.[project];
153
- const key = entryOf?.architect ? 'architect' : 'targets';
149
+ export async function addGlobalStyle(config, entry) {
150
+ let text = await readFile(config.file, 'utf8');
151
+ let node = parse(text);
152
+ for (const key of config.path)
153
+ node = node?.[key];
154
+ const project = node;
155
+ const key = project?.architect ? 'architect' : 'targets';
154
156
  let changed = false;
155
- for (const [name, target] of Object.entries(entryOf?.[key] ?? {})) {
157
+ for (const [name, target] of Object.entries(project?.[key] ?? {})) {
156
158
  const styles = target.options?.styles;
157
159
  if (!Array.isArray(styles) || styles.some(s => (typeof s === 'string' ? s : s.input) === entry))
158
160
  continue;
159
- const edits = modify(text, ['projects', project, key, name, 'options', 'styles', 0], entry, {
161
+ const edits = modify(text, [...config.path, key, name, 'options', 'styles', 0], entry, {
160
162
  isArrayInsertion: true,
161
163
  formattingOptions: { insertSpaces: true, tabSize: 2, eol: '\n' },
162
164
  });
@@ -164,6 +166,35 @@ export async function addGlobalStyle(packageRoot, project, entry) {
164
166
  changed = true;
165
167
  }
166
168
  if (changed)
167
- await writeFile(file, text, 'utf8');
169
+ await writeFile(config.file, text, 'utf8');
168
170
  return changed;
169
171
  }
172
+ /**
173
+ * Universal rules outside any `@layer` that zero margin or padding (`* { padding: 0 }`). Tailwind v4 puts utilities in a cascade layer, and unlayered CSS
174
+ * always wins over layered CSS, so a reset like this strips the spacing from every component.
175
+ * Returns the offending selectors.
176
+ */
177
+ export function unlayeredResets(css) {
178
+ let text = css.replace(/\/\*[\s\S]*?\*\//g, '');
179
+ // Drop @layer blocks (brace-matched) — rules inside a layer are fine.
180
+ for (let i = text.search(/@layer\b[^;{]*\{/); i !== -1; i = text.search(/@layer\b[^;{]*\{/)) {
181
+ let depth = 0;
182
+ let j = text.indexOf('{', i);
183
+ for (; j < text.length; j++) {
184
+ if (text[j] === '{')
185
+ depth++;
186
+ else if (text[j] === '}' && --depth === 0)
187
+ break;
188
+ }
189
+ text = text.slice(0, i) + text.slice(j + 1);
190
+ }
191
+ const found = [];
192
+ for (const m of text.matchAll(/(^|[};])\s*([^{};@]+?)\s*\{([^{}]*)\}/g)) {
193
+ const selector = m[2].trim().replace(/\s+/g, ' ');
194
+ // Only the universal selector reaches component elements; `html, body { margin: 0 }` is harmless.
195
+ const universal = selector.split(',').some(part => /^\*(::?[a-z-]+)?$/.test(part.trim()));
196
+ if (universal && /(^|[;\s])(margin|padding)\s*:\s*0(px)?\s*(;|$)/.test(m[3]))
197
+ found.push(selector);
198
+ }
199
+ return found;
200
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uipkge-ng",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Add uipkge Angular components to your project: copy the source, own the code.",
5
5
  "license": "MIT",
6
6
  "author": "Uday Adaka (https://uipkge.dev)",
@@ -25,7 +25,8 @@
25
25
  "uipkge-ng": "dist/index.js"
26
26
  },
27
27
  "files": [
28
- "dist"
28
+ "dist",
29
+ "CHANGELOG.md"
29
30
  ],
30
31
  "engines": {
31
32
  "node": ">=20.12"
@@ -34,7 +35,9 @@
34
35
  "build": "tsc -p tsconfig.build.json",
35
36
  "dev": "tsx src/index.ts",
36
37
  "test": "vitest run",
37
- "typecheck": "tsc --noEmit -p tsconfig.json"
38
+ "typecheck": "tsc --noEmit -p tsconfig.json",
39
+ "prepublishOnly": "npm run typecheck && npm test && npm run build",
40
+ "test:coverage": "vitest run --coverage"
38
41
  },
39
42
  "dependencies": {
40
43
  "@clack/prompts": "^1.8.1",
@@ -43,6 +46,7 @@
43
46
  },
44
47
  "devDependencies": {
45
48
  "@types/node": "^22.0.0",
49
+ "@vitest/coverage-v8": "3.2.7",
46
50
  "tsx": "^4.20.0",
47
51
  "typescript": "^5.9.3",
48
52
  "vitest": "^3.2.7"