@dowel-ui/cli 0.1.1

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/dist/index.js ADDED
@@ -0,0 +1,1056 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from "commander";
3
+ import * as prompts from "@clack/prompts";
4
+ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
5
+ import { dirname, join } from "node:path";
6
+ import { createHash } from "node:crypto";
7
+ import { z } from "zod";
8
+ import pc from "picocolors";
9
+ import { spawnSync } from "node:child_process";
10
+ import { fileURLToPath } from "node:url";
11
+ //#region src/branding.ts
12
+ /**
13
+ * Branding, mirrored from the repository root config.
14
+ *
15
+ * Duplicated deliberately: the published CLI cannot import from the monorepo
16
+ * root, and `pnpm rebrand` rewrites both copies in the same pass.
17
+ */
18
+ const branding = {
19
+ libraryName: "Dowel",
20
+ cliName: "dowel",
21
+ /**
22
+ * The npm package name, which is NOT the command name. npm rejected the
23
+ * unscoped `dowel` as too similar to `del` and `bower` — a rule that runs
24
+ * only at publish time, so a 404 from the registry proves a name is unused,
25
+ * never that it can be claimed. This is what follows `npx`; `cliName` is the
26
+ * binary the package installs.
27
+ */
28
+ cliPackage: "@dowel-ui/cli",
29
+ registryUrl: "https://dowel-eight.vercel.app/r"
30
+ };
31
+ //#endregion
32
+ //#region ../registry/dist/schema-ByqIpT47.js
33
+ /**
34
+ * Content hash used to detect local edits to an installed file.
35
+ *
36
+ * Line endings are normalised before hashing so a Windows checkout does not
37
+ * read as "every file modified", which would make `update` useless there.
38
+ */
39
+ function hashContent(content) {
40
+ const normalised = content.replace(/\r\n/g, "\n");
41
+ return `sha256:${createHash("sha256").update(normalised, "utf8").digest("hex")}`;
42
+ }
43
+ const registryFileTypeSchema = z.enum([
44
+ "registry:ui",
45
+ "registry:lib",
46
+ "registry:hook",
47
+ "registry:block",
48
+ "registry:style"
49
+ ]);
50
+ const registryItemTypeSchema = z.enum([
51
+ "registry:ui",
52
+ "registry:lib",
53
+ "registry:hook",
54
+ "registry:theme",
55
+ "registry:block"
56
+ ]);
57
+ const registryFileSchema = z.object({
58
+ /**
59
+ * Logical path within the registry, e.g. `ui/button.tsx`, `lib/utils.ts`.
60
+ *
61
+ * The leading segment selects which of the consumer's aliases the file is
62
+ * written under. The registry deliberately does not know the destination —
63
+ * that depends on a project layout it has never seen.
64
+ */
65
+ path: z.string().min(1),
66
+ type: registryFileTypeSchema,
67
+ content: z.string(),
68
+ /**
69
+ * `sha256:<hex>` of `content` as published.
70
+ *
71
+ * Recorded at install time so `update` can tell an untouched file from one
72
+ * the user has edited. This cannot be added later: an install that did not
73
+ * record a hash leaves no way to know what it originally wrote.
74
+ */
75
+ hash: z.string().regex(/^sha256:[0-9a-f]{64}$/)
76
+ });
77
+ const registryItemSchema = z.object({
78
+ $schema: z.string().optional(),
79
+ registryVersion: z.literal(1),
80
+ name: z.string().regex(/^[a-z][a-z0-9-]*$/),
81
+ type: registryItemTypeSchema,
82
+ title: z.string().min(1),
83
+ description: z.string().min(10),
84
+ category: z.string().min(1),
85
+ status: z.enum([
86
+ "stable",
87
+ "beta",
88
+ "experimental"
89
+ ]),
90
+ /** npm packages to install alongside the files. */
91
+ dependencies: z.array(z.string()),
92
+ /** Other registry items to install first. */
93
+ registryDependencies: z.array(z.string()),
94
+ files: z.array(registryFileSchema).min(1),
95
+ a11y: z.string().optional()
96
+ });
97
+ const registryIndexEntrySchema = registryItemSchema.pick({
98
+ name: true,
99
+ type: true,
100
+ title: true,
101
+ description: true,
102
+ category: true,
103
+ status: true,
104
+ dependencies: true,
105
+ registryDependencies: true
106
+ }).extend({ fileCount: z.number().int().positive() });
107
+ const registryIndexSchema = z.object({
108
+ $schema: z.string().optional(),
109
+ registryVersion: z.literal(1),
110
+ /** Version of the package the registry was generated from. */
111
+ generatedFrom: z.string().min(1),
112
+ items: z.array(registryIndexEntrySchema)
113
+ });
114
+ //#endregion
115
+ //#region src/lib/errors.ts
116
+ /**
117
+ * An error whose message is written for the person running the command.
118
+ *
119
+ * Anything thrown as a CliError is printed as a clean message with no stack
120
+ * trace; everything else is treated as a bug and printed in full, because a
121
+ * stack trace is exactly what is useful then and exactly what is noise when the
122
+ * problem is "you have not run init yet".
123
+ */
124
+ var CliError = class extends Error {
125
+ hint;
126
+ constructor(message, hint) {
127
+ super(message);
128
+ this.name = "CliError";
129
+ this.hint = hint;
130
+ }
131
+ };
132
+ //#endregion
133
+ //#region src/lib/config.ts
134
+ const CONFIG_FILE = "components.json";
135
+ /**
136
+ * How the CLI turns an import alias into a directory.
137
+ *
138
+ * Stored as a prefix/base pair taken from the project's tsconfig paths, rather
139
+ * than as absolute directories, so the config stays readable and survives the
140
+ * project being moved or checked out somewhere else.
141
+ */
142
+ const resolveSchema = z.object({
143
+ /** Alias prefix, e.g. "@/" for `"@/*": ["./src/*"]`. */
144
+ prefix: z.string().min(1),
145
+ /** Directory the prefix maps to, relative to the project root, e.g. "src". */
146
+ base: z.string()
147
+ });
148
+ const aliasesSchema = z.object({
149
+ components: z.string().min(1),
150
+ ui: z.string().min(1),
151
+ lib: z.string().min(1),
152
+ hooks: z.string().min(1),
153
+ utils: z.string().min(1),
154
+ /**
155
+ * Where blocks are installed.
156
+ *
157
+ * Optional so a components.json written before blocks existed still parses;
158
+ * `blocksAlias()` derives a sensible default from `components` when it is
159
+ * absent. Silently failing to install a block would be worse than either.
160
+ */
161
+ blocks: z.string().min(1).optional()
162
+ });
163
+ const installedItemSchema = z.object({
164
+ /** Registry version the item was installed from. */
165
+ from: z.string(),
166
+ /** Project-relative file path to the hash of the content we wrote. */
167
+ files: z.record(z.string(), z.string()),
168
+ /**
169
+ * Registry entries this one imports.
170
+ *
171
+ * Recorded so `remove` can refuse to delete something another installed
172
+ * component still needs, without having to reach the registry to find out.
173
+ * Optional, because installs made before this existed have no record of it.
174
+ */
175
+ dependsOn: z.array(z.string()).optional()
176
+ });
177
+ const configSchema = z.object({
178
+ $schema: z.string().optional(),
179
+ version: z.literal(1),
180
+ typescript: z.boolean(),
181
+ registry: z.string().min(1),
182
+ tailwind: z.object({
183
+ /** Project-relative path to the stylesheet that imports Tailwind. */
184
+ css: z.string().min(1) }),
185
+ aliases: aliasesSchema,
186
+ resolve: resolveSchema,
187
+ /**
188
+ * What has been installed, and the hash of what was written.
189
+ *
190
+ * This is what lets `update` tell an untouched file from one the user has
191
+ * edited. It has to be recorded at install time — an install that skipped it
192
+ * leaves no way to ever know what it originally wrote.
193
+ */
194
+ installed: z.record(z.string(), installedItemSchema).default({})
195
+ });
196
+ /** The blocks alias, or a default derived from where components live. */
197
+ function blocksAlias(config) {
198
+ return config.aliases.blocks ?? `${config.aliases.components}/blocks`;
199
+ }
200
+ function configPath(cwd) {
201
+ return join(cwd, CONFIG_FILE);
202
+ }
203
+ function configExists(cwd) {
204
+ return existsSync(configPath(cwd));
205
+ }
206
+ function readConfig(cwd) {
207
+ const path = configPath(cwd);
208
+ if (!existsSync(path)) throw new CliError(`No ${CONFIG_FILE} found in ${cwd}.`, "Run `init` first to set the project up.");
209
+ let raw;
210
+ try {
211
+ raw = JSON.parse(readFileSync(path, "utf8"));
212
+ } catch {
213
+ throw new CliError(`${CONFIG_FILE} is not valid JSON.`);
214
+ }
215
+ const parsed = configSchema.safeParse(raw);
216
+ if (!parsed.success) {
217
+ const issues = parsed.error.issues.map((issue) => ` ${issue.path.join(".") || "(root)"}: ${issue.message}`).join("\n");
218
+ throw new CliError(`${CONFIG_FILE} is not valid:\n${issues}`);
219
+ }
220
+ return parsed.data;
221
+ }
222
+ function writeConfig(cwd, config) {
223
+ writeFileSync(configPath(cwd), `${JSON.stringify(config, null, 2)}\n`);
224
+ }
225
+ //#endregion
226
+ //#region src/lib/logger.ts
227
+ /**
228
+ * All CLI output goes through here.
229
+ *
230
+ * A single place to route messages means the format stays consistent, and
231
+ * anything that needs to change later — quiet mode, JSON output, writing to
232
+ * stderr — changes in one file rather than in every command.
233
+ */
234
+ const logger = {
235
+ info(message) {
236
+ console.log(message);
237
+ },
238
+ success(message) {
239
+ console.log(`${pc.green("✓")} ${message}`);
240
+ },
241
+ warn(message) {
242
+ console.warn(`${pc.yellow("!")} ${message}`);
243
+ },
244
+ error(message) {
245
+ console.error(`${pc.red("✕")} ${message}`);
246
+ },
247
+ step(message) {
248
+ console.log(`${pc.dim("·")} ${message}`);
249
+ },
250
+ blank() {
251
+ console.log("");
252
+ }
253
+ };
254
+ //#endregion
255
+ //#region src/lib/package-manager.ts
256
+ const INSTALL_COMMAND = {
257
+ pnpm: ["add"],
258
+ yarn: ["add"],
259
+ bun: ["add"],
260
+ npm: ["install"]
261
+ };
262
+ /** Packages already present are skipped, so re-running `add` installs nothing. */
263
+ function missingDependencies(installed, required) {
264
+ return required.filter((dependency) => !(dependency in installed));
265
+ }
266
+ function installDependencies(manager, cwd, dependencies) {
267
+ if (dependencies.length === 0) return;
268
+ const args = [...INSTALL_COMMAND[manager], ...dependencies];
269
+ const result = spawnSync(manager, args, {
270
+ cwd,
271
+ stdio: "inherit"
272
+ });
273
+ if (result.error) throw new CliError(`Could not run ${manager}: ${result.error.message}`, `Install these manually: ${dependencies.join(" ")}`);
274
+ if (result.status !== 0) throw new CliError(`${manager} ${args.join(" ")} failed.`, "Fix the install and run the command again — no files were rolled back.");
275
+ }
276
+ //#endregion
277
+ //#region src/lib/paths.ts
278
+ /**
279
+ * Maps a registry file path to a destination in the project.
280
+ *
281
+ * The registry publishes logical paths — `ui/button.tsx`, `lib/utils.ts` —
282
+ * because it has never seen the project it is being installed into. The leading
283
+ * segment selects which alias the file belongs under, and the alias is resolved
284
+ * through the project's own tsconfig prefix.
285
+ */
286
+ function resolveDestination(config, registryPath) {
287
+ const [group, ...rest] = registryPath.split("/");
288
+ const relative = rest.join("/");
289
+ if (!group || relative === "") throw new CliError(`Registry path "${registryPath}" is not in a recognised group.`);
290
+ const alias = group === "ui" ? config.aliases.ui : group === "lib" ? config.aliases.lib : group === "hooks" ? config.aliases.hooks : group === "blocks" ? blocksAlias(config) : void 0;
291
+ if (!alias) throw new CliError(`Registry path "${registryPath}" uses unknown group "${group}".`, "This usually means the CLI is older than the registry it is reading.");
292
+ return join(aliasToDirectory(config, alias), relative);
293
+ }
294
+ /** Turns an import alias such as `@/components/ui` into `src/components/ui`. */
295
+ function aliasToDirectory(config, alias) {
296
+ const { prefix, base } = config.resolve;
297
+ if (!alias.startsWith(prefix)) throw new CliError(`Alias "${alias}" does not start with the configured prefix "${prefix}".`, `Check the "aliases" and "resolve" entries in components.json.`);
298
+ const withoutPrefix = alias.slice(prefix.length);
299
+ return base ? join(base, withoutPrefix) : withoutPrefix;
300
+ }
301
+ /**
302
+ * Rewrites the library's own import aliases to the ones the project uses.
303
+ *
304
+ * The published source is written against `@/components/*` and `@/lib/*`. A
305
+ * project that puts its components somewhere else, or uses `~/` instead of
306
+ * `@/`, gets files that import from where they actually live. Getting this
307
+ * wrong is the single most common way a source-first install produces code that
308
+ * does not compile.
309
+ */
310
+ function rewriteImports(content, config) {
311
+ const { aliases } = config;
312
+ return content.replace(/(["'])@\/lib\/utils\1/g, (_match, quote) => `${quote}${aliases.utils}${quote}`).replace(/(["'])@\/lib\/([^"']+)\1/g, (_match, quote, rest) => `${quote}${aliases.lib}/${rest}${quote}`).replace(/(["'])@\/components\/([^"']+)\1/g, (_match, quote, rest) => `${quote}${aliases.ui}/${rest}${quote}`).replace(/(["'])@\/hooks\/([^"']+)\1/g, (_match, quote, rest) => `${quote}${aliases.hooks}/${rest}${quote}`).replace(/(["'])@\/blocks\/([^"']+)\1/g, (_match, quote, rest) => `${quote}${blocksAlias(config)}/${rest}${quote}`);
313
+ }
314
+ //#endregion
315
+ //#region src/lib/project.ts
316
+ const LOCKFILES = [
317
+ ["pnpm-lock.yaml", "pnpm"],
318
+ ["bun.lock", "bun"],
319
+ ["bun.lockb", "bun"],
320
+ ["yarn.lock", "yarn"],
321
+ ["package-lock.json", "npm"]
322
+ ];
323
+ function detectPackageManager(root) {
324
+ for (const [lockfile, manager] of LOCKFILES) if (existsSync(join(root, lockfile))) return manager;
325
+ return "npm";
326
+ }
327
+ function readPackageJson(root) {
328
+ const path = join(root, "package.json");
329
+ if (!existsSync(path)) throw new CliError(`No package.json found in ${root}.`, "Run this from the root of your project.");
330
+ try {
331
+ return JSON.parse(readFileSync(path, "utf8"));
332
+ } catch {
333
+ throw new CliError("package.json is not valid JSON.");
334
+ }
335
+ }
336
+ function versionOf(packageJson, name) {
337
+ return packageJson.dependencies?.[name] ?? packageJson.devDependencies?.[name];
338
+ }
339
+ /** Leading integer of a range such as `^4.3.3`, `~4.0.0`, `4.x`. */
340
+ function majorVersion(range) {
341
+ if (!range) return void 0;
342
+ const match = /(\d+)/.exec(range);
343
+ return match?.[1] === void 0 ? void 0 : Number(match[1]);
344
+ }
345
+ function detectFramework(packageJson) {
346
+ if (versionOf(packageJson, "next")) return "next";
347
+ if (versionOf(packageJson, "@remix-run/react") ?? versionOf(packageJson, "react-router")) return "remix";
348
+ if (versionOf(packageJson, "vite")) return "vite";
349
+ return "unknown";
350
+ }
351
+ /** Directories worth searching for the stylesheet, in the order projects use them. */
352
+ const CSS_SEARCH_DIRS = [
353
+ "app",
354
+ "src/app",
355
+ "src/styles",
356
+ "styles",
357
+ "src",
358
+ "."
359
+ ];
360
+ /**
361
+ * Finds the stylesheet that pulls Tailwind in.
362
+ *
363
+ * Located by content rather than by name: `globals.css`, `index.css`,
364
+ * `app.css` and `main.css` are all common, and guessing at the filename would
365
+ * mean appending tokens to a stylesheet that is never loaded.
366
+ */
367
+ function findCssEntry(root) {
368
+ for (const dir of CSS_SEARCH_DIRS) {
369
+ const absolute = join(root, dir);
370
+ if (!existsSync(absolute) || !statSync(absolute).isDirectory()) continue;
371
+ for (const entry of readdirSync(absolute)) {
372
+ if (!entry.endsWith(".css")) continue;
373
+ const path = join(absolute, entry);
374
+ const content = readFileSync(path, "utf8");
375
+ if (/@import\s+["']tailwindcss["']/.test(content) || /@tailwind\s+/.test(content)) return dir === "." ? entry : `${dir}/${entry}`;
376
+ }
377
+ }
378
+ }
379
+ /**
380
+ * Reads the first wildcard alias out of tsconfig paths.
381
+ *
382
+ * `"@/*": ["./src/*"]` becomes `{ prefix: "@/", base: "src" }`. Only the
383
+ * wildcard form is understood, which covers how essentially every React project
384
+ * is set up; anything else falls through to a prompt rather than a wrong guess.
385
+ */
386
+ function detectResolve(root) {
387
+ for (const file of ["tsconfig.json", "jsconfig.json"]) {
388
+ const path = join(root, file);
389
+ if (!existsSync(path)) continue;
390
+ let parsed;
391
+ try {
392
+ const raw = readFileSync(path, "utf8").replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:])\/\/.*$/gm, "$1").replace(/,(\s*[}\]])/g, "$1");
393
+ parsed = JSON.parse(raw);
394
+ } catch {
395
+ continue;
396
+ }
397
+ const paths = parsed.compilerOptions?.paths;
398
+ if (!paths) continue;
399
+ for (const [alias, targets] of Object.entries(paths)) {
400
+ const target = targets[0];
401
+ if (!alias.endsWith("/*") || target === void 0 || !target.endsWith("/*")) continue;
402
+ return {
403
+ prefix: alias.slice(0, -1),
404
+ base: target.slice(0, -2).replace(/^\.\//, "").replace(/\/$/, "")
405
+ };
406
+ }
407
+ }
408
+ }
409
+ function inspectProject(root) {
410
+ const packageJson = readPackageJson(root);
411
+ return {
412
+ root,
413
+ packageManager: detectPackageManager(root),
414
+ packageJson,
415
+ isTypeScript: existsSync(join(root, "tsconfig.json")),
416
+ reactVersion: versionOf(packageJson, "react"),
417
+ tailwindVersion: versionOf(packageJson, "tailwindcss"),
418
+ framework: detectFramework(packageJson),
419
+ cssEntry: findCssEntry(root),
420
+ resolve: detectResolve(root)
421
+ };
422
+ }
423
+ /**
424
+ * Refuses to proceed on a project this version cannot support correctly.
425
+ *
426
+ * Every check here fails loudly on purpose. Writing v4 token syntax into a v3
427
+ * project, or TypeScript source into a JavaScript one, produces a project that
428
+ * does not build — and the person debugging it has no reason to suspect the
429
+ * install rather than their own code.
430
+ */
431
+ function assertSupported(project) {
432
+ if (!project.reactVersion) throw new CliError("This does not look like a React project — react is not in package.json.", "Run this from the root of a React application.");
433
+ if (!project.isTypeScript) throw new CliError("JavaScript projects are not supported yet.", "The published components are TypeScript. Add a tsconfig.json, or wait for JS output in a future release.");
434
+ const tailwindMajor = majorVersion(project.tailwindVersion);
435
+ if (tailwindMajor === void 0) throw new CliError("Tailwind CSS is not installed.", "Install tailwindcss v4 and its plugin for your bundler, then run init again.");
436
+ if (tailwindMajor < 4) throw new CliError(`Tailwind CSS v${String(tailwindMajor)} is not supported — v4 or later is required.`, "The design tokens are defined with @theme, which v3 cannot parse. Upgrade to Tailwind v4 first.");
437
+ }
438
+ //#endregion
439
+ //#region src/lib/registry-client.ts
440
+ /**
441
+ * Reads the registry over HTTP, or from a directory on disk.
442
+ *
443
+ * The local path form is not a testing shortcut bolted on afterwards — it is
444
+ * how private forks and enterprise mirrors are meant to work, and it is what
445
+ * lets the end-to-end tests run against a registry built in the same commit
446
+ * rather than against whatever happens to be deployed.
447
+ */
448
+ function isHttp(baseUrl) {
449
+ return baseUrl.startsWith("http://") || baseUrl.startsWith("https://");
450
+ }
451
+ function localPath(baseUrl, file) {
452
+ const root = baseUrl.startsWith("file:") ? fileURLToPath(baseUrl) : baseUrl;
453
+ return join(root, file);
454
+ }
455
+ async function readJson(baseUrl, file, what) {
456
+ if (!isHttp(baseUrl)) {
457
+ const path = localPath(baseUrl, file);
458
+ if (!existsSync(path)) throw new CliError(`${what} not found at ${path}.`);
459
+ try {
460
+ return JSON.parse(readFileSync(path, "utf8"));
461
+ } catch {
462
+ throw new CliError(`${what} at ${path} is not valid JSON.`);
463
+ }
464
+ }
465
+ const url = `${baseUrl.replace(/\/$/, "")}/${file}`;
466
+ let response;
467
+ try {
468
+ response = await fetch(url);
469
+ } catch (cause) {
470
+ throw new CliError(`Could not reach the registry at ${url}.`, cause instanceof Error ? cause.message : void 0);
471
+ }
472
+ if (response.status === 404) throw new CliError(`${what} not found in the registry.`);
473
+ if (!response.ok) throw new CliError(`Registry returned ${String(response.status)} for ${url}.`);
474
+ try {
475
+ return await response.json();
476
+ } catch {
477
+ throw new CliError(`${what} at ${url} is not valid JSON.`);
478
+ }
479
+ }
480
+ async function fetchIndex(baseUrl) {
481
+ const raw = await readJson(baseUrl, "index.json", "Registry index");
482
+ const parsed = registryIndexSchema.safeParse(raw);
483
+ if (!parsed.success) throw new CliError("The registry index does not match the format this CLI understands.", "Update the CLI, or point --registry at a compatible registry.");
484
+ return parsed.data;
485
+ }
486
+ async function fetchItem(baseUrl, name) {
487
+ const raw = await readJson(baseUrl, `${name}.json`, `Component "${name}"`);
488
+ const parsed = registryItemSchema.safeParse(raw);
489
+ if (!parsed.success) throw new CliError(`Registry entry "${name}" does not match the format this CLI understands.`, "Update the CLI, or point --registry at a compatible registry.");
490
+ return parsed.data;
491
+ }
492
+ /**
493
+ * Resolves items and everything they depend on, dependencies first.
494
+ *
495
+ * Depth-first post-order, so a component is always ordered after the things it
496
+ * imports. A breadth-first walk reversed looks equivalent and is not: if two
497
+ * requested items depend on each other's subtrees it produces the wrong order.
498
+ * The visiting set makes a dependency cycle terminate rather than recurse
499
+ * forever.
500
+ */
501
+ async function resolveItems(baseUrl, names) {
502
+ const cache = /* @__PURE__ */ new Map();
503
+ const ordered = [];
504
+ const placed = /* @__PURE__ */ new Set();
505
+ const visiting = /* @__PURE__ */ new Set();
506
+ async function load(name) {
507
+ const cached = cache.get(name);
508
+ if (cached) return cached;
509
+ const item = await fetchItem(baseUrl, name);
510
+ cache.set(name, item);
511
+ return item;
512
+ }
513
+ async function visit(name) {
514
+ if (placed.has(name) || visiting.has(name)) return;
515
+ visiting.add(name);
516
+ const item = await load(name);
517
+ for (const dependency of item.registryDependencies) await visit(dependency);
518
+ visiting.delete(name);
519
+ placed.add(name);
520
+ ordered.push(item);
521
+ }
522
+ for (const name of names) await visit(name);
523
+ return ordered;
524
+ }
525
+ //#endregion
526
+ //#region src/commands/add.ts
527
+ /**
528
+ * Classifies an existing file against what we previously wrote there.
529
+ *
530
+ * Three outcomes, and the distinction matters. A file whose content still
531
+ * matches what we installed is ours to replace silently, which is what makes
532
+ * re-running `add` a no-op. A file that differs from what we installed has been
533
+ * edited by the user — the entire point of a source-first library — and must
534
+ * never be overwritten without them saying so.
535
+ */
536
+ function classifyFile(absolutePath, incomingHash, recordedHash) {
537
+ if (!existsSync(absolutePath)) return "write";
538
+ const currentHash = hashContent(readFileSync(absolutePath, "utf8"));
539
+ if (currentHash === incomingHash) return "unchanged";
540
+ if (recordedHash !== void 0 && currentHash === recordedHash) return "write";
541
+ return "modified";
542
+ }
543
+ function planFiles(cwd, config, items) {
544
+ const planned = [];
545
+ for (const item of items) {
546
+ const recorded = config.installed[item.name]?.files ?? {};
547
+ for (const file of item.files) {
548
+ if (file.type === "registry:style") continue;
549
+ const destination = resolveDestination(config, file.path);
550
+ const content = rewriteImports(file.content, config);
551
+ const hash = hashContent(content);
552
+ planned.push({
553
+ destination,
554
+ content,
555
+ hash,
556
+ action: classifyFile(join(cwd, destination), hash, recorded[destination])
557
+ });
558
+ }
559
+ }
560
+ return planned;
561
+ }
562
+ async function add(names, options) {
563
+ if (names.length === 0) throw new CliError("Name at least one component to add.", "For example: `add button dialog`.");
564
+ const { cwd, yes, overwrite } = options;
565
+ const config = readConfig(cwd);
566
+ const registry = options.registry ?? config.registry;
567
+ const project = inspectProject(cwd);
568
+ const items = await resolveItems(registry, names);
569
+ const requested = new Set(names);
570
+ const pulledIn = items.filter((item) => !requested.has(item.name));
571
+ const planned = planFiles(cwd, config, items);
572
+ const toWrite = planned.filter((file) => file.action === "write");
573
+ const modified = planned.filter((file) => file.action === "modified");
574
+ const unchanged = planned.filter((file) => file.action === "unchanged");
575
+ if (modified.length > 0 && !overwrite) {
576
+ logger.warn("These files have local changes and were left alone:");
577
+ for (const file of modified) logger.info(` ${file.destination}`);
578
+ logger.blank();
579
+ logger.info(pc.dim("Re-run with --overwrite to replace them."));
580
+ if (toWrite.length === 0) {
581
+ logger.blank();
582
+ logger.info("Nothing else to do.");
583
+ return;
584
+ }
585
+ }
586
+ const writable = overwrite ? [...toWrite, ...modified] : toWrite;
587
+ if (writable.length === 0) {
588
+ logger.success(unchanged.length > 0 ? "Already up to date." : "Nothing to write.");
589
+ return;
590
+ }
591
+ const dependencies = [...new Set(items.flatMap((item) => item.dependencies))];
592
+ const missing = missingDependencies({
593
+ ...project.packageJson.dependencies,
594
+ ...project.packageJson.devDependencies
595
+ }, dependencies);
596
+ if (!yes) {
597
+ logger.info(pc.dim("Will write:"));
598
+ for (const file of writable) logger.info(` ${file.destination}`);
599
+ if (pulledIn.length > 0) logger.info(pc.dim(`Pulled in as dependencies: ${pulledIn.map((i) => i.name).join(", ")}`));
600
+ if (missing.length > 0) logger.info(pc.dim(`Will install: ${missing.join(", ")}`));
601
+ logger.blank();
602
+ const proceed = await prompts.confirm({
603
+ message: "Continue?",
604
+ initialValue: true
605
+ });
606
+ if (prompts.isCancel(proceed) || !proceed) throw new CliError("Cancelled — nothing was changed.");
607
+ }
608
+ for (const file of writable) {
609
+ const absolute = join(cwd, file.destination);
610
+ mkdirSync(dirname(absolute), { recursive: true });
611
+ writeFileSync(absolute, file.content);
612
+ }
613
+ const writtenPaths = new Set(writable.map((file) => file.destination));
614
+ for (const item of items) {
615
+ const files = { ...config.installed[item.name]?.files };
616
+ for (const file of planFiles(cwd, config, [item])) if (writtenPaths.has(file.destination) || file.action === "unchanged") files[file.destination] = file.hash;
617
+ if (Object.keys(files).length > 0) config.installed[item.name] = {
618
+ from: item.registryVersion.toString(),
619
+ files,
620
+ dependsOn: item.registryDependencies
621
+ };
622
+ }
623
+ writeConfig(cwd, config);
624
+ if (missing.length > 0 && !options.skipInstall) installDependencies(project.packageManager, cwd, missing);
625
+ logger.blank();
626
+ logger.success(`Added ${items.map((item) => item.name).join(", ")}`);
627
+ if (missing.length > 0) logger.success(options.skipInstall ? `Install these yourself: ${missing.join(" ")}` : `Installed ${missing.join(", ")}`);
628
+ logger.blank();
629
+ logger.info(pc.dim("Files:"));
630
+ for (const file of writable) logger.info(` ${file.destination}`);
631
+ if (unchanged.length > 0) logger.info(pc.dim(` (${String(unchanged.length)} already up to date)`));
632
+ }
633
+ /**
634
+ * Appends the design tokens to the project stylesheet.
635
+ *
636
+ * Appended rather than written as a new file, and inserted after the Tailwind
637
+ * import rather than at the top, because `@theme` has to be processed by
638
+ * Tailwind. The existing stylesheet is never rewritten — whatever the project
639
+ * already had stays exactly where it was.
640
+ *
641
+ * Two separate idempotence checks, because they catch different things. The
642
+ * marker survives the user editing their tokens, which they are meant to do —
643
+ * a content check alone would re-insert the whole block over their changes. The
644
+ * content check covers a token block that carries no marker.
645
+ */
646
+ function insertTokens(stylesheet, tokens) {
647
+ if (stylesheet.includes("/* Design tokens. Safe to edit — this is your copy. */")) return stylesheet;
648
+ if (stylesheet.includes(tokens.trim())) return stylesheet;
649
+ const importMatch = /^[^\n]*@import\s+["']tailwindcss["'][^\n]*$/m.exec(stylesheet);
650
+ if (!importMatch) return `${stylesheet.trimEnd()}\n\n${tokens.trim()}\n`;
651
+ const insertAt = importMatch.index + importMatch[0].length;
652
+ return `${stylesheet.slice(0, insertAt)}\n\n${tokens.trim()}\n${stylesheet.slice(insertAt)}`;
653
+ }
654
+ async function init(options) {
655
+ const { cwd, registry, yes } = options;
656
+ if (configExists(cwd) && !yes) {
657
+ const overwrite = await prompts.confirm({
658
+ message: "components.json already exists. Overwrite it?",
659
+ initialValue: false
660
+ });
661
+ if (prompts.isCancel(overwrite) || !overwrite) throw new CliError("Cancelled — nothing was changed.");
662
+ }
663
+ const project = inspectProject(cwd);
664
+ assertSupported(project);
665
+ logger.step(`Detected ${pc.bold(project.framework)} · ${pc.bold(project.packageManager)}`);
666
+ let resolve = project.resolve;
667
+ if (!resolve) {
668
+ if (yes) throw new CliError("Could not read a path alias from tsconfig.json.", "Add something like `\"paths\": { \"@/*\": [\"./src/*\"] }` and run init again.");
669
+ const prefix = await prompts.text({
670
+ message: "What import alias do you use?",
671
+ placeholder: "@/",
672
+ initialValue: "@/"
673
+ });
674
+ const base = await prompts.text({
675
+ message: "Which directory does it point at?",
676
+ placeholder: "src",
677
+ initialValue: "src"
678
+ });
679
+ if (prompts.isCancel(prefix) || prompts.isCancel(base)) throw new CliError("Cancelled — nothing was changed.");
680
+ resolve = {
681
+ prefix,
682
+ base
683
+ };
684
+ }
685
+ let cssEntry = project.cssEntry;
686
+ if (!cssEntry) {
687
+ if (yes) throw new CliError("Could not find a stylesheet that imports Tailwind.", "Create one containing `@import \"tailwindcss\";` and run init again.");
688
+ const answer = await prompts.text({
689
+ message: "Where is the stylesheet that imports Tailwind?",
690
+ placeholder: "src/index.css"
691
+ });
692
+ if (prompts.isCancel(answer) || !answer) throw new CliError("Cancelled — nothing was changed.");
693
+ cssEntry = answer;
694
+ }
695
+ const config = {
696
+ $schema: `${branding.registryUrl}/schema/components.json`,
697
+ version: 1,
698
+ typescript: true,
699
+ registry,
700
+ tailwind: { css: cssEntry },
701
+ aliases: {
702
+ components: `${resolve.prefix}components`,
703
+ ui: `${resolve.prefix}components/ui`,
704
+ lib: `${resolve.prefix}lib`,
705
+ hooks: `${resolve.prefix}hooks`,
706
+ utils: `${resolve.prefix}lib/utils`,
707
+ blocks: `${resolve.prefix}components/blocks`
708
+ },
709
+ resolve,
710
+ installed: {}
711
+ };
712
+ const utils = await fetchItem(registry, "utils");
713
+ const theme = await fetchItem(registry, "theme");
714
+ const written = [];
715
+ const installedFiles = {};
716
+ for (const file of utils.files) {
717
+ const destination = resolveDestination(config, file.path);
718
+ const absolute = join(cwd, destination);
719
+ if (existsSync(absolute)) {
720
+ logger.step(`${pc.dim("skipped")} ${destination} ${pc.dim("(already exists)")}`);
721
+ continue;
722
+ }
723
+ mkdirSync(dirname(absolute), { recursive: true });
724
+ const content = rewriteImports(file.content, config);
725
+ writeFileSync(absolute, content);
726
+ installedFiles[destination] = file.hash;
727
+ written.push(destination);
728
+ }
729
+ config.installed.utils = {
730
+ from: utils.registryVersion.toString(),
731
+ files: installedFiles
732
+ };
733
+ const stylesheetPath = join(cwd, cssEntry);
734
+ if (!existsSync(stylesheetPath)) throw new CliError(`Stylesheet not found at ${cssEntry}.`);
735
+ const tokens = theme.files[0]?.content ?? "";
736
+ const stylesheet = readFileSync(stylesheetPath, "utf8");
737
+ const updated = insertTokens(stylesheet, tokens);
738
+ if (updated === stylesheet) logger.step(`${pc.dim("skipped")} ${cssEntry} ${pc.dim("(tokens already present)")}`);
739
+ else {
740
+ writeFileSync(stylesheetPath, updated);
741
+ written.push(cssEntry);
742
+ }
743
+ config.installed.theme = {
744
+ from: theme.registryVersion.toString(),
745
+ files: { [cssEntry]: theme.files[0]?.hash ?? "" }
746
+ };
747
+ writeConfig(cwd, config);
748
+ written.unshift("components.json");
749
+ const required = [...utils.dependencies];
750
+ const missing = missingDependencies({
751
+ ...project.packageJson.dependencies,
752
+ ...project.packageJson.devDependencies
753
+ }, required);
754
+ if (missing.length > 0 && !options.skipInstall) {
755
+ logger.step(`Installing ${missing.join(", ")}`);
756
+ installDependencies(project.packageManager, cwd, missing);
757
+ }
758
+ logger.blank();
759
+ logger.success("Project initialised.");
760
+ logger.blank();
761
+ logger.info(pc.dim("Files:"));
762
+ for (const file of written) logger.info(` ${file}`);
763
+ if (missing.length > 0) {
764
+ logger.blank();
765
+ logger.info(pc.dim(options.skipInstall ? "Install manually:" : "Dependencies:"));
766
+ logger.info(` ${missing.join(" ")}`);
767
+ }
768
+ logger.blank();
769
+ logger.info(`Next: ${pc.bold(`npx ${branding.cliPackage} add button`)}`);
770
+ }
771
+ //#endregion
772
+ //#region src/commands/list.ts
773
+ async function list(options) {
774
+ const config = configExists(options.cwd) ? readConfig(options.cwd) : void 0;
775
+ const registry = options.registry ?? config?.registry ?? branding.registryUrl;
776
+ const index = await fetchIndex(registry);
777
+ const installed = new Set(Object.keys(config?.installed ?? {}));
778
+ const items = index.items.filter((item) => item.type === "registry:ui").filter((item) => !options.category || item.category === options.category);
779
+ if (options.json) {
780
+ logger.info(JSON.stringify(items.map((item) => ({
781
+ ...item,
782
+ installed: installed.has(item.name)
783
+ })), null, 2));
784
+ return;
785
+ }
786
+ if (items.length === 0) {
787
+ logger.warn(options.category ? `No components in category "${options.category}".` : "The registry has no components.");
788
+ return;
789
+ }
790
+ const byCategory = /* @__PURE__ */ new Map();
791
+ for (const item of items) byCategory.set(item.category, [...byCategory.get(item.category) ?? [], item]);
792
+ const width = Math.max(...items.map((item) => item.name.length));
793
+ for (const [category, categoryItems] of [...byCategory].sort()) {
794
+ logger.blank();
795
+ logger.info(pc.bold(category));
796
+ for (const item of categoryItems) {
797
+ const mark = installed.has(item.name) ? pc.green("✓") : " ";
798
+ logger.info(` ${mark} ${item.name.padEnd(width)} ${pc.dim(item.description)}`);
799
+ }
800
+ }
801
+ logger.blank();
802
+ logger.info(pc.dim(`${String(items.length)} components · ${String(installed.size)} installed · ${registry}`));
803
+ }
804
+ //#endregion
805
+ //#region src/commands/remove.ts
806
+ /**
807
+ * Classifies a file before deleting it.
808
+ *
809
+ * Deleting is the one irreversible thing this CLI does, so it distinguishes a
810
+ * file still exactly as installed — safe to remove — from one that has been
811
+ * edited, which is the user's work and not ours to throw away.
812
+ */
813
+ function classifyRemoval(absolutePath, recordedHash) {
814
+ if (!existsSync(absolutePath)) return "missing";
815
+ if (recordedHash === void 0) return "modified";
816
+ return hashContent(readFileSync(absolutePath, "utf8")) === recordedHash ? "unchanged" : "modified";
817
+ }
818
+ /**
819
+ * Components that other installed entries still import.
820
+ *
821
+ * Removing a component something else depends on would break the project, so
822
+ * those are reported and skipped rather than deleted with a warning after the
823
+ * fact.
824
+ */
825
+ function findDependents(installed, registryDependencies, removing) {
826
+ const blockers = /* @__PURE__ */ new Map();
827
+ for (const name of removing) {
828
+ const dependents = Object.keys(installed).filter((candidate) => !removing.has(candidate) && (registryDependencies.get(candidate) ?? []).includes(name));
829
+ if (dependents.length > 0) blockers.set(name, dependents);
830
+ }
831
+ return blockers;
832
+ }
833
+ async function remove(names, options) {
834
+ if (names.length === 0) throw new CliError("Name at least one component to remove.");
835
+ const { cwd } = options;
836
+ const config = readConfig(cwd);
837
+ const unknown = names.filter((name) => !(name in config.installed));
838
+ if (unknown.length > 0) throw new CliError(`Not installed: ${unknown.join(", ")}.`, "Run `list` to see what is installed.");
839
+ const registryDependencies = /* @__PURE__ */ new Map();
840
+ for (const [name, entry] of Object.entries(config.installed)) registryDependencies.set(name, entry.dependsOn ?? []);
841
+ const removing = new Set(names);
842
+ const blockers = findDependents(config.installed, registryDependencies, removing);
843
+ if (blockers.size > 0) {
844
+ logger.error("These are still needed by something else:");
845
+ for (const [name, dependents] of blockers) logger.info(` ${name} — required by ${dependents.join(", ")}`);
846
+ throw new CliError("Nothing was removed.", "Remove the dependents first, or keep these.");
847
+ }
848
+ const planned = [];
849
+ for (const name of names) for (const [path, hash] of Object.entries(config.installed[name]?.files ?? {})) planned.push({
850
+ component: name,
851
+ path,
852
+ state: classifyRemoval(join(cwd, path), hash)
853
+ });
854
+ const modified = planned.filter((file) => file.state === "modified");
855
+ const deletable = planned.filter((file) => file.state === "unchanged" || options.force && file.state === "modified");
856
+ if (modified.length > 0 && !options.force) {
857
+ logger.warn("These have local changes and will be kept:");
858
+ for (const file of modified) logger.info(` ${file.path}`);
859
+ logger.blank();
860
+ logger.info(pc.dim("Re-run with --force to delete them as well."));
861
+ }
862
+ if (deletable.length === 0) {
863
+ logger.blank();
864
+ logger.info("Nothing to delete.");
865
+ return;
866
+ }
867
+ if (!options.yes) {
868
+ logger.info(pc.dim("Will delete:"));
869
+ for (const file of deletable) logger.info(` ${file.path}`);
870
+ logger.blank();
871
+ const proceed = await prompts.confirm({
872
+ message: `Delete ${String(deletable.length)} file(s)?`,
873
+ initialValue: false
874
+ });
875
+ if (prompts.isCancel(proceed) || !proceed) throw new CliError("Cancelled — nothing was deleted.");
876
+ }
877
+ for (const file of deletable) rmSync(join(cwd, file.path), { force: true });
878
+ const deleted = new Set(deletable.map((file) => file.path));
879
+ for (const name of names) {
880
+ const entry = config.installed[name];
881
+ if (!entry) continue;
882
+ const remaining = Object.fromEntries(Object.entries(entry.files).filter(([path]) => !deleted.has(path)));
883
+ if (Object.keys(remaining).length === 0) delete config.installed[name];
884
+ else entry.files = remaining;
885
+ }
886
+ writeConfig(cwd, config);
887
+ logger.blank();
888
+ logger.success(`Removed ${String(deletable.length)} file(s).`);
889
+ if (modified.length > 0 && !options.force) logger.warn(`${String(modified.length)} locally modified file(s) were kept.`);
890
+ logger.blank();
891
+ logger.info(pc.dim("npm packages are left installed — other code may still use them."));
892
+ }
893
+ //#endregion
894
+ //#region src/commands/update.ts
895
+ /**
896
+ * Compares three versions of a file: what the registry has now, what we
897
+ * installed, and what is on disk.
898
+ *
899
+ * The three-way comparison is why the install hash had to be recorded from the
900
+ * very first release. Without it there is no way to distinguish "the user
901
+ * edited this" from "upstream changed this", and the only safe behaviour left
902
+ * is to never update anything.
903
+ */
904
+ function compareFile(absolutePath, incomingHash, recordedHash) {
905
+ if (!existsSync(absolutePath)) return "missing";
906
+ const currentHash = hashContent(readFileSync(absolutePath, "utf8"));
907
+ if (currentHash === incomingHash) return "current";
908
+ if (recordedHash === void 0) return "conflict";
909
+ if (currentHash === recordedHash) return "outdated";
910
+ return incomingHash === recordedHash ? "modified" : "conflict";
911
+ }
912
+ const STATE_LABEL = {
913
+ current: "up to date",
914
+ outdated: "update available",
915
+ modified: "locally modified",
916
+ conflict: "modified, and changed upstream",
917
+ missing: "missing"
918
+ };
919
+ async function update(names, options) {
920
+ const { cwd } = options;
921
+ const config = readConfig(cwd);
922
+ const registry = options.registry ?? config.registry;
923
+ const targets = names.length > 0 ? names : Object.keys(config.installed);
924
+ if (targets.length === 0) throw new CliError("Nothing is installed yet.", "Add a component first.");
925
+ const unknown = targets.filter((name) => !(name in config.installed));
926
+ if (unknown.length > 0) throw new CliError(`Not installed: ${unknown.join(", ")}.`, "Run `list` to see what is installed.");
927
+ const reports = [];
928
+ for (const name of targets) {
929
+ const item = await fetchItem(registry, name);
930
+ const recorded = config.installed[name]?.files ?? {};
931
+ for (const file of item.files) {
932
+ if (file.type === "registry:style") continue;
933
+ const destination = resolveDestination(config, file.path);
934
+ const content = rewriteImports(file.content, config);
935
+ const hash = hashContent(content);
936
+ reports.push({
937
+ component: name,
938
+ destination,
939
+ hash,
940
+ content,
941
+ state: compareFile(join(cwd, destination), hash, recorded[destination])
942
+ });
943
+ }
944
+ }
945
+ const actionable = reports.filter((report) => report.state === "outdated" || report.state === "missing");
946
+ const conflicts = reports.filter((report) => report.state === "conflict" || report.state === "modified");
947
+ logger.blank();
948
+ for (const report of reports) {
949
+ const colour = report.state === "current" ? pc.dim : report.state === "outdated" || report.state === "missing" ? pc.yellow : pc.red;
950
+ logger.info(` ${colour(STATE_LABEL[report.state].padEnd(30))} ${report.destination}`);
951
+ }
952
+ logger.blank();
953
+ if (actionable.length === 0 && conflicts.length === 0) {
954
+ logger.success("Everything is up to date.");
955
+ return;
956
+ }
957
+ const writable = options.overwrite ? [...actionable, ...conflicts] : actionable;
958
+ if (writable.length === 0) {
959
+ logger.warn("Only locally modified files differ; none were touched.");
960
+ logger.info(pc.dim("Re-run with --overwrite to replace them and lose those edits."));
961
+ return;
962
+ }
963
+ if (!options.yes) {
964
+ const proceed = await prompts.confirm({
965
+ message: options.overwrite ? `Overwrite ${String(writable.length)} file(s), discarding any local changes?` : `Update ${String(writable.length)} file(s)?`,
966
+ initialValue: !options.overwrite
967
+ });
968
+ if (prompts.isCancel(proceed) || !proceed) throw new CliError("Cancelled — nothing was changed.");
969
+ }
970
+ for (const report of writable) {
971
+ writeFileSync(join(cwd, report.destination), report.content);
972
+ const entry = config.installed[report.component];
973
+ if (entry) entry.files[report.destination] = report.hash;
974
+ }
975
+ writeConfig(cwd, config);
976
+ logger.blank();
977
+ logger.success(`Updated ${String(writable.length)} file(s).`);
978
+ if (!options.overwrite && conflicts.length > 0) logger.warn(`${String(conflicts.length)} locally modified file(s) were left alone.`);
979
+ }
980
+ //#endregion
981
+ //#region src/index.ts
982
+ const program = new Command();
983
+ program.name(branding.cliName).description(`Add ${branding.libraryName} components to your project as source you own.`).version("0.1.0").option("-c, --cwd <path>", "project root", process.cwd()).option("-r, --registry <url>", "registry base URL, or a directory on disk");
984
+ function globals() {
985
+ return program.opts();
986
+ }
987
+ program.command("init").description("set the project up: config, utilities and design tokens").option("-y, --yes", "accept every default and never prompt", false).option("--skip-install", "write files but do not install dependencies", false).action(async (options) => {
988
+ const { cwd, registry } = globals();
989
+ await init({
990
+ cwd,
991
+ registry: registry ?? branding.registryUrl,
992
+ yes: options.yes,
993
+ skipInstall: options.skipInstall
994
+ });
995
+ });
996
+ program.command("add").description("add one or more components, with everything they depend on").argument("[components...]", "component names").option("-y, --yes", "do not ask for confirmation", false).option("-o, --overwrite", "replace files that have local changes", false).option("--skip-install", "write files but do not install dependencies", false).action(async (components, options) => {
997
+ const { cwd, registry } = globals();
998
+ await add(components, {
999
+ cwd,
1000
+ registry,
1001
+ yes: options.yes,
1002
+ overwrite: options.overwrite,
1003
+ skipInstall: options.skipInstall
1004
+ });
1005
+ });
1006
+ program.command("list").alias("ls").description("list everything in the registry, marking what is installed").option("--category <name>", "show one category only").option("--json", "machine-readable output", false).action(async (options) => {
1007
+ const { cwd, registry } = globals();
1008
+ await list({
1009
+ cwd,
1010
+ registry,
1011
+ category: options.category,
1012
+ json: options.json
1013
+ });
1014
+ });
1015
+ program.command("remove").alias("rm").description("delete installed components, keeping anything you have edited").argument("[components...]", "component names").option("-y, --yes", "do not ask for confirmation", false).option("-f, --force", "delete files that have local changes too", false).action(async (components, options) => {
1016
+ const { cwd } = globals();
1017
+ await remove(components, {
1018
+ cwd,
1019
+ yes: options.yes,
1020
+ force: options.force
1021
+ });
1022
+ });
1023
+ program.command("update").description("compare installed components against the registry").argument("[components...]", "component names; defaults to everything installed").option("-y, --yes", "do not ask for confirmation", false).option("-o, --overwrite", "replace files that have local changes", false).action(async (components, options) => {
1024
+ const { cwd, registry } = globals();
1025
+ await update(components, {
1026
+ cwd,
1027
+ registry,
1028
+ yes: options.yes,
1029
+ overwrite: options.overwrite
1030
+ });
1031
+ });
1032
+ /**
1033
+ * A CliError is a message for the person running the command; anything else is
1034
+ * a bug, and its stack trace is the useful part.
1035
+ */
1036
+ async function main() {
1037
+ try {
1038
+ await program.parseAsync(process.argv);
1039
+ } catch (error) {
1040
+ logger.blank();
1041
+ if (error instanceof CliError) {
1042
+ logger.error(error.message);
1043
+ if (error.hint) logger.info(pc.dim(` ${error.hint}`));
1044
+ } else {
1045
+ logger.error("Something went wrong.");
1046
+ logger.info(String(error instanceof Error ? error.stack ?? error.message : error));
1047
+ }
1048
+ logger.blank();
1049
+ process.exitCode = 1;
1050
+ }
1051
+ }
1052
+ main();
1053
+ //#endregion
1054
+ export { add, init, list, remove, update };
1055
+
1056
+ //# sourceMappingURL=index.js.map