blodemd 0.0.3 → 0.0.5

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/dist/cli.mjs CHANGED
@@ -1,12 +1,575 @@
1
1
  #!/usr/bin/env node
2
- import { spawnSync } from "node:child_process";
3
- import fs from "node:fs/promises";
4
- import path from "node:path";
5
- import { styleText } from "node:util";
6
- import { intro, log, outro, spinner } from "@clack/prompts";
2
+ import { spawn, spawnSync } from "node:child_process";
3
+ import fs, { mkdir, readFile, rm, writeFile } from "node:fs/promises";
4
+ import path, { join } from "node:path";
5
+ import { confirm, intro, isCancel, log, password, spinner } from "@clack/prompts";
6
+ import chalk from "chalk";
7
7
  import { Command } from "commander";
8
+ import open from "open";
9
+ import { homedir } from "node:os";
10
+ import { once } from "node:events";
11
+ import { setTimeout as setTimeout$1 } from "node:timers/promises";
12
+ import { fileURLToPath } from "node:url";
13
+ import { createFsSource, loadSiteConfig } from "@repo/previewing";
14
+ import { watch } from "chokidar";
15
+ import { createServer } from "node:http";
16
+ import { createHash, randomBytes } from "node:crypto";
17
+ //#region src/constants.ts
18
+ const CLI_NAME = "blodemd";
19
+ const OAUTH_CLIENT_ID = "6b5f9860-fe96-4a83-b1ad-266260523c91";
20
+ const DEFAULT_OAUTH_CALLBACK_PORT = 8787;
21
+ const DEFAULT_OAUTH_CALLBACK_PATH = "/auth/callback";
22
+ const getDefaultConfigBaseDir = () => {
23
+ if (process.platform === "win32") return process.env.APPDATA ?? join(homedir(), "AppData", "Roaming");
24
+ return process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config");
25
+ };
26
+ const CONFIG_DIR = join(getDefaultConfigBaseDir(), CLI_NAME);
27
+ const CREDENTIALS_FILE = join(CONFIG_DIR, "credentials.json");
28
+ //#endregion
29
+ //#region src/jwt.ts
30
+ const parseJwtBase64Url = (input) => {
31
+ const normalized = input.replaceAll("-", "+").replaceAll("_", "/");
32
+ const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "=");
33
+ return Buffer.from(padded, "base64").toString("utf8");
34
+ };
35
+ const parseJwtClaims = (token) => {
36
+ const payloadPart = token.split(".").at(1);
37
+ if (!payloadPart) return null;
38
+ try {
39
+ const payload = parseJwtBase64Url(payloadPart);
40
+ const parsed = JSON.parse(payload);
41
+ if (typeof parsed !== "object" || parsed === null) return null;
42
+ const claims = parsed;
43
+ return {
44
+ email: typeof claims.email === "string" ? claims.email : void 0,
45
+ exp: typeof claims.exp === "number" ? claims.exp : void 0,
46
+ sub: typeof claims.sub === "string" ? claims.sub : void 0
47
+ };
48
+ } catch {
49
+ return null;
50
+ }
51
+ };
52
+ //#endregion
53
+ //#region src/errors.ts
54
+ const EXIT_CODES = {
55
+ AUTH_REQUIRED: 4,
56
+ CANCELLED: 2,
57
+ ERROR: 1,
58
+ NETWORK: 5,
59
+ SUCCESS: 0,
60
+ VALIDATION: 3
61
+ };
62
+ var CliError = class extends Error {
63
+ exitCode;
64
+ hint;
65
+ constructor(message, exitCode = EXIT_CODES.ERROR, hint) {
66
+ super(message);
67
+ this.name = "CliError";
68
+ this.exitCode = exitCode;
69
+ this.hint = hint ?? null;
70
+ }
71
+ };
72
+ const toCliError = (error) => {
73
+ if (error instanceof CliError) return error;
74
+ if (error instanceof Error) {
75
+ if (error instanceof TypeError && error.message.includes("fetch")) return new CliError("Cannot connect to Blode.md API.", EXIT_CODES.NETWORK, "Check your internet connection and API URL configuration.");
76
+ if (error.name === "TimeoutError" || error.name === "AbortError") return new CliError("Request timed out.", EXIT_CODES.NETWORK, "The API may be unavailable. Try again later.");
77
+ return new CliError(error.message, EXIT_CODES.ERROR);
78
+ }
79
+ return new CliError("Unknown error", EXIT_CODES.ERROR);
80
+ };
81
+ //#endregion
82
+ //#region src/oauth-token.ts
83
+ const postTokenRequest = async (url, body) => {
84
+ const response = await fetch(url, {
85
+ body: body.toString(),
86
+ headers: { "content-type": "application/x-www-form-urlencoded" },
87
+ method: "POST"
88
+ });
89
+ if (!response.ok) {
90
+ const text = await response.text().catch(() => "");
91
+ throw new CliError(`OAuth token request failed (${response.status}): ${text}`, EXIT_CODES.AUTH_REQUIRED);
92
+ }
93
+ return await response.json();
94
+ };
95
+ const exchangeAuthorizationCode = (config, code, codeVerifier, redirectUri) => {
96
+ const body = new URLSearchParams({
97
+ client_id: config.clientId,
98
+ code,
99
+ code_verifier: codeVerifier,
100
+ grant_type: "authorization_code",
101
+ redirect_uri: redirectUri
102
+ });
103
+ return postTokenRequest(config.tokenUrl, body);
104
+ };
105
+ const refreshAccessToken = (config, refreshToken) => {
106
+ const body = new URLSearchParams({
107
+ client_id: config.clientId,
108
+ grant_type: "refresh_token",
109
+ refresh_token: refreshToken
110
+ });
111
+ return postTokenRequest(config.tokenUrl, body);
112
+ };
113
+ //#endregion
114
+ //#region src/storage.ts
115
+ const isRecord = (value) => typeof value === "object" && value !== null;
116
+ const parseStoredAuthSession = (value) => {
117
+ if (!isRecord(value)) return null;
118
+ if (typeof value.accessToken !== "string") return null;
119
+ if (value.refreshToken !== null && typeof value.refreshToken !== "string") return null;
120
+ if (value.expiresAt !== null && typeof value.expiresAt !== "string") return null;
121
+ const { user } = value;
122
+ if (user !== null && (!isRecord(user) || typeof user.id !== "string" || user.email !== null && typeof user.email !== "string")) return null;
123
+ if (typeof value.createdAt !== "string") return null;
124
+ const parsedUser = user === null || !isRecord(user) ? null : {
125
+ email: user.email ?? null,
126
+ id: user.id
127
+ };
128
+ return {
129
+ accessToken: value.accessToken,
130
+ createdAt: value.createdAt,
131
+ expiresAt: value.expiresAt ?? null,
132
+ refreshToken: value.refreshToken ?? null,
133
+ user: parsedUser
134
+ };
135
+ };
136
+ const parseApiKeyCredentials = (value) => {
137
+ if (!isRecord(value)) return null;
138
+ if (typeof value.apiKey !== "string") return null;
139
+ return {
140
+ apiKey: value.apiKey,
141
+ type: "api-key"
142
+ };
143
+ };
144
+ const readAuthFile = async () => {
145
+ try {
146
+ const raw = await readFile(CREDENTIALS_FILE, "utf8");
147
+ const parsed = JSON.parse(raw);
148
+ if (!isRecord(parsed) || parsed.version !== 1) throw new CliError(`Invalid credentials format in ${CREDENTIALS_FILE}`, EXIT_CODES.ERROR);
149
+ return {
150
+ apiKey: parseApiKeyCredentials(parsed.apiKey) ?? void 0,
151
+ session: parseStoredAuthSession(parsed.session) ?? void 0,
152
+ version: 1
153
+ };
154
+ } catch (error) {
155
+ if (isRecord(error) && error.code === "ENOENT") return null;
156
+ if (error instanceof CliError) throw error;
157
+ return null;
158
+ }
159
+ };
160
+ const writeAuthFile = async (data) => {
161
+ await mkdir(CONFIG_DIR, {
162
+ mode: 448,
163
+ recursive: true
164
+ });
165
+ await writeFile(CREDENTIALS_FILE, `${JSON.stringify(data, null, 2)}\n`, {
166
+ encoding: "utf8",
167
+ mode: 384
168
+ });
169
+ };
170
+ const writeStoredAuthSession = async (session) => {
171
+ await writeAuthFile({
172
+ session,
173
+ version: 1
174
+ });
175
+ };
176
+ const writeStoredApiKey = async (apiKey) => {
177
+ await writeAuthFile({
178
+ apiKey,
179
+ version: 1
180
+ });
181
+ };
182
+ const clearStoredCredentials = async () => {
183
+ await rm(CREDENTIALS_FILE, { force: true });
184
+ };
185
+ //#endregion
186
+ //#region src/supabase.ts
187
+ const resolveSupabaseConfig = () => {
188
+ return { url: process.env.SUPABASE_URL ?? process.env.NEXT_PUBLIC_SUPABASE_URL ?? "https://bwnxwgkgyklzzmpbzuoz.supabase.co" };
189
+ };
190
+ const buildOAuthUrls = (config) => ({
191
+ authorizeUrl: `${config.url}/auth/v1/oauth/authorize`,
192
+ tokenUrl: `${config.url}/auth/v1/oauth/token`
193
+ });
194
+ const tokenResponseToStoredSession = (response) => {
195
+ const claims = parseJwtClaims(response.access_token);
196
+ let expiresAt = null;
197
+ if (typeof claims?.exp === "number") expiresAt = (/* @__PURE__ */ new Date(claims.exp * 1e3)).toISOString();
198
+ else if (response.expires_in > 0) expiresAt = new Date(Date.now() + response.expires_in * 1e3).toISOString();
199
+ return {
200
+ accessToken: response.access_token,
201
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
202
+ expiresAt,
203
+ refreshToken: response.refresh_token ?? null,
204
+ user: claims?.sub || claims?.email ? {
205
+ email: claims.email ?? null,
206
+ id: claims.sub ?? "unknown"
207
+ } : null
208
+ };
209
+ };
210
+ //#endregion
211
+ //#region src/auth-session.ts
212
+ const expiresInMs = (session) => {
213
+ if (!session.expiresAt) return null;
214
+ const expiresAtMs = Date.parse(session.expiresAt);
215
+ if (Number.isNaN(expiresAtMs)) return null;
216
+ return expiresAtMs - Date.now();
217
+ };
218
+ const isExpired = (session) => {
219
+ const ms = expiresInMs(session);
220
+ return ms !== null && ms <= 0;
221
+ };
222
+ const shouldRefresh = (session) => {
223
+ const ms = expiresInMs(session);
224
+ return ms !== null && ms <= 6e4;
225
+ };
226
+ const tokenFromRaw = (token, source) => {
227
+ const claims = parseJwtClaims(token);
228
+ return {
229
+ expiresAt: typeof claims?.exp === "number" ? (/* @__PURE__ */ new Date(claims.exp * 1e3)).toISOString() : null,
230
+ source,
231
+ token,
232
+ user: claims?.sub || claims?.email ? {
233
+ email: claims.email ?? null,
234
+ id: claims.sub ?? "unknown"
235
+ } : null
236
+ };
237
+ };
238
+ const sessionToResolvedToken = (session) => ({
239
+ expiresAt: session.expiresAt,
240
+ source: "stored",
241
+ token: session.accessToken,
242
+ user: session.user
243
+ });
244
+ const resolveAuthToken = async (optApiKey) => {
245
+ const envToken = (optApiKey ?? process.env["BLODEMD_API_KEY"])?.trim();
246
+ if (envToken) return tokenFromRaw(envToken, optApiKey ? "flag" : "environment");
247
+ const data = await readAuthFile();
248
+ const session = data?.session;
249
+ if (session) {
250
+ if (!(shouldRefresh(session) || isExpired(session))) return sessionToResolvedToken(session);
251
+ if (session.refreshToken) try {
252
+ const { tokenUrl } = buildOAuthUrls(resolveSupabaseConfig());
253
+ const updatedSession = tokenResponseToStoredSession(await refreshAccessToken({
254
+ clientId: OAUTH_CLIENT_ID,
255
+ tokenUrl
256
+ }, session.refreshToken));
257
+ await writeStoredAuthSession(updatedSession);
258
+ return sessionToResolvedToken(updatedSession);
259
+ } catch {}
260
+ if (isExpired(session)) {
261
+ await clearStoredCredentials();
262
+ return null;
263
+ }
264
+ return sessionToResolvedToken(session);
265
+ }
266
+ if (data?.apiKey) return {
267
+ expiresAt: null,
268
+ source: "stored",
269
+ token: data.apiKey.apiKey,
270
+ user: null
271
+ };
272
+ return null;
273
+ };
274
+ const resolveTokenStatus = (token) => {
275
+ if (!token.expiresAt) return {
276
+ expired: false,
277
+ expiresInSeconds: null
278
+ };
279
+ const expiresAtMs = Date.parse(token.expiresAt);
280
+ if (Number.isNaN(expiresAtMs)) return {
281
+ expired: false,
282
+ expiresInSeconds: null
283
+ };
284
+ const expiresInSeconds = Math.floor((expiresAtMs - Date.now()) / 1e3);
285
+ return {
286
+ expired: expiresInSeconds <= 0,
287
+ expiresInSeconds
288
+ };
289
+ };
290
+ //#endregion
291
+ //#region src/dev/resolve-root.ts
292
+ const CONFIG_FILE$1 = "docs.json";
293
+ const fileExists$2 = async (filePath) => {
294
+ try {
295
+ await fs.access(filePath);
296
+ return true;
297
+ } catch {
298
+ return false;
299
+ }
300
+ };
301
+ const resolveDocsRoot$1 = async (dir) => {
302
+ if (dir) return path.resolve(process.cwd(), dir);
303
+ const candidates = [
304
+ process.cwd(),
305
+ path.join(process.cwd(), "docs"),
306
+ path.join(process.cwd(), "apps/docs")
307
+ ];
308
+ for (const candidate of candidates) if (await fileExists$2(path.join(candidate, CONFIG_FILE$1))) return candidate;
309
+ return process.cwd();
310
+ };
311
+ const validateDocsRoot = async (root) => {
312
+ const result = await loadSiteConfig(createFsSource(root));
313
+ if (!result.ok) throw new CliError(result.errors.join("\n"), EXIT_CODES.VALIDATION, `Make sure ${CONFIG_FILE$1} exists and is valid JSON.`);
314
+ return result;
315
+ };
316
+ //#endregion
317
+ //#region src/dev/watcher.ts
318
+ const INVALIDATE_ENDPOINT = "/blodemd-dev/invalidate";
319
+ const WATCH_DEBOUNCE_MS = 100;
320
+ const normalizeRelativePath$1 = (root, filePath) => path.relative(root, filePath).split(path.sep).join("/");
321
+ const isDirectoryEvent = (event) => event === "addDir" || event === "unlinkDir";
322
+ const createDevWatcher = ({ port, root }) => {
323
+ const watcher = watch(root, {
324
+ ignoreInitial: true,
325
+ ignored: [
326
+ "**/.git/**",
327
+ "**/.next/**",
328
+ "**/dist/**",
329
+ "**/node_modules/**"
330
+ ]
331
+ });
332
+ let flushTimer = null;
333
+ let pendingKind = "content";
334
+ const pendingPaths = /* @__PURE__ */ new Set();
335
+ const flush = async () => {
336
+ flushTimer = null;
337
+ const paths = [...pendingPaths];
338
+ const kind = pendingKind;
339
+ pendingPaths.clear();
340
+ pendingKind = "content";
341
+ if (!paths.length) return;
342
+ try {
343
+ const response = await fetch(`http://127.0.0.1:${port}${INVALIDATE_ENDPOINT}`, {
344
+ body: JSON.stringify({
345
+ kind,
346
+ paths
347
+ }),
348
+ headers: { "Content-Type": "application/json" },
349
+ method: "POST"
350
+ });
351
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
352
+ } catch (error) {
353
+ log.error(`Failed to invalidate preview cache: ${error instanceof Error ? error.message : "unknown error"}`);
354
+ }
355
+ };
356
+ watcher.on("all", (event, changedPath) => {
357
+ if (isDirectoryEvent(event)) return;
358
+ const relativePath = normalizeRelativePath$1(root, changedPath);
359
+ pendingPaths.add(relativePath);
360
+ if (path.basename(changedPath) === "docs.json") pendingKind = "config";
361
+ if (flushTimer) clearTimeout(flushTimer);
362
+ flushTimer = setTimeout(() => {
363
+ flush();
364
+ }, WATCH_DEBOUNCE_MS);
365
+ });
366
+ return { async close() {
367
+ if (flushTimer) {
368
+ clearTimeout(flushTimer);
369
+ await flush();
370
+ }
371
+ await watcher.close();
372
+ } };
373
+ };
374
+ //#endregion
375
+ //#region src/dev/command.ts
376
+ const DEV_READY_ENDPOINT = "/blodemd-dev/version";
377
+ const DEV_READY_TIMEOUT_MS = 45e3;
378
+ const parsePositiveInteger$1 = (value, label) => {
379
+ const parsed = Number.parseInt(value, 10);
380
+ if (!Number.isInteger(parsed) || parsed <= 0) throw new CliError(`${label} must be a positive integer.`, EXIT_CODES.VALIDATION);
381
+ return parsed;
382
+ };
383
+ const fileExists$1 = async (filePath) => {
384
+ try {
385
+ await fs.access(filePath);
386
+ return true;
387
+ } catch {
388
+ return false;
389
+ }
390
+ };
391
+ const findMonorepoRoot = async (start) => {
392
+ let current = start;
393
+ while (true) {
394
+ const packageJsonPath = path.join(current, "package.json");
395
+ if (await fileExists$1(packageJsonPath)) {
396
+ const raw = await fs.readFile(packageJsonPath, "utf8");
397
+ const workspaces = JSON.parse(raw).workspaces ?? [];
398
+ if (workspaces.includes("apps/*") && workspaces.includes("packages/*")) return current;
399
+ }
400
+ const parent = path.dirname(current);
401
+ if (parent === current) break;
402
+ current = parent;
403
+ }
404
+ throw new CliError("Could not locate the blodemd monorepo root.", EXIT_CODES.ERROR, "The monorepo-only dev server must be run from this repository checkout.");
405
+ };
406
+ const waitForServer = async ({ child, port }) => {
407
+ const url = `http://localhost:${port}${DEV_READY_ENDPOINT}`;
408
+ const startedAt = Date.now();
409
+ while (Date.now() - startedAt < DEV_READY_TIMEOUT_MS) {
410
+ if (child.exitCode !== null) throw new CliError("The local dev server exited before it became ready.", EXIT_CODES.ERROR);
411
+ try {
412
+ if ((await fetch(url, {
413
+ cache: "no-store",
414
+ headers: { accept: "application/json" }
415
+ })).ok) return;
416
+ } catch {}
417
+ await setTimeout$1(500);
418
+ }
419
+ throw new CliError("Timed out waiting for the local dev server to start.", EXIT_CODES.ERROR);
420
+ };
421
+ const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm";
422
+ const devCommand = async ({ dir, openBrowser, port: portValue }) => {
423
+ intro(chalk.bold("blodemd dev"));
424
+ try {
425
+ const port = parsePositiveInteger$1(portValue, "Port");
426
+ const root = await resolveDocsRoot$1(dir);
427
+ await validateDocsRoot(root);
428
+ const cliFilePath = fileURLToPath(import.meta.url);
429
+ const repoRoot = await findMonorepoRoot(path.dirname(cliFilePath));
430
+ const localUrl = `http://localhost:${port}`;
431
+ log.info(`Docs root: ${chalk.cyan(root)}`);
432
+ const child = spawn(npmCommand, [
433
+ "run",
434
+ "dev",
435
+ "--workspace=dev-server"
436
+ ], {
437
+ cwd: repoRoot,
438
+ env: {
439
+ ...process.env,
440
+ DOCS_ROOT: root,
441
+ PORT: String(port)
442
+ },
443
+ stdio: "inherit"
444
+ });
445
+ let watcher = null;
446
+ let shuttingDown = false;
447
+ const closeAll = async () => {
448
+ if (shuttingDown) return;
449
+ shuttingDown = true;
450
+ if (watcher) {
451
+ await watcher.close();
452
+ watcher = null;
453
+ }
454
+ if (child.exitCode === null && !child.killed) child.kill("SIGTERM");
455
+ };
456
+ const onSignal = async () => {
457
+ await closeAll();
458
+ };
459
+ process.once("SIGINT", onSignal);
460
+ process.once("SIGTERM", onSignal);
461
+ try {
462
+ await waitForServer({
463
+ child,
464
+ port
465
+ });
466
+ watcher = await createDevWatcher({
467
+ port,
468
+ root
469
+ });
470
+ log.success(`Dev server running at ${chalk.cyan(localUrl)}`);
471
+ if (openBrowser) await open(localUrl);
472
+ const [code, signal] = await once(child, "exit");
473
+ await closeAll();
474
+ process.removeListener("SIGINT", onSignal);
475
+ process.removeListener("SIGTERM", onSignal);
476
+ if (shuttingDown || signal === "SIGINT" || signal === "SIGTERM") return;
477
+ if (code !== 0) throw new CliError(`The local dev server exited with code ${code ?? "unknown"}.`, EXIT_CODES.ERROR);
478
+ } catch (error) {
479
+ await closeAll();
480
+ process.removeListener("SIGINT", onSignal);
481
+ process.removeListener("SIGTERM", onSignal);
482
+ throw error;
483
+ }
484
+ } catch (error) {
485
+ const cliError = toCliError(error);
486
+ log.error(cliError.message);
487
+ if (cliError.hint) log.info(cliError.hint);
488
+ process.exitCode = cliError.exitCode;
489
+ }
490
+ };
491
+ //#endregion
492
+ //#region src/oauth-callback.ts
493
+ const SUCCESS_HTML = "<!doctype html><html><head><meta charset=\"utf-8\"/><title>Blode.md CLI</title></head><body><h2>Logged in! You can close this tab.</h2></body></html>";
494
+ const escapeHtml = (text) => text.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("\"", "&quot;");
495
+ const errorHtml = (message) => `<!doctype html><html><head><meta charset="utf-8"/><title>Blode.md CLI</title></head><body><h2>Login failed</h2><p>${escapeHtml(message)}</p></body></html>`;
496
+ const waitForOAuthCode = (options) => {
497
+ const host = options.redirectUrl.hostname;
498
+ const port = Number(options.redirectUrl.port);
499
+ const { pathname } = options.redirectUrl;
500
+ if (!Number.isInteger(port) || port <= 0) return Promise.reject(new CliError("OAuth redirect URL requires an explicit port", EXIT_CODES.ERROR));
501
+ return new Promise((resolve, reject) => {
502
+ let settled = false;
503
+ const sockets = /* @__PURE__ */ new Set();
504
+ const settle = (ok, value) => {
505
+ if (settled) return;
506
+ settled = true;
507
+ clearTimeout(timer);
508
+ httpServer.close(() => {
509
+ if (ok) resolve(value);
510
+ else reject(value);
511
+ });
512
+ for (const socket of sockets) socket.destroy();
513
+ };
514
+ const httpServer = createServer((request, response) => {
515
+ if (!request.url) {
516
+ response.writeHead(400, { "content-type": "text/html; charset=utf-8" });
517
+ response.end(errorHtml("Missing request URL"));
518
+ settle(false, new CliError("OAuth callback is missing a request URL", EXIT_CODES.ERROR));
519
+ return;
520
+ }
521
+ const url = new URL(request.url, options.redirectUrl.origin);
522
+ if (url.pathname !== pathname) {
523
+ response.writeHead(404, { "content-type": "text/html; charset=utf-8" });
524
+ response.end(errorHtml("Invalid callback path"));
525
+ return;
526
+ }
527
+ const providerError = url.searchParams.get("error");
528
+ if (providerError) {
529
+ const description = url.searchParams.get("error_description") ?? providerError;
530
+ response.writeHead(400, { "content-type": "text/html; charset=utf-8" });
531
+ response.end(errorHtml(description));
532
+ settle(false, new CliError(`OAuth provider returned an error: ${description}`, EXIT_CODES.ERROR));
533
+ return;
534
+ }
535
+ if (url.searchParams.get("state") !== options.expectedState) {
536
+ response.writeHead(400, { "content-type": "text/html; charset=utf-8" });
537
+ response.end(errorHtml("State verification failed"));
538
+ settle(false, new CliError("OAuth state verification failed", EXIT_CODES.ERROR));
539
+ return;
540
+ }
541
+ const code = url.searchParams.get("code");
542
+ if (!code) {
543
+ response.writeHead(400, { "content-type": "text/html; charset=utf-8" });
544
+ response.end(errorHtml("Authorization code was missing"));
545
+ settle(false, new CliError("OAuth callback is missing an authorization code", EXIT_CODES.ERROR));
546
+ return;
547
+ }
548
+ response.writeHead(200, { "content-type": "text/html; charset=utf-8" });
549
+ response.end(SUCCESS_HTML);
550
+ settle(true, code);
551
+ });
552
+ httpServer.on("connection", (socket) => {
553
+ sockets.add(socket);
554
+ socket.once("close", () => sockets.delete(socket));
555
+ });
556
+ httpServer.on("error", (error) => {
557
+ settle(false, new CliError(`Failed to start callback server on ${host}:${port}: ${error.message}`, EXIT_CODES.ERROR));
558
+ });
559
+ const timer = setTimeout(() => {
560
+ settle(false, new CliError("Login timed out. Please try again.", EXIT_CODES.CANCELLED));
561
+ }, options.timeoutMs);
562
+ httpServer.listen(port, host);
563
+ });
564
+ };
565
+ //#endregion
566
+ //#region src/pkce.ts
567
+ const createOAuthState = () => randomBytes(24).toString("hex");
568
+ const createCodeVerifier = () => randomBytes(64).toString("base64url");
569
+ const createCodeChallenge = (verifier) => createHash("sha256").update(verifier).digest().toString("base64url");
570
+ //#endregion
8
571
  //#region src/cli.ts
