@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/LICENSE +21 -0
- package/dist/index.d.ts +49 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1056 -0
- package/dist/index.js.map +1 -0
- package/package.json +42 -0
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
|