lopebase 0.6.4 → 0.6.6

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 (3) hide show
  1. package/README.md +29 -0
  2. package/dist/lopebase.js +308 -12
  3. package/package.json +2 -1
package/README.md CHANGED
@@ -89,6 +89,35 @@ Made for web products, and fine with anything else:
89
89
  | `npx lopebase mcp` | Run the LopeBase tools as a local MCP server (stdio) for any agent. |
90
90
  | `npx lopebase adapter secret` | Print a new signing secret for [@lopebase/adapter](https://www.npmjs.com/package/@lopebase/adapter). Pipe it straight into your secret store. |
91
91
  | `npx lopebase adapter check <url>` | Test a running adapter, local or deployed, before you connect it. |
92
+ | `npx lopebase blog check [dir]` | Check the blog posts LopeBase publishes to your repo. Exits 1 on any problem, for CI. `--format <preset>`, `--allow-empty`. |
93
+
94
+ ## Check published blog posts in CI
95
+
96
+ When LopeBase publishes articles to your GitHub repo, add this to CI so a
97
+ malformed post fails the check instead of breaking your site build:
98
+
99
+ ```sh
100
+ npx lopebase blog check --format astro
101
+ ```
102
+
103
+ It reads only the post files in one folder, with no network calls, and prints
104
+ one line per problem as `<file>: <problem>` plus a summary.
105
+
106
+ | Preset | Folder | File name |
107
+ | --- | --- | --- |
108
+ | `markdown` (default) | `content/blog` | `<slug>.md` |
109
+ | `next-mdx` | `content/blog` | `<slug>.mdx` |
110
+ | `astro` | `src/content/blog` | `<slug>.md` |
111
+ | `hugo` | `content/posts` | `<slug>.md` |
112
+ | `jekyll` | `_posts` | `YYYY-MM-DD-<slug>.md` |
113
+
114
+ Pass a folder to check somewhere else. For each post it checks the front
115
+ matter (required fields, quoted strings, dates, tag and image lists), that the
116
+ slug is lowercase words joined by hyphens and matches the file name, that the
117
+ body has at least one `##` heading, does not open with a `#` heading, and has
118
+ no raw HTML outside code, and that no two posts share a slug. A missing or
119
+ empty folder fails too, unless you pass `--allow-empty` (useful before the
120
+ first post lands).
92
121
 
93
122
  ## What it reads, and what it keeps
94
123
 
package/dist/lopebase.js CHANGED
@@ -502,7 +502,7 @@ var require_cross_spawn = __commonJS(function(exports, module) {
502
502
 
503
503
  // bin/lopebase.ts
504
504
  import { spawn as spawn2 } from "node:child_process";
505
- import { readFileSync as readFileSync4 } from "node:fs";
505
+ import { readFileSync as readFileSync5 } from "node:fs";
506
506
 
507
507
  // ../../node_modules/.bun/@clack+prompts@0.11.0/node_modules/@clack/prompts/dist/index.mjs
508
508
  import { stripVTControlCharacters as S2 } from "node:util";
@@ -1206,7 +1206,18 @@ var Y2 = ({ indicator: t = "dots" } = {}) => {
1206
1206
  };
1207
1207
 
1208
1208
  // ../admin-adapter/src/releases.ts
1209
- var ADAPTER_VERSION = "0.4.1", ADAPTER_RELEASES = [
1209
+ var ADAPTER_VERSION = "0.4.2", ADAPTER_RELEASES = [
1210
+ {
1211
+ version: "0.4.2",
1212
+ date: "2026-11-03",
1213
+ features: [
1214
+ "A service check that fails is tried once more within its time limit before the health address reports it down, so one dropped database query no longer marks the product down. cachedCheck retries the same way before it keeps a failure.",
1215
+ "When health() reports ok: false and no services are declared, the health address now answers 503, so LopeBase sees the product down instead of up.",
1216
+ "A health() hook that throws is reported as unhealthy instead of crashing the request.",
1217
+ "Every answer is sent with Cache-Control: no-store, so a CDN in front of the app never serves an old health result or caches rows."
1218
+ ],
1219
+ steps: []
1220
+ },
1210
1221
  {
1211
1222
  version: "0.4.1",
1212
1223
  date: "2026-10-08",
@@ -1290,7 +1301,7 @@ function releasesSince(version) {
1290
1301
  function upgradePrompt(fromVersion) {
1291
1302
  let todo = releasesSince(fromVersion), breaking = todo.flatMap((r2) => r2.breaking ?? []), steps = todo.flatMap((r2) => r2.steps);
1292
1303
  return [
1293
- `Update this app's LopeBase adapter (@lopebase/adapter) from ${fromVersion ?? "its current version"} to 0.4.1.`,
1304
+ `Update this app's LopeBase adapter (@lopebase/adapter) from ${fromVersion ?? "its current version"} to 0.4.2.`,
1294
1305
  "",
1295
1306
  "1. Install it with this repo's package manager, for example `npm install @lopebase/adapter@latest`. A 0.x range like ^0.1.2 does not pick up 0.2 or later on its own.",
1296
1307
  ...breaking.length ? [
@@ -1654,12 +1665,270 @@ function installedAgents() {
1654
1665
  return AGENTS.filter((a) => isInstalled(a.bin));
1655
1666
  }
1656
1667
 
1668
+ // src/blog-check.ts
1669
+ import { readdirSync, readFileSync, statSync } from "node:fs";
1670
+ import { join } from "node:path";
1671
+ // ../lopebase-blog/src/contract.ts
1672
+ var shared = {
1673
+ title: { kind: "string", required: !0, role: "title" },
1674
+ description: { kind: "string", required: !0, role: "description" },
1675
+ author: { kind: "string", role: "author" },
1676
+ tags: { kind: "tags", role: "tags" }
1677
+ }, markdownKeys = {
1678
+ ...shared,
1679
+ date: { kind: "date", required: !0, role: "date" },
1680
+ slug: { kind: "string", required: !0, role: "slug" },
1681
+ updated: { kind: "date", role: "updated" },
1682
+ image: { kind: "url", role: "image" },
1683
+ image_alt: { kind: "string", role: "imageAlt" },
1684
+ faq: { kind: "faq", role: "faq" }
1685
+ }, BLOG_PRESETS = {
1686
+ markdown: {
1687
+ dir: "content/blog",
1688
+ ext: ".md",
1689
+ keys: markdownKeys,
1690
+ slugKey: "slug"
1691
+ },
1692
+ "next-mdx": {
1693
+ dir: "content/blog",
1694
+ ext: ".mdx",
1695
+ keys: markdownKeys,
1696
+ slugKey: "slug"
1697
+ },
1698
+ astro: {
1699
+ dir: "src/content/blog",
1700
+ ext: ".md",
1701
+ keys: {
1702
+ ...shared,
1703
+ pubDate: { kind: "date", required: !0, role: "date" },
1704
+ updatedDate: { kind: "date", role: "updated" },
1705
+ heroImage: { kind: "url", role: "image" }
1706
+ }
1707
+ },
1708
+ hugo: {
1709
+ dir: "content/posts",
1710
+ ext: ".md",
1711
+ keys: {
1712
+ ...shared,
1713
+ date: { kind: "datetime", required: !0, role: "date" },
1714
+ slug: { kind: "string", required: !0, role: "slug" },
1715
+ lastmod: { kind: "datetime", role: "updated" },
1716
+ images: { kind: "urls", role: "images" }
1717
+ },
1718
+ slugKey: "slug"
1719
+ },
1720
+ jekyll: {
1721
+ dir: "_posts",
1722
+ ext: ".md",
1723
+ keys: {
1724
+ layout: { kind: "layout", required: !0 },
1725
+ ...shared,
1726
+ date: { kind: "date", required: !0, role: "date" },
1727
+ last_modified_at: { kind: "date", role: "updated" },
1728
+ image: { kind: "url", role: "image" }
1729
+ },
1730
+ datedFileKey: "date"
1731
+ }
1732
+ }, isBlogFormat = (v2) => Object.hasOwn(BLOG_PRESETS, v2), SLUG = /^[a-z0-9]+(?:-[a-z0-9]+)*$/, DATE = /^(\d{4})-(\d{2})-(\d{2})$/, DATETIME = /^(\d{4})-(\d{2})-(\d{2})T([01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|[+-](?:[01]\d|2[0-3]):[0-5]\d)$/, JEKYLL_FILE = /^(\d{4}-\d{2}-\d{2})-(.+)$/, HTML_TAG = /<\/?[A-Za-z][A-Za-z0-9-]*(?:\s[^<>]*)?\/?>|<!--/;
1733
+ function realDate(y3, m2, d3) {
1734
+ let date = new Date(Date.UTC(+y3, +m2 - 1, +d3));
1735
+ return date.getUTCFullYear() === +y3 && date.getUTCMonth() === +m2 - 1 && date.getUTCDate() === +d3;
1736
+ }
1737
+ function isUrl(v2) {
1738
+ if (typeof v2 !== "string")
1739
+ return !1;
1740
+ try {
1741
+ let u2 = new URL(v2);
1742
+ return u2.protocol === "https:" || u2.protocol === "http:";
1743
+ } catch {
1744
+ return !1;
1745
+ }
1746
+ }
1747
+ var filled = (v2) => typeof v2 === "string" && v2.trim() !== "";
1748
+ function json(raw) {
1749
+ try {
1750
+ return { ok: !0, value: JSON.parse(raw) };
1751
+ } catch {
1752
+ return { ok: !1 };
1753
+ }
1754
+ }
1755
+ function checkValue(key, kind, raw) {
1756
+ switch (kind) {
1757
+ case "layout":
1758
+ return raw === "post" ? void 0 : `${key} must be post`;
1759
+ case "date": {
1760
+ let m2 = DATE.exec(raw);
1761
+ return m2 && realDate(m2[1], m2[2], m2[3]) ? void 0 : `${key} must be a date like 2026-11-05`;
1762
+ }
1763
+ case "datetime": {
1764
+ let m2 = DATETIME.exec(raw);
1765
+ return m2 && realDate(m2[1], m2[2], m2[3]) ? void 0 : `${key} must be a date and time like 2026-11-05T15:00:00Z`;
1766
+ }
1767
+ case "string": {
1768
+ let v2 = json(raw);
1769
+ if (!v2.ok || typeof v2.value !== "string")
1770
+ return `${key} must be a quoted string`;
1771
+ return v2.value.trim() ? void 0 : `${key} is empty`;
1772
+ }
1773
+ case "url": {
1774
+ let v2 = json(raw);
1775
+ return v2.ok && isUrl(v2.value) ? void 0 : `${key} must be a quoted http or https address`;
1776
+ }
1777
+ case "tags": {
1778
+ let v2 = json(raw);
1779
+ return v2.ok && Array.isArray(v2.value) && v2.value.every((t) => typeof t === "string") ? void 0 : `${key} must be a list of quoted strings, like ["seo"]`;
1780
+ }
1781
+ case "urls": {
1782
+ let v2 = json(raw);
1783
+ return v2.ok && Array.isArray(v2.value) && v2.value.every(isUrl) ? void 0 : `${key} must be a list of quoted http or https addresses`;
1784
+ }
1785
+ case "faq": {
1786
+ let v2 = json(raw);
1787
+ return v2.ok && Array.isArray(v2.value) && v2.value.every((f) => f !== null && typeof f === "object" && filled(f.q) && filled(f.a)) ? void 0 : `${key} must be one line of JSON like [{"q": "A question?", "a": "An answer."}]`;
1788
+ }
1789
+ }
1790
+ }
1791
+ function proseOnly(body2) {
1792
+ let out = [], fence;
1793
+ for (let line of body2.split(`
1794
+ `)) {
1795
+ let open = /^\s{0,3}(`{3,}|~{3,})/.exec(line);
1796
+ if (fence) {
1797
+ if (open && open[1][0] === fence[0] && open[1].length >= fence.length)
1798
+ fence = void 0;
1799
+ continue;
1800
+ }
1801
+ if (open) {
1802
+ fence = open[1];
1803
+ continue;
1804
+ }
1805
+ out.push(line.replace(/(`+)[^`]*?\1/g, ""));
1806
+ }
1807
+ return out;
1808
+ }
1809
+ function inspectPost(fileName, text, format) {
1810
+ let preset = BLOG_PRESETS[format], problems = [], base = fileName.endsWith(preset.ext) ? fileName.slice(0, fileName.length - preset.ext.length) : fileName.replace(/\.[^.]*$/, ""), fileSlug = base, fileDate;
1811
+ if (preset.datedFileKey) {
1812
+ let m2 = JEKYLL_FILE.exec(base);
1813
+ if (m2)
1814
+ fileDate = m2[1], fileSlug = m2[2];
1815
+ else
1816
+ problems.push("file name must look like YYYY-MM-DD-<slug>.md");
1817
+ }
1818
+ if (!SLUG.test(fileSlug))
1819
+ problems.push(`file name slug "${fileSlug}" must be lowercase words joined by hyphens`);
1820
+ let lines = text.replace(/^/, "").split(/\r?\n/), end = lines[0] === "---" ? lines.indexOf("---", 1) : -1;
1821
+ if (end < 0)
1822
+ return problems.push("front matter must open and close with --- lines at the top"), { slug: fileSlug, problems };
1823
+ let raws = /* @__PURE__ */ new Map;
1824
+ for (let line of lines.slice(1, end)) {
1825
+ if (!line.trim())
1826
+ continue;
1827
+ let m2 = /^([A-Za-z_][A-Za-z0-9_]*):\s*(.*)$/.exec(line);
1828
+ if (!m2) {
1829
+ problems.push(`front matter line is not "key: value": ${line.trim()}`);
1830
+ continue;
1831
+ }
1832
+ if (raws.has(m2[1]))
1833
+ problems.push(`${m2[1]} appears more than once`);
1834
+ raws.set(m2[1], m2[2].trim());
1835
+ }
1836
+ let values = {};
1837
+ for (let [key, spec] of Object.entries(preset.keys)) {
1838
+ let raw = raws.get(key);
1839
+ if (raw === void 0 || raw === "") {
1840
+ if (spec.required)
1841
+ problems.push(`missing ${key}`);
1842
+ continue;
1843
+ }
1844
+ let problem = checkValue(key, spec.kind, raw);
1845
+ if (problem)
1846
+ problems.push(problem);
1847
+ else if (spec.role)
1848
+ values[spec.role] = spec.kind === "date" || spec.kind === "datetime" ? raw : JSON.parse(raw);
1849
+ }
1850
+ let slug = fileSlug;
1851
+ if (preset.slugKey) {
1852
+ let v2 = json(raws.get(preset.slugKey) ?? "");
1853
+ if (v2.ok && typeof v2.value === "string" && v2.value) {
1854
+ if (!SLUG.test(v2.value))
1855
+ problems.push(`${preset.slugKey} "${v2.value}" must be lowercase words joined by hyphens`);
1856
+ else if (v2.value !== fileSlug)
1857
+ problems.push(`${preset.slugKey} "${v2.value}" does not match the file name "${fileSlug}"`);
1858
+ slug = v2.value;
1859
+ }
1860
+ }
1861
+ if (preset.datedFileKey && fileDate) {
1862
+ let date = raws.get(preset.datedFileKey);
1863
+ if (date && DATE.test(date) && date !== fileDate)
1864
+ problems.push(`${preset.datedFileKey} ${date} does not match the file name date ${fileDate}`);
1865
+ }
1866
+ let body2 = lines.slice(end + 1).join(`
1867
+ `), fields = { slug, values, body: body2 };
1868
+ if (!body2.trim())
1869
+ return problems.push("the post has no body"), { slug, problems, fields };
1870
+ let prose = proseOnly(body2), first = body2.split(`
1871
+ `).find((l2) => l2.trim());
1872
+ if (first && /^\s{0,3}#(?:\s|$)/.test(first))
1873
+ problems.push("the body starts with a # heading; the title comes from front matter");
1874
+ if (!prose.some((l2) => /^\s{0,3}## /.test(l2)))
1875
+ problems.push("the body needs at least one ## heading");
1876
+ if (prose.some((l2) => HTML_TAG.test(l2)))
1877
+ problems.push("the body has raw HTML outside code; use Markdown instead");
1878
+ return { slug, problems, fields };
1879
+ }
1880
+ function checkPost(fileName, text, format) {
1881
+ let { slug, problems } = inspectPost(fileName, text, format);
1882
+ return { slug, problems };
1883
+ }
1884
+ // src/blog-check.ts
1885
+ function checkBlog(opts) {
1886
+ let format = opts.format ?? "markdown", preset = BLOG_PRESETS[format], dir = (opts.dir ?? preset.dir).replace(/\\/g, "/").replace(/\/+$/, ""), abs = join(opts.cwd, dir), problems = [], names;
1887
+ try {
1888
+ if (!statSync(abs).isDirectory())
1889
+ return {
1890
+ dir,
1891
+ posts: 0,
1892
+ problems: [{ file: dir, problem: "is not a folder" }]
1893
+ };
1894
+ names = readdirSync(abs, { withFileTypes: !0 }).filter((e2) => e2.isFile() && e2.name.endsWith(preset.ext)).map((e2) => e2.name).sort();
1895
+ } catch {
1896
+ return {
1897
+ dir,
1898
+ posts: 0,
1899
+ problems: opts.allowEmpty ? [] : [{ file: dir, problem: "folder not found (pass --allow-empty)" }]
1900
+ };
1901
+ }
1902
+ if (!names.length && !opts.allowEmpty)
1903
+ problems.push({
1904
+ file: dir,
1905
+ problem: `no ${preset.ext} posts found (pass --allow-empty)`
1906
+ });
1907
+ let seen = /* @__PURE__ */ new Map;
1908
+ for (let name of names) {
1909
+ let file = `${dir}/${name}`, res = checkPost(name, readFileSync(join(abs, name), "utf8"), format);
1910
+ for (let problem of res.problems)
1911
+ problems.push({ file, problem });
1912
+ if (res.slug) {
1913
+ let other = seen.get(res.slug);
1914
+ if (other)
1915
+ problems.push({
1916
+ file,
1917
+ problem: `slug "${res.slug}" is also used by ${other}`
1918
+ });
1919
+ else
1920
+ seen.set(res.slug, file);
1921
+ }
1922
+ }
1923
+ return { dir, posts: names.length, problems };
1924
+ }
1925
+
1657
1926
  // src/config.ts
1658
1927
  import {
1659
1928
  chmodSync,
1660
1929
  existsSync,
1661
1930
  mkdirSync,
1662
- readFileSync,
1931
+ readFileSync as readFileSync2,
1663
1932
  writeFileSync
1664
1933
  } from "node:fs";
1665
1934
  import os from "node:os";
@@ -1705,7 +1974,7 @@ function loadAuth() {
1705
1974
  if (!existsSync(authFile()))
1706
1975
  return null;
1707
1976
  try {
1708
- let a = JSON.parse(readFileSync(authFile(), "utf8"));
1977
+ let a = JSON.parse(readFileSync2(authFile(), "utf8"));
1709
1978
  if (!a.token)
1710
1979
  return null;
1711
1980
  return { url: envUrl || a.url || DEFAULT_URL, token: a.token };
@@ -2530,7 +2799,7 @@ function onboardingPrompt(input) {
2530
2799
  }
2531
2800
 
2532
2801
  // src/scan.ts
2533
- import { existsSync as existsSync2, readdirSync, readFileSync as readFileSync2, statSync } from "node:fs";
2802
+ import { existsSync as existsSync2, readdirSync as readdirSync2, readFileSync as readFileSync3, statSync as statSync2 } from "node:fs";
2534
2803
  import path2 from "node:path";
2535
2804
  var MONOREPO_DIRS = ["apps", "packages", "services", "sites", "web", "site"], SKIP_DIRS = /* @__PURE__ */ new Set([
2536
2805
  "node_modules",
@@ -2588,7 +2857,7 @@ var MONOREPO_DIRS = ["apps", "packages", "services", "sites", "web", "site"], SK
2588
2857
  ], EXAMPLE_ENV = /^(\.env\.(example|sample|template|dist|local\.example|development\.example|production\.example)|example\.env|\.dev\.vars\.example)$/;
2589
2858
  function readText(file2) {
2590
2859
  try {
2591
- return readFileSync2(file2, "utf8");
2860
+ return readFileSync3(file2, "utf8");
2592
2861
  } catch {
2593
2862
  return null;
2594
2863
  }
@@ -2605,14 +2874,14 @@ function readJson(file2) {
2605
2874
  }
2606
2875
  function isDir(p2) {
2607
2876
  try {
2608
- return statSync(p2).isDirectory();
2877
+ return statSync2(p2).isDirectory();
2609
2878
  } catch {
2610
2879
  return !1;
2611
2880
  }
2612
2881
  }
2613
2882
  function listDir(dir) {
2614
2883
  try {
2615
- return readdirSync(dir);
2884
+ return readdirSync2(dir);
2616
2885
  } catch {
2617
2886
  return [];
2618
2887
  }
@@ -3030,11 +3299,11 @@ function watchRows(items) {
3030
3299
  }
3031
3300
 
3032
3301
  // src/update.ts
3033
- import { readFileSync as readFileSync3 } from "node:fs";
3302
+ import { readFileSync as readFileSync4 } from "node:fs";
3034
3303
  import path3 from "node:path";
3035
3304
  var readJson2 = (file2) => {
3036
3305
  try {
3037
- return JSON.parse(readFileSync3(file2, "utf8"));
3306
+ return JSON.parse(readFileSync4(file2, "utf8"));
3038
3307
  } catch {
3039
3308
  return null;
3040
3309
  }
@@ -3088,6 +3357,8 @@ ${import_picocolors4.default.bold("Usage")}
3088
3357
  npx lopebase mcp run the LopeBase tools as a stdio MCP server
3089
3358
  npx lopebase adapter secret print a new @lopebase/adapter signing secret
3090
3359
  npx lopebase adapter check <url> test a running adapter (reads LOPEBASE_SIGNING_SECRET)
3360
+ npx lopebase blog check [dir] check the blog posts LopeBase publishes to your repo
3361
+ (--format <preset>, --allow-empty)
3091
3362
 
3092
3363
  ${import_picocolors4.default.bold("Options")}
3093
3364
  --print print the agent prompt instead of starting an agent
@@ -3103,7 +3374,7 @@ ${import_picocolors4.default.bold("Learn more")}
3103
3374
  }, cwd = process.cwd(), motion = motionAllowed(), TAGLINE = "Every product, one place.", PAGEVIEWS = "LopeBase pageviews";
3104
3375
  function version() {
3105
3376
  try {
3106
- return JSON.parse(readFileSync4(new URL("../package.json", import.meta.url), "utf8")).version;
3377
+ return JSON.parse(readFileSync5(new URL("../package.json", import.meta.url), "utf8")).version;
3107
3378
  } catch {
3108
3379
  return;
3109
3380
  }
@@ -3400,6 +3671,29 @@ The adapter is in: ${where}. If you have the lopebase MCP tools, call get_onboar
3400
3671
  ` ${brand("3.")} run the adapter check and your tests`
3401
3672
  ]);
3402
3673
  }
3674
+ var BLOG_USAGE = `Usage:
3675
+ lopebase blog check [dir] [--format <preset>] [--allow-empty]
3676
+
3677
+ Presets: ${Object.entries(BLOG_PRESETS).map(([name, preset]) => `${name} (${preset.dir})`).join(", ")}. Default: markdown.`;
3678
+ function blog() {
3679
+ if (rest[0] !== "check")
3680
+ return console.log(BLOG_USAGE), process.exit(rest[0] ? 1 : 0);
3681
+ let format = option("format") ?? "markdown";
3682
+ if (!isBlogFormat(format))
3683
+ return console.error(`Unknown format "${format}".
3684
+
3685
+ ${BLOG_USAGE}`), process.exit(1);
3686
+ let dir = rest.slice(1).find((a, i, args) => !a.startsWith("--") && args[i - 1] !== "--format"), res = checkBlog({
3687
+ cwd,
3688
+ format,
3689
+ dir,
3690
+ allowEmpty: flag("allow-empty")
3691
+ });
3692
+ for (let { file: file2, problem } of res.problems)
3693
+ console.log(`${file2}: ${problem}`);
3694
+ let posts = `${res.posts} ${res.posts === 1 ? "post" : "posts"}`;
3695
+ console.log(res.problems.length ? import_picocolors4.default.red(`${res.problems.length} ${res.problems.length === 1 ? "problem" : "problems"} in ${posts} in ${res.dir}.`) : import_picocolors4.default.green(`Checked ${posts} in ${res.dir}. All good.`)), process.exit(res.problems.length ? 1 : 0);
3696
+ }
3403
3697
  async function main() {
3404
3698
  switch (command) {
3405
3699
  case "init":
@@ -3441,6 +3735,8 @@ async function main() {
3441
3735
  lopebase adapter secret print a new signing secret
3442
3736
  lopebase adapter check <base-url> test a running adapter (reads LOPEBASE_SIGNING_SECRET)`), process.exit(sub ? 1 : 0);
3443
3737
  }
3738
+ case "blog":
3739
+ return blog();
3444
3740
  case "mcp": {
3445
3741
  let auth = loadAuth();
3446
3742
  if (!auth)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lopebase",
3
- "version": "0.6.4",
3
+ "version": "0.6.6",
4
4
  "description": "Set up LopeBase from your project's folder: scan your stack, connect your workspace, and let your coding agent do the rest.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -28,6 +28,7 @@
28
28
  "devDependencies": {
29
29
  "@clack/prompts": "^0.11.0",
30
30
  "@lopebase/adapter": "workspace:*",
31
+ "@lopebase/blog": "workspace:*",
31
32
  "@types/cross-spawn": "^6.0.6",
32
33
  "@types/node": "^22.0.0",
33
34
  "cross-spawn": "^7.0.6",