9
- const CONTENT_CONFIG_FILE = "docs.json";
572
+ const CONFIG_FILE = "docs.json";
10
573
  const TEXT_CONTENT_TYPES = {
11
574
  ".css": "text/css; charset=utf-8",
12
575
  ".html": "text/html; charset=utf-8",
@@ -20,22 +583,34 @@ const TEXT_CONTENT_TYPES = {
20
583
  ".yml": "application/yaml; charset=utf-8"
21
584
  };
22
585
  const ensureFile = async (filePath, content) => {
586
+ try {
587
+ await fs.writeFile(filePath, content, { flag: "wx" });
588
+ } catch {}
589
+ };
590
+ const fileExists = async (filePath) => {
23
591
  try {
24
592
  await fs.access(filePath);
593
+ return true;
25
594
  } catch {
26
- await fs.writeFile(filePath, content);
595
+ return false;
27
596
  }
28
597
  };
29
- const isMissingFileError = (error) => error instanceof Error && "code" in error && error.code === "ENOENT";
30
- const validateConfigFile = async (root) => {
31
- try {
32
- const raw = await fs.readFile(path.join(root, CONTENT_CONFIG_FILE), "utf8");
33
- JSON.parse(raw);
34
- return CONTENT_CONFIG_FILE;
35
- } catch (error) {
36
- if (isMissingFileError(error)) throw new Error(`${CONTENT_CONFIG_FILE} not found.`, { cause: error });
37
- throw error;
38
- }
598
+ const readConfig = async (root) => {
599
+ const raw = await fs.readFile(path.join(root, CONFIG_FILE), "utf8");
600
+ return {
601
+ name: JSON.parse(raw).name,
602
+ raw
603
+ };
604
+ };
605
+ const resolveDocsRoot = async (dir) => {
606
+ if (dir) return path.resolve(process.cwd(), dir);
607
+ const candidates = [
608
+ process.cwd(),
609
+ path.join(process.cwd(), "docs"),
610
+ path.join(process.cwd(), "apps/docs")
611
+ ];
612
+ for (const candidate of candidates) if (await fileExists(path.join(candidate, CONFIG_FILE))) return candidate;
613
+ return process.cwd();
39
614
  };
40
615
  const readGitValue = (gitArgs) => {
41
616
  const result = spawnSync("git", gitArgs, {
@@ -84,17 +659,197 @@ const requestJson = async (url, init, message) => {
84
659
  }
85
660
  return data;
86
661
  };
662
+ const parsePositiveInteger = (value, label) => {
663
+ const parsed = Number.parseInt(value, 10);
664
+ if (!Number.isInteger(parsed) || parsed <= 0) throw new CliError(`${label} must be a positive integer.`, EXIT_CODES.VALIDATION);
665
+ return parsed;
666
+ };
667
+ const reportCommandError = (prefix, error) => {
668
+ const cliError = toCliError(error);
669
+ log.error(`${prefix}: ${cliError.message}`);
670
+ if (cliError.hint) log.info(cliError.hint);
671
+ log.info("Failed");
672
+ process.exitCode = cliError.exitCode;
673
+ };
674
+ const fetchUserEmail = async (apiUrl, token) => {
675
+ try {
676
+ return (await requestJson(`${apiUrl}/auth/me`, { headers: { Authorization: `Bearer ${token}` } }, "Failed to fetch user info")).email;
677
+ } catch {
678
+ return null;
679
+ }
680
+ };
681
+ const resolvePushConfig = async (config, options) => {
682
+ const project = options.project ?? process.env["BLODEMD_PROJECT"] ?? config.name;
683
+ const apiUrl = options.apiUrl ?? process.env["BLODEMD_API_URL"] ?? "https://api.blode.md";
684
+ const authToken = (await resolveAuthToken(options.apiKey))?.token;
685
+ const branch = options.branch ?? process.env["BLODEMD_BRANCH"] ?? process.env.GITHUB_REF_NAME ?? readGitValue([
686
+ "rev-parse",
687
+ "--abbrev-ref",
688
+ "HEAD"
689
+ ]) ?? "main";
690
+ const commitMessage = options.message ?? process.env["BLODEMD_COMMIT_MESSAGE"] ?? readGitValue([
691
+ "log",
692
+ "-1",
693
+ "--pretty=%s"
694
+ ]);
695
+ if (!project) throw new Error("Missing project slug. Set \"name\" in docs.json, pass --project, or set BLODEMD_PROJECT.");
696
+ if (!authToken) throw new Error("Missing credentials. Run \"blodemd login\", pass --api-key, or set BLODEMD_API_KEY.");
697
+ return {
698
+ apiUrl,
699
+ authToken,
700
+ branch,
701
+ commitMessage,
702
+ project
703
+ };
704
+ };
705
+ const autoCreateProject = async (project, apiUrl, headers) => {
706
+ if (!(await readAuthFile())?.session) throw new Error(`Project "${project}" not found. Create it at blode.md or login with "blodemd login" to auto-create.`);
707
+ const shouldCreate = await confirm({ message: `Project "${project}" doesn't exist. Create it?` });
708
+ if (isCancel(shouldCreate) || !shouldCreate) return false;
709
+ const createResult = await requestJson(new URL("/projects", apiUrl).toString(), {
710
+ body: JSON.stringify({
711
+ name: project,
712
+ slug: project
713
+ }),
714
+ headers,
715
+ method: "POST"
716
+ }, "Failed to create project");
717
+ log.success(`Project ${chalk.cyan(createResult.project.slug)} created`);
718
+ log.info(`API key for CI: ${chalk.dim(createResult.token)}`);
719
+ return true;
720
+ };
721
+ const uploadFiles = async (files, root, apiPath, deploymentId, headers, s) => {
722
+ s.start(`Uploading ${files.length} files`);
723
+ for (const [index, filePath] of files.entries()) {
724
+ const relativePath = normalizeRelativePath(root, filePath);
725
+ const content = await fs.readFile(filePath);
726
+ await requestJson(apiPath(`/${deploymentId}/files`), {
727
+ body: JSON.stringify({
728
+ contentBase64: content.toString("base64"),
729
+ contentType: getContentType(filePath),
730
+ path: relativePath
731
+ }),
732
+ headers,
733
+ method: "POST"
734
+ }, `Failed to upload ${relativePath}`);
735
+ s.message(`Uploading files (${index + 1}/${files.length})`);
736
+ }
737
+ s.stop(`Uploaded ${chalk.cyan(String(files.length))} files`);
738
+ };
87
739
  const program = new Command();
88
- program.name("blode-docs").description("Blode Docs CLI").version("0.0.3");
89
- program.command("init").description("Scaffold a content folder").argument("[dir]", "target directory", "docs").action(async (dir) => {
90
- intro(styleText("bold", "blode-docs init"));
740
+ program.name("blodemd").description("Blode.md CLI").version("0.0.3");
741
+ program.command("login").description("Authenticate with Blode.md").option("--token", "Paste an API key instead of using browser login").option("--port <port>", "Loopback callback port", String(DEFAULT_OAUTH_CALLBACK_PORT)).option("--timeout <seconds>", "OAuth timeout in seconds", String(180)).option("--no-open", "Print URL instead of opening the browser").action(async (options) => {
742
+ intro(chalk.bold("blodemd login"));
743
+ try {
744
+ if (options.token) {
745
+ const apiKey = await password({
746
+ message: "Enter your API key",
747
+ validate: (value) => {
748
+ if (!value) return "API key is required.";
749
+ }
750
+ });
751
+ if (isCancel(apiKey)) {
752
+ log.warn("Cancelled");
753
+ return;
754
+ }
755
+ await writeStoredApiKey({
756
+ apiKey,
757
+ type: "api-key"
758
+ });
759
+ const prefix = apiKey.split(".")[0] ?? apiKey.slice(0, 12);
760
+ log.success(`Authenticated as ${chalk.cyan(prefix)}`);
761
+ log.info("Done");
762
+ return;
763
+ }
764
+ const { authorizeUrl, tokenUrl } = buildOAuthUrls(resolveSupabaseConfig());
765
+ const clientId = OAUTH_CLIENT_ID;
766
+ const port = parsePositiveInteger(options.port, "Port");
767
+ const timeoutSeconds = parsePositiveInteger(options.timeout, "Timeout");
768
+ const redirectUrl = new URL(`http://127.0.0.1:${port}${DEFAULT_OAUTH_CALLBACK_PATH}`);
769
+ const state = createOAuthState();
770
+ const codeVerifier = createCodeVerifier();
771
+ const codeChallenge = createCodeChallenge(codeVerifier);
772
+ const authUrl = new URL(authorizeUrl);
773
+ authUrl.searchParams.set("response_type", "code");
774
+ authUrl.searchParams.set("client_id", clientId);
775
+ authUrl.searchParams.set("redirect_uri", redirectUrl.toString());
776
+ authUrl.searchParams.set("code_challenge", codeChallenge);
777
+ authUrl.searchParams.set("code_challenge_method", "S256");
778
+ authUrl.searchParams.set("state", state);
779
+ authUrl.searchParams.set("scope", "openid email profile");
780
+ const callbackPromise = waitForOAuthCode({
781
+ expectedState: state,
782
+ redirectUrl,
783
+ timeoutMs: timeoutSeconds * 1e3
784
+ });
785
+ if (options.open) {
786
+ log.info("Opening browser for authentication...");
787
+ log.info(`If the browser doesn't open, visit: ${chalk.cyan(authUrl.toString())}`);
788
+ await open(authUrl.toString());
789
+ } else {
790
+ log.info("Open this URL to continue authentication:");
791
+ log.info(chalk.cyan(authUrl.toString()));
792
+ }
793
+ const code = await callbackPromise;
794
+ const storedSession = tokenResponseToStoredSession(await exchangeAuthorizationCode({
795
+ clientId,
796
+ tokenUrl
797
+ }, code, codeVerifier, redirectUrl.toString()));
798
+ await writeStoredAuthSession(storedSession);
799
+ const email = storedSession.user?.email ?? await fetchUserEmail(process.env["BLODEMD_API_URL"] ?? "https://api.blode.md", storedSession.accessToken);
800
+ if (email) log.success(`Logged in as ${chalk.cyan(email)}`);
801
+ else log.success("Logged in successfully.");
802
+ log.info("Done");
803
+ } catch (error) {
804
+ reportCommandError("Login failed", error);
805
+ }
806
+ });
807
+ program.command("logout").description("Remove stored credentials").action(async () => {
808
+ intro(chalk.bold("blodemd logout"));
809
+ try {
810
+ const existing = await readAuthFile();
811
+ await clearStoredCredentials();
812
+ if (existing?.session || existing?.apiKey) log.success("Credentials removed.");
813
+ else log.info("No stored credentials found.");
814
+ log.info("Done");
815
+ } catch (error) {
816
+ reportCommandError("Logout failed", error);
817
+ }
818
+ });
819
+ program.command("whoami").description("Show current authentication").action(async () => {
820
+ try {
821
+ const resolved = await resolveAuthToken();
822
+ if (!resolved) {
823
+ log.warn("Not logged in. Run \"blodemd login\" to authenticate.");
824
+ return;
825
+ }
826
+ if (resolved.source === "environment") {
827
+ log.info("Authenticated via BLODEMD_API_KEY environment variable");
828
+ return;
829
+ }
830
+ if (!resolved.expiresAt && !resolved.user) {
831
+ const prefix = resolved.token.split(".")[0] ?? resolved.token.slice(0, 12);
832
+ log.info(`Logged in with API key ${chalk.cyan(prefix)}`);
833
+ return;
834
+ }
835
+ const status = resolveTokenStatus(resolved);
836
+ const email = resolved.user?.email ?? await fetchUserEmail(process.env["BLODEMD_API_URL"] ?? "https://api.blode.md", resolved.token);
837
+ if (email) log.info(`Logged in as ${chalk.cyan(email)}`);
838
+ else log.info("Logged in (could not fetch user details).");
839
+ if (resolved.expiresAt && status.expired) log.warn("Session has expired. Run \"blodemd login\" to re-authenticate.");
840
+ } catch (error) {
841
+ reportCommandError("Whoami failed", error);
842
+ }
843
+ });
844
+ program.command("init").description("Scaffold a docs folder").argument("[dir]", "target directory", "docs").action(async (dir) => {
845
+ intro(chalk.bold("blodemd init"));
91
846
  try {
92
847
  const root = path.resolve(process.cwd(), dir);
93
848
  await fs.mkdir(root, { recursive: true });
94
- await ensureFile(path.join(root, CONTENT_CONFIG_FILE), `${JSON.stringify({
849
+ await ensureFile(path.join(root, CONFIG_FILE), `${JSON.stringify({
95
850
  $schema: "https://mintlify.com/docs.json",
96
851
  colors: { primary: "#0D9373" },
97
- name: "My Site",
852
+ name: "my-project",
98
853
  navigation: { groups: [{
99
854
  group: "Getting Started",
100
855
  pages: ["index"]
@@ -102,85 +857,69 @@ program.command("init").description("Scaffold a content folder").argument("[dir]
102
857
  theme: "mint"
103
858
  }, null, 2)}\n`);
104
859
  await ensureFile(path.join(root, "index.mdx"), "---\ntitle: Welcome\n---\n\nStart writing your docs here.\n");
105
- log.success(`Docs scaffolded in ${styleText("cyan", root)}`);
106
- outro("Done");
860
+ log.success(`Docs scaffolded in ${chalk.cyan(root)}`);
861
+ log.info(`Set ${chalk.cyan("name")} in docs.json to your project slug.`);
862
+ log.info("Done");
107
863
  } catch (error) {
108
- log.error(`Init failed: ${error instanceof Error ? error.message : String(error)}`);
109
- outro("Failed");
110
- process.exitCode = 1;
864
+ reportCommandError("Init failed", error);
111
865
  }
112
866
  });
113
- program.command("validate").description("Validate docs.json").argument("[dir]", "target directory", "docs").action(async (dir) => {
114
- intro(styleText("bold", "blode-docs validate"));
115
- const root = path.resolve(process.cwd(), dir);
867
+ program.command("validate").description("Validate docs.json").argument("[dir]", "docs directory").action(async (dir) => {
868
+ intro(chalk.bold("blodemd validate"));
116
869
  try {
117
- const configFile = await validateConfigFile(root);
118
- log.success(`${styleText("cyan", configFile)} is valid JSON.`);
119
- outro("Done");
870
+ await readConfig(await resolveDocsRoot(dir));
871
+ log.success(`${chalk.cyan(CONFIG_FILE)} is valid.`);
872
+ log.info("Done");
120
873
  } catch (error) {
121
- log.error(`Validation failed: ${error instanceof Error ? error.message : String(error)}`);
122
- outro("Failed");
123
- process.exitCode = 1;
874
+ reportCommandError("Validation failed", error);
124
875
  }
125
876
  });
126
- program.command("push").description("Publish docs content").argument("[dir]", "target directory", "docs").option("--project <slug>", "project slug (env: BLODE_DOCS_PROJECT)").option("--api-url <url>", "API endpoint URL (env: BLODE_DOCS_API_URL)").option("--api-key <token>", "API authentication token (env: BLODE_DOCS_API_KEY)").option("--branch <name>", "git branch name (env: BLODE_DOCS_BRANCH)").option("--commit-message <message>", "deployment message (env: BLODE_DOCS_COMMIT_MESSAGE)").action(async (dir, options) => {
127
- intro(styleText("bold", "blode-docs push"));
877
+ program.command("push").description("Deploy docs").argument("[dir]", "docs directory").option("--project <slug>", "project slug (env: BLODEMD_PROJECT)").option("--api-url <url>", "API URL (env: BLODEMD_API_URL)").option("--api-key <token>", "API key (env: BLODEMD_API_KEY)").option("--branch <name>", "git branch (env: BLODEMD_BRANCH)").option("--message <msg>", "deploy message (env: BLODEMD_COMMIT_MESSAGE)").action(async (dir, options) => {
878
+ intro(chalk.bold("blodemd push"));
128
879
  const s = spinner();
129
880
  try {
130
- const root = path.resolve(process.cwd(), dir);
881
+ const root = await resolveDocsRoot(dir);
131
882
  s.start("Validating configuration");
132
- await validateConfigFile(root);
883
+ const config = await readConfig(root);
133
884
  s.stop("Configuration valid");
134
- const project = options.project ?? process.env.BLODE_DOCS_PROJECT;
135
- const apiUrl = options.apiUrl ?? process.env.BLODE_DOCS_API_URL ?? process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:4000";
136
- const apiKey = options.apiKey ?? process.env.BLODE_DOCS_API_KEY;
137
- const branch = options.branch ?? process.env.BLODE_DOCS_BRANCH ?? process.env.GITHUB_REF_NAME ?? readGitValue([
138
- "rev-parse",
139
- "--abbrev-ref",
140
- "HEAD"
141
- ]) ?? "main";
142
- const commitMessage = options.commitMessage ?? process.env.BLODE_DOCS_COMMIT_MESSAGE ?? readGitValue([
143
- "log",
144
- "-1",
145
- "--pretty=%s"
146
- ]);
147
- if (!project) throw new Error("Missing project slug. Pass --project or set BLODE_DOCS_PROJECT.");
148
- if (!apiKey) throw new Error("Missing API key. Pass --api-key or set BLODE_DOCS_API_KEY.");
885
+ const { project, apiUrl, authToken, branch, commitMessage } = await resolvePushConfig(config, options);
149
886
  s.start("Collecting files");
150
887
  const files = await collectFiles(root);
151
- if (files.length === 0) throw new Error("No files found to publish.");
152
- s.stop(`Found ${styleText("cyan", String(files.length))} files`);
888
+ if (files.length === 0) throw new Error("No files found to deploy.");
889
+ s.stop(`Found ${chalk.cyan(String(files.length))} files`);
153
890
  const headers = {
154
- Authorization: `Bearer ${apiKey}`,
891
+ Authorization: `Bearer ${authToken}`,
155
892
  "Content-Type": "application/json"
156
893
  };
157
894
  const apiPath = (suffix) => new URL(`/projects/slug/${project}/deployments${suffix}`, apiUrl).toString();
895
+ const createDeploymentBody = JSON.stringify({
896
+ branch,
897
+ commitMessage
898
+ });
158
899
  s.start("Creating deployment");
159
- const deployment = await requestJson(apiPath(""), {
160
- body: JSON.stringify({
161
- branch,
162
- commitMessage
163
- }),
164
- headers,
165
- method: "POST"
166
- }, "Failed to create deployment");
167
- s.stop(`Deployment ${styleText("cyan", deployment.id)} created`);
168
- s.start(`Uploading ${files.length} files`);
169
- for (const [index, filePath] of files.entries()) {
170
- const relativePath = normalizeRelativePath(root, filePath);
171
- const content = await fs.readFile(filePath);
172
- await requestJson(apiPath(`/${deployment.id}/files`), {
173
- body: JSON.stringify({
174
- contentBase64: content.toString("base64"),
175
- contentType: getContentType(filePath),
176
- path: relativePath
177
- }),
900
+ let deployment;
901
+ try {
902
+ deployment = await requestJson(apiPath(""), {
903
+ body: createDeploymentBody,
904
+ headers,
905
+ method: "POST"
906
+ }, "Failed to create deployment");
907
+ } catch (error) {
908
+ if (!(error instanceof Error ? error.message : "").includes("404")) throw error;
909
+ s.stop("Project not found");
910
+ if (!await autoCreateProject(project, apiUrl, headers)) {
911
+ log.info("Cancelled");
912
+ return;
913
+ }
914
+ s.start("Creating deployment");
915
+ deployment = await requestJson(apiPath(""), {
916
+ body: createDeploymentBody,
178
917
  headers,
179
918
  method: "POST"
180
- }, `Failed to upload ${relativePath}`);
181
- s.message(`Uploading files (${index + 1}/${files.length})`);
919
+ }, "Failed to create deployment");
182
920
  }
183
- s.stop(`Uploaded ${styleText("cyan", String(files.length))} files`);
921
+ s.stop(`Deployment ${chalk.cyan(deployment.id)} created`);
922
+ await uploadFiles(files, root, apiPath, deployment.id, headers, s);
184
923
  s.start("Finalizing deployment");
185
924
  const finalized = await requestJson(apiPath(`/${deployment.id}/finalize`), {
186
925
  body: JSON.stringify({ promote: true }),
@@ -188,23 +927,20 @@ program.command("push").description("Publish docs content").argument("[dir]", "t
188
927
  method: "POST"
189
928
  }, "Failed to finalize deployment");
190
929
  s.stop("Deployment finalized");
191
- log.success(`Published deployment ${styleText("cyan", finalized.id)}`);
930
+ log.success(`Published ${chalk.cyan(finalized.id)}`);
192
931
  if (finalized.manifestUrl) log.info(`Manifest: ${finalized.manifestUrl}`);
193
932
  if (typeof finalized.fileCount === "number") log.info(`Files: ${finalized.fileCount}`);
194
- outro("Done");
933
+ log.info("Done");
195
934
  } catch (error) {
196
935
  s.stop("Failed");
197
- log.error(error instanceof Error ? error.message : String(error));
198
- outro("Failed");
199
- process.exitCode = 1;
936
+ reportCommandError("Push failed", error);
200
937
  }
201
938
  });
202
- program.command("dev").description("Start the docs dev server").action(() => {
203
- intro(styleText("bold", "blode-docs dev"));
204
- log.info(`Run ${styleText("cyan", "npm run dev --filter=docs")} from the repo root.`);
205
- log.info(`Then open ${styleText("cyan", "http://localhost:3001")} to view the docs site.`);
206
- outro("Done");
207
- });
939
+ program.command("dev").description("Start the local docs dev server").option("-p, --port <port>", "Port number", "3030").option("-d, --dir <dir>", "Docs directory").option("--no-open", "Don't open browser").action(async (options) => await devCommand({
940
+ dir: options.dir,
941
+ openBrowser: options.open ?? true,
942
+ port: options.port
943
+ }));
208
944
  program.parse();
209
945
  //#endregion
210
946
  export {};