layro-cli 0.0.0-stage → 0.1.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 +21 -0
- package/README.md +32 -2
- package/dist/layro.mjs +704 -0
- package/package.json +35 -4
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,33 @@
|
|
|
1
|
-
#
|
|
1
|
+
# layro-cli
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Publish React components to [Layro](https://layro.dev) from your terminal.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npx layro-cli login
|
|
7
|
+
npx layro-cli publish components/pricing-card.tsx
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
Requires Node 20 or newer. No dependencies.
|
|
11
|
+
|
|
12
|
+
## Commands
|
|
13
|
+
|
|
14
|
+
| Command | Does |
|
|
15
|
+
| -------------------------- | -------------------------------------------------------------------------------------------------------- |
|
|
16
|
+
| `layro login` | Sign in with GitHub in your browser (no API keys). `--no-browser` prints the link. |
|
|
17
|
+
| `layro whoami` | Who you're signed in as, and submissions left today. |
|
|
18
|
+
| `layro publish <files…>` | Send a new component, or a new version of one you already have (same name). The first file is the entry. |
|
|
19
|
+
| `layro status [component]` | Where your submissions stand, with reviewers' reasons. `component` is a slug or `author/slug`. |
|
|
20
|
+
| `layro add <author/slug>` | Prints the shadcn command that installs a component. |
|
|
21
|
+
| `layro logout` | Revokes the session and forgets it on this computer. |
|
|
22
|
+
|
|
23
|
+
### Publish options
|
|
24
|
+
|
|
25
|
+
`--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).
|
|
26
|
+
|
|
27
|
+
Everything goes through review on Layro before it's live. The CLI and coding agents (MCP) share one daily allowance of submissions per account.
|
|
28
|
+
|
|
29
|
+
## Where tokens live
|
|
30
|
+
|
|
31
|
+
`~/.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.
|
|
32
|
+
|
|
33
|
+
Docs: https://layro.dev/docs/cli
|
package/dist/layro.mjs
ADDED
|
@@ -0,0 +1,704 @@
|
|
|
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.1.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/status.ts
|
|
524
|
+
var LABEL = {
|
|
525
|
+
live: "live",
|
|
526
|
+
review: "in review",
|
|
527
|
+
rejected: "changes requested",
|
|
528
|
+
processing: "processing",
|
|
529
|
+
approved: "approved"
|
|
530
|
+
};
|
|
531
|
+
async function status(server2, target) {
|
|
532
|
+
if (target) {
|
|
533
|
+
const me = target.includes("/") ? null : await api(server2, "/me");
|
|
534
|
+
const id = me ? `${me.handle}/${target}` : target;
|
|
535
|
+
const one = await api(
|
|
536
|
+
server2,
|
|
537
|
+
`/status?component=${encodeURIComponent(id)}`
|
|
538
|
+
);
|
|
539
|
+
say(`${bold(one.name)} ${dim(one.id)}: ${LABEL[one.status] ?? one.status}`);
|
|
540
|
+
if (one.url) say(` ${one.url}`);
|
|
541
|
+
say(` Studio: ${one.studio}`);
|
|
542
|
+
say();
|
|
543
|
+
for (const v of one.versions) {
|
|
544
|
+
const state = v.status === "approved" ? v.current ? "live" : "released" : LABEL[v.status];
|
|
545
|
+
say(
|
|
546
|
+
` ${v.version.padEnd(9)} ${state.padEnd(18)} ${dim(v.decidedAt ?? v.submittedAt)}`
|
|
547
|
+
);
|
|
548
|
+
if (v.notes) say(dim(` ${v.notes}`));
|
|
549
|
+
if (v.decisionNote)
|
|
550
|
+
say(
|
|
551
|
+
` ${v.status === "rejected" ? red("Reason:") : "Reviewer:"} ${v.decisionNote}`
|
|
552
|
+
);
|
|
553
|
+
}
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
const all = await api(server2, "/status");
|
|
557
|
+
say(`@${all.author}`);
|
|
558
|
+
if (all.components.length === 0) {
|
|
559
|
+
say(dim("No components yet. Send one with layro publish <files>."));
|
|
560
|
+
}
|
|
561
|
+
for (const c of all.components) {
|
|
562
|
+
const open = c.latestSubmission;
|
|
563
|
+
say(
|
|
564
|
+
` ${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}`) : ""}`
|
|
565
|
+
);
|
|
566
|
+
if (open?.status === "rejected" && open.decisionNote)
|
|
567
|
+
say(` ${red("Reason:")} ${open.decisionNote}`);
|
|
568
|
+
}
|
|
569
|
+
say(
|
|
570
|
+
dim(
|
|
571
|
+
`Submissions left today: ${all.submissions.left} of ${all.submissions.limit}`
|
|
572
|
+
)
|
|
573
|
+
);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// src/index.ts
|
|
577
|
+
var HELP = `${bold("layro")} ${dim(VERSION)}: publish React components to Layro
|
|
578
|
+
|
|
579
|
+
Usage
|
|
580
|
+
layro login Sign in with GitHub in your browser
|
|
581
|
+
layro whoami Who you're signed in as
|
|
582
|
+
layro publish <files\u2026> Send a new component, or a new version of yours
|
|
583
|
+
layro status [component] Where your submissions stand (component: slug or author/slug)
|
|
584
|
+
layro add <author/slug> How to install a component (shadcn does it)
|
|
585
|
+
layro logout Sign out on this computer
|
|
586
|
+
|
|
587
|
+
Publish options
|
|
588
|
+
--name <name> Defaults to the first file's name
|
|
589
|
+
--description <text> 20 to 200 characters (new components)
|
|
590
|
+
--category <slug> See https://layro.dev/components (new components)
|
|
591
|
+
--license <id> MIT, Apache-2.0, ISC or BSD-3-Clause (default MIT)
|
|
592
|
+
--version <x.y.z> New versions: defaults to a patch bump
|
|
593
|
+
--notes <text> New versions: what changed
|
|
594
|
+
--dark-only It only works on a dark page
|
|
595
|
+
--preview <plan.json> How to film its preview (see the docs)
|
|
596
|
+
-y, --yes Don't ask; send what's given (for CI)
|
|
597
|
+
|
|
598
|
+
Login options
|
|
599
|
+
--no-browser Print the sign-in link instead of opening it
|
|
600
|
+
|
|
601
|
+
Environment
|
|
602
|
+
LAYRO_TOKEN An access token to use instead of signing in
|
|
603
|
+
LAYRO_URL Another Layro server (default https://layro.dev)
|
|
604
|
+
|
|
605
|
+
Docs: https://layro.dev/docs/cli`;
|
|
606
|
+
var server = (process.env.LAYRO_URL || "https://layro.dev").replace(
|
|
607
|
+
/\/+$/,
|
|
608
|
+
""
|
|
609
|
+
);
|
|
610
|
+
async function whoami() {
|
|
611
|
+
const me = await api(server, "/me");
|
|
612
|
+
say(`@${me.handle} ${dim(`on ${server}`)}`);
|
|
613
|
+
say(
|
|
614
|
+
dim(
|
|
615
|
+
`Submissions left today: ${me.submissions.left} of ${me.submissions.limit}`
|
|
616
|
+
)
|
|
617
|
+
);
|
|
618
|
+
return me;
|
|
619
|
+
}
|
|
620
|
+
async function main(argv) {
|
|
621
|
+
const [command, ...rest] = argv;
|
|
622
|
+
if (!command || command === "help" || command === "--help" || command === "-h")
|
|
623
|
+
return say(HELP);
|
|
624
|
+
if (command === "--version" || command === "-v") return say(VERSION);
|
|
625
|
+
switch (command) {
|
|
626
|
+
case "login": {
|
|
627
|
+
const { values } = parseArgs({
|
|
628
|
+
args: rest,
|
|
629
|
+
options: { "no-browser": { type: "boolean" } }
|
|
630
|
+
});
|
|
631
|
+
const session = await login(server, { browser: !values["no-browser"] });
|
|
632
|
+
const me = await api(server, "/me");
|
|
633
|
+
await setSession(server, { ...session, handle: me.handle });
|
|
634
|
+
say(green(`\u2713 Signed in as @${me.handle}.`));
|
|
635
|
+
say(dim(`Tokens saved in ${credentialsPath()}`));
|
|
636
|
+
return;
|
|
637
|
+
}
|
|
638
|
+
case "logout": {
|
|
639
|
+
const out = await logout(server);
|
|
640
|
+
say(out ? "Signed out." : "You weren't signed in.");
|
|
641
|
+
if (process.env.LAYRO_TOKEN)
|
|
642
|
+
say(dim("LAYRO_TOKEN is still set in this shell."));
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
case "whoami": {
|
|
646
|
+
if (!process.env.LAYRO_TOKEN && !(await getSession(server))?.accessToken)
|
|
647
|
+
throw new CliError("Not signed in. Run `npx layro-cli login`.");
|
|
648
|
+
await whoami();
|
|
649
|
+
return;
|
|
650
|
+
}
|
|
651
|
+
case "publish": {
|
|
652
|
+
const { values, positionals } = parseArgs({
|
|
653
|
+
args: rest,
|
|
654
|
+
allowPositionals: true,
|
|
655
|
+
options: {
|
|
656
|
+
name: { type: "string" },
|
|
657
|
+
description: { type: "string" },
|
|
658
|
+
category: { type: "string" },
|
|
659
|
+
license: { type: "string" },
|
|
660
|
+
version: { type: "string" },
|
|
661
|
+
notes: { type: "string" },
|
|
662
|
+
preview: { type: "string" },
|
|
663
|
+
"dark-only": { type: "boolean" },
|
|
664
|
+
yes: { type: "boolean", short: "y" }
|
|
665
|
+
}
|
|
666
|
+
});
|
|
667
|
+
const { "dark-only": darkOnly, ...flags } = values;
|
|
668
|
+
await publish(server, positionals, {
|
|
669
|
+
...flags,
|
|
670
|
+
darkOnly
|
|
671
|
+
});
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
case "status": {
|
|
675
|
+
const { positionals } = parseArgs({ args: rest, allowPositionals: true });
|
|
676
|
+
await status(server, positionals[0]);
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
case "add": {
|
|
680
|
+
const id = rest[0] ?? "<author>/<slug>";
|
|
681
|
+
say("Layro components install with the shadcn CLI:");
|
|
682
|
+
say(` npx shadcn@latest add ${server}/r/${id}.json`);
|
|
683
|
+
say(dim("Or add Layro as a registry in components.json, then:"));
|
|
684
|
+
say(dim(` npx shadcn@latest add @layro/${id}`));
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
687
|
+
default:
|
|
688
|
+
throw new CliError(`Unknown command "${command}". Run layro --help.`);
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
process.stdout.on("error", (error) => {
|
|
692
|
+
if (error.code === "EPIPE") process.exit(0);
|
|
693
|
+
throw error;
|
|
694
|
+
});
|
|
695
|
+
main(process.argv.slice(2)).catch((error) => {
|
|
696
|
+
if (error instanceof CliError) {
|
|
697
|
+
warn(red(error.message));
|
|
698
|
+
} else if (error?.code?.startsWith("ERR_PARSE_ARGS")) {
|
|
699
|
+
warn(red(error.message));
|
|
700
|
+
} else {
|
|
701
|
+
warn(red(`Unexpected error: ${error?.stack ?? error}`));
|
|
702
|
+
}
|
|
703
|
+
process.exit(1);
|
|
704
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,37 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "layro-cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Publish React components to 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
|
+
}
|