layro-cli 0.0.0-stage → 0.2.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Layro
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,35 @@
1
- # Temporary Holding Version
1
+ # layro-cli
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Find and publish React components on [Layro](https://layro.dev) from your terminal.
4
+
5
+ ```bash
6
+ npx layro-cli search pricing toggle
7
+ npx layro-cli login
8
+ npx layro-cli publish components/pricing-card.tsx
9
+ ```
10
+
11
+ Requires Node 20 or newer. No dependencies.
12
+
13
+ ## Commands
14
+
15
+ | Command | Does |
16
+ | -------------------------- | -------------------------------------------------------------------------------------------------------------- |
17
+ | `layro search <words…>` | Find components, best match first (typos are fine, no sign-in). `--category`, `--author`, `--limit`, `--json`. |
18
+ | `layro login` | Sign in with GitHub in your browser (no API keys). `--no-browser` prints the link. |
19
+ | `layro whoami` | Who you're signed in as, and submissions left today. |
20
+ | `layro publish <files…>` | Send a new component, or a new version of one you already have (same name). The first file is the entry. |
21
+ | `layro status [component]` | Where your submissions stand, with reviewers' reasons. `component` is a slug or `author/slug`. |
22
+ | `layro add <author/slug>` | Prints the shadcn command that installs a component. |
23
+ | `layro logout` | Revokes the session and forgets it on this computer. |
24
+
25
+ ### Publish options
26
+
27
+ `--name`, `--description` (20 to 200 characters), `--category` (a slug from layro.dev/components), `--license` (MIT, Apache-2.0, ISC or BSD-3-Clause; default MIT), `--version` (new versions: defaults to a patch bump), `--notes` (what changed), `--dark-only`, `--preview <plan.json>` (how to film the preview), `-y, --yes` (don't ask; for CI).
28
+
29
+ Everything goes through review on Layro before it's live. The CLI and coding agents (MCP) share one daily allowance of submissions per account.
30
+
31
+ ## Where tokens live
32
+
33
+ `~/.config/layro/credentials.json` (or `$XDG_CONFIG_HOME/layro`), readable only by you. Access tokens last an hour and renew themselves. `LAYRO_TOKEN` uses an access token instead; `LAYRO_URL` points at another Layro server.
34
+
35
+ Docs: https://layro.dev/docs/cli
package/dist/layro.mjs ADDED
@@ -0,0 +1,755 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import { parseArgs } from "node:util";
5
+
6
+ // src/auth.ts
7
+ import { spawn } from "node:child_process";
8
+ import { createHash, randomBytes } from "node:crypto";
9
+ import { createServer } from "node:http";
10
+
11
+ // src/credentials.ts
12
+ import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
13
+ import { homedir } from "node:os";
14
+ import { dirname, join } from "node:path";
15
+ var credentialsPath = () => join(
16
+ process.env.XDG_CONFIG_HOME || join(homedir(), ".config"),
17
+ "layro",
18
+ "credentials.json"
19
+ );
20
+ async function load() {
21
+ try {
22
+ const parsed = JSON.parse(await readFile(credentialsPath(), "utf8"));
23
+ return parsed && typeof parsed.servers === "object" ? parsed : { servers: {} };
24
+ } catch {
25
+ return { servers: {} };
26
+ }
27
+ }
28
+ async function save(file) {
29
+ const path = credentialsPath();
30
+ await mkdir(dirname(path), { recursive: true, mode: 448 });
31
+ if (Object.keys(file.servers).length === 0) {
32
+ await rm(path, { force: true });
33
+ return;
34
+ }
35
+ await writeFile(path, `${JSON.stringify(file, null, 2)}
36
+ `, {
37
+ mode: 384
38
+ });
39
+ await chmod(path, 384);
40
+ }
41
+ async function getSession(server2) {
42
+ return (await load()).servers[server2] ?? null;
43
+ }
44
+ async function setSession(server2, session) {
45
+ const file = await load();
46
+ file.servers[server2] = session;
47
+ await save(file);
48
+ }
49
+ async function clearTokens(server2) {
50
+ const file = await load();
51
+ const session = file.servers[server2];
52
+ if (!session) return;
53
+ file.servers[server2] = { clientId: session.clientId };
54
+ await save(file);
55
+ }
56
+ async function forgetClient(server2) {
57
+ const file = await load();
58
+ delete file.servers[server2];
59
+ await save(file);
60
+ }
61
+
62
+ // src/ui.ts
63
+ import { createInterface } from "node:readline/promises";
64
+ var color = process.stdout.isTTY && !process.env.NO_COLOR;
65
+ var paint = (code) => (text) => color ? `\x1B[${code}m${text}\x1B[0m` : text;
66
+ var bold = paint(1);
67
+ var dim = paint(2);
68
+ var green = paint(32);
69
+ var red = paint(31);
70
+ var yellow = paint(33);
71
+ var say = (text = "") => process.stdout.write(`${text}
72
+ `);
73
+ var warn = (text) => process.stderr.write(`${text}
74
+ `);
75
+ var CliError = class extends Error {
76
+ };
77
+ var canAsk = () => Boolean(process.stdin.isTTY && process.stdout.isTTY);
78
+ async function ask(question, fallback = "") {
79
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
80
+ try {
81
+ const hint = fallback ? dim(` (${fallback})`) : "";
82
+ const answer = (await rl.question(`${question}${hint}: `)).trim();
83
+ return answer || fallback;
84
+ } catch (error) {
85
+ if (error.name === "AbortError")
86
+ throw new CliError("Cancelled.");
87
+ throw error;
88
+ } finally {
89
+ rl.close();
90
+ }
91
+ }
92
+ async function confirm(question) {
93
+ const answer = (await ask(`${question} ${dim("[Y/n]")}`)).toLowerCase();
94
+ return answer === "" || answer === "y" || answer === "yes";
95
+ }
96
+ async function choose(question, options, fallback) {
97
+ options.forEach((option, i) => {
98
+ say(` ${dim(String(i + 1).padStart(2))} ${option.label}`);
99
+ });
100
+ const fallbackIndex = options.findIndex((o) => o.value === fallback);
101
+ for (; ; ) {
102
+ const answer = await ask(
103
+ question,
104
+ fallbackIndex >= 0 ? String(fallbackIndex + 1) : ""
105
+ );
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
+ }
112
+ }
113
+
114
+ // src/auth.ts
115
+ var SCOPE = "layro offline_access";
116
+ var LOGIN_TIMEOUT_MS = 5 * 60 * 1e3;
117
+ var EARLY_MS = 60 * 1e3;
118
+ var apiResource = (server2) => `${server2}/api/v1`;
119
+ async function metadata(server2) {
120
+ const url = `${server2}/.well-known/oauth-authorization-server/api/auth`;
121
+ let response;
122
+ try {
123
+ response = await fetch(url);
124
+ } catch (error) {
125
+ throw new CliError(
126
+ `Can't reach ${server2} (${error.message}). Check your connection, or LAYRO_URL.`
127
+ );
128
+ }
129
+ if (!response.ok)
130
+ throw new CliError(`${server2} answered ${response.status} for ${url}.`);
131
+ return await response.json();
132
+ }
133
+ var base64url = (bytes) => bytes.toString("base64url");
134
+ async function register(meta, server2, redirect) {
135
+ const response = await fetch(meta.registration_endpoint, {
136
+ method: "POST",
137
+ headers: { "Content-Type": "application/json" },
138
+ body: JSON.stringify({
139
+ client_name: "Layro CLI",
140
+ client_uri: "https://layro.dev/docs/cli",
141
+ redirect_uris: [redirect],
142
+ token_endpoint_auth_method: "none",
143
+ grant_types: ["authorization_code", "refresh_token"],
144
+ response_types: ["code"],
145
+ scope: SCOPE,
146
+ resources: [apiResource(server2)]
147
+ })
148
+ });
149
+ const body = await response.json().catch(() => ({}));
150
+ if (!response.ok || !body.client_id) {
151
+ throw new CliError(
152
+ response.status === 429 ? "Too many sign-ins from this network in the last hour. Try again later." : `Couldn't register the CLI with ${server2}: ${body.error_description ?? body.message ?? response.status}.`
153
+ );
154
+ }
155
+ return body.client_id;
156
+ }
157
+ function openBrowser(url) {
158
+ const custom = process.env.LAYRO_OPEN_BROWSER;
159
+ const [command, ...args] = custom ? [...custom.split(" ").filter(Boolean), url] : process.platform === "darwin" ? ["open", url] : process.platform === "win32" ? ["cmd", "/c", "start", '""', url.replaceAll("&", "^&")] : ["xdg-open", url];
160
+ const child = spawn(command, args, {
161
+ stdio: custom ? "inherit" : "ignore",
162
+ detached: !custom
163
+ });
164
+ child.on("error", () => {
165
+ });
166
+ if (!custom) child.unref();
167
+ }
168
+ var page = (title, text) => `<!doctype html><meta charset="utf-8"><title>${title}</title><body style="font:16px/1.5 system-ui,sans-serif;max-width:32rem;margin:20vh auto;padding:0 1rem;color:#111"><h1 style="font-size:1.4rem">${title}</h1><p>${text}</p></body>`;
169
+ async function callbackServer(state) {
170
+ let settle = () => {
171
+ };
172
+ const result = new Promise((resolve2) => {
173
+ settle = resolve2;
174
+ });
175
+ const server2 = createServer((request, response) => {
176
+ const url = new URL(request.url ?? "/", "http://127.0.0.1");
177
+ if (url.pathname !== "/callback") {
178
+ response.writeHead(404).end();
179
+ return;
180
+ }
181
+ const error = url.searchParams.get("error");
182
+ const code = url.searchParams.get("code");
183
+ const ok = !error && code && url.searchParams.get("state") === state;
184
+ response.writeHead(ok ? 200 : 400, { "Content-Type": "text/html; charset=utf-8" }).end(
185
+ ok ? page(
186
+ "Signed in to Layro",
187
+ "You can close this tab and go back to your terminal."
188
+ ) : page(
189
+ "Not signed in",
190
+ error === "access_denied" ? "You denied access. Run layro login again if that was a mistake." : "Something went wrong. Run layro login again."
191
+ )
192
+ );
193
+ settle(
194
+ ok ? { code } : {
195
+ error: error ?? (code ? "state mismatch" : "no code in the callback")
196
+ }
197
+ );
198
+ });
199
+ await new Promise((resolve2, reject) => {
200
+ server2.once("error", reject);
201
+ server2.listen(0, "127.0.0.1", resolve2);
202
+ });
203
+ const { port } = server2.address();
204
+ return { port, result, close: () => server2.close() };
205
+ }
206
+ async function tokenRequest(meta, params) {
207
+ const response = await fetch(meta.token_endpoint, {
208
+ method: "POST",
209
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
210
+ body: new URLSearchParams(params)
211
+ });
212
+ const body = await response.json().catch(() => ({}));
213
+ if (!response.ok || !body.access_token) {
214
+ return {
215
+ ok: false,
216
+ error: body.error_description ?? body.error ?? String(response.status)
217
+ };
218
+ }
219
+ return { ok: true, tokens: body };
220
+ }
221
+ var toSession = (clientId, tokens) => ({
222
+ clientId,
223
+ accessToken: tokens.access_token,
224
+ refreshToken: tokens.refresh_token,
225
+ expiresAt: Date.now() + (tokens.expires_in ?? 3600) * 1e3
226
+ });
227
+ async function login(server2, options) {
228
+ const meta = await metadata(server2);
229
+ const state = base64url(randomBytes(16));
230
+ const callback = await callbackServer(state);
231
+ try {
232
+ const redirect = `http://127.0.0.1:${callback.port}/callback`;
233
+ const known = await getSession(server2);
234
+ const clientId = known?.clientId ?? await register(meta, server2, redirect);
235
+ await setSession(server2, { ...known ?? {}, clientId });
236
+ const verifier = base64url(randomBytes(32));
237
+ const challenge = base64url(createHash("sha256").update(verifier).digest());
238
+ const authorize = new URL(meta.authorization_endpoint);
239
+ authorize.search = new URLSearchParams({
240
+ response_type: "code",
241
+ client_id: clientId,
242
+ redirect_uri: redirect,
243
+ scope: SCOPE,
244
+ state,
245
+ code_challenge: challenge,
246
+ code_challenge_method: "S256",
247
+ resource: apiResource(server2)
248
+ }).toString();
249
+ say("Opening your browser to sign in to Layro with GitHub.");
250
+ say(dim(`If it doesn't open, visit:
251
+ ${authorize}`));
252
+ if (options.browser) openBrowser(authorize.toString());
253
+ const outcome = await Promise.race([
254
+ callback.result,
255
+ new Promise(
256
+ (resolve2) => setTimeout(
257
+ () => resolve2({ error: "timed out waiting for the browser" }),
258
+ LOGIN_TIMEOUT_MS
259
+ ).unref()
260
+ )
261
+ ]);
262
+ if (!outcome.code) {
263
+ if (outcome.error === "invalid_client") await forgetClient(server2);
264
+ throw new CliError(
265
+ outcome.error === "access_denied" ? "Sign-in denied on Layro." : `Sign-in didn't finish: ${outcome.error}. Run layro login again.`
266
+ );
267
+ }
268
+ const exchanged = await tokenRequest(meta, {
269
+ grant_type: "authorization_code",
270
+ code: outcome.code,
271
+ redirect_uri: redirect,
272
+ client_id: clientId,
273
+ code_verifier: verifier,
274
+ resource: apiResource(server2)
275
+ });
276
+ if (!exchanged.ok)
277
+ throw new CliError(`Sign-in failed: ${exchanged.error}.`);
278
+ const session = toSession(clientId, exchanged.tokens);
279
+ await setSession(server2, session);
280
+ return session;
281
+ } finally {
282
+ callback.close();
283
+ }
284
+ }
285
+ async function refresh(server2, session) {
286
+ if (!session.refreshToken) return null;
287
+ const meta = await metadata(server2);
288
+ const renewed = await tokenRequest(meta, {
289
+ grant_type: "refresh_token",
290
+ refresh_token: session.refreshToken,
291
+ client_id: session.clientId,
292
+ resource: apiResource(server2)
293
+ });
294
+ if (!renewed.ok) {
295
+ await clearTokens(server2);
296
+ return null;
297
+ }
298
+ const next = {
299
+ ...toSession(session.clientId, renewed.tokens),
300
+ refreshToken: renewed.tokens.refresh_token ?? session.refreshToken,
301
+ handle: session.handle
302
+ };
303
+ await setSession(server2, next);
304
+ return next;
305
+ }
306
+ var notSignedIn = () => new CliError("Not signed in. Run `npx layro-cli login` first.");
307
+ async function accessToken(server2, force = false) {
308
+ if (process.env.LAYRO_TOKEN) return process.env.LAYRO_TOKEN;
309
+ const session = await getSession(server2);
310
+ if (!session?.accessToken) throw notSignedIn();
311
+ if (!force && (session.expiresAt ?? 0) - EARLY_MS > Date.now())
312
+ return session.accessToken;
313
+ const renewed = await refresh(server2, session);
314
+ if (!renewed?.accessToken)
315
+ throw new CliError(
316
+ "Your Layro session expired. Run `npx layro-cli login` again."
317
+ );
318
+ return renewed.accessToken;
319
+ }
320
+ async function logout(server2) {
321
+ const session = await getSession(server2);
322
+ if (!session?.accessToken && !session?.refreshToken) return false;
323
+ try {
324
+ const meta = await metadata(server2);
325
+ if (meta.revocation_endpoint && session.refreshToken) {
326
+ await fetch(meta.revocation_endpoint, {
327
+ method: "POST",
328
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
329
+ body: new URLSearchParams({
330
+ token: session.refreshToken,
331
+ token_type_hint: "refresh_token",
332
+ client_id: session.clientId
333
+ })
334
+ });
335
+ }
336
+ } catch {
337
+ }
338
+ await clearTokens(server2);
339
+ return true;
340
+ }
341
+
342
+ // src/api.ts
343
+ var VERSION = true ? "0.2.0" : "dev";
344
+ var ApiFailure = class extends CliError {
345
+ constructor(detail) {
346
+ super(detail.error);
347
+ this.detail = detail;
348
+ }
349
+ detail;
350
+ };
351
+ async function api(server2, path, init = {}) {
352
+ const call = async (token) => {
353
+ try {
354
+ return await fetch(`${server2}/api/v1${path}`, {
355
+ method: init.method ?? "GET",
356
+ headers: {
357
+ Accept: "application/json",
358
+ "User-Agent": `layro-cli/${VERSION}`,
359
+ ...init.body !== void 0 ? { "Content-Type": "application/json" } : {},
360
+ ...token ? { Authorization: `Bearer ${token}` } : {}
361
+ },
362
+ body: init.body !== void 0 ? JSON.stringify(init.body) : void 0
363
+ });
364
+ } catch (error) {
365
+ throw new CliError(
366
+ `Can't reach ${server2} (${error.message}). Check your connection, or LAYRO_URL.`
367
+ );
368
+ }
369
+ };
370
+ const authed = init.auth !== false;
371
+ let response = await call(authed ? await accessToken(server2) : void 0);
372
+ if (response.status === 401 && authed && !process.env.LAYRO_TOKEN) {
373
+ response = await call(await accessToken(server2, true));
374
+ }
375
+ const body = await response.json().catch(() => null);
376
+ if (response.ok && body) return body;
377
+ if (response.status === 401) {
378
+ if (process.env.LAYRO_TOKEN)
379
+ throw new CliError("LAYRO_TOKEN was refused: it's invalid or expired.");
380
+ throw notSignedIn();
381
+ }
382
+ throw new ApiFailure({
383
+ status: response.status,
384
+ error: body?.error ?? `${server2} answered ${response.status} for ${path}.`,
385
+ errors: body?.errors
386
+ });
387
+ }
388
+
389
+ // src/publish.ts
390
+ import { readFile as readFile2 } from "node:fs/promises";
391
+ import { basename, dirname as dirname2, relative, resolve, sep } from "node:path";
392
+ function nameFromFile(path) {
393
+ const stem = basename(path).replace(/\.[^.]+$/, "");
394
+ return stem.replace(/([a-z0-9])([A-Z])/g, "$1 $2").split(/[-_\s.]+/).filter(Boolean).map((word) => word[0].toUpperCase() + word.slice(1)).join(" ");
395
+ }
396
+ async function readFiles(paths) {
397
+ const absolute = paths.map((p) => resolve(p));
398
+ let root = dirname2(absolute[0]);
399
+ for (const path of absolute) {
400
+ while (!`${dirname2(path)}${sep}`.startsWith(`${root}${sep}`)) {
401
+ root = dirname2(root);
402
+ }
403
+ }
404
+ return Promise.all(
405
+ absolute.map(async (path, i) => {
406
+ let content;
407
+ try {
408
+ content = await readFile2(path, "utf8");
409
+ } catch {
410
+ throw new CliError(`Can't read ${paths[i]}.`);
411
+ }
412
+ return { path: relative(root, path).split(sep).join("/"), content };
413
+ })
414
+ );
415
+ }
416
+ async function readPreview(path) {
417
+ if (!path) return void 0;
418
+ try {
419
+ return JSON.parse(await readFile2(path, "utf8"));
420
+ } catch {
421
+ throw new CliError(`--preview: ${path} isn't a readable JSON file.`);
422
+ }
423
+ }
424
+ function explain(error) {
425
+ if (error instanceof ApiFailure && error.detail.errors) {
426
+ say(red("Not sent. Fix these and try again:"));
427
+ for (const [field, message] of Object.entries(error.detail.errors)) {
428
+ say(` ${bold(field)}: ${message}`);
429
+ }
430
+ process.exit(1);
431
+ }
432
+ throw error;
433
+ }
434
+ async function publish(server2, paths, flags) {
435
+ if (paths.length === 0)
436
+ throw new CliError(
437
+ "Name the files to publish, entry first: layro publish components/pricing-card.tsx"
438
+ );
439
+ const interactive = canAsk() && !flags.yes;
440
+ const files = await readFiles(paths);
441
+ const preview = await readPreview(flags.preview);
442
+ const me = await api(server2, "/me");
443
+ say(dim(`Publishing as @${me.handle} to ${server2}`));
444
+ const name = flags.name ?? (interactive ? await ask("Name", nameFromFile(paths[0])) : nameFromFile(paths[0]));
445
+ let existing = null;
446
+ try {
447
+ existing = await api(
448
+ server2,
449
+ `/status?name=${encodeURIComponent(name)}`
450
+ );
451
+ } catch (error) {
452
+ if (!(error instanceof ApiFailure) || error.detail.status !== 404)
453
+ throw error;
454
+ }
455
+ let result;
456
+ if (existing) {
457
+ say(
458
+ `${bold(existing.name)} is already yours (${existing.liveVersion ? `${existing.liveVersion} live` : existing.status}): sending a new version.`
459
+ );
460
+ const version = flags.version ?? (interactive ? await ask("Version", existing.nextVersion) : existing.nextVersion);
461
+ const notes = flags.notes ?? (interactive ? await ask("What changed", "") : "");
462
+ if (interactive && !await confirm(`Send ${existing.id} ${version} for review?`))
463
+ return say("Nothing sent.");
464
+ result = await api(
465
+ server2,
466
+ `/components/${existing.id}/versions`,
467
+ {
468
+ method: "POST",
469
+ body: { version, notes, files, preview }
470
+ }
471
+ ).catch(explain);
472
+ } else {
473
+ if (flags.version && flags.version !== "1.0.0")
474
+ warn(
475
+ yellow(
476
+ `New components start at 1.0.0; ignoring --version ${flags.version}.`
477
+ )
478
+ );
479
+ const index = interactive || !flags.category ? await api(server2, "", { auth: false }) : null;
480
+ const description = flags.description ?? (interactive ? await ask("Description (20 to 200 characters)") : "");
481
+ let category = flags.category ?? "";
482
+ if (!category && interactive && index) {
483
+ say("Category:");
484
+ category = await choose(
485
+ "Category",
486
+ index.categories.map((c) => ({
487
+ value: c.slug,
488
+ label: `${c.name} ${dim(`(${c.slug}): ${c.description}`)}`
489
+ }))
490
+ );
491
+ }
492
+ let license = flags.license ?? "";
493
+ if (!license && interactive && index) {
494
+ say("Licence:");
495
+ license = await choose(
496
+ "Licence",
497
+ index.licenses.map((l) => ({ value: l, label: l })),
498
+ "MIT"
499
+ );
500
+ }
501
+ license ||= "MIT";
502
+ if (interactive && !await confirm(`Send ${name} 1.0.0 for review as @${me.handle}?`))
503
+ return say("Nothing sent.");
504
+ result = await api(server2, "/components", {
505
+ method: "POST",
506
+ body: {
507
+ name,
508
+ description,
509
+ category,
510
+ license,
511
+ files,
512
+ darkOnly: flags.darkOnly ?? false,
513
+ preview
514
+ }
515
+ }).catch(explain);
516
+ }
517
+ say(green(`\u2713 ${result.component} ${result.version} is in review.`));
518
+ say(` ${result.message}`);
519
+ say(` Studio: ${result.studio}`);
520
+ say(dim(` Submissions left today: ${result.submissionsLeftToday}`));
521
+ }
522
+
523
+ // src/search.ts
524
+ async function search(server2, words, flags) {
525
+ const q = words.join(" ").trim();
526
+ if (!q) throw new CliError("Give some words: layro search <words\u2026>");
527
+ const params = new URLSearchParams({ q });
528
+ if (flags.category) params.set("category", flags.category);
529
+ if (flags.author) params.set("author", flags.author);
530
+ if (flags.limit) params.set("limit", flags.limit);
531
+ const found = await api(server2, `/search?${params}`, { auth: false });
532
+ if (flags.json) return say(JSON.stringify(found, null, 2));
533
+ if (found.results.length === 0) {
534
+ say(`Nothing matches "${q}".`);
535
+ if (found.suggestion)
536
+ say(
537
+ `The closest category is ${bold(found.suggestion.name)}: ${found.suggestion.url}`
538
+ );
539
+ return;
540
+ }
541
+ for (const c of found.results) {
542
+ say(`${bold(c.name)} ${dim(c.id)}`);
543
+ say(` ${c.description}`);
544
+ say(dim(` ${c.install}`));
545
+ }
546
+ say(
547
+ dim(
548
+ `${found.results.length} ${found.results.length === 1 ? "result" : "results"}. Install one with the command under it.`
549
+ )
550
+ );
551
+ }
552
+
553
+ // src/status.ts
554
+ var LABEL = {
555
+ live: "live",
556
+ review: "in review",
557
+ rejected: "changes requested",
558
+ processing: "processing",
559
+ approved: "approved"
560
+ };
561
+ async function status(server2, target) {
562
+ if (target) {
563
+ const me = target.includes("/") ? null : await api(server2, "/me");
564
+ const id = me ? `${me.handle}/${target}` : target;
565
+ const one = await api(
566
+ server2,
567
+ `/status?component=${encodeURIComponent(id)}`
568
+ );
569
+ say(`${bold(one.name)} ${dim(one.id)}: ${LABEL[one.status] ?? one.status}`);
570
+ if (one.url) say(` ${one.url}`);
571
+ say(` Studio: ${one.studio}`);
572
+ say();
573
+ for (const v of one.versions) {
574
+ const state = v.status === "approved" ? v.current ? "live" : "released" : LABEL[v.status];
575
+ say(
576
+ ` ${v.version.padEnd(9)} ${state.padEnd(18)} ${dim(v.decidedAt ?? v.submittedAt)}`
577
+ );
578
+ if (v.notes) say(dim(` ${v.notes}`));
579
+ if (v.decisionNote)
580
+ say(
581
+ ` ${v.status === "rejected" ? red("Reason:") : "Reviewer:"} ${v.decisionNote}`
582
+ );
583
+ }
584
+ return;
585
+ }
586
+ const all = await api(server2, "/status");
587
+ say(`@${all.author}`);
588
+ if (all.components.length === 0) {
589
+ say(dim("No components yet. Send one with layro publish <files>."));
590
+ }
591
+ for (const c of all.components) {
592
+ const open = c.latestSubmission;
593
+ say(
594
+ ` ${c.id.padEnd(36)} ${(LABEL[c.status] ?? c.status).padEnd(18)} ${c.version}${open && c.status === "live" ? dim(` \xB7 ${open.version} ${LABEL[open.status] ?? open.status}`) : ""}`
595
+ );
596
+ if (open?.status === "rejected" && open.decisionNote)
597
+ say(` ${red("Reason:")} ${open.decisionNote}`);
598
+ }
599
+ say(
600
+ dim(
601
+ `Submissions left today: ${all.submissions.left} of ${all.submissions.limit}`
602
+ )
603
+ );
604
+ }
605
+
606
+ // src/index.ts
607
+ var HELP = `${bold("layro")} ${dim(VERSION)}: find and publish React components on Layro
608
+
609
+ Usage
610
+ layro search <words\u2026> Find components (typos are fine; no sign-in needed)
611
+ layro login Sign in with GitHub in your browser
612
+ layro whoami Who you're signed in as
613
+ layro publish <files\u2026> Send a new component, or a new version of yours
614
+ layro status [component] Where your submissions stand (component: slug or author/slug)
615
+ layro add <author/slug> How to install a component (shadcn does it)
616
+ layro logout Sign out on this computer
617
+
618
+ Publish options
619
+ --name <name> Defaults to the first file's name
620
+ --description <text> 20 to 200 characters (new components)
621
+ --category <slug> See https://layro.dev/components (new components)
622
+ --license <id> MIT, Apache-2.0, ISC or BSD-3-Clause (default MIT)
623
+ --version <x.y.z> New versions: defaults to a patch bump
624
+ --notes <text> New versions: what changed
625
+ --dark-only It only works on a dark page
626
+ --preview <plan.json> How to film its preview (see the docs)
627
+ -y, --yes Don't ask; send what's given (for CI)
628
+
629
+ Search options
630
+ --category <slug> Only this category
631
+ --author <handle> Only this author's components
632
+ --limit <n> 1 to 50 (default 20)
633
+ --json Print the API's JSON
634
+
635
+ Login options
636
+ --no-browser Print the sign-in link instead of opening it
637
+
638
+ Environment
639
+ LAYRO_TOKEN An access token to use instead of signing in
640
+ LAYRO_URL Another Layro server (default https://layro.dev)
641
+
642
+ Docs: https://layro.dev/docs/cli`;
643
+ var server = (process.env.LAYRO_URL || "https://layro.dev").replace(
644
+ /\/+$/,
645
+ ""
646
+ );
647
+ async function whoami() {
648
+ const me = await api(server, "/me");
649
+ say(`@${me.handle} ${dim(`on ${server}`)}`);
650
+ say(
651
+ dim(
652
+ `Submissions left today: ${me.submissions.left} of ${me.submissions.limit}`
653
+ )
654
+ );
655
+ return me;
656
+ }
657
+ async function main(argv) {
658
+ const [command, ...rest] = argv;
659
+ if (!command || command === "help" || command === "--help" || command === "-h")
660
+ return say(HELP);
661
+ if (command === "--version" || command === "-v") return say(VERSION);
662
+ switch (command) {
663
+ case "login": {
664
+ const { values } = parseArgs({
665
+ args: rest,
666
+ options: { "no-browser": { type: "boolean" } }
667
+ });
668
+ const session = await login(server, { browser: !values["no-browser"] });
669
+ const me = await api(server, "/me");
670
+ await setSession(server, { ...session, handle: me.handle });
671
+ say(green(`\u2713 Signed in as @${me.handle}.`));
672
+ say(dim(`Tokens saved in ${credentialsPath()}`));
673
+ return;
674
+ }
675
+ case "logout": {
676
+ const out = await logout(server);
677
+ say(out ? "Signed out." : "You weren't signed in.");
678
+ if (process.env.LAYRO_TOKEN)
679
+ say(dim("LAYRO_TOKEN is still set in this shell."));
680
+ return;
681
+ }
682
+ case "whoami": {
683
+ if (!process.env.LAYRO_TOKEN && !(await getSession(server))?.accessToken)
684
+ throw new CliError("Not signed in. Run `npx layro-cli login`.");
685
+ await whoami();
686
+ return;
687
+ }
688
+ case "publish": {
689
+ const { values, positionals } = parseArgs({
690
+ args: rest,
691
+ allowPositionals: true,
692
+ options: {
693
+ name: { type: "string" },
694
+ description: { type: "string" },
695
+ category: { type: "string" },
696
+ license: { type: "string" },
697
+ version: { type: "string" },
698
+ notes: { type: "string" },
699
+ preview: { type: "string" },
700
+ "dark-only": { type: "boolean" },
701
+ yes: { type: "boolean", short: "y" }
702
+ }
703
+ });
704
+ const { "dark-only": darkOnly, ...flags } = values;
705
+ await publish(server, positionals, {
706
+ ...flags,
707
+ darkOnly
708
+ });
709
+ return;
710
+ }
711
+ case "search": {
712
+ const { values, positionals } = parseArgs({
713
+ args: rest,
714
+ allowPositionals: true,
715
+ options: {
716
+ category: { type: "string" },
717
+ author: { type: "string" },
718
+ limit: { type: "string" },
719
+ json: { type: "boolean" }
720
+ }
721
+ });
722
+ await search(server, positionals, values);
723
+ return;
724
+ }
725
+ case "status": {
726
+ const { positionals } = parseArgs({ args: rest, allowPositionals: true });
727
+ await status(server, positionals[0]);
728
+ return;
729
+ }
730
+ case "add": {
731
+ const id = rest[0] ?? "<author>/<slug>";
732
+ say("Layro components install with the shadcn CLI:");
733
+ say(` npx shadcn@latest add ${server}/r/${id}.json`);
734
+ say(dim("Or add Layro as a registry in components.json, then:"));
735
+ say(dim(` npx shadcn@latest add @layro/${id}`));
736
+ return;
737
+ }
738
+ default:
739
+ throw new CliError(`Unknown command "${command}". Run layro --help.`);
740
+ }
741
+ }
742
+ process.stdout.on("error", (error) => {
743
+ if (error.code === "EPIPE") process.exit(0);
744
+ throw error;
745
+ });
746
+ main(process.argv.slice(2)).catch((error) => {
747
+ if (error instanceof CliError) {
748
+ warn(red(error.message));
749
+ } else if (error?.code?.startsWith("ERR_PARSE_ARGS")) {
750
+ warn(red(error.message));
751
+ } else {
752
+ warn(red(`Unexpected error: ${error?.stack ?? error}`));
753
+ }
754
+ process.exit(1);
755
+ });
package/package.json CHANGED
@@ -1,6 +1,37 @@
1
1
  {
2
2
  "name": "layro-cli",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "description": "Find and publish React components on Layro (layro.dev) from your terminal.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "bin": {
8
+ "layro": "dist/layro.mjs"
9
+ },
10
+ "files": [
11
+ "dist",
12
+ "README.md",
13
+ "LICENSE"
14
+ ],
15
+ "engines": {
16
+ "node": ">=20"
17
+ },
18
+ "homepage": "https://layro.dev/docs/cli",
19
+ "keywords": [
20
+ "layro",
21
+ "shadcn",
22
+ "react",
23
+ "components",
24
+ "registry",
25
+ "cli"
26
+ ],
27
+ "scripts": {
28
+ "build": "esbuild src/index.ts --bundle --platform=node --target=node20 --format=esm --outfile=dist/layro.mjs --banner:js=\"#!/usr/bin/env node\" --define:LAYRO_CLI_VERSION=\\\"$npm_package_version\\\"",
29
+ "typecheck": "tsc -p .",
30
+ "prepack": "pnpm run build"
31
+ },
32
+ "devDependencies": {
33
+ "@types/node": "22.20.5",
34
+ "esbuild": "0.28.2",
35
+ "typescript": "5.9.3"
36
+ }
37
+ }