layro-cli 0.3.0 → 0.5.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 +115 -11
  2. package/dist/layro.mjs +2210 -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.5.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,433 @@ async function api(server2, path, init = {}) {
389
1896
  });
390
1897
  }
391
1898
 
1899
+ // src/command-help.ts
1900
+ var commandHelp = {
1901
+ mcp: `layro mcp [--client <agent>] [options]
1902
+
1903
+ Add Layro's MCP server to a coding agent, so you never type its URL.
1904
+ Without --client it asks which, listing the agents it finds first.
1905
+
1906
+ claude claude mcp add --transport http --scope user (every project)
1907
+ cursor adds "layro" to ~/.cursor/mcp.json
1908
+ vscode code --add-mcp (your profile), or .vscode/mcp.json here
1909
+ codex codex mcp add layro --url
1910
+
1911
+ An existing "layro" entry is left alone; other servers are never touched.
1912
+ Sign-in is your agent's own: it opens the browser on first use.
1913
+
1914
+ -c, --client <agent> claude, cursor, vscode or codex
1915
+ --cwd <folder> Project for .vscode/mcp.json (default current folder)
1916
+ --dry-run Show what would change; change nothing
1917
+ -y, --yes Don't ask before changing
1918
+ --json Machine-readable result
1919
+
1920
+ Examples:
1921
+ npx layro-cli mcp
1922
+ npx layro-cli mcp --client cursor --yes`,
1923
+ search: `layro search <words...> [options]
1924
+
1925
+ Find public components and print their Layro add commands. No sign-in.
1926
+ --category <slug> Filter category
1927
+ --author <handle> Filter author
1928
+ --limit <n> 1 to 50 (default 20)
1929
+ --json Return results with Layro install commands as JSON
1930
+
1931
+ Examples:
1932
+ npx layro-cli search animated text
1933
+ layro search chart --author legions-developer --limit 5 --json`,
1934
+ publish: `layro publish <files...> [options]
1935
+
1936
+ Submit a new component or a version of your same-named component for review.
1937
+ The first file is the entry. Sign in with login or set LAYRO_TOKEN for CI.
1938
+ --name <name> Defaults to entry file's name
1939
+ --description <text> 20 to 200 characters for a new component
1940
+ --category <slug> Category from layro.dev/components
1941
+ --license <id> MIT (default), Apache-2.0, ISC or BSD-3-Clause
1942
+ --version <x.y.z> Existing component: next patch by default
1943
+ --notes <text> What changed
1944
+ --preview <file.json> How to record the preview
1945
+ --dark-only New component only works on a dark page
1946
+ --if-changed Unchanged/pending version: nothing sent, exit 0
1947
+ -y, --yes Send flags as given without prompts
1948
+ --json No prompts; return result or field errors as JSON
1949
+
1950
+ Examples:
1951
+ layro publish src/components/card.tsx --name "My Card" --description "A useful card for your dashboard" --category cards --yes
1952
+ layro publish src/components/card.tsx --if-changed --json`,
1953
+ status: `layro status [slug | author/slug] [--json]
1954
+
1955
+ Show your submissions and reviewers' reasons. Sign in or set LAYRO_TOKEN.
1956
+ Without a component, list all of yours. --json returns the API response.
1957
+
1958
+ Examples:
1959
+ layro status
1960
+ layro status author/card --json`,
1961
+ login: `layro login [--no-browser] [--timeout <seconds>] [--json]
1962
+
1963
+ Sign in with GitHub and approve Layro CLI in your browser.
1964
+ --no-browser Print the link; explicitly wait for its loopback callback
1965
+ --timeout <n> Wait 1 to 300 seconds for consent (default 300)
1966
+ --json Print newline-delimited JSON: authorization_required, then
1967
+ signed_in or error. Never includes access/refresh tokens.
1968
+
1969
+ Without a terminal, --no-browser is required. CI should use LAYRO_TOKEN.
1970
+ Network requests time out after 20 seconds. Consent uses the timeout above.
1971
+
1972
+ Examples:
1973
+ layro login
1974
+ layro login --no-browser --timeout 120 --json`,
1975
+ logout: `layro logout [--json]
1976
+
1977
+ Revoke and forget the saved session. Offline: forget local tokens anyway.
1978
+ --json returns signedOut and tokenEnvironmentSet. An environment token stays set.
1979
+
1980
+ Examples:
1981
+ layro logout
1982
+ layro logout --json`,
1983
+ whoami: `layro whoami [--json]
1984
+
1985
+ Show the signed-in handle, profile and submissions left today.
1986
+ Uses LAYRO_TOKEN when set, otherwise the saved session. --json returns API JSON.
1987
+
1988
+ Examples:
1989
+ layro whoami
1990
+ layro whoami --json`
1991
+ };
1992
+ var commands = [
1993
+ "init",
1994
+ "mcp",
1995
+ "add",
1996
+ "search",
1997
+ "publish",
1998
+ "status",
1999
+ "login",
2000
+ "logout",
2001
+ "whoami",
2002
+ "help"
2003
+ ];
2004
+ function distance(a, b) {
2005
+ let row = Array.from({ length: b.length + 1 }, (_, i) => i);
2006
+ for (let i = 1; i <= a.length; i++) {
2007
+ const next2 = [i];
2008
+ for (let j = 1; j <= b.length; j++)
2009
+ next2[j] = Math.min(
2010
+ next2[j - 1] + 1,
2011
+ row[j] + 1,
2012
+ row[j - 1] + Number(a[i - 1] !== b[j - 1])
2013
+ );
2014
+ row = next2;
2015
+ }
2016
+ return row[b.length];
2017
+ }
2018
+ function suggestCommand(input) {
2019
+ if (input.length > 32) return "";
2020
+ const found = commands.map((command) => ({ command, distance: distance(input, command) })).sort((a, b) => a.distance - b.distance)[0];
2021
+ return found.distance <= 2 ? ` Did you mean layro ${found.command}?` : "";
2022
+ }
2023
+
2024
+ // src/mcp.ts
2025
+ import { spawnSync } from "node:child_process";
2026
+ import { existsSync as existsSync4 } from "node:fs";
2027
+ import { mkdir as mkdir3, readFile as readFile5, writeFile as writeFile2 } from "node:fs/promises";
2028
+ import { homedir as homedir2 } from "node:os";
2029
+ import { dirname as dirname6, join as join5 } from "node:path";
2030
+ var byHand = (message, manual) => new SetupRequired(message, manual);
2031
+ var MCP_NAME = "layro";
2032
+ var clients = ["claude", "cursor", "vscode", "codex"];
2033
+ var labels = {
2034
+ claude: "Claude Code",
2035
+ cursor: "Cursor",
2036
+ vscode: "VS Code",
2037
+ codex: "Codex"
2038
+ };
2039
+ function mcpUrl(server2) {
2040
+ return server2 === "https://layro.dev" ? "https://mcp.layro.dev" : `${server2}/mcp`;
2041
+ }
2042
+ var windows = process.platform === "win32";
2043
+ function onPath(program) {
2044
+ const dirs = (process.env.PATH ?? "").split(windows ? ";" : ":");
2045
+ const names = windows ? [`${program}.cmd`, `${program}.exe`, program] : [program];
2046
+ return dirs.some(
2047
+ (dir) => dir && names.some((name) => existsSync4(join5(dir, name)))
2048
+ );
2049
+ }
2050
+ function run(program, args, quiet = false) {
2051
+ const result = spawnSync(program, args, {
2052
+ stdio: quiet ? "ignore" : "inherit",
2053
+ // .cmd shims need a shell on Windows; every argument here is ours.
2054
+ shell: windows
2055
+ });
2056
+ return result.status === 0;
2057
+ }
2058
+ async function mergeJson(file, key, entry, manual) {
2059
+ let config = {};
2060
+ if (existsSync4(file)) {
2061
+ const text = await readFile5(file, "utf8");
2062
+ try {
2063
+ config = text.trim() ? JSON.parse(text) : {};
2064
+ } catch {
2065
+ throw byHand(
2066
+ `${file} isn't plain JSON (comments?), so it was left alone. Add this by hand:`,
2067
+ manual
2068
+ );
2069
+ }
2070
+ if (!config || typeof config !== "object" || Array.isArray(config))
2071
+ throw byHand(`${file} isn't a JSON object. Add this by hand:`, manual);
2072
+ }
2073
+ const servers = config[key] ?? {};
2074
+ if (typeof servers !== "object" || Array.isArray(servers))
2075
+ throw byHand(
2076
+ `"${key}" in ${file} isn't an object. Add this by hand:`,
2077
+ manual
2078
+ );
2079
+ if (servers[MCP_NAME]) return "exists";
2080
+ config[key] = { ...servers, [MCP_NAME]: entry };
2081
+ await mkdir3(dirname6(file), { recursive: true });
2082
+ await writeFile2(file, `${JSON.stringify(config, null, 2)}
2083
+ `);
2084
+ return "added";
2085
+ }
2086
+ function planFor(client, server2, cwd) {
2087
+ const url = mcpUrl(server2);
2088
+ switch (client) {
2089
+ case "claude": {
2090
+ const args = [
2091
+ "mcp",
2092
+ "add",
2093
+ "--transport",
2094
+ "http",
2095
+ "--scope",
2096
+ "user",
2097
+ MCP_NAME,
2098
+ url
2099
+ ];
2100
+ const manual = `claude ${args.join(" ")}`;
2101
+ return {
2102
+ client,
2103
+ url,
2104
+ action: `Run: ${manual}`,
2105
+ manual,
2106
+ apply: async () => {
2107
+ if (!onPath("claude"))
2108
+ throw byHand(
2109
+ `Claude Code's CLI (claude) isn't on PATH. Run this where it is:`,
2110
+ manual
2111
+ );
2112
+ if (run("claude", ["mcp", "get", MCP_NAME], true)) return "exists";
2113
+ if (!run("claude", args))
2114
+ throw byHand(`claude mcp add failed. Run it yourself:`, manual);
2115
+ return "added";
2116
+ }
2117
+ };
2118
+ }
2119
+ case "codex": {
2120
+ const args = ["mcp", "add", MCP_NAME, "--url", url];
2121
+ const manual = `codex ${args.join(" ")}`;
2122
+ return {
2123
+ client,
2124
+ url,
2125
+ action: `Run: ${manual}`,
2126
+ manual,
2127
+ apply: async () => {
2128
+ if (!onPath("codex"))
2129
+ throw byHand(
2130
+ `Codex's CLI (codex) isn't on PATH. Run this where it is:`,
2131
+ manual
2132
+ );
2133
+ if (run("codex", ["mcp", "get", MCP_NAME], true)) return "exists";
2134
+ if (!run("codex", args))
2135
+ throw byHand(`codex mcp add failed. Run it yourself:`, manual);
2136
+ return "added";
2137
+ }
2138
+ };
2139
+ }
2140
+ case "cursor": {
2141
+ if (!homedir2())
2142
+ throw new CliError("Can't find your home folder (HOME isn't set).");
2143
+ const file = join5(homedir2(), ".cursor", "mcp.json");
2144
+ const manual = JSON.stringify(
2145
+ { mcpServers: { [MCP_NAME]: { url } } },
2146
+ null,
2147
+ 2
2148
+ );
2149
+ return {
2150
+ client,
2151
+ url,
2152
+ action: `Add "${MCP_NAME}" to ${file}`,
2153
+ manual,
2154
+ apply: () => mergeJson(file, "mcpServers", { url }, manual)
2155
+ };
2156
+ }
2157
+ case "vscode": {
2158
+ const entry = { type: "http", url };
2159
+ const json = JSON.stringify({ name: MCP_NAME, ...entry });
2160
+ if (!windows && onPath("code")) {
2161
+ const manual2 = `code --add-mcp '${json}'`;
2162
+ return {
2163
+ client,
2164
+ url,
2165
+ action: `Run: ${manual2}`,
2166
+ manual: manual2,
2167
+ apply: async () => {
2168
+ if (!run("code", ["--add-mcp", json]))
2169
+ throw byHand(`code --add-mcp failed. Run it yourself:`, manual2);
2170
+ return "added";
2171
+ }
2172
+ };
2173
+ }
2174
+ const file = join5(cwd, ".vscode", "mcp.json");
2175
+ const manual = JSON.stringify(
2176
+ { servers: { [MCP_NAME]: entry } },
2177
+ null,
2178
+ 2
2179
+ );
2180
+ return {
2181
+ client,
2182
+ url,
2183
+ action: `Add "${MCP_NAME}" to ${file} (this project)`,
2184
+ manual,
2185
+ apply: () => mergeJson(file, "servers", entry, manual)
2186
+ };
2187
+ }
2188
+ }
2189
+ }
2190
+ function detected() {
2191
+ const home = homedir2();
2192
+ const found = /* @__PURE__ */ new Set();
2193
+ if (onPath("claude") || existsSync4(join5(home, ".claude")))
2194
+ found.add("claude");
2195
+ if (onPath("cursor") || existsSync4(join5(home, ".cursor")))
2196
+ found.add("cursor");
2197
+ if (onPath("code")) found.add("vscode");
2198
+ if (onPath("codex") || existsSync4(join5(home, ".codex"))) found.add("codex");
2199
+ return found;
2200
+ }
2201
+ var nextStep = {
2202
+ claude: "Ask Claude for a component. The first call opens your browser to sign in with GitHub and approve Layro.",
2203
+ cursor: "Restart Cursor if it's open. It opens your browser to sign in on first use.",
2204
+ vscode: "Open Copilot Chat in agent mode. VS Code asks you to sign in to Layro on first use.",
2205
+ codex: `If Codex didn't open your browser, sign in once: codex mcp login ${MCP_NAME}`
2206
+ };
2207
+ async function addMcp(server2, flags) {
2208
+ const cwd = flags.cwd ?? process.cwd();
2209
+ let client = flags.client?.toLowerCase().replace(/[\s-]+/g, "");
2210
+ if (client === "claudecode") client = "claude";
2211
+ if (client === "code") client = "vscode";
2212
+ if (client && !clients.includes(client))
2213
+ throw new CliError(
2214
+ `Unknown agent "${safeText(flags.client ?? "")}". Use --client with one of: ${clients.join(", ")}.`
2215
+ );
2216
+ if (!client) {
2217
+ if (flags.json || flags.yes || !canAsk())
2218
+ throw new CliError(
2219
+ `Say which agent: --client ${clients.join(" | ")}. Any other MCP client takes ${mcpUrl(server2)}.`
2220
+ );
2221
+ const found = detected();
2222
+ const order = [...clients].sort(
2223
+ (a, b) => Number(found.has(b)) - Number(found.has(a))
2224
+ );
2225
+ client = await choose(
2226
+ "Add Layro to",
2227
+ order.map((value) => ({
2228
+ value,
2229
+ label: `${labels[value]}${found.has(value) ? dim(" found") : ""}`
2230
+ })),
2231
+ order[0]
2232
+ );
2233
+ }
2234
+ const plan = planFor(client, server2, cwd);
2235
+ if (flags["dry-run"]) {
2236
+ if (flags.json)
2237
+ return say(
2238
+ JSON.stringify({
2239
+ client: plan.client,
2240
+ url: plan.url,
2241
+ action: plan.action,
2242
+ manual: plan.manual,
2243
+ dryRun: true
2244
+ })
2245
+ );
2246
+ say(`${bold(labels[plan.client])}: ${plan.action}`);
2247
+ return say(dim("Dry run: nothing changed."));
2248
+ }
2249
+ if (!flags.yes && !flags.json && canAsk()) {
2250
+ say(`${bold(labels[plan.client])}: ${plan.action}`);
2251
+ if (!await confirm("Go ahead?")) throw new CliError("Cancelled.");
2252
+ }
2253
+ const result = await plan.apply();
2254
+ if (flags.json)
2255
+ return say(
2256
+ JSON.stringify({ client: plan.client, url: plan.url, status: result })
2257
+ );
2258
+ say(
2259
+ result === "added" ? green(`\u2713 Layro added to ${labels[plan.client]}.`) : `Layro is already set up in ${labels[plan.client]}. Nothing changed.`
2260
+ );
2261
+ say(dim(nextStep[plan.client]));
2262
+ }
2263
+
392
2264
  // src/publish.ts
