blodemd 0.0.15 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -9,7 +9,7 @@
9
9
  - **One-command deploy:** Push your entire docs folder to Blode.md with `blodemd push`.
10
10
  - **Scaffold in seconds:** Generate a ready-to-edit docs folder with `blodemd new`.
11
11
  - **Config validation:** Catch `docs.json` errors before deploying.
12
- - **Zero keys:** Sign in once with GitHub in your browser — the CLI handles the rest.
12
+ - **Zero keys locally:** Sign in once with GitHub in your browser — no keys for local deploys. CI uses a project deploy key.
13
13
 
14
14
  ## Install
15
15
 
@@ -41,6 +41,30 @@ blodemd dev
41
41
  blodemd push
42
42
  ```
43
43
 
44
+ ## Deploy from CI
45
+
46
+ The [GitHub App](#auto-deploy-without-the-cli) is the recommended zero-config path. To run the deploy yourself from GitHub Actions, use a project deploy key stored in the `BLODEMD_API_KEY` secret:
47
+
48
+ ```yaml
49
+ name: Deploy docs
50
+ on:
51
+ push:
52
+ branches: [main]
53
+ jobs:
54
+ deploy:
55
+ runs-on: ubuntu-latest
56
+ steps:
57
+ - uses: actions/checkout@v5
58
+ - uses: actions/setup-node@v5
59
+ with:
60
+ node-version: 24
61
+ - run: npx blodemd@latest push --project your-project-slug
62
+ env:
63
+ BLODEMD_API_KEY: ${{ secrets.BLODEMD_API_KEY }}
64
+ ```
65
+
66
+ Create a deploy key in the dashboard under **Settings → Deploy keys**, or copy the `bmd_...` key that `blodemd push` prints the first time it auto-creates a project. Deploy keys are project-scoped and deploy-only — store them as CI secrets, never commit them.
67
+
44
68
  ## Commands
45
69
 
46
70
  ```bash
@@ -57,6 +81,7 @@ blodemd dev [dir] Start the local docs preview server
57
81
 
