blodemd 0.0.13 → 0.0.15
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 +1 -1
- package/dev-server/lib/local-runtime.tsx +12 -15
- package/dev-server/package.json +5 -1
- package/dist/cli.mjs +1211 -983
- package/dist/cli.mjs.map +1 -1
- package/docs/app/globals.css +1 -1
- package/docs/components/api/api-playground.tsx +2 -5
- package/docs/components/api/api-reference.tsx +29 -3
- package/docs/components/docs/contextual-menu.tsx +37 -33
- package/docs/components/docs/copy-page-menu.tsx +18 -0
- package/docs/components/docs/doc-header.tsx +4 -1
- package/docs/components/docs/doc-shell.tsx +48 -18
- package/docs/components/docs/doc-sidebar.tsx +131 -89
- package/docs/components/docs/doc-toc.tsx +1 -0
- package/docs/components/docs/mobile-nav.tsx +72 -68
- package/docs/components/docs/sidebar-active-highlight.tsx +34 -0
- package/docs/components/docs/sidebar-scroll-area.tsx +9 -4
- package/docs/components/git/repo-picker.tsx +5 -2
- package/docs/components/mdx/index.tsx +31 -21
- package/docs/components/posthog-provider.tsx +42 -0
- package/docs/components/tenant-analytics.tsx +49 -0
- package/docs/components/third-parties.tsx +4 -1
- package/docs/components/ui/copy-button.tsx +16 -2
- package/docs/components/ui/search-dialog.tsx +431 -0
- package/docs/components/ui/search.tsx +44 -427
- package/docs/components/web-mcp.tsx +115 -0
- package/docs/lib/agent-skills.ts +48 -0
- package/docs/lib/config.ts +4 -5
- package/docs/lib/docs-collection.ts +7 -22
- package/docs/lib/docs-runtime.tsx +160 -10
- package/docs/lib/llms-full.ts +82 -0
- package/docs/lib/marketing-markdown.ts +79 -0
- package/docs/lib/marketing-site.ts +31 -0
- package/docs/lib/mdx.ts +17 -6
- package/docs/lib/routes.ts +19 -3
- package/docs/lib/shiki.ts +4 -0
- package/docs/lib/tenancy.ts +15 -2
- package/docs/lib/tenant-headers.ts +29 -0
- package/docs/lib/tenant-static.ts +73 -99
- package/package.json +8 -5
- package/packages/@repo/common/package.json +1 -1
- package/packages/@repo/contracts/dist/analytics.d.ts +21 -0
- package/packages/@repo/contracts/dist/analytics.d.ts.map +1 -0
- package/packages/@repo/contracts/dist/analytics.js +18 -0
- package/packages/@repo/contracts/dist/deployment.js +1 -1
- package/packages/@repo/contracts/dist/git.js +2 -2
- package/packages/@repo/contracts/dist/ids.d.ts.map +1 -1
- package/packages/@repo/contracts/dist/ids.js +2 -1
- package/packages/@repo/contracts/dist/index.d.ts +1 -0
- package/packages/@repo/contracts/dist/index.d.ts.map +1 -1
- package/packages/@repo/contracts/dist/index.js +1 -0
- package/packages/@repo/contracts/dist/project.d.ts +18 -1
- package/packages/@repo/contracts/dist/project.d.ts.map +1 -1
- package/packages/@repo/contracts/dist/project.js +3 -1
- package/packages/@repo/contracts/dist/tenant.d.ts +36 -0
- package/packages/@repo/contracts/dist/tenant.d.ts.map +1 -1
- package/packages/@repo/contracts/dist/tenant.js +2 -0
- package/packages/@repo/contracts/package.json +1 -1
- package/packages/@repo/contracts/src/analytics.ts +33 -0
- package/packages/@repo/contracts/src/deployment.ts +1 -1
- package/packages/@repo/contracts/src/git.ts +2 -2
- package/packages/@repo/contracts/src/ids.ts +2 -1
- package/packages/@repo/contracts/src/index.ts +1 -0
- package/packages/@repo/contracts/src/project.ts +3 -1
- package/packages/@repo/contracts/src/tenant.ts +2 -0
- package/packages/@repo/models/dist/docs-config.d.ts +0 -198
- package/packages/@repo/models/dist/docs-config.d.ts.map +1 -1
- package/packages/@repo/models/dist/docs-config.js +3 -2
- package/packages/@repo/models/dist/tenant.d.ts +12 -0
- package/packages/@repo/models/dist/tenant.d.ts.map +1 -1
- package/packages/@repo/models/package.json +1 -1
- package/packages/@repo/models/src/docs-config.ts +3 -3
- package/packages/@repo/models/src/tenant.ts +15 -0
- package/packages/@repo/prebuild/dist/openapi.d.ts.map +1 -1
- package/packages/@repo/prebuild/dist/openapi.js +32 -4
- package/packages/@repo/prebuild/package.json +1 -1
- package/packages/@repo/prebuild/src/openapi.ts +42 -4
- package/packages/@repo/previewing/dist/blob-source.d.ts.map +1 -1
- package/packages/@repo/previewing/dist/blob-source.js +18 -5
- package/packages/@repo/previewing/dist/config-loader.d.ts +134 -0
- package/packages/@repo/previewing/dist/config-loader.d.ts.map +1 -0
- package/packages/@repo/previewing/dist/config-loader.js +231 -0
- package/packages/@repo/previewing/dist/constants.d.ts +15 -0
- package/packages/@repo/previewing/dist/constants.d.ts.map +1 -0
- package/packages/@repo/previewing/dist/constants.js +14 -0
- package/packages/@repo/previewing/dist/content-index.d.ts +7 -0
- package/packages/@repo/previewing/dist/content-index.d.ts.map +1 -0
- package/packages/@repo/previewing/dist/content-index.js +291 -0
- package/packages/@repo/previewing/dist/index.d.ts +13 -121
- package/packages/@repo/previewing/dist/index.d.ts.map +1 -1
- package/packages/@repo/previewing/dist/index.js +11 -1003
- package/packages/@repo/previewing/dist/markdown/agent-markdown.d.ts +3 -0
- package/packages/@repo/previewing/dist/markdown/agent-markdown.d.ts.map +1 -0
- package/packages/@repo/previewing/dist/markdown/agent-markdown.js +176 -0
- package/packages/@repo/previewing/dist/markdown/format.d.ts +8 -0
- package/packages/@repo/previewing/dist/markdown/format.d.ts.map +1 -0
- package/packages/@repo/previewing/dist/markdown/format.js +43 -0
- package/packages/@repo/previewing/dist/markdown/links.d.ts +3 -0
- package/packages/@repo/previewing/dist/markdown/links.d.ts.map +1 -0
- package/packages/@repo/previewing/dist/markdown/links.js +31 -0
- package/packages/@repo/previewing/dist/openapi-pages.d.ts +7 -0
- package/packages/@repo/previewing/dist/openapi-pages.d.ts.map +1 -0
- package/packages/@repo/previewing/dist/openapi-pages.js +200 -0
- package/packages/@repo/previewing/dist/search-index.d.ts +4 -0
- package/packages/@repo/previewing/dist/search-index.d.ts.map +1 -0
- package/packages/@repo/previewing/dist/search-index.js +23 -0
- package/packages/@repo/previewing/dist/serialization.d.ts +13 -0
- package/packages/@repo/previewing/dist/serialization.d.ts.map +1 -0
- package/packages/@repo/previewing/dist/serialization.js +108 -0
- package/packages/@repo/previewing/dist/toc-index.d.ts +5 -0
- package/packages/@repo/previewing/dist/toc-index.d.ts.map +1 -0
- package/packages/@repo/previewing/dist/toc-index.js +53 -0
- package/packages/@repo/previewing/dist/types.d.ts +92 -0
- package/packages/@repo/previewing/dist/types.d.ts.map +1 -0
- package/packages/@repo/previewing/dist/types.js +1 -0
- package/packages/@repo/previewing/dist/utility-index.d.ts +7 -0
- package/packages/@repo/previewing/dist/utility-index.d.ts.map +1 -0
- package/packages/@repo/previewing/dist/utility-index.js +201 -0
- package/packages/@repo/previewing/package.json +1 -1
- package/packages/@repo/previewing/src/blob-source.ts +20 -5
- package/packages/@repo/previewing/src/config-loader.ts +316 -0
- package/packages/@repo/previewing/src/constants.ts +16 -0
- package/packages/@repo/previewing/src/content-index.ts +384 -0
- package/packages/@repo/previewing/src/index.ts +69 -1515
- package/packages/@repo/previewing/src/markdown/agent-markdown.ts +254 -0
- package/packages/@repo/previewing/src/markdown/format.ts +63 -0
- package/packages/@repo/previewing/src/markdown/links.ts +46 -0
- package/packages/@repo/previewing/src/openapi-pages.ts +319 -0
- package/packages/@repo/previewing/src/search-index.ts +38 -0
- package/packages/@repo/previewing/src/serialization.ts +191 -0
- package/packages/@repo/previewing/src/toc-index.ts +72 -0
- package/packages/@repo/previewing/src/types.ts +105 -0
- package/packages/@repo/previewing/src/utility-index.ts +304 -0
- package/packages/@repo/validation/package.json +1 -1
- package/scripts/repo-packages.mjs +8 -0
- package/docs/components/ui/site-footer.tsx +0 -41
package/dist/cli.mjs
CHANGED
|
@@ -1,45 +1,23 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { createRequire } from "node:module";
|
|
3
|
-
import {
|
|
4
|
-
import fs, { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
5
|
-
import path, { join } from "node:path";
|
|
3
|
+
import { Command, InvalidArgumentError } from "commander";
|
|
6
4
|
import { confirm, intro, isCancel, log, select, spinner, text } from "@clack/prompts";
|
|
7
5
|
import chalk from "chalk";
|
|
8
|
-
import { Command, InvalidArgumentError } from "commander";
|
|
9
|
-
import open from "open";
|
|
10
6
|
import { homedir } from "node:os";
|
|
7
|
+
import path, { join } from "node:path";
|
|
8
|
+
import fs, { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
9
|
+
import { z } from "zod";
|
|
10
|
+
import "yaml";
|
|
11
|
+
import open from "open";
|
|
12
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
13
|
+
import { createServer } from "node:http";
|
|
14
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
11
15
|
import { once } from "node:events";
|
|
12
|
-
import { createServer } from "node:net";
|
|
16
|
+
import { createServer as createServer$1 } from "node:net";
|
|
13
17
|
import { setTimeout as setTimeout$1 } from "node:timers/promises";
|
|
14
18
|
import { fileURLToPath } from "node:url";
|
|
15
|
-
import { z } from "zod";
|
|
16
|
-
import "yaml";
|
|
17
19
|
import { watch } from "chokidar";
|
|
18
|
-
import { createServer as createServer$1 } from "node:http";
|
|
19
|
-
import { createHash, randomBytes } from "node:crypto";
|
|
20
20
|
import { readFileSync } from "node:fs";
|
|
21
|
-
//#region ../../packages/common/dist/index.js
|
|
22
|
-
const BACKSLASH_TO_SLASH_REGEX = /\\/g;
|
|
23
|
-
const TRAILING_SLASHES_REGEX = /\/+$/g;
|
|
24
|
-
const LEADING_SLASHES_REGEX = /^\/+/;
|
|
25
|
-
const normalizePath = (value) => {
|
|
26
|
-
return value.replace(BACKSLASH_TO_SLASH_REGEX, "/").replace(TRAILING_SLASHES_REGEX, "").replace(LEADING_SLASHES_REGEX, "");
|
|
27
|
-
};
|
|
28
|
-
const slugify = (value) => value.toLowerCase().trim().replaceAll(/[^a-z0-9]+/g, "-").replaceAll(/(^-|-$)+/g, "");
|
|
29
|
-
const IGNORED_ROOT_DOCS_FILES = new Set([
|
|
30
|
-
".gitignore",
|
|
31
|
-
"AGENTS.md",
|
|
32
|
-
"CLAUDE.md",
|
|
33
|
-
"LICENSE",
|
|
34
|
-
"LICENSE.md",
|
|
35
|
-
"README.md"
|
|
36
|
-
]);
|
|
37
|
-
const shouldIgnoreRootDocsFile = (value) => {
|
|
38
|
-
const normalized = normalizePath(value);
|
|
39
|
-
if (!normalized || normalized.includes("/")) return false;
|
|
40
|
-
return IGNORED_ROOT_DOCS_FILES.has(normalized);
|
|
41
|
-
};
|
|
42
|
-
//#endregion
|
|
43
21
|
//#region src/constants.ts
|
|
44
22
|
const CLI_NAME = "blodemd";
|
|
45
23
|
const BLODE_PROJECT_ENV = "BLODEMD_PROJECT";
|
|
@@ -300,24 +278,31 @@ const resolveTokenStatus = (token) => {
|
|
|
300
278
|
};
|
|
301
279
|
};
|
|
302
280
|
//#endregion
|
|
303
|
-
//#region
|
|
304
|
-
const
|
|
305
|
-
const
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
if (!Number.isSafeInteger(parsed) || parsed <= 0) throw new CliError(`${label} must be a positive integer.`, EXIT_CODES.VALIDATION);
|
|
310
|
-
return parsed;
|
|
281
|
+
//#region ../../packages/common/dist/index.js
|
|
282
|
+
const BACKSLASH_TO_SLASH_REGEX = /\\/g;
|
|
283
|
+
const TRAILING_SLASHES_REGEX = /\/+$/g;
|
|
284
|
+
const LEADING_SLASHES_REGEX = /^\/+/;
|
|
285
|
+
const normalizePath = (value) => {
|
|
286
|
+
return value.replace(BACKSLASH_TO_SLASH_REGEX, "/").replace(TRAILING_SLASHES_REGEX, "").replace(LEADING_SLASHES_REGEX, "");
|
|
311
287
|
};
|
|
312
|
-
const
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
288
|
+
const slugify = (value) => value.toLowerCase().trim().replaceAll(/[^a-z0-9]+/g, "-").replaceAll(/(^-|-$)+/g, "");
|
|
289
|
+
const IGNORED_ROOT_DOCS_FILES = new Set([
|
|
290
|
+
".gitignore",
|
|
291
|
+
"AGENTS.md",
|
|
292
|
+
"CLAUDE.md",
|
|
293
|
+
"LICENSE",
|
|
294
|
+
"LICENSE.md",
|
|
295
|
+
"README.md"
|
|
296
|
+
]);
|
|
297
|
+
const shouldIgnoreRootDocsFile = (value) => {
|
|
298
|
+
const normalized = normalizePath(value);
|
|
299
|
+
if (!normalized || normalized.includes("/")) return false;
|
|
300
|
+
return IGNORED_ROOT_DOCS_FILES.has(normalized);
|
|
316
301
|
};
|
|
317
302
|
//#endregion
|
|
318
303
|
//#region ../../packages/models/dist/docs-config.js
|
|
319
304
|
const UrlOrPathSchema = z.string().min(1);
|
|
320
|
-
const SlugSchema = z.string().min(1).regex(/^[a-z0-9-]
|
|
305
|
+
const SlugSchema = z.string().min(1).regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/);
|
|
321
306
|
const DocsColorsSchema = z.object({
|
|
322
307
|
background: z.string().optional(),
|
|
323
308
|
border: z.string().optional(),
|
|
@@ -740,69 +725,10 @@ const validateDocsConfig = (input) => {
|
|
|
740
725
|
};
|
|
741
726
|
};
|
|
742
727
|
//#endregion
|
|
743
|
-
//#region ../../packages/previewing/dist/
|
|
744
|
-
const IGNORED_DIRECTORIES = new Set([
|
|
745
|
-
"app",
|
|
746
|
-
"lib",
|
|
747
|
-
"node_modules",
|
|
748
|
-
"public"
|
|
749
|
-
]);
|
|
750
|
-
const isNotFoundError = (error) => Boolean(error && typeof error === "object" && "code" in error && error.code === "ENOENT");
|
|
751
|
-
const isWithinRoot = (root, candidate) => candidate === root || candidate.startsWith(`${root}${path.sep}`);
|
|
752
|
-
const resolveWithinRoot = (root, relativePath) => {
|
|
753
|
-
const normalized = normalizePath(relativePath);
|
|
754
|
-
const absolutePath = path.resolve(root, normalized);
|
|
755
|
-
if (!isWithinRoot(root, absolutePath)) throw new Error(`Path "${relativePath}" escapes the content source root.`);
|
|
756
|
-
return absolutePath;
|
|
757
|
-
};
|
|
758
|
-
const walkFiles = async (directory, prefix) => {
|
|
759
|
-
const entries = await fs.readdir(directory, { withFileTypes: true });
|
|
760
|
-
const files = [];
|
|
761
|
-
for (const entry of entries) {
|
|
762
|
-
if (entry.name.startsWith(".")) continue;
|
|
763
|
-
const absolutePath = path.join(directory, entry.name);
|
|
764
|
-
const relativePath = prefix ? path.join(prefix, entry.name) : entry.name;
|
|
765
|
-
if (entry.isDirectory()) {
|
|
766
|
-
if (IGNORED_DIRECTORIES.has(entry.name)) continue;
|
|
767
|
-
files.push(...await walkFiles(absolutePath, relativePath));
|
|
768
|
-
continue;
|
|
769
|
-
}
|
|
770
|
-
if (entry.isFile()) {
|
|
771
|
-
if (!prefix && shouldIgnoreRootDocsFile(entry.name)) continue;
|
|
772
|
-
files.push(normalizePath(relativePath));
|
|
773
|
-
}
|
|
774
|
-
}
|
|
775
|
-
return files;
|
|
776
|
-
};
|
|
777
|
-
var FsContentSource = class {
|
|
778
|
-
root;
|
|
779
|
-
constructor(root) {
|
|
780
|
-
this.root = path.resolve(root);
|
|
781
|
-
}
|
|
782
|
-
async readFile(relativePath) {
|
|
783
|
-
return await fs.readFile(resolveWithinRoot(this.root, relativePath), "utf8");
|
|
784
|
-
}
|
|
785
|
-
async listFiles(directory) {
|
|
786
|
-
return await walkFiles(resolveWithinRoot(this.root, directory), "");
|
|
787
|
-
}
|
|
788
|
-
async exists(relativePath) {
|
|
789
|
-
try {
|
|
790
|
-
await fs.access(resolveWithinRoot(this.root, relativePath));
|
|
791
|
-
return true;
|
|
792
|
-
} catch (error) {
|
|
793
|
-
if (isNotFoundError(error)) return false;
|
|
794
|
-
throw error;
|
|
795
|
-
}
|
|
796
|
-
}
|
|
797
|
-
resolveUrl() {
|
|
798
|
-
return null;
|
|
799
|
-
}
|
|
800
|
-
};
|
|
801
|
-
const createFsSource = (root) => new FsContentSource(root);
|
|
802
|
-
//#endregion
|
|
803
|
-
//#region ../../packages/previewing/dist/index.js
|
|
728
|
+
//#region ../../packages/previewing/dist/constants.js
|
|
804
729
|
const LEGACY_PROJECT_NAME_FALLBACK_WARNING$1 = "docs.json.slug is recommended. Falling back to docs.json.name as the deployment slug is deprecated.";
|
|
805
|
-
|
|
730
|
+
//#endregion
|
|
731
|
+
//#region ../../packages/previewing/dist/config-loader.js
|
|
806
732
|
const DOCS_CONFIG_FILE = "docs.json";
|
|
807
733
|
const defaultLinkLabel = (input) => {
|
|
808
734
|
if (input.label) return input.label;
|
|
@@ -950,6 +876,81 @@ const loadSiteConfig = async (source) => {
|
|
|
950
876
|
ok: false
|
|
951
877
|
};
|
|
952
878
|
};
|
|
879
|
+
new Set(PageModeSchema.options);
|
|
880
|
+
//#endregion
|
|
881
|
+
//#region ../../packages/previewing/dist/fs-source.js
|
|
882
|
+
const IGNORED_DIRECTORIES = new Set([
|
|
883
|
+
"app",
|
|
884
|
+
"lib",
|
|
885
|
+
"node_modules",
|
|
886
|
+
"public"
|
|
887
|
+
]);
|
|
888
|
+
const isNotFoundError = (error) => Boolean(error && typeof error === "object" && "code" in error && error.code === "ENOENT");
|
|
889
|
+
const isWithinRoot = (root, candidate) => candidate === root || candidate.startsWith(`${root}${path.sep}`);
|
|
890
|
+
const resolveWithinRoot = (root, relativePath) => {
|
|
891
|
+
const normalized = normalizePath(relativePath);
|
|
892
|
+
const absolutePath = path.resolve(root, normalized);
|
|
893
|
+
if (!isWithinRoot(root, absolutePath)) throw new Error(`Path "${relativePath}" escapes the content source root.`);
|
|
894
|
+
return absolutePath;
|
|
895
|
+
};
|
|
896
|
+
const walkFiles = async (directory, prefix) => {
|
|
897
|
+
const entries = await fs.readdir(directory, { withFileTypes: true });
|
|
898
|
+
const files = [];
|
|
899
|
+
for (const entry of entries) {
|
|
900
|
+
if (entry.name.startsWith(".")) continue;
|
|
901
|
+
const absolutePath = path.join(directory, entry.name);
|
|
902
|
+
const relativePath = prefix ? path.join(prefix, entry.name) : entry.name;
|
|
903
|
+
if (entry.isDirectory()) {
|
|
904
|
+
if (IGNORED_DIRECTORIES.has(entry.name)) continue;
|
|
905
|
+
files.push(...await walkFiles(absolutePath, relativePath));
|
|
906
|
+
continue;
|
|
907
|
+
}
|
|
908
|
+
if (entry.isFile()) {
|
|
909
|
+
if (!prefix && shouldIgnoreRootDocsFile(entry.name)) continue;
|
|
910
|
+
files.push(normalizePath(relativePath));
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
return files;
|
|
914
|
+
};
|
|
915
|
+
var FsContentSource = class {
|
|
916
|
+
root;
|
|
917
|
+
constructor(root) {
|
|
918
|
+
this.root = path.resolve(root);
|
|
919
|
+
}
|
|
920
|
+
async readFile(relativePath) {
|
|
921
|
+
return await fs.readFile(resolveWithinRoot(this.root, relativePath), "utf8");
|
|
922
|
+
}
|
|
923
|
+
async listFiles(directory) {
|
|
924
|
+
return await walkFiles(resolveWithinRoot(this.root, directory), "");
|
|
925
|
+
}
|
|
926
|
+
async exists(relativePath) {
|
|
927
|
+
try {
|
|
928
|
+
await fs.access(resolveWithinRoot(this.root, relativePath));
|
|
929
|
+
return true;
|
|
930
|
+
} catch (error) {
|
|
931
|
+
if (isNotFoundError(error)) return false;
|
|
932
|
+
throw error;
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
resolveUrl() {
|
|
936
|
+
return null;
|
|
937
|
+
}
|
|
938
|
+
};
|
|
939
|
+
const createFsSource = (root) => new FsContentSource(root);
|
|
940
|
+
//#endregion
|
|
941
|
+
//#region ../../packages/previewing/dist/markdown/links.js
|
|
942
|
+
const PLACEHOLDER_URL_PATTERN = [
|
|
943
|
+
"https?://(?:[a-z0-9-]+\\.)*example\\.(?:com|org|net)\\b[^\\s)\\]\"'<>]*",
|
|
944
|
+
"https?://discord\\.gg/example\\b[^\\s)\\]\"'<>]*",
|
|
945
|
+
"https?://(?:[a-z0-9-]+\\.)+(?:test|invalid)(?:[/?#:][^\\s)\\]\"'<>]*)?",
|
|
946
|
+
"https?://localhost(?::\\d+)?[^\\s)\\]\"'<>]*",
|
|
947
|
+
"https?://(?:[a-z0-9-]+\\.)*your[-_]?domain\\.[a-z]+\\b[^\\s)\\]\"'<>]*",
|
|
948
|
+
"https?://acme\\.blode\\.md\\b[^\\s)\\]\"'<>]*",
|
|
949
|
+
"https?://github\\.com/example/[^\\s)\\]\"'<>]*",
|
|
950
|
+
"https?://(?:us|eu)\\.i\\.posthog\\.com\\b[^\\s)\\]\"'<>]*"
|
|
951
|
+
].join("|");
|
|
952
|
+
new RegExp(PLACEHOLDER_URL_PATTERN, "gi");
|
|
953
|
+
new RegExp(`\\[([^\\]]+)\\]\\((?:${PLACEHOLDER_URL_PATTERN})\\)`, "gi");
|
|
953
954
|
//#endregion
|
|
954
955
|
//#region src/site-config.ts
|
|
955
956
|
const CONFIG_FILE$2 = "docs.json";
|
|
@@ -1007,513 +1008,226 @@ const resolveDocsRoot = async (dir, cwd = process.cwd()) => {
|
|
|
1007
1008
|
};
|
|
1008
1009
|
const validateDocsRoot = loadValidatedSiteConfig;
|
|
1009
1010
|
//#endregion
|
|
1010
|
-
//#region src/
|
|
1011
|
-
const
|
|
1012
|
-
const
|
|
1013
|
-
const
|
|
1014
|
-
|
|
1015
|
-
const
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
}
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
}),
|
|
1041
|
-
headers: { "Content-Type": "application/json" },
|
|
1042
|
-
method: "POST"
|
|
1043
|
-
});
|
|
1044
|
-
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
1045
|
-
} catch (error) {
|
|
1046
|
-
log.error(`Failed to invalidate preview cache: ${error instanceof Error ? error.message : "unknown error"}`);
|
|
1047
|
-
}
|
|
1011
|
+
//#region src/project-config.ts
|
|
1012
|
+
const LEGACY_PROJECT_NAME_FALLBACK_WARNING = "docs.json.slug is recommended. Falling back to docs.json.name as the deployment slug is deprecated.";
|
|
1013
|
+
const validateProjectSlug = (value) => {
|
|
1014
|
+
const trimmed = value?.trim();
|
|
1015
|
+
if (!trimmed) return "Project slug is required.";
|
|
1016
|
+
const normalized = slugify(trimmed);
|
|
1017
|
+
if (!normalized) return "Use at least one letter or number.";
|
|
1018
|
+
if (normalized !== trimmed) return `Use lowercase letters, numbers, and hyphens. Try "${normalized}".`;
|
|
1019
|
+
};
|
|
1020
|
+
const deriveDisplayNameFromProjectSlug = (projectSlug) => projectSlug.split("-").filter(Boolean).map((segment) => segment[0]?.toUpperCase() + segment.slice(1)).join(" ");
|
|
1021
|
+
const resolveProjectTarget = (options) => {
|
|
1022
|
+
if (options.cliProject) return {
|
|
1023
|
+
project: options.cliProject,
|
|
1024
|
+
usedLegacyNameFallback: false
|
|
1025
|
+
};
|
|
1026
|
+
if (options.envProject) return {
|
|
1027
|
+
project: options.envProject,
|
|
1028
|
+
usedLegacyNameFallback: false
|
|
1029
|
+
};
|
|
1030
|
+
if (options.config.slug) return {
|
|
1031
|
+
project: options.config.slug,
|
|
1032
|
+
usedLegacyNameFallback: false
|
|
1033
|
+
};
|
|
1034
|
+
if (options.config.name) return {
|
|
1035
|
+
project: options.config.name,
|
|
1036
|
+
usedLegacyNameFallback: true
|
|
1037
|
+
};
|
|
1038
|
+
return {
|
|
1039
|
+
project: void 0,
|
|
1040
|
+
usedLegacyNameFallback: false
|
|
1048
1041
|
};
|
|
1049
|
-
watcher.on("all", (event, changedPath) => {
|
|
1050
|
-
if (isDirectoryEvent(event)) return;
|
|
1051
|
-
const relativePath = normalizeRelativePath$1(root, changedPath);
|
|
1052
|
-
pendingPaths.add(relativePath);
|
|
1053
|
-
if (path.basename(changedPath) === "docs.json") pendingKind = "config";
|
|
1054
|
-
if (flushTimer) clearTimeout(flushTimer);
|
|
1055
|
-
flushTimer = setTimeout(() => {
|
|
1056
|
-
flush();
|
|
1057
|
-
}, WATCH_DEBOUNCE_MS);
|
|
1058
|
-
});
|
|
1059
|
-
return { async close() {
|
|
1060
|
-
if (flushTimer) {
|
|
1061
|
-
clearTimeout(flushTimer);
|
|
1062
|
-
await flush();
|
|
1063
|
-
}
|
|
1064
|
-
await watcher.close();
|
|
1065
|
-
} };
|
|
1066
|
-
};
|
|
1067
|
-
//#endregion
|
|
1068
|
-
//#region src/dev/command.ts
|
|
1069
|
-
const DEV_READY_ENDPOINT = "/blodemd-dev/version";
|
|
1070
|
-
const DEV_READY_TIMEOUT_MS = 45e3;
|
|
1071
|
-
const DEV_PORT_SCAN_LIMIT = 10;
|
|
1072
|
-
const DEV_SHUTDOWN_TIMEOUT_MS = 5e3;
|
|
1073
|
-
const LOCALHOST = "127.0.0.1";
|
|
1074
|
-
const RUNTIME_EXCLUDE_DIRS = new Set([
|
|
1075
|
-
".next",
|
|
1076
|
-
".turbo",
|
|
1077
|
-
"node_modules"
|
|
1078
|
-
]);
|
|
1079
|
-
const STANDALONE_RUNTIME_MAX_AGE_MS = 1440 * 60 * 1e3;
|
|
1080
|
-
const STANDALONE_RUNTIME_PREFIX = "standalone-runtime-";
|
|
1081
|
-
const TURBOPACK_ARGS = ["dev", "--turbopack"];
|
|
1082
|
-
const fileExists = async (filePath) => {
|
|
1083
|
-
try {
|
|
1084
|
-
await fs.access(filePath);
|
|
1085
|
-
return true;
|
|
1086
|
-
} catch {
|
|
1087
|
-
return false;
|
|
1088
|
-
}
|
|
1089
|
-
};
|
|
1090
|
-
const resolveCommonAncestor = (pathsToCompare) => {
|
|
1091
|
-
const [firstPath, ...restPaths] = pathsToCompare;
|
|
1092
|
-
if (!firstPath) return path.sep;
|
|
1093
|
-
const first = path.resolve(firstPath);
|
|
1094
|
-
const { root } = path.parse(first);
|
|
1095
|
-
const firstSegments = first.slice(root.length).split(path.sep).filter(Boolean);
|
|
1096
|
-
const sharedSegments = [];
|
|
1097
|
-
for (const [index, segment] of firstSegments.entries()) {
|
|
1098
|
-
if (!restPaths.every((candidatePath) => {
|
|
1099
|
-
const candidate = path.resolve(candidatePath);
|
|
1100
|
-
if (path.parse(candidate).root !== root) return false;
|
|
1101
|
-
return candidate.slice(root.length).split(path.sep).filter(Boolean)[index] === segment;
|
|
1102
|
-
})) break;
|
|
1103
|
-
sharedSegments.push(segment);
|
|
1104
|
-
}
|
|
1105
|
-
return path.join(root, ...sharedSegments);
|
|
1106
1042
|
};
|
|
1107
|
-
const
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
await Promise.all(entries.filter((entry) => entry.isDirectory() && entry.name.startsWith(STANDALONE_RUNTIME_PREFIX)).map(async (entry) => {
|
|
1111
|
-
const entryPath = path.join(configDir, entry.name);
|
|
1112
|
-
if ((await fs.stat(entryPath)).mtimeMs >= cutoff) return;
|
|
1113
|
-
await fs.rm(entryPath, {
|
|
1114
|
-
force: true,
|
|
1115
|
-
recursive: true
|
|
1116
|
-
});
|
|
1117
|
-
}));
|
|
1043
|
+
const getProjectSlugError = (project) => {
|
|
1044
|
+
if (!project) return;
|
|
1045
|
+
return validateProjectSlug(project);
|
|
1118
1046
|
};
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
return {
|
|
1128
|
-
error,
|
|
1129
|
-
kind: "error"
|
|
1130
|
-
};
|
|
1131
|
-
})();
|
|
1132
|
-
server.listen({
|
|
1133
|
-
exclusive: true,
|
|
1134
|
-
host: LOCALHOST,
|
|
1135
|
-
port
|
|
1136
|
-
});
|
|
1137
|
-
const outcome = await Promise.race([listening, errored]);
|
|
1138
|
-
if (outcome.kind === "error") {
|
|
1139
|
-
if (outcome.error.code === "EADDRINUSE" || outcome.error.code === "EACCES") return false;
|
|
1140
|
-
throw outcome.error;
|
|
1141
|
-
}
|
|
1142
|
-
server.close();
|
|
1143
|
-
await once(server, "close");
|
|
1144
|
-
return true;
|
|
1047
|
+
//#endregion
|
|
1048
|
+
//#region src/analytics/validators.ts
|
|
1049
|
+
const GA4_MEASUREMENT_ID_REGEX = /^G-[A-Z0-9]{4,20}$/;
|
|
1050
|
+
const POSTHOG_PROJECT_KEY_REGEX = /^phc_[A-Za-z0-9]{20,}$/;
|
|
1051
|
+
const parseGa4MeasurementId = (value) => {
|
|
1052
|
+
const trimmed = value.trim();
|
|
1053
|
+
if (!GA4_MEASUREMENT_ID_REGEX.test(trimmed)) throw new InvalidArgumentError("GA4 measurement IDs look like G-XXXXXXXXXX.");
|
|
1054
|
+
return trimmed;
|
|
1145
1055
|
};
|
|
1146
|
-
const
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
}
|
|
1152
|
-
throw new CliError(`No available port found within ${DEV_PORT_SCAN_LIMIT} attempts starting at ${requestedPort}.`, EXIT_CODES.ERROR, "Close the process using the port or pass a different --port value.");
|
|
1056
|
+
const parsePosthogProjectKey = (value) => {
|
|
1057
|
+
const trimmed = value.trim();
|
|
1058
|
+
if (trimmed.startsWith("phx_")) throw new InvalidArgumentError("Personal API keys (phx_) are not supported. Use the project API key (phc_).");
|
|
1059
|
+
if (!POSTHOG_PROJECT_KEY_REGEX.test(trimmed)) throw new InvalidArgumentError("PostHog project keys start with phc_ followed by 20+ characters.");
|
|
1060
|
+
return trimmed;
|
|
1153
1061
|
};
|
|
1154
|
-
const
|
|
1155
|
-
|
|
1156
|
-
const timer = setTimeout(() => {
|
|
1157
|
-
if (child.exitCode === null) child.kill("SIGKILL");
|
|
1158
|
-
}, timeoutMs);
|
|
1159
|
-
const exitPromise = once(child, "exit");
|
|
1062
|
+
const parsePosthogHost = (value) => {
|
|
1063
|
+
const trimmed = value.trim();
|
|
1160
1064
|
try {
|
|
1161
|
-
|
|
1065
|
+
if (new URL(trimmed).protocol !== "https:") throw new InvalidArgumentError("PostHog host must use https://");
|
|
1162
1066
|
} catch (error) {
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
throw error;
|
|
1067
|
+
if (error instanceof InvalidArgumentError) throw error;
|
|
1068
|
+
throw new InvalidArgumentError("PostHog host must be a valid URL.");
|
|
1166
1069
|
}
|
|
1167
|
-
|
|
1168
|
-
clearTimeout(timer);
|
|
1169
|
-
});
|
|
1070
|
+
return trimmed;
|
|
1170
1071
|
};
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
const resolveCliPackageRoot = (cliFilePath) => path.dirname(path.dirname(cliFilePath));
|
|
1176
|
-
const copyStandaloneTree = async (sourceDir, targetDir) => {
|
|
1177
|
-
await fs.cp(sourceDir, targetDir, {
|
|
1178
|
-
filter: (source) => {
|
|
1179
|
-
const relative = path.relative(sourceDir, source);
|
|
1180
|
-
if (!relative) return true;
|
|
1181
|
-
const topSegment = relative.split(path.sep)[0] ?? "";
|
|
1182
|
-
return !RUNTIME_EXCLUDE_DIRS.has(topSegment);
|
|
1183
|
-
},
|
|
1184
|
-
recursive: true
|
|
1185
|
-
});
|
|
1072
|
+
const parseProvider = (value) => {
|
|
1073
|
+
const normalized = value.trim().toLowerCase();
|
|
1074
|
+
if (normalized === "ga4" || normalized === "posthog") return normalized;
|
|
1075
|
+
throw new InvalidArgumentError(`Unknown provider "${value}". Expected "ga4" or "posthog".`);
|
|
1186
1076
|
};
|
|
1187
|
-
|
|
1077
|
+
//#endregion
|
|
1078
|
+
//#region src/analytics/command.ts
|
|
1079
|
+
const apiBase = (options) => options.apiUrl ?? process.env["BLODEMD_API_URL"] ?? "https://api.blode.md";
|
|
1080
|
+
const resolveAuthorization = async () => {
|
|
1081
|
+
const resolved = await resolveAuthToken();
|
|
1082
|
+
if (!resolved?.token) throw new CliError("Not logged in. Run \"blodemd login\" to authenticate.", EXIT_CODES.AUTH_REQUIRED);
|
|
1083
|
+
return `Bearer ${resolved.token}`;
|
|
1084
|
+
};
|
|
1085
|
+
const tryLoadDocsSlug = async () => {
|
|
1188
1086
|
try {
|
|
1189
|
-
|
|
1087
|
+
const { config } = await loadValidatedSiteConfig(await resolveDocsRoot());
|
|
1088
|
+
return config.slug ?? config.name;
|
|
1190
1089
|
} catch {
|
|
1191
|
-
return
|
|
1090
|
+
return;
|
|
1192
1091
|
}
|
|
1193
1092
|
};
|
|
1194
|
-
const
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1093
|
+
const resolveSlug = async (options) => {
|
|
1094
|
+
if (options.project) return options.project;
|
|
1095
|
+
const envSlug = process.env[BLODE_PROJECT_ENV];
|
|
1096
|
+
if (envSlug) return envSlug;
|
|
1097
|
+
const { project } = resolveProjectTarget({
|
|
1098
|
+
cliProject: void 0,
|
|
1099
|
+
config: { slug: await tryLoadDocsSlug() },
|
|
1100
|
+
envProject: void 0
|
|
1101
|
+
});
|
|
1102
|
+
if (!project) throw new CliError("Could not resolve project. Pass --project <slug>, set BLODEMD_PROJECT, or run from a directory with docs.json.", EXIT_CODES.VALIDATION);
|
|
1103
|
+
return project;
|
|
1198
1104
|
};
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
* Resolve `next/package.json` and use the directory that owns it.
|
|
1205
|
-
*/
|
|
1206
|
-
const resolveRuntimeNodeModules = async (cliPackageRoot) => {
|
|
1207
|
-
const localNodeModules = path.join(cliPackageRoot, "node_modules");
|
|
1208
|
-
if (await fileExists(path.join(localNodeModules, "next", "package.json"))) return localNodeModules;
|
|
1209
|
-
const nextPkgPath = createRequire(path.join(cliPackageRoot, "package.json")).resolve("next/package.json");
|
|
1210
|
-
return path.dirname(path.dirname(nextPkgPath));
|
|
1105
|
+
const getProjectBySlug = async (apiUrl, authorization, slug) => {
|
|
1106
|
+
const response = await fetch(`${apiUrl}/projects/by-slug/${slug}`, { headers: { Authorization: authorization } });
|
|
1107
|
+
if (response.status === 404) throw new CliError(`Project "${slug}" not found or not accessible.`, EXIT_CODES.ERROR);
|
|
1108
|
+
if (!response.ok) throw new CliError(`Failed to fetch project: ${response.status} ${await response.text()}`, EXIT_CODES.ERROR);
|
|
1109
|
+
return await response.json();
|
|
1211
1110
|
};
|
|
1212
|
-
const
|
|
1213
|
-
const
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
"
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
}
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
"@types/react-dom": "19.2.3",
|
|
1241
|
-
typescript: "6.0.2"
|
|
1242
|
-
},
|
|
1243
|
-
name: "blodemd-dev-server",
|
|
1244
|
-
private: true,
|
|
1245
|
-
type: "module"
|
|
1111
|
+
const patchAnalytics = async (apiUrl, authorization, projectId, analytics) => {
|
|
1112
|
+
const response = await fetch(`${apiUrl}/projects/${projectId}`, {
|
|
1113
|
+
body: JSON.stringify({ analytics }),
|
|
1114
|
+
headers: {
|
|
1115
|
+
Authorization: authorization,
|
|
1116
|
+
"Content-Type": "application/json"
|
|
1117
|
+
},
|
|
1118
|
+
method: "PATCH"
|
|
1119
|
+
});
|
|
1120
|
+
if (!response.ok) throw new CliError(`Failed to update analytics: ${response.status} ${await response.text()}`, EXIT_CODES.ERROR);
|
|
1121
|
+
return await response.json();
|
|
1122
|
+
};
|
|
1123
|
+
const normalizeAnalytics = (analytics) => {
|
|
1124
|
+
if (!analytics) return null;
|
|
1125
|
+
const next = {};
|
|
1126
|
+
if (analytics.ga4?.measurementId) next.ga4 = { measurementId: analytics.ga4.measurementId };
|
|
1127
|
+
if (analytics.posthog?.projectKey) next.posthog = {
|
|
1128
|
+
projectKey: analytics.posthog.projectKey,
|
|
1129
|
+
...analytics.posthog.host ? { host: analytics.posthog.host } : {}
|
|
1130
|
+
};
|
|
1131
|
+
return next.ga4 || next.posthog ? next : null;
|
|
1132
|
+
};
|
|
1133
|
+
const printAnalytics = (project, format) => {
|
|
1134
|
+
const analytics = normalizeAnalytics(project.analytics);
|
|
1135
|
+
if (format === "json") {
|
|
1136
|
+
process.stdout.write(`${JSON.stringify({
|
|
1137
|
+
analytics,
|
|
1138
|
+
project: project.slug
|
|
1246
1139
|
}, null, 2)}\n`);
|
|
1247
|
-
return
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1140
|
+
return;
|
|
1141
|
+
}
|
|
1142
|
+
log.info(`Project: ${chalk.cyan(project.slug)}`);
|
|
1143
|
+
if (!analytics) {
|
|
1144
|
+
log.info(" No analytics configured.");
|
|
1145
|
+
return;
|
|
1146
|
+
}
|
|
1147
|
+
if (analytics.ga4) log.info(` GA4: ${chalk.cyan(analytics.ga4.measurementId)}`);
|
|
1148
|
+
if (analytics.posthog) {
|
|
1149
|
+
log.info(` PostHog: ${chalk.cyan(analytics.posthog.projectKey)}`);
|
|
1150
|
+
if (analytics.posthog.host) log.info(` host: ${analytics.posthog.host}`);
|
|
1257
1151
|
}
|
|
1258
1152
|
};
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
const
|
|
1264
|
-
const
|
|
1265
|
-
|
|
1266
|
-
|
|
1153
|
+
const runGet = async (options) => {
|
|
1154
|
+
const authorization = await resolveAuthorization();
|
|
1155
|
+
printAnalytics(await getProjectBySlug(apiBase(options), authorization, await resolveSlug(options)), options.json ? "json" : "text");
|
|
1156
|
+
};
|
|
1157
|
+
const runSetGa4 = async (measurementId, options) => {
|
|
1158
|
+
const authorization = await resolveAuthorization();
|
|
1159
|
+
const apiUrl = apiBase(options);
|
|
1160
|
+
const project = await getProjectBySlug(apiUrl, authorization, await resolveSlug(options));
|
|
1161
|
+
const next = {
|
|
1162
|
+
...project.analytics,
|
|
1163
|
+
ga4: { measurementId }
|
|
1164
|
+
};
|
|
1165
|
+
const updated = await patchAnalytics(apiUrl, authorization, project.id, next);
|
|
1166
|
+
log.success(`Updated GA4 for ${chalk.cyan(updated.slug)}.`);
|
|
1167
|
+
printAnalytics(updated, "text");
|
|
1168
|
+
};
|
|
1169
|
+
const runSetPosthog = async (projectKey, options) => {
|
|
1170
|
+
const authorization = await resolveAuthorization();
|
|
1171
|
+
const apiUrl = apiBase(options);
|
|
1172
|
+
const project = await getProjectBySlug(apiUrl, authorization, await resolveSlug(options));
|
|
1173
|
+
const next = {
|
|
1174
|
+
...project.analytics,
|
|
1175
|
+
posthog: {
|
|
1176
|
+
projectKey,
|
|
1177
|
+
...options.host ? { host: options.host } : {}
|
|
1178
|
+
}
|
|
1179
|
+
};
|
|
1180
|
+
const updated = await patchAnalytics(apiUrl, authorization, project.id, next);
|
|
1181
|
+
log.success(`Updated PostHog for ${chalk.cyan(updated.slug)}.`);
|
|
1182
|
+
printAnalytics(updated, "text");
|
|
1183
|
+
};
|
|
1184
|
+
const runUnset = async (provider, options) => {
|
|
1185
|
+
const authorization = await resolveAuthorization();
|
|
1186
|
+
const apiUrl = apiBase(options);
|
|
1187
|
+
const project = await getProjectBySlug(apiUrl, authorization, await resolveSlug(options));
|
|
1188
|
+
const next = normalizeAnalytics({
|
|
1189
|
+
...project.analytics,
|
|
1190
|
+
[provider]: void 0
|
|
1191
|
+
});
|
|
1192
|
+
const updated = await patchAnalytics(apiUrl, authorization, project.id, next);
|
|
1193
|
+
log.success(`Removed ${provider.toUpperCase()} for ${chalk.cyan(updated.slug)}.`);
|
|
1194
|
+
printAnalytics(updated, "text");
|
|
1195
|
+
};
|
|
1196
|
+
const runAction = async (label, action) => {
|
|
1267
1197
|
try {
|
|
1268
|
-
|
|
1269
|
-
} catch {
|
|
1270
|
-
|
|
1198
|
+
await action();
|
|
1199
|
+
} catch (error) {
|
|
1200
|
+
const cliError = toCliError(error);
|
|
1201
|
+
log.error(`${label}: ${cliError.message}`);
|
|
1202
|
+
if (cliError.hint) log.info(cliError.hint);
|
|
1203
|
+
process.exitCode = cliError.exitCode;
|
|
1271
1204
|
}
|
|
1272
|
-
const runtime = await materializeStandaloneRuntime(cliPackageRoot);
|
|
1273
|
-
return {
|
|
1274
|
-
devServerDir: runtime.devServerDir,
|
|
1275
|
-
mode: "standalone",
|
|
1276
|
-
nextPackageRoot: cliPackageRoot,
|
|
1277
|
-
runtimeRoot: runtime.runtimeRoot
|
|
1278
|
-
};
|
|
1279
1205
|
};
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
}
|
|
1296
|
-
const parent = path.dirname(current);
|
|
1297
|
-
if (parent === current) break;
|
|
1298
|
-
current = parent;
|
|
1299
|
-
}
|
|
1300
|
-
throw new CliError("Could not locate the blodemd dev server.", EXIT_CODES.ERROR, "Make sure blodemd is installed correctly (npm i blodemd).");
|
|
1301
|
-
};
|
|
1302
|
-
const resolveDevServer = async (cliFilePath) => {
|
|
1303
|
-
const standalone = await findStandaloneDevServer(resolveCliPackageRoot(cliFilePath));
|
|
1304
|
-
if (standalone) return standalone;
|
|
1305
|
-
return {
|
|
1306
|
-
mode: "monorepo",
|
|
1307
|
-
repoRoot: await findMonorepoRoot(path.dirname(cliFilePath))
|
|
1308
|
-
};
|
|
1309
|
-
};
|
|
1310
|
-
const buildDevServerLaunch = (server, { root, port }) => {
|
|
1311
|
-
if (server.mode === "standalone") return {
|
|
1312
|
-
args: [resolveNextBin(server.nextPackageRoot), ...TURBOPACK_ARGS],
|
|
1313
|
-
command: process.execPath,
|
|
1314
|
-
cwd: server.devServerDir,
|
|
1315
|
-
env: {
|
|
1316
|
-
...process.env,
|
|
1317
|
-
BLODEMD_PACKAGES_DIR: path.join(server.runtimeRoot, "packages"),
|
|
1318
|
-
BLODEMD_TURBOPACK_ROOT: resolveCommonAncestor([server.nextPackageRoot, server.runtimeRoot]),
|
|
1319
|
-
DOCS_ROOT: root,
|
|
1320
|
-
PORT: String(port)
|
|
1321
|
-
}
|
|
1322
|
-
};
|
|
1323
|
-
return {
|
|
1324
|
-
args: [
|
|
1325
|
-
"run",
|
|
1326
|
-
"dev",
|
|
1327
|
-
"--workspace=dev-server"
|
|
1328
|
-
],
|
|
1329
|
-
command: process.platform === "win32" ? "npm.cmd" : "npm",
|
|
1330
|
-
cwd: server.repoRoot,
|
|
1331
|
-
env: {
|
|
1332
|
-
...process.env,
|
|
1333
|
-
DOCS_ROOT: root,
|
|
1334
|
-
PORT: String(port)
|
|
1335
|
-
}
|
|
1336
|
-
};
|
|
1337
|
-
};
|
|
1338
|
-
const spawnDevServer = (server, options) => {
|
|
1339
|
-
const launch = buildDevServerLaunch(server, options);
|
|
1340
|
-
return spawn(launch.command, launch.args, {
|
|
1341
|
-
cwd: launch.cwd,
|
|
1342
|
-
env: launch.env,
|
|
1343
|
-
stdio: "inherit"
|
|
1344
|
-
});
|
|
1345
|
-
};
|
|
1346
|
-
const waitForServer = async ({ child, port }) => {
|
|
1347
|
-
const url = `http://localhost:${port}${DEV_READY_ENDPOINT}`;
|
|
1348
|
-
const startedAt = Date.now();
|
|
1349
|
-
while (Date.now() - startedAt < DEV_READY_TIMEOUT_MS) {
|
|
1350
|
-
if (child.exitCode !== null) throw new CliError("The local dev server exited before it became ready.", EXIT_CODES.ERROR);
|
|
1351
|
-
try {
|
|
1352
|
-
if ((await fetch(url, {
|
|
1353
|
-
cache: "no-store",
|
|
1354
|
-
headers: { accept: "application/json" }
|
|
1355
|
-
})).ok) return;
|
|
1356
|
-
} catch {}
|
|
1357
|
-
await setTimeout$1(500);
|
|
1358
|
-
}
|
|
1359
|
-
throw new CliError("Timed out waiting for the local dev server to start.", EXIT_CODES.ERROR);
|
|
1360
|
-
};
|
|
1361
|
-
const defaultDevCommandDependencies = {
|
|
1362
|
-
createWatcher: createDevWatcher,
|
|
1363
|
-
getCliFilePath: () => fileURLToPath(import.meta.url),
|
|
1364
|
-
getIntro: intro,
|
|
1365
|
-
getLog: log,
|
|
1366
|
-
getOpen: open,
|
|
1367
|
-
parsePortValue: parsePort,
|
|
1368
|
-
removeDirectory: fs.rm,
|
|
1369
|
-
resolveDevPortValue: resolveDevPort,
|
|
1370
|
-
resolveDocsRootValue: resolveDocsRoot,
|
|
1371
|
-
resolveServer: resolveDevServer,
|
|
1372
|
-
shutdownChild: shutdownChildProcess,
|
|
1373
|
-
spawnServer: spawnDevServer,
|
|
1374
|
-
validateDocsRootValue: validateDocsRoot,
|
|
1375
|
-
waitForServerReady: waitForServer
|
|
1376
|
-
};
|
|
1377
|
-
const devCommand = async ({ dir, openBrowser, port: portValue }, dependencies = defaultDevCommandDependencies) => {
|
|
1378
|
-
const cliLog = dependencies.getLog;
|
|
1379
|
-
dependencies.getIntro(chalk.bold("blodemd dev"));
|
|
1380
|
-
try {
|
|
1381
|
-
const port = dependencies.parsePortValue(portValue);
|
|
1382
|
-
const resolvedPort = await dependencies.resolveDevPortValue(port);
|
|
1383
|
-
const root = await dependencies.resolveDocsRootValue(dir);
|
|
1384
|
-
await dependencies.validateDocsRootValue(root);
|
|
1385
|
-
const cliFilePath = dependencies.getCliFilePath();
|
|
1386
|
-
const server = await dependencies.resolveServer(cliFilePath);
|
|
1387
|
-
const localUrl = `http://localhost:${resolvedPort}`;
|
|
1388
|
-
cliLog.info(`Docs root: ${chalk.cyan(root)}`);
|
|
1389
|
-
const child = dependencies.spawnServer(server, {
|
|
1390
|
-
port: resolvedPort,
|
|
1391
|
-
root
|
|
1392
|
-
});
|
|
1393
|
-
let watcher = null;
|
|
1394
|
-
let shuttingDown = false;
|
|
1395
|
-
const closeAll = async () => {
|
|
1396
|
-
if (shuttingDown) return;
|
|
1397
|
-
shuttingDown = true;
|
|
1398
|
-
if (watcher) {
|
|
1399
|
-
await watcher.close();
|
|
1400
|
-
watcher = null;
|
|
1401
|
-
}
|
|
1402
|
-
await dependencies.shutdownChild(child);
|
|
1403
|
-
if (server.mode === "standalone") await dependencies.removeDirectory(server.runtimeRoot, {
|
|
1404
|
-
force: true,
|
|
1405
|
-
recursive: true
|
|
1406
|
-
});
|
|
1407
|
-
};
|
|
1408
|
-
process.once("SIGINT", closeAll);
|
|
1409
|
-
process.once("SIGTERM", closeAll);
|
|
1410
|
-
try {
|
|
1411
|
-
await dependencies.waitForServerReady({
|
|
1412
|
-
child,
|
|
1413
|
-
port: resolvedPort
|
|
1414
|
-
});
|
|
1415
|
-
watcher = await dependencies.createWatcher({
|
|
1416
|
-
port: resolvedPort,
|
|
1417
|
-
root
|
|
1418
|
-
});
|
|
1419
|
-
cliLog.success(`Dev server running at ${chalk.cyan(localUrl)}`);
|
|
1420
|
-
if (openBrowser) await dependencies.getOpen(localUrl);
|
|
1421
|
-
const [code, signal] = await once(child, "exit");
|
|
1422
|
-
if (shuttingDown || signal === "SIGINT" || signal === "SIGTERM") return;
|
|
1423
|
-
if (code !== 0) throw new CliError(`The local dev server exited with code ${code ?? "unknown"}.`, EXIT_CODES.ERROR);
|
|
1424
|
-
} finally {
|
|
1425
|
-
await closeAll();
|
|
1426
|
-
process.removeListener("SIGINT", closeAll);
|
|
1427
|
-
process.removeListener("SIGTERM", closeAll);
|
|
1428
|
-
}
|
|
1429
|
-
} catch (error) {
|
|
1430
|
-
const cliError = toCliError(error);
|
|
1431
|
-
cliLog.error(cliError.message);
|
|
1432
|
-
if (cliError.hint) cliLog.info(cliError.hint);
|
|
1433
|
-
process.exitCode = cliError.exitCode;
|
|
1434
|
-
}
|
|
1435
|
-
};
|
|
1436
|
-
//#endregion
|
|
1437
|
-
//#region src/fs-utils.ts
|
|
1438
|
-
const writeFileIfMissing = async (filePath, content) => {
|
|
1439
|
-
try {
|
|
1440
|
-
await fs.writeFile(filePath, content, { flag: "wx" });
|
|
1441
|
-
} catch (error) {
|
|
1442
|
-
const { code } = error;
|
|
1443
|
-
if (code === "EEXIST") {
|
|
1444
|
-
if ((await fs.stat(filePath).catch(() => null))?.isFile()) return;
|
|
1445
|
-
}
|
|
1446
|
-
throw error;
|
|
1447
|
-
}
|
|
1448
|
-
};
|
|
1449
|
-
const writeSymlinkIfMissing = async (filePath, target, options) => {
|
|
1450
|
-
const lstat = options?.lstat ?? fs.lstat;
|
|
1451
|
-
const symlink = options?.symlink ?? fs.symlink;
|
|
1452
|
-
try {
|
|
1453
|
-
await symlink(target, filePath);
|
|
1454
|
-
} catch (error) {
|
|
1455
|
-
const { code } = error;
|
|
1456
|
-
if (code === "EEXIST") {
|
|
1457
|
-
const existing = await lstat(filePath).catch(() => null);
|
|
1458
|
-
if (existing?.isFile() || existing?.isSymbolicLink()) return;
|
|
1459
|
-
}
|
|
1460
|
-
if (options?.fallbackContent && (code === "EINVAL" || code === "ENOTSUP" || code === "EPERM" || code === "UNKNOWN")) {
|
|
1461
|
-
await writeFileIfMissing(filePath, options.fallbackContent);
|
|
1462
|
-
return;
|
|
1463
|
-
}
|
|
1464
|
-
throw error;
|
|
1465
|
-
}
|
|
1466
|
-
};
|
|
1467
|
-
const findExistingPaths = async (root, relativePaths) => {
|
|
1468
|
-
return (await Promise.all(relativePaths.map(async (relativePath) => {
|
|
1469
|
-
return await fs.lstat(path.join(root, relativePath)).catch(() => null) ? relativePath : null;
|
|
1470
|
-
}))).filter((relativePath) => relativePath !== null).toSorted((left, right) => left.localeCompare(right));
|
|
1471
|
-
};
|
|
1472
|
-
//#endregion
|
|
1473
|
-
//#region src/project-config.ts
|
|
1474
|
-
const LEGACY_PROJECT_NAME_FALLBACK_WARNING = "docs.json.slug is recommended. Falling back to docs.json.name as the deployment slug is deprecated.";
|
|
1475
|
-
const validateProjectSlug = (value) => {
|
|
1476
|
-
const trimmed = value?.trim();
|
|
1477
|
-
if (!trimmed) return "Project slug is required.";
|
|
1478
|
-
const normalized = slugify(trimmed);
|
|
1479
|
-
if (!normalized) return "Use at least one letter or number.";
|
|
1480
|
-
if (normalized !== trimmed) return `Use lowercase letters, numbers, and hyphens. Try "${normalized}".`;
|
|
1481
|
-
};
|
|
1482
|
-
const deriveDisplayNameFromProjectSlug = (projectSlug) => projectSlug.split("-").filter(Boolean).map((segment) => segment[0]?.toUpperCase() + segment.slice(1)).join(" ");
|
|
1483
|
-
const resolveProjectTarget = (options) => {
|
|
1484
|
-
if (options.cliProject) return {
|
|
1485
|
-
project: options.cliProject,
|
|
1486
|
-
usedLegacyNameFallback: false
|
|
1487
|
-
};
|
|
1488
|
-
if (options.envProject) return {
|
|
1489
|
-
project: options.envProject,
|
|
1490
|
-
usedLegacyNameFallback: false
|
|
1491
|
-
};
|
|
1492
|
-
if (options.config.slug) return {
|
|
1493
|
-
project: options.config.slug,
|
|
1494
|
-
usedLegacyNameFallback: false
|
|
1495
|
-
};
|
|
1496
|
-
if (options.config.name) return {
|
|
1497
|
-
project: options.config.name,
|
|
1498
|
-
usedLegacyNameFallback: true
|
|
1499
|
-
};
|
|
1500
|
-
return {
|
|
1501
|
-
project: void 0,
|
|
1502
|
-
usedLegacyNameFallback: false
|
|
1503
|
-
};
|
|
1504
|
-
};
|
|
1505
|
-
const getProjectSlugError = (project) => {
|
|
1506
|
-
if (!project) return;
|
|
1507
|
-
return validateProjectSlug(project);
|
|
1206
|
+
const registerAnalyticsCommand = (program) => {
|
|
1207
|
+
const analytics = program.command("analytics").description("Manage tenant analytics integrations (GA4, PostHog)");
|
|
1208
|
+
analytics.command("get").description("Show the analytics config for a project").option("--project <slug>", "project slug (env: BLODEMD_PROJECT)").option("--api-url <url>", "API URL (env: BLODEMD_API_URL)").option("--json", "print as JSON").action(async (options) => {
|
|
1209
|
+
await runAction("Analytics get failed", () => runGet(options));
|
|
1210
|
+
});
|
|
1211
|
+
const set = analytics.command("set").description("Set an analytics integration");
|
|
1212
|
+
set.command("ga4").description("Set the Google Analytics 4 measurement ID").argument("<measurementId>", "GA4 measurement ID (G-XXXXXXXXXX)", parseGa4MeasurementId).option("--project <slug>", "project slug (env: BLODEMD_PROJECT)").option("--api-url <url>", "API URL (env: BLODEMD_API_URL)").action(async (measurementId, options) => {
|
|
1213
|
+
await runAction("Set GA4 failed", () => runSetGa4(measurementId, options));
|
|
1214
|
+
});
|
|
1215
|
+
set.command("posthog").description("Set the PostHog project key").argument("<projectKey>", "PostHog project key (phc_...)", parsePosthogProjectKey).option("--host <url>", "PostHog host (default: https://us.i.posthog.com)", parsePosthogHost).option("--project <slug>", "project slug (env: BLODEMD_PROJECT)").option("--api-url <url>", "API URL (env: BLODEMD_API_URL)").action(async (projectKey, options) => {
|
|
1216
|
+
await runAction("Set PostHog failed", () => runSetPosthog(projectKey, options));
|
|
1217
|
+
});
|
|
1218
|
+
analytics.command("unset").description("Remove an analytics integration").argument("<provider>", "provider to remove (ga4 | posthog)", parseProvider).option("--project <slug>", "project slug (env: BLODEMD_PROJECT)").option("--api-url <url>", "API URL (env: BLODEMD_API_URL)").action(async (provider, options) => {
|
|
1219
|
+
await runAction("Unset failed", () => runUnset(provider, options));
|
|
1220
|
+
});
|
|
1508
1221
|
};
|
|
1509
1222
|
//#endregion
|
|
1510
1223
|
//#region src/scaffold.ts
|
|
1511
1224
|
const SCAFFOLD_TEMPLATES = ["minimal", "starter"];
|
|
1512
1225
|
const DEFAULT_SCAFFOLD_DIRECTORY = "docs";
|
|
1226
|
+
const DEFAULT_PROJECT_SLUG = "my-project";
|
|
1513
1227
|
const stringifyJson = (value) => `${JSON.stringify(value, null, 2)}\n`;
|
|
1514
1228
|
const escapeXmlText = (value) => value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll("\"", """).replaceAll("'", "'");
|
|
1515
1229
|
const isScaffoldTemplate = (value) => SCAFFOLD_TEMPLATES.includes(value);
|
|
1516
|
-
const normalizeProjectSlug = (value) => slugify(value) ||
|
|
1230
|
+
const normalizeProjectSlug = (value) => slugify(value) || DEFAULT_PROJECT_SLUG;
|
|
1517
1231
|
const resolveScaffoldDirectory = (directory) => directory?.trim() || "docs";
|
|
1518
1232
|
const deriveDefaultProjectSlug = (directory, cwd) => {
|
|
1519
1233
|
const resolvedDirectory = resolveScaffoldDirectory(directory);
|
|
@@ -1866,42 +1580,87 @@ const createStarterFiles = (projectSlug, displayName) => {
|
|
|
1866
1580
|
];
|
|
1867
1581
|
};
|
|
1868
1582
|
const getScaffoldFiles = (template, options) => {
|
|
1869
|
-
const projectSlug = options?.projectSlug ??
|
|
1583
|
+
const projectSlug = options?.projectSlug ?? DEFAULT_PROJECT_SLUG;
|
|
1870
1584
|
const displayName = options?.displayName ?? deriveDisplayNameFromProjectSlug(projectSlug);
|
|
1871
1585
|
return template === "starter" ? createStarterFiles(projectSlug, displayName) : createMinimalFiles(projectSlug, displayName);
|
|
1872
1586
|
};
|
|
1873
1587
|
//#endregion
|
|
1874
|
-
//#region src/
|
|
1875
|
-
const
|
|
1876
|
-
const
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
};
|
|
1887
|
-
if (options.currentDirectoryEntries.length === 0) return {
|
|
1888
|
-
directory: ".",
|
|
1889
|
-
kind: "target"
|
|
1890
|
-
};
|
|
1891
|
-
return { kind: "prompt" };
|
|
1892
|
-
};
|
|
1893
|
-
const resolveDirectoryFromAction = (action, subdirectory) => {
|
|
1894
|
-
if (action === "scaffold-current-directory") return ".";
|
|
1895
|
-
if (action === "create-in-subdirectory") return subdirectory?.trim() || "docs";
|
|
1588
|
+
//#region src/command-utils.ts
|
|
1589
|
+
const readGitValue = (gitArgs) => {
|
|
1590
|
+
const result = spawnSync("git", gitArgs, {
|
|
1591
|
+
encoding: "utf8",
|
|
1592
|
+
stdio: [
|
|
1593
|
+
"ignore",
|
|
1594
|
+
"pipe",
|
|
1595
|
+
"ignore"
|
|
1596
|
+
]
|
|
1597
|
+
});
|
|
1598
|
+
if (result.status !== 0) return;
|
|
1599
|
+
return result.stdout.trim() || void 0;
|
|
1896
1600
|
};
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
const
|
|
1900
|
-
const
|
|
1901
|
-
const
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1601
|
+
const shouldSkipEntry = (name) => name.startsWith(".") || name === "node_modules";
|
|
1602
|
+
const collectFiles = async (root, directory = root) => {
|
|
1603
|
+
const entries = await fs.readdir(directory, { withFileTypes: true });
|
|
1604
|
+
const files = [];
|
|
1605
|
+
for (const entry of entries) {
|
|
1606
|
+
if (shouldSkipEntry(entry.name)) continue;
|
|
1607
|
+
const absolutePath = path.join(directory, entry.name);
|
|
1608
|
+
const relativePath = path.relative(root, absolutePath).split(path.sep).join("/");
|
|
1609
|
+
if (entry.isDirectory()) {
|
|
1610
|
+
files.push(...await collectFiles(root, absolutePath));
|
|
1611
|
+
continue;
|
|
1612
|
+
}
|
|
1613
|
+
if (entry.isFile()) {
|
|
1614
|
+
if (shouldIgnoreRootDocsFile(relativePath)) continue;
|
|
1615
|
+
files.push(absolutePath);
|
|
1616
|
+
}
|
|
1617
|
+
}
|
|
1618
|
+
return files.toSorted((left, right) => left.localeCompare(right));
|
|
1619
|
+
};
|
|
1620
|
+
const reportCommandError = (prefix, error) => {
|
|
1621
|
+
const cliError = toCliError(error);
|
|
1622
|
+
log.error(`${prefix}: ${cliError.message}`);
|
|
1623
|
+
if (cliError.hint) log.info(cliError.hint);
|
|
1624
|
+
log.info("Failed");
|
|
1625
|
+
process.exitCode = cliError.exitCode;
|
|
1626
|
+
};
|
|
1627
|
+
const parseScaffoldTemplate = (value) => {
|
|
1628
|
+
if (isScaffoldTemplate(value)) return value;
|
|
1629
|
+
throw new InvalidArgumentError(`Expected one of: ${SCAFFOLD_TEMPLATES.join(", ")}.`);
|
|
1630
|
+
};
|
|
1631
|
+
const parseProjectSlug = (value) => {
|
|
1632
|
+
const validationError = validateProjectSlug(value);
|
|
1633
|
+
if (validationError) throw new InvalidArgumentError(validationError);
|
|
1634
|
+
return value.trim();
|
|
1635
|
+
};
|
|
1636
|
+
//#endregion
|
|
1637
|
+
//#region src/http.ts
|
|
1638
|
+
const readJson = async (response) => {
|
|
1639
|
+
const responseText = await response.text();
|
|
1640
|
+
if (!responseText) return null;
|
|
1641
|
+
try {
|
|
1642
|
+
return JSON.parse(responseText);
|
|
1643
|
+
} catch {
|
|
1644
|
+
return responseText;
|
|
1645
|
+
}
|
|
1646
|
+
};
|
|
1647
|
+
const requestJson = async (url, init, message) => {
|
|
1648
|
+
const response = await fetch(url, init);
|
|
1649
|
+
const data = await readJson(response);
|
|
1650
|
+
if (!response.ok) {
|
|
1651
|
+
const detail = typeof data === "string" ? data : JSON.stringify(data ?? {}, null, 2);
|
|
1652
|
+
throw new Error(`${message}: ${response.status} ${detail}`);
|
|
1653
|
+
}
|
|
1654
|
+
return data;
|
|
1655
|
+
};
|
|
1656
|
+
//#endregion
|
|
1657
|
+
//#region src/oauth-callback.ts
|
|
1658
|
+
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>";
|
|
1659
|
+
const escapeHtml = (text) => text.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll("\"", """);
|
|
1660
|
+
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>`;
|
|
1661
|
+
const waitForOAuthCode = (options) => {
|
|
1662
|
+
const host = options.redirectUrl.hostname;
|
|
1663
|
+
const port = Number(options.redirectUrl.port);
|
|
1905
1664
|
const { pathname } = options.redirectUrl;
|
|
1906
1665
|
if (!Number.isInteger(port) || port <= 0) return Promise.reject(new CliError("OAuth redirect URL requires an explicit port", EXIT_CODES.ERROR));
|
|
1907
1666
|
return new Promise((resolve, reject) => {
|
|
@@ -1917,7 +1676,7 @@ const waitForOAuthCode = (options) => {
|
|
|
1917
1676
|
});
|
|
1918
1677
|
for (const socket of sockets) socket.destroy();
|
|
1919
1678
|
};
|
|
1920
|
-
const httpServer = createServer
|
|
1679
|
+
const httpServer = createServer((request, response) => {
|
|
1921
1680
|
if (!request.url) {
|
|
1922
1681
|
response.writeHead(400, { "content-type": "text/html; charset=utf-8" });
|
|
1923
1682
|
response.end(errorHtml("Missing request URL"));
|
|
@@ -1974,160 +1733,610 @@ const createOAuthState = () => randomBytes(24).toString("hex");
|
|
|
1974
1733
|
const createCodeVerifier = () => randomBytes(64).toString("base64url");
|
|
1975
1734
|
const createCodeChallenge = (verifier) => createHash("sha256").update(verifier).digest().toString("base64url");
|
|
1976
1735
|
//#endregion
|
|
1977
|
-
//#region src/
|
|
1978
|
-
const
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
const match = /^v?(\d+)\.(\d+)\.(\d+)/.exec(input.trim());
|
|
1986
|
-
if (!match) return null;
|
|
1987
|
-
const [, majorText = "", minorText = "", patchText = ""] = match;
|
|
1988
|
-
if (!majorText || !minorText || !patchText) return null;
|
|
1989
|
-
const major = Number.parseInt(majorText, 10);
|
|
1990
|
-
const minor = Number.parseInt(minorText, 10);
|
|
1991
|
-
const patch = Number.parseInt(patchText, 10);
|
|
1992
|
-
if ([
|
|
1993
|
-
major,
|
|
1994
|
-
minor,
|
|
1995
|
-
patch
|
|
1996
|
-
].some((value) => Number.isNaN(value))) return null;
|
|
1997
|
-
return [
|
|
1998
|
-
major,
|
|
1999
|
-
minor,
|
|
2000
|
-
patch
|
|
2001
|
-
];
|
|
2002
|
-
};
|
|
2003
|
-
const isSupportedNodeVersion = (version) => {
|
|
2004
|
-
const parsed = parseVersion(version);
|
|
2005
|
-
if (!parsed) return false;
|
|
2006
|
-
const [major, minor, patch] = parsed;
|
|
2007
|
-
const [minMajor, minMinor, minPatch] = MIN_SUPPORTED_NODE_VERSION;
|
|
2008
|
-
if (major !== minMajor) return major > minMajor;
|
|
2009
|
-
if (minor !== minMinor) return minor > minMinor;
|
|
2010
|
-
return patch >= minPatch;
|
|
2011
|
-
};
|
|
2012
|
-
const assertSupportedNodeVersion = (version = process.versions.node) => {
|
|
2013
|
-
if (isSupportedNodeVersion(version)) return;
|
|
2014
|
-
throw new CliError(`blodemd requires Node.js ${SUPPORTED_NODE_RANGE}. Current version: ${version}.`, EXIT_CODES.VALIDATION, "Install a supported Node.js version and try again.");
|
|
1736
|
+
//#region src/validation.ts
|
|
1737
|
+
const MAX_PORT = 65535;
|
|
1738
|
+
const parsePositiveInteger = (value, label) => {
|
|
1739
|
+
const trimmed = value.trim();
|
|
1740
|
+
if (!/^\d+$/u.test(trimmed)) throw new CliError(`${label} must be a positive integer.`, EXIT_CODES.VALIDATION);
|
|
1741
|
+
const parsed = Number(trimmed);
|
|
1742
|
+
if (!Number.isSafeInteger(parsed) || parsed <= 0) throw new CliError(`${label} must be a positive integer.`, EXIT_CODES.VALIDATION);
|
|
1743
|
+
return parsed;
|
|
2015
1744
|
};
|
|
2016
|
-
const
|
|
2017
|
-
const
|
|
2018
|
-
|
|
2019
|
-
return
|
|
1745
|
+
const parsePort = (value, label = "Port") => {
|
|
1746
|
+
const parsed = parsePositiveInteger(value, label);
|
|
1747
|
+
if (parsed > MAX_PORT) throw new CliError(`${label} must be between 1 and ${MAX_PORT}.`, EXIT_CODES.VALIDATION);
|
|
1748
|
+
return parsed;
|
|
2020
1749
|
};
|
|
2021
1750
|
//#endregion
|
|
2022
|
-
//#region src/
|
|
2023
|
-
const
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
".mdx": "text/markdown; charset=utf-8",
|
|
2030
|
-
".svg": "image/svg+xml",
|
|
2031
|
-
".txt": "text/plain; charset=utf-8",
|
|
2032
|
-
".yaml": "application/yaml; charset=utf-8",
|
|
2033
|
-
".yml": "application/yaml; charset=utf-8"
|
|
2034
|
-
};
|
|
2035
|
-
const normalizeRelativePath = (root, filePath) => path.relative(root, filePath).split(path.sep).join("/");
|
|
2036
|
-
const getContentType = (filePath) => TEXT_CONTENT_TYPES[path.extname(filePath).toLowerCase()] ?? "application/octet-stream";
|
|
2037
|
-
const estimateUploadItemBytes = (item) => item.contentBase64.length + item.path.length + 64;
|
|
2038
|
-
const createUploadBatchItem = async (filePath, root, readFile) => {
|
|
2039
|
-
return {
|
|
2040
|
-
contentBase64: (await readFile(filePath)).toString("base64"),
|
|
2041
|
-
contentType: getContentType(filePath),
|
|
2042
|
-
path: normalizeRelativePath(root, filePath)
|
|
2043
|
-
};
|
|
1751
|
+
//#region src/commands/auth.ts
|
|
1752
|
+
const fetchUserEmail = async (apiUrl, token) => {
|
|
1753
|
+
try {
|
|
1754
|
+
return (await requestJson(`${apiUrl}/auth/me`, { headers: { Authorization: `Bearer ${token}` } }, "Failed to fetch user info")).email;
|
|
1755
|
+
} catch {
|
|
1756
|
+
return null;
|
|
1757
|
+
}
|
|
2044
1758
|
};
|
|
2045
|
-
const
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
1759
|
+
const registerAuthCommands = (program) => {
|
|
1760
|
+
program.command("login").description("Authenticate with Blode.md via GitHub in your browser").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) => {
|
|
1761
|
+
intro(chalk.bold("blodemd login"));
|
|
1762
|
+
try {
|
|
1763
|
+
const { authorizeUrl, tokenUrl } = buildOAuthUrls(resolveSupabaseConfig());
|
|
1764
|
+
const clientId = OAUTH_CLIENT_ID;
|
|
1765
|
+
const port = parsePort(options.port);
|
|
1766
|
+
const timeoutSeconds = parsePositiveInteger(options.timeout, "Timeout");
|
|
1767
|
+
const redirectUrl = new URL(`http://127.0.0.1:${port}${DEFAULT_OAUTH_CALLBACK_PATH}`);
|
|
1768
|
+
const state = createOAuthState();
|
|
1769
|
+
const codeVerifier = createCodeVerifier();
|
|
1770
|
+
const codeChallenge = createCodeChallenge(codeVerifier);
|
|
1771
|
+
const authUrl = new URL(authorizeUrl);
|
|
1772
|
+
authUrl.searchParams.set("response_type", "code");
|
|
1773
|
+
authUrl.searchParams.set("client_id", clientId);
|
|
1774
|
+
authUrl.searchParams.set("redirect_uri", redirectUrl.toString());
|
|
1775
|
+
authUrl.searchParams.set("code_challenge", codeChallenge);
|
|
1776
|
+
authUrl.searchParams.set("code_challenge_method", "S256");
|
|
1777
|
+
authUrl.searchParams.set("state", state);
|
|
1778
|
+
authUrl.searchParams.set("scope", "openid email profile");
|
|
1779
|
+
authUrl.searchParams.set("provider", "github");
|
|
1780
|
+
const callbackPromise = waitForOAuthCode({
|
|
1781
|
+
expectedState: state,
|
|
1782
|
+
redirectUrl,
|
|
1783
|
+
timeoutMs: timeoutSeconds * 1e3
|
|
1784
|
+
});
|
|
1785
|
+
if (options.open) {
|
|
1786
|
+
log.info("Opening browser for authentication...");
|
|
1787
|
+
log.info(`If the browser doesn't open, visit: ${chalk.cyan(authUrl.toString())}`);
|
|
1788
|
+
await open(authUrl.toString());
|
|
1789
|
+
} else {
|
|
1790
|
+
log.info("Open this URL to continue authentication:");
|
|
1791
|
+
log.info(chalk.cyan(authUrl.toString()));
|
|
1792
|
+
}
|
|
1793
|
+
const code = await callbackPromise;
|
|
1794
|
+
const storedSession = tokenResponseToStoredSession(await exchangeAuthorizationCode({
|
|
1795
|
+
clientId,
|
|
1796
|
+
tokenUrl
|
|
1797
|
+
}, code, codeVerifier, redirectUrl.toString()));
|
|
1798
|
+
await writeStoredAuthSession(storedSession);
|
|
1799
|
+
const email = storedSession.user?.email ?? await fetchUserEmail(process.env["BLODEMD_API_URL"] ?? "https://api.blode.md", storedSession.accessToken);
|
|
1800
|
+
if (email) log.success(`Logged in as ${chalk.cyan(email)}`);
|
|
1801
|
+
else log.success("Logged in successfully.");
|
|
1802
|
+
log.info("Done");
|
|
1803
|
+
} catch (error) {
|
|
1804
|
+
reportCommandError("Login failed", error);
|
|
2057
1805
|
}
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
1806
|
+
});
|
|
1807
|
+
program.command("logout").description("Remove stored credentials").action(async () => {
|
|
1808
|
+
intro(chalk.bold("blodemd logout"));
|
|
1809
|
+
try {
|
|
1810
|
+
let existing = false;
|
|
1811
|
+
try {
|
|
1812
|
+
await fs.access(CREDENTIALS_FILE);
|
|
1813
|
+
existing = true;
|
|
1814
|
+
} catch {
|
|
1815
|
+
existing = false;
|
|
1816
|
+
}
|
|
1817
|
+
await clearStoredCredentials();
|
|
1818
|
+
if (existing) log.success("Credentials removed.");
|
|
1819
|
+
else log.info("No stored credentials found.");
|
|
1820
|
+
log.info("Done");
|
|
1821
|
+
} catch (error) {
|
|
1822
|
+
reportCommandError("Logout failed", error);
|
|
1823
|
+
}
|
|
1824
|
+
});
|
|
1825
|
+
program.command("whoami").description("Show current authentication").action(async () => {
|
|
1826
|
+
try {
|
|
1827
|
+
const resolved = await resolveAuthToken();
|
|
1828
|
+
if (!resolved) {
|
|
1829
|
+
log.warn("Not logged in. Run \"blodemd login\" to authenticate.");
|
|
1830
|
+
return;
|
|
1831
|
+
}
|
|
1832
|
+
const status = resolveTokenStatus(resolved);
|
|
1833
|
+
const email = resolved.user?.email ?? await fetchUserEmail(process.env["BLODEMD_API_URL"] ?? "https://api.blode.md", resolved.token);
|
|
1834
|
+
if (email) log.info(`Logged in as ${chalk.cyan(email)}`);
|
|
1835
|
+
else log.info("Logged in (could not fetch user details).");
|
|
1836
|
+
if (resolved.expiresAt && status.expired) log.warn("Session has expired. Run \"blodemd login\" to re-authenticate.");
|
|
1837
|
+
} catch (error) {
|
|
1838
|
+
reportCommandError("Whoami failed", error);
|
|
1839
|
+
}
|
|
1840
|
+
});
|
|
2062
1841
|
};
|
|
2063
1842
|
//#endregion
|
|
2064
|
-
//#region src/
|
|
2065
|
-
const
|
|
2066
|
-
const
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
1843
|
+
//#region src/dev/watcher.ts
|
|
1844
|
+
const INVALIDATE_ENDPOINT = "/blodemd-dev/invalidate";
|
|
1845
|
+
const WATCH_DEBOUNCE_MS = 100;
|
|
1846
|
+
const normalizeRelativePath$1 = (root, filePath) => path.relative(root, filePath).split(path.sep).join("/");
|
|
1847
|
+
const isDirectoryEvent = (event) => event === "addDir" || event === "unlinkDir";
|
|
1848
|
+
const createDevWatcher = ({ port, root }) => {
|
|
1849
|
+
const watcher = watch(root, {
|
|
1850
|
+
ignoreInitial: true,
|
|
1851
|
+
ignored: [
|
|
1852
|
+
"**/.git/**",
|
|
1853
|
+
"**/.next/**",
|
|
1854
|
+
"**/dist/**",
|
|
1855
|
+
"**/node_modules/**"
|
|
2073
1856
|
]
|
|
2074
1857
|
});
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
const
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
1858
|
+
let flushTimer = null;
|
|
1859
|
+
let pendingKind = "content";
|
|
1860
|
+
const pendingPaths = /* @__PURE__ */ new Set();
|
|
1861
|
+
const flush = async () => {
|
|
1862
|
+
flushTimer = null;
|
|
1863
|
+
const paths = [...pendingPaths];
|
|
1864
|
+
const kind = pendingKind;
|
|
1865
|
+
pendingPaths.clear();
|
|
1866
|
+
pendingKind = "content";
|
|
1867
|
+
if (!paths.length) return;
|
|
1868
|
+
try {
|
|
1869
|
+
const response = await fetch(`http://127.0.0.1:${port}${INVALIDATE_ENDPOINT}`, {
|
|
1870
|
+
body: JSON.stringify({
|
|
1871
|
+
kind,
|
|
1872
|
+
paths
|
|
1873
|
+
}),
|
|
1874
|
+
headers: { "Content-Type": "application/json" },
|
|
1875
|
+
method: "POST"
|
|
1876
|
+
});
|
|
1877
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
1878
|
+
} catch (error) {
|
|
1879
|
+
log.error(`Failed to invalidate preview cache: ${error instanceof Error ? error.message : "unknown error"}`);
|
|
2089
1880
|
}
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
1881
|
+
};
|
|
1882
|
+
watcher.on("all", (event, changedPath) => {
|
|
1883
|
+
if (isDirectoryEvent(event)) return;
|
|
1884
|
+
const relativePath = normalizeRelativePath$1(root, changedPath);
|
|
1885
|
+
pendingPaths.add(relativePath);
|
|
1886
|
+
if (path.basename(changedPath) === "docs.json") pendingKind = "config";
|
|
1887
|
+
if (flushTimer) clearTimeout(flushTimer);
|
|
1888
|
+
flushTimer = setTimeout(() => {
|
|
1889
|
+
flush();
|
|
1890
|
+
}, WATCH_DEBOUNCE_MS);
|
|
1891
|
+
});
|
|
1892
|
+
return { async close() {
|
|
1893
|
+
if (flushTimer) {
|
|
1894
|
+
clearTimeout(flushTimer);
|
|
1895
|
+
await flush();
|
|
2093
1896
|
}
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
};
|
|
2097
|
-
const readJson = async (response) => {
|
|
2098
|
-
const responseText = await response.text();
|
|
2099
|
-
if (!responseText) return null;
|
|
2100
|
-
try {
|
|
2101
|
-
return JSON.parse(responseText);
|
|
2102
|
-
} catch {
|
|
2103
|
-
return responseText;
|
|
2104
|
-
}
|
|
1897
|
+
await watcher.close();
|
|
1898
|
+
} };
|
|
2105
1899
|
};
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
1900
|
+
//#endregion
|
|
1901
|
+
//#region src/dev/command.ts
|
|
1902
|
+
const DEV_READY_ENDPOINT = "/blodemd-dev/version";
|
|
1903
|
+
const DEV_READY_TIMEOUT_MS = 45e3;
|
|
1904
|
+
const DEV_PORT_SCAN_LIMIT = 10;
|
|
1905
|
+
const DEV_SHUTDOWN_TIMEOUT_MS = 5e3;
|
|
1906
|
+
const LOCALHOST = "127.0.0.1";
|
|
1907
|
+
const RUNTIME_EXCLUDE_DIRS = new Set([
|
|
1908
|
+
".next",
|
|
1909
|
+
".turbo",
|
|
1910
|
+
"node_modules"
|
|
1911
|
+
]);
|
|
1912
|
+
const STANDALONE_RUNTIME_MAX_AGE_MS = 1440 * 60 * 1e3;
|
|
1913
|
+
const STANDALONE_RUNTIME_PREFIX = "standalone-runtime-";
|
|
1914
|
+
const TURBOPACK_ARGS = ["dev", "--turbopack"];
|
|
1915
|
+
const fileExists = async (filePath) => {
|
|
1916
|
+
try {
|
|
1917
|
+
await fs.access(filePath);
|
|
1918
|
+
return true;
|
|
1919
|
+
} catch {
|
|
1920
|
+
return false;
|
|
2112
1921
|
}
|
|
2113
|
-
return data;
|
|
2114
1922
|
};
|
|
2115
|
-
const
|
|
2116
|
-
const
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
1923
|
+
const resolveCommonAncestor = (pathsToCompare) => {
|
|
1924
|
+
const [firstPath, ...restPaths] = pathsToCompare;
|
|
1925
|
+
if (!firstPath) return path.sep;
|
|
1926
|
+
const first = path.resolve(firstPath);
|
|
1927
|
+
const { root } = path.parse(first);
|
|
1928
|
+
const firstSegments = first.slice(root.length).split(path.sep).filter(Boolean);
|
|
1929
|
+
const sharedSegments = [];
|
|
1930
|
+
for (const [index, segment] of firstSegments.entries()) {
|
|
1931
|
+
if (!restPaths.every((candidatePath) => {
|
|
1932
|
+
const candidate = path.resolve(candidatePath);
|
|
1933
|
+
if (path.parse(candidate).root !== root) return false;
|
|
1934
|
+
return candidate.slice(root.length).split(path.sep).filter(Boolean)[index] === segment;
|
|
1935
|
+
})) break;
|
|
1936
|
+
sharedSegments.push(segment);
|
|
1937
|
+
}
|
|
1938
|
+
return path.join(root, ...sharedSegments);
|
|
2121
1939
|
};
|
|
2122
|
-
const
|
|
2123
|
-
|
|
2124
|
-
|
|
1940
|
+
const cleanupStandaloneRuntimeRoots = async (configDir, maxAgeMs = STANDALONE_RUNTIME_MAX_AGE_MS) => {
|
|
1941
|
+
const cutoff = Date.now() - maxAgeMs;
|
|
1942
|
+
const entries = await fs.readdir(configDir, { withFileTypes: true });
|
|
1943
|
+
await Promise.all(entries.filter((entry) => entry.isDirectory() && entry.name.startsWith(STANDALONE_RUNTIME_PREFIX)).map(async (entry) => {
|
|
1944
|
+
const entryPath = path.join(configDir, entry.name);
|
|
1945
|
+
if ((await fs.stat(entryPath)).mtimeMs >= cutoff) return;
|
|
1946
|
+
await fs.rm(entryPath, {
|
|
1947
|
+
force: true,
|
|
1948
|
+
recursive: true
|
|
1949
|
+
});
|
|
1950
|
+
}));
|
|
1951
|
+
};
|
|
1952
|
+
const probePortAvailability = async (port) => {
|
|
1953
|
+
const server = createServer$1();
|
|
1954
|
+
const listening = (async () => {
|
|
1955
|
+
await once(server, "listening");
|
|
1956
|
+
return { kind: "listening" };
|
|
1957
|
+
})();
|
|
1958
|
+
const errored = (async () => {
|
|
1959
|
+
const [error] = await once(server, "error");
|
|
1960
|
+
return {
|
|
1961
|
+
error,
|
|
1962
|
+
kind: "error"
|
|
1963
|
+
};
|
|
1964
|
+
})();
|
|
1965
|
+
server.listen({
|
|
1966
|
+
exclusive: true,
|
|
1967
|
+
host: LOCALHOST,
|
|
1968
|
+
port
|
|
1969
|
+
});
|
|
1970
|
+
const outcome = await Promise.race([listening, errored]);
|
|
1971
|
+
if (outcome.kind === "error") {
|
|
1972
|
+
if (outcome.error.code === "EADDRINUSE" || outcome.error.code === "EACCES") return false;
|
|
1973
|
+
throw outcome.error;
|
|
1974
|
+
}
|
|
1975
|
+
server.close();
|
|
1976
|
+
await once(server, "close");
|
|
1977
|
+
return true;
|
|
1978
|
+
};
|
|
1979
|
+
const resolveDevPort = async (requestedPort, probePort = probePortAvailability) => {
|
|
1980
|
+
for (let offset = 0; offset < DEV_PORT_SCAN_LIMIT; offset += 1) {
|
|
1981
|
+
const candidate = requestedPort + offset;
|
|
1982
|
+
if (candidate > 65535) break;
|
|
1983
|
+
if (await probePort(candidate)) return candidate;
|
|
1984
|
+
}
|
|
1985
|
+
throw new CliError(`No available port found within ${DEV_PORT_SCAN_LIMIT} attempts starting at ${requestedPort}.`, EXIT_CODES.ERROR, "Close the process using the port or pass a different --port value.");
|
|
1986
|
+
};
|
|
1987
|
+
const shutdownChildProcess = async (child, timeoutMs = DEV_SHUTDOWN_TIMEOUT_MS) => {
|
|
1988
|
+
if (child.exitCode !== null) return;
|
|
1989
|
+
const timer = setTimeout(() => {
|
|
1990
|
+
if (child.exitCode === null) child.kill("SIGKILL");
|
|
1991
|
+
}, timeoutMs);
|
|
1992
|
+
const exitPromise = once(child, "exit");
|
|
1993
|
+
try {
|
|
1994
|
+
child.kill("SIGTERM");
|
|
1995
|
+
} catch (error) {
|
|
1996
|
+
clearTimeout(timer);
|
|
1997
|
+
if (error.code === "ESRCH") return;
|
|
1998
|
+
throw error;
|
|
1999
|
+
}
|
|
2000
|
+
await exitPromise.finally(() => {
|
|
2001
|
+
clearTimeout(timer);
|
|
2002
|
+
});
|
|
2003
|
+
};
|
|
2004
|
+
/**
|
|
2005
|
+
* Derive the CLI npm package root from the running script path.
|
|
2006
|
+
* The CLI entry point is at `<pkg-root>/dist/cli.mjs`.
|
|
2007
|
+
*/
|
|
2008
|
+
const resolveCliPackageRoot = (cliFilePath) => path.dirname(path.dirname(cliFilePath));
|
|
2009
|
+
const copyStandaloneTree = async (sourceDir, targetDir) => {
|
|
2010
|
+
await fs.cp(sourceDir, targetDir, {
|
|
2011
|
+
filter: (source) => {
|
|
2012
|
+
const relative = path.relative(sourceDir, source);
|
|
2013
|
+
if (!relative) return true;
|
|
2014
|
+
const topSegment = relative.split(path.sep)[0] ?? "";
|
|
2015
|
+
return !RUNTIME_EXCLUDE_DIRS.has(topSegment);
|
|
2016
|
+
},
|
|
2017
|
+
recursive: true
|
|
2018
|
+
});
|
|
2019
|
+
};
|
|
2020
|
+
const isStandaloneCliInstall = async (cliPackageRoot) => {
|
|
2021
|
+
try {
|
|
2022
|
+
return (await fs.realpath(cliPackageRoot)).split(path.sep).includes("node_modules");
|
|
2023
|
+
} catch {
|
|
2024
|
+
return cliPackageRoot.split(path.sep).includes("node_modules");
|
|
2025
|
+
}
|
|
2026
|
+
};
|
|
2027
|
+
const createStandaloneRuntimeRoot = async (configDir = CONFIG_DIR) => {
|
|
2028
|
+
await fs.mkdir(configDir, { recursive: true });
|
|
2029
|
+
await cleanupStandaloneRuntimeRoots(configDir);
|
|
2030
|
+
return await fs.mkdtemp(path.join(configDir, STANDALONE_RUNTIME_PREFIX));
|
|
2031
|
+
};
|
|
2032
|
+
/**
|
|
2033
|
+
* Locate the `node_modules` directory that actually contains the CLI's
|
|
2034
|
+
* transitive dependencies. Package managers like npm/yarn-classic (and
|
|
2035
|
+
* `npx` caches) hoist shared deps above the package directory, so
|
|
2036
|
+
* `<cliPackageRoot>/node_modules` may not exist or may not contain `next`.
|
|
2037
|
+
* Resolve `next/package.json` and use the directory that owns it.
|
|
2038
|
+
*/
|
|
2039
|
+
const resolveRuntimeNodeModules = async (cliPackageRoot) => {
|
|
2040
|
+
const localNodeModules = path.join(cliPackageRoot, "node_modules");
|
|
2041
|
+
if (await fileExists(path.join(localNodeModules, "next", "package.json"))) return localNodeModules;
|
|
2042
|
+
const nextPkgPath = createRequire(path.join(cliPackageRoot, "package.json")).resolve("next/package.json");
|
|
2043
|
+
return path.dirname(path.dirname(nextPkgPath));
|
|
2044
|
+
};
|
|
2045
|
+
const materializeStandaloneRuntime = async (cliPackageRoot) => {
|
|
2046
|
+
const runtimeRoot = await createStandaloneRuntimeRoot();
|
|
2047
|
+
try {
|
|
2048
|
+
for (const dir of [
|
|
2049
|
+
"dev-server",
|
|
2050
|
+
"docs",
|
|
2051
|
+
"packages"
|
|
2052
|
+
]) await copyStandaloneTree(path.join(cliPackageRoot, dir), path.join(runtimeRoot, dir));
|
|
2053
|
+
const runtimeNodeModules = await resolveRuntimeNodeModules(cliPackageRoot);
|
|
2054
|
+
await fs.symlink(runtimeNodeModules, path.join(runtimeRoot, "node_modules"), process.platform === "win32" ? "junction" : "dir");
|
|
2055
|
+
const linkTarget = path.join(runtimeRoot, "packages", "@repo");
|
|
2056
|
+
for (const consumer of [
|
|
2057
|
+
"dev-server",
|
|
2058
|
+
"docs",
|
|
2059
|
+
"packages"
|
|
2060
|
+
]) {
|
|
2061
|
+
await fs.mkdir(path.join(runtimeRoot, consumer, "node_modules"), { recursive: true });
|
|
2062
|
+
await fs.symlink(linkTarget, path.join(runtimeRoot, consumer, "node_modules", "@repo"), process.platform === "win32" ? "junction" : "dir");
|
|
2063
|
+
}
|
|
2064
|
+
await fs.writeFile(path.join(runtimeRoot, "dev-server", "package.json"), `${JSON.stringify({
|
|
2065
|
+
dependencies: {
|
|
2066
|
+
next: "16.2.1",
|
|
2067
|
+
react: "^19.2.0",
|
|
2068
|
+
"react-dom": "^19.2.0"
|
|
2069
|
+
},
|
|
2070
|
+
devDependencies: {
|
|
2071
|
+
"@types/node": "^24.12.2",
|
|
2072
|
+
"@types/react": "19.2.14",
|
|
2073
|
+
"@types/react-dom": "19.2.3",
|
|
2074
|
+
typescript: "6.0.2"
|
|
2075
|
+
},
|
|
2076
|
+
name: "blodemd-dev-server",
|
|
2077
|
+
private: true,
|
|
2078
|
+
type: "module"
|
|
2079
|
+
}, null, 2)}\n`);
|
|
2080
|
+
return {
|
|
2081
|
+
devServerDir: path.join(runtimeRoot, "dev-server"),
|
|
2082
|
+
runtimeRoot
|
|
2083
|
+
};
|
|
2084
|
+
} catch (error) {
|
|
2085
|
+
await fs.rm(runtimeRoot, {
|
|
2086
|
+
force: true,
|
|
2087
|
+
recursive: true
|
|
2088
|
+
});
|
|
2089
|
+
throw error;
|
|
2090
|
+
}
|
|
2091
|
+
};
|
|
2092
|
+
/**
|
|
2093
|
+
* Check if a shipped dev-server exists alongside an installed CLI package.
|
|
2094
|
+
* We only use standalone mode when the package root lives under `node_modules`.
|
|
2095
|
+
*/
|
|
2096
|
+
const findStandaloneDevServer = async (cliPackageRoot) => {
|
|
2097
|
+
const devServerDir = path.join(cliPackageRoot, "dev-server");
|
|
2098
|
+
if (!await fileExists(path.join(devServerDir, "next.config.js"))) return null;
|
|
2099
|
+
if (!await isStandaloneCliInstall(cliPackageRoot)) return null;
|
|
2100
|
+
try {
|
|
2101
|
+
createRequire(path.join(cliPackageRoot, "package.json")).resolve("next/package.json");
|
|
2102
|
+
} catch {
|
|
2103
|
+
return null;
|
|
2104
|
+
}
|
|
2105
|
+
const runtime = await materializeStandaloneRuntime(cliPackageRoot);
|
|
2106
|
+
return {
|
|
2107
|
+
devServerDir: runtime.devServerDir,
|
|
2108
|
+
mode: "standalone",
|
|
2109
|
+
nextPackageRoot: cliPackageRoot,
|
|
2110
|
+
runtimeRoot: runtime.runtimeRoot
|
|
2111
|
+
};
|
|
2112
|
+
};
|
|
2113
|
+
/**
|
|
2114
|
+
* Resolve the `next` CLI binary from the blodemd package's own dependencies.
|
|
2115
|
+
*/
|
|
2116
|
+
const resolveNextBin = (cliPackageRoot) => {
|
|
2117
|
+
const nextPkgPath = createRequire(path.join(cliPackageRoot, "package.json")).resolve("next/package.json");
|
|
2118
|
+
return path.join(path.dirname(nextPkgPath), "dist", "bin", "next");
|
|
2119
|
+
};
|
|
2120
|
+
const findMonorepoRoot = async (start) => {
|
|
2121
|
+
let current = start;
|
|
2122
|
+
while (true) {
|
|
2123
|
+
const packageJsonPath = path.join(current, "package.json");
|
|
2124
|
+
if (await fileExists(packageJsonPath)) {
|
|
2125
|
+
const raw = await fs.readFile(packageJsonPath, "utf8");
|
|
2126
|
+
const workspaces = JSON.parse(raw).workspaces ?? [];
|
|
2127
|
+
if (workspaces.includes("apps/*") && workspaces.includes("packages/*")) return current;
|
|
2128
|
+
}
|
|
2129
|
+
const parent = path.dirname(current);
|
|
2130
|
+
if (parent === current) break;
|
|
2131
|
+
current = parent;
|
|
2132
|
+
}
|
|
2133
|
+
throw new CliError("Could not locate the blodemd dev server.", EXIT_CODES.ERROR, "Make sure blodemd is installed correctly (npm i blodemd).");
|
|
2134
|
+
};
|
|
2135
|
+
const resolveDevServer = async (cliFilePath) => {
|
|
2136
|
+
const standalone = await findStandaloneDevServer(resolveCliPackageRoot(cliFilePath));
|
|
2137
|
+
if (standalone) return standalone;
|
|
2138
|
+
return {
|
|
2139
|
+
mode: "monorepo",
|
|
2140
|
+
repoRoot: await findMonorepoRoot(path.dirname(cliFilePath))
|
|
2141
|
+
};
|
|
2142
|
+
};
|
|
2143
|
+
const buildDevServerLaunch = (server, { root, port }) => {
|
|
2144
|
+
if (server.mode === "standalone") return {
|
|
2145
|
+
args: [resolveNextBin(server.nextPackageRoot), ...TURBOPACK_ARGS],
|
|
2146
|
+
command: process.execPath,
|
|
2147
|
+
cwd: server.devServerDir,
|
|
2148
|
+
env: {
|
|
2149
|
+
...process.env,
|
|
2150
|
+
BLODEMD_PACKAGES_DIR: path.join(server.runtimeRoot, "packages"),
|
|
2151
|
+
BLODEMD_TURBOPACK_ROOT: resolveCommonAncestor([server.nextPackageRoot, server.runtimeRoot]),
|
|
2152
|
+
DOCS_ROOT: root,
|
|
2153
|
+
PORT: String(port)
|
|
2154
|
+
}
|
|
2155
|
+
};
|
|
2156
|
+
return {
|
|
2157
|
+
args: [
|
|
2158
|
+
"run",
|
|
2159
|
+
"dev",
|
|
2160
|
+
"--workspace=dev-server"
|
|
2161
|
+
],
|
|
2162
|
+
command: process.platform === "win32" ? "npm.cmd" : "npm",
|
|
2163
|
+
cwd: server.repoRoot,
|
|
2164
|
+
env: {
|
|
2165
|
+
...process.env,
|
|
2166
|
+
DOCS_ROOT: root,
|
|
2167
|
+
PORT: String(port)
|
|
2168
|
+
}
|
|
2169
|
+
};
|
|
2170
|
+
};
|
|
2171
|
+
const spawnDevServer = (server, options) => {
|
|
2172
|
+
const launch = buildDevServerLaunch(server, options);
|
|
2173
|
+
return spawn(launch.command, launch.args, {
|
|
2174
|
+
cwd: launch.cwd,
|
|
2175
|
+
env: launch.env,
|
|
2176
|
+
stdio: "inherit"
|
|
2177
|
+
});
|
|
2178
|
+
};
|
|
2179
|
+
const waitForServer = async ({ child, port }) => {
|
|
2180
|
+
const url = `http://localhost:${port}${DEV_READY_ENDPOINT}`;
|
|
2181
|
+
const startedAt = Date.now();
|
|
2182
|
+
while (Date.now() - startedAt < DEV_READY_TIMEOUT_MS) {
|
|
2183
|
+
if (child.exitCode !== null) throw new CliError("The local dev server exited before it became ready.", EXIT_CODES.ERROR);
|
|
2184
|
+
try {
|
|
2185
|
+
if ((await fetch(url, {
|
|
2186
|
+
cache: "no-store",
|
|
2187
|
+
headers: { accept: "application/json" }
|
|
2188
|
+
})).ok) return;
|
|
2189
|
+
} catch {}
|
|
2190
|
+
await setTimeout$1(500);
|
|
2191
|
+
}
|
|
2192
|
+
throw new CliError("Timed out waiting for the local dev server to start.", EXIT_CODES.ERROR);
|
|
2193
|
+
};
|
|
2194
|
+
const defaultDevCommandDependencies = {
|
|
2195
|
+
createWatcher: createDevWatcher,
|
|
2196
|
+
getCliFilePath: () => fileURLToPath(import.meta.url),
|
|
2197
|
+
getIntro: intro,
|
|
2198
|
+
getLog: log,
|
|
2199
|
+
getOpen: open,
|
|
2200
|
+
parsePortValue: parsePort,
|
|
2201
|
+
removeDirectory: fs.rm,
|
|
2202
|
+
resolveDevPortValue: resolveDevPort,
|
|
2203
|
+
resolveDocsRootValue: resolveDocsRoot,
|
|
2204
|
+
resolveServer: resolveDevServer,
|
|
2205
|
+
shutdownChild: shutdownChildProcess,
|
|
2206
|
+
spawnServer: spawnDevServer,
|
|
2207
|
+
validateDocsRootValue: validateDocsRoot,
|
|
2208
|
+
waitForServerReady: waitForServer
|
|
2209
|
+
};
|
|
2210
|
+
const devCommand = async ({ dir, openBrowser, port: portValue }, dependencies = defaultDevCommandDependencies) => {
|
|
2211
|
+
const cliLog = dependencies.getLog;
|
|
2212
|
+
dependencies.getIntro(chalk.bold("blodemd dev"));
|
|
2213
|
+
try {
|
|
2214
|
+
const port = dependencies.parsePortValue(portValue);
|
|
2215
|
+
const resolvedPort = await dependencies.resolveDevPortValue(port);
|
|
2216
|
+
const root = await dependencies.resolveDocsRootValue(dir);
|
|
2217
|
+
await dependencies.validateDocsRootValue(root);
|
|
2218
|
+
const cliFilePath = dependencies.getCliFilePath();
|
|
2219
|
+
const server = await dependencies.resolveServer(cliFilePath);
|
|
2220
|
+
const localUrl = `http://localhost:${resolvedPort}`;
|
|
2221
|
+
cliLog.info(`Docs root: ${chalk.cyan(root)}`);
|
|
2222
|
+
const child = dependencies.spawnServer(server, {
|
|
2223
|
+
port: resolvedPort,
|
|
2224
|
+
root
|
|
2225
|
+
});
|
|
2226
|
+
let watcher = null;
|
|
2227
|
+
let shuttingDown = false;
|
|
2228
|
+
const closeAll = async () => {
|
|
2229
|
+
if (shuttingDown) return;
|
|
2230
|
+
shuttingDown = true;
|
|
2231
|
+
if (watcher) {
|
|
2232
|
+
await watcher.close();
|
|
2233
|
+
watcher = null;
|
|
2234
|
+
}
|
|
2235
|
+
await dependencies.shutdownChild(child);
|
|
2236
|
+
if (server.mode === "standalone") await dependencies.removeDirectory(server.runtimeRoot, {
|
|
2237
|
+
force: true,
|
|
2238
|
+
recursive: true
|
|
2239
|
+
});
|
|
2240
|
+
};
|
|
2241
|
+
process.once("SIGINT", closeAll);
|
|
2242
|
+
process.once("SIGTERM", closeAll);
|
|
2243
|
+
try {
|
|
2244
|
+
await dependencies.waitForServerReady({
|
|
2245
|
+
child,
|
|
2246
|
+
port: resolvedPort
|
|
2247
|
+
});
|
|
2248
|
+
watcher = await dependencies.createWatcher({
|
|
2249
|
+
port: resolvedPort,
|
|
2250
|
+
root
|
|
2251
|
+
});
|
|
2252
|
+
cliLog.success(`Dev server running at ${chalk.cyan(localUrl)}`);
|
|
2253
|
+
if (openBrowser) await dependencies.getOpen(localUrl);
|
|
2254
|
+
const [code, signal] = await once(child, "exit");
|
|
2255
|
+
if (shuttingDown || signal === "SIGINT" || signal === "SIGTERM") return;
|
|
2256
|
+
if (code !== 0) throw new CliError(`The local dev server exited with code ${code ?? "unknown"}.`, EXIT_CODES.ERROR);
|
|
2257
|
+
} finally {
|
|
2258
|
+
await closeAll();
|
|
2259
|
+
process.removeListener("SIGINT", closeAll);
|
|
2260
|
+
process.removeListener("SIGTERM", closeAll);
|
|
2261
|
+
}
|
|
2262
|
+
} catch (error) {
|
|
2263
|
+
const cliError = toCliError(error);
|
|
2264
|
+
cliLog.error(cliError.message);
|
|
2265
|
+
if (cliError.hint) cliLog.info(cliError.hint);
|
|
2266
|
+
process.exitCode = cliError.exitCode;
|
|
2267
|
+
}
|
|
2268
|
+
};
|
|
2269
|
+
//#endregion
|
|
2270
|
+
//#region src/commands/dev.ts
|
|
2271
|
+
const registerDevCommand = (program) => {
|
|
2272
|
+
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({
|
|
2273
|
+
dir: options.dir,
|
|
2274
|
+
openBrowser: options.open ?? true,
|
|
2275
|
+
port: options.port
|
|
2276
|
+
}));
|
|
2277
|
+
};
|
|
2278
|
+
//#endregion
|
|
2279
|
+
//#region src/fs-utils.ts
|
|
2280
|
+
const writeFileIfMissing = async (filePath, content) => {
|
|
2281
|
+
try {
|
|
2282
|
+
await fs.writeFile(filePath, content, { flag: "wx" });
|
|
2283
|
+
} catch (error) {
|
|
2284
|
+
const { code } = error;
|
|
2285
|
+
if (code === "EEXIST") {
|
|
2286
|
+
if ((await fs.stat(filePath).catch(() => null))?.isFile()) return;
|
|
2287
|
+
}
|
|
2288
|
+
throw error;
|
|
2289
|
+
}
|
|
2290
|
+
};
|
|
2291
|
+
const writeSymlinkIfMissing = async (filePath, target, options) => {
|
|
2292
|
+
const lstat = options?.lstat ?? fs.lstat;
|
|
2293
|
+
const symlink = options?.symlink ?? fs.symlink;
|
|
2294
|
+
try {
|
|
2295
|
+
await symlink(target, filePath);
|
|
2296
|
+
} catch (error) {
|
|
2297
|
+
const { code } = error;
|
|
2298
|
+
if (code === "EEXIST") {
|
|
2299
|
+
const existing = await lstat(filePath).catch(() => null);
|
|
2300
|
+
if (existing?.isFile() || existing?.isSymbolicLink()) return;
|
|
2301
|
+
}
|
|
2302
|
+
if (options?.fallbackContent && (code === "EINVAL" || code === "ENOTSUP" || code === "EPERM" || code === "UNKNOWN")) {
|
|
2303
|
+
await writeFileIfMissing(filePath, options.fallbackContent);
|
|
2304
|
+
return;
|
|
2305
|
+
}
|
|
2306
|
+
throw error;
|
|
2307
|
+
}
|
|
2308
|
+
};
|
|
2309
|
+
const findExistingPaths = async (root, relativePaths) => {
|
|
2310
|
+
return (await Promise.all(relativePaths.map(async (relativePath) => {
|
|
2311
|
+
return await fs.lstat(path.join(root, relativePath)).catch(() => null) ? relativePath : null;
|
|
2312
|
+
}))).filter((relativePath) => relativePath !== null).toSorted((left, right) => left.localeCompare(right));
|
|
2313
|
+
};
|
|
2314
|
+
//#endregion
|
|
2315
|
+
//#region src/new-flow.ts
|
|
2316
|
+
const CREATE_IN_SUBDIRECTORY = "create-in-subdirectory";
|
|
2317
|
+
const SCAFFOLD_CURRENT_DIRECTORY = "scaffold-current-directory";
|
|
2318
|
+
const CANCEL_SCAFFOLD = "cancel";
|
|
2319
|
+
const resolveInitialDirectory = (options) => {
|
|
2320
|
+
if (options.directory) return {
|
|
2321
|
+
directory: options.directory,
|
|
2322
|
+
kind: "target"
|
|
2323
|
+
};
|
|
2324
|
+
if (!options.interactive) return {
|
|
2325
|
+
directory: DEFAULT_SCAFFOLD_DIRECTORY,
|
|
2326
|
+
kind: "target"
|
|
2327
|
+
};
|
|
2328
|
+
if (options.currentDirectoryEntries.length === 0) return {
|
|
2329
|
+
directory: ".",
|
|
2330
|
+
kind: "target"
|
|
2331
|
+
};
|
|
2332
|
+
return { kind: "prompt" };
|
|
2125
2333
|
};
|
|
2126
|
-
const
|
|
2127
|
-
|
|
2128
|
-
if (
|
|
2129
|
-
return value.trim();
|
|
2334
|
+
const resolveDirectoryFromAction = (action, subdirectory) => {
|
|
2335
|
+
if (action === "scaffold-current-directory") return ".";
|
|
2336
|
+
if (action === "create-in-subdirectory") return subdirectory?.trim() || "docs";
|
|
2130
2337
|
};
|
|
2338
|
+
//#endregion
|
|
2339
|
+
//#region src/commands/new.ts
|
|
2131
2340
|
const isInteractiveTerminal = () => process.stdin.isTTY === true && process.stdout.isTTY === true;
|
|
2132
2341
|
const promptForNoArgDirectoryAction = async () => {
|
|
2133
2342
|
const directoryAction = await select({
|
|
@@ -2240,12 +2449,125 @@ const writeScaffoldFiles = async (root, template, options) => {
|
|
|
2240
2449
|
await writeFileIfMissing(filePath, file.content);
|
|
2241
2450
|
}
|
|
2242
2451
|
};
|
|
2243
|
-
const
|
|
2452
|
+
const scaffoldDocsSite = async (directory, options) => {
|
|
2453
|
+
intro(chalk.bold("blodemd new"));
|
|
2454
|
+
if (options?.deprecatedCommand) log.warn(`"${options.deprecatedCommand}" is deprecated. Use ${chalk.cyan("blodemd new")} instead.`);
|
|
2455
|
+
if (options?.name && !options.slug) log.warn(`"${chalk.cyan("--name")}" is deprecated. Use ${chalk.cyan("--slug")} instead.`);
|
|
2244
2456
|
try {
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
|
|
2457
|
+
const template = options?.template ?? "minimal";
|
|
2458
|
+
const shouldPrompt = isInteractiveTerminal() && !options?.yes;
|
|
2459
|
+
const selectedDirectory = await resolveRequestedDirectory(directory, shouldPrompt);
|
|
2460
|
+
if (!selectedDirectory) {
|
|
2461
|
+
log.warn("Cancelled");
|
|
2462
|
+
return;
|
|
2463
|
+
}
|
|
2464
|
+
const resolvedDirectory = resolveScaffoldDirectory(selectedDirectory.directory);
|
|
2465
|
+
const root = path.resolve(process.cwd(), resolvedDirectory);
|
|
2466
|
+
if (!await confirmScaffoldTarget(root, template, shouldPrompt, { skipNonEmptyConfirmation: selectedDirectory.skipNonEmptyConfirmation })) {
|
|
2467
|
+
log.warn("Cancelled");
|
|
2468
|
+
return;
|
|
2469
|
+
}
|
|
2470
|
+
const projectSlug = await resolveProjectSlug(options?.slug ?? options?.name, resolvedDirectory, shouldPrompt);
|
|
2471
|
+
if (!projectSlug) {
|
|
2472
|
+
log.warn("Cancelled");
|
|
2473
|
+
return;
|
|
2474
|
+
}
|
|
2475
|
+
const displayName = await resolveDisplayName(options?.displayName, projectSlug, shouldPrompt);
|
|
2476
|
+
if (!displayName) {
|
|
2477
|
+
log.warn("Cancelled");
|
|
2478
|
+
return;
|
|
2479
|
+
}
|
|
2480
|
+
await fs.mkdir(root, { recursive: true });
|
|
2481
|
+
await writeScaffoldFiles(root, template, {
|
|
2482
|
+
displayName,
|
|
2483
|
+
projectSlug
|
|
2484
|
+
});
|
|
2485
|
+
log.success(`Docs scaffolded in ${chalk.cyan(root)}`);
|
|
2486
|
+
if (template === "starter") log.info("Starter template includes brand assets and helper files.");
|
|
2487
|
+
log.info(`Display name: ${chalk.cyan(displayName)}`);
|
|
2488
|
+
log.info(`Project slug: ${chalk.cyan(projectSlug)}`);
|
|
2489
|
+
log.info("Done");
|
|
2490
|
+
} catch (error) {
|
|
2491
|
+
reportCommandError("New failed", error);
|
|
2492
|
+
}
|
|
2493
|
+
};
|
|
2494
|
+
const registerNewCommand = (program) => {
|
|
2495
|
+
program.command("new").description("Create a new blode.md documentation site").argument("[directory]", "target directory").option("--slug <slug>", "project slug for docs.json", parseProjectSlug).option("--name <slug>", "deprecated alias for --slug", parseProjectSlug).option("--display-name <name>", "display name for docs.json").option("-t, --template <template>", `scaffold template (${SCAFFOLD_TEMPLATES.join(", ")})`, parseScaffoldTemplate, "minimal").option("-y, --yes", "accept defaults without prompting").action(async (directory, options) => {
|
|
2496
|
+
await scaffoldDocsSite(directory, {
|
|
2497
|
+
displayName: options.displayName,
|
|
2498
|
+
name: options.name,
|
|
2499
|
+
slug: options.slug ?? options.name,
|
|
2500
|
+
template: options.template,
|
|
2501
|
+
yes: options.yes
|
|
2502
|
+
});
|
|
2503
|
+
});
|
|
2504
|
+
program.command("init", { hidden: true }).argument("[directory]", "target directory").option("--slug <slug>", "project slug for docs.json", parseProjectSlug).option("--name <slug>", "deprecated alias for --slug", parseProjectSlug).option("--display-name <name>", "display name for docs.json").option("-t, --template <template>", `scaffold template (${SCAFFOLD_TEMPLATES.join(", ")})`, parseScaffoldTemplate, "minimal").option("-y, --yes", "accept defaults without prompting").action(async (directory, options) => {
|
|
2505
|
+
await scaffoldDocsSite(directory, {
|
|
2506
|
+
deprecatedCommand: "blodemd init",
|
|
2507
|
+
displayName: options.displayName,
|
|
2508
|
+
name: options.name,
|
|
2509
|
+
slug: options.slug ?? options.name,
|
|
2510
|
+
template: options.template,
|
|
2511
|
+
yes: options.yes
|
|
2512
|
+
});
|
|
2513
|
+
});
|
|
2514
|
+
};
|
|
2515
|
+
//#endregion
|
|
2516
|
+
//#region src/upload.ts
|
|
2517
|
+
const TEXT_CONTENT_TYPES = {
|
|
2518
|
+
".css": "text/css; charset=utf-8",
|
|
2519
|
+
".html": "text/html; charset=utf-8",
|
|
2520
|
+
".js": "text/javascript; charset=utf-8",
|
|
2521
|
+
".json": "application/json; charset=utf-8",
|
|
2522
|
+
".md": "text/markdown; charset=utf-8",
|
|
2523
|
+
".mdx": "text/markdown; charset=utf-8",
|
|
2524
|
+
".svg": "image/svg+xml",
|
|
2525
|
+
".txt": "text/plain; charset=utf-8",
|
|
2526
|
+
".yaml": "application/yaml; charset=utf-8",
|
|
2527
|
+
".yml": "application/yaml; charset=utf-8"
|
|
2528
|
+
};
|
|
2529
|
+
const normalizeRelativePath = (root, filePath) => path.relative(root, filePath).split(path.sep).join("/");
|
|
2530
|
+
const getContentType = (filePath) => TEXT_CONTENT_TYPES[path.extname(filePath).toLowerCase()] ?? "application/octet-stream";
|
|
2531
|
+
const estimateUploadItemBytes = (item) => item.contentBase64.length + item.path.length + 64;
|
|
2532
|
+
const createUploadBatchItem = async (filePath, root, readFile) => {
|
|
2533
|
+
return {
|
|
2534
|
+
contentBase64: (await readFile(filePath)).toString("base64"),
|
|
2535
|
+
contentType: getContentType(filePath),
|
|
2536
|
+
path: normalizeRelativePath(root, filePath)
|
|
2537
|
+
};
|
|
2538
|
+
};
|
|
2539
|
+
const createUploadBatches = async function* createUploadBatchesGenerator({ files, maxBatchBytes, readFile = fs.readFile, root }) {
|
|
2540
|
+
let currentBatch = [];
|
|
2541
|
+
let currentBatchBytes = 0;
|
|
2542
|
+
for (const filePath of files) {
|
|
2543
|
+
const item = await createUploadBatchItem(filePath, root, readFile);
|
|
2544
|
+
if (shouldIgnoreRootDocsFile(item.path)) continue;
|
|
2545
|
+
const itemBytes = estimateUploadItemBytes(item);
|
|
2546
|
+
if (itemBytes > maxBatchBytes) throw new CliError(`File "${item.path}" is too large to upload in a single request.`, EXIT_CODES.VALIDATION, "Split the file into smaller pieces or raise the server request limit.");
|
|
2547
|
+
if (currentBatch.length > 0 && currentBatchBytes + itemBytes > maxBatchBytes) {
|
|
2548
|
+
yield currentBatch;
|
|
2549
|
+
currentBatch = [];
|
|
2550
|
+
currentBatchBytes = 0;
|
|
2551
|
+
}
|
|
2552
|
+
currentBatch.push(item);
|
|
2553
|
+
currentBatchBytes += itemBytes;
|
|
2248
2554
|
}
|
|
2555
|
+
if (currentBatch.length > 0) yield currentBatch;
|
|
2556
|
+
};
|
|
2557
|
+
//#endregion
|
|
2558
|
+
//#region src/commands/push.ts
|
|
2559
|
+
const resolveAuthHeaders = async (apiKeyOption) => {
|
|
2560
|
+
const apiKey = (apiKeyOption ?? process.env["BLODEMD_API_KEY"])?.trim();
|
|
2561
|
+
if (apiKey) return {
|
|
2562
|
+
canAutoCreate: false,
|
|
2563
|
+
headers: { "x-admin-token": apiKey }
|
|
2564
|
+
};
|
|
2565
|
+
const resolved = await resolveAuthToken();
|
|
2566
|
+
if (!resolved?.token) throw new Error("Not logged in. Run \"blodemd login\" to authenticate, pass --api-key, or set BLODEMD_API_KEY.");
|
|
2567
|
+
return {
|
|
2568
|
+
canAutoCreate: true,
|
|
2569
|
+
headers: { Authorization: `Bearer ${resolved.token}` }
|
|
2570
|
+
};
|
|
2249
2571
|
};
|
|
2250
2572
|
const resolvePushConfig = async (config, options) => {
|
|
2251
2573
|
const { project, usedLegacyNameFallback } = resolveProjectTarget({
|
|
@@ -2254,7 +2576,6 @@ const resolvePushConfig = async (config, options) => {
|
|
|
2254
2576
|
envProject: process.env[BLODE_PROJECT_ENV]
|
|
2255
2577
|
});
|
|
2256
2578
|
const apiUrl = options.apiUrl ?? process.env["BLODEMD_API_URL"] ?? "https://api.blode.md";
|
|
2257
|
-
const authToken = (await resolveAuthToken())?.token;
|
|
2258
2579
|
const branch = options.branch ?? process.env["BLODEMD_BRANCH"] ?? process.env.GITHUB_REF_NAME ?? readGitValue([
|
|
2259
2580
|
"rev-parse",
|
|
2260
2581
|
"--abbrev-ref",
|
|
@@ -2271,19 +2592,20 @@ const resolvePushConfig = async (config, options) => {
|
|
|
2271
2592
|
if (usedLegacyNameFallback) throw new Error(`docs.json.name is not a valid deployment slug. Add "slug" to docs.json, pass --project, or set BLODEMD_PROJECT. ${projectSlugError}`);
|
|
2272
2593
|
throw new Error(`Invalid project slug "${project}". ${projectSlugError}`);
|
|
2273
2594
|
}
|
|
2274
|
-
|
|
2595
|
+
const { headers: authHeaders, canAutoCreate } = await resolveAuthHeaders(options.apiKey);
|
|
2275
2596
|
return {
|
|
2276
2597
|
apiUrl,
|
|
2277
|
-
|
|
2598
|
+
authHeaders,
|
|
2278
2599
|
branch,
|
|
2600
|
+
canAutoCreate,
|
|
2279
2601
|
commitMessage,
|
|
2280
2602
|
project,
|
|
2281
2603
|
projectDisplayName: config.name?.trim() || project,
|
|
2282
2604
|
usedLegacyNameFallback
|
|
2283
2605
|
};
|
|
2284
2606
|
};
|
|
2285
|
-
const autoCreateProject = async (project, projectDisplayName, apiUrl, headers) => {
|
|
2286
|
-
if (!
|
|
2607
|
+
const autoCreateProject = async (project, projectDisplayName, apiUrl, headers, canAutoCreate) => {
|
|
2608
|
+
if (!canAutoCreate) throw new Error(`Project "${project}" not found. Create it at blode.md or login with "blodemd login" to auto-create.`);
|
|
2287
2609
|
const shouldCreate = await confirm({ message: `Project "${project}" doesn't exist. Create it?` });
|
|
2288
2610
|
if (isCancel(shouldCreate) || !shouldCreate) return false;
|
|
2289
2611
|
const createResult = await requestJson(new URL("/projects", apiUrl).toString(), {
|
|
@@ -2297,48 +2619,6 @@ const autoCreateProject = async (project, projectDisplayName, apiUrl, headers) =
|
|
|
2297
2619
|
log.success(`Project ${chalk.cyan(createResult.slug)} created`);
|
|
2298
2620
|
return true;
|
|
2299
2621
|
};
|
|
2300
|
-
const scaffoldDocsSite = async (directory, options) => {
|
|
2301
|
-
intro(chalk.bold("blodemd new"));
|
|
2302
|
-
if (options?.deprecatedCommand) log.warn(`"${options.deprecatedCommand}" is deprecated. Use ${chalk.cyan("blodemd new")} instead.`);
|
|
2303
|
-
if (options?.name && !options.slug) log.warn(`"${chalk.cyan("--name")}" is deprecated. Use ${chalk.cyan("--slug")} instead.`);
|
|
2304
|
-
try {
|
|
2305
|
-
const template = options?.template ?? "minimal";
|
|
2306
|
-
const shouldPrompt = isInteractiveTerminal() && !options?.yes;
|
|
2307
|
-
const selectedDirectory = await resolveRequestedDirectory(directory, shouldPrompt);
|
|
2308
|
-
if (!selectedDirectory) {
|
|
2309
|
-
log.warn("Cancelled");
|
|
2310
|
-
return;
|
|
2311
|
-
}
|
|
2312
|
-
const resolvedDirectory = resolveScaffoldDirectory(selectedDirectory.directory);
|
|
2313
|
-
const root = path.resolve(process.cwd(), resolvedDirectory);
|
|
2314
|
-
if (!await confirmScaffoldTarget(root, template, shouldPrompt, { skipNonEmptyConfirmation: selectedDirectory.skipNonEmptyConfirmation })) {
|
|
2315
|
-
log.warn("Cancelled");
|
|
2316
|
-
return;
|
|
2317
|
-
}
|
|
2318
|
-
const projectSlug = await resolveProjectSlug(options?.slug ?? options?.name, resolvedDirectory, shouldPrompt);
|
|
2319
|
-
if (!projectSlug) {
|
|
2320
|
-
log.warn("Cancelled");
|
|
2321
|
-
return;
|
|
2322
|
-
}
|
|
2323
|
-
const displayName = await resolveDisplayName(options?.displayName, projectSlug, shouldPrompt);
|
|
2324
|
-
if (!displayName) {
|
|
2325
|
-
log.warn("Cancelled");
|
|
2326
|
-
return;
|
|
2327
|
-
}
|
|
2328
|
-
await fs.mkdir(root, { recursive: true });
|
|
2329
|
-
await writeScaffoldFiles(root, template, {
|
|
2330
|
-
displayName,
|
|
2331
|
-
projectSlug
|
|
2332
|
-
});
|
|
2333
|
-
log.success(`Docs scaffolded in ${chalk.cyan(root)}`);
|
|
2334
|
-
if (template === "starter") log.info("Starter template includes brand assets and helper files.");
|
|
2335
|
-
log.info(`Display name: ${chalk.cyan(displayName)}`);
|
|
2336
|
-
log.info(`Project slug: ${chalk.cyan(projectSlug)}`);
|
|
2337
|
-
log.info("Done");
|
|
2338
|
-
} catch (error) {
|
|
2339
|
-
reportCommandError("New failed", error);
|
|
2340
|
-
}
|
|
2341
|
-
};
|
|
2342
2622
|
const MAX_BATCH_BYTES = 4 * 1024 * 1024;
|
|
2343
2623
|
const uploadFiles = async (files, root, apiPath, deploymentId, headers, s) => {
|
|
2344
2624
|
s.start(`Uploading ${files.length} files`);
|
|
@@ -2358,192 +2638,140 @@ const uploadFiles = async (files, root, apiPath, deploymentId, headers, s) => {
|
|
|
2358
2638
|
}
|
|
2359
2639
|
s.stop(`Uploaded ${chalk.cyan(String(files.length))} files`);
|
|
2360
2640
|
};
|
|
2361
|
-
const
|
|
2362
|
-
|
|
2363
|
-
|
|
2364
|
-
|
|
2365
|
-
assertSupportedNodeVersion();
|
|
2366
|
-
});
|
|
2367
|
-
program.command("login").description("Authenticate with Blode.md via GitHub in your browser").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) => {
|
|
2368
|
-
intro(chalk.bold("blodemd login"));
|
|
2369
|
-
try {
|
|
2370
|
-
const { authorizeUrl, tokenUrl } = buildOAuthUrls(resolveSupabaseConfig());
|
|
2371
|
-
const clientId = OAUTH_CLIENT_ID;
|
|
2372
|
-
const port = parsePort(options.port);
|
|
2373
|
-
const timeoutSeconds = parsePositiveInteger(options.timeout, "Timeout");
|
|
2374
|
-
const redirectUrl = new URL(`http://127.0.0.1:${port}${DEFAULT_OAUTH_CALLBACK_PATH}`);
|
|
2375
|
-
const state = createOAuthState();
|
|
2376
|
-
const codeVerifier = createCodeVerifier();
|
|
2377
|
-
const codeChallenge = createCodeChallenge(codeVerifier);
|
|
2378
|
-
const authUrl = new URL(authorizeUrl);
|
|
2379
|
-
authUrl.searchParams.set("response_type", "code");
|
|
2380
|
-
authUrl.searchParams.set("client_id", clientId);
|
|
2381
|
-
authUrl.searchParams.set("redirect_uri", redirectUrl.toString());
|
|
2382
|
-
authUrl.searchParams.set("code_challenge", codeChallenge);
|
|
2383
|
-
authUrl.searchParams.set("code_challenge_method", "S256");
|
|
2384
|
-
authUrl.searchParams.set("state", state);
|
|
2385
|
-
authUrl.searchParams.set("scope", "openid email profile");
|
|
2386
|
-
authUrl.searchParams.set("provider", "github");
|
|
2387
|
-
const callbackPromise = waitForOAuthCode({
|
|
2388
|
-
expectedState: state,
|
|
2389
|
-
redirectUrl,
|
|
2390
|
-
timeoutMs: timeoutSeconds * 1e3
|
|
2391
|
-
});
|
|
2392
|
-
if (options.open) {
|
|
2393
|
-
log.info("Opening browser for authentication...");
|
|
2394
|
-
log.info(`If the browser doesn't open, visit: ${chalk.cyan(authUrl.toString())}`);
|
|
2395
|
-
await open(authUrl.toString());
|
|
2396
|
-
} else {
|
|
2397
|
-
log.info("Open this URL to continue authentication:");
|
|
2398
|
-
log.info(chalk.cyan(authUrl.toString()));
|
|
2399
|
-
}
|
|
2400
|
-
const code = await callbackPromise;
|
|
2401
|
-
const storedSession = tokenResponseToStoredSession(await exchangeAuthorizationCode({
|
|
2402
|
-
clientId,
|
|
2403
|
-
tokenUrl
|
|
2404
|
-
}, code, codeVerifier, redirectUrl.toString()));
|
|
2405
|
-
await writeStoredAuthSession(storedSession);
|
|
2406
|
-
const email = storedSession.user?.email ?? await fetchUserEmail(process.env["BLODEMD_API_URL"] ?? "https://api.blode.md", storedSession.accessToken);
|
|
2407
|
-
if (email) log.success(`Logged in as ${chalk.cyan(email)}`);
|
|
2408
|
-
else log.success("Logged in successfully.");
|
|
2409
|
-
log.info("Done");
|
|
2410
|
-
} catch (error) {
|
|
2411
|
-
reportCommandError("Login failed", error);
|
|
2412
|
-
}
|
|
2413
|
-
});
|
|
2414
|
-
program.command("logout").description("Remove stored credentials").action(async () => {
|
|
2415
|
-
intro(chalk.bold("blodemd logout"));
|
|
2416
|
-
try {
|
|
2417
|
-
let existing = false;
|
|
2641
|
+
const registerPushCommand = (program) => {
|
|
2642
|
+
program.command("push").description("Deploy docs").argument("[dir]", "docs directory").option("--project <slug>", "project slug (env: BLODEMD_PROJECT)").option("--api-key <token>", "API key (env: BLODEMD_API_KEY)").option("--api-url <url>", "API URL (env: BLODEMD_API_URL)").option("--branch <name>", "git branch (env: BLODEMD_BRANCH)").option("--message <msg>", "deploy message (env: BLODEMD_COMMIT_MESSAGE)").action(async (dir, options) => {
|
|
2643
|
+
intro(chalk.bold("blodemd push"));
|
|
2644
|
+
const s = spinner();
|
|
2418
2645
|
try {
|
|
2419
|
-
await
|
|
2420
|
-
|
|
2421
|
-
|
|
2422
|
-
|
|
2423
|
-
|
|
2424
|
-
|
|
2425
|
-
|
|
2426
|
-
|
|
2427
|
-
|
|
2428
|
-
|
|
2429
|
-
|
|
2430
|
-
|
|
2431
|
-
|
|
2432
|
-
|
|
2433
|
-
|
|
2434
|
-
|
|
2435
|
-
|
|
2436
|
-
|
|
2437
|
-
|
|
2646
|
+
const root = await resolveDocsRoot(dir);
|
|
2647
|
+
s.start("Validating configuration");
|
|
2648
|
+
const { config, warnings } = await loadValidatedSiteConfig(root);
|
|
2649
|
+
s.stop("Configuration valid");
|
|
2650
|
+
for (const warning of warnings) log.warn(warning);
|
|
2651
|
+
const { project, projectDisplayName, apiUrl, authHeaders, canAutoCreate, branch, commitMessage, usedLegacyNameFallback } = await resolvePushConfig(config, options);
|
|
2652
|
+
if (usedLegacyNameFallback) log.warn(LEGACY_PROJECT_NAME_FALLBACK_WARNING);
|
|
2653
|
+
s.start("Collecting files");
|
|
2654
|
+
const files = await collectFiles(root);
|
|
2655
|
+
if (files.length === 0) throw new Error("No files found to deploy.");
|
|
2656
|
+
s.stop(`Found ${chalk.cyan(String(files.length))} files`);
|
|
2657
|
+
const headers = {
|
|
2658
|
+
...authHeaders,
|
|
2659
|
+
"Content-Type": "application/json"
|
|
2660
|
+
};
|
|
2661
|
+
const apiPath = (suffix) => new URL(`/projects/slug/${project}/deployments${suffix}`, apiUrl).toString();
|
|
2662
|
+
const createDeploymentBody = JSON.stringify({
|
|
2663
|
+
branch,
|
|
2664
|
+
commitMessage
|
|
2665
|
+
});
|
|
2666
|
+
s.start("Creating deployment");
|
|
2667
|
+
let deployment;
|
|
2668
|
+
try {
|
|
2669
|
+
deployment = await requestJson(apiPath(""), {
|
|
2670
|
+
body: createDeploymentBody,
|
|
2671
|
+
headers,
|
|
2672
|
+
method: "POST"
|
|
2673
|
+
}, "Failed to create deployment");
|
|
2674
|
+
} catch (error) {
|
|
2675
|
+
if (!(error instanceof Error ? error.message : "").includes("404")) throw error;
|
|
2676
|
+
s.stop("Project not found");
|
|
2677
|
+
if (!await autoCreateProject(project, projectDisplayName, apiUrl, headers, canAutoCreate)) {
|
|
2678
|
+
log.info("Cancelled");
|
|
2679
|
+
return;
|
|
2680
|
+
}
|
|
2681
|
+
s.start("Creating deployment");
|
|
2682
|
+
deployment = await requestJson(apiPath(""), {
|
|
2683
|
+
body: createDeploymentBody,
|
|
2684
|
+
headers,
|
|
2685
|
+
method: "POST"
|
|
2686
|
+
}, "Failed to create deployment");
|
|
2687
|
+
}
|
|
2688
|
+
s.stop(`Deployment ${chalk.cyan(deployment.id)} created`);
|
|
2689
|
+
await uploadFiles(files, root, apiPath, deployment.id, headers, s);
|
|
2690
|
+
s.start("Finalizing deployment");
|
|
2691
|
+
const finalized = await requestJson(apiPath(`/${deployment.id}/finalize`), {
|
|
2692
|
+
body: JSON.stringify({ promote: true }),
|
|
2693
|
+
headers,
|
|
2694
|
+
method: "POST"
|
|
2695
|
+
}, "Failed to finalize deployment");
|
|
2696
|
+
s.stop("Deployment finalized");
|
|
2697
|
+
log.success(`Published ${chalk.cyan(finalized.id)}`);
|
|
2698
|
+
if (finalized.manifestUrl) log.info(`Manifest: ${finalized.manifestUrl}`);
|
|
2699
|
+
if (typeof finalized.fileCount === "number") log.info(`Files: ${finalized.fileCount}`);
|
|
2700
|
+
log.info("Done");
|
|
2701
|
+
} catch (error) {
|
|
2702
|
+
s.stop("Failed");
|
|
2703
|
+
reportCommandError("Push failed", error);
|
|
2438
2704
|
}
|
|
2439
|
-
const status = resolveTokenStatus(resolved);
|
|
2440
|
-
const email = resolved.user?.email ?? await fetchUserEmail(process.env["BLODEMD_API_URL"] ?? "https://api.blode.md", resolved.token);
|
|
2441
|
-
if (email) log.info(`Logged in as ${chalk.cyan(email)}`);
|
|
2442
|
-
else log.info("Logged in (could not fetch user details).");
|
|
2443
|
-
if (resolved.expiresAt && status.expired) log.warn("Session has expired. Run \"blodemd login\" to re-authenticate.");
|
|
2444
|
-
} catch (error) {
|
|
2445
|
-
reportCommandError("Whoami failed", error);
|
|
2446
|
-
}
|
|
2447
|
-
});
|
|
2448
|
-
program.command("new").description("Create a new blode.md documentation site").argument("[directory]", "target directory").option("--slug <slug>", "project slug for docs.json", parseProjectSlug).option("--name <slug>", "deprecated alias for --slug", parseProjectSlug).option("--display-name <name>", "display name for docs.json").option("-t, --template <template>", `scaffold template (${SCAFFOLD_TEMPLATES.join(", ")})`, parseScaffoldTemplate, "minimal").option("-y, --yes", "accept defaults without prompting").action(async (directory, options) => {
|
|
2449
|
-
await scaffoldDocsSite(directory, {
|
|
2450
|
-
displayName: options.displayName,
|
|
2451
|
-
name: options.name,
|
|
2452
|
-
slug: options.slug ?? options.name,
|
|
2453
|
-
template: options.template,
|
|
2454
|
-
yes: options.yes
|
|
2455
|
-
});
|
|
2456
|
-
});
|
|
2457
|
-
program.command("init", { hidden: true }).argument("[directory]", "target directory").option("--slug <slug>", "project slug for docs.json", parseProjectSlug).option("--name <slug>", "deprecated alias for --slug", parseProjectSlug).option("--display-name <name>", "display name for docs.json").option("-t, --template <template>", `scaffold template (${SCAFFOLD_TEMPLATES.join(", ")})`, parseScaffoldTemplate, "minimal").option("-y, --yes", "accept defaults without prompting").action(async (directory, options) => {
|
|
2458
|
-
await scaffoldDocsSite(directory, {
|
|
2459
|
-
deprecatedCommand: "blodemd init",
|
|
2460
|
-
displayName: options.displayName,
|
|
2461
|
-
name: options.name,
|
|
2462
|
-
slug: options.slug ?? options.name,
|
|
2463
|
-
template: options.template,
|
|
2464
|
-
yes: options.yes
|
|
2465
2705
|
});
|
|
2466
|
-
}
|
|
2467
|
-
|
|
2468
|
-
|
|
2469
|
-
|
|
2470
|
-
|
|
2471
|
-
|
|
2472
|
-
|
|
2473
|
-
log.info("Done");
|
|
2474
|
-
} catch (error) {
|
|
2475
|
-
reportCommandError("Validation failed", error);
|
|
2476
|
-
}
|
|
2477
|
-
});
|
|
2478
|
-
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("--branch <name>", "git branch (env: BLODEMD_BRANCH)").option("--message <msg>", "deploy message (env: BLODEMD_COMMIT_MESSAGE)").action(async (dir, options) => {
|
|
2479
|
-
intro(chalk.bold("blodemd push"));
|
|
2480
|
-
const s = spinner();
|
|
2481
|
-
try {
|
|
2482
|
-
const root = await resolveDocsRoot(dir);
|
|
2483
|
-
s.start("Validating configuration");
|
|
2484
|
-
const { config, warnings } = await loadValidatedSiteConfig(root);
|
|
2485
|
-
s.stop("Configuration valid");
|
|
2486
|
-
for (const warning of warnings) log.warn(warning);
|
|
2487
|
-
const { project, projectDisplayName, apiUrl, authToken, branch, commitMessage, usedLegacyNameFallback } = await resolvePushConfig(config, options);
|
|
2488
|
-
if (usedLegacyNameFallback) log.warn(LEGACY_PROJECT_NAME_FALLBACK_WARNING);
|
|
2489
|
-
s.start("Collecting files");
|
|
2490
|
-
const files = await collectFiles(root);
|
|
2491
|
-
if (files.length === 0) throw new Error("No files found to deploy.");
|
|
2492
|
-
s.stop(`Found ${chalk.cyan(String(files.length))} files`);
|
|
2493
|
-
const headers = {
|
|
2494
|
-
Authorization: `Bearer ${authToken}`,
|
|
2495
|
-
"Content-Type": "application/json"
|
|
2496
|
-
};
|
|
2497
|
-
const apiPath = (suffix) => new URL(`/projects/slug/${project}/deployments${suffix}`, apiUrl).toString();
|
|
2498
|
-
const createDeploymentBody = JSON.stringify({
|
|
2499
|
-
branch,
|
|
2500
|
-
commitMessage
|
|
2501
|
-
});
|
|
2502
|
-
s.start("Creating deployment");
|
|
2503
|
-
let deployment;
|
|
2706
|
+
};
|
|
2707
|
+
//#endregion
|
|
2708
|
+
//#region src/commands/validate.ts
|
|
2709
|
+
const CONFIG_FILE = "docs.json";
|
|
2710
|
+
const registerValidateCommand = (program) => {
|
|
2711
|
+
program.command("validate").description("Validate docs.json").argument("[dir]", "docs directory").action(async (dir) => {
|
|
2712
|
+
intro(chalk.bold("blodemd validate"));
|
|
2504
2713
|
try {
|
|
2505
|
-
|
|
2506
|
-
|
|
2507
|
-
|
|
2508
|
-
|
|
2509
|
-
}, "Failed to create deployment");
|
|
2714
|
+
const { warnings } = await loadValidatedSiteConfig(await resolveDocsRoot(dir));
|
|
2715
|
+
for (const warning of warnings) log.warn(warning);
|
|
2716
|
+
log.success(`${chalk.cyan(CONFIG_FILE)} is valid.`);
|
|
2717
|
+
log.info("Done");
|
|
2510
2718
|
} catch (error) {
|
|
2511
|
-
|
|
2512
|
-
s.stop("Project not found");
|
|
2513
|
-
if (!await autoCreateProject(project, projectDisplayName, apiUrl, headers)) {
|
|
2514
|
-
log.info("Cancelled");
|
|
2515
|
-
return;
|
|
2516
|
-
}
|
|
2517
|
-
s.start("Creating deployment");
|
|
2518
|
-
deployment = await requestJson(apiPath(""), {
|
|
2519
|
-
body: createDeploymentBody,
|
|
2520
|
-
headers,
|
|
2521
|
-
method: "POST"
|
|
2522
|
-
}, "Failed to create deployment");
|
|
2719
|
+
reportCommandError("Validation failed", error);
|
|
2523
2720
|
}
|
|
2524
|
-
|
|
2525
|
-
|
|
2526
|
-
|
|
2527
|
-
|
|
2528
|
-
|
|
2529
|
-
|
|
2530
|
-
|
|
2531
|
-
|
|
2532
|
-
|
|
2533
|
-
|
|
2534
|
-
|
|
2535
|
-
|
|
2536
|
-
|
|
2537
|
-
|
|
2538
|
-
|
|
2539
|
-
|
|
2540
|
-
|
|
2721
|
+
});
|
|
2722
|
+
};
|
|
2723
|
+
//#endregion
|
|
2724
|
+
//#region src/runtime.ts
|
|
2725
|
+
const SUPPORTED_NODE_MAJOR = 24;
|
|
2726
|
+
const SUPPORTED_NODE_RANGE = "24.x";
|
|
2727
|
+
const parseVersion = (input) => {
|
|
2728
|
+
const match = /^v?(\d+)\.(\d+)\.(\d+)/.exec(input.trim());
|
|
2729
|
+
if (!match) return null;
|
|
2730
|
+
const [, majorText = "", minorText = "", patchText = ""] = match;
|
|
2731
|
+
if (!majorText || !minorText || !patchText) return null;
|
|
2732
|
+
const major = Number.parseInt(majorText, 10);
|
|
2733
|
+
const minor = Number.parseInt(minorText, 10);
|
|
2734
|
+
const patch = Number.parseInt(patchText, 10);
|
|
2735
|
+
if ([
|
|
2736
|
+
major,
|
|
2737
|
+
minor,
|
|
2738
|
+
patch
|
|
2739
|
+
].some((value) => Number.isNaN(value))) return null;
|
|
2740
|
+
return [
|
|
2741
|
+
major,
|
|
2742
|
+
minor,
|
|
2743
|
+
patch
|
|
2744
|
+
];
|
|
2745
|
+
};
|
|
2746
|
+
const isSupportedNodeVersion = (version) => {
|
|
2747
|
+
const parsed = parseVersion(version);
|
|
2748
|
+
if (!parsed) return false;
|
|
2749
|
+
const [major] = parsed;
|
|
2750
|
+
return major === SUPPORTED_NODE_MAJOR;
|
|
2751
|
+
};
|
|
2752
|
+
const assertSupportedNodeVersion = (version = process.versions.node) => {
|
|
2753
|
+
if (isSupportedNodeVersion(version)) return;
|
|
2754
|
+
throw new CliError(`blodemd requires Node.js ${SUPPORTED_NODE_RANGE}. Current version: ${version}.`, EXIT_CODES.VALIDATION, "Install a supported Node.js version and try again.");
|
|
2755
|
+
};
|
|
2756
|
+
const readCliVersion = (moduleUrl) => {
|
|
2757
|
+
const moduleDir = path.dirname(fileURLToPath(moduleUrl));
|
|
2758
|
+
const raw = readFileSync(path.resolve(moduleDir, "..", "package.json"), "utf8");
|
|
2759
|
+
return JSON.parse(raw).version ?? "0.0.0";
|
|
2760
|
+
};
|
|
2761
|
+
//#endregion
|
|
2762
|
+
//#region src/cli.ts
|
|
2763
|
+
const program = new Command();
|
|
2764
|
+
const cliVersion = readCliVersion(import.meta.url);
|
|
2765
|
+
program.name("blodemd").description("Blode.md CLI").version(cliVersion);
|
|
2766
|
+
program.hook("preAction", () => {
|
|
2767
|
+
assertSupportedNodeVersion();
|
|
2541
2768
|
});
|
|
2542
|
-
program
|
|
2543
|
-
|
|
2544
|
-
|
|
2545
|
-
|
|
2546
|
-
|
|
2769
|
+
registerAuthCommands(program);
|
|
2770
|
+
registerNewCommand(program);
|
|
2771
|
+
registerValidateCommand(program);
|
|
2772
|
+
registerPushCommand(program);
|
|
2773
|
+
registerDevCommand(program);
|
|
2774
|
+
registerAnalyticsCommand(program);
|
|
2547
2775
|
program.parse();
|
|
2548
2776
|
//#endregion
|
|
2549
2777
|
export {};
|