blodemd 0.0.14 → 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",
@@ -2556,6 +2650,19 @@ const createUploadBatches = async function* createUploadBatchesGenerator({ files
2556
2650
  };
2557
2651
  //#endregion
2558
2652
  //#region src/commands/push.ts
2653
+ const resolveAuthHeaders = async (apiKeyOption) => {
2654
+ const apiKey = (apiKeyOption ?? process.env["BLODEMD_API_KEY"])?.trim();
2655
+ if (apiKey) return {
2656
+ canAutoCreate: false,
2657
+ headers: { Authorization: `Bearer ${apiKey}` }
2658
+ };
2659
+ const resolved = await resolveAuthToken();
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).");
2661
+ return {
2662
+ canAutoCreate: true,
2663
+ headers: { Authorization: `Bearer ${resolved.token}` }
2664
+ };
2665
+ };
2559
2666
  const resolvePushConfig = async (config, options) => {
2560
2667
  const { project, usedLegacyNameFallback } = resolveProjectTarget({
2561
2668
  cliProject: options.project,
@@ -2563,7 +2670,6 @@ const resolvePushConfig = async (config, options) => {
2563
2670
  envProject: process.env[BLODE_PROJECT_ENV]
2564
2671
  });
2565
2672
  const apiUrl = options.apiUrl ?? process.env["BLODEMD_API_URL"] ?? "https://api.blode.md";
2566
- const authToken = (await resolveAuthToken())?.token;
2567
2673
  const branch = options.branch ?? process.env["BLODEMD_BRANCH"] ?? process.env.GITHUB_REF_NAME ?? readGitValue([
2568
2674
  "rev-parse",
2569
2675
  "--abbrev-ref",
@@ -2580,19 +2686,21 @@ const resolvePushConfig = async (config, options) => {
2580
2686
  if (usedLegacyNameFallback) throw new Error(`docs.json.name is not a valid deployment slug. Add "slug" to docs.json, pass --project, or set BLODEMD_PROJECT. ${projectSlugError}`);
2581
2687
  throw new Error(`Invalid project slug "${project}". ${projectSlugError}`);
2582
2688
  }
2583
- if (!authToken) throw new Error("Not logged in. Run \"blodemd login\" to authenticate.");
2689
+ const { headers: authHeaders, canAutoCreate } = await resolveAuthHeaders(options.apiKey);
2584
2690
  return {
2585
2691
  apiUrl,
2586
- authToken,
2692
+ authHeaders,
2587
2693
  branch,
2694
+ canAutoCreate,
2588
2695
  commitMessage,
2589
2696
  project,
2590
2697
  projectDisplayName: config.name?.trim() || project,
2591
2698
  usedLegacyNameFallback
2592
2699
  };
2593
2700
  };
2594
- const autoCreateProject = async (project, projectDisplayName, apiUrl, headers) => {
2595
- if (!(await readAuthFile())?.session) throw new Error(`Project "${project}" not found. Create it at blode.md or login with "blodemd login" to auto-create.`);
2701
+ const autoCreateProject = async (project, projectDisplayName, apiUrl, headers, canAutoCreate, reporter) => {
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.`);
2596
2704
  const shouldCreate = await confirm({ message: `Project "${project}" doesn't exist. Create it?` });
2597
2705
  if (isCancel(shouldCreate) || !shouldCreate) return false;
2598
2706
  const createResult = await requestJson(new URL("/projects", apiUrl).toString(), {
@@ -2603,12 +2711,18 @@ const autoCreateProject = async (project, projectDisplayName, apiUrl, headers) =
2603
2711
  headers,
2604
2712
  method: "POST"
2605
2713
  }, "Failed to create project");
2606
- 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)}`);
2607
2721
  return true;
2608
2722
  };
2609
2723
  const MAX_BATCH_BYTES = 4 * 1024 * 1024;
2610
- const uploadFiles = async (files, root, apiPath, deploymentId, headers, s) => {
2611
- s.start(`Uploading ${files.length} files`);
2724
+ const uploadFiles = async (files, root, apiPath, deploymentId, headers, reporter) => {
2725
+ reporter.step(`Uploading ${files.length} files`);
2612
2726
  let uploaded = 0;
2613
2727
  for await (const batch of createUploadBatches({
2614
2728
  files,
@@ -2621,28 +2735,28 @@ const uploadFiles = async (files, root, apiPath, deploymentId, headers, s) => {
2621
2735
  method: "POST"
2622
2736
  }, "Failed to upload files");
2623
2737
  uploaded += batch.length;
2624
- s.message(`Uploading files (${uploaded}/${files.length})`);
2738
+ reporter.step(`Uploading files (${uploaded}/${files.length})`);
2625
2739
  }
2626
- s.stop(`Uploaded ${chalk.cyan(String(files.length))} files`);
2740
+ reporter.success(`Uploaded ${chalk.cyan(String(files.length))} files`);
2627
2741
  };
2628
2742
  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();
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"));
2632
2746
  try {
2633
2747
  const root = await resolveDocsRoot(dir);
2634
- s.start("Validating configuration");
2748
+ reporter.step("Validating configuration");
2635
2749
  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");
2750
+ reporter.success("Configuration valid");
2751
+ for (const warning of warnings) reporter.warn(warning);
2752
+ const { project, projectDisplayName, apiUrl, authHeaders, canAutoCreate, branch, commitMessage, usedLegacyNameFallback } = await resolvePushConfig(config, options);
2753
+ if (usedLegacyNameFallback) reporter.warn(LEGACY_PROJECT_NAME_FALLBACK_WARNING);
2754
+ reporter.step("Collecting files");
2641
2755
  const files = await collectFiles(root);
2642
2756
  if (files.length === 0) throw new Error("No files found to deploy.");
2643
- s.stop(`Found ${chalk.cyan(String(files.length))} files`);
2757
+ reporter.success(`Found ${chalk.cyan(String(files.length))} files`);
2644
2758
  const headers = {
2645
- Authorization: `Bearer ${authToken}`,
2759
+ ...authHeaders,
2646
2760
  "Content-Type": "application/json"
2647
2761
  };
2648
2762
  const apiPath = (suffix) => new URL(`/projects/slug/${project}/deployments${suffix}`, apiUrl).toString();
@@ -2650,7 +2764,7 @@ const registerPushCommand = (program) => {
2650
2764
  branch,
2651
2765
  commitMessage
2652
2766
  });
2653
- s.start("Creating deployment");
2767
+ reporter.step("Creating deployment");
2654
2768
  let deployment;
2655
2769
  try {
2656
2770
  deployment = await requestJson(apiPath(""), {
@@ -2660,34 +2774,39 @@ const registerPushCommand = (program) => {
2660
2774
  }, "Failed to create deployment");
2661
2775
  } catch (error) {
2662
2776
  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");
2777
+ reporter.stop("Project not found");
2778
+ if (!await autoCreateProject(project, projectDisplayName, apiUrl, headers, canAutoCreate, reporter)) {
2779
+ reporter.info("Cancelled");
2666
2780
  return;
2667
2781
  }
2668
- s.start("Creating deployment");
2782
+ reporter.step("Creating deployment");
2669
2783
  deployment = await requestJson(apiPath(""), {
2670
2784
  body: createDeploymentBody,
2671
2785
  headers,
2672
2786
  method: "POST"
2673
2787
  }, "Failed to create deployment");
2674
2788
  }
2675
- s.stop(`Deployment ${chalk.cyan(deployment.id)} created`);
2676
- await uploadFiles(files, root, apiPath, deployment.id, headers, s);
2677
- 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");
2678
2792
  const finalized = await requestJson(apiPath(`/${deployment.id}/finalize`), {
2679
2793
  body: JSON.stringify({ promote: true }),
2680
2794
  headers,
2681
2795
  method: "POST"
2682
2796
  }, "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");
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
+ });
2688
2807
  } catch (error) {
2689
- s.stop("Failed");
2690
- reportCommandError("Push failed", error);
2808
+ reporter.stop("Failed");
2809
+ reportCommandError("Push failed", error, { json: options.json });
2691
2810
  }
2692
2811
  });
2693
2812
  };
@@ -2695,15 +2814,24 @@ const registerPushCommand = (program) => {
2695
2814
  //#region src/commands/validate.ts
2696
2815
  const CONFIG_FILE = "docs.json";
2697
2816
  const registerValidateCommand = (program) => {
2698
- program.command("validate").description("Validate docs.json").argument("[dir]", "docs directory").action(async (dir) => {
2699
- 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"));
2700
2820
  try {
2701
2821
  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");
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
+ });
2705
2829
  } catch (error) {
2706
- 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 });
2707
2835
  }
2708
2836
  });
2709
2837
  };
@@ -2757,9 +2885,18 @@ registerAuthCommands(program);
2757
2885
  registerNewCommand(program);
2758
2886
  registerValidateCommand(program);
2759
2887
  registerPushCommand(program);
2888
+ registerProjectsCommand(program);
2760
2889
  registerDevCommand(program);
2761
2890
  registerAnalyticsCommand(program);
2762
- 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
+ }
2763
2900
  //#endregion
2764
2901
  export {};
2765
2902