58
82
  ```
59
83
  --project <slug> Project slug (env: BLODEMD_PROJECT)
84
+ --api-key <token> API key (env: BLODEMD_API_KEY)
60
85
  --api-url <url> API URL (env: BLODEMD_API_URL)
61
86
  --branch <name> Git branch (env: BLODEMD_BRANCH)
62
87
  --message <msg> Deploy message (env: BLODEMD_COMMIT_MESSAGE)
package/dist/cli.mjs CHANGED
@@ -20,6 +20,7 @@ import { watch } from "chokidar";
20
20
  import { readFileSync } from "node:fs";
21
21
  //#region src/constants.ts
22
22
  const CLI_NAME = "blodemd";
23
+ const BLODE_API_KEY_ENV = "BLODEMD_API_KEY";
23
24
  const BLODE_PROJECT_ENV = "BLODEMD_PROJECT";
24
25
  const OAUTH_CLIENT_ID = "6b5f9860-fe96-4a83-b1ad-266260523c91";
25
26
  const DEFAULT_OAUTH_CALLBACK_PORT = 8787;
@@ -55,6 +56,7 @@ const toCliError = (error) => {
55
56
  if (error instanceof Error) {
56
57
  if (error instanceof TypeError && error.message.includes("fetch")) return new CliError("Cannot connect to Blode.md API.", EXIT_CODES.NETWORK, "Check your internet connection and API URL configuration.");
57
58
  if (error.name === "TimeoutError" || error.name === "AbortError") return new CliError("Request timed out.", EXIT_CODES.NETWORK, "The API may be unavailable. Try again later.");
59
+ if (/\b401\b/.test(error.message)) return new CliError(error.message, EXIT_CODES.AUTH_REQUIRED, "Check your API key or run \"blodemd login\".");
58
60
  return new CliError(error.message, EXIT_CODES.ERROR);
59
61
  }
60
62
  return new CliError("Unknown error", EXIT_CODES.ERROR);
@@ -1220,6 +1222,67 @@ const registerAnalyticsCommand = (program) => {
1220
1222
  });
1221
1223
  };
1222
1224
  //#endregion
1225
+ //#region src/output.ts
1226
+ const isInteractive = (json = false) => !json && Boolean(process.stdout.isTTY) && !process.env.CI;
1227
+ const writeLine = (message) => {
1228
+ process.stderr.write(`${message}\n`);
1229
+ };
1230
+ const createReporter = ({ json = false } = {}) => {
1231
+ const interactive = isInteractive(json);
1232
+ if (!interactive) return {
1233
+ info: writeLine,
1234
+ interactive,
1235
+ json: (payload) => {
1236
+ process.stdout.write(`${JSON.stringify(payload)}\n`);
1237
+ },
1238
+ step: writeLine,
1239
+ stop: (message) => {
1240
+ if (message) writeLine(message);
1241
+ },
1242
+ success: writeLine,
1243
+ warn: writeLine
1244
+ };
1245
+ const s = spinner();
1246
+ let active = false;
1247
+ return {
1248
+ info(message) {
1249
+ if (active) {
1250
+ s.stop();
1251
+ active = false;
1252
+ }
1253
+ log.info(message);
1254
+ },
1255
+ interactive,
1256
+ json() {},
1257
+ step(message) {
1258
+ if (active) s.message(message);
1259
+ else {
1260
+ s.start(message);
1261
+ active = true;
1262
+ }
1263
+ },
1264
+ stop(message) {
1265
+ if (active) {
1266
+ s.stop(message);
1267
+ active = false;
1268
+ }
1269
+ },
1270
+ success(message) {
1271
+ if (active) {
1272
+ s.stop(message);
1273
+ active = false;
1274
+ } else log.success(message);
1275
+ },
1276
+ warn(message) {
1277
+ if (active) {
1278
+ s.stop();
1279
+ active = false;
1280
+ }
1281
+ log.warn(message);
1282
+ }
1283
+ };
1284
+ };
1285
+ //#endregion
1223
1286
  //#region src/scaffold.ts
1224
1287
  const SCAFFOLD_TEMPLATES = ["minimal", "starter"];
1225
1288
  const DEFAULT_SCAFFOLD_DIRECTORY = "docs";
@@ -1617,11 +1680,16 @@ const collectFiles = async (root, directory = root) => {
1617
1680
  }
1618
1681
  return files.toSorted((left, right) => left.localeCompare(right));
1619
1682
  };
1620
- const reportCommandError = (prefix, error) => {
1683
+ const reportCommandError = (prefix, error, options) => {
1621
1684
  const cliError = toCliError(error);
1622
- log.error(`${prefix}: ${cliError.message}`);
1623
- if (cliError.hint) log.info(cliError.hint);
1624
- log.info("Failed");
1685
+ if (isInteractive(options?.json)) {
1686
+ log.error(`${prefix}: ${cliError.message}`);
1687
+ if (cliError.hint) log.info(cliError.hint);
1688
+ log.info("Failed");
1689
+ } else {
1690
+ process.stderr.write(`${prefix}: ${cliError.message}\n`);
1691
+ if (cliError.hint) process.stderr.write(`${cliError.hint}\n`);
1692
+ }
1625
1693
  process.exitCode = cliError.exitCode;
1626
1694
  };
1627
1695
  const parseScaffoldTemplate = (value) => {
@@ -1827,6 +1895,7 @@ const registerAuthCommands = (program) => {
1827
1895
  const resolved = await resolveAuthToken();
1828
1896
  if (!resolved) {
1829
1897
  log.warn("Not logged in. Run \"blodemd login\" to authenticate.");
1898
+ process.exitCode = EXIT_CODES.AUTH_REQUIRED;
1830
1899
  return;
1831
1900
  }
1832
1901
  const status = resolveTokenStatus(resolved);
@@ -2513,6 +2582,31 @@ const registerNewCommand = (program) => {
2513
2582
  });
2514
2583
  };
2515
2584
  //#endregion
2585
+ //#region src/commands/projects.ts
2586
+ const registerProjectsCommand = (program) => {
2587
+ program.command("projects").description("List your projects").option("--api-url <url>", "API URL (env: BLODEMD_API_URL)").option("--json", "output machine-readable JSON (implies non-interactive)").action(async (options) => {
2588
+ const reporter = createReporter({ json: options.json });
2589
+ if (reporter.interactive) intro(chalk.bold("blodemd projects"));
2590
+ try {
2591
+ const resolved = await resolveAuthToken();
2592
+ if (!resolved?.token) throw new CliError("Run \"blodemd login\" to list your projects. API keys cannot list projects.", EXIT_CODES.AUTH_REQUIRED, "Run \"blodemd login\" to authenticate.");
2593
+ const apiUrl = options.apiUrl ?? process.env["BLODEMD_API_URL"] ?? "https://api.blode.md";
2594
+ const projects = await requestJson(new URL("/projects", apiUrl).toString(), { headers: { Authorization: `Bearer ${resolved.token}` } }, "Failed to list projects");
2595
+ reporter.json(projects);
2596
+ if (projects.length === 0) {
2597
+ reporter.info("No projects yet.");
2598
+ return;
2599
+ }
2600
+ for (const project of projects) {
2601
+ const label = project.name ? `${chalk.cyan(project.slug)} — ${project.name}` : chalk.cyan(project.slug);
2602
+ reporter.info(label);
2603
+ }
2604
+ } catch (error) {
2605
+ reportCommandError("Projects failed", error, { json: options.json });
2606
+ }
2607
+ });
2608
+ };
2609
+ //#endregion
2516
2610
  //#region src/upload.ts
2517
2611
  const TEXT_CONTENT_TYPES = {
2518
2612
  ".css": "text/css; charset=utf-8",
@@ -2560,10 +2654,10 @@ const resolveAuthHeaders = async (apiKeyOption) => {
2560
2654
  const apiKey = (apiKeyOption ?? process.env["BLODEMD_API_KEY"])?.trim();
2561
2655
  if (apiKey) return {
2562
2656
  canAutoCreate: false,
2563
- headers: { "x-admin-token": apiKey }
2657
+ headers: { Authorization: `Bearer ${apiKey}` }
2564
2658
  };
2565
2659
  const resolved = await resolveAuthToken();
2566
- if (!resolved?.token) throw new Error("Not logged in. Run \"blodemd login\" to authenticate, pass --api-key, or set BLODEMD_API_KEY.");
2660
+ if (!resolved?.token) throw new Error("Not logged in. Run \"blodemd login\" to authenticate, pass --api-key, or set BLODEMD_API_KEY (see https://blode.md/docs/deployment/ci).");
2567
2661
  return {
2568
2662
  canAutoCreate: true,
2569
2663
  headers: { Authorization: `Bearer ${resolved.token}` }
@@ -2604,8 +2698,9 @@ const resolvePushConfig = async (config, options) => {
2604
2698
  usedLegacyNameFallback
2605
2699
  };
2606
2700
  };
2607
- const autoCreateProject = async (project, projectDisplayName, apiUrl, headers, canAutoCreate) => {
2701
+ const autoCreateProject = async (project, projectDisplayName, apiUrl, headers, canAutoCreate, reporter) => {
2608
2702
  if (!canAutoCreate) throw new Error(`Project "${project}" not found. Create it at blode.md or login with "blodemd login" to auto-create.`);
2703
+ if (!reporter.interactive) throw new Error(`Project "${project}" not found. Create it in the dashboard or run \`blodemd push\` in an interactive terminal to auto-create it.`);
2609
2704
  const shouldCreate = await confirm({ message: `Project "${project}" doesn't exist. Create it?` });