393
- import { readFile as readFile2 } from "node:fs/promises";
394
- import { basename, dirname as dirname2, relative, resolve, sep } from "node:path";
2265
+ import { readFile as readFile6 } from "node:fs/promises";
2266
+ import {
2267
+ basename,
2268
+ dirname as dirname7,
2269
+ isAbsolute as isAbsolute2,
2270
+ relative as relative5,
2271
+ resolve as resolve5,
2272
+ sep as sep2
2273
+ } from "node:path";
395
2274
  function nameFromFile(path) {
396
2275
  const stem = basename(path).replace(/\.[^.]+$/, "");
397
2276
  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
2277
  }
399
2278
  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
- }
2279
+ if (!paths.length)
2280
+ throw new CliError("Give at least one source file to publish.");
2281
+ const absolute = paths.map((p) => resolve5(p));
2282
+ const root = commonFileRoot(absolute);
407
2283
  return Promise.all(
408
2284
  absolute.map(async (path, i) => {
409
2285
  let content;
410
2286
  try {
411
- content = await readFile2(path, "utf8");
2287
+ content = await readFile6(path, "utf8");
412
2288
  } catch {
413
2289
  throw new CliError(`Can't read ${paths[i]}.`);
414
2290
  }
415
- return { path: relative(root, path).split(sep).join("/"), content };
2291
+ return { path: relative5(root, path).split(sep2).join("/"), content };
416
2292
  })
417
2293
  );
