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/README.md +115 -0
- package/dist/cli.mjs +827 -91
- package/dist/cli.mjs.map +1 -1
- package/package.json +8 -4
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 {
|
|
6
|
-
import
|
|
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("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll("\"", """);
|
|
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
|
|
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
|
-
|
|
595
|
+
return false;
|
|
27
596
|
}
|
|
28
597
|
};
|
|
29
|
-
const
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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("
|
|
89
|
-
program.command("
|
|
90
|
-
intro(
|
|
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,
|
|
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: "
|
|
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 ${
|
|
106
|
-
|
|
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
|
-
|
|
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]", "
|
|
114
|
-
intro(
|
|
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
|
-
|
|
118
|
-
log.success(`${
|
|
119
|
-
|
|
870
|
+
await readConfig(await resolveDocsRoot(dir));
|
|
871
|
+
log.success(`${chalk.cyan(CONFIG_FILE)} is valid.`);
|
|
872
|
+
log.info("Done");
|
|
120
873
|
} catch (error) {
|
|
121
|
-
|
|
122
|
-
outro("Failed");
|
|
123
|
-
process.exitCode = 1;
|
|
874
|
+
reportCommandError("Validation failed", error);
|
|
124
875
|
}
|
|
125
876
|
});
|
|
126
|
-
program.command("push").description("
|
|
127
|
-
intro(
|
|
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 =
|
|
881
|
+
const root = await resolveDocsRoot(dir);
|
|
131
882
|
s.start("Validating configuration");
|
|
132
|
-
await
|
|
883
|
+
const config = await readConfig(root);
|
|
133
884
|
s.stop("Configuration valid");
|
|
134
|
-
const 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
|
|
152
|
-
s.stop(`Found ${
|
|
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 ${
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
},
|
|
181
|
-
s.message(`Uploading files (${index + 1}/${files.length})`);
|
|
919
|
+
}, "Failed to create deployment");
|
|
182
920
|
}
|
|
183
|
-
s.stop(`
|
|
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
|
|
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
|
-
|
|
933
|
+
log.info("Done");
|
|
195
934
|
} catch (error) {
|
|
196
935
|
s.stop("Failed");
|
|
197
|
-
|
|
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
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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 {};
|