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.
- package/README.md +115 -11
- package/dist/layro.mjs +2210 -140
- 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 = () =>
|
|
16
|
-
process.env.XDG_CONFIG_HOME ||
|
|
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
|
|
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
|
|
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/
|
|
63
|
-
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
1594
|
+
return await fetch(url, {
|
|
1595
|
+
...init,
|
|
1596
|
+
redirect: "error",
|
|
1597
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
1598
|
+
});
|
|
84
1599
|
} catch (error) {
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
|
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
|
|
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 =
|
|
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((
|
|
173
|
-
settle =
|
|
1665
|
+
const result = new Promise((resolve6) => {
|
|
1666
|
+
settle = resolve6;
|
|
174
1667
|
});
|
|
175
|
-
const server2 = createServer((
|
|
176
|
-
const url = new URL(
|
|
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((
|
|
1692
|
+
await new Promise((resolve6, reject) => {
|
|
200
1693
|
server2.once("error", reject);
|
|
201
|
-
server2.listen(0, "127.0.0.1",
|
|
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
|
|
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(
|
|
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(
|
|
237
|
-
const challenge = base64url(
|
|
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
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
-
(
|
|
257
|
-
() =>
|
|
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
|
|
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,
|
|
304
|
-
return
|
|
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
|
|
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
|
|
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
|
|
394
|
-
import {
|
|
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
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
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
|
|
2287
|
+
content = await readFile6(path, "utf8");
|
|
412
2288
|
} catch {
|
|
413
2289
|
throw new CliError(`Can't read ${paths[i]}.`);
|
|
414
2290
|
}
|
|
415
|
-
return { path:
|
|
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
|
|
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
|
-
|
|
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
|
|
2345
|
+
let existing2 = null;
|
|
449
2346
|
try {
|
|
450
|
-
|
|
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 (
|
|
460
|
-
|
|
461
|
-
`${bold(
|
|
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",
|
|
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 ${
|
|
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/${
|
|
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
|
-
|
|
481
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
2524
|
+
const open2 = c.latestSubmission;
|
|
607
2525
|
say(
|
|
608
|
-
` ${c.id.padEnd(36)} ${(LABEL[c.status] ?? c.status).padEnd(18)} ${c.version}${
|
|
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 (
|
|
611
|
-
say(` ${red("Reason:")} ${
|
|
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>
|
|
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
|
-
|
|
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: {
|
|
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
|
-
|
|
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({
|
|
749
|
-
|
|
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
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
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(
|
|
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 (
|
|
770
|
-
|
|
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(
|
|
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
|
});
|