418
2294
  }
2295
+ function commonFileRoot(paths, path = { dirname: dirname7, relative: relative5, isAbsolute: isAbsolute2 }) {
2296
+ let root = path.dirname(paths[0]);
2297
+ for (const file of paths) {
2298
+ for (; ; ) {
2299
+ const rel = path.relative(root, path.dirname(file));
2300
+ if (!path.isAbsolute(rel) && rel !== ".." && !rel.startsWith("../") && !rel.startsWith("..\\"))
2301
+ break;
2302
+ const parent = path.dirname(root);
2303
+ if (parent === root)
2304
+ throw new CliError(
2305
+ "Publish files must share a directory on the same drive. Copy them into one project first."
2306
+ );
2307
+ root = parent;
2308
+ }
2309
+ }
2310
+ return root;
2311
+ }
419
2312
  async function readPreview(path) {
420
2313
  if (!path) return void 0;
421
2314
  try {
422
- return JSON.parse(await readFile2(path, "utf8"));
2315
+ return JSON.parse(await readFile6(path, "utf8"));
423
2316
  } catch {
424
2317
  throw new CliError(`--preview: ${path} isn't a readable JSON file.`);
425
2318
  }
426
2319
  }
427
- function explain(error) {
2320
+ function explain(error, json = false) {
2321
+ if (json) throw error;
428
2322
  if (error instanceof ApiFailure && error.detail.errors) {
429
2323
  say(red("Not sent. Fix these and try again:"));
430
2324
  for (const [field, message] of Object.entries(error.detail.errors)) {
431
- say(` ${bold(field)}: ${message}`);
2325
+ say(` ${bold(safeText(field))}: ${safeText(message)}`);
432
2326
  }
433
2327
  process.exit(1);
434
2328
  }
@@ -439,15 +2333,18 @@ async function publish(server2, paths, flags) {
439
2333
  throw new CliError(
440
2334
  "Name the files to publish, entry first: layro publish components/pricing-card.tsx"
441
2335
  );
442
- const interactive = canAsk() && !flags.yes;
2336
+ const interactive = canAsk() && !flags.yes && !flags.json;
2337
+ const note = (text) => {
2338
+ if (!flags.json) say(safeText(text));
2339
+ };
443
2340
  const files = await readFiles(paths);
444
2341
  const preview = await readPreview(flags.preview);
445
2342
  const me = await api(server2, "/me");
446
- say(dim(`Publishing as @${me.handle} to ${server2}`));
2343
+ note(`Publishing as @${me.handle} to ${server2}`);
447
2344
  const name = flags.name ?? (interactive ? await ask("Name", nameFromFile(paths[0])) : nameFromFile(paths[0]));
448
- let existing = null;
2345
+ let existing2 = null;
449
2346
  try {
450
- existing = await api(
2347
+ existing2 = await api(
451
2348
  server2,
452
2349
  `/status?name=${encodeURIComponent(name)}`
453
2350
  );
@@ -456,17 +2353,17 @@ async function publish(server2, paths, flags) {
456
2353
  throw error;
457
2354
  }
458
2355
  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.`
2356
+ if (existing2) {
2357
+ note(
2358
+ `${bold(existing2.name)} is already yours (${existing2.liveVersion ? `${existing2.liveVersion} live` : existing2.status}): sending a new version.`
462
2359
  );
463
- const version = flags.version ?? (interactive ? await ask("Version", existing.nextVersion) : existing.nextVersion);
2360
+ const version = flags.version ?? (interactive ? await ask("Version", existing2.nextVersion) : existing2.nextVersion);
464
2361
  const notes = flags.notes ?? (interactive ? await ask("What changed", "") : "");
465
- if (interactive && !await confirm(`Send ${existing.id} ${version} for review?`))
2362
+ if (interactive && !await confirm(`Send ${existing2.id} ${version} for review?`))
466
2363
  return say("Nothing sent.");
467
2364
  const sent = await api(
468
2365
  server2,
469
- `/components/${existing.id}/versions`,
2366
+ `/components/${existing2.id}/versions`,
470
2367
  {
471
2368
  method: "POST",
472
2369
  body: { version, notes, files, preview }
@@ -474,17 +2371,18 @@ async function publish(server2, paths, flags) {
474
2371
  ).catch((error) => {
475
2372
  if (flags.ifChanged && error instanceof ApiFailure && (error.detail.code === "unchanged" || error.detail.code === "pending"))
476
2373
  return error;
477
- return explain(error);
2374
+ return explain(error, flags.json);
478
2375
  });
479
2376
  if (sent instanceof ApiFailure) {
480
- say(
481
- `Nothing sent: ${Object.values(sent.detail.errors ?? {})[0] ?? sent.message}`
482
- );
2377
+ const message = Object.values(sent.detail.errors ?? {})[0] ?? sent.message;
2378
+ if (flags.json)
2379
+ say(JSON.stringify({ sent: false, status: sent.detail.code, message }));
2380
+ else note(`Nothing sent: ${message}`);
483
2381
  return;
484
2382
  }
485
2383
  result = sent;
486
2384
  } else {
487
- if (flags.version && flags.version !== "1.0.0")
2385
+ if (!flags.json && flags.version && flags.version !== "1.0.0")
488
2386
  warn(
489
2387
  yellow(
490
2388
  `New components start at 1.0.0; ignoring --version ${flags.version}.`
@@ -526,8 +2424,13 @@ async function publish(server2, paths, flags) {
526
2424
  darkOnly: flags.darkOnly ?? false,
527
2425
  preview
528
2426
  }
529
- }).catch(explain);
2427
+ }).catch((error) => explain(error, flags.json));
530
2428
  }
2429
+ if (flags.json)
2430
+ return say(
2431
+ JSON.stringify({ sent: true, status: "review", ...result }, null, 2)
2432
+ );
2433
+ result = terminalData(result);
531
2434
  say(green(`\u2713 ${result.component} ${result.version} is in review.`));
532
2435
  say(` ${result.message}`);
533
2436
  say(` Studio: ${result.studio}`);
@@ -538,23 +2441,34 @@ async function publish(server2, paths, flags) {
538
2441
  async function search(server2, words, flags) {
539
2442
  const q = words.join(" ").trim();
540
2443
  if (!q) throw new CliError("Give some words: layro search <words\u2026>");
2444
+ if (flags.limit && (!/^\d+$/.test(flags.limit) || Number(flags.limit) < 1 || Number(flags.limit) > 50))
2445
+ throw new CliError(
2446
+ "--limit must be an integer from 1 to 50. Example: layro search chart --limit 10."
2447
+ );
541
2448
  const params = new URLSearchParams({ q });
542
2449
  if (flags.category) params.set("category", flags.category);
543
2450
  if (flags.author) params.set("author", flags.author);
544
2451
  if (flags.limit) params.set("limit", flags.limit);
545
2452
  const found = await api(server2, `/search?${params}`, { auth: false });
2453
+ for (const result of found.results) {
2454
+ if (!/^@?[a-zA-Z0-9][a-zA-Z0-9_-]*\/[a-z0-9][a-z0-9-]*$/.test(result.id))
2455
+ throw new CliError(
2456
+ "The search API returned an invalid component id. Check LAYRO_URL and retry."
2457
+ );
2458
+ result.install = `npx layro-cli add @${result.id.replace(/^@/, "")}`;
2459
+ }
546
2460
  if (flags.json) return say(JSON.stringify(found, null, 2));
547
2461
  if (found.results.length === 0) {
548
- say(`Nothing matches "${q}".`);
2462
+ say(`Nothing matches "${safeText(q)}".`);
549
2463
  if (found.suggestion)
550
2464
  say(
551
- `The closest category is ${bold(found.suggestion.name)}: ${found.suggestion.url}`
2465
+ `The closest category is ${bold(safeText(found.suggestion.name))}: ${safeText(found.suggestion.url)}`
552
2466
  );
553
2467
  return;
554
2468
  }
555
2469
  for (const c of found.results) {
556
- say(`${bold(c.name)} ${dim(c.id)}`);
557
- say(` ${c.description}`);
2470
+ say(`${bold(safeText(c.name))} ${dim(c.id)}`);
2471
+ say(` ${safeText(c.description)}`);
558
2472
  say(dim(` ${c.install}`));
559
2473
  }
560
2474
  say(
@@ -572,14 +2486,16 @@ var LABEL = {
572
2486
  processing: "processing",
573
2487
  approved: "approved"
574
2488
  };
575
- async function status(server2, target) {
2489
+ async function status(server2, target, json = false) {
576
2490
  if (target) {
577
2491
  const me = target.includes("/") ? null : await api(server2, "/me");
578
2492
  const id = me ? `${me.handle}/${target}` : target;
579
- const one = await api(
2493
+ let one = await api(
580
2494
  server2,
581
2495
  `/status?component=${encodeURIComponent(id)}`
582
2496
  );
2497
+ if (json) return say(JSON.stringify(one, null, 2));
2498
+ one = terminalData(one);
583
2499
  say(`${bold(one.name)} ${dim(one.id)}: ${LABEL[one.status] ?? one.status}`);
584
2500
  if (one.url) say(` ${one.url}`);
585
2501
  say(` Studio: ${one.studio}`);
@@ -597,18 +2513,20 @@ async function status(server2, target) {
597
2513
  }
598
2514
  return;
599
2515
  }
600
- const all = await api(server2, "/status");
2516
+ let all = await api(server2, "/status");
2517
+ if (json) return say(JSON.stringify(all, null, 2));
2518
+ all = terminalData(all);
601
2519
  say(`@${all.author}`);
602
2520
  if (all.components.length === 0) {
603
2521
  say(dim("No components yet. Send one with layro publish <files>."));
604
2522
  }
605
2523
  for (const c of all.components) {
606
- const open = c.latestSubmission;
2524
+ const open2 = c.latestSubmission;
607
2525
  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}`) : ""}`
2526
+ ` ${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
2527
  );
610
- if (open?.status === "rejected" && open.decisionNote)
611
- say(` ${red("Reason:")} ${open.decisionNote}`);
2528
+ if (open2?.status === "rejected" && open2.decisionNote)
2529
+ say(` ${red("Reason:")} ${open2.decisionNote}`);
612
2530
  }
613
2531
  say(
614
2532
  dim(
@@ -626,7 +2544,10 @@ Usage
626
2544
  layro whoami Who you're signed in as
627
2545
  layro publish <files\u2026> Send a new component, or a new version of yours
628
2546
  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)
2547
+ layro add <author/slug> Install a component into your project
2548
+ layro init Set up shared helpers and check Tailwind
2549
+ layro mcp Add Layro to your coding agent (Claude Code,
2550
+ Cursor, VS Code, Codex): no URL to type
630
2551
  layro logout Sign out on this computer
631
2552
 
632
2553
  Publish options
@@ -640,6 +2561,7 @@ Publish options
640
2561
  --preview <plan.json> How to film its preview (see the docs)
641
2562
  --if-changed Unchanged code, or a version still in review: say so, exit 0 (CI)
642
2563
  -y, --yes Don't ask; send what's given (for CI)
2564
+ --json No prompts; machine-readable result and errors
643
2565
 
644
2566
  Search options
645
2567
  --category <slug> Only this category
@@ -649,6 +2571,10 @@ Search options
649
2571
 
650
2572
  Login options
651
2573
  --no-browser Print the sign-in link instead of opening it
2574
+ --timeout <seconds> Wait 1 to 300 seconds for consent (default 300)
2575
+ --json Newline-delimited authorization/result events
2576
+
2577
+ Use layro <command> --help for options and examples for each command.
652
2578
 
653
2579
  Environment
654
2580
  LAYRO_TOKEN A token to use instead of signing in: an access
@@ -660,9 +2586,13 @@ var server = (process.env.LAYRO_URL || "https://layro.dev").replace(
660
2586
  /\/+$/,
661
2587
  ""
662
2588
  );
663
- async function whoami() {
2589
+ async function whoami(json = false) {
664
2590
  const me = await api(server, "/me");
665
- say(`@${me.handle} ${dim(`on ${server}`)}`);
2591
+ if (json) {
2592
+ say(JSON.stringify(me, null, 2));
2593
+ return me;
2594
+ }
2595
+ say(`@${safeText(me.handle)} ${dim(`on ${safeText(server)}`)}`);
666
2596
  say(
667
2597
  dim(
668
2598
  `Submissions left today: ${me.submissions.left} of ${me.submissions.limit}`
@@ -671,34 +2601,116 @@ async function whoami() {
671
2601
  return me;
672
2602
  }
673
2603
  async function main(argv) {
2604
+ if (argv[0] === "help" && argv[1]) return main([argv[1], "--help"]);
674
2605
  const [command, ...rest] = argv;
675
2606
  if (!command || command === "help" || command === "--help" || command === "-h")
676
2607
  return say(HELP);
677
2608
  if (command === "--version" || command === "-v") return say(VERSION);
2609
+ if ((rest.includes("--help") || rest.includes("-h")) && commandHelp[command])
2610
+ return say(commandHelp[command]);
678
2611
  switch (command) {
2612
+ case "init": {
2613
+ if (rest.includes("--help") || rest.includes("-h"))
2614
+ return say(`layro init [options]
2615
+
2616
+ Check React, TypeScript, import aliases and Tailwind. Create components.json
2617
+ and lib/utils.ts (cn), then install clsx and tailwind-merge with scripts off.
2618
+ Existing shadcn configuration and helpers are preserved. Run twice safely.
2619
+ If setup is missing, print the exact next steps and stop before writing.
2620
+ Init does not create an app or rewrite bundler configuration.
2621
+
2622
+ --cwd <folder> App package (default current folder)
2623
+ --dry-run Show the plan; change nothing
2624
+ -y, --yes Never prompt
2625
+ --json Machine-readable result or error
2626
+
2627
+ Examples:
2628
+ npx layro-cli init --yes
2629
+ layro init --cwd apps/web --dry-run --json`);
2630
+ const { values } = parseArgs({
2631
+ args: rest,
2632
+ options: {
2633
+ cwd: { type: "string" },
2634
+ "dry-run": { type: "boolean" },
2635
+ yes: { type: "boolean", short: "y" },
2636
+ json: { type: "boolean" }
2637
+ }
2638
+ });
2639
+ await add(server, [], values, true);
2640
+ return;
2641
+ }
2642
+ case "mcp": {
2643
+ const args = rest[0] === "add" || rest[0] === "init" ? rest.slice(1) : rest;
2644
+ const { values } = parseArgs({
2645
+ args,
2646
+ options: {
2647
+ client: { type: "string", short: "c" },
2648
+ cwd: { type: "string" },
2649
+ "dry-run": { type: "boolean" },
2650
+ yes: { type: "boolean", short: "y" },
2651
+ json: { type: "boolean" }
2652
+ }
2653
+ });
2654
+ await addMcp(server, values);
2655
+ return;
2656
+ }
679
2657
  case "login": {
680
2658
  const { values } = parseArgs({
681
2659
  args: rest,
682
- options: { "no-browser": { type: "boolean" } }
2660
+ options: {
2661
+ "no-browser": { type: "boolean" },
2662
+ json: { type: "boolean" },
2663
+ timeout: { type: "string" }
2664
+ }
2665
+ });
2666
+ if (!canAsk() && !values["no-browser"])
2667
+ throw new CliError(
2668
+ "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."
2669
+ );
2670
+ const timeout = values.timeout === void 0 ? 300 : Number(values.timeout);
2671
+ if (!Number.isInteger(timeout) || timeout < 1 || timeout > 300)
2672
+ throw new CliError(
2673
+ "--timeout must be an integer from 1 to 300 seconds."
2674
+ );
2675
+ const session = await login(server, {
2676
+ browser: !values["no-browser"],
2677
+ json: values.json,
2678
+ timeout
683
2679
  });
684
- const session = await login(server, { browser: !values["no-browser"] });
685
2680
  const me = await api(server, "/me");
686
2681
  await setSession(server, { ...session, handle: me.handle });
687
- say(green(`\u2713 Signed in as @${me.handle}.`));
2682
+ if (values.json)
2683
+ return say(JSON.stringify({ status: "signed_in", handle: me.handle }));
2684
+ say(green(`\u2713 Signed in as @${safeText(me.handle)}.`));
688
2685
  say(dim(`Tokens saved in ${credentialsPath()}`));
689
2686
  return;
690
2687
  }
691
2688
  case "logout": {
2689
+ const { values } = parseArgs({
2690
+ args: rest,
2691
+ options: { json: { type: "boolean" } }
2692
+ });
692
2693
  const out = await logout(server);
2694
+ if (values.json)
2695
+ return say(
2696
+ JSON.stringify({
2697
+ signedOut: out,
2698
+ tokenEnvironmentSet: Boolean(process.env.LAYRO_TOKEN)
2699
+ })
2700
+ );
693
2701
  say(out ? "Signed out." : "You weren't signed in.");
694
2702
  if (process.env.LAYRO_TOKEN)
695
2703
  say(dim("LAYRO_TOKEN is still set in this shell."));
696
2704
  return;
697
2705
  }
698
2706
  case "whoami": {
2707
+ const { values } = parseArgs({
2708
+ args: rest,
2709
+ options: { json: { type: "boolean" } }
2710
+ });
699
2711
  if (!process.env.LAYRO_TOKEN && !(await getSession(server))?.accessToken)
700
2712
  throw new CliError("Not signed in. Run `npx layro-cli login`.");
701
- await whoami();
2713
+ await whoami(values.json);
702
2714
  return;
703
2715
  }
704
2716
  case "publish": {
@@ -715,7 +2727,8 @@ async function main(argv) {
715
2727
  preview: { type: "string" },
716
2728
  "dark-only": { type: "boolean" },
717
2729
  "if-changed": { type: "boolean" },
718
- yes: { type: "boolean", short: "y" }
2730
+ yes: { type: "boolean", short: "y" },
2731
+ json: { type: "boolean" }
719
2732
  }
720
2733
  });
721
2734
  const {
@@ -745,20 +2758,55 @@ async function main(argv) {
745
2758
  return;
746
2759
  }
747
2760
  case "status": {
748
- const { positionals } = parseArgs({ args: rest, allowPositionals: true });
749
- await status(server, positionals[0]);
2761
+ const { positionals, values } = parseArgs({
2762
+ args: rest,
2763
+ allowPositionals: true,
2764
+ options: { json: { type: "boolean" } }
2765
+ });
2766
+ if (positionals.length > 1)
2767
+ throw new CliError(
2768
+ "Use one component: layro status author/slug --json."
2769
+ );
2770
+ await status(server, positionals[0]?.replace(/^@/, ""), values.json);
750
2771
  return;
751
2772
  }
752
2773
  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}`));
2774
+ if (rest.includes("--help") || rest.includes("-h"))
2775
+ return say(`layro add @author/slug [more...] [options]
2776
+
2777
+ Install Layro source, shared cn, CSS and npm dependencies in a React TypeScript app.
2778
+ Reads components.json and project aliases. Dependencies resolve before any writes.
2779
+ Package scripts are disabled. Review third-party code before running your app.
2780
+
2781
+ --cwd <folder> App package to install into (default current folder)
2782
+ --path <folder> Destination for component files, relative to app root
2783
+ --dry-run Show the plan without changing files or packages
2784
+ -y, --yes Skip differing existing files; never prompt
2785
+ --overwrite Replace differing files
2786
+ --json Machine-readable plan/result
2787
+
2788
+ Examples:
2789
+ npx layro-cli add @magicuidesign/aurora-text --yes
2790
+ layro add author/one author/two --cwd apps/web --dry-run --json`);
2791
+ const { values, positionals } = parseArgs({
2792
+ args: rest,
2793
+ allowPositionals: true,
2794
+ options: {
2795
+ cwd: { type: "string" },
2796
+ path: { type: "string" },
2797
+ "dry-run": { type: "boolean" },
2798
+ yes: { type: "boolean", short: "y" },
2799
+ overwrite: { type: "boolean" },
2800
+ json: { type: "boolean" }
2801
+ }
2802
+ });
2803
+ await add(server, positionals, values);
758
2804
  return;
759
2805
  }
760
2806
  default:
761
- throw new CliError(`Unknown command "${command}". Run layro --help.`);
2807
+ throw new CliError(
2808
+ `Unknown command "${safeText(command)}".${suggestCommand(command)} Run layro --help.`
2809
+ );
762
2810
  }
763
2811
  }
764
2812
  process.stdout.on("error", (error) => {
@@ -766,12 +2814,34 @@ process.stdout.on("error", (error) => {
766
2814
  throw error;
767
2815
  });
768
2816
  main(process.argv.slice(2)).catch((error) => {
769
- if (error instanceof CliError) {
770
- warn(red(error.message));
2817
+ if (process.argv.includes("--json")) {
2818
+ say(
2819
+ JSON.stringify({
2820
+ error: error instanceof Error ? error.message : "Could not finish the command.",
2821
+ ...error instanceof ApiFailure ? {
2822
+ status: error.detail.status,
2823
+ code: error.detail.code,
2824
+ errors: error.detail.errors
2825
+ } : {}
2826
+ })
2827
+ );
2828
+ process.exitCode = 1;
2829
+ return;
2830
+ }
2831
+ if (error instanceof SetupRequired) {
2832
+ for (const line of error.message.split("\n")) warn(safeText(line));
2833
+ } else if (error instanceof CliError) {
2834
+ warn(red(safeText(error.message)));
771
2835
  } else if (error?.code?.startsWith("ERR_PARSE_ARGS")) {
772
- warn(red(error.message));
2836
+ warn(red(safeText(error.message)));
773
2837
  } else {
774
- warn(red(`Unexpected error: ${error?.stack ?? error}`));
2838
+ warn(
2839
+ red(
2840
+ safeText(
2841
+ `Could not finish: ${error?.message ?? "unknown error"}.`
2842
+ )
2843
+ )
2844
+ );
775
2845
  }
776
2846
  process.exit(1);
777
2847
  });