layro-cli 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +107 -11
- package/dist/layro.mjs +1930 -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.4.0" : "dev";
|
|
805
|
+
|
|
806
|
+
// src/install-registry.ts
|
|
807
|
+
var record = (v) => Boolean(v && typeof v === "object" && !Array.isArray(v));
|
|
808
|
+
function validateItem(raw) {
|
|
809
|
+
if (!record(raw) || typeof raw.name !== "string" || !raw.name || typeof raw.type !== "string" || !raw.type.startsWith("registry:") || !Array.isArray(raw.files))
|
|
810
|
+
throw new CliError(
|
|
811
|
+
"Registry returned the wrong item shape (expected name, type and files)."
|
|
812
|
+
);
|
|
813
|
+
if (raw.files.length > 200)
|
|
814
|
+
throw new CliError("Registry item has too many files (maximum 200).");
|
|
815
|
+
for (const file of raw.files) {
|
|
816
|
+
if (!record(file) || typeof file.path !== "string" || typeof file.content !== "string" || typeof file.type !== "string" || file.target !== void 0 && typeof file.target !== "string")
|
|
817
|
+
throw new CliError(
|
|
818
|
+
"Registry returned an invalid file (expected path, type and content)."
|
|
819
|
+
);
|
|
820
|
+
safePath(file.path);
|
|
821
|
+
if (file.target)
|
|
822
|
+
safePath(file.target.replace(/^@(components|ui|lib|hooks)\//, "$1/"));
|
|
823
|
+
}
|
|
824
|
+
const strings = (key) => {
|
|
825
|
+
if (raw[key] === void 0) return [];
|
|
826
|
+
if (!Array.isArray(raw[key]) || !raw[key].every((v) => typeof v === "string"))
|
|
827
|
+
throw new CliError(`Registry ${key} must be an array of strings.`);
|
|
828
|
+
return raw[key];
|
|
829
|
+
};
|
|
830
|
+
const dependencies = strings("dependencies");
|
|
831
|
+
const devDependencies = strings("devDependencies");
|
|
832
|
+
[...dependencies, ...devDependencies].forEach(packageSpec);
|
|
833
|
+
if (raw.css !== void 0 && !record(raw.css) || raw.cssVars !== void 0 && !record(raw.cssVars))
|
|
834
|
+
throw new CliError("Registry css and cssVars must be objects.");
|
|
835
|
+
return {
|
|
836
|
+
...raw,
|
|
837
|
+
dependencies,
|
|
838
|
+
devDependencies,
|
|
839
|
+
registryDependencies: strings("registryDependencies")
|
|
840
|
+
};
|
|
841
|
+
}
|
|
842
|
+
function registryBase(server2) {
|
|
843
|
+
let url;
|
|
844
|
+
try {
|
|
845
|
+
url = new URL(server2);
|
|
846
|
+
} catch {
|
|
847
|
+
throw new CliError(
|
|
848
|
+
"Invalid LAYRO_URL. Use https://layro.dev, or http://localhost:<port> for local development."
|
|
849
|
+
);
|
|
850
|
+
}
|
|
851
|
+
if (url.username || url.password || url.pathname !== "/" || url.search || url.hash || url.protocol !== "https:" && !(process.env.LAYRO_URL && url.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname)))
|
|
852
|
+
throw new CliError(
|
|
853
|
+
"Registry requires HTTPS. Only localhost through LAYRO_URL may use HTTP."
|
|
854
|
+
);
|
|
855
|
+
return url.origin;
|
|
856
|
+
}
|
|
857
|
+
function itemUrl(input, base) {
|
|
858
|
+
let id = input.replace(/^@/, "");
|
|
859
|
+
if (/^https?:/.test(input)) {
|
|
860
|
+
let url;
|
|
861
|
+
try {
|
|
862
|
+
url = new URL(input);
|
|
863
|
+
} catch {
|
|
864
|
+
throw new CliError("Invalid component URL.");
|
|
865
|
+
}
|
|
866
|
+
if (url.origin !== base && url.origin !== "https://layro.dev")
|
|
867
|
+
throw new CliError(
|
|
868
|
+
`Refused registry host ${url.host}. Use a Layro component.`
|
|
869
|
+
);
|
|
870
|
+
if (url.username || url.password || url.search || url.hash)
|
|
871
|
+
throw new CliError(
|
|
872
|
+
"Use a plain Layro component URL without credentials, a query or fragment."
|
|
873
|
+
);
|
|
874
|
+
id = url.pathname.replace(/^\/@/, "").replace(/^\/r\//, "").replace(/\.json$/, "");
|
|
875
|
+
}
|
|
876
|
+
if (!/^[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*$/i.test(id))
|
|
877
|
+
throw new CliError(
|
|
878
|
+
`Use @author/slug or a Layro component URL: ${JSON.stringify(input)}.`
|
|
879
|
+
);
|
|
880
|
+
return `${base}/r/${id}.json`;
|
|
881
|
+
}
|
|
882
|
+
var MAX_BYTES = 5e6;
|
|
883
|
+
async function fetchItem(url, attempts = 3) {
|
|
884
|
+
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
885
|
+
try {
|
|
886
|
+
const response = await fetch(url, {
|
|
887
|
+
redirect: "manual",
|
|
888
|
+
signal: AbortSignal.timeout(2e4),
|
|
889
|
+
headers: {
|
|
890
|
+
Accept: "application/json",
|
|
891
|
+
"User-Agent": `layro-cli/${VERSION}`
|
|
892
|
+
}
|
|
893
|
+
});
|
|
894
|
+
if (response.status >= 300 && response.status < 400) {
|
|
895
|
+
const target = new URL(response.headers.get("location") ?? url, url);
|
|
896
|
+
await response.body?.cancel();
|
|
897
|
+
throw new CliError(
|
|
898
|
+
`Registry redirect refused (${target.host}). Use the direct registry URL.`
|
|
899
|
+
);
|
|
900
|
+
}
|
|
901
|
+
if (response.status === 429 && attempt + 1 < attempts) {
|
|
902
|
+
const retry = response.headers.get("retry-after");
|
|
903
|
+
const seconds = retry && /^\d+$/.test(retry) ? Number(retry) : retry ? Math.ceil((Date.parse(retry) - Date.now()) / 1e3) : 2 ** attempt;
|
|
904
|
+
await response.body?.cancel();
|
|
905
|
+
if (!Number.isFinite(seconds) || seconds > 60)
|
|
906
|
+
throw new CliError(
|
|
907
|
+
"Registry rate limit reached. Wait for Retry-After (over 60 seconds), then run add again."
|
|
908
|
+
);
|
|
909
|
+
await new Promise((r) => setTimeout(r, Math.max(1, seconds) * 1e3));
|
|
910
|
+
continue;
|
|
911
|
+
}
|
|
912
|
+
if (!response.ok) {
|
|
913
|
+
await response.body?.cancel();
|
|
914
|
+
throw new CliError(
|
|
915
|
+
response.status === 404 ? `Component or dependency not found: ${url}. Check its name.` : response.status === 429 ? "Registry rate limit reached (429). Wait a minute and try again." : `Registry answered ${response.status} for ${url}. Try again later.`
|
|
916
|
+
);
|
|
917
|
+
}
|
|
918
|
+
if (Number(response.headers.get("content-length")) > MAX_BYTES) {
|
|
919
|
+
await response.body?.cancel();
|
|
920
|
+
throw new CliError("Registry response is too large (maximum 5 MB).");
|
|
921
|
+
}
|
|
922
|
+
const reader = response.body?.getReader();
|
|
923
|
+
if (!reader) throw new CliError("Registry returned an empty response.");
|
|
924
|
+
const chunks = [];
|
|
925
|
+
let length = 0;
|
|
926
|
+
for (; ; ) {
|
|
927
|
+
const { value, done } = await reader.read();
|
|
928
|
+
if (done) break;
|
|
929
|
+
length += value.length;
|
|
930
|
+
if (length > MAX_BYTES) {
|
|
931
|
+
await reader.cancel();
|
|
932
|
+
throw new CliError("Registry response is too large (maximum 5 MB).");
|
|
933
|
+
}
|
|
934
|
+
chunks.push(value);
|
|
935
|
+
}
|
|
936
|
+
let raw;
|
|
937
|
+
try {
|
|
938
|
+
raw = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
939
|
+
} catch {
|
|
940
|
+
throw new CliError("Registry returned invalid JSON. Try again later.");
|
|
941
|
+
}
|
|
942
|
+
return validateItem(raw);
|
|
943
|
+
} catch (error) {
|
|
944
|
+
if (error instanceof CliError) throw error;
|
|
945
|
+
if (["TimeoutError", "AbortError"].includes(error.name))
|
|
946
|
+
throw new CliError(
|
|
947
|
+
`Registry request timed out: ${url}. Check your connection and try again.`
|
|
948
|
+
);
|
|
949
|
+
throw new CliError(
|
|
950
|
+
`Can't reach the registry at ${new URL(url).host}. Check your connection or LAYRO_URL.`
|
|
951
|
+
);
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
throw new CliError("Registry request failed. Try again later.");
|
|
955
|
+
}
|
|
956
|
+
async function resolveItems(roots, base, style, load2 = fetchItem) {
|
|
957
|
+
if (!/^[a-z0-9-]+$/.test(style))
|
|
958
|
+
throw new CliError("Unsupported shadcn style in components.json.");
|
|
959
|
+
const result = [];
|
|
960
|
+
const visiting = /* @__PURE__ */ new Set();
|
|
961
|
+
const visited = /* @__PURE__ */ new Set();
|
|
962
|
+
const visit = async (url) => {
|
|
963
|
+
if (visiting.has(url))
|
|
964
|
+
throw new CliError(
|
|
965
|
+
`Circular registry dependency: ${url}. Nothing written.`
|
|
966
|
+
);
|
|
967
|
+
if (visited.has(url)) return;
|
|
968
|
+
if (visiting.size + visited.size >= 200)
|
|
969
|
+
throw new CliError(
|
|
970
|
+
"Too many registry dependencies (maximum 200). Nothing written."
|
|
971
|
+
);
|
|
972
|
+
visiting.add(url);
|
|
973
|
+
const item = await load2(url);
|
|
974
|
+
for (const dependency of item.registryDependencies) {
|
|
975
|
+
if (dependency === "utils") continue;
|
|
976
|
+
let next2;
|
|
977
|
+
if (/^https?:/.test(dependency)) {
|
|
978
|
+
const parsed = new URL(dependency);
|
|
979
|
+
if (parsed.origin !== base && !(parsed.origin === "https://layro.dev" && base !== "https://layro.dev"))
|
|
980
|
+
throw new CliError(
|
|
981
|
+
`Refused dependency host ${parsed.host}. Only Layro registry URLs and shadcn names are accepted.`
|
|
982
|
+
);
|
|
983
|
+
next2 = itemUrl(dependency, base);
|
|
984
|
+
} else if (/^[a-z0-9][a-z0-9-]*$/.test(dependency)) {
|
|
985
|
+
next2 = `https://ui.shadcn.com/r/styles/${style}/${dependency}.json`;
|
|
986
|
+
} else
|
|
987
|
+
throw new CliError(
|
|
988
|
+
`Invalid registry dependency: ${JSON.stringify(dependency)}.`
|
|
989
|
+
);
|
|
990
|
+
await visit(next2);
|
|
991
|
+
}
|
|
992
|
+
visiting.delete(url);
|
|
993
|
+
visited.add(url);
|
|
994
|
+
result.push({ url, item });
|
|
995
|
+
};
|
|
996
|
+
for (const url of roots) await visit(url);
|
|
997
|
+
return result;
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
// src/install-destination.ts
|
|
1001
|
+
function fileDestination(file, project, override) {
|
|
1002
|
+
safePath(file.path);
|
|
1003
|
+
if (file.target) {
|
|
1004
|
+
const target = file.target.replace(
|
|
1005
|
+
/^@(components|ui|lib|hooks)(?=\/)/,
|
|
1006
|
+
"$1"
|
|
1007
|
+
);
|
|
1008
|
+
safePath(target);
|
|
1009
|
+
const mappings = [
|
|
1010
|
+
["components/ui", "ui"],
|
|
1011
|
+
["ui", "ui"],
|
|
1012
|
+
["components", "components"],
|
|
1013
|
+
["lib", "lib"],
|
|
1014
|
+
["hooks", "hooks"]
|
|
1015
|
+
];
|
|
1016
|
+
const entry = mappings.find(([prefix]) => target.startsWith(`${prefix}/`));
|
|
1017
|
+
if (entry)
|
|
1018
|
+
return safePath(
|
|
1019
|
+
`${override && entry[1] === "components" ? override : aliasPath(project, project.aliases[entry[1]])}/${target.slice(entry[0].length + 1)}`
|
|
1020
|
+
);
|
|
1021
|
+
return target;
|
|
1022
|
+
}
|
|
1023
|
+
const kind = file.type;
|
|
1024
|
+
const key = kind === "registry:ui" ? "ui" : kind === "registry:lib" ? "lib" : kind === "registry:hook" ? "hooks" : "components";
|
|
1025
|
+
const folder = override && key === "components" ? override : aliasPath(project, project.aliases[key]);
|
|
1026
|
+
const canonical = /^(components\/ui|components|lib|hooks)\/(.+)$/.exec(
|
|
1027
|
+
file.path
|
|
1028
|
+
);
|
|
1029
|
+
if (canonical)
|
|
1030
|
+
return fileDestination({ ...file, target: file.path }, project, override);
|
|
1031
|
+
const suffix = file.path.split("/").at(-1);
|
|
1032
|
+
return safePath(`${folder}/${suffix}`);
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
// src/add.ts
|
|
1036
|
+
async function existing(root, path) {
|
|
1037
|
+
const target = await safeDestination(root, path);
|
|
1038
|
+
try {
|
|
1039
|
+
return await readFile3(target, "utf8");
|
|
1040
|
+
} catch (error) {
|
|
1041
|
+
if (error.code === "ENOENT") return null;
|
|
1042
|
+
throw error;
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
function managerCommand(manager, args, platform = process.platform, pathEnv = process.env.PATH ?? "") {
|
|
1046
|
+
if (platform !== "win32") return { command: manager, args };
|
|
1047
|
+
for (const directory of pathEnv.split(
|
|
1048
|
+
platform === process.platform ? delimiter : ";"
|
|
1049
|
+
)) {
|
|
1050
|
+
const exe = join3(directory, `${manager}.exe`);
|
|
1051
|
+
if (existsSync3(exe)) return { command: exe, args };
|
|
1052
|
+
const shim = join3(directory, `${manager}.cmd`);
|
|
1053
|
+
if (!existsSync3(shim)) continue;
|
|
1054
|
+
const body = readFileSync(shim, "utf8");
|
|
1055
|
+
const hit = body.match(/"%(?:~dp0|dp0%)%?\\([^"\r\n]+\.(?:c?js|mjs))"/i);
|
|
1056
|
+
if (hit) {
|
|
1057
|
+
const entry = resolve4(directory, hit[1].replaceAll("\\", "/"));
|
|
1058
|
+
if (existsSync3(entry))
|
|
1059
|
+
return { command: process.execPath, args: [entry, ...args] };
|
|
1060
|
+
}
|
|
1061
|
+
throw new CliError(
|
|
1062
|
+
`Cannot safely run ${manager}.cmd. Install ${manager} with a Node JS entry or use a native executable.`
|
|
1063
|
+
);
|
|
1064
|
+
}
|
|
1065
|
+
throw new CliError(
|
|
1066
|
+
`${manager} is not on PATH. Install it and run add again.`
|
|
1067
|
+
);
|
|
1068
|
+
}
|
|
1069
|
+
var installing;
|
|
1070
|
+
function yarnIsModern(root) {
|
|
1071
|
+
const { command, args } = managerCommand("yarn", ["--version"]);
|
|
1072
|
+
return new Promise((done) => {
|
|
1073
|
+
const child = spawn(command, args, {
|
|
1074
|
+
cwd: root,
|
|
1075
|
+
shell: false,
|
|
1076
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
1077
|
+
timeout: 3e4
|
|
1078
|
+
});
|
|
1079
|
+
let out = "";
|
|
1080
|
+
child.stdout?.on("data", (data) => {
|
|
1081
|
+
out += data;
|
|
1082
|
+
});
|
|
1083
|
+
child.once("error", () => done(false));
|
|
1084
|
+
child.once("close", () => done(Number.parseInt(out, 10) >= 2));
|
|
1085
|
+
});
|
|
1086
|
+
}
|
|
1087
|
+
async function installPackages(project, specs, dev, json) {
|
|
1088
|
+
if (!specs.length) return;
|
|
1089
|
+
specs.forEach(packageSpec);
|
|
1090
|
+
const fresh = specs.filter(
|
|
1091
|
+
(spec) => !project.dependencies[packageSpec(spec).name]
|
|
1092
|
+
);
|
|
1093
|
+
const yarnSettings = project.manager === "yarn" && await yarnIsModern(project.root);
|
|
1094
|
+
const noScripts = yarnSettings ? [] : ["--ignore-scripts"];
|
|
1095
|
+
const invocations = [];
|
|
1096
|
+
if (fresh.length !== specs.length)
|
|
1097
|
+
invocations.push(["install", ...noScripts]);
|
|
1098
|
+
if (fresh.length)
|
|
1099
|
+
invocations.push([
|
|
1100
|
+
project.manager === "npm" ? "install" : "add",
|
|
1101
|
+
...noScripts,
|
|
1102
|
+
...dev ? ["-D"] : [],
|
|
1103
|
+
...fresh
|
|
1104
|
+
]);
|
|
1105
|
+
for (const args of invocations) {
|
|
1106
|
+
const invocation = managerCommand(project.manager, args);
|
|
1107
|
+
await new Promise((resolveDone, reject) => {
|
|
1108
|
+
const child = spawn(invocation.command, invocation.args, {
|
|
1109
|
+
cwd: project.root,
|
|
1110
|
+
shell: false,
|
|
1111
|
+
stdio: json ? ["ignore", "pipe", "pipe"] : "inherit",
|
|
1112
|
+
timeout: 18e4,
|
|
1113
|
+
env: yarnSettings ? { ...process.env, YARN_ENABLE_SCRIPTS: "false" } : process.env
|
|
1114
|
+
});
|
|
1115
|
+
installing = child;
|
|
1116
|
+
child.stdout?.resume();
|
|
1117
|
+
child.stderr?.resume();
|
|
1118
|
+
child.once(
|
|
1119
|
+
"error",
|
|
1120
|
+
() => reject(
|
|
1121
|
+
new CliError(
|
|
1122
|
+
`Could not start ${project.manager}. Install it, then run add again.`
|
|
1123
|
+
)
|
|
1124
|
+
)
|
|
1125
|
+
);
|
|
1126
|
+
child.once(
|
|
1127
|
+
"exit",
|
|
1128
|
+
(code, signal) => code === 0 ? resolveDone() : reject(
|
|
1129
|
+
new CliError(
|
|
1130
|
+
`${project.manager} ${args[0]} ${signal ? `stopped (${signal})` : `failed (exit ${code})`}. Files are listed below; inspect package.json and the lockfile, then rerun the command to finish.`
|
|
1131
|
+
)
|
|
1132
|
+
)
|
|
1133
|
+
);
|
|
1134
|
+
});
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
function printReport(report, json = false) {
|
|
1138
|
+
if (json) {
|
|
1139
|
+
say(JSON.stringify(report, null, 2));
|
|
1140
|
+
return;
|
|
1141
|
+
}
|
|
1142
|
+
say(
|
|
1143
|
+
safeText(
|
|
1144
|
+
`${report.dryRun ? "Dry run" : report.error ? "Install stopped" : "Install complete"}: ${report.components.join(", ")} in ${report.project} (${report.framework}, ${report.packageManager}).`
|
|
1145
|
+
)
|
|
1146
|
+
);
|
|
1147
|
+
for (const [label, values] of [
|
|
1148
|
+
["Planned", report.planned],
|
|
1149
|
+
["Written", report.written],
|
|
1150
|
+
["Unchanged", report.unchanged],
|
|
1151
|
+
["Skipped", report.skipped],
|
|
1152
|
+
["Packages requested", report.packages],
|
|
1153
|
+
["Dev packages requested", report.devPackages],
|
|
1154
|
+
["Installed", report.installed],
|
|
1155
|
+
["Import", report.imports],
|
|
1156
|
+
["Note", report.warnings]
|
|
1157
|
+
])
|
|
1158
|
+
for (const value of values) say(safeText(` ${label}: ${value}`));
|
|
1159
|
+
}
|
|
1160
|
+
async function add(server2, inputs, flags, initOnly = false) {
|
|
1161
|
+
if (!inputs.length && !initOnly)
|
|
1162
|
+
throw new CliError(
|
|
1163
|
+
"Give a component: layro add @author/slug [more...] --yes. See layro add --help."
|
|
1164
|
+
);
|
|
1165
|
+
const base = registryBase(server2);
|
|
1166
|
+
const project = await detectProject(flags.cwd ?? process.cwd());
|
|
1167
|
+
if (!project.tsx)
|
|
1168
|
+
throw new CliError(
|
|
1169
|
+
"JavaScript (tsx:false) projects are not supported: Layro does not convert TypeScript source. Create a TypeScript app (npm create vite@latest my-app -- --template react-ts), or use the Manual tab and convert the source yourself. Nothing written."
|
|
1170
|
+
);
|
|
1171
|
+
const config = initOnly ? await initConfig(project) : null;
|
|
1172
|
+
if (flags.path) {
|
|
1173
|
+
safePath(flags.path);
|
|
1174
|
+
const mapped = relative4(
|
|
1175
|
+
resolve4(project.root, project.base),
|
|
1176
|
+
resolve4(project.root, flags.path)
|
|
1177
|
+
).replaceAll("\\", "/");
|
|
1178
|
+
project.paths["#layro"] = [mapped];
|
|
1179
|
+
project.paths["#layro/*"] = [`${mapped}/*`];
|
|
1180
|
+
project.aliases.components = "#layro";
|
|
1181
|
+
project.aliases.ui = "#layro/ui";
|
|
1182
|
+
}
|
|
1183
|
+
const roots = [...new Set(inputs.map((input) => itemUrl(input, base)))];
|
|
1184
|
+
const report = {
|
|
1185
|
+
command: initOnly ? "init" : "add",
|
|
1186
|
+
project: project.root,
|
|
1187
|
+
framework: project.framework,
|
|
1188
|
+
packageManager: project.manager,
|
|
1189
|
+
components: initOnly ? ["project setup"] : inputs,
|
|
1190
|
+
dryRun: Boolean(flags["dry-run"]),
|
|
1191
|
+
planned: [],
|
|
1192
|
+
written: [],
|
|
1193
|
+
unchanged: [],
|
|
1194
|
+
skipped: [],
|
|
1195
|
+
packages: [],
|
|
1196
|
+
devPackages: [],
|
|
1197
|
+
installed: [],
|
|
1198
|
+
warnings: [],
|
|
1199
|
+
imports: []
|
|
1200
|
+
};
|
|
1201
|
+
const writes = /* @__PURE__ */ new Map();
|
|
1202
|
+
const plan = async (path, content, kind) => {
|
|
1203
|
+
const previous = writes.get(path);
|
|
1204
|
+
if (previous && previous.content !== content)
|
|
1205
|
+
throw new CliError(
|
|
1206
|
+
`Conflicting registry files for ${path}. Nothing written.`
|
|
1207
|
+
);
|
|
1208
|
+
if (!previous)
|
|
1209
|
+
writes.set(path, {
|
|
1210
|
+
path,
|
|
1211
|
+
content,
|
|
1212
|
+
kind,
|
|
1213
|
+
before: await existing(project.root, path)
|
|
1214
|
+
});
|
|
1215
|
+
};
|
|
1216
|
+
const items = initOnly ? [] : await resolveItems(roots, base, project.style);
|
|
1217
|
+
if (config !== null) await plan("components.json", config, "config");
|
|
1218
|
+
else if (initOnly) report.unchanged.push("components.json");
|
|
1219
|
+
if (!initOnly && !project.config)
|
|
1220
|
+
report.warnings.push(
|
|
1221
|
+
"Run npx layro-cli init --yes to create shared project configuration. Existing component installs still use relative imports."
|
|
1222
|
+
);
|
|
1223
|
+
const packages = /* @__PURE__ */ new Map();
|
|
1224
|
+
const need = (spec, dev = false) => {
|
|
1225
|
+
const { name, version } = packageSpec(spec);
|
|
1226
|
+
const previous = packages.get(name);
|
|
1227
|
+
if (previous?.version && version && previous.version !== version)
|
|
1228
|
+
throw new CliError(
|
|
1229
|
+
`Conflicting dependency versions: ${previous.spec} and ${spec}. Nothing written.`
|
|
1230
|
+
);
|
|
1231
|
+
packages.set(name, {
|
|
1232
|
+
spec: version || !previous ? spec : previous.spec,
|
|
1233
|
+
version: version ?? previous?.version,
|
|
1234
|
+
dev: previous ? previous.dev && dev : dev
|
|
1235
|
+
});
|
|
1236
|
+
};
|
|
1237
|
+
const utils = `${aliasPath(project, project.aliases.utils)}.ts`;
|
|
1238
|
+
const helperCandidates = [
|
|
1239
|
+
utils,
|
|
1240
|
+
utils.replace(/\.ts$/, ".js"),
|
|
1241
|
+
utils.replace(/\.ts$/, ".tsx"),
|
|
1242
|
+
utils.replace(/\.ts$/, ".jsx"),
|
|
1243
|
+
utils.replace(/\.ts$/, "/index.ts"),
|
|
1244
|
+
utils.replace(/\.ts$/, "/index.js")
|
|
1245
|
+
];
|
|
1246
|
+
const hasHelper = (await Promise.all(
|
|
1247
|
+
helperCandidates.map((path) => existing(project.root, path))
|
|
1248
|
+
)).some((content) => content !== null);
|
|
1249
|
+
if (!hasHelper) {
|
|
1250
|
+
await plan(utils, CN_SOURCE, "helper");
|
|
1251
|
+
need("clsx");
|
|
1252
|
+
need("tailwind-merge");
|
|
1253
|
+
} else if (initOnly) {
|
|
1254
|
+
for (const path of helperCandidates) {
|
|
1255
|
+
const content = await existing(project.root, path);
|
|
1256
|
+
if (content === null) continue;
|
|
1257
|
+
report.unchanged.push(path);
|
|
1258
|
+
for (const { spec } of moduleSpecifiers(content))
|
|
1259
|
+
if (spec === "clsx" || spec === "tailwind-merge") need(spec);
|
|
1260
|
+
break;
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
const tailwind4 = /^\D*4(?:\.|$)/.test(
|
|
1264
|
+
project.dependencies.tailwindcss ?? ""
|
|
1265
|
+
);
|
|
1266
|
+
if (!project.dependencies.react)
|
|
1267
|
+
report.warnings.push(
|
|
1268
|
+
"React is missing. Add React before rendering these components."
|
|
1269
|
+
);
|
|
1270
|
+
if (!project.dependencies.tailwindcss)
|
|
1271
|
+
report.warnings.push(
|
|
1272
|
+
`Tailwind CSS is missing. Components will be unstyled until setup is complete. Run npx layro-cli init for the next steps. ${setupGuide2(project)}`
|
|
1273
|
+
);
|
|
1274
|
+
let css = project.css ? await existing(project.root, project.css) : null;
|
|
1275
|
+
const originalCss = css;
|
|
1276
|
+
for (const { url, item } of items) {
|
|
1277
|
+
for (const spec of item.dependencies) need(spec);
|
|
1278
|
+
for (const spec of item.devDependencies) need(spec, true);
|
|
1279
|
+
for (const file of item.files) {
|
|
1280
|
+
if (file.type === "registry:example" || /(?:^|\/)(?:demos?|examples?)(?:\/|\.)/i.test(file.path))
|
|
1281
|
+
continue;
|
|
1282
|
+
if (![
|
|
1283
|
+
"registry:component",
|
|
1284
|
+
"registry:ui",
|
|
1285
|
+
"registry:lib",
|
|
1286
|
+
"registry:hook",
|
|
1287
|
+
"registry:block",
|
|
1288
|
+
"registry:file",
|
|
1289
|
+
"registry:page"
|
|
1290
|
+
].includes(file.type))
|
|
1291
|
+
throw new CliError(
|
|
1292
|
+
`Unsupported file type ${file.type}. Nothing written; use the Manual tab.`
|
|
1293
|
+
);
|
|
1294
|
+
const imports = /\.[jt]sx?$/.test(file.path) ? moduleSpecifiers(file.content) : [];
|
|
1295
|
+
for (const { spec } of imports) {
|
|
1296
|
+
if (spec.startsWith(".") || spec.startsWith("@/") || spec.startsWith("node:"))
|
|
1297
|
+
continue;
|
|
1298
|
+
const name = spec.startsWith("@") ? spec.split("/").slice(0, 2).join("/") : spec.split("/")[0];
|
|
1299
|
+
need(name);
|
|
1300
|
+
}
|
|
1301
|
+
const path = fileDestination(file, project);
|
|
1302
|
+
if (!/\.(tsx?|jsx?|css)$/.test(path))
|
|
1303
|
+
throw new CliError(
|
|
1304
|
+
`Refused file extension in ${path}. Only source and CSS files are installed.`
|
|
1305
|
+
);
|
|
1306
|
+
const content = rewriteAliases(file.content, project, path);
|
|
1307
|
+
await plan(path, content, "component");
|
|
1308
|
+
if (roots.includes(url) && /\.[jt]sx?$/.test(path)) {
|
|
1309
|
+
const source = path.replace(/\.[jt]sx?$/, "");
|
|
1310
|
+
const named = [
|
|
1311
|
+
...content.matchAll(
|
|
1312
|
+
/export\s+(?:async\s+)?(?:function|const|class)\s+(\w+)/g
|
|
1313
|
+
)
|
|
1314
|
+
].map((m) => m[1]);
|
|
1315
|
+
const exports = named.length ? `{ ${named.join(", ")} }` : /export\s+default\b/.test(content) ? "Component" : "{ /* see file exports */ }";
|
|
1316
|
+
report.imports.push(
|
|
1317
|
+
`import ${exports} from "./${source}"; (from a file at the project root; adjust relative to your route)`
|
|
1318
|
+
);
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
if ((item.cssVars?.theme || Object.keys(item.css ?? {}).length) && !tailwind4)
|
|
1322
|
+
report.warnings.push(
|
|
1323
|
+
`${item.name} may need Tailwind v4; this project has ${project.dependencies.tailwindcss ?? "none"}.`
|
|
1324
|
+
);
|
|
1325
|
+
const merged = mergeCss(css ?? "", item, tailwind4);
|
|
1326
|
+
report.warnings.push(...merged.manual);
|
|
1327
|
+
if (merged.css !== (css ?? "")) {
|
|
1328
|
+
if (!project.css || css === null)
|
|
1329
|
+
report.warnings.push(
|
|
1330
|
+
`No stylesheet found. Add this CSS to the stylesheet your app imports: ${merged.css}`
|
|
1331
|
+
);
|
|
1332
|
+
else css = merged.css;
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
if (project.css && css !== null && css !== originalCss)
|
|
1336
|
+
await plan(project.css, css, "css");
|
|
1337
|
+
report.warnings.push(
|
|
1338
|
+
...nextPrerenderGuidance(
|
|
1339
|
+
project,
|
|
1340
|
+
[...writes.values()].filter((entry) => entry.kind === "component").map((entry) => entry.content)
|
|
1341
|
+
)
|
|
1342
|
+
);
|
|
1343
|
+
const pnp = existsSync3(join3(project.root, ".pnp.cjs"));
|
|
1344
|
+
for (const { spec, version, dev } of packages.values()) {
|
|
1345
|
+
const { name } = packageSpec(spec);
|
|
1346
|
+
let installed;
|
|
1347
|
+
let dir = project.root;
|
|
1348
|
+
for (; ; ) {
|
|
1349
|
+
try {
|
|
1350
|
+
installed = JSON.parse(
|
|
1351
|
+
await readFile3(
|
|
1352
|
+
join3(dir, "node_modules", name, "package.json"),
|
|
1353
|
+
"utf8"
|
|
1354
|
+
)
|
|
1355
|
+
).version;
|
|
1356
|
+
break;
|
|
1357
|
+
} catch {
|
|
1358
|
+
}
|
|
1359
|
+
const parent = dirname4(dir);
|
|
1360
|
+
if (parent === dir) break;
|
|
1361
|
+
dir = parent;
|
|
1362
|
+
}
|
|
1363
|
+
const declared = project.dependencies[name];
|
|
1364
|
+
if (declared && !installed && pnp) continue;
|
|
1365
|
+
if (declared) {
|
|
1366
|
+
const known = satisfies("0.0.0", declared);
|
|
1367
|
+
const wanted = satisfies(installed ?? "0.0.0", version);
|
|
1368
|
+
if (known === void 0 || wanted === void 0) {
|
|
1369
|
+
report.warnings.push(
|
|
1370
|
+
`Left ${name} unchanged: cannot safely read project range ${declared} or requested range ${version ?? "any"}. Review and install a compatible version yourself.`
|
|
1371
|
+
);
|
|
1372
|
+
continue;
|
|
1373
|
+
}
|
|
1374
|
+
if (installed) {
|
|
1375
|
+
if (!satisfies(installed, declared) || !wanted)
|
|
1376
|
+
report.warnings.push(
|
|
1377
|
+
`Left ${name}@${declared} unchanged (installed ${installed}; component requests ${version ?? "any"}). Review the ranges and install a compatible version yourself.`
|
|
1378
|
+
);
|
|
1379
|
+
continue;
|
|
1380
|
+
}
|
|
1381
|
+
const preserved = `${name}@${declared}`;
|
|
1382
|
+
packageSpec(preserved);
|
|
1383
|
+
(dev ? report.devPackages : report.packages).push(preserved);
|
|
1384
|
+
if (version)
|
|
1385
|
+
report.warnings.push(
|
|
1386
|
+
`Preserving ${name}@${declared}; component requests ${version}. Check compatibility after installing.`
|
|
1387
|
+
);
|
|
1388
|
+
continue;
|
|
1389
|
+
}
|
|
1390
|
+
if (version && satisfies("0.0.0", version) === void 0) {
|
|
1391
|
+
report.warnings.push(
|
|
1392
|
+
`Left ${name} uninstalled: cannot safely read requested range ${version}. Review and install a compatible version yourself.`
|
|
1393
|
+
);
|
|
1394
|
+
continue;
|
|
1395
|
+
}
|
|
1396
|
+
(dev ? report.devPackages : report.packages).push(spec);
|
|
1397
|
+
}
|
|
1398
|
+
const accepted = [];
|
|
1399
|
+
for (const entry of writes.values()) {
|
|
1400
|
+
if (entry.before === entry.content) {
|
|
1401
|
+
report.unchanged.push(entry.path);
|
|
1402
|
+
continue;
|
|
1403
|
+
}
|
|
1404
|
+
if (entry.before !== null && entry.kind !== "css" && !flags.overwrite) {
|
|
1405
|
+
if (flags["dry-run"]) {
|
|
1406
|
+
report.skipped.push(entry.path);
|
|
1407
|
+
report.warnings.push(
|
|
1408
|
+
`${entry.path} differs; --overwrite would replace it, --yes would skip it.`
|
|
1409
|
+
);
|
|
1410
|
+
continue;
|
|
1411
|
+
}
|
|
1412
|
+
if (flags.yes) {
|
|
1413
|
+
report.skipped.push(entry.path);
|
|
1414
|
+
continue;
|
|
1415
|
+
}
|
|
1416
|
+
if (!canAsk() || flags.json)
|
|
1417
|
+
throw new CliError(
|
|
1418
|
+
`File differs: ${entry.path}. Use --yes to skip it, or --overwrite to replace it. Nothing written.`
|
|
1419
|
+
);
|
|
1420
|
+
if (!flags["dry-run"] && !await confirm(`Replace ${safeText(entry.path)}?`)) {
|
|
1421
|
+
report.skipped.push(entry.path);
|
|
1422
|
+
continue;
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1425
|
+
accepted.push(entry);
|
|
1426
|
+
report.planned.push(entry.path);
|
|
1427
|
+
}
|
|
1428
|
+
if (flags["dry-run"]) {
|
|
1429
|
+
printReport(report, flags.json);
|
|
1430
|
+
return report;
|
|
1431
|
+
}
|
|
1432
|
+
if (!flags.json) {
|
|
1433
|
+
say(
|
|
1434
|
+
safeText(
|
|
1435
|
+
`Installing ${inputs.join(", ") || "shared project setup"} into ${project.root}.`
|
|
1436
|
+
)
|
|
1437
|
+
);
|
|
1438
|
+
say(
|
|
1439
|
+
safeText(
|
|
1440
|
+
`Files: ${report.planned.join(", ") || "none"}. Packages: ${[...report.packages, ...report.devPackages].join(", ") || "none"}. Package scripts are disabled.`
|
|
1441
|
+
)
|
|
1442
|
+
);
|
|
1443
|
+
}
|
|
1444
|
+
let temporary;
|
|
1445
|
+
const stopped = (signal) => {
|
|
1446
|
+
installing?.kill(signal);
|
|
1447
|
+
if (temporary) {
|
|
1448
|
+
try {
|
|
1449
|
+
unlinkSync(temporary);
|
|
1450
|
+
} catch {
|
|
1451
|
+
}
|
|
1452
|
+
}
|
|
1453
|
+
report.error = `Stopped by ${signal}. Rerun ${report.command} to finish. Package installation may be incomplete; inspect package.json and the lockfile.`;
|
|
1454
|
+
printReport(report, flags.json);
|
|
1455
|
+
process.exit(signal === "SIGINT" ? 130 : 143);
|
|
1456
|
+
};
|
|
1457
|
+
const interrupt = () => stopped("SIGINT");
|
|
1458
|
+
const terminate = () => stopped("SIGTERM");
|
|
1459
|
+
process.once("SIGINT", interrupt);
|
|
1460
|
+
process.once("SIGTERM", terminate);
|
|
1461
|
+
try {
|
|
1462
|
+
for (const entry of accepted) {
|
|
1463
|
+
const target = await safeDestination(project.root, entry.path);
|
|
1464
|
+
if (await existing(project.root, entry.path) !== entry.before)
|
|
1465
|
+
throw new CliError(
|
|
1466
|
+
`File changed during install: ${entry.path}. Rerun add.`
|
|
1467
|
+
);
|
|
1468
|
+
await mkdir(dirname4(target), { recursive: true });
|
|
1469
|
+
await safeDestination(project.root, entry.path);
|
|
1470
|
+
temporary = `${target}.layro-${randomBytes(8).toString("hex")}`;
|
|
1471
|
+
const handle = await open(
|
|
1472
|
+
temporary,
|
|
1473
|
+
constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY,
|
|
1474
|
+
420
|
|
1475
|
+
);
|
|
1476
|
+
try {
|
|
1477
|
+
await handle.writeFile(entry.content);
|
|
1478
|
+
} finally {
|
|
1479
|
+
await handle.close();
|
|
1480
|
+
}
|
|
1481
|
+
await safeDestination(project.root, entry.path);
|
|
1482
|
+
await rename(temporary, target);
|
|
1483
|
+
temporary = void 0;
|
|
1484
|
+
report.written.push(entry.path);
|
|
1485
|
+
}
|
|
1486
|
+
await installPackages(project, report.packages, false, Boolean(flags.json));
|
|
1487
|
+
report.installed.push(...report.packages);
|
|
1488
|
+
await installPackages(
|
|
1489
|
+
project,
|
|
1490
|
+
report.devPackages,
|
|
1491
|
+
true,
|
|
1492
|
+
Boolean(flags.json)
|
|
1493
|
+
);
|
|
1494
|
+
report.installed.push(...report.devPackages);
|
|
1495
|
+
for (const spec of report.installed) {
|
|
1496
|
+
const { name } = packageSpec(spec);
|
|
1497
|
+
const folder = join3(project.root, "node_modules", name);
|
|
1498
|
+
let manifest;
|
|
1499
|
+
try {
|
|
1500
|
+
manifest = JSON.parse(
|
|
1501
|
+
await readFile3(join3(folder, "package.json"), "utf8")
|
|
1502
|
+
);
|
|
1503
|
+
} catch {
|
|
1504
|
+
continue;
|
|
1505
|
+
}
|
|
1506
|
+
const lifecycle = ["preinstall", "install", "postinstall"].filter(
|
|
1507
|
+
(script) => typeof manifest.scripts?.[script] === "string"
|
|
1508
|
+
);
|
|
1509
|
+
if (lifecycle.length)
|
|
1510
|
+
report.warnings.push(
|
|
1511
|
+
`${name} has disabled install scripts (${lifecycle.join(", ")}). If it needs its build step, review node_modules/${name}/package.json first. From the app root, run explicitly: cd node_modules/${name} && ${lifecycle.map((script) => `${project.manager} run ${script}`).join(" && ")}`
|
|
1512
|
+
);
|
|
1513
|
+
else if (existsSync3(join3(folder, "binding.gyp")))
|
|
1514
|
+
report.warnings.push(
|
|
1515
|
+
`${name} has a native binding whose build script was disabled. Review node_modules/${name}/binding.gyp; if needed, run from the app root: npm rebuild ${name} --ignore-scripts=false`
|
|
1516
|
+
);
|
|
1517
|
+
}
|
|
1518
|
+
} catch (error) {
|
|
1519
|
+
if (temporary) await unlink(temporary).catch(() => {
|
|
1520
|
+
});
|
|
1521
|
+
report.error = error instanceof CliError ? error.message : `Could not finish writing the project (${error.code ?? "I/O error"}). Rerun ${report.command} after checking permissions.`;
|
|
1522
|
+
printReport(report, flags.json);
|
|
1523
|
+
if (!flags.json) warn(safeText(report.error));
|
|
1524
|
+
process.exitCode = 1;
|
|
1525
|
+
return report;
|
|
1526
|
+
} finally {
|
|
1527
|
+
installing = void 0;
|
|
1528
|
+
process.removeListener("SIGINT", interrupt);
|
|
1529
|
+
process.removeListener("SIGTERM", terminate);
|
|
1530
|
+
}
|
|
1531
|
+
printReport(report, flags.json);
|
|
1532
|
+
return report;
|
|
1533
|
+
}
|
|
1534
|
+
|
|
6
1535
|
// src/auth.ts
|
|
7
|
-
import { spawn } from "node:child_process";
|
|
8
|
-
import { createHash, randomBytes } from "node:crypto";
|
|
1536
|
+
import { spawn as spawn2 } from "node:child_process";
|
|
1537
|
+
import { createHash as createHash2, randomBytes as randomBytes2 } from "node:crypto";
|
|
9
1538
|
import { createServer } from "node:http";
|
|
10
1539
|
|
|
11
1540
|
// src/credentials.ts
|
|
12
|
-
import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
1541
|
+
import { chmod, mkdir as mkdir2, readFile as readFile4, rm, writeFile } from "node:fs/promises";
|
|
13
1542
|
import { homedir } from "node:os";
|
|
14
|
-
import { dirname, join } from "node:path";
|
|
15
|
-
var credentialsPath = () =>
|
|
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,170 @@ async function api(server2, path, init = {}) {
|
|
|
389
1896
|
});
|
|
390
1897
|
}
|
|
391
1898
|
|
|
1899
|
+
// src/command-help.ts
|
|
1900
|
+
var commandHelp = {
|
|
1901
|
+
search: `layro search <words...> [options]
|
|
1902
|
+
|
|
1903
|
+
Find public components and print their Layro add commands. No sign-in.
|
|
1904
|
+
--category <slug> Filter category
|
|
1905
|
+
--author <handle> Filter author
|
|
1906
|
+
--limit <n> 1 to 50 (default 20)
|
|
1907
|
+
--json Return results with Layro install commands as JSON
|
|
1908
|
+
|
|
1909
|
+
Examples:
|
|
1910
|
+
npx layro-cli search animated text
|
|
1911
|
+
layro search chart --author legions-developer --limit 5 --json`,
|
|
1912
|
+
publish: `layro publish <files...> [options]
|
|
1913
|
+
|
|
1914
|
+
Submit a new component or a version of your same-named component for review.
|
|
1915
|
+
The first file is the entry. Sign in with login or set LAYRO_TOKEN for CI.
|
|
1916
|
+
--name <name> Defaults to entry file's name
|
|
1917
|
+
--description <text> 20 to 200 characters for a new component
|
|
1918
|
+
--category <slug> Category from layro.dev/components
|
|
1919
|
+
--license <id> MIT (default), Apache-2.0, ISC or BSD-3-Clause
|
|
1920
|
+
--version <x.y.z> Existing component: next patch by default
|
|
1921
|
+
--notes <text> What changed
|
|
1922
|
+
--preview <file.json> How to record the preview
|
|
1923
|
+
--dark-only New component only works on a dark page
|
|
1924
|
+
--if-changed Unchanged/pending version: nothing sent, exit 0
|
|
1925
|
+
-y, --yes Send flags as given without prompts
|
|
1926
|
+
--json No prompts; return result or field errors as JSON
|
|
1927
|
+
|
|
1928
|
+
Examples:
|
|
1929
|
+
layro publish src/components/card.tsx --name "My Card" --description "A useful card for your dashboard" --category cards --yes
|
|
1930
|
+
layro publish src/components/card.tsx --if-changed --json`,
|
|
1931
|
+
status: `layro status [slug | author/slug] [--json]
|
|
1932
|
+
|
|
1933
|
+
Show your submissions and reviewers' reasons. Sign in or set LAYRO_TOKEN.
|
|
1934
|
+
Without a component, list all of yours. --json returns the API response.
|
|
1935
|
+
|
|
1936
|
+
Examples:
|
|
1937
|
+
layro status
|
|
1938
|
+
layro status author/card --json`,
|
|
1939
|
+
login: `layro login [--no-browser] [--timeout <seconds>] [--json]
|
|
1940
|
+
|
|
1941
|
+
Sign in with GitHub and approve Layro CLI in your browser.
|
|
1942
|
+
--no-browser Print the link; explicitly wait for its loopback callback
|
|
1943
|
+
--timeout <n> Wait 1 to 300 seconds for consent (default 300)
|
|
1944
|
+
--json Print newline-delimited JSON: authorization_required, then
|
|
1945
|
+
signed_in or error. Never includes access/refresh tokens.
|
|
1946
|
+
|
|
1947
|
+
Without a terminal, --no-browser is required. CI should use LAYRO_TOKEN.
|
|
1948
|
+
Network requests time out after 20 seconds. Consent uses the timeout above.
|
|
1949
|
+
|
|
1950
|
+
Examples:
|
|
1951
|
+
layro login
|
|
1952
|
+
layro login --no-browser --timeout 120 --json`,
|
|
1953
|
+
logout: `layro logout [--json]
|
|
1954
|
+
|
|
1955
|
+
Revoke and forget the saved session. Offline: forget local tokens anyway.
|
|
1956
|
+
--json returns signedOut and tokenEnvironmentSet. An environment token stays set.
|
|
1957
|
+
|
|
1958
|
+
Examples:
|
|
1959
|
+
layro logout
|
|
1960
|
+
layro logout --json`,
|
|
1961
|
+
whoami: `layro whoami [--json]
|
|
1962
|
+
|
|
1963
|
+
Show the signed-in handle, profile and submissions left today.
|
|
1964
|
+
Uses LAYRO_TOKEN when set, otherwise the saved session. --json returns API JSON.
|
|
1965
|
+
|
|
1966
|
+
Examples:
|
|
1967
|
+
layro whoami
|
|
1968
|
+
layro whoami --json`
|
|
1969
|
+
};
|
|
1970
|
+
var commands = [
|
|
1971
|
+
"init",
|
|
1972
|
+
"add",
|
|
1973
|
+
"search",
|
|
1974
|
+
"publish",
|
|
1975
|
+
"status",
|
|
1976
|
+
"login",
|
|
1977
|
+
"logout",
|
|
1978
|
+
"whoami",
|
|
1979
|
+
"help"
|
|
1980
|
+
];
|
|
1981
|
+
function distance(a, b) {
|
|
1982
|
+
let row = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
1983
|
+
for (let i = 1; i <= a.length; i++) {
|
|
1984
|
+
const next2 = [i];
|
|
1985
|
+
for (let j = 1; j <= b.length; j++)
|
|
1986
|
+
next2[j] = Math.min(
|
|
1987
|
+
next2[j - 1] + 1,
|
|
1988
|
+
row[j] + 1,
|
|
1989
|
+
row[j - 1] + Number(a[i - 1] !== b[j - 1])
|
|
1990
|
+
);
|
|
1991
|
+
row = next2;
|
|
1992
|
+
}
|
|
1993
|
+
return row[b.length];
|
|
1994
|
+
}
|
|
1995
|
+
function suggestCommand(input) {
|
|
1996
|
+
if (input.length > 32) return "";
|
|
1997
|
+
const found = commands.map((command) => ({ command, distance: distance(input, command) })).sort((a, b) => a.distance - b.distance)[0];
|
|
1998
|
+
return found.distance <= 2 ? ` Did you mean layro ${found.command}?` : "";
|
|
1999
|
+
}
|
|
2000
|
+
|
|
392
2001
|
// src/publish.ts
|
|
393
|
-
import { readFile as
|
|
394
|
-
import {
|
|
2002
|
+
import { readFile as readFile5 } from "node:fs/promises";
|
|
2003
|
+
import {
|
|
2004
|
+
basename,
|
|
2005
|
+
dirname as dirname6,
|
|
2006
|
+
isAbsolute as isAbsolute2,
|
|
2007
|
+
relative as relative5,
|
|
2008
|
+
resolve as resolve5,
|
|
2009
|
+
sep as sep2
|
|
2010
|
+
} from "node:path";
|
|
395
2011
|
function nameFromFile(path) {
|
|
396
2012
|
const stem = basename(path).replace(/\.[^.]+$/, "");
|
|
397
2013
|
return stem.replace(/([a-z0-9])([A-Z])/g, "$1 $2").split(/[-_\s.]+/).filter(Boolean).map((word) => word[0].toUpperCase() + word.slice(1)).join(" ");
|
|
398
2014
|
}
|
|
399
2015
|
async function readFiles(paths) {
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
root = dirname2(root);
|
|
405
|
-
}
|
|
406
|
-
}
|
|
2016
|
+
if (!paths.length)
|
|
2017
|
+
throw new CliError("Give at least one source file to publish.");
|
|
2018
|
+
const absolute = paths.map((p) => resolve5(p));
|
|
2019
|
+
const root = commonFileRoot(absolute);
|
|
407
2020
|
return Promise.all(
|
|
408
2021
|
absolute.map(async (path, i) => {
|
|
409
2022
|
let content;
|
|
410
2023
|
try {
|
|
411
|
-
content = await
|
|
2024
|
+
content = await readFile5(path, "utf8");
|
|
412
2025
|
} catch {
|
|
413
2026
|
throw new CliError(`Can't read ${paths[i]}.`);
|
|
414
2027
|
}
|
|
415
|
-
return { path:
|
|
2028
|
+
return { path: relative5(root, path).split(sep2).join("/"), content };
|
|
416
2029
|
})
|
|
417
2030
|
);
|
|
418
2031
|
}
|
|
2032
|
+
function commonFileRoot(paths, path = { dirname: dirname6, relative: relative5, isAbsolute: isAbsolute2 }) {
|
|
2033
|
+
let root = path.dirname(paths[0]);
|
|
2034
|
+
for (const file of paths) {
|
|
2035
|
+
for (; ; ) {
|
|
2036
|
+
const rel = path.relative(root, path.dirname(file));
|
|
2037
|
+
if (!path.isAbsolute(rel) && rel !== ".." && !rel.startsWith("../") && !rel.startsWith("..\\"))
|
|
2038
|
+
break;
|
|
2039
|
+
const parent = path.dirname(root);
|
|
2040
|
+
if (parent === root)
|
|
2041
|
+
throw new CliError(
|
|
2042
|
+
"Publish files must share a directory on the same drive. Copy them into one project first."
|
|
2043
|
+
);
|
|
2044
|
+
root = parent;
|
|
2045
|
+
}
|
|
2046
|
+
}
|
|
2047
|
+
return root;
|
|
2048
|
+
}
|
|
419
2049
|
async function readPreview(path) {
|
|
420
2050
|
if (!path) return void 0;
|
|
421
2051
|
try {
|
|
422
|
-
return JSON.parse(await
|
|
2052
|
+
return JSON.parse(await readFile5(path, "utf8"));
|
|
423
2053
|
} catch {
|
|
424
2054
|
throw new CliError(`--preview: ${path} isn't a readable JSON file.`);
|
|
425
2055
|
}
|
|
426
2056
|
}
|
|
427
|
-
function explain(error) {
|
|
2057
|
+
function explain(error, json = false) {
|
|
2058
|
+
if (json) throw error;
|
|
428
2059
|
if (error instanceof ApiFailure && error.detail.errors) {
|
|
429
2060
|
say(red("Not sent. Fix these and try again:"));
|
|
430
2061
|
for (const [field, message] of Object.entries(error.detail.errors)) {
|
|
431
|
-
say(` ${bold(field)}: ${message}`);
|
|
2062
|
+
say(` ${bold(safeText(field))}: ${safeText(message)}`);
|
|
432
2063
|
}
|
|
433
2064
|
process.exit(1);
|
|
434
2065
|
}
|
|
@@ -439,15 +2070,18 @@ async function publish(server2, paths, flags) {
|
|
|
439
2070
|
throw new CliError(
|
|
440
2071
|
"Name the files to publish, entry first: layro publish components/pricing-card.tsx"
|
|
441
2072
|
);
|
|
442
|
-
const interactive = canAsk() && !flags.yes;
|
|
2073
|
+
const interactive = canAsk() && !flags.yes && !flags.json;
|
|
2074
|
+
const note = (text) => {
|
|
2075
|
+
if (!flags.json) say(safeText(text));
|
|
2076
|
+
};
|
|
443
2077
|
const files = await readFiles(paths);
|
|
444
2078
|
const preview = await readPreview(flags.preview);
|
|
445
2079
|
const me = await api(server2, "/me");
|
|
446
|
-
|
|
2080
|
+
note(`Publishing as @${me.handle} to ${server2}`);
|
|
447
2081
|
const name = flags.name ?? (interactive ? await ask("Name", nameFromFile(paths[0])) : nameFromFile(paths[0]));
|
|
448
|
-
let
|
|
2082
|
+
let existing2 = null;
|
|
449
2083
|
try {
|
|
450
|
-
|
|
2084
|
+
existing2 = await api(
|
|
451
2085
|
server2,
|
|
452
2086
|
`/status?name=${encodeURIComponent(name)}`
|
|
453
2087
|
);
|
|
@@ -456,17 +2090,17 @@ async function publish(server2, paths, flags) {
|
|
|
456
2090
|
throw error;
|
|
457
2091
|
}
|
|
458
2092
|
let result;
|
|
459
|
-
if (
|
|
460
|
-
|
|
461
|
-
`${bold(
|
|
2093
|
+
if (existing2) {
|
|
2094
|
+
note(
|
|
2095
|
+
`${bold(existing2.name)} is already yours (${existing2.liveVersion ? `${existing2.liveVersion} live` : existing2.status}): sending a new version.`
|
|
462
2096
|
);
|
|
463
|
-
const version = flags.version ?? (interactive ? await ask("Version",
|
|
2097
|
+
const version = flags.version ?? (interactive ? await ask("Version", existing2.nextVersion) : existing2.nextVersion);
|
|
464
2098
|
const notes = flags.notes ?? (interactive ? await ask("What changed", "") : "");
|
|
465
|
-
if (interactive && !await confirm(`Send ${
|
|
2099
|
+
if (interactive && !await confirm(`Send ${existing2.id} ${version} for review?`))
|
|
466
2100
|
return say("Nothing sent.");
|
|
467
2101
|
const sent = await api(
|
|
468
2102
|
server2,
|
|
469
|
-
`/components/${
|
|
2103
|
+
`/components/${existing2.id}/versions`,
|
|
470
2104
|
{
|
|
471
2105
|
method: "POST",
|
|
472
2106
|
body: { version, notes, files, preview }
|
|
@@ -474,17 +2108,18 @@ async function publish(server2, paths, flags) {
|
|
|
474
2108
|
).catch((error) => {
|
|
475
2109
|
if (flags.ifChanged && error instanceof ApiFailure && (error.detail.code === "unchanged" || error.detail.code === "pending"))
|
|
476
2110
|
return error;
|
|
477
|
-
return explain(error);
|
|
2111
|
+
return explain(error, flags.json);
|
|
478
2112
|
});
|
|
479
2113
|
if (sent instanceof ApiFailure) {
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
2114
|
+
const message = Object.values(sent.detail.errors ?? {})[0] ?? sent.message;
|
|
2115
|
+
if (flags.json)
|
|
2116
|
+
say(JSON.stringify({ sent: false, status: sent.detail.code, message }));
|
|
2117
|
+
else note(`Nothing sent: ${message}`);
|
|
483
2118
|
return;
|
|
484
2119
|
}
|
|
485
2120
|
result = sent;
|
|
486
2121
|
} else {
|
|
487
|
-
if (flags.version && flags.version !== "1.0.0")
|
|
2122
|
+
if (!flags.json && flags.version && flags.version !== "1.0.0")
|
|
488
2123
|
warn(
|
|
489
2124
|
yellow(
|
|
490
2125
|
`New components start at 1.0.0; ignoring --version ${flags.version}.`
|
|
@@ -526,8 +2161,13 @@ async function publish(server2, paths, flags) {
|
|
|
526
2161
|
darkOnly: flags.darkOnly ?? false,
|
|
527
2162
|
preview
|
|
528
2163
|
}
|
|
529
|
-
}).catch(explain);
|
|
2164
|
+
}).catch((error) => explain(error, flags.json));
|
|
530
2165
|
}
|
|
2166
|
+
if (flags.json)
|
|
2167
|
+
return say(
|
|
2168
|
+
JSON.stringify({ sent: true, status: "review", ...result }, null, 2)
|
|
2169
|
+
);
|
|
2170
|
+
result = terminalData(result);
|
|
531
2171
|
say(green(`\u2713 ${result.component} ${result.version} is in review.`));
|
|
532
2172
|
say(` ${result.message}`);
|
|
533
2173
|
say(` Studio: ${result.studio}`);
|
|
@@ -538,23 +2178,34 @@ async function publish(server2, paths, flags) {
|
|
|
538
2178
|
async function search(server2, words, flags) {
|
|
539
2179
|
const q = words.join(" ").trim();
|
|
540
2180
|
if (!q) throw new CliError("Give some words: layro search <words\u2026>");
|
|
2181
|
+
if (flags.limit && (!/^\d+$/.test(flags.limit) || Number(flags.limit) < 1 || Number(flags.limit) > 50))
|
|
2182
|
+
throw new CliError(
|
|
2183
|
+
"--limit must be an integer from 1 to 50. Example: layro search chart --limit 10."
|
|
2184
|
+
);
|
|
541
2185
|
const params = new URLSearchParams({ q });
|
|
542
2186
|
if (flags.category) params.set("category", flags.category);
|
|
543
2187
|
if (flags.author) params.set("author", flags.author);
|
|
544
2188
|
if (flags.limit) params.set("limit", flags.limit);
|
|
545
2189
|
const found = await api(server2, `/search?${params}`, { auth: false });
|
|
2190
|
+
for (const result of found.results) {
|
|
2191
|
+
if (!/^@?[a-zA-Z0-9][a-zA-Z0-9_-]*\/[a-z0-9][a-z0-9-]*$/.test(result.id))
|
|
2192
|
+
throw new CliError(
|
|
2193
|
+
"The search API returned an invalid component id. Check LAYRO_URL and retry."
|
|
2194
|
+
);
|
|
2195
|
+
result.install = `npx layro-cli add @${result.id.replace(/^@/, "")}`;
|
|
2196
|
+
}
|
|
546
2197
|
if (flags.json) return say(JSON.stringify(found, null, 2));
|
|
547
2198
|
if (found.results.length === 0) {
|
|
548
|
-
say(`Nothing matches "${q}".`);
|
|
2199
|
+
say(`Nothing matches "${safeText(q)}".`);
|
|
549
2200
|
if (found.suggestion)
|
|
550
2201
|
say(
|
|
551
|
-
`The closest category is ${bold(found.suggestion.name)}: ${found.suggestion.url}`
|
|
2202
|
+
`The closest category is ${bold(safeText(found.suggestion.name))}: ${safeText(found.suggestion.url)}`
|
|
552
2203
|
);
|
|
553
2204
|
return;
|
|
554
2205
|
}
|
|
555
2206
|
for (const c of found.results) {
|
|
556
|
-
say(`${bold(c.name)} ${dim(c.id)}`);
|
|
557
|
-
say(` ${c.description}`);
|
|
2207
|
+
say(`${bold(safeText(c.name))} ${dim(c.id)}`);
|
|
2208
|
+
say(` ${safeText(c.description)}`);
|
|
558
2209
|
say(dim(` ${c.install}`));
|
|
559
2210
|
}
|
|
560
2211
|
say(
|
|
@@ -572,14 +2223,16 @@ var LABEL = {
|
|
|
572
2223
|
processing: "processing",
|
|
573
2224
|
approved: "approved"
|
|
574
2225
|
};
|
|
575
|
-
async function status(server2, target) {
|
|
2226
|
+
async function status(server2, target, json = false) {
|
|
576
2227
|
if (target) {
|
|
577
2228
|
const me = target.includes("/") ? null : await api(server2, "/me");
|
|
578
2229
|
const id = me ? `${me.handle}/${target}` : target;
|
|
579
|
-
|
|
2230
|
+
let one = await api(
|
|
580
2231
|
server2,
|
|
581
2232
|
`/status?component=${encodeURIComponent(id)}`
|
|
582
2233
|
);
|
|
2234
|
+
if (json) return say(JSON.stringify(one, null, 2));
|
|
2235
|
+
one = terminalData(one);
|
|
583
2236
|
say(`${bold(one.name)} ${dim(one.id)}: ${LABEL[one.status] ?? one.status}`);
|
|
584
2237
|
if (one.url) say(` ${one.url}`);
|
|
585
2238
|
say(` Studio: ${one.studio}`);
|
|
@@ -597,18 +2250,20 @@ async function status(server2, target) {
|
|
|
597
2250
|
}
|
|
598
2251
|
return;
|
|
599
2252
|
}
|
|
600
|
-
|
|
2253
|
+
let all = await api(server2, "/status");
|
|
2254
|
+
if (json) return say(JSON.stringify(all, null, 2));
|
|
2255
|
+
all = terminalData(all);
|
|
601
2256
|
say(`@${all.author}`);
|
|
602
2257
|
if (all.components.length === 0) {
|
|
603
2258
|
say(dim("No components yet. Send one with layro publish <files>."));
|
|
604
2259
|
}
|
|
605
2260
|
for (const c of all.components) {
|
|
606
|
-
const
|
|
2261
|
+
const open2 = c.latestSubmission;
|
|
607
2262
|
say(
|
|
608
|
-
` ${c.id.padEnd(36)} ${(LABEL[c.status] ?? c.status).padEnd(18)} ${c.version}${
|
|
2263
|
+
` ${c.id.padEnd(36)} ${(LABEL[c.status] ?? c.status).padEnd(18)} ${c.version}${open2 && c.status === "live" ? dim(` \xB7 ${open2.version} ${LABEL[open2.status] ?? open2.status}`) : ""}`
|
|
609
2264
|
);
|
|
610
|
-
if (
|
|
611
|
-
say(` ${red("Reason:")} ${
|
|
2265
|
+
if (open2?.status === "rejected" && open2.decisionNote)
|
|
2266
|
+
say(` ${red("Reason:")} ${open2.decisionNote}`);
|
|
612
2267
|
}
|
|
613
2268
|
say(
|
|
614
2269
|
dim(
|
|
@@ -626,7 +2281,8 @@ Usage
|
|
|
626
2281
|
layro whoami Who you're signed in as
|
|
627
2282
|
layro publish <files\u2026> Send a new component, or a new version of yours
|
|
628
2283
|
layro status [component] Where your submissions stand (component: slug or author/slug)
|
|
629
|
-
layro add <author/slug>
|
|
2284
|
+
layro add <author/slug> Install a component into your project
|
|
2285
|
+
layro init Set up shared helpers and check Tailwind
|
|
630
2286
|
layro logout Sign out on this computer
|
|
631
2287
|
|
|
632
2288
|
Publish options
|
|
@@ -640,6 +2296,7 @@ Publish options
|
|
|
640
2296
|
--preview <plan.json> How to film its preview (see the docs)
|
|
641
2297
|
--if-changed Unchanged code, or a version still in review: say so, exit 0 (CI)
|
|
642
2298
|
-y, --yes Don't ask; send what's given (for CI)
|
|
2299
|
+
--json No prompts; machine-readable result and errors
|
|
643
2300
|
|
|
644
2301
|
Search options
|
|
645
2302
|
--category <slug> Only this category
|
|
@@ -649,6 +2306,10 @@ Search options
|
|
|
649
2306
|
|
|
650
2307
|
Login options
|
|
651
2308
|
--no-browser Print the sign-in link instead of opening it
|
|
2309
|
+
--timeout <seconds> Wait 1 to 300 seconds for consent (default 300)
|
|
2310
|
+
--json Newline-delimited authorization/result events
|
|
2311
|
+
|
|
2312
|
+
Use layro <command> --help for options and examples for each command.
|
|
652
2313
|
|
|
653
2314
|
Environment
|
|
654
2315
|
LAYRO_TOKEN A token to use instead of signing in: an access
|
|
@@ -660,9 +2321,13 @@ var server = (process.env.LAYRO_URL || "https://layro.dev").replace(
|
|
|
660
2321
|
/\/+$/,
|
|
661
2322
|
""
|
|
662
2323
|
);
|
|
663
|
-
async function whoami() {
|
|
2324
|
+
async function whoami(json = false) {
|
|
664
2325
|
const me = await api(server, "/me");
|
|
665
|
-
|
|
2326
|
+
if (json) {
|
|
2327
|
+
say(JSON.stringify(me, null, 2));
|
|
2328
|
+
return me;
|
|
2329
|
+
}
|
|
2330
|
+
say(`@${safeText(me.handle)} ${dim(`on ${safeText(server)}`)}`);
|
|
666
2331
|
say(
|
|
667
2332
|
dim(
|
|
668
2333
|
`Submissions left today: ${me.submissions.left} of ${me.submissions.limit}`
|
|
@@ -671,34 +2336,101 @@ async function whoami() {
|
|
|
671
2336
|
return me;
|
|
672
2337
|
}
|
|
673
2338
|
async function main(argv) {
|
|
2339
|
+
if (argv[0] === "help" && argv[1]) return main([argv[1], "--help"]);
|
|
674
2340
|
const [command, ...rest] = argv;
|
|
675
2341
|
if (!command || command === "help" || command === "--help" || command === "-h")
|
|
676
2342
|
return say(HELP);
|
|
677
2343
|
if (command === "--version" || command === "-v") return say(VERSION);
|
|
2344
|
+
if ((rest.includes("--help") || rest.includes("-h")) && commandHelp[command])
|
|
2345
|
+
return say(commandHelp[command]);
|
|
678
2346
|
switch (command) {
|
|
2347
|
+
case "init": {
|
|
2348
|
+
if (rest.includes("--help") || rest.includes("-h"))
|
|
2349
|
+
return say(`layro init [options]
|
|
2350
|
+
|
|
2351
|
+
Check React, TypeScript, import aliases and Tailwind. Create components.json
|
|
2352
|
+
and lib/utils.ts (cn), then install clsx and tailwind-merge with scripts off.
|
|
2353
|
+
Existing shadcn configuration and helpers are preserved. Run twice safely.
|
|
2354
|
+
If setup is missing, print the exact next steps and stop before writing.
|
|
2355
|
+
Init does not create an app or rewrite bundler configuration.
|
|
2356
|
+
|
|
2357
|
+
--cwd <folder> App package (default current folder)
|
|
2358
|
+
--dry-run Show the plan; change nothing
|
|
2359
|
+
-y, --yes Never prompt
|
|
2360
|
+
--json Machine-readable result or error
|
|
2361
|
+
|
|
2362
|
+
Examples:
|
|
2363
|
+
npx layro-cli init --yes
|
|
2364
|
+
layro init --cwd apps/web --dry-run --json`);
|
|
2365
|
+
const { values } = parseArgs({
|
|
2366
|
+
args: rest,
|
|
2367
|
+
options: {
|
|
2368
|
+
cwd: { type: "string" },
|
|
2369
|
+
"dry-run": { type: "boolean" },
|
|
2370
|
+
yes: { type: "boolean", short: "y" },
|
|
2371
|
+
json: { type: "boolean" }
|
|
2372
|
+
}
|
|
2373
|
+
});
|
|
2374
|
+
await add(server, [], values, true);
|
|
2375
|
+
return;
|
|
2376
|
+
}
|
|
679
2377
|
case "login": {
|
|
680
2378
|
const { values } = parseArgs({
|
|
681
2379
|
args: rest,
|
|
682
|
-
options: {
|
|
2380
|
+
options: {
|
|
2381
|
+
"no-browser": { type: "boolean" },
|
|
2382
|
+
json: { type: "boolean" },
|
|
2383
|
+
timeout: { type: "string" }
|
|
2384
|
+
}
|
|
2385
|
+
});
|
|
2386
|
+
if (!canAsk() && !values["no-browser"])
|
|
2387
|
+
throw new CliError(
|
|
2388
|
+
"Login needs a browser and consent. In a terminal run layro login; for an explicit headless flow use layro login --no-browser --timeout 300. For CI use LAYRO_TOKEN from /settings/tokens."
|
|
2389
|
+
);
|
|
2390
|
+
const timeout = values.timeout === void 0 ? 300 : Number(values.timeout);
|
|
2391
|
+
if (!Number.isInteger(timeout) || timeout < 1 || timeout > 300)
|
|
2392
|
+
throw new CliError(
|
|
2393
|
+
"--timeout must be an integer from 1 to 300 seconds."
|
|
2394
|
+
);
|
|
2395
|
+
const session = await login(server, {
|
|
2396
|
+
browser: !values["no-browser"],
|
|
2397
|
+
json: values.json,
|
|
2398
|
+
timeout
|
|
683
2399
|
});
|
|
684
|
-
const session = await login(server, { browser: !values["no-browser"] });
|
|
685
2400
|
const me = await api(server, "/me");
|
|
686
2401
|
await setSession(server, { ...session, handle: me.handle });
|
|
687
|
-
|
|
2402
|
+
if (values.json)
|
|
2403
|
+
return say(JSON.stringify({ status: "signed_in", handle: me.handle }));
|
|
2404
|
+
say(green(`\u2713 Signed in as @${safeText(me.handle)}.`));
|
|
688
2405
|
say(dim(`Tokens saved in ${credentialsPath()}`));
|
|
689
2406
|
return;
|
|
690
2407
|
}
|
|
691
2408
|
case "logout": {
|
|
2409
|
+
const { values } = parseArgs({
|
|
2410
|
+
args: rest,
|
|
2411
|
+
options: { json: { type: "boolean" } }
|
|
2412
|
+
});
|
|
692
2413
|
const out = await logout(server);
|
|
2414
|
+
if (values.json)
|
|
2415
|
+
return say(
|
|
2416
|
+
JSON.stringify({
|
|
2417
|
+
signedOut: out,
|
|
2418
|
+
tokenEnvironmentSet: Boolean(process.env.LAYRO_TOKEN)
|
|
2419
|
+
})
|
|
2420
|
+
);
|
|
693
2421
|
say(out ? "Signed out." : "You weren't signed in.");
|
|
694
2422
|
if (process.env.LAYRO_TOKEN)
|
|
695
2423
|
say(dim("LAYRO_TOKEN is still set in this shell."));
|
|
696
2424
|
return;
|
|
697
2425
|
}
|
|
698
2426
|
case "whoami": {
|
|
2427
|
+
const { values } = parseArgs({
|
|
2428
|
+
args: rest,
|
|
2429
|
+
options: { json: { type: "boolean" } }
|
|
2430
|
+
});
|
|
699
2431
|
if (!process.env.LAYRO_TOKEN && !(await getSession(server))?.accessToken)
|
|
700
2432
|
throw new CliError("Not signed in. Run `npx layro-cli login`.");
|
|
701
|
-
await whoami();
|
|
2433
|
+
await whoami(values.json);
|
|
702
2434
|
return;
|
|
703
2435
|
}
|
|
704
2436
|
case "publish": {
|
|
@@ -715,7 +2447,8 @@ async function main(argv) {
|
|
|
715
2447
|
preview: { type: "string" },
|
|
716
2448
|
"dark-only": { type: "boolean" },
|
|
717
2449
|
"if-changed": { type: "boolean" },
|
|
718
|
-
yes: { type: "boolean", short: "y" }
|
|
2450
|
+
yes: { type: "boolean", short: "y" },
|
|
2451
|
+
json: { type: "boolean" }
|
|
719
2452
|
}
|
|
720
2453
|
});
|
|
721
2454
|
const {
|
|
@@ -745,20 +2478,55 @@ async function main(argv) {
|
|
|
745
2478
|
return;
|
|
746
2479
|
}
|
|
747
2480
|
case "status": {
|
|
748
|
-
const { positionals } = parseArgs({
|
|
749
|
-
|
|
2481
|
+
const { positionals, values } = parseArgs({
|
|
2482
|
+
args: rest,
|
|
2483
|
+
allowPositionals: true,
|
|
2484
|
+
options: { json: { type: "boolean" } }
|
|
2485
|
+
});
|
|
2486
|
+
if (positionals.length > 1)
|
|
2487
|
+
throw new CliError(
|
|
2488
|
+
"Use one component: layro status author/slug --json."
|
|
2489
|
+
);
|
|
2490
|
+
await status(server, positionals[0]?.replace(/^@/, ""), values.json);
|
|
750
2491
|
return;
|
|
751
2492
|
}
|
|
752
2493
|
case "add": {
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
2494
|
+
if (rest.includes("--help") || rest.includes("-h"))
|
|
2495
|
+
return say(`layro add @author/slug [more...] [options]
|
|
2496
|
+
|
|
2497
|
+
Install Layro source, shared cn, CSS and npm dependencies in a React TypeScript app.
|
|
2498
|
+
Reads components.json and project aliases. Dependencies resolve before any writes.
|
|
2499
|
+
Package scripts are disabled. Review third-party code before running your app.
|
|
2500
|
+
|
|
2501
|
+
--cwd <folder> App package to install into (default current folder)
|
|
2502
|
+
--path <folder> Destination for component files, relative to app root
|
|
2503
|
+
--dry-run Show the plan without changing files or packages
|
|
2504
|
+
-y, --yes Skip differing existing files; never prompt
|
|
2505
|
+
--overwrite Replace differing files
|
|
2506
|
+
--json Machine-readable plan/result
|
|
2507
|
+
|
|
2508
|
+
Examples:
|
|
2509
|
+
npx layro-cli add @magicuidesign/aurora-text --yes
|
|
2510
|
+
layro add author/one author/two --cwd apps/web --dry-run --json`);
|
|
2511
|
+
const { values, positionals } = parseArgs({
|
|
2512
|
+
args: rest,
|
|
2513
|
+
allowPositionals: true,
|
|
2514
|
+
options: {
|
|
2515
|
+
cwd: { type: "string" },
|
|
2516
|
+
path: { type: "string" },
|
|
2517
|
+
"dry-run": { type: "boolean" },
|
|
2518
|
+
yes: { type: "boolean", short: "y" },
|
|
2519
|
+
overwrite: { type: "boolean" },
|
|
2520
|
+
json: { type: "boolean" }
|
|
2521
|
+
}
|
|
2522
|
+
});
|
|
2523
|
+
await add(server, positionals, values);
|
|
758
2524
|
return;
|
|
759
2525
|
}
|
|
760
2526
|
default:
|
|
761
|
-
throw new CliError(
|
|
2527
|
+
throw new CliError(
|
|
2528
|
+
`Unknown command "${safeText(command)}".${suggestCommand(command)} Run layro --help.`
|
|
2529
|
+
);
|
|
762
2530
|
}
|
|
763
2531
|
}
|
|
764
2532
|
process.stdout.on("error", (error) => {
|
|
@@ -766,12 +2534,34 @@ process.stdout.on("error", (error) => {
|
|
|
766
2534
|
throw error;
|
|
767
2535
|
});
|
|
768
2536
|
main(process.argv.slice(2)).catch((error) => {
|
|
769
|
-
if (
|
|
770
|
-
|
|
2537
|
+
if (process.argv.includes("--json")) {
|
|
2538
|
+
say(
|
|
2539
|
+
JSON.stringify({
|
|
2540
|
+
error: error instanceof Error ? error.message : "Could not finish the command.",
|
|
2541
|
+
...error instanceof ApiFailure ? {
|
|
2542
|
+
status: error.detail.status,
|
|
2543
|
+
code: error.detail.code,
|
|
2544
|
+
errors: error.detail.errors
|
|
2545
|
+
} : {}
|
|
2546
|
+
})
|
|
2547
|
+
);
|
|
2548
|
+
process.exitCode = 1;
|
|
2549
|
+
return;
|
|
2550
|
+
}
|
|
2551
|
+
if (error instanceof SetupRequired) {
|
|
2552
|
+
for (const line of error.message.split("\n")) warn(safeText(line));
|
|
2553
|
+
} else if (error instanceof CliError) {
|
|
2554
|
+
warn(red(safeText(error.message)));
|
|
771
2555
|
} else if (error?.code?.startsWith("ERR_PARSE_ARGS")) {
|
|
772
|
-
warn(red(error.message));
|
|
2556
|
+
warn(red(safeText(error.message)));
|
|
773
2557
|
} else {
|
|
774
|
-
warn(
|
|
2558
|
+
warn(
|
|
2559
|
+
red(
|
|
2560
|
+
safeText(
|
|
2561
|
+
`Could not finish: ${error?.message ?? "unknown error"}.`
|
|
2562
|
+
)
|
|
2563
|
+
)
|
|
2564
|
+
);
|
|
775
2565
|
}
|
|
776
2566
|
process.exit(1);
|
|
777
2567
|
});
|