blodemd 0.0.12 → 0.0.14

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.
Files changed (138) hide show
  1. package/README.md +1 -1
  2. package/dev-server/lib/local-runtime.tsx +12 -15
  3. package/dev-server/next.config.js +19 -9
  4. package/dev-server/package.json +5 -1
  5. package/dev-server/tsconfig.json +0 -3
  6. package/dist/cli.mjs +1205 -969
  7. package/dist/cli.mjs.map +1 -1
  8. package/docs/app/globals.css +1 -1
  9. package/docs/components/api/api-playground.tsx +2 -5
  10. package/docs/components/api/api-reference.tsx +29 -3
  11. package/docs/components/docs/contextual-menu.tsx +37 -33
  12. package/docs/components/docs/copy-page-menu.tsx +18 -0
  13. package/docs/components/docs/doc-header.tsx +4 -1
  14. package/docs/components/docs/doc-shell.tsx +48 -18
  15. package/docs/components/docs/doc-sidebar.tsx +131 -89
  16. package/docs/components/docs/doc-toc.tsx +1 -0
  17. package/docs/components/docs/mobile-nav.tsx +72 -68
  18. package/docs/components/docs/sidebar-active-highlight.tsx +34 -0
  19. package/docs/components/docs/sidebar-scroll-area.tsx +9 -4
  20. package/docs/components/git/repo-picker.tsx +5 -2
  21. package/docs/components/mdx/index.tsx +31 -21
  22. package/docs/components/posthog-provider.tsx +42 -0
  23. package/docs/components/tenant-analytics.tsx +49 -0
  24. package/docs/components/third-parties.tsx +4 -1
  25. package/docs/components/ui/copy-button.tsx +16 -2
  26. package/docs/components/ui/search-dialog.tsx +431 -0
  27. package/docs/components/ui/search.tsx +44 -427
  28. package/docs/components/web-mcp.tsx +115 -0
  29. package/docs/lib/agent-skills.ts +48 -0
  30. package/docs/lib/config.ts +4 -5
  31. package/docs/lib/docs-collection.ts +7 -22
  32. package/docs/lib/docs-runtime.tsx +160 -10
  33. package/docs/lib/llms-full.ts +82 -0
  34. package/docs/lib/marketing-markdown.ts +79 -0
  35. package/docs/lib/marketing-site.ts +31 -0
  36. package/docs/lib/mdx.ts +17 -6
  37. package/docs/lib/routes.ts +19 -3
  38. package/docs/lib/shiki.ts +4 -0
  39. package/docs/lib/tenancy.ts +15 -2
  40. package/docs/lib/tenant-headers.ts +29 -0
  41. package/docs/lib/tenant-static.ts +73 -99
  42. package/package.json +10 -5
  43. package/packages/@repo/common/package.json +3 -3
  44. package/packages/@repo/contracts/dist/analytics.d.ts +21 -0
  45. package/packages/@repo/contracts/dist/analytics.d.ts.map +1 -0
  46. package/packages/@repo/contracts/dist/analytics.js +18 -0
  47. package/packages/@repo/contracts/dist/deployment.js +1 -1
  48. package/packages/@repo/contracts/dist/git.js +2 -2
  49. package/packages/@repo/contracts/dist/ids.d.ts.map +1 -1
  50. package/packages/@repo/contracts/dist/ids.js +2 -1
  51. package/packages/@repo/contracts/dist/index.d.ts +1 -0
  52. package/packages/@repo/contracts/dist/index.d.ts.map +1 -1
  53. package/packages/@repo/contracts/dist/index.js +1 -0
  54. package/packages/@repo/contracts/dist/project.d.ts +18 -1
  55. package/packages/@repo/contracts/dist/project.d.ts.map +1 -1
  56. package/packages/@repo/contracts/dist/project.js +3 -1
  57. package/packages/@repo/contracts/dist/tenant.d.ts +36 -0
  58. package/packages/@repo/contracts/dist/tenant.d.ts.map +1 -1
  59. package/packages/@repo/contracts/dist/tenant.js +2 -0
  60. package/packages/@repo/contracts/package.json +3 -3
  61. package/packages/@repo/contracts/src/analytics.ts +33 -0
  62. package/packages/@repo/contracts/src/deployment.ts +1 -1
  63. package/packages/@repo/contracts/src/git.ts +2 -2
  64. package/packages/@repo/contracts/src/ids.ts +2 -1
  65. package/packages/@repo/contracts/src/index.ts +1 -0
  66. package/packages/@repo/contracts/src/project.ts +3 -1
  67. package/packages/@repo/contracts/src/tenant.ts +2 -0
  68. package/packages/@repo/models/dist/docs-config.d.ts +0 -198
  69. package/packages/@repo/models/dist/docs-config.d.ts.map +1 -1
  70. package/packages/@repo/models/dist/docs-config.js +3 -2
  71. package/packages/@repo/models/dist/tenant.d.ts +12 -0
  72. package/packages/@repo/models/dist/tenant.d.ts.map +1 -1
  73. package/packages/@repo/models/package.json +3 -3
  74. package/packages/@repo/models/src/docs-config.ts +3 -3
  75. package/packages/@repo/models/src/tenant.ts +15 -0
  76. package/packages/@repo/prebuild/dist/openapi.d.ts.map +1 -1
  77. package/packages/@repo/prebuild/dist/openapi.js +32 -4
  78. package/packages/@repo/prebuild/package.json +3 -3
  79. package/packages/@repo/prebuild/src/openapi.ts +42 -4
  80. package/packages/@repo/previewing/dist/blob-source.d.ts.map +1 -1
  81. package/packages/@repo/previewing/dist/blob-source.js +18 -5
  82. package/packages/@repo/previewing/dist/config-loader.d.ts +134 -0
  83. package/packages/@repo/previewing/dist/config-loader.d.ts.map +1 -0
  84. package/packages/@repo/previewing/dist/config-loader.js +231 -0
  85. package/packages/@repo/previewing/dist/constants.d.ts +15 -0
  86. package/packages/@repo/previewing/dist/constants.d.ts.map +1 -0
  87. package/packages/@repo/previewing/dist/constants.js +14 -0
  88. package/packages/@repo/previewing/dist/content-index.d.ts +7 -0
  89. package/packages/@repo/previewing/dist/content-index.d.ts.map +1 -0
  90. package/packages/@repo/previewing/dist/content-index.js +291 -0
  91. package/packages/@repo/previewing/dist/index.d.ts +13 -121
  92. package/packages/@repo/previewing/dist/index.d.ts.map +1 -1
  93. package/packages/@repo/previewing/dist/index.js +11 -1003
  94. package/packages/@repo/previewing/dist/markdown/agent-markdown.d.ts +3 -0
  95. package/packages/@repo/previewing/dist/markdown/agent-markdown.d.ts.map +1 -0
  96. package/packages/@repo/previewing/dist/markdown/agent-markdown.js +176 -0
  97. package/packages/@repo/previewing/dist/markdown/format.d.ts +8 -0
  98. package/packages/@repo/previewing/dist/markdown/format.d.ts.map +1 -0
  99. package/packages/@repo/previewing/dist/markdown/format.js +43 -0
  100. package/packages/@repo/previewing/dist/markdown/links.d.ts +3 -0
  101. package/packages/@repo/previewing/dist/markdown/links.d.ts.map +1 -0
  102. package/packages/@repo/previewing/dist/markdown/links.js +31 -0
  103. package/packages/@repo/previewing/dist/openapi-pages.d.ts +7 -0
  104. package/packages/@repo/previewing/dist/openapi-pages.d.ts.map +1 -0
  105. package/packages/@repo/previewing/dist/openapi-pages.js +200 -0
  106. package/packages/@repo/previewing/dist/search-index.d.ts +4 -0
  107. package/packages/@repo/previewing/dist/search-index.d.ts.map +1 -0
  108. package/packages/@repo/previewing/dist/search-index.js +23 -0
  109. package/packages/@repo/previewing/dist/serialization.d.ts +13 -0
  110. package/packages/@repo/previewing/dist/serialization.d.ts.map +1 -0
  111. package/packages/@repo/previewing/dist/serialization.js +108 -0
  112. package/packages/@repo/previewing/dist/toc-index.d.ts +5 -0
  113. package/packages/@repo/previewing/dist/toc-index.d.ts.map +1 -0
  114. package/packages/@repo/previewing/dist/toc-index.js +53 -0
  115. package/packages/@repo/previewing/dist/types.d.ts +92 -0
  116. package/packages/@repo/previewing/dist/types.d.ts.map +1 -0
  117. package/packages/@repo/previewing/dist/types.js +1 -0
  118. package/packages/@repo/previewing/dist/utility-index.d.ts +7 -0
  119. package/packages/@repo/previewing/dist/utility-index.d.ts.map +1 -0
  120. package/packages/@repo/previewing/dist/utility-index.js +201 -0
  121. package/packages/@repo/previewing/package.json +3 -3
  122. package/packages/@repo/previewing/src/blob-source.ts +20 -5
  123. package/packages/@repo/previewing/src/config-loader.ts +316 -0
  124. package/packages/@repo/previewing/src/constants.ts +16 -0
  125. package/packages/@repo/previewing/src/content-index.ts +384 -0
  126. package/packages/@repo/previewing/src/index.ts +69 -1515
  127. package/packages/@repo/previewing/src/markdown/agent-markdown.ts +254 -0
  128. package/packages/@repo/previewing/src/markdown/format.ts +63 -0
  129. package/packages/@repo/previewing/src/markdown/links.ts +46 -0
  130. package/packages/@repo/previewing/src/openapi-pages.ts +319 -0
  131. package/packages/@repo/previewing/src/search-index.ts +38 -0
  132. package/packages/@repo/previewing/src/serialization.ts +191 -0
  133. package/packages/@repo/previewing/src/toc-index.ts +72 -0
  134. package/packages/@repo/previewing/src/types.ts +105 -0
  135. package/packages/@repo/previewing/src/utility-index.ts +304 -0
  136. package/packages/@repo/validation/package.json +3 -3
  137. package/scripts/repo-packages.mjs +8 -0
  138. 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 { spawn, spawnSync } from "node:child_process";
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 src/validation.ts
304
- const MAX_PORT = 65535;
305
- const parsePositiveInteger = (value, label) => {
306
- const trimmed = value.trim();
307
- if (!/^\d+$/u.test(trimmed)) throw new CliError(`${label} must be a positive integer.`, EXIT_CODES.VALIDATION);
308
- const parsed = Number(trimmed);
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 parsePort = (value, label = "Port") => {
313
- const parsed = parsePositiveInteger(value, label);
314
- if (parsed > MAX_PORT) throw new CliError(`${label} must be between 1 and ${MAX_PORT}.`, EXIT_CODES.VALIDATION);
315
- return parsed;
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/fs-source.js
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
- new Set(PageModeSchema.options);
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,497 +1008,231 @@ const resolveDocsRoot = async (dir, cwd = process.cwd()) => {
1007
1008
  };
1008
1009
  const validateDocsRoot = loadValidatedSiteConfig;
1009
1010
  //#endregion
1010
- //#region src/dev/watcher.ts
1011
- const INVALIDATE_ENDPOINT = "/blodemd-dev/invalidate";
1012
- const WATCH_DEBOUNCE_MS = 100;
1013
- const normalizeRelativePath$1 = (root, filePath) => path.relative(root, filePath).split(path.sep).join("/");
1014
- const isDirectoryEvent = (event) => event === "addDir" || event === "unlinkDir";
1015
- const createDevWatcher = ({ port, root }) => {
1016
- const watcher = watch(root, {
1017
- ignoreInitial: true,
1018
- ignored: [
1019
- "**/.git/**",
1020
- "**/.next/**",
1021
- "**/dist/**",
1022
- "**/node_modules/**"
1023
- ]
1024
- });
1025
- let flushTimer = null;
1026
- let pendingKind = "content";
1027
- const pendingPaths = /* @__PURE__ */ new Set();
1028
- const flush = async () => {
1029
- flushTimer = null;
1030
- const paths = [...pendingPaths];
1031
- const kind = pendingKind;
1032
- pendingPaths.clear();
1033
- pendingKind = "content";
1034
- if (!paths.length) return;
1035
- try {
1036
- const response = await fetch(`http://127.0.0.1:${port}${INVALIDATE_ENDPOINT}`, {
1037
- body: JSON.stringify({
1038
- kind,
1039
- paths
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
1042
  };
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
- }
1043
+ const getProjectSlugError = (project) => {
1044
+ if (!project) return;
1045
+ return validateProjectSlug(project);
1089
1046
  };
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);
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;
1106
1055
  };
1107
- const cleanupStandaloneRuntimeRoots = async (configDir, maxAgeMs = STANDALONE_RUNTIME_MAX_AGE_MS) => {
1108
- const cutoff = Date.now() - maxAgeMs;
1109
- const entries = await fs.readdir(configDir, { withFileTypes: true });
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
- }));
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;
1118
1061
  };
1119
- const probePortAvailability = async (port) => {
1120
- const server = createServer();
1121
- const listening = (async () => {
1122
- await once(server, "listening");
1123
- return { kind: "listening" };
1124
- })();
1125
- const errored = (async () => {
1126
- const [error] = await once(server, "error");
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;
1062
+ const parsePosthogHost = (value) => {
1063
+ const trimmed = value.trim();
1064
+ try {
1065
+ if (new URL(trimmed).protocol !== "https:") throw new InvalidArgumentError("PostHog host must use https://");
1066
+ } catch (error) {
1067
+ if (error instanceof InvalidArgumentError) throw error;
1068
+ throw new InvalidArgumentError("PostHog host must be a valid URL.");
1141
1069
  }
1142
- server.close();
1143
- await once(server, "close");
1144
- return true;
1070
+ return trimmed;
1145
1071
  };
1146
- const resolveDevPort = async (requestedPort, probePort = probePortAvailability) => {
1147
- for (let offset = 0; offset < DEV_PORT_SCAN_LIMIT; offset += 1) {
1148
- const candidate = requestedPort + offset;
1149
- if (candidate > 65535) break;
1150
- if (await probePort(candidate)) return candidate;
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.");
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".`);
1153
1076
  };
1154
- const shutdownChildProcess = async (child, timeoutMs = DEV_SHUTDOWN_TIMEOUT_MS) => {
1155
- if (child.exitCode !== null) return;
1156
- const timer = setTimeout(() => {
1157
- if (child.exitCode === null) child.kill("SIGKILL");
1158
- }, timeoutMs);
1159
- const exitPromise = once(child, "exit");
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 () => {
1160
1086
  try {
1161
- child.kill("SIGTERM");
1162
- } catch (error) {
1163
- clearTimeout(timer);
1164
- if (error.code === "ESRCH") return;
1165
- throw error;
1087
+ const { config } = await loadValidatedSiteConfig(await resolveDocsRoot());
1088
+ return config.slug ?? config.name;
1089
+ } catch {
1090
+ return;
1166
1091
  }
1167
- await exitPromise.finally(() => {
1168
- clearTimeout(timer);
1092
+ };
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
1169
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;
1170
1104
  };
1171
- /**
1172
- * Derive the CLI npm package root from the running script path.
1173
- * The CLI entry point is at `<pkg-root>/dist/cli.mjs`.
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);
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();
1110
+ };
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"
1183
1117
  },
1184
- recursive: true
1118
+ method: "PATCH"
1185
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();
1186
1122
  };
1187
- const isStandaloneCliInstall = async (cliPackageRoot) => {
1188
- try {
1189
- return (await fs.realpath(cliPackageRoot)).split(path.sep).includes("node_modules");
1190
- } catch {
1191
- return cliPackageRoot.split(path.sep).includes("node_modules");
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
1139
+ }, null, 2)}\n`);
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}`);
1192
1151
  }
1193
1152
  };
1194
- const createStandaloneRuntimeRoot = async (configDir = CONFIG_DIR) => {
1195
- await fs.mkdir(configDir, { recursive: true });
1196
- await cleanupStandaloneRuntimeRoots(configDir);
1197
- return await fs.mkdtemp(path.join(configDir, STANDALONE_RUNTIME_PREFIX));
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");
1198
1195
  };
1199
- const materializeStandaloneRuntime = async (cliPackageRoot) => {
1200
- const runtimeRoot = await createStandaloneRuntimeRoot();
1196
+ const runAction = async (label, action) => {
1201
1197
  try {
1202
- for (const dir of [
1203
- "dev-server",
1204
- "docs",
1205
- "packages"
1206
- ]) await copyStandaloneTree(path.join(cliPackageRoot, dir), path.join(runtimeRoot, dir));
1207
- await fs.symlink(path.join(cliPackageRoot, "node_modules"), path.join(runtimeRoot, "node_modules"), process.platform === "win32" ? "junction" : "dir");
1208
- await fs.mkdir(path.join(runtimeRoot, "dev-server", "node_modules"), { recursive: true });
1209
- await fs.symlink(path.join(runtimeRoot, "packages", "@repo"), path.join(runtimeRoot, "dev-server", "node_modules", "@repo"), process.platform === "win32" ? "junction" : "dir");
1210
- await fs.writeFile(path.join(runtimeRoot, "dev-server", "package.json"), `${JSON.stringify({
1211
- dependencies: {
1212
- next: "16.2.1",
1213
- react: "^19.2.0",
1214
- "react-dom": "^19.2.0"
1215
- },
1216
- devDependencies: {
1217
- "@types/node": "^22.19.15",
1218
- "@types/react": "19.2.14",
1219
- "@types/react-dom": "19.2.3",
1220
- typescript: "6.0.2"
1221
- },
1222
- name: "blodemd-dev-server",
1223
- private: true,
1224
- type: "module"
1225
- }, null, 2)}\n`);
1226
- return {
1227
- devServerDir: path.join(runtimeRoot, "dev-server"),
1228
- runtimeRoot
1229
- };
1198
+ await action();
1230
1199
  } catch (error) {
1231
- await fs.rm(runtimeRoot, {
1232
- force: true,
1233
- recursive: true
1234
- });
1235
- throw error;
1236
- }
1237
- };
1238
- /**
1239
- * Check if a shipped dev-server exists alongside an installed CLI package.
1240
- * We only use standalone mode when the package root lives under `node_modules`.
1241
- */
1242
- const findStandaloneDevServer = async (cliPackageRoot) => {
1243
- const devServerDir = path.join(cliPackageRoot, "dev-server");
1244
- if (!await fileExists(path.join(devServerDir, "next.config.js"))) return null;
1245
- if (!await isStandaloneCliInstall(cliPackageRoot)) return null;
1246
- try {
1247
- createRequire(path.join(cliPackageRoot, "package.json")).resolve("next/package.json");
1248
- } catch {
1249
- return null;
1200
+ const cliError = toCliError(error);
1201
+ log.error(`${label}: ${cliError.message}`);
1202
+ if (cliError.hint) log.info(cliError.hint);
1203
+ process.exitCode = cliError.exitCode;
1250
1204
  }
1251
- const runtime = await materializeStandaloneRuntime(cliPackageRoot);
1252
- return {
1253
- devServerDir: runtime.devServerDir,
1254
- mode: "standalone",
1255
- nextPackageRoot: cliPackageRoot,
1256
- runtimeRoot: runtime.runtimeRoot
1257
- };
1258
1205
  };
1259
- /**
1260
- * Resolve the `next` CLI binary from the blodemd package's own dependencies.
1261
- */
1262
- const resolveNextBin = (cliPackageRoot) => {
1263
- const nextPkgPath = createRequire(path.join(cliPackageRoot, "package.json")).resolve("next/package.json");
1264
- return path.join(path.dirname(nextPkgPath), "dist", "bin", "next");
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
+ });
1265
1221
  };
1266
- const findMonorepoRoot = async (start) => {
1267
- let current = start;
1268
- while (true) {
1269
- const packageJsonPath = path.join(current, "package.json");
1270
- if (await fileExists(packageJsonPath)) {
1271
- const raw = await fs.readFile(packageJsonPath, "utf8");
1272
- const workspaces = JSON.parse(raw).workspaces ?? [];
1273
- if (workspaces.includes("apps/*") && workspaces.includes("packages/*")) return current;
1274
- }
1275
- const parent = path.dirname(current);
1276
- if (parent === current) break;
1277
- current = parent;
1278
- }
1279
- throw new CliError("Could not locate the blodemd dev server.", EXIT_CODES.ERROR, "Make sure blodemd is installed correctly (npm i blodemd).");
1280
- };
1281
- const resolveDevServer = async (cliFilePath) => {
1282
- const standalone = await findStandaloneDevServer(resolveCliPackageRoot(cliFilePath));
1283
- if (standalone) return standalone;
1284
- return {
1285
- mode: "monorepo",
1286
- repoRoot: await findMonorepoRoot(path.dirname(cliFilePath))
1287
- };
1288
- };
1289
- const buildDevServerLaunch = (server, { root, port }) => {
1290
- if (server.mode === "standalone") return {
1291
- args: [resolveNextBin(server.nextPackageRoot), ...TURBOPACK_ARGS],
1292
- command: process.execPath,
1293
- cwd: server.devServerDir,
1294
- env: {
1295
- ...process.env,
1296
- BLODEMD_PACKAGES_DIR: path.join(server.runtimeRoot, "packages"),
1297
- BLODEMD_TURBOPACK_ROOT: resolveCommonAncestor([server.nextPackageRoot, server.runtimeRoot]),
1298
- DOCS_ROOT: root,
1299
- PORT: String(port)
1300
- }
1301
- };
1302
- return {
1303
- args: [
1304
- "run",
1305
- "dev",
1306
- "--workspace=dev-server"
1307
- ],
1308
- command: process.platform === "win32" ? "npm.cmd" : "npm",
1309
- cwd: server.repoRoot,
1310
- env: {
1311
- ...process.env,
1312
- DOCS_ROOT: root,
1313
- PORT: String(port)
1314
- }
1315
- };
1316
- };
1317
- const spawnDevServer = (server, options) => {
1318
- const launch = buildDevServerLaunch(server, options);
1319
- return spawn(launch.command, launch.args, {
1320
- cwd: launch.cwd,
1321
- env: launch.env,
1322
- stdio: "inherit"
1323
- });
1324
- };
1325
- const waitForServer = async ({ child, port }) => {
1326
- const url = `http://localhost:${port}${DEV_READY_ENDPOINT}`;
1327
- const startedAt = Date.now();
1328
- while (Date.now() - startedAt < DEV_READY_TIMEOUT_MS) {
1329
- if (child.exitCode !== null) throw new CliError("The local dev server exited before it became ready.", EXIT_CODES.ERROR);
1330
- try {
1331
- if ((await fetch(url, {
1332
- cache: "no-store",
1333
- headers: { accept: "application/json" }
1334
- })).ok) return;
1335
- } catch {}
1336
- await setTimeout$1(500);
1337
- }
1338
- throw new CliError("Timed out waiting for the local dev server to start.", EXIT_CODES.ERROR);
1339
- };
1340
- const defaultDevCommandDependencies = {
1341
- createWatcher: createDevWatcher,
1342
- getCliFilePath: () => fileURLToPath(import.meta.url),
1343
- getIntro: intro,
1344
- getLog: log,
1345
- getOpen: open,
1346
- parsePortValue: parsePort,
1347
- removeDirectory: fs.rm,
1348
- resolveDevPortValue: resolveDevPort,
1349
- resolveDocsRootValue: resolveDocsRoot,
1350
- resolveServer: resolveDevServer,
1351
- shutdownChild: shutdownChildProcess,
1352
- spawnServer: spawnDevServer,
1353
- validateDocsRootValue: validateDocsRoot,
1354
- waitForServerReady: waitForServer
1355
- };
1356
- const devCommand = async ({ dir, openBrowser, port: portValue }, dependencies = defaultDevCommandDependencies) => {
1357
- const cliLog = dependencies.getLog;
1358
- dependencies.getIntro(chalk.bold("blodemd dev"));
1359
- try {
1360
- const port = dependencies.parsePortValue(portValue);
1361
- const resolvedPort = await dependencies.resolveDevPortValue(port);
1362
- const root = await dependencies.resolveDocsRootValue(dir);
1363
- await dependencies.validateDocsRootValue(root);
1364
- const cliFilePath = dependencies.getCliFilePath();
1365
- const server = await dependencies.resolveServer(cliFilePath);
1366
- const localUrl = `http://localhost:${resolvedPort}`;
1367
- cliLog.info(`Docs root: ${chalk.cyan(root)}`);
1368
- const child = dependencies.spawnServer(server, {
1369
- port: resolvedPort,
1370
- root
1371
- });
1372
- let watcher = null;
1373
- let shuttingDown = false;
1374
- const closeAll = async () => {
1375
- if (shuttingDown) return;
1376
- shuttingDown = true;
1377
- if (watcher) {
1378
- await watcher.close();
1379
- watcher = null;
1380
- }
1381
- await dependencies.shutdownChild(child);
1382
- if (server.mode === "standalone") await dependencies.removeDirectory(server.runtimeRoot, {
1383
- force: true,
1384
- recursive: true
1385
- });
1386
- };
1387
- process.once("SIGINT", closeAll);
1388
- process.once("SIGTERM", closeAll);
1389
- try {
1390
- await dependencies.waitForServerReady({
1391
- child,
1392
- port: resolvedPort
1393
- });
1394
- watcher = await dependencies.createWatcher({
1395
- port: resolvedPort,
1396
- root
1397
- });
1398
- cliLog.success(`Dev server running at ${chalk.cyan(localUrl)}`);
1399
- if (openBrowser) await dependencies.getOpen(localUrl);
1400
- const [code, signal] = await once(child, "exit");
1401
- if (shuttingDown || signal === "SIGINT" || signal === "SIGTERM") return;
1402
- if (code !== 0) throw new CliError(`The local dev server exited with code ${code ?? "unknown"}.`, EXIT_CODES.ERROR);
1403
- } finally {
1404
- await closeAll();
1405
- process.removeListener("SIGINT", closeAll);
1406
- process.removeListener("SIGTERM", closeAll);
1407
- }
1408
- } catch (error) {
1409
- const cliError = toCliError(error);
1410
- cliLog.error(cliError.message);
1411
- if (cliError.hint) cliLog.info(cliError.hint);
1412
- process.exitCode = cliError.exitCode;
1413
- }
1414
- };
1415
- //#endregion
1416
- //#region src/fs-utils.ts
1417
- const writeFileIfMissing = async (filePath, content) => {
1418
- try {
1419
- await fs.writeFile(filePath, content, { flag: "wx" });
1420
- } catch (error) {
1421
- const { code } = error;
1422
- if (code === "EEXIST") {
1423
- if ((await fs.stat(filePath).catch(() => null))?.isFile()) return;
1424
- }
1425
- throw error;
1426
- }
1427
- };
1428
- const writeSymlinkIfMissing = async (filePath, target, options) => {
1429
- const lstat = options?.lstat ?? fs.lstat;
1430
- const symlink = options?.symlink ?? fs.symlink;
1431
- try {
1432
- await symlink(target, filePath);
1433
- } catch (error) {
1434
- const { code } = error;
1435
- if (code === "EEXIST") {
1436
- const existing = await lstat(filePath).catch(() => null);
1437
- if (existing?.isFile() || existing?.isSymbolicLink()) return;
1438
- }
1439
- if (options?.fallbackContent && (code === "EINVAL" || code === "ENOTSUP" || code === "EPERM" || code === "UNKNOWN")) {
1440
- await writeFileIfMissing(filePath, options.fallbackContent);
1441
- return;
1442
- }
1443
- throw error;
1444
- }
1445
- };
1446
- const findExistingPaths = async (root, relativePaths) => {
1447
- return (await Promise.all(relativePaths.map(async (relativePath) => {
1448
- return await fs.lstat(path.join(root, relativePath)).catch(() => null) ? relativePath : null;
1449
- }))).filter((relativePath) => relativePath !== null).toSorted((left, right) => left.localeCompare(right));
1450
- };
1451
- //#endregion
1452
- //#region src/project-config.ts
1453
- const LEGACY_PROJECT_NAME_FALLBACK_WARNING = "docs.json.slug is recommended. Falling back to docs.json.name as the deployment slug is deprecated.";
1454
- const validateProjectSlug = (value) => {
1455
- const trimmed = value?.trim();
1456
- if (!trimmed) return "Project slug is required.";
1457
- const normalized = slugify(trimmed);
1458
- if (!normalized) return "Use at least one letter or number.";
1459
- if (normalized !== trimmed) return `Use lowercase letters, numbers, and hyphens. Try "${normalized}".`;
1460
- };
1461
- const deriveDisplayNameFromProjectSlug = (projectSlug) => projectSlug.split("-").filter(Boolean).map((segment) => segment[0]?.toUpperCase() + segment.slice(1)).join(" ");
1462
- const resolveProjectTarget = (options) => {
1463
- if (options.cliProject) return {
1464
- project: options.cliProject,
1465
- usedLegacyNameFallback: false
1466
- };
1467
- if (options.envProject) return {
1468
- project: options.envProject,
1469
- usedLegacyNameFallback: false
1470
- };
1471
- if (options.config.slug) return {
1472
- project: options.config.slug,
1473
- usedLegacyNameFallback: false
1474
- };
1475
- if (options.config.name) return {
1476
- project: options.config.name,
1477
- usedLegacyNameFallback: true
1478
- };
1479
- return {
1480
- project: void 0,
1481
- usedLegacyNameFallback: false
1482
- };
1483
- };
1484
- const getProjectSlugError = (project) => {
1485
- if (!project) return;
1486
- return validateProjectSlug(project);
1487
- };
1488
- //#endregion
1489
- //#region src/scaffold.ts
1490
- const SCAFFOLD_TEMPLATES = ["minimal", "starter"];
1491
- const DEFAULT_SCAFFOLD_DIRECTORY = "docs";
1492
- const stringifyJson = (value) => `${JSON.stringify(value, null, 2)}\n`;
1493
- const escapeXmlText = (value) => value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("\"", "&quot;").replaceAll("'", "&apos;");
1494
- const isScaffoldTemplate = (value) => SCAFFOLD_TEMPLATES.includes(value);
1495
- const normalizeProjectSlug = (value) => slugify(value) || "my-project";
1496
- const resolveScaffoldDirectory = (directory) => directory?.trim() || "docs";
1497
- const deriveDefaultProjectSlug = (directory, cwd) => {
1498
- const resolvedDirectory = resolveScaffoldDirectory(directory);
1499
- if (resolvedDirectory === "." || resolvedDirectory === "docs") return normalizeProjectSlug(path.basename(cwd));
1500
- return normalizeProjectSlug(path.basename(path.resolve(cwd, resolvedDirectory)));
1222
+ //#endregion
1223
+ //#region src/scaffold.ts
1224
+ const SCAFFOLD_TEMPLATES = ["minimal", "starter"];
1225
+ const DEFAULT_SCAFFOLD_DIRECTORY = "docs";
1226
+ const DEFAULT_PROJECT_SLUG = "my-project";
1227
+ const stringifyJson = (value) => `${JSON.stringify(value, null, 2)}\n`;
1228
+ const escapeXmlText = (value) => value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("\"", "&quot;").replaceAll("'", "&apos;");
1229
+ const isScaffoldTemplate = (value) => SCAFFOLD_TEMPLATES.includes(value);
1230
+ const normalizeProjectSlug = (value) => slugify(value) || DEFAULT_PROJECT_SLUG;
1231
+ const resolveScaffoldDirectory = (directory) => directory?.trim() || "docs";
1232
+ const deriveDefaultProjectSlug = (directory, cwd) => {
1233
+ const resolvedDirectory = resolveScaffoldDirectory(directory);
1234
+ if (resolvedDirectory === "." || resolvedDirectory === "docs") return normalizeProjectSlug(path.basename(cwd));
1235
+ return normalizeProjectSlug(path.basename(path.resolve(cwd, resolvedDirectory)));
1501
1236
  };
1502
1237
  const createMinimalDocsJson = (projectSlug, displayName) => ({
1503
1238
  $schema: "https://blode.md/docs.json",
@@ -1845,42 +1580,87 @@ const createStarterFiles = (projectSlug, displayName) => {
1845
1580
  ];
1846
1581
  };
1847
1582
  const getScaffoldFiles = (template, options) => {
1848
- const projectSlug = options?.projectSlug ?? "my-project";
1583
+ const projectSlug = options?.projectSlug ?? DEFAULT_PROJECT_SLUG;
1849
1584
  const displayName = options?.displayName ?? deriveDisplayNameFromProjectSlug(projectSlug);
1850
1585
  return template === "starter" ? createStarterFiles(projectSlug, displayName) : createMinimalFiles(projectSlug, displayName);
1851
1586
  };
1852
1587
  //#endregion
1853
- //#region src/new-flow.ts
1854
- const CREATE_IN_SUBDIRECTORY = "create-in-subdirectory";
1855
- const SCAFFOLD_CURRENT_DIRECTORY = "scaffold-current-directory";
1856
- const CANCEL_SCAFFOLD = "cancel";
1857
- const resolveInitialDirectory = (options) => {
1858
- if (options.directory) return {
1859
- directory: options.directory,
1860
- kind: "target"
1861
- };
1862
- if (!options.interactive) return {
1863
- directory: DEFAULT_SCAFFOLD_DIRECTORY,
1864
- kind: "target"
1865
- };
1866
- if (options.currentDirectoryEntries.length === 0) return {
1867
- directory: ".",
1868
- kind: "target"
1869
- };
1870
- return { kind: "prompt" };
1871
- };
1872
- const resolveDirectoryFromAction = (action, subdirectory) => {
1873
- if (action === "scaffold-current-directory") return ".";
1874
- 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;
1875
1600
  };
1876
- //#endregion
1877
- //#region src/oauth-callback.ts
1878
- 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>";
1879
- const escapeHtml = (text) => text.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("\"", "&quot;");
1880
- 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>`;
1881
- const waitForOAuthCode = (options) => {
1882
- const host = options.redirectUrl.hostname;
1883
- const port = Number(options.redirectUrl.port);
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("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("\"", "&quot;");
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);
1884
1664
  const { pathname } = options.redirectUrl;
1885
1665
  if (!Number.isInteger(port) || port <= 0) return Promise.reject(new CliError("OAuth redirect URL requires an explicit port", EXIT_CODES.ERROR));
1886
1666
  return new Promise((resolve, reject) => {
@@ -1896,7 +1676,7 @@ const waitForOAuthCode = (options) => {
1896
1676
  });
1897
1677
  for (const socket of sockets) socket.destroy();
1898
1678
  };
1899
- const httpServer = createServer$1((request, response) => {
1679
+ const httpServer = createServer((request, response) => {
1900
1680
  if (!request.url) {
1901
1681
  response.writeHead(400, { "content-type": "text/html; charset=utf-8" });
1902
1682
  response.end(errorHtml("Missing request URL"));
@@ -1953,160 +1733,610 @@ const createOAuthState = () => randomBytes(24).toString("hex");
1953
1733
  const createCodeVerifier = () => randomBytes(64).toString("base64url");
1954
1734
  const createCodeChallenge = (verifier) => createHash("sha256").update(verifier).digest().toString("base64url");
1955
1735
  //#endregion
1956
- //#region src/runtime.ts
1957
- const MIN_SUPPORTED_NODE_VERSION = [
1958
- 20,
1959
- 17,
1960
- 0
1961
- ];
1962
- const SUPPORTED_NODE_RANGE = ">=20.17.0";
1963
- const parseVersion = (input) => {
1964
- const match = /^v?(\d+)\.(\d+)\.(\d+)/.exec(input.trim());
1965
- if (!match) return null;
1966
- const [, majorText = "", minorText = "", patchText = ""] = match;
1967
- if (!majorText || !minorText || !patchText) return null;
1968
- const major = Number.parseInt(majorText, 10);
1969
- const minor = Number.parseInt(minorText, 10);
1970
- const patch = Number.parseInt(patchText, 10);
1971
- if ([
1972
- major,
1973
- minor,
1974
- patch
1975
- ].some((value) => Number.isNaN(value))) return null;
1976
- return [
1977
- major,
1978
- minor,
1979
- patch
1980
- ];
1981
- };
1982
- const isSupportedNodeVersion = (version) => {
1983
- const parsed = parseVersion(version);
1984
- if (!parsed) return false;
1985
- const [major, minor, patch] = parsed;
1986
- const [minMajor, minMinor, minPatch] = MIN_SUPPORTED_NODE_VERSION;
1987
- if (major !== minMajor) return major > minMajor;
1988
- if (minor !== minMinor) return minor > minMinor;
1989
- return patch >= minPatch;
1990
- };
1991
- const assertSupportedNodeVersion = (version = process.versions.node) => {
1992
- if (isSupportedNodeVersion(version)) return;
1993
- 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;
1994
1744
  };
1995
- const readCliVersion = (moduleUrl) => {
1996
- const moduleDir = path.dirname(fileURLToPath(moduleUrl));
1997
- const raw = readFileSync(path.resolve(moduleDir, "..", "package.json"), "utf8");
1998
- return JSON.parse(raw).version ?? "0.0.0";
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;
1999
1749
  };
2000
1750
  //#endregion
2001
- //#region src/upload.ts
2002
- const TEXT_CONTENT_TYPES = {
2003
- ".css": "text/css; charset=utf-8",
2004
- ".html": "text/html; charset=utf-8",
2005
- ".js": "text/javascript; charset=utf-8",
2006
- ".json": "application/json; charset=utf-8",
2007
- ".md": "text/markdown; charset=utf-8",
2008
- ".mdx": "text/markdown; charset=utf-8",
2009
- ".svg": "image/svg+xml",
2010
- ".txt": "text/plain; charset=utf-8",
2011
- ".yaml": "application/yaml; charset=utf-8",
2012
- ".yml": "application/yaml; charset=utf-8"
2013
- };
2014
- const normalizeRelativePath = (root, filePath) => path.relative(root, filePath).split(path.sep).join("/");
2015
- const getContentType = (filePath) => TEXT_CONTENT_TYPES[path.extname(filePath).toLowerCase()] ?? "application/octet-stream";
2016
- const estimateUploadItemBytes = (item) => item.contentBase64.length + item.path.length + 64;
2017
- const createUploadBatchItem = async (filePath, root, readFile) => {
2018
- return {
2019
- contentBase64: (await readFile(filePath)).toString("base64"),
2020
- contentType: getContentType(filePath),
2021
- path: normalizeRelativePath(root, filePath)
2022
- };
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
+ }
2023
1758
  };
2024
- const createUploadBatches = async function* createUploadBatchesGenerator({ files, maxBatchBytes, readFile = fs.readFile, root }) {
2025
- let currentBatch = [];
2026
- let currentBatchBytes = 0;
2027
- for (const filePath of files) {
2028
- const item = await createUploadBatchItem(filePath, root, readFile);
2029
- if (shouldIgnoreRootDocsFile(item.path)) continue;
2030
- const itemBytes = estimateUploadItemBytes(item);
2031
- 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.");
2032
- if (currentBatch.length > 0 && currentBatchBytes + itemBytes > maxBatchBytes) {
2033
- yield currentBatch;
2034
- currentBatch = [];
2035
- currentBatchBytes = 0;
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);
2036
1805
  }
2037
- currentBatch.push(item);
2038
- currentBatchBytes += itemBytes;
2039
- }
2040
- if (currentBatch.length > 0) yield currentBatch;
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
+ });
2041
1841
  };
2042
1842
  //#endregion
2043
- //#region src/cli.ts
2044
- const CONFIG_FILE = "docs.json";
2045
- const readGitValue = (gitArgs) => {
2046
- const result = spawnSync("git", gitArgs, {
2047
- encoding: "utf8",
2048
- stdio: [
2049
- "ignore",
2050
- "pipe",
2051
- "ignore"
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/**"
2052
1856
  ]
2053
1857
  });
2054
- if (result.status !== 0) return;
2055
- return result.stdout.trim() || void 0;
2056
- };
2057
- const shouldSkipEntry = (name) => name.startsWith(".") || name === "node_modules";
2058
- const collectFiles = async (root, directory = root) => {
2059
- const entries = await fs.readdir(directory, { withFileTypes: true });
2060
- const files = [];
2061
- for (const entry of entries) {
2062
- if (shouldSkipEntry(entry.name)) continue;
2063
- const absolutePath = path.join(directory, entry.name);
2064
- const relativePath = path.relative(root, absolutePath).split(path.sep).join("/");
2065
- if (entry.isDirectory()) {
2066
- files.push(...await collectFiles(root, absolutePath));
2067
- continue;
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"}`);
2068
1880
  }
2069
- if (entry.isFile()) {
2070
- if (shouldIgnoreRootDocsFile(relativePath)) continue;
2071
- files.push(absolutePath);
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();
2072
1896
  }
2073
- }
2074
- return files.toSorted((left, right) => left.localeCompare(right));
2075
- };
2076
- const readJson = async (response) => {
2077
- const responseText = await response.text();
2078
- if (!responseText) return null;
2079
- try {
2080
- return JSON.parse(responseText);
2081
- } catch {
2082
- return responseText;
2083
- }
1897
+ await watcher.close();
1898
+ } };
2084
1899
  };
2085
- const requestJson = async (url, init, message) => {
2086
- const response = await fetch(url, init);
2087
- const data = await readJson(response);
2088
- if (!response.ok) {
2089
- const detail = typeof data === "string" ? data : JSON.stringify(data ?? {}, null, 2);
2090
- throw new Error(`${message}: ${response.status} ${detail}`);
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;
2091
1921
  }
2092
- return data;
2093
1922
  };
2094
- const reportCommandError = (prefix, error) => {
2095
- const cliError = toCliError(error);
2096
- log.error(`${prefix}: ${cliError.message}`);
2097
- if (cliError.hint) log.info(cliError.hint);
2098
- log.info("Failed");
2099
- process.exitCode = cliError.exitCode;
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);
2100
1939
  };
2101
- const parseScaffoldTemplate = (value) => {
2102
- if (isScaffoldTemplate(value)) return value;
2103
- throw new InvalidArgumentError(`Expected one of: ${SCAFFOLD_TEMPLATES.join(", ")}.`);
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" };
2104
2333
  };
2105
- const parseProjectSlug = (value) => {
2106
- const validationError = validateProjectSlug(value);
2107
- if (validationError) throw new InvalidArgumentError(validationError);
2108
- 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";
2109
2337
  };
2338
+ //#endregion
2339
+ //#region src/commands/new.ts
2110
2340
  const isInteractiveTerminal = () => process.stdin.isTTY === true && process.stdout.isTTY === true;
2111
2341
  const promptForNoArgDirectoryAction = async () => {
2112
2342
  const directoryAction = await select({
@@ -2219,13 +2449,113 @@ const writeScaffoldFiles = async (root, template, options) => {
2219
2449
  await writeFileIfMissing(filePath, file.content);
2220
2450
  }
2221
2451
  };
2222
- const fetchUserEmail = async (apiUrl, token) => {
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.`);
2223
2456
  try {
2224
- return (await requestJson(`${apiUrl}/auth/me`, { headers: { Authorization: `Bearer ${token}` } }, "Failed to fetch user info")).email;
2225
- } catch {
2226
- return null;
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);
2227
2492
  }
2228
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;
2554
+ }
2555
+ if (currentBatch.length > 0) yield currentBatch;
2556
+ };
2557
+ //#endregion
2558
+ //#region src/commands/push.ts
2229
2559
  const resolvePushConfig = async (config, options) => {
2230
2560
  const { project, usedLegacyNameFallback } = resolveProjectTarget({
2231
2561
  cliProject: options.project,
@@ -2276,48 +2606,6 @@ const autoCreateProject = async (project, projectDisplayName, apiUrl, headers) =
2276
2606
  log.success(`Project ${chalk.cyan(createResult.slug)} created`);
2277
2607
  return true;
2278
2608
  };
2279
- const scaffoldDocsSite = async (directory, options) => {
2280
- intro(chalk.bold("blodemd new"));
2281
- if (options?.deprecatedCommand) log.warn(`"${options.deprecatedCommand}" is deprecated. Use ${chalk.cyan("blodemd new")} instead.`);
2282
- if (options?.name && !options.slug) log.warn(`"${chalk.cyan("--name")}" is deprecated. Use ${chalk.cyan("--slug")} instead.`);
2283
- try {
2284
- const template = options?.template ?? "minimal";
2285
- const shouldPrompt = isInteractiveTerminal() && !options?.yes;
2286
- const selectedDirectory = await resolveRequestedDirectory(directory, shouldPrompt);
2287
- if (!selectedDirectory) {
2288
- log.warn("Cancelled");
2289
- return;
2290
- }
2291
- const resolvedDirectory = resolveScaffoldDirectory(selectedDirectory.directory);
2292
- const root = path.resolve(process.cwd(), resolvedDirectory);
2293
- if (!await confirmScaffoldTarget(root, template, shouldPrompt, { skipNonEmptyConfirmation: selectedDirectory.skipNonEmptyConfirmation })) {
2294
- log.warn("Cancelled");
2295
- return;
2296
- }
2297
- const projectSlug = await resolveProjectSlug(options?.slug ?? options?.name, resolvedDirectory, shouldPrompt);
2298
- if (!projectSlug) {
2299
- log.warn("Cancelled");
2300
- return;
2301
- }
2302
- const displayName = await resolveDisplayName(options?.displayName, projectSlug, shouldPrompt);
2303
- if (!displayName) {
2304
- log.warn("Cancelled");
2305
- return;
2306
- }
2307
- await fs.mkdir(root, { recursive: true });
2308
- await writeScaffoldFiles(root, template, {
2309
- displayName,
2310
- projectSlug
2311
- });
2312
- log.success(`Docs scaffolded in ${chalk.cyan(root)}`);
2313
- if (template === "starter") log.info("Starter template includes brand assets and helper files.");
2314
- log.info(`Display name: ${chalk.cyan(displayName)}`);
2315
- log.info(`Project slug: ${chalk.cyan(projectSlug)}`);
2316
- log.info("Done");
2317
- } catch (error) {
2318
- reportCommandError("New failed", error);
2319
- }
2320
- };
2321
2609
  const MAX_BATCH_BYTES = 4 * 1024 * 1024;
2322
2610
  const uploadFiles = async (files, root, apiPath, deploymentId, headers, s) => {
2323
2611
  s.start(`Uploading ${files.length} files`);
@@ -2337,192 +2625,140 @@ const uploadFiles = async (files, root, apiPath, deploymentId, headers, s) => {
2337
2625
  }
2338
2626
  s.stop(`Uploaded ${chalk.cyan(String(files.length))} files`);
2339
2627
  };
2340
- const program = new Command();
2341
- const cliVersion = readCliVersion(import.meta.url);
2342
- program.name("blodemd").description("Blode.md CLI").version(cliVersion);
2343
- program.hook("preAction", () => {
2344
- assertSupportedNodeVersion();
2345
- });
2346
- 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) => {
2347
- intro(chalk.bold("blodemd login"));
2348
- try {
2349
- const { authorizeUrl, tokenUrl } = buildOAuthUrls(resolveSupabaseConfig());
2350
- const clientId = OAUTH_CLIENT_ID;
2351
- const port = parsePort(options.port);
2352
- const timeoutSeconds = parsePositiveInteger(options.timeout, "Timeout");
2353
- const redirectUrl = new URL(`http://127.0.0.1:${port}${DEFAULT_OAUTH_CALLBACK_PATH}`);
2354
- const state = createOAuthState();
2355
- const codeVerifier = createCodeVerifier();
2356
- const codeChallenge = createCodeChallenge(codeVerifier);
2357
- const authUrl = new URL(authorizeUrl);
2358
- authUrl.searchParams.set("response_type", "code");
2359
- authUrl.searchParams.set("client_id", clientId);
2360
- authUrl.searchParams.set("redirect_uri", redirectUrl.toString());
2361
- authUrl.searchParams.set("code_challenge", codeChallenge);
2362
- authUrl.searchParams.set("code_challenge_method", "S256");
2363
- authUrl.searchParams.set("state", state);
2364
- authUrl.searchParams.set("scope", "openid email profile");
2365
- authUrl.searchParams.set("provider", "github");
2366
- const callbackPromise = waitForOAuthCode({
2367
- expectedState: state,
2368
- redirectUrl,
2369
- timeoutMs: timeoutSeconds * 1e3
2370
- });
2371
- if (options.open) {
2372
- log.info("Opening browser for authentication...");
2373
- log.info(`If the browser doesn't open, visit: ${chalk.cyan(authUrl.toString())}`);
2374
- await open(authUrl.toString());
2375
- } else {
2376
- log.info("Open this URL to continue authentication:");
2377
- log.info(chalk.cyan(authUrl.toString()));
2378
- }
2379
- const code = await callbackPromise;
2380
- const storedSession = tokenResponseToStoredSession(await exchangeAuthorizationCode({
2381
- clientId,
2382
- tokenUrl
2383
- }, code, codeVerifier, redirectUrl.toString()));
2384
- await writeStoredAuthSession(storedSession);
2385
- const email = storedSession.user?.email ?? await fetchUserEmail(process.env["BLODEMD_API_URL"] ?? "https://api.blode.md", storedSession.accessToken);
2386
- if (email) log.success(`Logged in as ${chalk.cyan(email)}`);
2387
- else log.success("Logged in successfully.");
2388
- log.info("Done");
2389
- } catch (error) {
2390
- reportCommandError("Login failed", error);
2391
- }
2392
- });
2393
- program.command("logout").description("Remove stored credentials").action(async () => {
2394
- intro(chalk.bold("blodemd logout"));
2395
- try {
2396
- let existing = false;
2628
+ const registerPushCommand = (program) => {
2629
+ 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) => {
2630
+ intro(chalk.bold("blodemd push"));
2631
+ const s = spinner();
2397
2632
  try {
2398
- await fs.access(CREDENTIALS_FILE);
2399
- existing = true;
2400
- } catch {
2401
- existing = false;
2402
- }
2403
- await clearStoredCredentials();
2404
- if (existing) log.success("Credentials removed.");
2405
- else log.info("No stored credentials found.");
2406
- log.info("Done");
2407
- } catch (error) {
2408
- reportCommandError("Logout failed", error);
2409
- }
2410
- });
2411
- program.command("whoami").description("Show current authentication").action(async () => {
2412
- try {
2413
- const resolved = await resolveAuthToken();
2414
- if (!resolved) {
2415
- log.warn("Not logged in. Run \"blodemd login\" to authenticate.");
2416
- return;
2633
+ const root = await resolveDocsRoot(dir);
2634
+ s.start("Validating configuration");
2635
+ const { config, warnings } = await loadValidatedSiteConfig(root);
2636
+ s.stop("Configuration valid");
2637
+ for (const warning of warnings) log.warn(warning);
2638
+ const { project, projectDisplayName, apiUrl, authToken, branch, commitMessage, usedLegacyNameFallback } = await resolvePushConfig(config, options);
2639
+ if (usedLegacyNameFallback) log.warn(LEGACY_PROJECT_NAME_FALLBACK_WARNING);
2640
+ s.start("Collecting files");
2641
+ const files = await collectFiles(root);
2642
+ if (files.length === 0) throw new Error("No files found to deploy.");
2643
+ s.stop(`Found ${chalk.cyan(String(files.length))} files`);
2644
+ const headers = {
2645
+ Authorization: `Bearer ${authToken}`,
2646
+ "Content-Type": "application/json"
2647
+ };
2648
+ const apiPath = (suffix) => new URL(`/projects/slug/${project}/deployments${suffix}`, apiUrl).toString();
2649
+ const createDeploymentBody = JSON.stringify({
2650
+ branch,
2651
+ commitMessage
2652
+ });
2653
+ s.start("Creating deployment");
2654
+ let deployment;
2655
+ try {
2656
+ deployment = await requestJson(apiPath(""), {
2657
+ body: createDeploymentBody,
2658
+ headers,
2659
+ method: "POST"
2660
+ }, "Failed to create deployment");
2661
+ } catch (error) {
2662
+ if (!(error instanceof Error ? error.message : "").includes("404")) throw error;
2663
+ s.stop("Project not found");
2664
+ if (!await autoCreateProject(project, projectDisplayName, apiUrl, headers)) {
2665
+ log.info("Cancelled");
2666
+ return;
2667
+ }
2668
+ s.start("Creating deployment");
2669
+ deployment = await requestJson(apiPath(""), {
2670
+ body: createDeploymentBody,
2671
+ headers,
2672
+ method: "POST"
2673
+ }, "Failed to create deployment");
2674
+ }
2675
+ s.stop(`Deployment ${chalk.cyan(deployment.id)} created`);
2676
+ await uploadFiles(files, root, apiPath, deployment.id, headers, s);
2677
+ s.start("Finalizing deployment");
2678
+ const finalized = await requestJson(apiPath(`/${deployment.id}/finalize`), {
2679
+ body: JSON.stringify({ promote: true }),
2680
+ headers,
2681
+ method: "POST"
2682
+ }, "Failed to finalize deployment");
2683
+ s.stop("Deployment finalized");
2684
+ log.success(`Published ${chalk.cyan(finalized.id)}`);
2685
+ if (finalized.manifestUrl) log.info(`Manifest: ${finalized.manifestUrl}`);
2686
+ if (typeof finalized.fileCount === "number") log.info(`Files: ${finalized.fileCount}`);
2687
+ log.info("Done");
2688
+ } catch (error) {
2689
+ s.stop("Failed");
2690
+ reportCommandError("Push failed", error);
2417
2691
  }
2418
- const status = resolveTokenStatus(resolved);
2419
- const email = resolved.user?.email ?? await fetchUserEmail(process.env["BLODEMD_API_URL"] ?? "https://api.blode.md", resolved.token);
2420
- if (email) log.info(`Logged in as ${chalk.cyan(email)}`);
2421
- else log.info("Logged in (could not fetch user details).");
2422
- if (resolved.expiresAt && status.expired) log.warn("Session has expired. Run \"blodemd login\" to re-authenticate.");
2423
- } catch (error) {
2424
- reportCommandError("Whoami failed", error);
2425
- }
2426
- });
2427
- 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) => {
2428
- await scaffoldDocsSite(directory, {
2429
- displayName: options.displayName,
2430
- name: options.name,
2431
- slug: options.slug ?? options.name,
2432
- template: options.template,
2433
- yes: options.yes
2434
- });
2435
- });
2436
- 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) => {
2437
- await scaffoldDocsSite(directory, {
2438
- deprecatedCommand: "blodemd init",
2439
- displayName: options.displayName,
2440
- name: options.name,
2441
- slug: options.slug ?? options.name,
2442
- template: options.template,
2443
- yes: options.yes
2444
2692
  });
2445
- });
2446
- program.command("validate").description("Validate docs.json").argument("[dir]", "docs directory").action(async (dir) => {
2447
- intro(chalk.bold("blodemd validate"));
2448
- try {
2449
- const { warnings } = await loadValidatedSiteConfig(await resolveDocsRoot(dir));
2450
- for (const warning of warnings) log.warn(warning);
2451
- log.success(`${chalk.cyan(CONFIG_FILE)} is valid.`);
2452
- log.info("Done");
2453
- } catch (error) {
2454
- reportCommandError("Validation failed", error);
2455
- }
2456
- });
2457
- 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) => {
2458
- intro(chalk.bold("blodemd push"));
2459
- const s = spinner();
2460
- try {
2461
- const root = await resolveDocsRoot(dir);
2462
- s.start("Validating configuration");
2463
- const { config, warnings } = await loadValidatedSiteConfig(root);
2464
- s.stop("Configuration valid");
2465
- for (const warning of warnings) log.warn(warning);
2466
- const { project, projectDisplayName, apiUrl, authToken, branch, commitMessage, usedLegacyNameFallback } = await resolvePushConfig(config, options);
2467
- if (usedLegacyNameFallback) log.warn(LEGACY_PROJECT_NAME_FALLBACK_WARNING);
2468
- s.start("Collecting files");
2469
- const files = await collectFiles(root);
2470
- if (files.length === 0) throw new Error("No files found to deploy.");
2471
- s.stop(`Found ${chalk.cyan(String(files.length))} files`);
2472
- const headers = {
2473
- Authorization: `Bearer ${authToken}`,
2474
- "Content-Type": "application/json"
2475
- };
2476
- const apiPath = (suffix) => new URL(`/projects/slug/${project}/deployments${suffix}`, apiUrl).toString();
2477
- const createDeploymentBody = JSON.stringify({
2478
- branch,
2479
- commitMessage
2480
- });
2481
- s.start("Creating deployment");
2482
- let deployment;
2693
+ };
2694
+ //#endregion
2695
+ //#region src/commands/validate.ts
2696
+ const CONFIG_FILE = "docs.json";
2697
+ const registerValidateCommand = (program) => {
2698
+ program.command("validate").description("Validate docs.json").argument("[dir]", "docs directory").action(async (dir) => {
2699
+ intro(chalk.bold("blodemd validate"));
2483
2700
  try {
2484
- deployment = await requestJson(apiPath(""), {
2485
- body: createDeploymentBody,
2486
- headers,
2487
- method: "POST"
2488
- }, "Failed to create deployment");
2701
+ const { warnings } = await loadValidatedSiteConfig(await resolveDocsRoot(dir));
2702
+ for (const warning of warnings) log.warn(warning);
2703
+ log.success(`${chalk.cyan(CONFIG_FILE)} is valid.`);
2704
+ log.info("Done");
2489
2705
  } catch (error) {
2490
- if (!(error instanceof Error ? error.message : "").includes("404")) throw error;
2491
- s.stop("Project not found");
2492
- if (!await autoCreateProject(project, projectDisplayName, apiUrl, headers)) {
2493
- log.info("Cancelled");
2494
- return;
2495
- }
2496
- s.start("Creating deployment");
2497
- deployment = await requestJson(apiPath(""), {
2498
- body: createDeploymentBody,
2499
- headers,
2500
- method: "POST"
2501
- }, "Failed to create deployment");
2706
+ reportCommandError("Validation failed", error);
2502
2707
  }
2503
- s.stop(`Deployment ${chalk.cyan(deployment.id)} created`);
2504
- await uploadFiles(files, root, apiPath, deployment.id, headers, s);
2505
- s.start("Finalizing deployment");
2506
- const finalized = await requestJson(apiPath(`/${deployment.id}/finalize`), {
2507
- body: JSON.stringify({ promote: true }),
2508
- headers,
2509
- method: "POST"
2510
- }, "Failed to finalize deployment");
2511
- s.stop("Deployment finalized");
2512
- log.success(`Published ${chalk.cyan(finalized.id)}`);
2513
- if (finalized.manifestUrl) log.info(`Manifest: ${finalized.manifestUrl}`);
2514
- if (typeof finalized.fileCount === "number") log.info(`Files: ${finalized.fileCount}`);
2515
- log.info("Done");
2516
- } catch (error) {
2517
- s.stop("Failed");
2518
- reportCommandError("Push failed", error);
2519
- }
2708
+ });
2709
+ };
2710
+ //#endregion
2711
+ //#region src/runtime.ts
2712
+ const SUPPORTED_NODE_MAJOR = 24;
2713
+ const SUPPORTED_NODE_RANGE = "24.x";
2714
+ const parseVersion = (input) => {
2715
+ const match = /^v?(\d+)\.(\d+)\.(\d+)/.exec(input.trim());
2716
+ if (!match) return null;
2717
+ const [, majorText = "", minorText = "", patchText = ""] = match;
2718
+ if (!majorText || !minorText || !patchText) return null;
2719
+ const major = Number.parseInt(majorText, 10);
2720
+ const minor = Number.parseInt(minorText, 10);
2721
+ const patch = Number.parseInt(patchText, 10);
2722
+ if ([
2723
+ major,
2724
+ minor,
2725
+ patch
2726
+ ].some((value) => Number.isNaN(value))) return null;
2727
+ return [
2728
+ major,
2729
+ minor,
2730
+ patch
2731
+ ];
2732
+ };
2733
+ const isSupportedNodeVersion = (version) => {
2734
+ const parsed = parseVersion(version);
2735
+ if (!parsed) return false;
2736
+ const [major] = parsed;
2737
+ return major === SUPPORTED_NODE_MAJOR;
2738
+ };
2739
+ const assertSupportedNodeVersion = (version = process.versions.node) => {
2740
+ if (isSupportedNodeVersion(version)) return;
2741
+ 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.");
2742
+ };
2743
+ const readCliVersion = (moduleUrl) => {
2744
+ const moduleDir = path.dirname(fileURLToPath(moduleUrl));
2745
+ const raw = readFileSync(path.resolve(moduleDir, "..", "package.json"), "utf8");
2746
+ return JSON.parse(raw).version ?? "0.0.0";
2747
+ };
2748
+ //#endregion
2749
+ //#region src/cli.ts
2750
+ const program = new Command();
2751
+ const cliVersion = readCliVersion(import.meta.url);
2752
+ program.name("blodemd").description("Blode.md CLI").version(cliVersion);
2753
+ program.hook("preAction", () => {
2754
+ assertSupportedNodeVersion();
2520
2755
  });
2521
- 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({
2522
- dir: options.dir,
2523
- openBrowser: options.open ?? true,
2524
- port: options.port
2525
- }));
2756
+ registerAuthCommands(program);
2757
+ registerNewCommand(program);
2758
+ registerValidateCommand(program);
2759
+ registerPushCommand(program);
2760
+ registerDevCommand(program);
2761
+ registerAnalyticsCommand(program);
2526
2762
  program.parse();
2527
2763
  //#endregion
2528
2764
  export {};