layro-cli 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +107 -11
  2. package/dist/layro.mjs +1930 -140
  3. package/package.json +2 -2
package/dist/layro.mjs CHANGED
@@ -3,23 +3,1552 @@
3
3
  // src/index.ts
4
4
  import { parseArgs } from "node:util";
5
5
 
6
+ // src/add.ts
7
+ import { spawn } from "node:child_process";
8
+ import { randomBytes } from "node:crypto";
9
+ import { constants, existsSync as existsSync3, readFileSync, unlinkSync } from "node:fs";
10
+ import { mkdir, open, readFile as readFile3, rename, unlink } from "node:fs/promises";
11
+ import { delimiter, dirname as dirname4, join as join3, relative as relative4, resolve as resolve4 } from "node:path";
12
+
13
+ // src/init.ts
14
+ import { existsSync as existsSync2 } from "node:fs";
15
+ import { readFile as readFile2 } from "node:fs/promises";
16
+ import { join as join2 } from "node:path";
17
+
18
+ // src/install-project.ts
19
+ import { existsSync } from "node:fs";
20
+ import { readFile, realpath as realpath2, stat } from "node:fs/promises";
21
+ import { createRequire } from "node:module";
22
+ import { dirname as dirname3, isAbsolute, join, relative as relative3, resolve as resolve3 } from "node:path";
23
+
24
+ // src/install-safety.ts
25
+ import { lstat, realpath } from "node:fs/promises";
26
+ import { dirname, relative, resolve, sep } from "node:path";
27
+
28
+ // src/ui.ts
29
+ import { createInterface } from "node:readline/promises";
30
+ import { stripVTControlCharacters } from "node:util";
31
+
32
+ // src/install-error.ts
33
+ var CliError = class extends Error {
34
+ };
35
+
36
+ // src/ui.ts
37
+ var safeText = (text) => stripVTControlCharacters(text).replace(
38
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: Reject hostile control characters.
39
+ /[\x00-\x1f\x7f-\x9f\u202a-\u202e\u2066-\u2069]/g,
40
+ " "
41
+ );
42
+ function terminalData(value) {
43
+ if (typeof value === "string") return safeText(value);
44
+ if (Array.isArray(value)) return value.map(terminalData);
45
+ if (value && typeof value === "object")
46
+ return Object.fromEntries(
47
+ Object.entries(value).map(([key, entry]) => [key, terminalData(entry)])
48
+ );
49
+ return value;
50
+ }
51
+ var color = process.stdout.isTTY && process.env.NO_COLOR === void 0;
52
+ var paint = (code) => (text) => color ? `\x1B[${code}m${text}\x1B[0m` : text;
53
+ var bold = paint(1);
54
+ var dim = paint(2);
55
+ var green = paint(32);
56
+ var red = paint(31);
57
+ var yellow = paint(33);
58
+ var say = (text = "") => process.stdout.write(`${text}
59
+ `);
60
+ var warn = (text) => process.stderr.write(`${text}
61
+ `);
62
+ var canAsk = () => Boolean(process.stdin.isTTY && process.stdout.isTTY);
63
+ async function ask(question, fallback = "") {
64
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
65
+ try {
66
+ const hint = fallback ? dim(` (${fallback})`) : "";
67
+ const answer = (await rl.question(`${question}${hint}: `)).trim();
68
+ return answer || fallback;
69
+ } catch (error) {
70
+ if (error.name === "AbortError")
71
+ throw new CliError("Cancelled.");
72
+ throw error;
73
+ } finally {
74
+ rl.close();
75
+ }
76
+ }
77
+ async function confirm(question) {
78
+ const answer = (await ask(`${question} ${dim("[Y/n]")}`)).toLowerCase();
79
+ return answer === "" || answer === "y" || answer === "yes";
80
+ }
81
+ async function choose(question, options, fallback) {
82
+ options.forEach((option, i) => {
83
+ say(` ${dim(String(i + 1).padStart(2))} ${option.label}`);
84
+ });
85
+ const fallbackIndex = options.findIndex((o) => o.value === fallback);
86
+ for (; ; ) {
87
+ const answer = await ask(
88
+ question,
89
+ fallbackIndex >= 0 ? String(fallbackIndex + 1) : ""
90
+ );
91
+ const byNumber = options[Number(answer) - 1];
92
+ const byValue = options.find((o) => o.value === answer);
93
+ const picked = byNumber ?? byValue;
94
+ if (picked) return picked.value;
95
+ say(red(`Pick a number from 1 to ${options.length}.`));
96
+ }
97
+ }
98
+
99
+ // src/install-data.ts
100
+ function safePath(path) {
101
+ if (!path || path.length > 500 || // biome-ignore lint/suspicious/noControlCharactersInRegex: Reject hostile control characters.
102
+ /[\\:<>"|?*\x00-\x1f\x7f]/.test(path) || path.startsWith("/") || path.split("/").some(
103
+ (part) => !part || part === "." || part === ".." || /[. ]$/.test(part) || /^(con|prn|aux|nul|com[0-9]|lpt[0-9])(?:\.|$)/i.test(part) || part.startsWith(".") || /^(node_modules|package\.json|package-lock\.json|pnpm-lock\.yaml|yarn\.lock|bun\.lockb?|npm-shrinkwrap\.json|.*\.ya?ml|.*\.cmd|.*\.bat|.*\.ps1|.*\.sh)$/i.test(
104
+ part
105
+ )
106
+ ))
107
+ throw new CliError(`Refused unsafe file path: ${JSON.stringify(path)}.`);
108
+ return path;
109
+ }
110
+ var NAME = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/;
111
+ var VERSION_TOKEN = "(?:[0-9]+|[xX*])(?:\\.(?:[0-9]+|[xX*])){0,2}(?:-[0-9a-zA-Z.-]+)?(?:\\+[0-9a-zA-Z.-]+)?";
112
+ var RANGE = new RegExp(
113
+ `^(?:(?:\\^|~|>=?|<=?|=)?\\s*${VERSION_TOKEN})(?:(?:\\s+|\\s*\\|\\|\\s*|\\s+-\\s+)(?:\\^|~|>=?|<=?|=)?\\s*${VERSION_TOKEN})*$`
114
+ );
115
+ function packageSpec(spec) {
116
+ const at = spec.indexOf("@", 1);
117
+ const name = at < 0 ? spec : spec.slice(0, at);
118
+ const version = at < 0 ? void 0 : spec.slice(at + 1);
119
+ if (!NAME.test(name) || name.length > 214 || version !== void 0 && (!version || version.length > 500 || !RANGE.test(version) || /[\r\n\t]/.test(version) || !/[0-9xX*]/.test(version)))
120
+ throw new CliError(
121
+ `Refused unsafe npm dependency: ${JSON.stringify(spec)}.`
122
+ );
123
+ return { name, version };
124
+ }
125
+
126
+ // src/install-semver.ts
127
+ var compare = (a, b) => a[0] - b[0] || a[1] - b[1] || a[2] - b[2];
128
+ function partial(text) {
129
+ if (!/^(?:0|[1-9]\d*|[xX*])(?:\.(?:0|[1-9]\d*|[xX*])){0,2}$/.test(text))
130
+ return;
131
+ const parts = text.split(".");
132
+ const numbers = [];
133
+ let wildcard = false;
134
+ for (const part of parts) {
135
+ if (/^[xX*]$/.test(part)) wildcard = true;
136
+ else {
137
+ if (wildcard || !Number.isSafeInteger(Number(part))) return;
138
+ numbers.push(Number(part));
139
+ }
140
+ }
141
+ return {
142
+ version: [numbers[0] ?? 0, numbers[1] ?? 0, numbers[2] ?? 0],
143
+ length: numbers.length
144
+ };
145
+ }
146
+ function next(version, index) {
147
+ return version.map(
148
+ (value, i) => i < index ? value : i === index ? value + 1 : 0
149
+ );
150
+ }
151
+ function primitive(text) {
152
+ const match = /^(\^|~|>=|<=|>|<|=)?\s*(\S+)$/.exec(text);
153
+ if (!match) return;
154
+ const value = partial(match[2]);
155
+ if (!value) return;
156
+ const { version, length } = value;
157
+ const op = match[1] ?? "=";
158
+ if (!length)
159
+ return op === "=" || op === "^" || op === "~" || op === ">=" || op === "<=" ? {} : void 0;
160
+ const lower = { version, inclusive: true };
161
+ const upper = { version: next(version, length - 1), inclusive: false };
162
+ switch (op) {
163
+ case "=":
164
+ return length === 3 ? { lower, upper: lower } : { lower, upper };
165
+ case ">=":
166
+ return { lower };
167
+ case ">":
168
+ return {
169
+ lower: length === 3 ? { version, inclusive: false } : { version: upper.version, inclusive: true }
170
+ };
171
+ case "<":
172
+ return { upper: { version, inclusive: false } };
173
+ case "<=":
174
+ return { upper: length === 3 ? lower : upper };
175
+ case "~":
176
+ return {
177
+ lower,
178
+ upper: {
179
+ version: next(version, length === 1 ? 0 : 1),
180
+ inclusive: false
181
+ }
182
+ };
183
+ case "^": {
184
+ const index = version[0] || length === 1 ? 0 : version[1] || length === 2 ? 1 : 2;
185
+ return {
186
+ lower,
187
+ upper: { version: next(version, index), inclusive: false }
188
+ };
189
+ }
190
+ }
191
+ }
192
+ function ranges(wanted) {
193
+ if (!wanted.trim() || wanted.length > 500) return;
194
+ const alternatives = [];
195
+ for (const raw of wanted.split("||")) {
196
+ const text = raw.trim();
197
+ if (!text) return;
198
+ const hyphen = /^(\S+)\s+-\s+(\S+)$/.exec(text);
199
+ if (hyphen) {
200
+ const lower = partial(hyphen[1]);
201
+ const upper = partial(hyphen[2]);
202
+ if (!lower || !upper || !lower.length || !upper.length) return;
203
+ alternatives.push([
204
+ {
205
+ lower: { version: lower.version, inclusive: true },
206
+ upper: {
207
+ version: upper.length === 3 ? upper.version : next(upper.version, upper.length - 1),
208
+ inclusive: upper.length === 3
209
+ }
210
+ }
211
+ ]);
212
+ } else {
213
+ const tokens = text.replace(/(\^|~|>=|<=|>|<|=)\s+/g, "$1").split(/\s+/);
214
+ const intervals = [];
215
+ for (const token of tokens) {
216
+ const interval = primitive(token);
217
+ if (!interval) return;
218
+ intervals.push(interval);
219
+ }
220
+ alternatives.push(intervals);
221
+ }
222
+ }
223
+ return alternatives;
224
+ }
225
+ function satisfies(installed, wanted) {
226
+ if (!wanted) return true;
227
+ const parsed = ranges(wanted);
228
+ if (!parsed) return installed === wanted ? true : void 0;
229
+ if (/^\d+\.\d+\.\d+-/.test(installed)) return false;
230
+ const value = partial(installed);
231
+ if (value?.length !== 3) return;
232
+ return parsed.some(
233
+ (intervals) => intervals.every(
234
+ ({ lower, upper }) => (!lower || compare(value.version, lower.version) > 0 || lower.inclusive && compare(value.version, lower.version) === 0) && (!upper || compare(value.version, upper.version) < 0 || upper.inclusive && compare(value.version, upper.version) === 0)
235
+ )
236
+ );
237
+ }
238
+
239
+ // src/install-safety.ts
240
+ async function safeDestination(root, path) {
241
+ safePath(path);
242
+ const base = await realpath(root);
243
+ const target = resolve(base, path);
244
+ if (relative(base, target).startsWith(`..${sep}`) || target === base)
245
+ throw new CliError(`Refused path outside the project: ${path}.`);
246
+ let check = target;
247
+ while (check !== base) {
248
+ try {
249
+ if ((await lstat(check)).isSymbolicLink())
250
+ throw new CliError(`Refused symlink in destination: ${path}.`);
251
+ } catch (error) {
252
+ if (error.code !== "ENOENT") throw error;
253
+ }
254
+ check = dirname(check);
255
+ }
256
+ return target;
257
+ }
258
+
259
+ // src/install-paths.ts
260
+ import { dirname as dirname2, relative as relative2, resolve as resolve2 } from "node:path";
261
+
262
+ // src/install-imports.ts
263
+ function moduleSpecifiers(source) {
264
+ const found = [];
265
+ const tokens = [];
266
+ const pattern = /\/\*[\s\S]*?\*\/|\/\/[^\n]*|"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|`(?:[^`\\]|\\.)*`|[a-zA-Z_$][\w$]*|[^\s]/g;
267
+ for (const match of source.matchAll(pattern)) {
268
+ const token = match[0];
269
+ if (token.startsWith("//") || token.startsWith("/*")) continue;
270
+ const previous = tokens.at(-1);
271
+ const before = tokens.at(-2);
272
+ if (/^["'`]/.test(token) && !token.includes("${") && (previous === "from" || previous === "import" || previous === "(" && (before === "import" || before === "require"))) {
273
+ found.push({
274
+ spec: token.slice(1, -1),
275
+ start: match.index,
276
+ end: match.index + token.length,
277
+ quote: token[0]
278
+ });
279
+ }
280
+ tokens.push(token);
281
+ if (tokens.length > 2) tokens.shift();
282
+ }
283
+ return found;
284
+ }
285
+
286
+ // src/install-paths.ts
287
+ function aliasPath(project, alias) {
288
+ for (const [key, values] of Object.entries(project.paths).sort(
289
+ ([a], [b]) => b.length - a.length
290
+ )) {
291
+ const prefix = key.endsWith("*") ? key.slice(0, -1) : key;
292
+ if ((key.endsWith("*") ? alias.startsWith(prefix) : alias === key) && Array.isArray(values) && values[0]) {
293
+ const mapped = values[0].replace("*", alias.slice(prefix.length)).replace(/^\.\//, "");
294
+ return safePath(
295
+ relative2(
296
+ project.root,
297
+ resolve2(project.root, project.base, mapped)
298
+ ).replaceAll("\\", "/")
299
+ );
300
+ }
301
+ }
302
+ throw new CliError(
303
+ `Can't resolve alias ${alias}. Set its paths mapping in tsconfig.json or jsconfig.json.`
304
+ );
305
+ }
306
+ function rewriteAliases(content, project, destination) {
307
+ const mappings = [
308
+ ["@/lib/utils", project.aliases.utils],
309
+ ["@/components/ui", project.aliases.ui],
310
+ ["@/components", project.aliases.components],
311
+ ["@/lib", project.aliases.lib],
312
+ ["@/hooks", project.aliases.hooks]
313
+ ];
314
+ for (const { spec, start, end, quote } of moduleSpecifiers(
315
+ content
316
+ ).reverse()) {
317
+ const entry = mappings.find(
318
+ ([from]) => spec === from || spec.startsWith(`${from}/`)
319
+ );
320
+ if (!entry) continue;
321
+ const alias = entry[1] + spec.slice(entry[0].length);
322
+ let path = relative2(
323
+ dirname2(destination),
324
+ aliasPath(project, alias)
325
+ ).replaceAll("\\", "/");
326
+ if (!path.startsWith(".")) path = `./${path}`;
327
+ content = `${content.slice(0, start)}${quote}${path}${quote}${content.slice(end)}`;
328
+ }
329
+ return content;
330
+ }
331
+
332
+ // src/install-project.ts
333
+ async function readJson(path, jsonc = false) {
334
+ try {
335
+ let source = await readFile(path, "utf8");
336
+ if (jsonc) {
337
+ source = source.replace(
338
+ /"(?:[^"\\]|\\.)*"|\/\/[^\n]*|\/\*[\s\S]*?\*\//g,
339
+ (s) => s.startsWith('"') ? s : s.replace(/[^\n]/g, " ")
340
+ );
341
+ source = source.replace(
342
+ /"(?:[^"\\]|\\.)*"|,\s*([}\]])/g,
343
+ (match, close) => close ?? match
344
+ );
345
+ }
346
+ return JSON.parse(source);
347
+ } catch {
348
+ throw new CliError(`Can't read ${path}. Fix its JSON and try again.`);
349
+ }
350
+ }
351
+ var object = (v) => v && typeof v === "object" && !Array.isArray(v) ? v : {};
352
+ async function extendedConfig(spec, from) {
353
+ const file = async (path) => (await stat(path).catch(() => null))?.isFile() ? path : void 0;
354
+ if (isAbsolute(spec) || /^\.\.?\//.test(spec)) {
355
+ const path = resolve3(dirname3(from), spec);
356
+ const found = await file(path) ?? await file(`${path}.json`);
357
+ if (found) return found;
358
+ } else if (/^(?:@[\w.-]+\/)?[\w.-]+(?:\/[\w./-]+)*$/.test(spec)) {
359
+ const parts = spec.split("/");
360
+ const name = parts.splice(0, spec.startsWith("@") ? 2 : 1).join("/");
361
+ const subpath = parts.join("/");
362
+ const require2 = createRequire(from);
363
+ for (const directory of require2.resolve.paths(name) ?? []) {
364
+ const folder = join(directory, name);
365
+ const pkg = existsSync(join(folder, "package.json")) ? await readJson(join(folder, "package.json")) : {};
366
+ if (pkg.exports !== void 0) {
367
+ const exported = (() => {
368
+ try {
369
+ return require2.resolve(spec);
370
+ } catch {
371
+ return void 0;
372
+ }
373
+ })();
374
+ if (exported?.endsWith(".json") && await file(exported))
375
+ return exported;
376
+ break;
377
+ }
378
+ if (subpath) {
379
+ const path = join(folder, subpath);
380
+ const found = await file(path) ?? await file(`${path}.json`) ?? await file(join(path, "tsconfig.json"));
381
+ if (found) return found;
382
+ } else {
383
+ if (typeof pkg.tsconfig === "string") {
384
+ const path = resolve3(folder, pkg.tsconfig);
385
+ const found2 = await file(path) ?? await file(`${path}.json`);
386
+ if (found2) return found2;
387
+ }
388
+ const found = await file(join(folder, "tsconfig.json"));
389
+ if (found) return found;
390
+ }
391
+ }
392
+ }
393
+ throw new CliError(
394
+ `Cannot resolve tsconfig extends ${spec} from ${from}. Install the config package or fix extends, then rerun. Nothing written.`
395
+ );
396
+ }
397
+ async function configPaths(path, stack = /* @__PURE__ */ new Set()) {
398
+ path = await realpath2(path);
399
+ if (stack.has(path) || stack.size >= 32)
400
+ throw new CliError(
401
+ "Circular or too deep tsconfig extends. Fix the project config first."
402
+ );
403
+ const next2 = new Set(stack).add(path);
404
+ const config = await readJson(path, true);
405
+ const parents = config.extends === void 0 ? [] : Array.isArray(config.extends) ? config.extends : [config.extends];
406
+ let result = {};
407
+ for (const parent of parents) {
408
+ if (typeof parent !== "string")
409
+ throw new CliError(
410
+ "tsconfig extends must be a string or an array of strings."
411
+ );
412
+ result = {
413
+ ...result,
414
+ ...await configPaths(await extendedConfig(parent, path), next2)
415
+ };
416
+ }
417
+ const options = object(config.compilerOptions);
418
+ if (typeof options.baseUrl === "string")
419
+ result.baseUrl = resolve3(dirname3(path), options.baseUrl);
420
+ if (options.paths !== void 0) {
421
+ const paths = object(options.paths);
422
+ if (Object.values(paths).some(
423
+ (values) => !Array.isArray(values) || values.some((value) => typeof value !== "string")
424
+ ))
425
+ throw new CliError(
426
+ "tsconfig paths must map aliases to arrays of strings."
427
+ );
428
+ result.paths = paths;
429
+ result.pathsOrigin = dirname3(path);
430
+ }
431
+ return result;
432
+ }
433
+ async function detectProject(cwd) {
434
+ let root = await realpath2(resolve3(cwd)).catch(() => {
435
+ throw new CliError(`No folder ${cwd}. Check --cwd.`);
436
+ });
437
+ while (!existsSync(join(root, "package.json"))) {
438
+ const parent = dirname3(root);
439
+ if (parent === root)
440
+ throw new CliError(
441
+ "No project found. Create an app first: npm create vite@latest my-app -- --template react-ts, then cd my-app and npm install --ignore-scripts. Then run npx layro-cli init --yes for the Tailwind and alias setup steps. Init does not create an app. Nothing written."
442
+ );
443
+ root = parent;
444
+ }
445
+ const pkg = await readJson(join(root, "package.json"));
446
+ const dependencies = {
447
+ ...object(pkg.devDependencies),
448
+ ...object(pkg.dependencies)
449
+ };
450
+ let framework = "React";
451
+ if (dependencies.next)
452
+ framework = existsSync(join(root, "app")) || existsSync(join(root, "src/app")) ? "Next.js app" : "Next.js pages";
453
+ else if (dependencies.astro) framework = "Astro";
454
+ else if (dependencies["@remix-run/react"]) framework = "Remix";
455
+ else if (dependencies["@react-router/dev"]) framework = "React Router";
456
+ else if (dependencies.vite) framework = "Vite";
457
+ if (!dependencies.react && framework === "React")
458
+ throw new CliError(
459
+ "This package is not a React app. Run in the app package, or pass --cwd <app>. To start: npm create vite@latest my-app -- --template react-ts."
460
+ );
461
+ let manager;
462
+ let dir = root;
463
+ let declared = pkg.packageManager;
464
+ for (; ; ) {
465
+ const locks = [
466
+ ["pnpm-lock.yaml", "pnpm"],
467
+ ["yarn.lock", "yarn"],
468
+ ["bun.lock", "bun"],
469
+ ["bun.lockb", "bun"],
470
+ ["package-lock.json", "npm"],
471
+ ["npm-shrinkwrap.json", "npm"]
472
+ ].filter(([lock]) => existsSync(join(dir, lock)));
473
+ const managers = [...new Set(locks.map(([, pm]) => pm))];
474
+ if (managers.length > 1)
475
+ throw new CliError(
476
+ `Conflicting lockfiles in ${dir}. Keep the lockfile for the package manager you use.`
477
+ );
478
+ if (managers.length) {
479
+ manager = managers[0];
480
+ break;
481
+ }
482
+ const parent = dirname3(dir);
483
+ if (parent === dir) break;
484
+ if (existsSync(join(parent, "package.json"))) {
485
+ const ancestor = await readJson(join(parent, "package.json"));
486
+ if (!ancestor.workspaces && !existsSync(join(parent, "pnpm-workspace.yaml")))
487
+ break;
488
+ declared ??= ancestor.packageManager;
489
+ }
490
+ dir = parent;
491
+ }
492
+ manager ??= typeof declared === "string" && /^(npm|pnpm|yarn|bun)@/.test(declared) ? declared.split("@")[0] : "npm";
493
+ const yarnModern = manager === "yarn" && (/^yarn@(?:[2-9]|\d{2})/.test(String(declared)) || existsSync(join(dir, ".yarnrc.yml")) || existsSync(join(dir, "yarn.lock")) && /^__metadata:/m.test(await readFile(join(dir, "yarn.lock"), "utf8")));
494
+ let paths = {};
495
+ let base = "";
496
+ for (const file of ["jsconfig.json", "tsconfig.json", "tsconfig.app.json"]) {
497
+ if (existsSync(join(root, file))) {
498
+ const config2 = await configPaths(join(root, file));
499
+ if (config2.paths) {
500
+ paths = config2.paths;
501
+ base = relative3(
502
+ root,
503
+ config2.baseUrl ?? config2.pathsOrigin ?? root
504
+ ).replaceAll("\\", "/");
505
+ }
506
+ }
507
+ }
508
+ const config = existsSync(join(root, "components.json")) ? await readJson(join(root, "components.json")) : null;
509
+ const source = framework === "React Router" || framework === "Remix" ? "app" : existsSync(join(root, "src")) ? "src" : "";
510
+ const prefix = Object.entries(paths).find(
511
+ ([key, values]) => key.endsWith("/*") && Array.isArray(values) && values[0]?.replace(/^\.\//, "").replace(/\/\*$/, "") === (source || ".")
512
+ )?.[0].slice(0, -2);
513
+ const defaultPrefix = prefix ?? "@";
514
+ const inferredPaths = !prefix && !paths[`${defaultPrefix}/*`];
515
+ if (inferredPaths)
516
+ paths[`${defaultPrefix}/*`] = [
517
+ `${relative3(resolve3(root, base), resolve3(root, source)).replaceAll("\\", "/") || "."}/*`
518
+ ];
519
+ const defaults = {
520
+ components: `${defaultPrefix}/components`,
521
+ ui: `${defaultPrefix}/components/ui`,
522
+ lib: `${defaultPrefix}/lib`,
523
+ utils: `${defaultPrefix}/lib/utils`,
524
+ hooks: `${defaultPrefix}/hooks`
525
+ };
526
+ const aliases = { ...defaults, ...object(config?.aliases) };
527
+ for (const value of Object.values(aliases))
528
+ if (typeof value !== "string")
529
+ throw new CliError("components.json aliases must be strings.");
530
+ const tailwind = object(config?.tailwind);
531
+ const candidates = framework.startsWith("Next.js") ? [
532
+ `${source ? `${source}/` : ""}app/globals.css`,
533
+ `${source ? `${source}/` : ""}styles/globals.css`
534
+ ] : [
535
+ ...framework === "Vite" ? [`${source ? `${source}/` : ""}index.css`] : [],
536
+ `${source ? `${source}/` : ""}app.css`,
537
+ `${source ? `${source}/` : ""}index.css`,
538
+ `${source ? `${source}/` : ""}styles/global.css`,
539
+ "app.css"
540
+ ];
541
+ const css = typeof tailwind.css === "string" && tailwind.css ? tailwind.css : candidates.find((p) => existsSync(join(root, p)));
542
+ if (css) safePath(css);
543
+ return {
544
+ root,
545
+ manager,
546
+ yarnModern,
547
+ framework,
548
+ config,
549
+ paths,
550
+ inferredPaths,
551
+ base,
552
+ aliases,
553
+ css,
554
+ rsc: typeof config?.rsc === "boolean" ? config.rsc : framework === "Next.js app",
555
+ tsx: typeof config?.tsx === "boolean" ? config.tsx : existsSync(join(root, "tsconfig.json")),
556
+ style: typeof config?.style === "string" ? config.style : "new-york",
557
+ dependencies
558
+ };
559
+ }
560
+
561
+ // src/install-setup.ts
562
+ var safeText2 = (text) => text.replace(/[\r\n]/g, " ");
563
+ var CN_SOURCE = 'import { clsx, type ClassValue } from "clsx";\nimport { twMerge } from "tailwind-merge";\n\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs));\n}\n';
564
+ function dependencyCommand(project, packages) {
565
+ const noScripts = project.yarnModern ? "--mode=skip-build" : "--ignore-scripts";
566
+ return `${project.manager} ${project.manager === "npm" ? "install" : "add"} -D ${noScripts} ${packages}`;
567
+ }
568
+ function setupGuide(project, hasSrc = true) {
569
+ const vite = ["Vite", "React Router", "Remix", "Astro"].includes(
570
+ project.framework
571
+ );
572
+ const source = project.framework === "React Router" || project.framework === "Remix" ? "app" : hasSrc ? "src" : ".";
573
+ const css = project.css ?? (project.framework.startsWith("Next.js") ? `${source === "." ? "" : `${source}/`}${project.rsc ? "app" : "styles"}/globals.css` : `${source === "." ? "" : `${source}/`}index.css`);
574
+ const lines = [
575
+ `Project: ${project.framework}, ${project.manager}. From ${safeText2(project.root)}:`,
576
+ dependencyCommand(
577
+ project,
578
+ vite ? "tailwindcss@4 @tailwindcss/vite@4" : "tailwindcss@4 @tailwindcss/postcss@4 postcss"
579
+ )
580
+ ];
581
+ if (project.framework === "Astro") {
582
+ lines.push(
583
+ 'In astro.config.mjs, import tailwindcss from "@tailwindcss/vite" and add tailwindcss() to vite.plugins (keep existing plugins).'
584
+ );
585
+ } else if (vite) {
586
+ lines.push(
587
+ 'In vite.config.ts, import tailwindcss from "@tailwindcss/vite" and add tailwindcss() to plugins (keep existing plugins).'
588
+ );
589
+ } else {
590
+ lines.push(
591
+ 'In postcss.config.mjs, configure: export default { plugins: { "@tailwindcss/postcss": {} } }; Merge with any existing plugins.'
592
+ );
593
+ }
594
+ lines.push(
595
+ `In ${css}, add @import "tailwindcss"; before other CSS. Keep your styles. Import that stylesheet in your app entry or root layout.`
596
+ );
597
+ if (project.inferredPaths) {
598
+ const mapping = source === "." ? "./*" : `./${source}/*`;
599
+ lines.push(
600
+ `In tsconfig.json compilerOptions, merge "paths": { "@/*": ["${mapping}"] }. In Vite's split TypeScript template, also add it to tsconfig.app.json. Paths are relative to baseUrl when present; adjust that mapping to reach ${source}.`
601
+ );
602
+ if (vite)
603
+ lines.push(
604
+ `For Vite, also import { fileURLToPath, URL } from "node:url" in vite.config.ts and merge resolve: { alias: { "@": fileURLToPath(new URL("./${source === "." ? "" : source}", import.meta.url)) } }. Keep existing aliases. If node:url lacks types: ${dependencyCommand(project, "@types/node")}. For Astro, put the alias under vite.resolve in astro.config.mjs.`
605
+ );
606
+ }
607
+ lines.push(
608
+ "Then run: npx layro-cli init --yes",
609
+ "Tailwind setup: https://tailwindcss.com/docs/installation/using-vite (Next.js: https://tailwindcss.com/docs/installation/framework-guides/nextjs)"
610
+ );
611
+ return lines.join("\n");
612
+ }
613
+
614
+ // src/init.ts
615
+ var SetupRequired = class extends CliError {
616
+ constructor(message, instructions) {
617
+ super(`${message}
618
+ ${instructions}`);
619
+ }
620
+ };
621
+ function setupGuide2(project) {
622
+ return setupGuide(project, existsSync2(join2(project.root, "src")));
623
+ }
624
+ async function initConfig(project) {
625
+ if (!project.dependencies.react)
626
+ throw new CliError(
627
+ "React is missing. Install React in the app package before running init. Nothing written."
628
+ );
629
+ const css = project.css ? await readFile2(
630
+ await safeDestination(project.root, project.css),
631
+ "utf8"
632
+ ).catch((error) => {
633
+ if (error.code === "ENOENT") return "";
634
+ throw error;
635
+ }) : "";
636
+ const active = css.replace(/\/\*[\s\S]*?\*\//g, "");
637
+ const v4 = /@import\s+(?:url\(\s*)?["']tailwindcss(?:\/utilities(?:\.css)?)?["']/.test(
638
+ active
639
+ );
640
+ const v3 = /@tailwind\s+utilities\s*;/.test(active);
641
+ if (!project.dependencies.tailwindcss || !(v4 || v3))
642
+ throw new SetupRequired(
643
+ "Tailwind is not ready: init needs its package and a stylesheet with Tailwind directives. Nothing written.",
644
+ setupGuide2(project)
645
+ );
646
+ if (project.inferredPaths)
647
+ throw new SetupRequired(
648
+ "Set up real import aliases before init. Layro will not guess how to rewrite your TypeScript or bundler config. Nothing written.",
649
+ setupGuide2(project)
650
+ );
651
+ for (const alias of Object.values(project.aliases)) aliasPath(project, alias);
652
+ if (project.config) return null;
653
+ const tailwindConfig = v4 ? "" : [
654
+ "tailwind.config.ts",
655
+ "tailwind.config.js",
656
+ "tailwind.config.cjs",
657
+ "tailwind.config.mjs"
658
+ ].find((file) => existsSync2(join2(project.root, file)));
659
+ if (tailwindConfig === void 0)
660
+ throw new CliError(
661
+ "Tailwind v3 needs a tailwind.config file. Set it up, then run init again. Nothing written."
662
+ );
663
+ return `${JSON.stringify(
664
+ {
665
+ $schema: "https://ui.shadcn.com/schema.json",
666
+ style: project.style,
667
+ rsc: project.rsc,
668
+ tsx: true,
669
+ tailwind: {
670
+ config: tailwindConfig,
671
+ css: project.css,
672
+ baseColor: "neutral",
673
+ cssVariables: true
674
+ },
675
+ aliases: project.aliases
676
+ },
677
+ null,
678
+ 2
679
+ )}
680
+ `;
681
+ }
682
+
683
+ // src/install-css.ts
684
+ import { createHash } from "node:crypto";
685
+ function mergeCss(source, item, tailwind4) {
686
+ let css = source;
687
+ const manual = [];
688
+ const append = (block) => {
689
+ const hash = createHash("sha256").update(block).digest("hex").slice(0, 16);
690
+ const marker = `/* layro:${hash} */`;
691
+ if (!css.includes(marker))
692
+ css = `${css.trimEnd()}
693
+
694
+ ${marker}
695
+ ${block}
696
+ `;
697
+ };
698
+ for (const [group, values] of Object.entries(item.cssVars ?? {})) {
699
+ if (!["theme", "light", "dark"].includes(group) || !values || typeof values !== "object" || Array.isArray(values)) {
700
+ manual.push(`Apply cssVars.${group} manually: ${JSON.stringify(values)}`);
701
+ continue;
702
+ }
703
+ if (group === "theme" && !tailwind4) {
704
+ manual.push(`Tailwind v4 theme needed: ${JSON.stringify(values)}`);
705
+ continue;
706
+ }
707
+ const selector = group === "theme" ? "@theme inline" : group === "dark" ? ".dark" : ":root";
708
+ const pattern = group === "theme" ? /@theme(?: inline)?\s*\{([^{}]*)\}/g : group === "dark" ? /(?:^|\n)\.dark\s*\{([^{}]*)\}/g : /(?:^|\n):root\s*\{([^{}]*)\}/g;
709
+ const scopes = [...css.matchAll(pattern)];
710
+ const scope = scopes.map((match) => match[1]).join("\n");
711
+ const declarations = [];
712
+ for (const [raw, value] of Object.entries(values)) {
713
+ const key = raw.replace(/^--/, "");
714
+ if (!/^[a-zA-Z0-9_-]+$/.test(key) || typeof value !== "string" || // biome-ignore lint/suspicious/noControlCharactersInRegex: Reject hostile control characters.
715
+ /[{};\x00-\x1f]/.test(value)) {
716
+ manual.push(
717
+ `Apply CSS variable ${raw} manually: ${JSON.stringify(value)}`
718
+ );
719
+ continue;
720
+ }
721
+ const declaration = `--${key}: ${value};`;
722
+ if (scope.includes(declaration)) continue;
723
+ if (new RegExp(`--${key}\\s*:`).test(scope)) {
724
+ manual.push(`Check existing --${key}; requested ${value} (${group}).`);
725
+ continue;
726
+ }
727
+ declarations.push(` ${declaration}`);
728
+ }
729
+ if (declarations.length) {
730
+ const last = scopes.at(-1);
731
+ if (last && last.index !== void 0) {
732
+ const at = last.index + last[0].lastIndexOf("}");
733
+ css = `${css.slice(0, at).trimEnd()}
734
+ ${declarations.join("\n")}
735
+ ${css.slice(at)}`;
736
+ } else append(`${selector} {
737
+ ${declarations.join("\n")}
738
+ }`);
739
+ }
740
+ }
741
+ const render = (selector, value, depth = 0) => {
742
+ if (depth > 10 || !value || typeof value !== "object" || Array.isArray(value) || // biome-ignore lint/suspicious/noControlCharactersInRegex: Reject hostile control characters.
743
+ /[{};\x00-\x1f]/.test(selector))
744
+ return null;
745
+ if (selector.startsWith("@") && !/^@(keyframes [a-zA-Z0-9_-]+|layer (base|components|utilities)|utility [a-zA-Z0-9_-]+)$/.test(
746
+ selector
747
+ ))
748
+ return null;
749
+ const lines = [];
750
+ for (const [key, v] of Object.entries(value)) {
751
+ if (typeof v === "object") {
752
+ const nested = render(key, v, depth + 1);
753
+ if (!nested) return null;
754
+ lines.push(nested);
755
+ } else {
756
+ if (!/^[-a-zA-Z0-9]+$/.test(key) || typeof v !== "string" && typeof v !== "number" || // biome-ignore lint/suspicious/noControlCharactersInRegex: Reject hostile control characters.
757
+ /[{};\x00-\x1f]/.test(String(v)))
758
+ return null;
759
+ const property = key.startsWith("--") ? key : key.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);
760
+ lines.push(`${property}: ${v};`);
761
+ }
762
+ }
763
+ return `${selector} {
764
+ ${lines.map((line) => ` ${line}`).join("\n")}
765
+ }`;
766
+ };
767
+ for (const [selector, value] of Object.entries(item.css ?? {})) {
768
+ const block = render(selector, value);
769
+ if (!block)
770
+ manual.push(
771
+ `Apply CSS manually: ${JSON.stringify({ [selector]: value })}`
772
+ );
773
+ else if (css.includes(block)) continue;
774
+ else if (new RegExp(
775
+ `${selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*\\{`
776
+ ).test(css))
777
+ manual.push(`Existing CSS rule ${selector}; merge manually: ${block}`);
778
+ else append(block);
779
+ }
780
+ if (item.tailwind)
781
+ manual.push(
782
+ `Apply Tailwind config manually: ${JSON.stringify(item.tailwind)}`
783
+ );
784
+ return { css, manual };
785
+ }
786
+
787
+ // src/install-guidance.ts
788
+ function nextPrerenderGuidance(project, sources) {
789
+ const major = Number(project.dependencies.next?.match(/\d+/)?.[0]);
790
+ if (project.framework !== "Next.js app" || major < 16 || !Number.isFinite(major))
791
+ return [];
792
+ if (!sources.some(
793
+ (source) => /\b(?:Math\s*\.\s*random|Date\s*\.\s*now|crypto\s*\.\s*(?:randomUUID|getRandomValues))\s*\(|\bnew\s+Date\s*\(\s*\)|\buseSearchParams\s*\(/.test(
794
+ source
795
+ )
796
+ ))
797
+ return [];
798
+ return [
799
+ 'Next.js 16+ App Router: installed source mentions randomness, current time or useSearchParams. With Cache Components/prerendering, calls during render can require a Suspense fallback. Calls only in effects or event handlers do not need this random/time fix. If next build reports a missing fallback, wrap your component usage in an ancestor boundary: import { Suspense } from "react"; then <Suspense fallback={<p>Loading component...</p>}><YourComponent {...props} /></Suspense>. Put the boundary outside the component that makes the call (for example, wrap your client demo in app/page.tsx). Keep the author source unchanged. A Suspense boundary does not repair an unrelated hydration mismatch. https://nextjs.org/docs/messages/next-prerender-random-client'
800
+ ];
801
+ }
802
+
803
+ // src/version.ts
804
+ var VERSION = true ? "0.4.0" : "dev";
805
+
806
+ // src/install-registry.ts
807
+ var record = (v) => Boolean(v && typeof v === "object" && !Array.isArray(v));
808
+ function validateItem(raw) {
809
+ if (!record(raw) || typeof raw.name !== "string" || !raw.name || typeof raw.type !== "string" || !raw.type.startsWith("registry:") || !Array.isArray(raw.files))
810
+ throw new CliError(
811
+ "Registry returned the wrong item shape (expected name, type and files)."
812
+ );
813
+ if (raw.files.length > 200)
814
+ throw new CliError("Registry item has too many files (maximum 200).");
815
+ for (const file of raw.files) {
816
+ if (!record(file) || typeof file.path !== "string" || typeof file.content !== "string" || typeof file.type !== "string" || file.target !== void 0 && typeof file.target !== "string")
817
+ throw new CliError(
818
+ "Registry returned an invalid file (expected path, type and content)."
819
+ );
820
+ safePath(file.path);
821
+ if (file.target)
822
+ safePath(file.target.replace(/^@(components|ui|lib|hooks)\//, "$1/"));
823
+ }
824
+ const strings = (key) => {
825
+ if (raw[key] === void 0) return [];
826
+ if (!Array.isArray(raw[key]) || !raw[key].every((v) => typeof v === "string"))
827
+ throw new CliError(`Registry ${key} must be an array of strings.`);
828
+ return raw[key];
829
+ };
830
+ const dependencies = strings("dependencies");
831
+ const devDependencies = strings("devDependencies");
832
+ [...dependencies, ...devDependencies].forEach(packageSpec);
833
+ if (raw.css !== void 0 && !record(raw.css) || raw.cssVars !== void 0 && !record(raw.cssVars))
834
+ throw new CliError("Registry css and cssVars must be objects.");
835
+ return {
836
+ ...raw,
837
+ dependencies,
838
+ devDependencies,
839
+ registryDependencies: strings("registryDependencies")
840
+ };
841
+ }
842
+ function registryBase(server2) {
843
+ let url;
844
+ try {
845
+ url = new URL(server2);
846
+ } catch {
847
+ throw new CliError(
848
+ "Invalid LAYRO_URL. Use https://layro.dev, or http://localhost:<port> for local development."
849
+ );
850
+ }
851
+ if (url.username || url.password || url.pathname !== "/" || url.search || url.hash || url.protocol !== "https:" && !(process.env.LAYRO_URL && url.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname)))
852
+ throw new CliError(
853
+ "Registry requires HTTPS. Only localhost through LAYRO_URL may use HTTP."
854
+ );
855
+ return url.origin;
856
+ }
857
+ function itemUrl(input, base) {
858
+ let id = input.replace(/^@/, "");
859
+ if (/^https?:/.test(input)) {
860
+ let url;
861
+ try {
862
+ url = new URL(input);
863
+ } catch {
864
+ throw new CliError("Invalid component URL.");
865
+ }
866
+ if (url.origin !== base && url.origin !== "https://layro.dev")
867
+ throw new CliError(
868
+ `Refused registry host ${url.host}. Use a Layro component.`
869
+ );
870
+ if (url.username || url.password || url.search || url.hash)
871
+ throw new CliError(
872
+ "Use a plain Layro component URL without credentials, a query or fragment."
873
+ );
874
+ id = url.pathname.replace(/^\/@/, "").replace(/^\/r\//, "").replace(/\.json$/, "");
875
+ }
876
+ if (!/^[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*$/i.test(id))
877
+ throw new CliError(
878
+ `Use @author/slug or a Layro component URL: ${JSON.stringify(input)}.`
879
+ );
880
+ return `${base}/r/${id}.json`;
881
+ }
882
+ var MAX_BYTES = 5e6;
883
+ async function fetchItem(url, attempts = 3) {
884
+ for (let attempt = 0; attempt < attempts; attempt++) {
885
+ try {
886
+ const response = await fetch(url, {
887
+ redirect: "manual",
888
+ signal: AbortSignal.timeout(2e4),
889
+ headers: {
890
+ Accept: "application/json",
891
+ "User-Agent": `layro-cli/${VERSION}`
892
+ }
893
+ });
894
+ if (response.status >= 300 && response.status < 400) {
895
+ const target = new URL(response.headers.get("location") ?? url, url);
896
+ await response.body?.cancel();
897
+ throw new CliError(
898
+ `Registry redirect refused (${target.host}). Use the direct registry URL.`
899
+ );
900
+ }
901
+ if (response.status === 429 && attempt + 1 < attempts) {
902
+ const retry = response.headers.get("retry-after");
903
+ const seconds = retry && /^\d+$/.test(retry) ? Number(retry) : retry ? Math.ceil((Date.parse(retry) - Date.now()) / 1e3) : 2 ** attempt;
904
+ await response.body?.cancel();
905
+ if (!Number.isFinite(seconds) || seconds > 60)
906
+ throw new CliError(
907
+ "Registry rate limit reached. Wait for Retry-After (over 60 seconds), then run add again."
908
+ );
909
+ await new Promise((r) => setTimeout(r, Math.max(1, seconds) * 1e3));
910
+ continue;
911
+ }
912
+ if (!response.ok) {
913
+ await response.body?.cancel();
914
+ throw new CliError(
915
+ response.status === 404 ? `Component or dependency not found: ${url}. Check its name.` : response.status === 429 ? "Registry rate limit reached (429). Wait a minute and try again." : `Registry answered ${response.status} for ${url}. Try again later.`
916
+ );
917
+ }
918
+ if (Number(response.headers.get("content-length")) > MAX_BYTES) {
919
+ await response.body?.cancel();
920
+ throw new CliError("Registry response is too large (maximum 5 MB).");
921
+ }
922
+ const reader = response.body?.getReader();
923
+ if (!reader) throw new CliError("Registry returned an empty response.");
924
+ const chunks = [];
925
+ let length = 0;
926
+ for (; ; ) {
927
+ const { value, done } = await reader.read();
928
+ if (done) break;
929
+ length += value.length;
930
+ if (length > MAX_BYTES) {
931
+ await reader.cancel();
932
+ throw new CliError("Registry response is too large (maximum 5 MB).");
933
+ }
934
+ chunks.push(value);
935
+ }
936
+ let raw;
937
+ try {
938
+ raw = JSON.parse(Buffer.concat(chunks).toString("utf8"));
939
+ } catch {
940
+ throw new CliError("Registry returned invalid JSON. Try again later.");
941
+ }
942
+ return validateItem(raw);
943
+ } catch (error) {
944
+ if (error instanceof CliError) throw error;
945
+ if (["TimeoutError", "AbortError"].includes(error.name))
946
+ throw new CliError(
947
+ `Registry request timed out: ${url}. Check your connection and try again.`
948
+ );
949
+ throw new CliError(
950
+ `Can't reach the registry at ${new URL(url).host}. Check your connection or LAYRO_URL.`
951
+ );
952
+ }
953
+ }
954
+ throw new CliError("Registry request failed. Try again later.");
955
+ }
956
+ async function resolveItems(roots, base, style, load2 = fetchItem) {
957
+ if (!/^[a-z0-9-]+$/.test(style))
958
+ throw new CliError("Unsupported shadcn style in components.json.");
959
+ const result = [];
960
+ const visiting = /* @__PURE__ */ new Set();
961
+ const visited = /* @__PURE__ */ new Set();
962
+ const visit = async (url) => {
963
+ if (visiting.has(url))
964
+ throw new CliError(
965
+ `Circular registry dependency: ${url}. Nothing written.`
966
+ );
967
+ if (visited.has(url)) return;
968
+ if (visiting.size + visited.size >= 200)
969
+ throw new CliError(
970
+ "Too many registry dependencies (maximum 200). Nothing written."
971
+ );
972
+ visiting.add(url);
973
+ const item = await load2(url);
974
+ for (const dependency of item.registryDependencies) {
975
+ if (dependency === "utils") continue;
976
+ let next2;
977
+ if (/^https?:/.test(dependency)) {
978
+ const parsed = new URL(dependency);
979
+ if (parsed.origin !== base && !(parsed.origin === "https://layro.dev" && base !== "https://layro.dev"))
980
+ throw new CliError(
981
+ `Refused dependency host ${parsed.host}. Only Layro registry URLs and shadcn names are accepted.`
982
+ );
983
+ next2 = itemUrl(dependency, base);
984
+ } else if (/^[a-z0-9][a-z0-9-]*$/.test(dependency)) {
985
+ next2 = `https://ui.shadcn.com/r/styles/${style}/${dependency}.json`;
986
+ } else
987
+ throw new CliError(
988
+ `Invalid registry dependency: ${JSON.stringify(dependency)}.`
989
+ );
990
+ await visit(next2);
991
+ }
992
+ visiting.delete(url);
993
+ visited.add(url);
994
+ result.push({ url, item });
995
+ };
996
+ for (const url of roots) await visit(url);
997
+ return result;
998
+ }
999
+
1000
+ // src/install-destination.ts
1001
+ function fileDestination(file, project, override) {
1002
+ safePath(file.path);
1003
+ if (file.target) {
1004
+ const target = file.target.replace(
1005
+ /^@(components|ui|lib|hooks)(?=\/)/,
1006
+ "$1"
1007
+ );
1008
+ safePath(target);
1009
+ const mappings = [
1010
+ ["components/ui", "ui"],
1011
+ ["ui", "ui"],
1012
+ ["components", "components"],
1013
+ ["lib", "lib"],
1014
+ ["hooks", "hooks"]
1015
+ ];
1016
+ const entry = mappings.find(([prefix]) => target.startsWith(`${prefix}/`));
1017
+ if (entry)
1018
+ return safePath(
1019
+ `${override && entry[1] === "components" ? override : aliasPath(project, project.aliases[entry[1]])}/${target.slice(entry[0].length + 1)}`
1020
+ );
1021
+ return target;
1022
+ }
1023
+ const kind = file.type;
1024
+ const key = kind === "registry:ui" ? "ui" : kind === "registry:lib" ? "lib" : kind === "registry:hook" ? "hooks" : "components";
1025
+ const folder = override && key === "components" ? override : aliasPath(project, project.aliases[key]);
1026
+ const canonical = /^(components\/ui|components|lib|hooks)\/(.+)$/.exec(
1027
+ file.path
1028
+ );
1029
+ if (canonical)
1030
+ return fileDestination({ ...file, target: file.path }, project, override);
1031
+ const suffix = file.path.split("/").at(-1);
1032
+ return safePath(`${folder}/${suffix}`);
1033
+ }
1034
+
1035
+ // src/add.ts
1036
+ async function existing(root, path) {
1037
+ const target = await safeDestination(root, path);
1038
+ try {
1039
+ return await readFile3(target, "utf8");
1040
+ } catch (error) {
1041
+ if (error.code === "ENOENT") return null;
1042
+ throw error;
1043
+ }
1044
+ }
1045
+ function managerCommand(manager, args, platform = process.platform, pathEnv = process.env.PATH ?? "") {
1046
+ if (platform !== "win32") return { command: manager, args };
1047
+ for (const directory of pathEnv.split(
1048
+ platform === process.platform ? delimiter : ";"
1049
+ )) {
1050
+ const exe = join3(directory, `${manager}.exe`);
1051
+ if (existsSync3(exe)) return { command: exe, args };
1052
+ const shim = join3(directory, `${manager}.cmd`);
1053
+ if (!existsSync3(shim)) continue;
1054
+ const body = readFileSync(shim, "utf8");
1055
+ const hit = body.match(/"%(?:~dp0|dp0%)%?\\([^"\r\n]+\.(?:c?js|mjs))"/i);
1056
+ if (hit) {
1057
+ const entry = resolve4(directory, hit[1].replaceAll("\\", "/"));
1058
+ if (existsSync3(entry))
1059
+ return { command: process.execPath, args: [entry, ...args] };
1060
+ }
1061
+ throw new CliError(
1062
+ `Cannot safely run ${manager}.cmd. Install ${manager} with a Node JS entry or use a native executable.`
1063
+ );
1064
+ }
1065
+ throw new CliError(
1066
+ `${manager} is not on PATH. Install it and run add again.`
1067
+ );
1068
+ }
1069
+ var installing;
1070
+ function yarnIsModern(root) {
1071
+ const { command, args } = managerCommand("yarn", ["--version"]);
1072
+ return new Promise((done) => {
1073
+ const child = spawn(command, args, {
1074
+ cwd: root,
1075
+ shell: false,
1076
+ stdio: ["ignore", "pipe", "ignore"],
1077
+ timeout: 3e4
1078
+ });
1079
+ let out = "";
1080
+ child.stdout?.on("data", (data) => {
1081
+ out += data;
1082
+ });
1083
+ child.once("error", () => done(false));
1084
+ child.once("close", () => done(Number.parseInt(out, 10) >= 2));
1085
+ });
1086
+ }
1087
+ async function installPackages(project, specs, dev, json) {
1088
+ if (!specs.length) return;
1089
+ specs.forEach(packageSpec);
1090
+ const fresh = specs.filter(
1091
+ (spec) => !project.dependencies[packageSpec(spec).name]
1092
+ );
1093
+ const yarnSettings = project.manager === "yarn" && await yarnIsModern(project.root);
1094
+ const noScripts = yarnSettings ? [] : ["--ignore-scripts"];
1095
+ const invocations = [];
1096
+ if (fresh.length !== specs.length)
1097
+ invocations.push(["install", ...noScripts]);
1098
+ if (fresh.length)
1099
+ invocations.push([
1100
+ project.manager === "npm" ? "install" : "add",
1101
+ ...noScripts,
1102
+ ...dev ? ["-D"] : [],
1103
+ ...fresh
1104
+ ]);
1105
+ for (const args of invocations) {
1106
+ const invocation = managerCommand(project.manager, args);
1107
+ await new Promise((resolveDone, reject) => {
1108
+ const child = spawn(invocation.command, invocation.args, {
1109
+ cwd: project.root,
1110
+ shell: false,
1111
+ stdio: json ? ["ignore", "pipe", "pipe"] : "inherit",
1112
+ timeout: 18e4,
1113
+ env: yarnSettings ? { ...process.env, YARN_ENABLE_SCRIPTS: "false" } : process.env
1114
+ });
1115
+ installing = child;
1116
+ child.stdout?.resume();
1117
+ child.stderr?.resume();
1118
+ child.once(
1119
+ "error",
1120
+ () => reject(
1121
+ new CliError(
1122
+ `Could not start ${project.manager}. Install it, then run add again.`
1123
+ )
1124
+ )
1125
+ );
1126
+ child.once(
1127
+ "exit",
1128
+ (code, signal) => code === 0 ? resolveDone() : reject(
1129
+ new CliError(
1130
+ `${project.manager} ${args[0]} ${signal ? `stopped (${signal})` : `failed (exit ${code})`}. Files are listed below; inspect package.json and the lockfile, then rerun the command to finish.`
1131
+ )
1132
+ )
1133
+ );
1134
+ });
1135
+ }
1136
+ }
1137
+ function printReport(report, json = false) {
1138
+ if (json) {
1139
+ say(JSON.stringify(report, null, 2));
1140
+ return;
1141
+ }
1142
+ say(
1143
+ safeText(
1144
+ `${report.dryRun ? "Dry run" : report.error ? "Install stopped" : "Install complete"}: ${report.components.join(", ")} in ${report.project} (${report.framework}, ${report.packageManager}).`
1145
+ )
1146
+ );
1147
+ for (const [label, values] of [
1148
+ ["Planned", report.planned],
1149
+ ["Written", report.written],
1150
+ ["Unchanged", report.unchanged],
1151
+ ["Skipped", report.skipped],
1152
+ ["Packages requested", report.packages],
1153
+ ["Dev packages requested", report.devPackages],
1154
+ ["Installed", report.installed],
1155
+ ["Import", report.imports],
1156
+ ["Note", report.warnings]
1157
+ ])
1158
+ for (const value of values) say(safeText(` ${label}: ${value}`));
1159
+ }
1160
+ async function add(server2, inputs, flags, initOnly = false) {
1161
+ if (!inputs.length && !initOnly)
1162
+ throw new CliError(
1163
+ "Give a component: layro add @author/slug [more...] --yes. See layro add --help."
1164
+ );
1165
+ const base = registryBase(server2);
1166
+ const project = await detectProject(flags.cwd ?? process.cwd());
1167
+ if (!project.tsx)
1168
+ throw new CliError(
1169
+ "JavaScript (tsx:false) projects are not supported: Layro does not convert TypeScript source. Create a TypeScript app (npm create vite@latest my-app -- --template react-ts), or use the Manual tab and convert the source yourself. Nothing written."
1170
+ );
1171
+ const config = initOnly ? await initConfig(project) : null;
1172
+ if (flags.path) {
1173
+ safePath(flags.path);
1174
+ const mapped = relative4(
1175
+ resolve4(project.root, project.base),
1176
+ resolve4(project.root, flags.path)
1177
+ ).replaceAll("\\", "/");
1178
+ project.paths["#layro"] = [mapped];
1179
+ project.paths["#layro/*"] = [`${mapped}/*`];
1180
+ project.aliases.components = "#layro";
1181
+ project.aliases.ui = "#layro/ui";
1182
+ }
1183
+ const roots = [...new Set(inputs.map((input) => itemUrl(input, base)))];
1184
+ const report = {
1185
+ command: initOnly ? "init" : "add",
1186
+ project: project.root,
1187
+ framework: project.framework,
1188
+ packageManager: project.manager,
1189
+ components: initOnly ? ["project setup"] : inputs,
1190
+ dryRun: Boolean(flags["dry-run"]),
1191
+ planned: [],
1192
+ written: [],
1193
+ unchanged: [],
1194
+ skipped: [],
1195
+ packages: [],
1196
+ devPackages: [],
1197
+ installed: [],
1198
+ warnings: [],
1199
+ imports: []
1200
+ };
1201
+ const writes = /* @__PURE__ */ new Map();
1202
+ const plan = async (path, content, kind) => {
1203
+ const previous = writes.get(path);
1204
+ if (previous && previous.content !== content)
1205
+ throw new CliError(
1206
+ `Conflicting registry files for ${path}. Nothing written.`
1207
+ );
1208
+ if (!previous)
1209
+ writes.set(path, {
1210
+ path,
1211
+ content,
1212
+ kind,
1213
+ before: await existing(project.root, path)
1214
+ });
1215
+ };
1216
+ const items = initOnly ? [] : await resolveItems(roots, base, project.style);
1217
+ if (config !== null) await plan("components.json", config, "config");
1218
+ else if (initOnly) report.unchanged.push("components.json");
1219
+ if (!initOnly && !project.config)
1220
+ report.warnings.push(
1221
+ "Run npx layro-cli init --yes to create shared project configuration. Existing component installs still use relative imports."
1222
+ );
1223
+ const packages = /* @__PURE__ */ new Map();
1224
+ const need = (spec, dev = false) => {
1225
+ const { name, version } = packageSpec(spec);
1226
+ const previous = packages.get(name);
1227
+ if (previous?.version && version && previous.version !== version)
1228
+ throw new CliError(
1229
+ `Conflicting dependency versions: ${previous.spec} and ${spec}. Nothing written.`
1230
+ );
1231
+ packages.set(name, {
1232
+ spec: version || !previous ? spec : previous.spec,
1233
+ version: version ?? previous?.version,
1234
+ dev: previous ? previous.dev && dev : dev
1235
+ });
1236
+ };
1237
+ const utils = `${aliasPath(project, project.aliases.utils)}.ts`;
1238
+ const helperCandidates = [
1239
+ utils,
1240
+ utils.replace(/\.ts$/, ".js"),
1241
+ utils.replace(/\.ts$/, ".tsx"),
1242
+ utils.replace(/\.ts$/, ".jsx"),
1243
+ utils.replace(/\.ts$/, "/index.ts"),
1244
+ utils.replace(/\.ts$/, "/index.js")
1245
+ ];
1246
+ const hasHelper = (await Promise.all(
1247
+ helperCandidates.map((path) => existing(project.root, path))
1248
+ )).some((content) => content !== null);
1249
+ if (!hasHelper) {
1250
+ await plan(utils, CN_SOURCE, "helper");
1251
+ need("clsx");
1252
+ need("tailwind-merge");
1253
+ } else if (initOnly) {
1254
+ for (const path of helperCandidates) {
1255
+ const content = await existing(project.root, path);
1256
+ if (content === null) continue;
1257
+ report.unchanged.push(path);
1258
+ for (const { spec } of moduleSpecifiers(content))
1259
+ if (spec === "clsx" || spec === "tailwind-merge") need(spec);
1260
+ break;
1261
+ }
1262
+ }
1263
+ const tailwind4 = /^\D*4(?:\.|$)/.test(
1264
+ project.dependencies.tailwindcss ?? ""
1265
+ );
1266
+ if (!project.dependencies.react)
1267
+ report.warnings.push(
1268
+ "React is missing. Add React before rendering these components."
1269
+ );
1270
+ if (!project.dependencies.tailwindcss)
1271
+ report.warnings.push(
1272
+ `Tailwind CSS is missing. Components will be unstyled until setup is complete. Run npx layro-cli init for the next steps. ${setupGuide2(project)}`
1273
+ );
1274
+ let css = project.css ? await existing(project.root, project.css) : null;
1275
+ const originalCss = css;
1276
+ for (const { url, item } of items) {
1277
+ for (const spec of item.dependencies) need(spec);
1278
+ for (const spec of item.devDependencies) need(spec, true);
1279
+ for (const file of item.files) {
1280
+ if (file.type === "registry:example" || /(?:^|\/)(?:demos?|examples?)(?:\/|\.)/i.test(file.path))
1281
+ continue;
1282
+ if (![
1283
+ "registry:component",
1284
+ "registry:ui",
1285
+ "registry:lib",
1286
+ "registry:hook",
1287
+ "registry:block",
1288
+ "registry:file",
1289
+ "registry:page"
1290
+ ].includes(file.type))
1291
+ throw new CliError(
1292
+ `Unsupported file type ${file.type}. Nothing written; use the Manual tab.`
1293
+ );
1294
+ const imports = /\.[jt]sx?$/.test(file.path) ? moduleSpecifiers(file.content) : [];
1295
+ for (const { spec } of imports) {
1296
+ if (spec.startsWith(".") || spec.startsWith("@/") || spec.startsWith("node:"))
1297
+ continue;
1298
+ const name = spec.startsWith("@") ? spec.split("/").slice(0, 2).join("/") : spec.split("/")[0];
1299
+ need(name);
1300
+ }
1301
+ const path = fileDestination(file, project);
1302
+ if (!/\.(tsx?|jsx?|css)$/.test(path))
1303
+ throw new CliError(
1304
+ `Refused file extension in ${path}. Only source and CSS files are installed.`
1305
+ );
1306
+ const content = rewriteAliases(file.content, project, path);
1307
+ await plan(path, content, "component");
1308
+ if (roots.includes(url) && /\.[jt]sx?$/.test(path)) {
1309
+ const source = path.replace(/\.[jt]sx?$/, "");
1310
+ const named = [
1311
+ ...content.matchAll(
1312
+ /export\s+(?:async\s+)?(?:function|const|class)\s+(\w+)/g
1313
+ )
1314
+ ].map((m) => m[1]);
1315
+ const exports = named.length ? `{ ${named.join(", ")} }` : /export\s+default\b/.test(content) ? "Component" : "{ /* see file exports */ }";
1316
+ report.imports.push(
1317
+ `import ${exports} from "./${source}"; (from a file at the project root; adjust relative to your route)`
1318
+ );
1319
+ }
1320
+ }
1321
+ if ((item.cssVars?.theme || Object.keys(item.css ?? {}).length) && !tailwind4)
1322
+ report.warnings.push(
1323
+ `${item.name} may need Tailwind v4; this project has ${project.dependencies.tailwindcss ?? "none"}.`
1324
+ );
1325
+ const merged = mergeCss(css ?? "", item, tailwind4);
1326
+ report.warnings.push(...merged.manual);
1327
+ if (merged.css !== (css ?? "")) {
1328
+ if (!project.css || css === null)
1329
+ report.warnings.push(
1330
+ `No stylesheet found. Add this CSS to the stylesheet your app imports: ${merged.css}`
1331
+ );
1332
+ else css = merged.css;
1333
+ }
1334
+ }
1335
+ if (project.css && css !== null && css !== originalCss)
1336
+ await plan(project.css, css, "css");
1337
+ report.warnings.push(
1338
+ ...nextPrerenderGuidance(
1339
+ project,
1340
+ [...writes.values()].filter((entry) => entry.kind === "component").map((entry) => entry.content)
1341
+ )
1342
+ );
1343
+ const pnp = existsSync3(join3(project.root, ".pnp.cjs"));
1344
+ for (const { spec, version, dev } of packages.values()) {
1345
+ const { name } = packageSpec(spec);
1346
+ let installed;
1347
+ let dir = project.root;
1348
+ for (; ; ) {
1349
+ try {
1350
+ installed = JSON.parse(
1351
+ await readFile3(
1352
+ join3(dir, "node_modules", name, "package.json"),
1353
+ "utf8"
1354
+ )
1355
+ ).version;
1356
+ break;
1357
+ } catch {
1358
+ }
1359
+ const parent = dirname4(dir);
1360
+ if (parent === dir) break;
1361
+ dir = parent;
1362
+ }
1363
+ const declared = project.dependencies[name];
1364
+ if (declared && !installed && pnp) continue;
1365
+ if (declared) {
1366
+ const known = satisfies("0.0.0", declared);
1367
+ const wanted = satisfies(installed ?? "0.0.0", version);
1368
+ if (known === void 0 || wanted === void 0) {
1369
+ report.warnings.push(
1370
+ `Left ${name} unchanged: cannot safely read project range ${declared} or requested range ${version ?? "any"}. Review and install a compatible version yourself.`
1371
+ );
1372
+ continue;
1373
+ }
1374
+ if (installed) {
1375
+ if (!satisfies(installed, declared) || !wanted)
1376
+ report.warnings.push(
1377
+ `Left ${name}@${declared} unchanged (installed ${installed}; component requests ${version ?? "any"}). Review the ranges and install a compatible version yourself.`
1378
+ );
1379
+ continue;
1380
+ }
1381
+ const preserved = `${name}@${declared}`;
1382
+ packageSpec(preserved);
1383
+ (dev ? report.devPackages : report.packages).push(preserved);
1384
+ if (version)
1385
+ report.warnings.push(
1386
+ `Preserving ${name}@${declared}; component requests ${version}. Check compatibility after installing.`
1387
+ );
1388
+ continue;
1389
+ }
1390
+ if (version && satisfies("0.0.0", version) === void 0) {
1391
+ report.warnings.push(
1392
+ `Left ${name} uninstalled: cannot safely read requested range ${version}. Review and install a compatible version yourself.`
1393
+ );
1394
+ continue;
1395
+ }
1396
+ (dev ? report.devPackages : report.packages).push(spec);
1397
+ }
1398
+ const accepted = [];
1399
+ for (const entry of writes.values()) {
1400
+ if (entry.before === entry.content) {
1401
+ report.unchanged.push(entry.path);
1402
+ continue;
1403
+ }
1404
+ if (entry.before !== null && entry.kind !== "css" && !flags.overwrite) {
1405
+ if (flags["dry-run"]) {
1406
+ report.skipped.push(entry.path);
1407
+ report.warnings.push(
1408
+ `${entry.path} differs; --overwrite would replace it, --yes would skip it.`
1409
+ );
1410
+ continue;
1411
+ }
1412
+ if (flags.yes) {
1413
+ report.skipped.push(entry.path);
1414
+ continue;
1415
+ }
1416
+ if (!canAsk() || flags.json)
1417
+ throw new CliError(
1418
+ `File differs: ${entry.path}. Use --yes to skip it, or --overwrite to replace it. Nothing written.`
1419
+ );
1420
+ if (!flags["dry-run"] && !await confirm(`Replace ${safeText(entry.path)}?`)) {
1421
+ report.skipped.push(entry.path);
1422
+ continue;
1423
+ }
1424
+ }
1425
+ accepted.push(entry);
1426
+ report.planned.push(entry.path);
1427
+ }
1428
+ if (flags["dry-run"]) {
1429
+ printReport(report, flags.json);
1430
+ return report;
1431
+ }
1432
+ if (!flags.json) {
1433
+ say(
1434
+ safeText(
1435
+ `Installing ${inputs.join(", ") || "shared project setup"} into ${project.root}.`
1436
+ )
1437
+ );
1438
+ say(
1439
+ safeText(
1440
+ `Files: ${report.planned.join(", ") || "none"}. Packages: ${[...report.packages, ...report.devPackages].join(", ") || "none"}. Package scripts are disabled.`
1441
+ )
1442
+ );
1443
+ }
1444
+ let temporary;
1445
+ const stopped = (signal) => {
1446
+ installing?.kill(signal);
1447
+ if (temporary) {
1448
+ try {
1449
+ unlinkSync(temporary);
1450
+ } catch {
1451
+ }
1452
+ }
1453
+ report.error = `Stopped by ${signal}. Rerun ${report.command} to finish. Package installation may be incomplete; inspect package.json and the lockfile.`;
1454
+ printReport(report, flags.json);
1455
+ process.exit(signal === "SIGINT" ? 130 : 143);
1456
+ };
1457
+ const interrupt = () => stopped("SIGINT");
1458
+ const terminate = () => stopped("SIGTERM");
1459
+ process.once("SIGINT", interrupt);
1460
+ process.once("SIGTERM", terminate);
1461
+ try {
1462
+ for (const entry of accepted) {
1463
+ const target = await safeDestination(project.root, entry.path);
1464
+ if (await existing(project.root, entry.path) !== entry.before)
1465
+ throw new CliError(
1466
+ `File changed during install: ${entry.path}. Rerun add.`
1467
+ );
1468
+ await mkdir(dirname4(target), { recursive: true });
1469
+ await safeDestination(project.root, entry.path);
1470
+ temporary = `${target}.layro-${randomBytes(8).toString("hex")}`;
1471
+ const handle = await open(
1472
+ temporary,
1473
+ constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY,
1474
+ 420
1475
+ );
1476
+ try {
1477
+ await handle.writeFile(entry.content);
1478
+ } finally {
1479
+ await handle.close();
1480
+ }
1481
+ await safeDestination(project.root, entry.path);
1482
+ await rename(temporary, target);
1483
+ temporary = void 0;
1484
+ report.written.push(entry.path);
1485
+ }
1486
+ await installPackages(project, report.packages, false, Boolean(flags.json));
1487
+ report.installed.push(...report.packages);
1488
+ await installPackages(
1489
+ project,
1490
+ report.devPackages,
1491
+ true,
1492
+ Boolean(flags.json)
1493
+ );
1494
+ report.installed.push(...report.devPackages);
1495
+ for (const spec of report.installed) {
1496
+ const { name } = packageSpec(spec);
1497
+ const folder = join3(project.root, "node_modules", name);
1498
+ let manifest;
1499
+ try {
1500
+ manifest = JSON.parse(
1501
+ await readFile3(join3(folder, "package.json"), "utf8")
1502
+ );
1503
+ } catch {
1504
+ continue;
1505
+ }
1506
+ const lifecycle = ["preinstall", "install", "postinstall"].filter(
1507
+ (script) => typeof manifest.scripts?.[script] === "string"
1508
+ );
1509
+ if (lifecycle.length)
1510
+ report.warnings.push(
1511
+ `${name} has disabled install scripts (${lifecycle.join(", ")}). If it needs its build step, review node_modules/${name}/package.json first. From the app root, run explicitly: cd node_modules/${name} && ${lifecycle.map((script) => `${project.manager} run ${script}`).join(" && ")}`
1512
+ );
1513
+ else if (existsSync3(join3(folder, "binding.gyp")))
1514
+ report.warnings.push(
1515
+ `${name} has a native binding whose build script was disabled. Review node_modules/${name}/binding.gyp; if needed, run from the app root: npm rebuild ${name} --ignore-scripts=false`
1516
+ );
1517
+ }
1518
+ } catch (error) {
1519
+ if (temporary) await unlink(temporary).catch(() => {
1520
+ });
1521
+ report.error = error instanceof CliError ? error.message : `Could not finish writing the project (${error.code ?? "I/O error"}). Rerun ${report.command} after checking permissions.`;
1522
+ printReport(report, flags.json);
1523
+ if (!flags.json) warn(safeText(report.error));
1524
+ process.exitCode = 1;
1525
+ return report;
1526
+ } finally {
1527
+ installing = void 0;
1528
+ process.removeListener("SIGINT", interrupt);
1529
+ process.removeListener("SIGTERM", terminate);
1530
+ }
1531
+ printReport(report, flags.json);
1532
+ return report;
1533
+ }
1534
+
6
1535
  // src/auth.ts
7
- import { spawn } from "node:child_process";
8
- import { createHash, randomBytes } from "node:crypto";
1536
+ import { spawn as spawn2 } from "node:child_process";
1537
+ import { createHash as createHash2, randomBytes as randomBytes2 } from "node:crypto";
9
1538
  import { createServer } from "node:http";
10
1539
 
11
1540
  // src/credentials.ts
12
- import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
1541
+ import { chmod, mkdir as mkdir2, readFile as readFile4, rm, writeFile } from "node:fs/promises";
13
1542
  import { homedir } from "node:os";
14
- import { dirname, join } from "node:path";
15
- var credentialsPath = () => join(
16
- process.env.XDG_CONFIG_HOME || join(homedir(), ".config"),
1543
+ import { dirname as dirname5, join as join4 } from "node:path";
1544
+ var credentialsPath = () => join4(
1545
+ process.env.XDG_CONFIG_HOME || join4(homedir(), ".config"),
17
1546
  "layro",
18
1547
  "credentials.json"
19
1548
  );
20
1549
  async function load() {
21
1550
  try {
22
- const parsed = JSON.parse(await readFile(credentialsPath(), "utf8"));
1551
+ const parsed = JSON.parse(await readFile4(credentialsPath(), "utf8"));
23
1552
  return parsed && typeof parsed.servers === "object" ? parsed : { servers: {} };
24
1553
  } catch {
25
1554
  return { servers: {} };
@@ -27,7 +1556,7 @@ async function load() {
27
1556
  }
28
1557
  async function save(file) {
29
1558
  const path = credentialsPath();
30
- await mkdir(dirname(path), { recursive: true, mode: 448 });
1559
+ await mkdir2(dirname5(path), { recursive: true, mode: 448 });
31
1560
  if (Object.keys(file.servers).length === 0) {
32
1561
  await rm(path, { force: true });
33
1562
  return;
@@ -59,55 +1588,19 @@ async function forgetClient(server2) {
59
1588
  await save(file);
60
1589
  }
61
1590
 
62
- // src/ui.ts
63
- import { createInterface } from "node:readline/promises";
64
- var color = process.stdout.isTTY && !process.env.NO_COLOR;
65
- var paint = (code) => (text) => color ? `\x1B[${code}m${text}\x1B[0m` : text;
66
- var bold = paint(1);
67
- var dim = paint(2);
68
- var green = paint(32);
69
- var red = paint(31);
70
- var yellow = paint(33);
71
- var say = (text = "") => process.stdout.write(`${text}
72
- `);
73
- var warn = (text) => process.stderr.write(`${text}
74
- `);
75
- var CliError = class extends Error {
76
- };
77
- var canAsk = () => Boolean(process.stdin.isTTY && process.stdout.isTTY);
78
- async function ask(question, fallback = "") {
79
- const rl = createInterface({ input: process.stdin, output: process.stdout });
1591
+ // src/http.ts
1592
+ async function request(url, init = {}, timeoutMs = 2e4) {
80
1593
  try {
81
- const hint = fallback ? dim(` (${fallback})`) : "";
82
- const answer = (await rl.question(`${question}${hint}: `)).trim();
83
- return answer || fallback;
1594
+ return await fetch(url, {
1595
+ ...init,
1596
+ redirect: "error",
1597
+ signal: AbortSignal.timeout(timeoutMs)
1598
+ });
84
1599
  } catch (error) {
85
- if (error.name === "AbortError")
86
- throw new CliError("Cancelled.");
87
- throw error;
88
- } finally {
89
- rl.close();
90
- }
91
- }
92
- async function confirm(question) {
93
- const answer = (await ask(`${question} ${dim("[Y/n]")}`)).toLowerCase();
94
- return answer === "" || answer === "y" || answer === "yes";
95
- }
96
- async function choose(question, options, fallback) {
97
- options.forEach((option, i) => {
98
- say(` ${dim(String(i + 1).padStart(2))} ${option.label}`);
99
- });
100
- const fallbackIndex = options.findIndex((o) => o.value === fallback);
101
- for (; ; ) {
102
- const answer = await ask(
103
- question,
104
- fallbackIndex >= 0 ? String(fallbackIndex + 1) : ""
1600
+ const timeout = error instanceof Error && ["TimeoutError", "AbortError"].includes(error.name);
1601
+ throw new CliError(
1602
+ timeout ? "Request timed out. Check your connection and LAYRO_URL, then retry." : "Could not reach the Layro server (network failure or refused redirect). Check your connection and LAYRO_URL, then retry."
105
1603
  );
106
- const byNumber = options[Number(answer) - 1];
107
- const byValue = options.find((o) => o.value === answer);
108
- const picked = byNumber ?? byValue;
109
- if (picked) return picked.value;
110
- say(red(`Pick a number from 1 to ${options.length}.`));
111
1604
  }
112
1605
  }
113
1606
 
@@ -120,7 +1613,7 @@ async function metadata(server2) {
120
1613
  const url = `${server2}/.well-known/oauth-authorization-server/api/auth`;
121
1614
  let response;
122
1615
  try {
123
- response = await fetch(url);
1616
+ response = await request(url);
124
1617
  } catch (error) {
125
1618
  throw new CliError(
126
1619
  `Can't reach ${server2} (${error.message}). Check your connection, or LAYRO_URL.`
@@ -132,7 +1625,7 @@ async function metadata(server2) {
132
1625
  }
133
1626
  var base64url = (bytes) => bytes.toString("base64url");
134
1627
  async function register(meta, server2, redirect) {
135
- const response = await fetch(meta.registration_endpoint, {
1628
+ const response = await request(meta.registration_endpoint, {
136
1629
  method: "POST",
137
1630
  headers: { "Content-Type": "application/json" },
138
1631
  body: JSON.stringify({
@@ -157,7 +1650,7 @@ async function register(meta, server2, redirect) {
157
1650
  function openBrowser(url) {
158
1651
  const custom = process.env.LAYRO_OPEN_BROWSER;
159
1652
  const [command, ...args] = custom ? [...custom.split(" ").filter(Boolean), url] : process.platform === "darwin" ? ["open", url] : process.platform === "win32" ? ["cmd", "/c", "start", '""', url.replaceAll("&", "^&")] : ["xdg-open", url];
160
- const child = spawn(command, args, {
1653
+ const child = spawn2(command, args, {
161
1654
  stdio: custom ? "inherit" : "ignore",
162
1655
  detached: !custom
163
1656
  });
@@ -169,11 +1662,11 @@ var page = (title, text) => `<!doctype html><meta charset="utf-8"><title>${title
169
1662
  async function callbackServer(state) {
170
1663
  let settle = () => {
171
1664
  };
172
- const result = new Promise((resolve2) => {
173
- settle = resolve2;
1665
+ const result = new Promise((resolve6) => {
1666
+ settle = resolve6;
174
1667
  });
175
- const server2 = createServer((request, response) => {
176
- const url = new URL(request.url ?? "/", "http://127.0.0.1");
1668
+ const server2 = createServer((request2, response) => {
1669
+ const url = new URL(request2.url ?? "/", "http://127.0.0.1");
177
1670
  if (url.pathname !== "/callback") {
178
1671
  response.writeHead(404).end();
179
1672
  return;
@@ -196,15 +1689,15 @@ async function callbackServer(state) {
196
1689
  }
197
1690
  );
198
1691
  });
199
- await new Promise((resolve2, reject) => {
1692
+ await new Promise((resolve6, reject) => {
200
1693
  server2.once("error", reject);
201
- server2.listen(0, "127.0.0.1", resolve2);
1694
+ server2.listen(0, "127.0.0.1", resolve6);
202
1695
  });
203
1696
  const { port } = server2.address();
204
1697
  return { port, result, close: () => server2.close() };
205
1698
  }
206
1699
  async function tokenRequest(meta, params) {
207
- const response = await fetch(meta.token_endpoint, {
1700
+ const response = await request(meta.token_endpoint, {
208
1701
  method: "POST",
209
1702
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
210
1703
  body: new URLSearchParams(params)
@@ -226,15 +1719,15 @@ var toSession = (clientId, tokens) => ({
226
1719
  });
227
1720
  async function login(server2, options) {
228
1721
  const meta = await metadata(server2);
229
- const state = base64url(randomBytes(16));
1722
+ const state = base64url(randomBytes2(16));
230
1723
  const callback = await callbackServer(state);
231
1724
  try {
232
1725
  const redirect = `http://127.0.0.1:${callback.port}/callback`;
233
1726
  const known = await getSession(server2);
234
1727
  const clientId = known?.clientId ?? await register(meta, server2, redirect);
235
1728
  await setSession(server2, { ...known ?? {}, clientId });
236
- const verifier = base64url(randomBytes(32));
237
- const challenge = base64url(createHash("sha256").update(verifier).digest());
1729
+ const verifier = base64url(randomBytes2(32));
1730
+ const challenge = base64url(createHash2("sha256").update(verifier).digest());
238
1731
  const authorize = new URL(meta.authorization_endpoint);
239
1732
  authorize.search = new URLSearchParams({
240
1733
  response_type: "code",
@@ -246,16 +1739,27 @@ async function login(server2, options) {
246
1739
  code_challenge_method: "S256",
247
1740
  resource: apiResource(server2)
248
1741
  }).toString();
249
- say("Opening your browser to sign in to Layro with GitHub.");
250
- say(dim(`If it doesn't open, visit:
251
- ${authorize}`));
1742
+ if (options.json)
1743
+ say(
1744
+ JSON.stringify({
1745
+ status: "authorization_required",
1746
+ url: authorize.toString(),
1747
+ expiresIn: options.timeout ?? LOGIN_TIMEOUT_MS / 1e3
1748
+ })
1749
+ );
1750
+ else {
1751
+ say(
1752
+ options.browser ? "Opening your browser to sign in to Layro with GitHub." : "Visit this link to sign in to Layro with GitHub:"
1753
+ );
1754
+ say(dim(safeText(authorize.toString())));
1755
+ }
252
1756
  if (options.browser) openBrowser(authorize.toString());
253
1757
  const outcome = await Promise.race([
254
1758
  callback.result,
255
1759
  new Promise(
256
- (resolve2) => setTimeout(
257
- () => resolve2({ error: "timed out waiting for the browser" }),
258
- LOGIN_TIMEOUT_MS
1760
+ (resolve6) => setTimeout(
1761
+ () => resolve6({ error: "timed out waiting for the browser" }),
1762
+ options.timeout ? options.timeout * 1e3 : LOGIN_TIMEOUT_MS
259
1763
  ).unref()
260
1764
  )
261
1765
  ]);
@@ -295,13 +1799,13 @@ async function refresh(server2, session) {
295
1799
  await clearTokens(server2);
296
1800
  return null;
297
1801
  }
298
- const next = {
1802
+ const next2 = {
299
1803
  ...toSession(session.clientId, renewed.tokens),
300
1804
  refreshToken: renewed.tokens.refresh_token ?? session.refreshToken,
301
1805
  handle: session.handle
302
1806
  };
303
- await setSession(server2, next);
304
- return next;
1807
+ await setSession(server2, next2);
1808
+ return next2;
305
1809
  }
306
1810
  var notSignedIn = () => new CliError("Not signed in. Run `npx layro-cli login` first.");
307
1811
  async function accessToken(server2, force = false) {
@@ -323,7 +1827,7 @@ async function logout(server2) {
323
1827
  try {
324
1828
  const meta = await metadata(server2);
325
1829
  if (meta.revocation_endpoint && session.refreshToken) {
326
- await fetch(meta.revocation_endpoint, {
1830
+ await request(meta.revocation_endpoint, {
327
1831
  method: "POST",
328
1832
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
329
1833
  body: new URLSearchParams({
@@ -340,7 +1844,6 @@ async function logout(server2) {
340
1844
  }
341
1845
 
342
1846
  // src/api.ts
343
- var VERSION = true ? "0.3.0" : "dev";
344
1847
  var ApiFailure = class extends CliError {
345
1848
  constructor(detail) {
346
1849
  super(detail.error);
@@ -351,7 +1854,7 @@ var ApiFailure = class extends CliError {
351
1854
  async function api(server2, path, init = {}) {
352
1855
  const call = async (token) => {
353
1856
  try {
354
- return await fetch(`${server2}/api/v1${path}`, {
1857
+ return await request(`${server2}/api/v1${path}`, {
355
1858
  method: init.method ?? "GET",
356
1859
  headers: {
357
1860
  Accept: "application/json",
@@ -374,6 +1877,10 @@ async function api(server2, path, init = {}) {
374
1877
  }
375
1878
  const body = await response.json().catch(() => null);
376
1879
  if (response.ok && body) return body;
1880
+ if (response.ok)
1881
+ throw new CliError(
1882
+ "The Layro API returned invalid JSON or an empty answer. Retry, or check LAYRO_URL."
1883
+ );
377
1884
  if (response.status === 401) {
378
1885
  if (process.env.LAYRO_TOKEN)
379
1886
  throw new CliError(
@@ -389,46 +1896,170 @@ async function api(server2, path, init = {}) {
389
1896
  });
390
1897
  }
391
1898
 
1899
+ // src/command-help.ts
1900
+ var commandHelp = {
1901
+ search: `layro search <words...> [options]
1902
+
1903
+ Find public components and print their Layro add commands. No sign-in.
1904
+ --category <slug> Filter category
1905
+ --author <handle> Filter author
1906
+ --limit <n> 1 to 50 (default 20)
1907
+ --json Return results with Layro install commands as JSON
1908
+
1909
+ Examples:
1910
+ npx layro-cli search animated text
1911
+ layro search chart --author legions-developer --limit 5 --json`,
1912
+ publish: `layro publish <files...> [options]
1913
+
1914
+ Submit a new component or a version of your same-named component for review.
1915
+ The first file is the entry. Sign in with login or set LAYRO_TOKEN for CI.
1916
+ --name <name> Defaults to entry file's name
1917
+ --description <text> 20 to 200 characters for a new component
1918
+ --category <slug> Category from layro.dev/components
1919
+ --license <id> MIT (default), Apache-2.0, ISC or BSD-3-Clause
1920
+ --version <x.y.z> Existing component: next patch by default
1921
+ --notes <text> What changed
1922
+ --preview <file.json> How to record the preview
1923
+ --dark-only New component only works on a dark page
1924
+ --if-changed Unchanged/pending version: nothing sent, exit 0
1925
+ -y, --yes Send flags as given without prompts
1926
+ --json No prompts; return result or field errors as JSON
1927
+
1928
+ Examples:
1929
+ layro publish src/components/card.tsx --name "My Card" --description "A useful card for your dashboard" --category cards --yes
1930
+ layro publish src/components/card.tsx --if-changed --json`,
1931
+ status: `layro status [slug | author/slug] [--json]
1932
+
1933
+ Show your submissions and reviewers' reasons. Sign in or set LAYRO_TOKEN.
1934
+ Without a component, list all of yours. --json returns the API response.
1935
+
1936
+ Examples:
1937
+ layro status
1938
+ layro status author/card --json`,
1939
+ login: `layro login [--no-browser] [--timeout <seconds>] [--json]
1940
+
1941
+ Sign in with GitHub and approve Layro CLI in your browser.
1942
+ --no-browser Print the link; explicitly wait for its loopback callback
1943
+ --timeout <n> Wait 1 to 300 seconds for consent (default 300)
1944
+ --json Print newline-delimited JSON: authorization_required, then
1945
+ signed_in or error. Never includes access/refresh tokens.
1946
+
1947
+ Without a terminal, --no-browser is required. CI should use LAYRO_TOKEN.
1948
+ Network requests time out after 20 seconds. Consent uses the timeout above.
1949
+
1950
+ Examples:
1951
+ layro login
1952
+ layro login --no-browser --timeout 120 --json`,
1953
+ logout: `layro logout [--json]
1954
+
1955
+ Revoke and forget the saved session. Offline: forget local tokens anyway.
1956
+ --json returns signedOut and tokenEnvironmentSet. An environment token stays set.
1957
+
1958
+ Examples:
1959
+ layro logout
1960
+ layro logout --json`,
1961
+ whoami: `layro whoami [--json]
1962
+
1963
+ Show the signed-in handle, profile and submissions left today.
1964
+ Uses LAYRO_TOKEN when set, otherwise the saved session. --json returns API JSON.
1965
+
1966
+ Examples:
1967
+ layro whoami
1968
+ layro whoami --json`
1969
+ };
1970
+ var commands = [
1971
+ "init",
1972
+ "add",
1973
+ "search",
1974
+ "publish",
1975
+ "status",
1976
+ "login",
1977
+ "logout",
1978
+ "whoami",
1979
+ "help"
1980
+ ];
1981
+ function distance(a, b) {
1982
+ let row = Array.from({ length: b.length + 1 }, (_, i) => i);
1983
+ for (let i = 1; i <= a.length; i++) {
1984
+ const next2 = [i];
1985
+ for (let j = 1; j <= b.length; j++)
1986
+ next2[j] = Math.min(
1987
+ next2[j - 1] + 1,
1988
+ row[j] + 1,
1989
+ row[j - 1] + Number(a[i - 1] !== b[j - 1])
1990
+ );
1991
+ row = next2;
1992
+ }
1993
+ return row[b.length];
1994
+ }
1995
+ function suggestCommand(input) {
1996
+ if (input.length > 32) return "";
1997
+ const found = commands.map((command) => ({ command, distance: distance(input, command) })).sort((a, b) => a.distance - b.distance)[0];
1998
+ return found.distance <= 2 ? ` Did you mean layro ${found.command}?` : "";
1999
+ }
2000
+
392
2001
  // src/publish.ts
393
- import { readFile as readFile2 } from "node:fs/promises";
394
- import { basename, dirname as dirname2, relative, resolve, sep } from "node:path";
2002
+ import { readFile as readFile5 } from "node:fs/promises";
2003
+ import {
2004
+ basename,
2005
+ dirname as dirname6,
2006
+ isAbsolute as isAbsolute2,
2007
+ relative as relative5,
2008
+ resolve as resolve5,
2009
+ sep as sep2
2010
+ } from "node:path";
395
2011
  function nameFromFile(path) {
396
2012
  const stem = basename(path).replace(/\.[^.]+$/, "");
397
2013
  return stem.replace(/([a-z0-9])([A-Z])/g, "$1 $2").split(/[-_\s.]+/).filter(Boolean).map((word) => word[0].toUpperCase() + word.slice(1)).join(" ");
398
2014
  }
399
2015
  async function readFiles(paths) {
400
- const absolute = paths.map((p) => resolve(p));
401
- let root = dirname2(absolute[0]);
402
- for (const path of absolute) {
403
- while (!`${dirname2(path)}${sep}`.startsWith(`${root}${sep}`)) {
404
- root = dirname2(root);
405
- }
406
- }
2016
+ if (!paths.length)
2017
+ throw new CliError("Give at least one source file to publish.");
2018
+ const absolute = paths.map((p) => resolve5(p));
2019
+ const root = commonFileRoot(absolute);
407
2020
  return Promise.all(
408
2021
  absolute.map(async (path, i) => {
409
2022
  let content;
410
2023
  try {
411
- content = await readFile2(path, "utf8");
2024
+ content = await readFile5(path, "utf8");
412
2025
  } catch {
413
2026
  throw new CliError(`Can't read ${paths[i]}.`);
414
2027
  }
415
- return { path: relative(root, path).split(sep).join("/"), content };
2028
+ return { path: relative5(root, path).split(sep2).join("/"), content };
416
2029
  })
417
2030
  );
418
2031
  }
2032
+ function commonFileRoot(paths, path = { dirname: dirname6, relative: relative5, isAbsolute: isAbsolute2 }) {
2033
+ let root = path.dirname(paths[0]);
2034
+ for (const file of paths) {
2035
+ for (; ; ) {
2036
+ const rel = path.relative(root, path.dirname(file));
2037
+ if (!path.isAbsolute(rel) && rel !== ".." && !rel.startsWith("../") && !rel.startsWith("..\\"))
2038
+ break;
2039
+ const parent = path.dirname(root);
2040
+ if (parent === root)
2041
+ throw new CliError(
2042
+ "Publish files must share a directory on the same drive. Copy them into one project first."
2043
+ );
2044
+ root = parent;
2045
+ }
2046
+ }
2047
+ return root;
2048
+ }
419
2049
  async function readPreview(path) {
420
2050
  if (!path) return void 0;
421
2051
  try {
422
- return JSON.parse(await readFile2(path, "utf8"));
2052
+ return JSON.parse(await readFile5(path, "utf8"));
423
2053
  } catch {
424
2054
  throw new CliError(`--preview: ${path} isn't a readable JSON file.`);
425
2055
  }
426
2056
  }
427
- function explain(error) {
2057
+ function explain(error, json = false) {
2058
+ if (json) throw error;
428
2059
  if (error instanceof ApiFailure && error.detail.errors) {
429
2060
  say(red("Not sent. Fix these and try again:"));
430
2061
  for (const [field, message] of Object.entries(error.detail.errors)) {
431
- say(` ${bold(field)}: ${message}`);
2062
+ say(` ${bold(safeText(field))}: ${safeText(message)}`);
432
2063
  }
433
2064
  process.exit(1);
434
2065
  }
@@ -439,15 +2070,18 @@ async function publish(server2, paths, flags) {
439
2070
  throw new CliError(
440
2071
  "Name the files to publish, entry first: layro publish components/pricing-card.tsx"
441
2072
  );
442
- const interactive = canAsk() && !flags.yes;
2073
+ const interactive = canAsk() && !flags.yes && !flags.json;
2074
+ const note = (text) => {
2075
+ if (!flags.json) say(safeText(text));
2076
+ };
443
2077
  const files = await readFiles(paths);
444
2078
  const preview = await readPreview(flags.preview);
445
2079
  const me = await api(server2, "/me");
446
- say(dim(`Publishing as @${me.handle} to ${server2}`));
2080
+ note(`Publishing as @${me.handle} to ${server2}`);
447
2081
  const name = flags.name ?? (interactive ? await ask("Name", nameFromFile(paths[0])) : nameFromFile(paths[0]));
448
- let existing = null;
2082
+ let existing2 = null;
449
2083
  try {
450
- existing = await api(
2084
+ existing2 = await api(
451
2085
  server2,
452
2086
  `/status?name=${encodeURIComponent(name)}`
453
2087
  );
@@ -456,17 +2090,17 @@ async function publish(server2, paths, flags) {
456
2090
  throw error;
457
2091
  }
458
2092
  let result;
459
- if (existing) {
460
- say(
461
- `${bold(existing.name)} is already yours (${existing.liveVersion ? `${existing.liveVersion} live` : existing.status}): sending a new version.`
2093
+ if (existing2) {
2094
+ note(
2095
+ `${bold(existing2.name)} is already yours (${existing2.liveVersion ? `${existing2.liveVersion} live` : existing2.status}): sending a new version.`
462
2096
  );
463
- const version = flags.version ?? (interactive ? await ask("Version", existing.nextVersion) : existing.nextVersion);
2097
+ const version = flags.version ?? (interactive ? await ask("Version", existing2.nextVersion) : existing2.nextVersion);
464
2098
  const notes = flags.notes ?? (interactive ? await ask("What changed", "") : "");
465
- if (interactive && !await confirm(`Send ${existing.id} ${version} for review?`))
2099
+ if (interactive && !await confirm(`Send ${existing2.id} ${version} for review?`))
466
2100
  return say("Nothing sent.");
467
2101
  const sent = await api(
468
2102
  server2,
469
- `/components/${existing.id}/versions`,
2103
+ `/components/${existing2.id}/versions`,
470
2104
  {
471
2105
  method: "POST",
472
2106
  body: { version, notes, files, preview }
@@ -474,17 +2108,18 @@ async function publish(server2, paths, flags) {
474
2108
  ).catch((error) => {
475
2109
  if (flags.ifChanged && error instanceof ApiFailure && (error.detail.code === "unchanged" || error.detail.code === "pending"))
476
2110
  return error;
477
- return explain(error);
2111
+ return explain(error, flags.json);
478
2112
  });
479
2113
  if (sent instanceof ApiFailure) {
480
- say(
481
- `Nothing sent: ${Object.values(sent.detail.errors ?? {})[0] ?? sent.message}`
482
- );
2114
+ const message = Object.values(sent.detail.errors ?? {})[0] ?? sent.message;
2115
+ if (flags.json)
2116
+ say(JSON.stringify({ sent: false, status: sent.detail.code, message }));
2117
+ else note(`Nothing sent: ${message}`);
483
2118
  return;
484
2119
  }
485
2120
  result = sent;
486
2121
  } else {
487
- if (flags.version && flags.version !== "1.0.0")
2122
+ if (!flags.json && flags.version && flags.version !== "1.0.0")
488
2123
  warn(
489
2124
  yellow(
490
2125
  `New components start at 1.0.0; ignoring --version ${flags.version}.`
@@ -526,8 +2161,13 @@ async function publish(server2, paths, flags) {
526
2161
  darkOnly: flags.darkOnly ?? false,
527
2162
  preview
528
2163
  }
529
- }).catch(explain);
2164
+ }).catch((error) => explain(error, flags.json));
530
2165
  }
2166
+ if (flags.json)
2167
+ return say(
2168
+ JSON.stringify({ sent: true, status: "review", ...result }, null, 2)
2169
+ );
2170
+ result = terminalData(result);
531
2171
  say(green(`\u2713 ${result.component} ${result.version} is in review.`));
532
2172
  say(` ${result.message}`);
533
2173
  say(` Studio: ${result.studio}`);
@@ -538,23 +2178,34 @@ async function publish(server2, paths, flags) {
538
2178
  async function search(server2, words, flags) {
539
2179
  const q = words.join(" ").trim();
540
2180
  if (!q) throw new CliError("Give some words: layro search <words\u2026>");
2181
+ if (flags.limit && (!/^\d+$/.test(flags.limit) || Number(flags.limit) < 1 || Number(flags.limit) > 50))
2182
+ throw new CliError(
2183
+ "--limit must be an integer from 1 to 50. Example: layro search chart --limit 10."
2184
+ );
541
2185
  const params = new URLSearchParams({ q });
542
2186
  if (flags.category) params.set("category", flags.category);
543
2187
  if (flags.author) params.set("author", flags.author);
544
2188
  if (flags.limit) params.set("limit", flags.limit);
545
2189
  const found = await api(server2, `/search?${params}`, { auth: false });
2190
+ for (const result of found.results) {
2191
+ if (!/^@?[a-zA-Z0-9][a-zA-Z0-9_-]*\/[a-z0-9][a-z0-9-]*$/.test(result.id))
2192
+ throw new CliError(
2193
+ "The search API returned an invalid component id. Check LAYRO_URL and retry."
2194
+ );
2195
+ result.install = `npx layro-cli add @${result.id.replace(/^@/, "")}`;
2196
+ }
546
2197
  if (flags.json) return say(JSON.stringify(found, null, 2));
547
2198
  if (found.results.length === 0) {
548
- say(`Nothing matches "${q}".`);
2199
+ say(`Nothing matches "${safeText(q)}".`);
549
2200
  if (found.suggestion)
550
2201
  say(
551
- `The closest category is ${bold(found.suggestion.name)}: ${found.suggestion.url}`
2202
+ `The closest category is ${bold(safeText(found.suggestion.name))}: ${safeText(found.suggestion.url)}`
552
2203
  );
553
2204
  return;
554
2205
  }
555
2206
  for (const c of found.results) {
556
- say(`${bold(c.name)} ${dim(c.id)}`);
557
- say(` ${c.description}`);
2207
+ say(`${bold(safeText(c.name))} ${dim(c.id)}`);
2208
+ say(` ${safeText(c.description)}`);
558
2209
  say(dim(` ${c.install}`));
559
2210
  }
560
2211
  say(
@@ -572,14 +2223,16 @@ var LABEL = {
572
2223
  processing: "processing",
573
2224
  approved: "approved"
574
2225
  };
575
- async function status(server2, target) {
2226
+ async function status(server2, target, json = false) {
576
2227
  if (target) {
577
2228
  const me = target.includes("/") ? null : await api(server2, "/me");
578
2229
  const id = me ? `${me.handle}/${target}` : target;
579
- const one = await api(
2230
+ let one = await api(
580
2231
  server2,
581
2232
  `/status?component=${encodeURIComponent(id)}`
582
2233
  );
2234
+ if (json) return say(JSON.stringify(one, null, 2));
2235
+ one = terminalData(one);
583
2236
  say(`${bold(one.name)} ${dim(one.id)}: ${LABEL[one.status] ?? one.status}`);
584
2237
  if (one.url) say(` ${one.url}`);
585
2238
  say(` Studio: ${one.studio}`);
@@ -597,18 +2250,20 @@ async function status(server2, target) {
597
2250
  }
598
2251
  return;
599
2252
  }
600
- const all = await api(server2, "/status");
2253
+ let all = await api(server2, "/status");
2254
+ if (json) return say(JSON.stringify(all, null, 2));
2255
+ all = terminalData(all);
601
2256
  say(`@${all.author}`);
602
2257
  if (all.components.length === 0) {
603
2258
  say(dim("No components yet. Send one with layro publish <files>."));
604
2259
  }
605
2260
  for (const c of all.components) {
606
- const open = c.latestSubmission;
2261
+ const open2 = c.latestSubmission;
607
2262
  say(
608
- ` ${c.id.padEnd(36)} ${(LABEL[c.status] ?? c.status).padEnd(18)} ${c.version}${open && c.status === "live" ? dim(` \xB7 ${open.version} ${LABEL[open.status] ?? open.status}`) : ""}`
2263
+ ` ${c.id.padEnd(36)} ${(LABEL[c.status] ?? c.status).padEnd(18)} ${c.version}${open2 && c.status === "live" ? dim(` \xB7 ${open2.version} ${LABEL[open2.status] ?? open2.status}`) : ""}`
609
2264
  );
610
- if (open?.status === "rejected" && open.decisionNote)
611
- say(` ${red("Reason:")} ${open.decisionNote}`);
2265
+ if (open2?.status === "rejected" && open2.decisionNote)
2266
+ say(` ${red("Reason:")} ${open2.decisionNote}`);
612
2267
  }
613
2268
  say(
614
2269
  dim(
@@ -626,7 +2281,8 @@ Usage
626
2281
  layro whoami Who you're signed in as
627
2282
  layro publish <files\u2026> Send a new component, or a new version of yours
628
2283
  layro status [component] Where your submissions stand (component: slug or author/slug)
629
- layro add <author/slug> How to install a component (shadcn does it)
2284
+ layro add <author/slug> Install a component into your project
2285
+ layro init Set up shared helpers and check Tailwind
630
2286
  layro logout Sign out on this computer
631
2287
 
632
2288
  Publish options
@@ -640,6 +2296,7 @@ Publish options
640
2296
  --preview <plan.json> How to film its preview (see the docs)
641
2297
  --if-changed Unchanged code, or a version still in review: say so, exit 0 (CI)
642
2298
  -y, --yes Don't ask; send what's given (for CI)
2299
+ --json No prompts; machine-readable result and errors
643
2300
 
644
2301
  Search options
645
2302
  --category <slug> Only this category
@@ -649,6 +2306,10 @@ Search options
649
2306
 
650
2307
  Login options
651
2308
  --no-browser Print the sign-in link instead of opening it
2309
+ --timeout <seconds> Wait 1 to 300 seconds for consent (default 300)
2310
+ --json Newline-delimited authorization/result events
2311
+
2312
+ Use layro <command> --help for options and examples for each command.
652
2313
 
653
2314
  Environment
654
2315
  LAYRO_TOKEN A token to use instead of signing in: an access
@@ -660,9 +2321,13 @@ var server = (process.env.LAYRO_URL || "https://layro.dev").replace(
660
2321
  /\/+$/,
661
2322
  ""
662
2323
  );
663
- async function whoami() {
2324
+ async function whoami(json = false) {
664
2325
  const me = await api(server, "/me");
665
- say(`@${me.handle} ${dim(`on ${server}`)}`);
2326
+ if (json) {
2327
+ say(JSON.stringify(me, null, 2));
2328
+ return me;
2329
+ }
2330
+ say(`@${safeText(me.handle)} ${dim(`on ${safeText(server)}`)}`);
666
2331
  say(
667
2332
  dim(
668
2333
  `Submissions left today: ${me.submissions.left} of ${me.submissions.limit}`
@@ -671,34 +2336,101 @@ async function whoami() {
671
2336
  return me;
672
2337
  }
673
2338
  async function main(argv) {
2339
+ if (argv[0] === "help" && argv[1]) return main([argv[1], "--help"]);
674
2340
  const [command, ...rest] = argv;
675
2341
  if (!command || command === "help" || command === "--help" || command === "-h")
676
2342
  return say(HELP);
677
2343
  if (command === "--version" || command === "-v") return say(VERSION);
2344
+ if ((rest.includes("--help") || rest.includes("-h")) && commandHelp[command])
2345
+ return say(commandHelp[command]);
678
2346
  switch (command) {
2347
+ case "init": {
2348
+ if (rest.includes("--help") || rest.includes("-h"))
2349
+ return say(`layro init [options]
2350
+
2351
+ Check React, TypeScript, import aliases and Tailwind. Create components.json
2352
+ and lib/utils.ts (cn), then install clsx and tailwind-merge with scripts off.
2353
+ Existing shadcn configuration and helpers are preserved. Run twice safely.
2354
+ If setup is missing, print the exact next steps and stop before writing.
2355
+ Init does not create an app or rewrite bundler configuration.
2356
+
2357
+ --cwd <folder> App package (default current folder)
2358
+ --dry-run Show the plan; change nothing
2359
+ -y, --yes Never prompt
2360
+ --json Machine-readable result or error
2361
+
2362
+ Examples:
2363
+ npx layro-cli init --yes
2364
+ layro init --cwd apps/web --dry-run --json`);
2365
+ const { values } = parseArgs({
2366
+ args: rest,
2367
+ options: {
2368
+ cwd: { type: "string" },
2369
+ "dry-run": { type: "boolean" },
2370
+ yes: { type: "boolean", short: "y" },
2371
+ json: { type: "boolean" }
2372
+ }
2373
+ });
2374
+ await add(server, [], values, true);
2375
+ return;
2376
+ }
679
2377
  case "login": {
680
2378
  const { values } = parseArgs({
681
2379
  args: rest,
682
- options: { "no-browser": { type: "boolean" } }
2380
+ options: {
2381
+ "no-browser": { type: "boolean" },
2382
+ json: { type: "boolean" },
2383
+ timeout: { type: "string" }
2384
+ }
2385
+ });
2386
+ if (!canAsk() && !values["no-browser"])
2387
+ throw new CliError(
2388
+ "Login needs a browser and consent. In a terminal run layro login; for an explicit headless flow use layro login --no-browser --timeout 300. For CI use LAYRO_TOKEN from /settings/tokens."
2389
+ );
2390
+ const timeout = values.timeout === void 0 ? 300 : Number(values.timeout);
2391
+ if (!Number.isInteger(timeout) || timeout < 1 || timeout > 300)
2392
+ throw new CliError(
2393
+ "--timeout must be an integer from 1 to 300 seconds."
2394
+ );
2395
+ const session = await login(server, {
2396
+ browser: !values["no-browser"],
2397
+ json: values.json,
2398
+ timeout
683
2399
  });
684
- const session = await login(server, { browser: !values["no-browser"] });
685
2400
  const me = await api(server, "/me");
686
2401
  await setSession(server, { ...session, handle: me.handle });
687
- say(green(`\u2713 Signed in as @${me.handle}.`));
2402
+ if (values.json)
2403
+ return say(JSON.stringify({ status: "signed_in", handle: me.handle }));
2404
+ say(green(`\u2713 Signed in as @${safeText(me.handle)}.`));
688
2405
  say(dim(`Tokens saved in ${credentialsPath()}`));
689
2406
  return;
690
2407
  }
691
2408
  case "logout": {
2409
+ const { values } = parseArgs({
2410
+ args: rest,
2411
+ options: { json: { type: "boolean" } }
2412
+ });
692
2413
  const out = await logout(server);
2414
+ if (values.json)
2415
+ return say(
2416
+ JSON.stringify({
2417
+ signedOut: out,
2418
+ tokenEnvironmentSet: Boolean(process.env.LAYRO_TOKEN)
2419
+ })
2420
+ );
693
2421
  say(out ? "Signed out." : "You weren't signed in.");
694
2422
  if (process.env.LAYRO_TOKEN)
695
2423
  say(dim("LAYRO_TOKEN is still set in this shell."));
696
2424
  return;
697
2425
  }
698
2426
  case "whoami": {
2427
+ const { values } = parseArgs({
2428
+ args: rest,
2429
+ options: { json: { type: "boolean" } }
2430
+ });
699
2431
  if (!process.env.LAYRO_TOKEN && !(await getSession(server))?.accessToken)
700
2432
  throw new CliError("Not signed in. Run `npx layro-cli login`.");
701
- await whoami();
2433
+ await whoami(values.json);
702
2434
  return;
703
2435
  }
704
2436
  case "publish": {
@@ -715,7 +2447,8 @@ async function main(argv) {
715
2447
  preview: { type: "string" },
716
2448
  "dark-only": { type: "boolean" },
717
2449
  "if-changed": { type: "boolean" },
718
- yes: { type: "boolean", short: "y" }
2450
+ yes: { type: "boolean", short: "y" },
2451
+ json: { type: "boolean" }
719
2452
  }
720
2453
  });
721
2454
  const {
@@ -745,20 +2478,55 @@ async function main(argv) {
745
2478
  return;
746
2479
  }
747
2480
  case "status": {
748
- const { positionals } = parseArgs({ args: rest, allowPositionals: true });
749
- await status(server, positionals[0]);
2481
+ const { positionals, values } = parseArgs({
2482
+ args: rest,
2483
+ allowPositionals: true,
2484
+ options: { json: { type: "boolean" } }
2485
+ });
2486
+ if (positionals.length > 1)
2487
+ throw new CliError(
2488
+ "Use one component: layro status author/slug --json."
2489
+ );
2490
+ await status(server, positionals[0]?.replace(/^@/, ""), values.json);
750
2491
  return;
751
2492
  }
752
2493
  case "add": {
753
- const id = rest[0] ?? "<author>/<slug>";
754
- say("Layro components install with the shadcn CLI:");
755
- say(` npx shadcn@latest add ${server}/r/${id}.json`);
756
- say(dim("Or add Layro as a registry in components.json, then:"));
757
- say(dim(` npx shadcn@latest add @layro/${id}`));
2494
+ if (rest.includes("--help") || rest.includes("-h"))
2495
+ return say(`layro add @author/slug [more...] [options]
2496
+
2497
+ Install Layro source, shared cn, CSS and npm dependencies in a React TypeScript app.
2498
+ Reads components.json and project aliases. Dependencies resolve before any writes.
2499
+ Package scripts are disabled. Review third-party code before running your app.
2500
+
2501
+ --cwd <folder> App package to install into (default current folder)
2502
+ --path <folder> Destination for component files, relative to app root
2503
+ --dry-run Show the plan without changing files or packages
2504
+ -y, --yes Skip differing existing files; never prompt
2505
+ --overwrite Replace differing files
2506
+ --json Machine-readable plan/result
2507
+
2508
+ Examples:
2509
+ npx layro-cli add @magicuidesign/aurora-text --yes
2510
+ layro add author/one author/two --cwd apps/web --dry-run --json`);
2511
+ const { values, positionals } = parseArgs({
2512
+ args: rest,
2513
+ allowPositionals: true,
2514
+ options: {
2515
+ cwd: { type: "string" },
2516
+ path: { type: "string" },
2517
+ "dry-run": { type: "boolean" },
2518
+ yes: { type: "boolean", short: "y" },
2519
+ overwrite: { type: "boolean" },
2520
+ json: { type: "boolean" }
2521
+ }
2522
+ });
2523
+ await add(server, positionals, values);
758
2524
  return;
759
2525
  }
760
2526
  default:
761
- throw new CliError(`Unknown command "${command}". Run layro --help.`);
2527
+ throw new CliError(
2528
+ `Unknown command "${safeText(command)}".${suggestCommand(command)} Run layro --help.`
2529
+ );
762
2530
  }
763
2531
  }
764
2532
  process.stdout.on("error", (error) => {
@@ -766,12 +2534,34 @@ process.stdout.on("error", (error) => {
766
2534
  throw error;
767
2535
  });
768
2536
  main(process.argv.slice(2)).catch((error) => {
769
- if (error instanceof CliError) {
770
- warn(red(error.message));
2537
+ if (process.argv.includes("--json")) {
2538
+ say(
2539
+ JSON.stringify({
2540
+ error: error instanceof Error ? error.message : "Could not finish the command.",
2541
+ ...error instanceof ApiFailure ? {
2542
+ status: error.detail.status,
2543
+ code: error.detail.code,
2544
+ errors: error.detail.errors
2545
+ } : {}
2546
+ })
2547
+ );
2548
+ process.exitCode = 1;
2549
+ return;
2550
+ }
2551
+ if (error instanceof SetupRequired) {
2552
+ for (const line of error.message.split("\n")) warn(safeText(line));
2553
+ } else if (error instanceof CliError) {
2554
+ warn(red(safeText(error.message)));
771
2555
  } else if (error?.code?.startsWith("ERR_PARSE_ARGS")) {
772
- warn(red(error.message));
2556
+ warn(red(safeText(error.message)));
773
2557
  } else {
774
- warn(red(`Unexpected error: ${error?.stack ?? error}`));
2558
+ warn(
2559
+ red(
2560
+ safeText(
2561
+ `Could not finish: ${error?.message ?? "unknown error"}.`
2562
+ )
2563
+ )
2564
+ );
775
2565
  }
776
2566
  process.exit(1);
777
2567
  });