2610
2705
  if (isCancel(shouldCreate) || !shouldCreate) return false;
2611
2706
  const createResult = await requestJson(new URL("/projects", apiUrl).toString(), {
@@ -2616,12 +2711,18 @@ const autoCreateProject = async (project, projectDisplayName, apiUrl, headers, c
2616
2711
  headers,
2617
2712
  method: "POST"
2618
2713
  }, "Failed to create project");
2619
- log.success(`Project ${chalk.cyan(createResult.slug)} created`);
2714
+ reporter.success(`Project ${chalk.cyan(createResult.slug)} created`);
2715
+ const keyResult = await requestJson(new URL(`/projects/${createResult.id}/keys`, apiUrl).toString(), {
2716
+ body: JSON.stringify({ name: "CI deploy key" }),
2717
+ headers,
2718
+ method: "POST"
2719
+ }, "Failed to create deploy key");
2720
+ reporter.info(`Deploy key created (save this — shown once, use as ${BLODE_API_KEY_ENV} in CI): ${chalk.cyan(keyResult.key)}`);
2620
2721
  return true;
2621
2722
  };
2622
2723
  const MAX_BATCH_BYTES = 4 * 1024 * 1024;
2623
- const uploadFiles = async (files, root, apiPath, deploymentId, headers, s) => {
2624
- s.start(`Uploading ${files.length} files`);
2724
+ const uploadFiles = async (files, root, apiPath, deploymentId, headers, reporter) => {
2725
+ reporter.step(`Uploading ${files.length} files`);
2625
2726
  let uploaded = 0;
2626
2727
  for await (const batch of createUploadBatches({
2627
2728
  files,
@@ -2634,26 +2735,26 @@ const uploadFiles = async (files, root, apiPath, deploymentId, headers, s) => {
2634
2735
  method: "POST"
2635
2736
  }, "Failed to upload files");
2636
2737
  uploaded += batch.length;
2637
- s.message(`Uploading files (${uploaded}/${files.length})`);
2738
+ reporter.step(`Uploading files (${uploaded}/${files.length})`);
2638
2739
  }
2639
- s.stop(`Uploaded ${chalk.cyan(String(files.length))} files`);
2740
+ reporter.success(`Uploaded ${chalk.cyan(String(files.length))} files`);
2640
2741
  };
2641
2742
  const registerPushCommand = (program) => {
2642
- program.command("push").description("Deploy docs").argument("[dir]", "docs directory").option("--project <slug>", "project slug (env: BLODEMD_PROJECT)").option("--api-key <token>", "API key (env: BLODEMD_API_KEY)").option("--api-url <url>", "API URL (env: BLODEMD_API_URL)").option("--branch <name>", "git branch (env: BLODEMD_BRANCH)").option("--message <msg>", "deploy message (env: BLODEMD_COMMIT_MESSAGE)").action(async (dir, options) => {
2643
- intro(chalk.bold("blodemd push"));
2644
- const s = spinner();
2743
+ program.command("push").description("Deploy docs").argument("[dir]", "docs directory").option("--project <slug>", "project slug (env: BLODEMD_PROJECT)").option("--api-key <token>", "API key (env: BLODEMD_API_KEY)").option("--api-url <url>", "API URL (env: BLODEMD_API_URL)").option("--branch <name>", "git branch (env: BLODEMD_BRANCH)").option("--message <msg>", "deploy message (env: BLODEMD_COMMIT_MESSAGE)").option("--json", "output machine-readable JSON (implies non-interactive)").action(async (dir, options) => {
2744
+ const reporter = createReporter({ json: options.json });
2745
+ if (reporter.interactive) intro(chalk.bold("blodemd push"));
2645
2746
  try {
2646
2747
  const root = await resolveDocsRoot(dir);
2647
- s.start("Validating configuration");
2748
+ reporter.step("Validating configuration");
2648
2749
  const { config, warnings } = await loadValidatedSiteConfig(root);
2649
- s.stop("Configuration valid");
2650
- for (const warning of warnings) log.warn(warning);
2750
+ reporter.success("Configuration valid");
2751
+ for (const warning of warnings) reporter.warn(warning);
2651
2752
  const { project, projectDisplayName, apiUrl, authHeaders, canAutoCreate, branch, commitMessage, usedLegacyNameFallback } = await resolvePushConfig(config, options);
2652
- if (usedLegacyNameFallback) log.warn(LEGACY_PROJECT_NAME_FALLBACK_WARNING);
2653
- s.start("Collecting files");
2753
+ if (usedLegacyNameFallback) reporter.warn(LEGACY_PROJECT_NAME_FALLBACK_WARNING);
2754
+ reporter.step("Collecting files");
2654
2755
  const files = await collectFiles(root);
2655
2756
  if (files.length === 0) throw new Error("No files found to deploy.");
2656
- s.stop(`Found ${chalk.cyan(String(files.length))} files`);
2757
+ reporter.success(`Found ${chalk.cyan(String(files.length))} files`);
2657
2758
  const headers = {
2658
2759
  ...authHeaders,
2659
2760
  "Content-Type": "application/json"
@@ -2663,7 +2764,7 @@ const registerPushCommand = (program) => {
2663
2764
  branch,
2664
2765
  commitMessage
2665
2766
  });
2666
- s.start("Creating deployment");
2767
+ reporter.step("Creating deployment");
2667
2768
  let deployment;
2668
2769
  try {
2669
2770
  deployment = await requestJson(apiPath(""), {
@@ -2673,34 +2774,39 @@ const registerPushCommand = (program) => {
2673
2774
  }, "Failed to create deployment");
2674
2775
  } catch (error) {
2675
2776
  if (!(error instanceof Error ? error.message : "").includes("404")) throw error;
2676
- s.stop("Project not found");
2677
- if (!await autoCreateProject(project, projectDisplayName, apiUrl, headers, canAutoCreate)) {
2678
- log.info("Cancelled");
2777
+ reporter.stop("Project not found");
2778
+ if (!await autoCreateProject(project, projectDisplayName, apiUrl, headers, canAutoCreate, reporter)) {
2779
+ reporter.info("Cancelled");
2679
2780
  return;
2680
2781
  }
2681
- s.start("Creating deployment");
2782
+ reporter.step("Creating deployment");
2682
2783
  deployment = await requestJson(apiPath(""), {
2683
2784
  body: createDeploymentBody,
2684
2785
  headers,
2685
2786
  method: "POST"
2686
2787
  }, "Failed to create deployment");
2687
2788
  }
2688
- s.stop(`Deployment ${chalk.cyan(deployment.id)} created`);
2689
- await uploadFiles(files, root, apiPath, deployment.id, headers, s);
2690
- s.start("Finalizing deployment");
2789
+ reporter.success(`Deployment ${chalk.cyan(deployment.id)} created`);
2790
+ await uploadFiles(files, root, apiPath, deployment.id, headers, reporter);
2791
+ reporter.step("Finalizing deployment");
2691
2792
  const finalized = await requestJson(apiPath(`/${deployment.id}/finalize`), {
2692
2793
  body: JSON.stringify({ promote: true }),
2693
2794
  headers,
2694
2795
  method: "POST"
2695
2796
  }, "Failed to finalize deployment");
2696
- s.stop("Deployment finalized");
2697
- log.success(`Published ${chalk.cyan(finalized.id)}`);
2698
- if (finalized.manifestUrl) log.info(`Manifest: ${finalized.manifestUrl}`);
2699
- if (typeof finalized.fileCount === "number") log.info(`Files: ${finalized.fileCount}`);
2700
- log.info("Done");
2797
+ reporter.success("Deployment finalized");
2798
+ reporter.success(`Published ${chalk.cyan(finalized.id)}`);
2799
+ if (finalized.manifestUrl) reporter.info(`Manifest: ${finalized.manifestUrl}`);
2800
+ if (typeof finalized.fileCount === "number") reporter.info(`Files: ${finalized.fileCount}`);
2801
+ reporter.info("Done");
2802
+ reporter.json({
2803
+ deploymentId: finalized.id,
2804
+ fileCount: finalized.fileCount ?? files.length,
2805
+ manifestUrl: finalized.manifestUrl ?? null
2806
+ });
2701
2807
  } catch (error) {
2702
- s.stop("Failed");
2703
- reportCommandError("Push failed", error);
2808
+ reporter.stop("Failed");
2809
+ reportCommandError("Push failed", error, { json: options.json });
2704
2810
  }
2705
2811
  });
2706
2812
  };
@@ -2708,15 +2814,24 @@ const registerPushCommand = (program) => {
2708
2814
  //#region src/commands/validate.ts
2709
2815
  const CONFIG_FILE = "docs.json";
2710
2816
  const registerValidateCommand = (program) => {
2711
- program.command("validate").description("Validate docs.json").argument("[dir]", "docs directory").action(async (dir) => {
2712
- intro(chalk.bold("blodemd validate"));
2817
+ program.command("validate").description("Validate docs.json").argument("[dir]", "docs directory").option("--json", "output machine-readable JSON (implies non-interactive)").action(async (dir, options) => {
2818
+ const reporter = createReporter({ json: options.json });
2819
+ if (reporter.interactive) intro(chalk.bold("blodemd validate"));
2713
2820
  try {
2714
2821
  const { warnings } = await loadValidatedSiteConfig(await resolveDocsRoot(dir));
2715
- for (const warning of warnings) log.warn(warning);
2716
- log.success(`${chalk.cyan(CONFIG_FILE)} is valid.`);
2717
- log.info("Done");
2822
+ for (const warning of warnings) reporter.warn(warning);
2823
+ reporter.success(`${chalk.cyan(CONFIG_FILE)} is valid.`);
2824
+ reporter.info("Done");
2825
+ reporter.json({
2826
+ valid: true,
2827
+ warnings
2828
+ });
2718
2829
  } catch (error) {
2719
- reportCommandError("Validation failed", error);
2830
+ if (options.json && error instanceof CliError && error.exitCode === EXIT_CODES.VALIDATION) reporter.json({
2831
+ errors: error.message.split("\n"),
2832
+ valid: false
2833
+ });
2834
+ reportCommandError("Validation failed", error, { json: options.json });
2720
2835
  }
2721
2836
  });
2722
2837
  };
@@ -2770,9 +2885,18 @@ registerAuthCommands(program);
2770
2885
  registerNewCommand(program);
2771
2886
  registerValidateCommand(program);
2772
2887
  registerPushCommand(program);
2888
+ registerProjectsCommand(program);
2773
2889
  registerDevCommand(program);
2774
2890
  registerAnalyticsCommand(program);
2775
- program.parse();
2891
+ program.addHelpText("after", "\nExample:\n $ blodemd push ./docs --project my-docs\n");
2892
+ try {
2893
+ await program.parseAsync();
2894
+ } catch (error) {
2895
+ const cliError = toCliError(error);
2896
+ console.error(cliError.message);
2897
+ if (cliError.hint) console.error(cliError.hint);
2898
+ process.exitCode = cliError.exitCode;
2899
+ }
2776
2900
  //#endregion
2777
2901
  export {};
2778
2902